Compare commits

..

17 Commits

Author SHA1 Message Date
0d83ec6b6c Migrate repository to new hosting and cleanup CI/CD files
Switched module path and imports to gitea.tryanks.com. Removed GitHub-specific files like workflows, dependabot, and devcontainer as part of migration. This streamlines the codebase for the new hosting environment.
2025-05-12 22:33:48 +08:00
43924743e2 Merge branch 'main' into feature/image-appearance 2025-05-12 22:14:06 +08:00
Paul van Brouwershaven
7feb03999b Enhance handling of PDF Contents in createIncPageUpdate to preserve stream structure 2025-05-12 11:12:20 +02:00
Paul van Brouwershaven
0f834debb7 Fix test removed invalid source test file 2025-05-12 11:00:34 +02:00
Paul van Brouwershaven
bf1f861031 Ensure PDF version is at least 1.5 for SigFlags and UF support 2025-05-12 11:00:34 +02:00
Paul van Brouwershaven
e026bf1577 Add newline, fix #71 2025-05-12 09:30:51 +02:00
Corentin Mors
25feb83db7 Update readme with image appearance info 2025-05-10 18:28:20 +02:00
Corentin Mors
4b3957a882 Fix JPG decode 2025-05-10 18:24:05 +02:00
dependabot[bot]
6cbaf97a70 Bump golang.org/x/text from 0.22.0 to 0.25.0
Bumps [golang.org/x/text](https://github.com/golang/text) from 0.22.0 to 0.25.0.
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.22.0...v0.25.0)

---
updated-dependencies:
- dependency-name: golang.org/x/text
  dependency-version: 0.25.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-10 11:46:50 +02:00
dependabot[bot]
1aafac7336 Bump golang.org/x/crypto from 0.33.0 to 0.35.0 (#68) 2025-05-10 11:40:19 +02:00
Corentin Mors
51569c8aec PNG support 2025-05-01 15:10:54 +02:00
Corentin Mors
948b24a084 Revert some unecessary changes 2025-05-01 13:22:42 +02:00
Corentin Mors
abd05a5aaa Implement image watermark 2025-05-01 11:31:06 +02:00
Corentin Mors
a3b23161c4 Split down into multiple functions 2025-05-01 11:01:57 +02:00
Corentin Mors
9616146853 Test with two consecutive signatures 2025-04-29 11:47:37 +02:00
Corentin Mors
ce8274cc9c Image visual appearance 2025-04-29 11:35:42 +02:00
Corentin Mors
c958b32269 Add a devcontainer for development 2025-04-28 16:34:17 +02:00
24 changed files with 624 additions and 176 deletions

View File

@@ -1,11 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"

View File

@@ -1,35 +0,0 @@
name: Build & Test
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.x
uses: actions/setup-go@v5
with:
go-version: ^1.20
- name: Check out code into the Go module directory
uses: actions/checkout@v4
- name: Build
run: go build -v ./...
- name: Test
run: go test -v -race -coverprofile=coverage.txt -covermode=atomic ./...
- name: Upload coverage report
uses: codecov/codecov-action@v4
with:
file: ./coverage.txt
flags: unittests
name: codecov-umbrella

View File

@@ -1,55 +0,0 @@
name: golangci-lint
on:
push:
branches:
- master
- main
pull_request:
permissions:
contents: read
# Optional: allow read access to pull request. Use with `only-new-issues` option.
# pull-requests: read
jobs:
golangci:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
cache: false
- name: golangci-lint
uses: golangci/golangci-lint-action@v4
with:
# Require: The version of golangci-lint to use.
# When `install-mode` is `binary` (default) the value can be v1.2 or v1.2.3 or `latest` to use the latest version.
# When `install-mode` is `goinstall` the value can be v1.2.3, `latest`, or the hash of a commit.
version: v1.62
# Optional: working directory, useful for monorepos
# working-directory: somedir
# Optional: golangci-lint command line arguments.
#
# Note: By default, the `.golangci.yml` file should be at the root of the repository.
# The location of the configuration file can be changed by using `--config=`
# args: --timeout=30m --config=/my/path/.golangci.yml --issues-exit-code=0
# Optional: show only new issues if it's a pull request. The default value is `false`.
# only-new-issues: true
# Optional: if set to true, then all caching functionality will be completely disabled,
# takes precedence over all other caching options.
# skip-cache: true
# Optional: if set to true, then the action won't cache or restore ~/go/pkg.
# skip-pkg-cache: true
# Optional: if set to true, then the action won't cache or restore ~/.cache/go-build.
# skip-build-cache: true
# Optional: The mode to install golangci-lint. It can be 'binary' or 'goinstall'.
# install-mode: "goinstall"

1
.gitignore vendored
View File

@@ -6,3 +6,4 @@ testfiles/*_signed.pdf
testfiles/failed/* testfiles/failed/*
pdfsign pdfsign
certs/* certs/*
tmp/

View File

@@ -1,15 +1,7 @@
# Signing PDF files with Go # Signing PDF files with Go
[![Build & Test](https://github.com/digitorus/pdfsign/workflows/Build%20&%20Test/badge.svg)](https://github.com/digitorus/pdfsign/actions?query=workflow%3Abuild-and-test)
[![golangci-lint](https://github.com/digitorus/pdfsign/workflows/golangci-lint/badge.svg)](https://github.com/digitorus/pdfsign/actions?query=workflow%3Agolangci-lint)
[![Go Report Card](https://goreportcard.com/badge/github.com/digitorus/pdfsign)](https://goreportcard.com/report/github.com/digitorus/pdfsign)
[![Coverage Status](https://codecov.io/gh/digitorus/pdfsign/branch/main/graph/badge.svg)](https://codecov.io/gh/)
[![Go Reference](https://pkg.go.dev/badge/github.com/digitorus/pdfsign.svg)](https://pkg.go.dev/github.com/digitorus/pdfsign)
This PDF signing library is written in [Go](https://go.dev). The library is in development, might not work for all PDF files and the API might change, bug reports, contributions and suggestions are welcome. This PDF signing library is written in [Go](https://go.dev). The library is in development, might not work for all PDF files and the API might change, bug reports, contributions and suggestions are welcome.
**See also our [PDFSigner](https://github.com/digitorus/pdfsigner/), a more advanced digital signature server that is using this project.**
## From the command line ## From the command line
``` ```
@@ -98,3 +90,60 @@ if err != nil {
} }
``` ```
## Signature Appearance with Text and / or Images
You can add an image (JPG or PNG) to the visible signature appearance. This is useful for including a handwritten signature or a company logo in the signature field.
**Supported image formats:** JPG and PNG.
### Example: Signing a PDF with a visible signature and image
```go
// Read the signature image file
signatureImage, err := os.ReadFile("signature.jpg")
if err != nil {
log.Fatal(err)
}
err := sign.Sign(inputFile, outputFile, rdr, size, sign.SignData{
Signature: sign.SignDataSignature{
Info: sign.SignDataSignatureInfo{
Name: "John Doe",
Location: "Somewhere",
Reason: "Signed with image",
ContactInfo: "None",
Date: time.Now().Local(),
},
CertType: sign.ApprovalSignature,
DocMDPPerm: sign.AllowFillingExistingFormFieldsAndSignaturesPerms,
},
Appearance: sign.Appearance{
Visible: true,
LowerLeftX: 400,
LowerLeftY: 50,
UpperRightX: 600,
UpperRightY: 125,
Image: signatureImage, // JPG or PNG image bytes
// ImageAsWatermark: true, // Optional: set to true to draw text over the image
},
DigestAlgorithm: crypto.SHA512,
Signer: privateKey,
Certificate: certificate,
})
if err != nil {
log.Fatal(err)
}
```
### Key Features:
1. **Image Support**: Both JPG and PNG formats are supported
2. **Flexible Positioning**: Control signature placement with LowerLeftX/Y and UpperRightX/Y coordinates
3. **Watermark Mode**: Optional ImageAsWatermark setting allows drawing text over the image
4. **Transparency Support**: PNG images with alpha channel (transparency) are properly handled
### Notes:
- The image will be scaled to fit the signature rectangle while maintaining its aspect ratio
- For optimal results, prepare your image with the desired dimensions and transparency before using it
- Only visible approval signatures can include images

10
go.mod
View File

@@ -1,12 +1,14 @@
module github.com/digitorus/pdfsign module gitea.tryanks.com/Tryanks/pdfsign
go 1.22 go 1.23.0
toolchain go1.23.6
require ( require (
github.com/digitorus/pdf v0.1.2 github.com/digitorus/pdf v0.1.2
github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352
github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7
github.com/mattetti/filebuffer v1.0.1 github.com/mattetti/filebuffer v1.0.1
golang.org/x/crypto v0.33.0 golang.org/x/crypto v0.35.0
golang.org/x/text v0.22.0 golang.org/x/text v0.25.0
) )

8
go.sum
View File

@@ -7,7 +7,7 @@ github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 h1:lxmTCgmHE1G
github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y=
github.com/mattetti/filebuffer v1.0.1 h1:gG7pyfnSIZCxdoKq+cPa8T0hhYtD9NxCdI4D7PTjRLM= github.com/mattetti/filebuffer v1.0.1 h1:gG7pyfnSIZCxdoKq+cPa8T0hhYtD9NxCdI4D7PTjRLM=
github.com/mattetti/filebuffer v1.0.1/go.mod h1:YdMURNDOttIiruleeVr6f56OrMc+MydEnTcXwtkxNVs= github.com/mattetti/filebuffer v1.0.1/go.mod h1:YdMURNDOttIiruleeVr6f56OrMc+MydEnTcXwtkxNVs=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=

View File

@@ -12,8 +12,8 @@ import (
"os" "os"
"time" "time"
"github.com/digitorus/pdfsign/sign" "gitea.tryanks.com/Tryanks/pdfsign/sign"
"github.com/digitorus/pdfsign/verify" "gitea.tryanks.com/Tryanks/pdfsign/verify"
) )
var ( var (

View File

@@ -2,12 +2,218 @@ package sign
import ( import (
"bytes" "bytes"
"compress/zlib"
"fmt" "fmt"
"image"
_ "image/jpeg" // register JPEG format
_ "image/png" // register PNG format
) )
func (context *SignContext) createAppearance(rect [4]float64) ([]byte, error) { // Helper functions for PDF resource components
text := context.SignData.Signature.Info.Name
// writeAppearanceHeader writes the header for the appearance stream.
//
// Should be closed by writeFormTypeAndLength.
func writeAppearanceHeader(buffer *bytes.Buffer, rectWidth, rectHeight float64) {
buffer.WriteString("<<\n")
buffer.WriteString(" /Type /XObject\n")
buffer.WriteString(" /Subtype /Form\n")
buffer.WriteString(fmt.Sprintf(" /BBox [0 0 %f %f]\n", rectWidth, rectHeight))
buffer.WriteString(" /Matrix [1 0 0 1 0 0]\n") // No scaling or translation
}
func createFontResource(buffer *bytes.Buffer) {
buffer.WriteString(" /Font <<\n")
buffer.WriteString(" /F1 <<\n")
buffer.WriteString(" /Type /Font\n")
buffer.WriteString(" /Subtype /Type1\n")
buffer.WriteString(" /BaseFont /Times-Roman\n")
buffer.WriteString(" >>\n")
buffer.WriteString(" >>\n")
}
func createImageResource(buffer *bytes.Buffer, imageObjectId uint32) {
buffer.WriteString(" /XObject <<\n")
buffer.WriteString(fmt.Sprintf(" /Im1 %d 0 R\n", imageObjectId))
buffer.WriteString(" >>\n")
}
func writeFormTypeAndLength(buffer *bytes.Buffer, streamLength int) {
buffer.WriteString(" /FormType 1\n")
buffer.WriteString(fmt.Sprintf(" /Length %d\n", streamLength))
buffer.WriteString(">>\n")
}
func writeAppearanceStreamBuffer(buffer *bytes.Buffer, stream []byte) {
buffer.WriteString("stream\n")
buffer.Write(stream)
buffer.WriteString("endstream\n")
}
func (context *SignContext) createImageXObject() ([]byte, []byte, error) {
imageData := context.SignData.Appearance.Image
// Read image to get format and decode image data
img, format, err := image.Decode(bytes.NewReader(imageData))
if err != nil {
return nil, nil, fmt.Errorf("failed to decode image: %w", err)
}
// 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 %d\n", width))
imageObject.WriteString(fmt.Sprintf(" /Height %d\n", height))
imageObject.WriteString(" /ColorSpace /DeviceRGB\n")
imageObject.WriteString(" /BitsPerComponent 8\n")
var rgbData = new(bytes.Buffer)
var alphaData = new(bytes.Buffer)
// Handle different formats
switch format {
case "jpeg":
imageObject.WriteString(" /Filter [/FlateDecode/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(compressedRgbData)
imageObject.WriteString("\nendstream\n")
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) {
// Calculate 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
}
// 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
return fontSize, textX, textY
}
func drawText(buffer *bytes.Buffer, text string, fontSize float64, x, y float64) {
buffer.WriteString("q\n") // Save graphics state
buffer.WriteString("BT\n") // Begin text
buffer.WriteString(fmt.Sprintf("/F1 %.2f Tf\n", fontSize)) // Set font and size
buffer.WriteString(fmt.Sprintf("%.2f %.2f Td\n", x, y)) // Set text position
buffer.WriteString("0.2 0.2 0.6 rg\n") // Set font color to ballpoint-like color (RGB)
buffer.WriteString(fmt.Sprintf("%s Tj\n", pdfString(text))) // Show text
buffer.WriteString("ET\n") // End text
buffer.WriteString("Q\n") // Restore graphics state
}
func drawImage(buffer *bytes.Buffer, rectWidth, rectHeight float64) {
// We save state twice on purpose due to the cm operation
buffer.WriteString("q\n") // Save graphics state
buffer.WriteString("q\n") // Save before image transformation
buffer.WriteString(fmt.Sprintf("%.2f 0 0 %.2f 0 0 cm\n", rectWidth, rectHeight))
buffer.WriteString("/Im1 Do\n") // Draw image
buffer.WriteString("Q\n") // Restore after transformation
buffer.WriteString("Q\n") // Restore graphics state
}
func (context *SignContext) createAppearance(rect [4]float64) ([]byte, error) {
rectWidth := rect[2] - rect[0] rectWidth := rect[2] - rect[0]
rectHeight := rect[3] - rect[1] rectHeight := rect[3] - rect[1]
@@ -15,48 +221,61 @@ 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) return nil, fmt.Errorf("invalid rectangle dimensions: width %.2f and height %.2f must be greater than 0", rectWidth, rectHeight)
} }
// Calculate font size hasImage := len(context.SignData.Appearance.Image) > 0
fontSize := rectHeight * 0.8 // Initial font size shouldDisplayText := context.SignData.Appearance.ImageAsWatermark || !hasImage
textWidth := float64(len(text)) * fontSize * 0.5 // Approximate text width
if textWidth > rectWidth { // Create the appearance XObject
fontSize = rectWidth / (float64(len(text)) * 0.5) // Adjust font size to fit text within rect width var appearance_buffer bytes.Buffer
writeAppearanceHeader(&appearance_buffer, rectWidth, rectHeight)
// Resources dictionary with font
appearance_buffer.WriteString(" /Resources <<\n")
if hasImage {
// Create and add the image XObject
imageBytes, maskObjectBytes, err := context.createImageXObject()
if err != nil {
return nil, fmt.Errorf("failed to create image XObject: %w", err)
}
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)
} }
var appearance_stream_buffer bytes.Buffer if shouldDisplayText {
appearance_stream_buffer.WriteString("q\n") // Save graphics state createFontResource(&appearance_buffer)
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
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))
appearance_buffer.WriteString(" /Matrix [1 0 0 1 0 0]\n") // No scaling or translation
// Resources dictionary
appearance_buffer.WriteString(" /Resources <<\n")
appearance_buffer.WriteString(" /Font <<\n")
appearance_buffer.WriteString(" /F1 <<\n")
appearance_buffer.WriteString(" /Type /Font\n")
appearance_buffer.WriteString(" /Subtype /Type1\n")
appearance_buffer.WriteString(" /BaseFont /Times-Roman\n")
appearance_buffer.WriteString(" >>\n")
appearance_buffer.WriteString(" >>\n")
appearance_buffer.WriteString(" >>\n") appearance_buffer.WriteString(" >>\n")
appearance_buffer.WriteString(" /FormType 1\n") // Create the appearance stream
appearance_buffer.WriteString(fmt.Sprintf(" /Length %d\n", appearance_stream_buffer.Len())) var appearance_stream_buffer bytes.Buffer
appearance_buffer.WriteString(">>\n")
appearance_buffer.WriteString("stream\n") if hasImage {
appearance_buffer.Write(appearance_stream_buffer.Bytes()) drawImage(&appearance_stream_buffer, rectWidth, rectHeight)
appearance_buffer.WriteString("endstream\n") }
if shouldDisplayText {
text := context.SignData.Signature.Info.Name
fontSize, textX, textY := computeTextSizeAndPosition(text, rectWidth, rectHeight)
drawText(&appearance_stream_buffer, text, fontSize, textX, textY)
}
writeFormTypeAndLength(&appearance_buffer, appearance_stream_buffer.Len())
writeAppearanceStreamBuffer(&appearance_buffer, appearance_stream_buffer.Bytes())
return appearance_buffer.Bytes(), nil return appearance_buffer.Bytes(), nil
} }

View File

@@ -28,9 +28,10 @@ func (context *SignContext) createCatalog() ([]byte, error) {
// written in the PDF file (for example, /1.4). // written in the PDF file (for example, /1.4).
// //
// If an incremental upgrade requires a version that is higher than specified by the document. // If an incremental upgrade requires a version that is higher than specified by the document.
// if context.PDFReader.PDFVersion < "2.0" { // Ensure PDF version is at least 1.5 to support SigFlags in acroFormDict (1.4) and UF in the fileSpecDict (1.5)
// catalog_buffer.WriteString(" /Version /2.0") if v, err := strconv.ParseFloat(context.PDFReader.PDFVersion, 64); err == nil && v < 1.5 {
// } catalog_buffer.WriteString(" /Version /1.5\n")
}
// Retrieve the root, its pointer and set the root string // Retrieve the root, its pointer and set the root string
root := context.PDFReader.Trailer().Key("Root") root := context.PDFReader.Trailer().Key("Root")

View File

@@ -21,11 +21,11 @@ var testFiles = []struct {
}, },
}, },
{ {
file: "../testfiles/testfile21.pdf", file: "../testfiles/testfile12.pdf",
expectedCatalogs: map[CertType]string{ expectedCatalogs: map[CertType]string{
CertificationSignature: "<<\n /Type /Catalog\n /Metadata 8 0 R\n /Names 6 0 R\n /Pages 9 0 R\n /AcroForm <<\n /Fields [16 0 R]\n /SigFlags 3\n >>\n>>\n", CertificationSignature: "<<\n /Type /Catalog\n /Version /1.5\n /Outlines 2 0 R\n /Pages 3 0 R\n /AcroForm <<\n /Fields [16 0 R]\n /SigFlags 3\n >>\n>>\n",
UsageRightsSignature: "<<\n /Type /Catalog\n /Metadata 8 0 R\n /Names 6 0 R\n /Pages 9 0 R\n /AcroForm <<\n /Fields [16 0 R]\n /SigFlags 1\n >>\n>>\n", UsageRightsSignature: "<<\n /Type /Catalog\n /Version /1.5\n /Outlines 2 0 R\n /Pages 3 0 R\n /AcroForm <<\n /Fields [16 0 R]\n /SigFlags 1\n >>\n>>\n",
ApprovalSignature: "<<\n /Type /Catalog\n /Metadata 8 0 R\n /Names 6 0 R\n /Pages 9 0 R\n /AcroForm <<\n /Fields [16 0 R]\n /SigFlags 3\n >>\n>>\n", ApprovalSignature: "<<\n /Type /Catalog\n /Version /1.5\n /Outlines 2 0 R\n /Pages 3 0 R\n /AcroForm <<\n /Fields [16 0 R]\n /SigFlags 3\n >>\n>>\n",
}, },
}, },
} }

View File

@@ -43,7 +43,7 @@ func (context *SignContext) writeTrailer() error {
lines[i] = " " + strings.TrimSpace(line) lines[i] = " " + strings.TrimSpace(line)
} }
} }
trailer_string = strings.Join(lines, "\n") trailer_string = strings.Join(lines, "\n") + "\n"
// Write the new trailer. // Write the new trailer.
if _, err := context.OutputBuffer.Write([]byte(trailer_string)); err != nil { if _, err := context.OutputBuffer.Write([]byte(trailer_string)); err != nil {
@@ -54,7 +54,6 @@ func (context *SignContext) writeTrailer() error {
return err return err
} }
} }
// Write the new xref start position. // Write the new xref start position.
if _, err := context.OutputBuffer.Write([]byte(strconv.FormatInt(context.NewXrefStart, 10) + "\n")); err != nil { if _, err := context.OutputBuffer.Write([]byte(strconv.FormatInt(context.NewXrefStart, 10) + "\n")); err != nil {
return err return err

View File

@@ -128,9 +128,25 @@ func (context *SignContext) createIncPageUpdate(pageNumber, annot uint32) ([]byt
// TODO: Update digitorus/pdf to get raw values without resolving pointers // TODO: Update digitorus/pdf to get raw values without resolving pointers
for _, key := range page.Keys() { for _, key := range page.Keys() {
switch key { switch key {
case "Contents", "Parent": case "Parent":
ptr := page.Key(key).GetPtr() ptr := page.Key(key).GetPtr()
page_buffer.WriteString(fmt.Sprintf(" /%s %d 0 R\n", key, ptr.GetID())) page_buffer.WriteString(fmt.Sprintf(" /%s %d 0 R\n", key, ptr.GetID()))
case "Contents":
// Special handling for Contents - must preserve stream structure
contentsValue := page.Key(key)
if contentsValue.Kind() == pdf.Array {
// If Contents is an array, keep it as an array reference
page_buffer.WriteString(" /Contents [")
for i := 0; i < contentsValue.Len(); i++ {
ptr := contentsValue.Index(i).GetPtr()
page_buffer.WriteString(fmt.Sprintf(" %d 0 R", ptr.GetID()))
}
page_buffer.WriteString(" ]\n")
} else {
// If Contents is a single reference, keep it as a single reference
ptr := contentsValue.GetPtr()
page_buffer.WriteString(fmt.Sprintf(" /%s %d 0 R\n", key, ptr.GetID()))
}
case "Annots": case "Annots":
page_buffer.WriteString(" /Annots [\n") page_buffer.WriteString(" /Annots [\n")
for i := 0; i < page.Key("Annots").Len(); i++ { for i := 0; i < page.Key("Annots").Len(); i++ {

View File

@@ -16,6 +16,19 @@ const (
objectFooter = "\nendobj\n" 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) { func (context *SignContext) addObject(object []byte) (uint32, error) {
if context.lastXrefID == 0 { if context.lastXrefID == 0 {
lastXrefID, err := context.getLastObjectIDFromXref() lastXrefID, err := context.getLastObjectIDFromXref()

View File

@@ -20,7 +20,6 @@ func TestGetLastObjectIDFromXref(t *testing.T) {
{"testfile16.pdf", 567}, {"testfile16.pdf", 567},
{"testfile17.pdf", 20}, {"testfile17.pdf", 20},
{"testfile20.pdf", 10}, {"testfile20.pdf", 10},
{"testfile21.pdf", 16},
} }
for _, tc := range testCases { for _, tc := range testCases {

View File

@@ -8,7 +8,7 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/digitorus/pdfsign/revocation" "gitea.tryanks.com/Tryanks/pdfsign/revocation"
"golang.org/x/crypto/ocsp" "golang.org/x/crypto/ocsp"
) )

View File

@@ -9,7 +9,7 @@ import (
"encoding/pem" "encoding/pem"
"testing" "testing"
"github.com/digitorus/pdfsign/revocation" "gitea.tryanks.com/Tryanks/pdfsign/revocation"
) )
const certPem = `-----BEGIN CERTIFICATE----- const certPem = `-----BEGIN CERTIFICATE-----

View File

@@ -9,8 +9,8 @@ import (
"os" "os"
"time" "time"
"gitea.tryanks.com/Tryanks/pdfsign/revocation"
"github.com/digitorus/pdf" "github.com/digitorus/pdf"
"github.com/digitorus/pdfsign/revocation"
"github.com/digitorus/pkcs7" "github.com/digitorus/pkcs7"
"github.com/mattetti/filebuffer" "github.com/mattetti/filebuffer"
@@ -45,12 +45,16 @@ type SignData struct {
// Appearance represents the appearance of the signature // Appearance represents the appearance of the signature
type Appearance struct { type Appearance struct {
Visible bool Visible bool
Page uint32 Page uint32
LowerLeftX float64 LowerLeftX float64
LowerLeftY float64 LowerLeftY float64
UpperRightX float64 UpperRightX float64
UpperRightY float64 UpperRightY float64
Image []byte // Image data to use as signature appearance
ImageAsWatermark bool // If true, the text will be drawn over the image
} }
type VisualSignData struct { type VisualSignData struct {

View File

@@ -12,9 +12,9 @@ import (
"testing" "testing"
"time" "time"
"gitea.tryanks.com/Tryanks/pdfsign/revocation"
"gitea.tryanks.com/Tryanks/pdfsign/verify"
"github.com/digitorus/pdf" "github.com/digitorus/pdf"
"github.com/digitorus/pdfsign/revocation"
"github.com/digitorus/pdfsign/verify"
"github.com/mattetti/filebuffer" "github.com/mattetti/filebuffer"
) )
@@ -231,7 +231,7 @@ func TestSignPDFFileUTF8(t *testing.T) {
func TestSignPDFVisible(t *testing.T) { func TestSignPDFVisible(t *testing.T) {
cert, pkey := loadCertificateAndKey(t) cert, pkey := loadCertificateAndKey(t)
inputFilePath := "../testfiles/testfile20.pdf" inputFilePath := "../testfiles/testfile12.pdf"
originalFileName := filepath.Base(inputFilePath) originalFileName := filepath.Base(inputFilePath)
tmpfile, err := os.CreateTemp("", t.Name()) tmpfile, err := os.CreateTemp("", t.Name())
@@ -475,3 +475,248 @@ func TestTimestampPDFFile(t *testing.T) {
verifySignedFile(t, tmpfile, "testfile20.pdf") 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(),
},
CertType: ApprovalSignature,
DocMDPPerm: AllowFillingExistingFormFieldsAndSignaturesPerms,
},
Appearance: Appearance{
Visible: true,
LowerLeftX: 400,
LowerLeftY: 50,
UpperRightX: 600,
UpperRightY: 125,
Image: signatureImage, // Use the signature image
},
DigestAlgorithm: crypto.SHA512,
Signer: pkey,
Certificate: cert,
})
if err != nil {
t.Fatalf("%s: %s", originalFileName, err.Error())
}
verifySignedFile(t, tmpfile, originalFileName)
}
// TestSignPDFWithTwoImages tests signing a PDF with two different signatures with images
func TestSignPDFWithTwoImages(t *testing.T) {
cert, pkey := loadCertificateAndKey(t)
tbsFile := "../testfiles/testfile12.pdf"
// 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())
}
// First signature
firstSignature, err := os.CreateTemp("", fmt.Sprintf("%s_first_", t.Name()))
if err != nil {
t.Fatalf("%s", err.Error())
}
if !testing.Verbose() {
defer os.Remove(firstSignature.Name())
}
err = SignFile(tbsFile, firstSignature.Name(), SignData{
Signature: SignDataSignature{
Info: SignDataSignatureInfo{
Name: "John Doe",
Location: "Somewhere",
Reason: "First signature with image",
ContactInfo: "None",
Date: time.Now().Local(),
},
CertType: ApprovalSignature,
DocMDPPerm: AllowFillingExistingFormFieldsAndSignaturesPerms,
},
Appearance: Appearance{
Visible: true,
LowerLeftX: 50,
LowerLeftY: 50,
UpperRightX: 250,
UpperRightY: 125,
Image: signatureImage,
},
DigestAlgorithm: crypto.SHA512,
Signer: pkey,
Certificate: cert,
})
if err != nil {
t.Fatalf("First signature failed: %s", err.Error())
}
verifySignedFile(t, firstSignature, filepath.Base(tbsFile))
// Second signature
secondSignature, err := os.CreateTemp("", fmt.Sprintf("%s_second_", t.Name()))
if err != nil {
t.Fatalf("%s", err.Error())
}
if !testing.Verbose() {
defer os.Remove(secondSignature.Name())
}
err = SignFile(firstSignature.Name(), secondSignature.Name(), SignData{
Signature: SignDataSignature{
Info: SignDataSignatureInfo{
Name: "Jane Doe",
Location: "Elsewhere",
Reason: "Second signature with image",
ContactInfo: "None",
Date: time.Now().Local(),
},
CertType: ApprovalSignature,
DocMDPPerm: AllowFillingExistingFormFieldsAndSignaturesPerms,
},
Appearance: Appearance{
Visible: true,
LowerLeftX: 300,
LowerLeftY: 50,
UpperRightX: 500,
UpperRightY: 125,
Image: signatureImage,
},
DigestAlgorithm: crypto.SHA512,
Signer: pkey,
Certificate: cert,
})
if err != nil {
t.Fatalf("Second signature failed: %s", err.Error())
}
verifySignedFile(t, secondSignature, filepath.Base(tbsFile))
}
// 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)
// Read the signature image file
signatureImage, err := os.ReadFile("../testfiles/pdfsign-signature-watermark.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: "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)
}
// 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)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

View File

@@ -12,8 +12,9 @@ import (
"strings" "strings"
"time" "time"
"gitea.tryanks.com/Tryanks/pdfsign/revocation"
"github.com/digitorus/pdf" "github.com/digitorus/pdf"
"github.com/digitorus/pdfsign/revocation"
"github.com/digitorus/pkcs7" "github.com/digitorus/pkcs7"
"github.com/digitorus/timestamp" "github.com/digitorus/timestamp"
"golang.org/x/crypto/ocsp" "golang.org/x/crypto/ocsp"