diff --git a/sign/appearance.go b/sign/appearance.go index 3a349fd..50bba51 100644 --- a/sign/appearance.go +++ b/sign/appearance.go @@ -3,11 +3,18 @@ package sign import ( "bytes" "fmt" + "image" + _ "image/jpeg" // register JPEG format ) func (context *SignContext) createAppearance(rect [4]float64) ([]byte, error) { - text := context.SignData.Signature.Info.Name + if len(context.SignData.Signature.Info.Image) > 0 { + return context.createImageAppearance(rect) + } + return context.createTextAppearance(rect) +} +func (context *SignContext) createTextAppearance(rect [4]float64) ([]byte, error) { rectWidth := rect[2] - rect[0] rectHeight := rect[3] - rect[1] @@ -15,23 +22,36 @@ func (context *SignContext) createAppearance(rect [4]float64) ([]byte, error) { return nil, fmt.Errorf("invalid rectangle dimensions: width %.2f and height %.2f must be greater than 0", rectWidth, rectHeight) } + var appearance_stream_buffer bytes.Buffer + + text := context.SignData.Signature.Info.Name + // Calculate font size - fontSize := rectHeight * 0.8 // Initial font size + fontSize := rectHeight * 0.8 // Use most of the height for the font textWidth := float64(len(text)) * fontSize * 0.5 // Approximate text width if textWidth > rectWidth { fontSize = rectWidth / (float64(len(text)) * 0.5) // Adjust font size to fit text within rect width } - var appearance_stream_buffer bytes.Buffer - appearance_stream_buffer.WriteString("q\n") // Save graphics state - appearance_stream_buffer.WriteString("BT\n") // Begin text - appearance_stream_buffer.WriteString(fmt.Sprintf("/F1 %.2f Tf\n", fontSize)) // Font and size - appearance_stream_buffer.WriteString(fmt.Sprintf("0 %.2f Td\n", rectHeight-fontSize)) // Position in unit square - appearance_stream_buffer.WriteString("0.2 0.2 0.6 rg\n") // Set font color to ballpoint-like color (RGB) - appearance_stream_buffer.WriteString(fmt.Sprintf("%s Tj\n", pdfString(text))) // Show text - appearance_stream_buffer.WriteString("ET\n") // End text - appearance_stream_buffer.WriteString("Q\n") // Restore graphics state + appearance_stream_buffer.WriteString("q\n") // Save graphics state + appearance_stream_buffer.WriteString("BT\n") // Begin text + appearance_stream_buffer.WriteString(fmt.Sprintf("/F1 %.2f Tf\n", fontSize)) // Font and size + // Center text horizontally and vertically + textWidth = float64(len(text)) * fontSize * 0.5 + textX := (rectWidth - textWidth) / 2 + if textX < 0 { + textX = 0 + } + textY := (rectHeight-fontSize)/2 + fontSize/3 // Approximate vertical centering + + appearance_stream_buffer.WriteString(fmt.Sprintf("%.2f %.2f Td\n", textX, textY)) // Position text + appearance_stream_buffer.WriteString("0.2 0.2 0.6 rg\n") // Set font color to ballpoint-like color (RGB) + appearance_stream_buffer.WriteString(fmt.Sprintf("%s Tj\n", pdfString(text))) // Show text + appearance_stream_buffer.WriteString("ET\n") // End text + appearance_stream_buffer.WriteString("Q\n") // Restore graphics state + + // Create the appearance XObject var appearance_buffer bytes.Buffer appearance_buffer.WriteString("<<\n") appearance_buffer.WriteString(" /Type /XObject\n") @@ -39,7 +59,7 @@ func (context *SignContext) createAppearance(rect [4]float64) ([]byte, error) { appearance_buffer.WriteString(fmt.Sprintf(" /BBox [0 0 %f %f]\n", rectWidth, rectHeight)) appearance_buffer.WriteString(" /Matrix [1 0 0 1 0 0]\n") // No scaling or translation - // Resources dictionary + // Resources dictionary with font appearance_buffer.WriteString(" /Resources <<\n") appearance_buffer.WriteString(" /Font <<\n") appearance_buffer.WriteString(" /F1 <<\n") @@ -60,3 +80,93 @@ func (context *SignContext) createAppearance(rect [4]float64) ([]byte, error) { return appearance_buffer.Bytes(), nil } + +func (context *SignContext) createImageAppearance(rect [4]float64) ([]byte, error) { + rectWidth := rect[2] - rect[0] + rectHeight := rect[3] - rect[1] + + if rectWidth < 1 || rectHeight < 1 { + return nil, fmt.Errorf("invalid rectangle dimensions: width %.2f and height %.2f must be greater than 0", rectWidth, rectHeight) + } + + // Create and add the image XObject + imageStream, err := context.createImageXObject() + if err != nil { + return nil, fmt.Errorf("failed to create image XObject: %w", err) + } + + imageObjectId, err := context.addObject(imageStream) + if err != nil { + return nil, fmt.Errorf("failed to add image object: %w", err) + } + + var appearance_stream_buffer bytes.Buffer + + // We save state twice on purpose due to the cm operation + appearance_stream_buffer.WriteString("q\n") // Save graphics state + appearance_stream_buffer.WriteString("q\n") // Save before image transformation + appearance_stream_buffer.WriteString(fmt.Sprintf("%.2f 0 0 %.2f 0 0 cm\n", rectWidth, rectHeight)) + appearance_stream_buffer.WriteString("/Im1 Do\n") + appearance_stream_buffer.WriteString("Q\n") // Restore after transformation + appearance_stream_buffer.WriteString("Q\n") // Restore graphics state + + // Create the appearance XObject + var appearance_buffer bytes.Buffer + appearance_buffer.WriteString("<<\n") + appearance_buffer.WriteString(" /Type /XObject\n") + + appearance_buffer.WriteString(" /Subtype /Form\n") + appearance_buffer.WriteString(fmt.Sprintf(" /BBox [0 0 %f %f]\n", rectWidth, rectHeight)) + + // Resources dictionary with XObject + appearance_buffer.WriteString(" /Resources <<\n") + + appearance_buffer.WriteString(" /XObject <<\n") + appearance_buffer.WriteString(fmt.Sprintf(" /Im1 %d 0 R\n", imageObjectId)) + appearance_buffer.WriteString(" >>\n") + appearance_buffer.WriteString(" >>\n") + + appearance_buffer.WriteString(" /FormType 1\n") + appearance_buffer.WriteString(fmt.Sprintf(" /Length %d\n", appearance_stream_buffer.Len())) + appearance_buffer.WriteString(">>\n") + + appearance_buffer.WriteString("stream\n") + appearance_buffer.Write(appearance_stream_buffer.Bytes()) + appearance_buffer.WriteString("endstream\n") + + return appearance_buffer.Bytes(), nil +} + +func (context *SignContext) createImageXObject() ([]byte, error) { + imageData := context.SignData.Signature.Info.Image + + // Read image configuration to get original dimensions + img, _, err := image.DecodeConfig(bytes.NewReader(imageData)) + if err != nil { + return nil, fmt.Errorf("failed to decode image configuration: %w", err) + } + + // Use original image dimensions + width := float64(img.Width) + height := float64(img.Height) + + // Create basic PDF Image XObject + var imageObject bytes.Buffer + + imageObject.WriteString("<<\n") + imageObject.WriteString(" /Type /XObject\n") + imageObject.WriteString(" /Subtype /Image\n") + imageObject.WriteString(fmt.Sprintf(" /Width %.0f\n", width)) + imageObject.WriteString(fmt.Sprintf(" /Height %.0f\n", height)) + imageObject.WriteString(" /ColorSpace /DeviceRGB\n") + imageObject.WriteString(" /BitsPerComponent 8\n") + imageObject.WriteString(" /Filter /DCTDecode\n") // JPEG compression + imageObject.WriteString(fmt.Sprintf(" /Length %d\n", len(imageData))) + imageObject.WriteString(">>\n") + + imageObject.WriteString("stream\n") + imageObject.Write(imageData) + imageObject.WriteString("\nendstream\n") + + return imageObject.Bytes(), nil +} diff --git a/sign/helpers.go b/sign/helpers.go index 32a44f5..f0cb971 100644 --- a/sign/helpers.go +++ b/sign/helpers.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "math" + "os" "strings" "time" @@ -195,3 +196,35 @@ func isASCII(s string) bool { } return true } + +// CopyFile copies a file from src to dst. +// It's a replacement for os.Rename that works across filesystems. +func CopyFile(src, dst string) error { + // Open the source file + srcFile, err := os.Open(src) + if err != nil { + return fmt.Errorf("failed to open source file: %w", err) + } + defer srcFile.Close() + + // Create the destination directory if it doesn't exist + dstDir := dst[:strings.LastIndex(dst, "/")] + if err := os.MkdirAll(dstDir, 0o755); err != nil { + return fmt.Errorf("failed to create destination directory: %w", err) + } + + // Create the destination file + dstFile, err := os.Create(dst) + if err != nil { + return fmt.Errorf("failed to create destination file: %w", err) + } + defer dstFile.Close() + + // Copy the contents + _, err = io.Copy(dstFile, srcFile) + if err != nil { + return fmt.Errorf("failed to copy file contents: %w", err) + } + + return nil +} diff --git a/sign/sign.go b/sign/sign.go index 694c460..27935cf 100644 --- a/sign/sign.go +++ b/sign/sign.go @@ -93,6 +93,7 @@ type SignDataSignatureInfo struct { Reason string ContactInfo string Date time.Time + Image []byte // Image data to use in signature appearance } type SignContext struct { diff --git a/sign/sign_test.go b/sign/sign_test.go index 80dc975..2b9f74f 100644 --- a/sign/sign_test.go +++ b/sign/sign_test.go @@ -80,9 +80,9 @@ func verifySignedFile(t *testing.T, tmpfile *os.File, originalFileName string) { if err != nil { t.Fatalf("%s: %s", tmpfile.Name(), err.Error()) - err2 := os.Rename(tmpfile.Name(), "../testfiles/failed/"+originalFileName) - if err2 != nil { - t.Error(err2) + destinationPath := "../testfiles/failed/" + originalFileName + if copyErr := CopyFile(tmpfile.Name(), destinationPath); copyErr != nil { + t.Errorf("Failed to copy failed test file: %s", copyErr) } } } @@ -214,7 +214,7 @@ func TestSignPDFFileUTF8(t *testing.T) { info, err := verify.File(tmpfile) if err != nil { t.Fatalf("%s: %s", tmpfile.Name(), err.Error()) - if err := os.Rename(tmpfile.Name(), "../testfiles/failed/"+originalFileName); err != nil { + if err := CopyFile(tmpfile.Name(), "../testfiles/failed/"+originalFileName); err != nil { t.Error(err) } } else if len(info.Signers) == 0 { @@ -231,7 +231,7 @@ func TestSignPDFFileUTF8(t *testing.T) { func TestSignPDFVisible(t *testing.T) { cert, pkey := loadCertificateAndKey(t) - inputFilePath := "../testfiles/testfile20.pdf" + inputFilePath := "../testfiles/testfile12.pdf" originalFileName := filepath.Base(inputFilePath) tmpfile, err := os.CreateTemp("", t.Name()) @@ -271,7 +271,7 @@ func TestSignPDFVisible(t *testing.T) { _, err = verify.File(tmpfile) if err != nil { t.Fatalf("%s: %s", tmpfile.Name(), err.Error()) - if err := os.Rename(tmpfile.Name(), "../testfiles/failed/"+originalFileName); err != nil { + if err := CopyFile(tmpfile.Name(), "../testfiles/failed/"+originalFileName); err != nil { t.Error(err) } } @@ -475,3 +475,61 @@ func TestTimestampPDFFile(t *testing.T) { verifySignedFile(t, tmpfile, "testfile20.pdf") } + +// TestSignPDFWithImage tests signing a PDF with an image in the signature +func TestSignPDFWithImage(t *testing.T) { + cert, pkey := loadCertificateAndKey(t) + inputFilePath := "../testfiles/testfile12.pdf" + originalFileName := filepath.Base(inputFilePath) + + // Read the signature image file + signatureImage, err := os.ReadFile("../testfiles/pdfsign-signature.jpg") + if err != nil { + t.Fatalf("Failed to read signature image: %s", err.Error()) + } + + tmpfile, err := os.CreateTemp("", t.Name()) + if err != nil { + t.Fatalf("%s", err.Error()) + } + if !testing.Verbose() { + defer os.Remove(tmpfile.Name()) + } + + err = SignFile(inputFilePath, tmpfile.Name(), SignData{ + Signature: SignDataSignature{ + Info: SignDataSignatureInfo{ + Name: "John Doe", + Location: "Somewhere", + Reason: "Test with visible signature and image", + ContactInfo: "None", + Date: time.Now().Local(), + Image: signatureImage, // Use the signature image + }, + CertType: ApprovalSignature, + DocMDPPerm: AllowFillingExistingFormFieldsAndSignaturesPerms, + }, + Appearance: Appearance{ + Visible: true, + LowerLeftX: 400, + LowerLeftY: 50, + UpperRightX: 600, + UpperRightY: 125, + }, + DigestAlgorithm: crypto.SHA512, + Signer: pkey, + Certificate: cert, + }) + if err != nil { + t.Fatalf("%s: %s", originalFileName, err.Error()) + } + + _, err = verify.File(tmpfile) + if err != nil { + t.Fatalf("%s: %s", tmpfile.Name(), err.Error()) + destinationPath := "../testfiles/failed/" + originalFileName + if copyErr := CopyFile(tmpfile.Name(), destinationPath); copyErr != nil { + t.Errorf("Failed to copy failed test file: %s", copyErr) + } + } +} diff --git a/testfiles/pdfsign-signature.jpg b/testfiles/pdfsign-signature.jpg new file mode 100644 index 0000000..2e0d6ea Binary files /dev/null and b/testfiles/pdfsign-signature.jpg differ