diff --git a/sign/appearance.go b/sign/appearance.go index 7b3b5d5..4ad4db0 100644 --- a/sign/appearance.go +++ b/sign/appearance.go @@ -2,9 +2,11 @@ package sign import ( "bytes" + "compress/zlib" "fmt" "image" _ "image/jpeg" // register JPEG format + _ "image/png" // register PNG format ) // Helper functions for PDF resource components @@ -48,38 +50,127 @@ func writeAppearanceStreamBuffer(buffer *bytes.Buffer, stream []byte) { buffer.WriteString("endstream\n") } -func (context *SignContext) createImageXObject() ([]byte, error) { +func (context *SignContext) createImageXObject() ([]byte, []byte, error) { imageData := context.SignData.Appearance.Image - // Read image configuration to get original dimensions - img, _, err := image.DecodeConfig(bytes.NewReader(imageData)) + // Read image to get format and decode image data + img, format, err := image.Decode(bytes.NewReader(imageData)) if err != nil { - return nil, fmt.Errorf("failed to decode image configuration: %w", err) + return nil, nil, fmt.Errorf("failed to decode image: %w", err) } - // Use original image dimensions - width := float64(img.Width) - height := float64(img.Height) + // Get image dimensions + bounds := img.Bounds() + width := bounds.Max.X - bounds.Min.X + height := bounds.Max.Y - bounds.Min.Y // Create basic PDF Image XObject var imageObject bytes.Buffer + var maskObjectBytes []byte 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(fmt.Sprintf(" /Width %d\n", width)) + imageObject.WriteString(fmt.Sprintf(" /Height %d\n", height)) imageObject.WriteString(" /ColorSpace /DeviceRGB\n") imageObject.WriteString(" /BitsPerComponent 8\n") - imageObject.WriteString(" /Filter /DCTDecode\n") - imageObject.WriteString(fmt.Sprintf(" /Length %d\n", len(imageData))) - imageObject.WriteString(">>\n") + var rgbData = new(bytes.Buffer) + var alphaData = new(bytes.Buffer) + + // Handle different formats + switch format { + case "jpeg": + imageObject.WriteString(" /Filter /DCTDecode\n") + rgbData = bytes.NewBuffer(imageData) // JPEG data is already in the correct format + case "png": + imageObject.WriteString(" /Filter /FlateDecode\n") + + // Extract RGB and alpha values from each pixel + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + // Get the color at pixel (x,y) + originalColor := img.At(x, y) + + // Extract RGBA values (ranges from 0-65535 in Go's color model) + r, g, b, a := originalColor.RGBA() + + // Convert to 8-bit (0-255) + rgbData.WriteByte(byte(r >> 8)) + rgbData.WriteByte(byte(g >> 8)) + rgbData.WriteByte(byte(b >> 8)) + alphaData.WriteByte(byte(a >> 8)) + } + } + + // If image has alpha channel, create soft mask + if hasAlpha(img) { + compressedAlphaData := compressData(alphaData.Bytes()) + + // Create and add the soft mask object + maskObjectBytes, err = context.createAlphaMask(width, height, compressedAlphaData) + if err != nil { + return nil, nil, fmt.Errorf("failed to create alpha mask: %w", err) + } + + imageObject.WriteString(fmt.Sprintf(" /SMask %d 0 R\n", context.getNextObjectID()+1)) // the smask will be placed after the image + } + default: + return nil, nil, fmt.Errorf("unsupported image format: %s", format) + } + + compressedRgbData := compressData(rgbData.Bytes()) + + imageObject.WriteString(fmt.Sprintf(" /Length %d\n", len(compressedRgbData))) + imageObject.WriteString(">>\n") imageObject.WriteString("stream\n") - imageObject.Write(imageData) + imageObject.Write(compressedRgbData) imageObject.WriteString("\nendstream\n") - return imageObject.Bytes(), nil + return imageObject.Bytes(), maskObjectBytes, nil +} + +func compressData(data []byte) []byte { + var compressedData bytes.Buffer + writer := zlib.NewWriter(&compressedData) + defer writer.Close() + _, err := writer.Write(data) + if err != nil { + return nil + } + writer.Close() + return compressedData.Bytes() +} + +func (context *SignContext) createAlphaMask(width, height int, alphaData []byte) ([]byte, error) { + var maskObject bytes.Buffer + + maskObject.WriteString("<<\n") + maskObject.WriteString(" /Type /XObject\n") + maskObject.WriteString(" /Subtype /Image\n") + maskObject.WriteString(fmt.Sprintf(" /Width %d\n", width)) + maskObject.WriteString(fmt.Sprintf(" /Height %d\n", height)) + maskObject.WriteString(" /ColorSpace /DeviceGray\n") + maskObject.WriteString(" /BitsPerComponent 8\n") + maskObject.WriteString(" /Filter /FlateDecode\n") + maskObject.WriteString(fmt.Sprintf(" /Length %d\n", len(alphaData))) + maskObject.WriteString(">>\n") + maskObject.WriteString("stream\n") + maskObject.Write(alphaData) + maskObject.WriteString("\nendstream\n") + + return maskObject.Bytes(), nil +} + +// hasAlpha checks if the image has an alpha channel +func hasAlpha(img image.Image) bool { + switch img.(type) { + case *image.NRGBA, *image.RGBA: + return true + default: + return false + } } func computeTextSizeAndPosition(text string, rectWidth, rectHeight float64) (float64, float64, float64) { @@ -142,16 +233,24 @@ func (context *SignContext) createAppearance(rect [4]float64) ([]byte, error) { if hasImage { // Create and add the image XObject - imageStream, err := context.createImageXObject() + imageBytes, maskObjectBytes, err := context.createImageXObject() if err != nil { return nil, fmt.Errorf("failed to create image XObject: %w", err) } - imageObjectId, err := context.addObject(imageStream) + imageObjectId, err := context.addObject(imageBytes) if err != nil { return nil, fmt.Errorf("failed to add image object: %w", err) } + if maskObjectBytes != nil { + // Create and add the mask XObject + _, err := context.addObject(maskObjectBytes) + if err != nil { + return nil, fmt.Errorf("failed to add mask object: %w", err) + } + } + createImageResource(&appearance_buffer, imageObjectId) } diff --git a/sign/pdfxref.go b/sign/pdfxref.go index 6776390..4647843 100644 --- a/sign/pdfxref.go +++ b/sign/pdfxref.go @@ -16,6 +16,19 @@ const ( objectFooter = "\nendobj\n" ) +func (context *SignContext) getNextObjectID() uint32 { + if context.lastXrefID == 0 { + lastXrefID, err := context.getLastObjectIDFromXref() + if err != nil { + return 0 + } + context.lastXrefID = lastXrefID + } + + objectID := context.lastXrefID + uint32(len(context.newXrefEntries)) + 1 + return objectID +} + func (context *SignContext) addObject(object []byte) (uint32, error) { if context.lastXrefID == 0 { lastXrefID, err := context.getLastObjectIDFromXref() diff --git a/sign/sign_test.go b/sign/sign_test.go index e1160f6..d714374 100644 --- a/sign/sign_test.go +++ b/sign/sign_test.go @@ -617,8 +617,8 @@ func TestSignPDFWithTwoImages(t *testing.T) { verifySignedFile(t, secondSignature, filepath.Base(tbsFile)) } -// TestSignPDFWithWatermarkImage tests signing a PDF with an image and text above -func TestSignPDFWithWatermarkImage(t *testing.T) { +// TestSignPDFWithWatermarkImage tests signing a PDF with a JPG image and text above +func TestSignPDFWithWatermarkImageJPG(t *testing.T) { cert, pkey := loadCertificateAndKey(t) inputFilePath := "../testfiles/testfile12.pdf" originalFileName := filepath.Base(inputFilePath) @@ -668,3 +668,55 @@ func TestSignPDFWithWatermarkImage(t *testing.T) { verifySignedFile(t, tmpfile, originalFileName) } + +// TestSignPDFWithWatermarkImage tests signing a PDF with a PNG image and text above +func TestSignPDFWithWatermarkImagePNG(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-watermark.png") + 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: "James SuperSmith", + Location: "Somewhere", + Reason: "Test with visible signature and watermark image", + ContactInfo: "None", + Date: time.Now().Local(), + }, + CertType: ApprovalSignature, + DocMDPPerm: AllowFillingExistingFormFieldsAndSignaturesPerms, + }, + Appearance: Appearance{ + Visible: true, + LowerLeftX: 400, + LowerLeftY: 50, + UpperRightX: 600, + UpperRightY: 125, + Image: signatureImage, // Use the signature image + ImageAsWatermark: true, // Set the image as a watermark + }, + DigestAlgorithm: crypto.SHA512, + Signer: pkey, + Certificate: cert, + }) + if err != nil { + t.Fatalf("%s: %s", originalFileName, err.Error()) + } + + verifySignedFile(t, tmpfile, originalFileName) +} diff --git a/testfiles/pdfsign-signature-watermark.png b/testfiles/pdfsign-signature-watermark.png new file mode 100644 index 0000000..752ef68 Binary files /dev/null and b/testfiles/pdfsign-signature-watermark.png differ