diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 9ce51e4..759f67d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -63,7 +63,7 @@ body: id: gpdf-version attributes: label: gpdf Version - placeholder: "v1.0.3" + placeholder: "v1.0.4" validations: required: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a368a9..5f0d8d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [1.0.4] - 2026-04-07 + +### Added +- AcroForm flatten support — flatten form fields into static content (#17) + +## [1.0.3] - 2026-03-23 + ### Fixed - Multi-page table support — tables inside Row/Col now automatically split across pages - `layoutHorizontal` propagates child overflow to the paginator @@ -21,7 +28,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - `pdf.Writer.AddRawPage()`, `PageTreeRef()`: Raw page insertion support - Merge examples: basic merge, page range extraction, metadata, merge + overlay, issue #11 scenario -## [1.0.0] - 2026-03-20 +## [1.0.1] - 2026-03-22 + +### Added +- RFC 3161 timestamping for digital signatures + +## [1.0.0] - 2026-03-22 ### Added - Existing PDF overlay — open, read, and modify existing PDFs @@ -107,8 +119,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - Reed-Solomon coefficient order in QR code encoder - binary.Write return value handling for errcheck lint -[Unreleased]: https://github.com/gpdf-dev/gpdf/compare/v1.0.3...HEAD -[1.0.2]: https://github.com/gpdf-dev/gpdf/compare/v1.0.0...v1.0.3 +[Unreleased]: https://github.com/gpdf-dev/gpdf/compare/v1.0.4...HEAD +[1.0.4]: https://github.com/gpdf-dev/gpdf/compare/v1.0.3...v1.0.4 +[1.0.3]: https://github.com/gpdf-dev/gpdf/compare/v1.0.2...v1.0.3 +[1.0.2]: https://github.com/gpdf-dev/gpdf/compare/v1.0.1...v1.0.2 +[1.0.1]: https://github.com/gpdf-dev/gpdf/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/gpdf-dev/gpdf/compare/v0.9.0...v1.0.0 [0.9.0]: https://github.com/gpdf-dev/gpdf/compare/v0.8.0...v0.9.0 [0.8.0]: https://github.com/gpdf-dev/gpdf/compare/v0.5.0...v0.8.0 diff --git a/README.md b/README.md index 24a7295..5b03256 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Go Reference](https://pkg.go.dev/badge/github.com/gpdf-dev/gpdf.svg)](https://pkg.go.dev/github.com/gpdf-dev/gpdf) [![CI](https://github.com/gpdf-dev/gpdf/actions/workflows/check-code.yml/badge.svg)](https://github.com/gpdf-dev/gpdf/actions/workflows/check-code.yml) -![coverage](https://img.shields.io/badge/coverage-86.3%25-green) +![coverage](https://img.shields.io/badge/coverage-86.1%25-green) [![Go Report Card](https://goreportcard.com/badge/github.com/gpdf-dev/gpdf)](https://goreportcard.com/report/github.com/gpdf-dev/gpdf) [![Go Version](https://img.shields.io/badge/Go-%3E%3D1.22-blue)](https://go.dev/) [![Website](https://img.shields.io/badge/Website-gpdf.dev-blue)](https://gpdf.dev/) @@ -33,6 +33,7 @@ A pure Go, zero-dependency PDF generation library with a layered architecture an - **Images** — JPEG and PNG embedding with fit options - **Absolute positioning** — place elements at exact XY coordinates on the page - **Existing PDF overlay** — open existing PDFs and add text, images, stamps on top +- **Form flattening** — flatten AcroForm fields into static page content, preserving non-widget annotations - **PDF merging** — combine multiple PDFs into one with page range selection - **Document metadata** — title, author, subject, creator - **Encryption** — AES-256 encryption (ISO 32000-2, Rev 6) with owner/user passwords and permissions @@ -499,6 +500,22 @@ doc.EachPage(func(i int, p *template.PageBuilder) { result, _ := doc.Save() ``` +### Form Flattening + +Flatten interactive AcroForm fields into static page content. Non-widget annotations (links, comments) are preserved: + +```go +// Open a PDF with form fields +doc, err := gpdf.Open(filledFormPDF) + +// Flatten all form fields into static content +if err := doc.FlattenForms(); err != nil { + log.Fatal(err) +} + +result, _ := doc.Save() +``` + ### PDF Merging Combine multiple PDFs into a single document with optional page range selection: @@ -796,6 +813,7 @@ doc.Render(f) | `doc.PageCount()` | Get the number of pages | | `doc.Overlay(page, fn)` | Add content on top of a specific page | | `doc.EachPage(fn)` | Apply overlay to every page | +| `doc.FlattenForms()` | Flatten AcroForm fields into static page content | | `doc.Save()` | Save the modified PDF | | `gpdf.Merge(sources, opts...)` | Merge multiple PDFs into one | | `WithMergeMetadata(title, author, producer)` | Set metadata on merged output | diff --git a/README_es.md b/README_es.md index 70708bd..2c97c17 100644 --- a/README_es.md +++ b/README_es.md @@ -32,6 +32,7 @@ Biblioteca de generación de PDF en Go puro, sin dependencias externas, con arqu - **Imágenes** — incrustación de JPEG y PNG con opciones de ajuste - **Posicionamiento absoluto** — colocar elementos en coordenadas XY exactas en la página - **Superposición de PDF existente** — abrir PDFs existentes y agregar texto, imágenes, sellos encima +- **Aplanamiento de formularios** — aplanar campos AcroForm en contenido de página estático, preservando anotaciones que no son widgets - **Fusión de PDF** — combinar múltiples PDFs en uno con selección de rango de páginas - **Metadatos del documento** — título, autor, asunto, creador - **Encriptación** — encriptación AES-256 (ISO 32000-2, Rev 6) con contraseñas de propietario/usuario y permisos @@ -297,6 +298,80 @@ c.Line(template.LineThickness(document.Pt(3))) // Gruesa c.Spacer(document.Mm(5)) // Espacio vertical de 5mm ``` +### Listas + +Listas con viñetas y numeradas: + +```go +// Lista con viñetas +c.List([]string{"Primer elemento", "Segundo elemento", "Tercer elemento"}) + +// Lista numerada +c.OrderedList([]string{"Paso uno", "Paso dos", "Paso tres"}) +``` + +### Códigos QR + +Generar códigos QR con tamaño y nivel de corrección configurables: + +```go +// Código QR básico +c.QRCode("https://gpdf.dev") + +// Tamaño y nivel de corrección personalizados +c.QRCode("https://gpdf.dev", + template.QRSize(document.Mm(30)), + template.QRErrorCorrection(qrcode.LevelH)) +``` + +### Códigos de barras + +Generación de Code 128: + +```go +// Código de barras básico +c.Barcode("INV-2026-0001") + +// Ancho personalizado +c.Barcode("INV-2026-0001", template.BarcodeWidth(document.Mm(80))) +``` + +### Números de página + +Número de página automático y total de páginas: + +```go +doc.Footer(func(p *template.PageBuilder) { + p.AutoRow(func(r *template.RowBuilder) { + r.Col(6, func(c *template.ColBuilder) { + c.Text("Generado con gpdf", template.FontSize(8)) + }) + r.Col(6, func(c *template.ColBuilder) { + c.PageNumber(template.AlignRight(), template.FontSize(8)) + }) + }) +}) + +doc.Header(func(p *template.PageBuilder) { + p.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { + c.TotalPages(template.AlignRight(), template.FontSize(9)) + }) + }) +}) +``` + +### Decoraciones de texto + +Subrayado, tachado, espaciado de letras y sangría: + +```go +c.Text("Texto subrayado", template.Underline()) +c.Text("Texto tachado", template.Strikethrough()) +c.Text("Espaciado amplio", template.LetterSpacing(3)) +c.Text("Párrafo con sangría...", template.TextIndent(document.Pt(24))) +``` + ### Encabezados y pies de página Defina encabezados y pies de página que se repiten en cada página: @@ -324,6 +399,19 @@ doc.Footer(func(p *template.PageBuilder) { }) ``` +### Documentos de múltiples páginas + +```go +for i := 1; i <= 5; i++ { + page := doc.AddPage() + page.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { + c.Text("Contenido de la página") + }) + }) +} +``` + ### Componentes reutilizables Genere tipos de documentos comunes con una sola llamada de función: @@ -389,6 +477,65 @@ doc := template.Letter(template.LetterData{ }) ``` +### Encriptación + +Encriptación AES-256 con contraseñas de propietario/usuario y control de permisos: + +```go +// Solo contraseña de propietario (el PDF se abre sin contraseña, la edición está restringida) +doc := gpdf.NewDocument( + gpdf.WithPageSize(gpdf.A4), + gpdf.WithEncryption( + encrypt.WithOwnerPassword("owner-secret"), + ), +) + +// Ambas contraseñas con control de permisos +doc := gpdf.NewDocument( + gpdf.WithPageSize(gpdf.A4), + gpdf.WithEncryption( + encrypt.WithOwnerPassword("owner-pass"), + encrypt.WithUserPassword("user-pass"), + encrypt.WithPermissions(encrypt.PermPrint|encrypt.PermCopy), + ), +) +``` + +### Conformidad PDF/A + +Generar documentos conformes a PDF/A-1b o PDF/A-2b: + +```go +doc := gpdf.NewDocument( + gpdf.WithPageSize(gpdf.A4), + gpdf.WithPDFA( + pdfa.WithLevel(pdfa.LevelA2b), + pdfa.WithMetadata(pdfa.MetadataInfo{ + Title: "Informe Archivado", + Author: "gpdf", + }), + ), +) +``` + +### Firmas digitales + +Firmas CMS/PKCS#7 con claves RSA o ECDSA: + +```go +data, _ := doc.Generate() + +signed, err := gpdf.SignDocument(data, signature.Signer{ + Certificate: cert, + PrivateKey: key, + Chain: intermediates, +}, + signature.WithReason("Aprobado"), + signature.WithLocation("Madrid"), + signature.WithTimestamp("http://tsa.example.com"), +) +``` + ### Superposición de PDF existente Abrir un PDF existente y superponer contenido usando la misma API de constructores: @@ -416,6 +563,22 @@ doc.EachPage(func(i int, p *template.PageBuilder) { result, _ := doc.Save() ``` +### Aplanamiento de formularios + +Aplanar campos AcroForm interactivos en contenido de página estático. Las anotaciones que no son widgets (enlaces, comentarios) se preservan: + +```go +// Abrir un PDF con campos de formulario +doc, err := gpdf.Open(filledFormPDF) + +// Aplanar todos los campos de formulario en contenido estático +if err := doc.FlattenForms(); err != nil { + log.Fatal(err) +} + +result, _ := doc.Save() +``` + ### Fusión de PDF Combine múltiples PDFs en un solo documento con selección opcional de rango de páginas: @@ -432,6 +595,58 @@ merged, _ := gpdf.Merge( ) ``` +### Esquema JSON + +Defina documentos completamente en JSON: + +```go +schema := []byte(`{ + "page": {"size": "A4", "margins": "20mm"}, + "metadata": {"title": "Informe", "author": "gpdf"}, + "body": [ + {"row": {"cols": [ + {"span": 12, "text": "Hola desde JSON", "style": {"size": 24, "bold": true}} + ]}}, + {"row": {"cols": [ + {"span": 12, "table": { + "header": ["Nombre", "Valor"], + "rows": [["Alpha", "100"], ["Beta", "200"]], + "headerStyle": {"bold": true, "color": "white", "background": "#1A237E"} + }} + ]}} + ] +}`) + +doc, err := template.FromJSON(schema, nil) +data, _ := doc.Generate() +``` + +### Integración con Go Templates + +Use plantillas Go con esquemas JSON para contenido dinámico: + +```go +schema := []byte(`{ + "page": {"size": "A4", "margins": "20mm"}, + "metadata": {"title": "{{.Title}}"}, + "body": [ + {"row": {"cols": [ + {"span": 12, "text": "{{.Title}}", "style": {"size": 24, "bold": true}} + ]}} + ] +}`) + +data := map[string]any{"Title": "Informe Dinámico"} +doc, err := template.FromJSON(schema, data) +``` + +Para más control, use una plantilla Go pre-parseada: + +```go +tmpl, _ := gotemplate.New("doc").Funcs(template.TemplateFuncMap()).Parse(schemaStr) +doc, err := template.FromTemplate(tmpl, data) +``` + ### Metadatos del documento ```go @@ -494,6 +709,8 @@ doc.Render(f) | `WithFont(family, data)` | Registrar una fuente TrueType | | `WithDefaultFont(family, size)` | Establecer la fuente predeterminada | | `WithMetadata(meta)` | Establecer metadatos del documento | +| `WithEncryption(opts...)` | Habilitar encriptación AES-256 | +| `WithPDFA(opts...)` | Habilitar conformidad PDF/A | ### Contenido de columna @@ -535,6 +752,7 @@ doc.Render(f) | `doc.PageCount()` | Obtener el número de páginas | | `doc.Overlay(page, fn)` | Superponer contenido en una página específica | | `doc.EachPage(fn)` | Aplicar superposición a todas las páginas | +| `doc.FlattenForms()` | Aplanar campos AcroForm en contenido de página estático | | `doc.Save()` | Guardar el PDF modificado | | `gpdf.Merge(sources, opts...)` | Fusionar múltiples PDFs en uno | | `WithMergeMetadata(title, author, producer)` | Establecer metadatos en la salida fusionada | @@ -589,6 +807,31 @@ doc.Render(f) | `template.BarcodeHeight(value)` | Altura del código de barras | | `template.BarcodeFormat(fmt)` | Formato del código de barras (Code 128) | +### Opciones de encriptación + +| Opción | Descripción | +|---|---| +| `encrypt.WithOwnerPassword(pw)` | Establecer contraseña de propietario | +| `encrypt.WithUserPassword(pw)` | Establecer contraseña de usuario | +| `encrypt.WithPermissions(perm)` | Establecer permisos del documento (PermPrint, PermCopy, PermModify, etc.) | + +### Opciones de PDF/A + +| Opción | Descripción | +|---|---| +| `pdfa.WithLevel(level)` | Establecer nivel de conformidad (LevelA1b, LevelA2b) | +| `pdfa.WithMetadata(info)` | Establecer metadatos XMP (Title, Author, Subject, etc.) | + +### Firma digital + +| Función / Opción | Descripción | +|---|---| +| `gpdf.SignDocument(data, signer, opts...)` | Firmar un PDF con firma digital | +| `signature.WithReason(reason)` | Establecer razón de la firma | +| `signature.WithLocation(location)` | Establecer ubicación de la firma | +| `signature.WithTimestamp(tsaURL)` | Habilitar sellado de tiempo RFC 3161 | +| `signature.WithSignTime(t)` | Establecer hora de la firma | + ### Generación de plantillas | Función | Descripción | @@ -597,6 +840,14 @@ doc.Render(f) | `template.FromTemplate(tmpl, data)` | Generar documento desde plantilla Go | | `template.TemplateFuncMap()` | Obtener funciones auxiliares de plantilla (incluye `toJSON`) | +### Componentes reutilizables + +| Función | Descripción | +|---|---| +| `template.Invoice(data)` | Generar una factura PDF profesional | +| `template.Report(data)` | Generar un informe PDF estructurado | +| `template.Letter(data)` | Generar una carta comercial PDF | + ### Opciones de línea | Opción | Descripción | diff --git a/README_ja.md b/README_ja.md index b30cca3..e4b3f6e 100644 --- a/README_ja.md +++ b/README_ja.md @@ -32,6 +32,7 @@ - **画像** — JPEGとPNGの埋め込み(フィットオプション対応) - **絶対位置指定** — ページ上の任意のXY座標に要素を配置 - **既存PDFオーバーレイ** — 既存PDFを開いてテキスト、画像、スタンプを上に追加 +- **フォームフラット化** — AcroFormフィールドを静的ページコンテンツに変換、非ウィジェット注釈は保持 - **PDFマージ** — 複数のPDFをページ範囲指定付きで1つに結合 - **ドキュメントメタデータ** — タイトル、著者、件名、作成者 - **暗号化** — AES-256暗号化(ISO 32000-2, Rev 6)、オーナー/ユーザーパスワードと権限制御 @@ -398,6 +399,19 @@ doc.Footer(func(p *template.PageBuilder) { }) ``` +### 複数ページドキュメント + +```go +for i := 1; i <= 5; i++ { + page := doc.AddPage() + page.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { + c.Text("ページコンテンツ") + }) + }) +} +``` + ### 既存PDFオーバーレイ 既存のPDFを開いて、同じビルダーAPIでコンテンツを重ねて配置: @@ -425,6 +439,22 @@ doc.EachPage(func(i int, p *template.PageBuilder) { result, _ := doc.Save() ``` +### フォームフラット化 + +インタラクティブなAcroFormフィールドを静的ページコンテンツにフラット化。リンクやコメントなどの非ウィジェット注釈は保持されます: + +```go +// フォームフィールドを含むPDFを開く +doc, err := gpdf.Open(filledFormPDF) + +// すべてのフォームフィールドを静的コンテンツにフラット化 +if err := doc.FlattenForms(); err != nil { + log.Fatal(err) +} + +result, _ := doc.Save() +``` + ### PDFマージ 複数のPDFをページ範囲指定付きで1つのドキュメントに結合: @@ -551,6 +581,65 @@ doc := template.Letter(template.LetterData{ }) ``` +### 暗号化 + +AES-256暗号化、オーナー/ユーザーパスワードと権限制御: + +```go +// オーナーパスワードのみ(パスワードなしでPDFを開けるが、編集は制限) +doc := gpdf.NewDocument( + gpdf.WithPageSize(gpdf.A4), + gpdf.WithEncryption( + encrypt.WithOwnerPassword("owner-secret"), + ), +) + +// 両方のパスワードと権限制御 +doc := gpdf.NewDocument( + gpdf.WithPageSize(gpdf.A4), + gpdf.WithEncryption( + encrypt.WithOwnerPassword("owner-pass"), + encrypt.WithUserPassword("user-pass"), + encrypt.WithPermissions(encrypt.PermPrint|encrypt.PermCopy), + ), +) +``` + +### PDF/A準拠 + +PDF/A-1bまたはPDF/A-2b準拠ドキュメントの生成: + +```go +doc := gpdf.NewDocument( + gpdf.WithPageSize(gpdf.A4), + gpdf.WithPDFA( + pdfa.WithLevel(pdfa.LevelA2b), + pdfa.WithMetadata(pdfa.MetadataInfo{ + Title: "アーカイブレポート", + Author: "gpdf", + }), + ), +) +``` + +### デジタル署名 + +RSAまたはECDSA鍵によるCMS/PKCS#7署名: + +```go +data, _ := doc.Generate() + +signed, err := gpdf.SignDocument(data, signature.Signer{ + Certificate: cert, + PrivateKey: key, + Chain: intermediates, +}, + signature.WithReason("承認済み"), + signature.WithLocation("東京"), + signature.WithTimestamp("http://tsa.example.com"), +) +``` + ### ドキュメントメタデータ ```go @@ -613,6 +702,8 @@ doc.Render(f) | `WithFont(family, data)` | TrueTypeフォントを登録 | | `WithDefaultFont(family, size)` | デフォルトフォントを設定 | | `WithMetadata(meta)` | ドキュメントメタデータを設定 | +| `WithEncryption(opts...)` | AES-256暗号化を有効化 | +| `WithPDFA(opts...)` | PDF/A準拠を有効化 | ### カラムコンテンツ @@ -654,6 +745,7 @@ doc.Render(f) | `doc.PageCount()` | ページ数を取得 | | `doc.Overlay(page, fn)` | 特定ページにコンテンツを重ねて配置 | | `doc.EachPage(fn)` | 全ページにオーバーレイを適用 | +| `doc.FlattenForms()` | AcroFormフィールドを静的ページコンテンツにフラット化 | | `doc.Save()` | 変更したPDFを保存 | | `gpdf.Merge(sources, opts...)` | 複数のPDFを1つに結合 | | `WithMergeMetadata(title, author, producer)` | 結合後のメタデータを設定 | @@ -708,6 +800,31 @@ doc.Render(f) | `template.BarcodeHeight(value)` | バーコードの高さを設定 | | `template.BarcodeFormat(fmt)` | バーコードフォーマットを設定 (Code 128) | +### 暗号化オプション + +| オプション | 説明 | +|---|---| +| `encrypt.WithOwnerPassword(pw)` | オーナーパスワードを設定 | +| `encrypt.WithUserPassword(pw)` | ユーザーパスワードを設定 | +| `encrypt.WithPermissions(perm)` | ドキュメント権限を設定 (PermPrint, PermCopy, PermModify等) | + +### PDF/Aオプション + +| オプション | 説明 | +|---|---| +| `pdfa.WithLevel(level)` | 準拠レベルを設定 (LevelA1b, LevelA2b) | +| `pdfa.WithMetadata(info)` | XMPメタデータを設定 (Title, Author, Subject等) | + +### デジタル署名 + +| 関数 / オプション | 説明 | +|---|---| +| `gpdf.SignDocument(data, signer, opts...)` | デジタル署名でPDFに署名 | +| `signature.WithReason(reason)` | 署名理由を設定 | +| `signature.WithLocation(location)` | 署名場所を設定 | +| `signature.WithTimestamp(tsaURL)` | RFC 3161タイムスタンプを有効化 | +| `signature.WithSignTime(t)` | 署名時刻を設定 | + ### テンプレート生成 | 関数 | 説明 | diff --git a/README_ko.md b/README_ko.md index 2f60d40..32e792c 100644 --- a/README_ko.md +++ b/README_ko.md @@ -32,6 +32,7 @@ - **이미지** — JPEG 및 PNG 임베딩 (맞춤 옵션 지원) - **절대 위치 지정** — 페이지의 정확한 XY 좌표에 요소 배치 - **기존 PDF 오버레이** — 기존 PDF를 열어 텍스트, 이미지, 스탬프를 위에 추가 +- **폼 플래트닝** — AcroForm 필드를 정적 페이지 콘텐츠로 변환, 비위젯 주석은 보존 - **PDF 병합** — 여러 PDF를 페이지 범위 선택으로 하나로 결합 - **문서 메타데이터** — 제목, 저자, 주제, 작성자 - **암호화** — AES-256 암호화 (ISO 32000-2, Rev 6), 소유자/사용자 비밀번호 및 권한 제어 @@ -297,6 +298,80 @@ c.Line(template.LineThickness(document.Pt(3))) // 굵은 선 c.Spacer(document.Mm(5)) // 5mm 세로 간격 ``` +### 리스트 + +글머리 기호 목록 및 번호 목록: + +```go +// 글머리 기호 목록 +c.List([]string{"첫 번째 항목", "두 번째 항목", "세 번째 항목"}) + +// 번호 목록 +c.OrderedList([]string{"단계 1", "단계 2", "단계 3"}) +``` + +### QR 코드 + +크기와 오류 정정 레벨을 설정할 수 있는 QR 코드 생성: + +```go +// 기본 QR 코드 +c.QRCode("https://gpdf.dev") + +// 크기와 오류 정정 레벨 지정 +c.QRCode("https://gpdf.dev", + template.QRSize(document.Mm(30)), + template.QRErrorCorrection(qrcode.LevelH)) +``` + +### 바코드 + +Code 128 바코드 생성: + +```go +// 기본 바코드 +c.Barcode("INV-2026-0001") + +// 너비 지정 +c.Barcode("INV-2026-0001", template.BarcodeWidth(document.Mm(80))) +``` + +### 페이지 번호 + +자동 페이지 번호 및 전체 페이지 수: + +```go +doc.Footer(func(p *template.PageBuilder) { + p.AutoRow(func(r *template.RowBuilder) { + r.Col(6, func(c *template.ColBuilder) { + c.Text("gpdf로 생성", template.FontSize(8)) + }) + r.Col(6, func(c *template.ColBuilder) { + c.PageNumber(template.AlignRight(), template.FontSize(8)) + }) + }) +}) + +doc.Header(func(p *template.PageBuilder) { + p.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { + c.TotalPages(template.AlignRight(), template.FontSize(9)) + }) + }) +}) +``` + +### 텍스트 장식 + +밑줄, 취소선, 자간, 들여쓰기: + +```go +c.Text("밑줄 텍스트", template.Underline()) +c.Text("취소선 텍스트", template.Strikethrough()) +c.Text("넓은 자간", template.LetterSpacing(3)) +c.Text("들여쓰기 단락...", template.TextIndent(document.Pt(24))) +``` + ### 머리글 및 바닥글 모든 페이지에 반복 표시되는 머리글과 바닥글: @@ -324,6 +399,19 @@ doc.Footer(func(p *template.PageBuilder) { }) ``` +### 다중 페이지 문서 + +```go +for i := 1; i <= 5; i++ { + page := doc.AddPage() + page.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { + c.Text("페이지 콘텐츠") + }) + }) +} +``` + ### 재사용 가능 컴포넌트 함수 하나로 일반적인 문서 유형을 생성할 수 있습니다: @@ -389,6 +477,65 @@ doc := template.Letter(template.LetterData{ }) ``` +### 암호화 + +AES-256 암호화, 소유자/사용자 비밀번호 및 권한 제어: + +```go +// 소유자 비밀번호만 (비밀번호 없이 PDF를 열 수 있지만 편집은 제한) +doc := gpdf.NewDocument( + gpdf.WithPageSize(gpdf.A4), + gpdf.WithEncryption( + encrypt.WithOwnerPassword("owner-secret"), + ), +) + +// 양쪽 비밀번호와 권한 제어 +doc := gpdf.NewDocument( + gpdf.WithPageSize(gpdf.A4), + gpdf.WithEncryption( + encrypt.WithOwnerPassword("owner-pass"), + encrypt.WithUserPassword("user-pass"), + encrypt.WithPermissions(encrypt.PermPrint|encrypt.PermCopy), + ), +) +``` + +### PDF/A 준수 + +PDF/A-1b 또는 PDF/A-2b 준수 문서 생성: + +```go +doc := gpdf.NewDocument( + gpdf.WithPageSize(gpdf.A4), + gpdf.WithPDFA( + pdfa.WithLevel(pdfa.LevelA2b), + pdfa.WithMetadata(pdfa.MetadataInfo{ + Title: "아카이브 보고서", + Author: "gpdf", + }), + ), +) +``` + +### 디지털 서명 + +RSA 또는 ECDSA 키로 CMS/PKCS#7 서명: + +```go +data, _ := doc.Generate() + +signed, err := gpdf.SignDocument(data, signature.Signer{ + Certificate: cert, + PrivateKey: key, + Chain: intermediates, +}, + signature.WithReason("승인됨"), + signature.WithLocation("서울"), + signature.WithTimestamp("http://tsa.example.com"), +) +``` + ### 기존 PDF 오버레이 기존 PDF를 열어 동일한 빌더 API로 콘텐츠를 오버레이: @@ -416,6 +563,74 @@ doc.EachPage(func(i int, p *template.PageBuilder) { result, _ := doc.Save() ``` +### JSON 스키마 + +JSON으로만 문서를 정의: + +```go +schema := []byte(`{ + "page": {"size": "A4", "margins": "20mm"}, + "metadata": {"title": "보고서", "author": "gpdf"}, + "body": [ + {"row": {"cols": [ + {"span": 12, "text": "JSON에서 안녕하세요", "style": {"size": 24, "bold": true}} + ]}}, + {"row": {"cols": [ + {"span": 12, "table": { + "header": ["이름", "값"], + "rows": [["Alpha", "100"], ["Beta", "200"]], + "headerStyle": {"bold": true, "color": "white", "background": "#1A237E"} + }} + ]}} + ] +}`) + +doc, err := template.FromJSON(schema, nil) +data, _ := doc.Generate() +``` + +### Go 템플릿 통합 + +Go 템플릿과 JSON 스키마로 동적 콘텐츠 생성: + +```go +schema := []byte(`{ + "page": {"size": "A4", "margins": "20mm"}, + "metadata": {"title": "{{.Title}}"}, + "body": [ + {"row": {"cols": [ + {"span": 12, "text": "{{.Title}}", "style": {"size": 24, "bold": true}} + ]}} + ] +}`) + +data := map[string]any{"Title": "동적 보고서"} +doc, err := template.FromJSON(schema, data) +``` + +사전 파싱된 Go 템플릿으로 더 세밀한 제어: + +```go +tmpl, _ := gotemplate.New("doc").Funcs(template.TemplateFuncMap()).Parse(schemaStr) +doc, err := template.FromTemplate(tmpl, data) +``` + +### 폼 플래트닝 + +인터랙티브 AcroForm 필드를 정적 페이지 콘텐츠로 플래트닝. 링크, 댓글 등 비위젯 주석은 보존됩니다: + +```go +// 폼 필드가 있는 PDF 열기 +doc, err := gpdf.Open(filledFormPDF) + +// 모든 폼 필드를 정적 콘텐츠로 플래트닝 +if err := doc.FlattenForms(); err != nil { + log.Fatal(err) +} + +result, _ := doc.Save() +``` + ### PDF 병합 여러 PDF를 페이지 범위 선택으로 하나의 문서로 결합: @@ -494,6 +709,8 @@ doc.Render(f) | `WithFont(family, data)` | TrueType 폰트 등록 | | `WithDefaultFont(family, size)` | 기본 폰트 설정 | | `WithMetadata(meta)` | 문서 메타데이터 설정 | +| `WithEncryption(opts...)` | AES-256 암호화 활성화 | +| `WithPDFA(opts...)` | PDF/A 준수 활성화 | ### 컬럼 콘텐츠 @@ -535,6 +752,7 @@ doc.Render(f) | `doc.PageCount()` | 페이지 수 가져오기 | | `doc.Overlay(page, fn)` | 특정 페이지에 콘텐츠 오버레이 | | `doc.EachPage(fn)` | 모든 페이지에 오버레이 적용 | +| `doc.FlattenForms()` | AcroForm 필드를 정적 페이지 콘텐츠로 플래트닝 | | `doc.Save()` | 수정된 PDF 저장 | | `gpdf.Merge(sources, opts...)` | 여러 PDF를 하나로 병합 | | `WithMergeMetadata(title, author, producer)` | 병합된 출력의 메타데이터 설정 | @@ -589,6 +807,31 @@ doc.Render(f) | `template.BarcodeHeight(value)` | 바코드 높이 설정 | | `template.BarcodeFormat(fmt)` | 바코드 형식 설정 (Code 128) | +### 암호화 옵션 + +| 옵션 | 설명 | +|---|---| +| `encrypt.WithOwnerPassword(pw)` | 소유자 비밀번호 설정 | +| `encrypt.WithUserPassword(pw)` | 사용자 비밀번호 설정 | +| `encrypt.WithPermissions(perm)` | 문서 권한 설정 (PermPrint, PermCopy, PermModify 등) | + +### PDF/A 옵션 + +| 옵션 | 설명 | +|---|---| +| `pdfa.WithLevel(level)` | 준수 레벨 설정 (LevelA1b, LevelA2b) | +| `pdfa.WithMetadata(info)` | XMP 메타데이터 설정 (Title, Author, Subject 등) | + +### 디지털 서명 + +| 함수 / 옵션 | 설명 | +|---|---| +| `gpdf.SignDocument(data, signer, opts...)` | 디지털 서명으로 PDF 서명 | +| `signature.WithReason(reason)` | 서명 사유 설정 | +| `signature.WithLocation(location)` | 서명 위치 설정 | +| `signature.WithTimestamp(tsaURL)` | RFC 3161 타임스탬프 활성화 | +| `signature.WithSignTime(t)` | 서명 시각 설정 | + ### 템플릿 생성 | 함수 | 설명 | diff --git a/README_pt.md b/README_pt.md index ead2f0f..f7a3aab 100644 --- a/README_pt.md +++ b/README_pt.md @@ -32,6 +32,7 @@ Biblioteca de geração de PDF em Go puro, sem dependências externas, com arqui - **Imagens** — incorporação de JPEG e PNG com opções de ajuste - **Posicionamento absoluto** — posicionar elementos em coordenadas XY exatas na página - **Sobreposição de PDF existente** — abrir PDFs existentes e adicionar texto, imagens, carimbos por cima +- **Achatamento de formulários** — achatar campos AcroForm em conteúdo de página estático, preservando anotações que não são widgets - **Fusão de PDF** — combinar múltiplos PDFs em um com seleção de intervalo de páginas - **Metadados do documento** — título, autor, assunto, criador - **Criptografia** — criptografia AES-256 (ISO 32000-2, Rev 6) com senhas de proprietário/usuário e permissões @@ -297,6 +298,80 @@ c.Line(template.LineThickness(document.Pt(3))) // Grossa c.Spacer(document.Mm(5)) // Espaço vertical de 5mm ``` +### Listas + +Listas com marcadores e numeradas: + +```go +// Lista com marcadores +c.List([]string{"Primeiro item", "Segundo item", "Terceiro item"}) + +// Lista numerada +c.OrderedList([]string{"Passo um", "Passo dois", "Passo três"}) +``` + +### QR Codes + +Gerar QR codes com tamanho e nível de correção configuráveis: + +```go +// QR code básico +c.QRCode("https://gpdf.dev") + +// Tamanho e nível de correção personalizados +c.QRCode("https://gpdf.dev", + template.QRSize(document.Mm(30)), + template.QRErrorCorrection(qrcode.LevelH)) +``` + +### Códigos de barras + +Geração de Code 128: + +```go +// Código de barras básico +c.Barcode("INV-2026-0001") + +// Largura personalizada +c.Barcode("INV-2026-0001", template.BarcodeWidth(document.Mm(80))) +``` + +### Números de página + +Número de página automático e total de páginas: + +```go +doc.Footer(func(p *template.PageBuilder) { + p.AutoRow(func(r *template.RowBuilder) { + r.Col(6, func(c *template.ColBuilder) { + c.Text("Gerado com gpdf", template.FontSize(8)) + }) + r.Col(6, func(c *template.ColBuilder) { + c.PageNumber(template.AlignRight(), template.FontSize(8)) + }) + }) +}) + +doc.Header(func(p *template.PageBuilder) { + p.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { + c.TotalPages(template.AlignRight(), template.FontSize(9)) + }) + }) +}) +``` + +### Decorações de texto + +Sublinhado, tachado, espaçamento de letras e recuo: + +```go +c.Text("Texto sublinhado", template.Underline()) +c.Text("Texto tachado", template.Strikethrough()) +c.Text("Espaçamento amplo", template.LetterSpacing(3)) +c.Text("Parágrafo com recuo...", template.TextIndent(document.Pt(24))) +``` + ### Cabeçalhos e rodapés Defina cabeçalhos e rodapés que se repetem em cada página: @@ -324,6 +399,19 @@ doc.Footer(func(p *template.PageBuilder) { }) ``` +### Documentos de múltiplas páginas + +```go +for i := 1; i <= 5; i++ { + page := doc.AddPage() + page.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { + c.Text("Conteúdo da página") + }) + }) +} +``` + ### Componentes reutilizáveis Gere tipos de documentos comuns com uma única chamada de função: @@ -389,6 +477,117 @@ doc := template.Letter(template.LetterData{ }) ``` +### Criptografia + +Criptografia AES-256 com senhas de proprietário/usuário e controle de permissões: + +```go +// Apenas senha de proprietário (PDF abre sem senha, edição restrita) +doc := gpdf.NewDocument( + gpdf.WithPageSize(gpdf.A4), + gpdf.WithEncryption( + encrypt.WithOwnerPassword("owner-secret"), + ), +) + +// Ambas as senhas com controle de permissões +doc := gpdf.NewDocument( + gpdf.WithPageSize(gpdf.A4), + gpdf.WithEncryption( + encrypt.WithOwnerPassword("owner-pass"), + encrypt.WithUserPassword("user-pass"), + encrypt.WithPermissions(encrypt.PermPrint|encrypt.PermCopy), + ), +) +``` + +### Conformidade PDF/A + +Gerar documentos conformes a PDF/A-1b ou PDF/A-2b: + +```go +doc := gpdf.NewDocument( + gpdf.WithPageSize(gpdf.A4), + gpdf.WithPDFA( + pdfa.WithLevel(pdfa.LevelA2b), + pdfa.WithMetadata(pdfa.MetadataInfo{ + Title: "Relatório Arquivado", + Author: "gpdf", + }), + ), +) +``` + +### Assinaturas digitais + +Assinaturas CMS/PKCS#7 com chaves RSA ou ECDSA: + +```go +data, _ := doc.Generate() + +signed, err := gpdf.SignDocument(data, signature.Signer{ + Certificate: cert, + PrivateKey: key, + Chain: intermediates, +}, + signature.WithReason("Aprovado"), + signature.WithLocation("São Paulo"), + signature.WithTimestamp("http://tsa.example.com"), +) +``` + +### Esquema JSON + +Defina documentos inteiramente em JSON: + +```go +schema := []byte(`{ + "page": {"size": "A4", "margins": "20mm"}, + "metadata": {"title": "Relatório", "author": "gpdf"}, + "body": [ + {"row": {"cols": [ + {"span": 12, "text": "Olá do JSON", "style": {"size": 24, "bold": true}} + ]}}, + {"row": {"cols": [ + {"span": 12, "table": { + "header": ["Nome", "Valor"], + "rows": [["Alpha", "100"], ["Beta", "200"]], + "headerStyle": {"bold": true, "color": "white", "background": "#1A237E"} + }} + ]}} + ] +}`) + +doc, err := template.FromJSON(schema, nil) +data, _ := doc.Generate() +``` + +### Integração com Go Templates + +Use templates Go com esquemas JSON para conteúdo dinâmico: + +```go +schema := []byte(`{ + "page": {"size": "A4", "margins": "20mm"}, + "metadata": {"title": "{{.Title}}"}, + "body": [ + {"row": {"cols": [ + {"span": 12, "text": "{{.Title}}", "style": {"size": 24, "bold": true}} + ]}} + ] +}`) + +data := map[string]any{"Title": "Relatório Dinâmico"} +doc, err := template.FromJSON(schema, data) +``` + +Para mais controle, use um template Go pré-parseado: + +```go +tmpl, _ := gotemplate.New("doc").Funcs(template.TemplateFuncMap()).Parse(schemaStr) +doc, err := template.FromTemplate(tmpl, data) +``` + ### Metadados do documento ```go @@ -467,6 +666,22 @@ doc.EachPage(func(i int, p *template.PageBuilder) { result, _ := doc.Save() ``` +### Achatamento de formulários + +Achatar campos AcroForm interativos em conteúdo de página estático. Anotações que não são widgets (links, comentários) são preservadas: + +```go +// Abrir um PDF com campos de formulário +doc, err := gpdf.Open(filledFormPDF) + +// Achatar todos os campos de formulário em conteúdo estático +if err := doc.FlattenForms(); err != nil { + log.Fatal(err) +} + +result, _ := doc.Save() +``` + ### Fusão de PDF Combine múltiplos PDFs em um único documento com seleção opcional de intervalo de páginas: @@ -494,6 +709,8 @@ merged, _ := gpdf.Merge( | `WithFont(family, data)` | Registrar uma fonte TrueType | | `WithDefaultFont(family, size)` | Definir a fonte padrão | | `WithMetadata(meta)` | Definir metadados do documento | +| `WithEncryption(opts...)` | Habilitar criptografia AES-256 | +| `WithPDFA(opts...)` | Habilitar conformidade PDF/A | ### Conteúdo da coluna @@ -535,6 +752,7 @@ merged, _ := gpdf.Merge( | `doc.PageCount()` | Obter o número de páginas | | `doc.Overlay(page, fn)` | Sobrepor conteúdo em uma página específica | | `doc.EachPage(fn)` | Aplicar sobreposição em todas as páginas | +| `doc.FlattenForms()` | Achatar campos AcroForm em conteúdo de página estático | | `doc.Save()` | Salvar o PDF modificado | | `gpdf.Merge(sources, opts...)` | Fundir múltiplos PDFs em um | | `WithMergeMetadata(title, author, producer)` | Definir metadados na saída fundida | @@ -589,6 +807,31 @@ merged, _ := gpdf.Merge( | `template.BarcodeHeight(value)` | Altura do código de barras | | `template.BarcodeFormat(fmt)` | Formato do código de barras (Code 128) | +### Opções de criptografia + +| Opção | Descrição | +|---|---| +| `encrypt.WithOwnerPassword(pw)` | Definir senha de proprietário | +| `encrypt.WithUserPassword(pw)` | Definir senha de usuário | +| `encrypt.WithPermissions(perm)` | Definir permissões do documento (PermPrint, PermCopy, PermModify, etc.) | + +### Opções de PDF/A + +| Opção | Descrição | +|---|---| +| `pdfa.WithLevel(level)` | Definir nível de conformidade (LevelA1b, LevelA2b) | +| `pdfa.WithMetadata(info)` | Definir metadados XMP (Title, Author, Subject, etc.) | + +### Assinatura digital + +| Função / Opção | Descrição | +|---|---| +| `gpdf.SignDocument(data, signer, opts...)` | Assinar um PDF com assinatura digital | +| `signature.WithReason(reason)` | Definir razão da assinatura | +| `signature.WithLocation(location)` | Definir localização da assinatura | +| `signature.WithTimestamp(tsaURL)` | Habilitar carimbo de tempo RFC 3161 | +| `signature.WithSignTime(t)` | Definir hora da assinatura | + ### Geração de templates | Função | Descrição | @@ -597,6 +840,14 @@ merged, _ := gpdf.Merge( | `template.FromTemplate(tmpl, data)` | Gerar documento a partir de template Go | | `template.TemplateFuncMap()` | Obter funções auxiliares de template (inclui `toJSON`) | +### Componentes reutilizáveis + +| Função | Descrição | +|---|---| +| `template.Invoice(data)` | Gerar uma fatura PDF profissional | +| `template.Report(data)` | Gerar um relatório PDF estruturado | +| `template.Letter(data)` | Gerar uma carta comercial PDF | + ### Opções de linha | Opção | Descrição | diff --git a/README_zh.md b/README_zh.md index cc2f9fd..0285f3d 100644 --- a/README_zh.md +++ b/README_zh.md @@ -32,6 +32,7 @@ - **图片** — JPEG 和 PNG 嵌入(支持缩放选项) - **绝对定位** — 在页面上以精确 XY 坐标放置元素 - **现有 PDF 叠加** — 打开现有 PDF 并在上面添加文字、图片、印章 +- **表单扁平化** — 将 AcroForm 字段扁平化为静态页面内容,保留非控件注释 - **PDF 合并** — 将多个 PDF 合并为一个,支持页面范围选择 - **文档元数据** — 标题、作者、主题、创建者 - **加密** — AES-256 加密(ISO 32000-2, Rev 6),支持所有者/用户密码和权限控制 @@ -297,6 +298,80 @@ c.Line(template.LineThickness(document.Pt(3))) // 粗线 c.Spacer(document.Mm(5)) // 5mm 垂直间距 ``` +### 列表 + +无序列表和有序列表: + +```go +// 无序列表 +c.List([]string{"第一项", "第二项", "第三项"}) + +// 有序列表 +c.OrderedList([]string{"步骤一", "步骤二", "步骤三"}) +``` + +### 二维码 + +可配置大小和纠错等级的二维码生成: + +```go +// 基本二维码 +c.QRCode("https://gpdf.dev") + +// 自定义大小和纠错等级 +c.QRCode("https://gpdf.dev", + template.QRSize(document.Mm(30)), + template.QRErrorCorrection(qrcode.LevelH)) +``` + +### 条形码 + +Code 128 条形码生成: + +```go +// 基本条形码 +c.Barcode("INV-2026-0001") + +// 自定义宽度 +c.Barcode("INV-2026-0001", template.BarcodeWidth(document.Mm(80))) +``` + +### 页码 + +自动页码和总页数: + +```go +doc.Footer(func(p *template.PageBuilder) { + p.AutoRow(func(r *template.RowBuilder) { + r.Col(6, func(c *template.ColBuilder) { + c.Text("由 gpdf 生成", template.FontSize(8)) + }) + r.Col(6, func(c *template.ColBuilder) { + c.PageNumber(template.AlignRight(), template.FontSize(8)) + }) + }) +}) + +doc.Header(func(p *template.PageBuilder) { + p.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { + c.TotalPages(template.AlignRight(), template.FontSize(9)) + }) + }) +}) +``` + +### 文本装饰 + +下划线、删除线、字间距和首行缩进: + +```go +c.Text("下划线文本", template.Underline()) +c.Text("删除线文本", template.Strikethrough()) +c.Text("宽字间距", template.LetterSpacing(3)) +c.Text("首行缩进段落...", template.TextIndent(document.Pt(24))) +``` + ### 页眉和页脚 定义在每一页重复显示的页眉和页脚: @@ -324,6 +399,19 @@ doc.Footer(func(p *template.PageBuilder) { }) ``` +### 多页文档 + +```go +for i := 1; i <= 5; i++ { + page := doc.AddPage() + page.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { + c.Text("页面内容") + }) + }) +} +``` + ### 可复用组件 一个函数调用即可生成常见文档类型: @@ -389,6 +477,65 @@ doc := template.Letter(template.LetterData{ }) ``` +### 加密 + +AES-256 加密,支持所有者/用户密码和权限控制: + +```go +// 仅所有者密码(无需密码即可打开 PDF,但编辑受限) +doc := gpdf.NewDocument( + gpdf.WithPageSize(gpdf.A4), + gpdf.WithEncryption( + encrypt.WithOwnerPassword("owner-secret"), + ), +) + +// 双密码和权限控制 +doc := gpdf.NewDocument( + gpdf.WithPageSize(gpdf.A4), + gpdf.WithEncryption( + encrypt.WithOwnerPassword("owner-pass"), + encrypt.WithUserPassword("user-pass"), + encrypt.WithPermissions(encrypt.PermPrint|encrypt.PermCopy), + ), +) +``` + +### PDF/A 合规 + +生成 PDF/A-1b 或 PDF/A-2b 合规文档: + +```go +doc := gpdf.NewDocument( + gpdf.WithPageSize(gpdf.A4), + gpdf.WithPDFA( + pdfa.WithLevel(pdfa.LevelA2b), + pdfa.WithMetadata(pdfa.MetadataInfo{ + Title: "归档报告", + Author: "gpdf", + }), + ), +) +``` + +### 数字签名 + +使用 RSA 或 ECDSA 密钥的 CMS/PKCS#7 签名: + +```go +data, _ := doc.Generate() + +signed, err := gpdf.SignDocument(data, signature.Signer{ + Certificate: cert, + PrivateKey: key, + Chain: intermediates, +}, + signature.WithReason("已批准"), + signature.WithLocation("北京"), + signature.WithTimestamp("http://tsa.example.com"), +) +``` + ### 现有 PDF 叠加 打开现有 PDF,使用同一构建器 API 叠加内容: @@ -416,6 +563,22 @@ doc.EachPage(func(i int, p *template.PageBuilder) { result, _ := doc.Save() ``` +### 表单扁平化 + +将交互式 AcroForm 字段扁平化为静态页面内容。链接、批注等非控件注释会被保留: + +```go +// 打开包含表单字段的 PDF +doc, err := gpdf.Open(filledFormPDF) + +// 将所有表单字段扁平化为静态内容 +if err := doc.FlattenForms(); err != nil { + log.Fatal(err) +} + +result, _ := doc.Save() +``` + ### PDF 合并 将多个 PDF 合并为一个文档,支持页面范围选择: @@ -432,6 +595,58 @@ merged, _ := gpdf.Merge( ) ``` +### JSON 模式 + +完全用 JSON 定义文档: + +```go +schema := []byte(`{ + "page": {"size": "A4", "margins": "20mm"}, + "metadata": {"title": "报告", "author": "gpdf"}, + "body": [ + {"row": {"cols": [ + {"span": 12, "text": "来自 JSON 的问候", "style": {"size": 24, "bold": true}} + ]}}, + {"row": {"cols": [ + {"span": 12, "table": { + "header": ["名称", "值"], + "rows": [["Alpha", "100"], ["Beta", "200"]], + "headerStyle": {"bold": true, "color": "white", "background": "#1A237E"} + }} + ]}} + ] +}`) + +doc, err := template.FromJSON(schema, nil) +data, _ := doc.Generate() +``` + +### Go 模板集成 + +使用 Go 模板和 JSON 模式生成动态内容: + +```go +schema := []byte(`{ + "page": {"size": "A4", "margins": "20mm"}, + "metadata": {"title": "{{.Title}}"}, + "body": [ + {"row": {"cols": [ + {"span": 12, "text": "{{.Title}}", "style": {"size": 24, "bold": true}} + ]}} + ] +}`) + +data := map[string]any{"Title": "动态报告"} +doc, err := template.FromJSON(schema, data) +``` + +使用预解析的 Go 模板实现更多控制: + +```go +tmpl, _ := gotemplate.New("doc").Funcs(template.TemplateFuncMap()).Parse(schemaStr) +doc, err := template.FromTemplate(tmpl, data) +``` + ### 文档元数据 ```go @@ -494,6 +709,8 @@ doc.Render(f) | `WithFont(family, data)` | 注册 TrueType 字体 | | `WithDefaultFont(family, size)` | 设置默认字体 | | `WithMetadata(meta)` | 设置文档元数据 | +| `WithEncryption(opts...)` | 启用 AES-256 加密 | +| `WithPDFA(opts...)` | 启用 PDF/A 合规 | ### 列内容 @@ -535,6 +752,7 @@ doc.Render(f) | `doc.PageCount()` | 获取页数 | | `doc.Overlay(page, fn)` | 在指定页上叠加内容 | | `doc.EachPage(fn)` | 对每页应用叠加 | +| `doc.FlattenForms()` | 将 AcroForm 字段扁平化为静态页面内容 | | `doc.Save()` | 保存修改后的 PDF | | `gpdf.Merge(sources, opts...)` | 将多个 PDF 合并为一个 | | `WithMergeMetadata(title, author, producer)` | 设置合并后的元数据 | @@ -589,6 +807,31 @@ doc.Render(f) | `template.BarcodeHeight(value)` | 设置条形码高度 | | `template.BarcodeFormat(fmt)` | 设置条形码格式(Code 128) | +### 加密选项 + +| 选项 | 说明 | +|---|---| +| `encrypt.WithOwnerPassword(pw)` | 设置所有者密码 | +| `encrypt.WithUserPassword(pw)` | 设置用户密码 | +| `encrypt.WithPermissions(perm)` | 设置文档权限 (PermPrint, PermCopy, PermModify 等) | + +### PDF/A 选项 + +| 选项 | 说明 | +|---|---| +| `pdfa.WithLevel(level)` | 设置合规级别 (LevelA1b, LevelA2b) | +| `pdfa.WithMetadata(info)` | 设置 XMP 元数据 (Title, Author, Subject 等) | + +### 数字签名 + +| 函数 / 选项 | 说明 | +|---|---| +| `gpdf.SignDocument(data, signer, opts...)` | 使用数字签名签署 PDF | +| `signature.WithReason(reason)` | 设置签名原因 | +| `signature.WithLocation(location)` | 设置签名位置 | +| `signature.WithTimestamp(tsaURL)` | 启用 RFC 3161 时间戳 | +| `signature.WithSignTime(t)` | 设置签名时间 | + ### 模板生成 | 函数 | 说明 | diff --git a/_benchmark/go.mod b/_benchmark/go.mod index b493340..1aa3a38 100644 --- a/_benchmark/go.mod +++ b/_benchmark/go.mod @@ -5,24 +5,24 @@ go 1.24 replace github.com/gpdf-dev/gpdf => ../ require ( - github.com/go-pdf/fpdf v1.0.3 + github.com/go-pdf/fpdf v1.0.4 github.com/johnfercher/maroto/v2 v2.3.3 - github.com/gpdf-dev/gpdf v1.0.3 + github.com/gpdf-dev/gpdf v1.0.4 github.com/signintech/gopdf v0.36.0 golang.org/x/image v0.18.0 ) require ( - github.com/boombuler/barcode v1.0.3 // indirect + github.com/boombuler/barcode v1.0.4 // indirect github.com/f-amaral/go-async v0.3.0 // indirect github.com/google/uuid v1.5.0 // indirect - github.com/hhrutter/lzw v1.0.3 // indirect - github.com/hhrutter/tiff v1.0.3 // indirect + github.com/hhrutter/lzw v1.0.4 // indirect + github.com/hhrutter/tiff v1.0.4 // indirect github.com/johnfercher/go-tree v1.0.5 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/pdfcpu/pdfcpu v0.6.0 // indirect github.com/phpdave11/gofpdf v1.4.3 // indirect - github.com/phpdave11/gofpdi v1.0.35 // indirect + github.com/phpdave11/gofpdi v1.0.45 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.4 // indirect golang.org/x/text v0.22.0 // indirect diff --git a/_benchmark/go.sum b/_benchmark/go.sum index b2e8c36..49a71ab 100644 --- a/_benchmark/go.sum +++ b/_benchmark/go.sum @@ -1,38 +1,38 @@ -github.com/boombuler/barcode v1.0.3/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= -github.com/boombuler/barcode v1.0.3 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= -github.com/boombuler/barcode v1.0.3/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.0.4/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.0.4 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= +github.com/boombuler/barcode v1.0.4/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/f-amaral/go-async v0.3.0 h1:h4kLsX7aKfdWaHvV0lf+/EE3OIeCzyeDYJDb/vDZUyg= github.com/f-amaral/go-async v0.3.0/go.mod h1:Hz5Qr6DAWpbTTUjytnrg1WIsDgS7NtOei5y8SipYS7U= -github.com/go-pdf/fpdf v1.0.3 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw= -github.com/go-pdf/fpdf v1.0.3/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y= +github.com/go-pdf/fpdf v1.0.4 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw= +github.com/go-pdf/fpdf v1.0.4/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hhrutter/lzw v1.0.3 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= -github.com/hhrutter/lzw v1.0.3/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= -github.com/hhrutter/tiff v1.0.3 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0= -github.com/hhrutter/tiff v1.0.3/go.mod h1:zU/dNgDm0cMIa8y8YwcYBeuEEveI4B0owqHyiPpJPHc= +github.com/hhrutter/lzw v1.0.4 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= +github.com/hhrutter/lzw v1.0.4/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= +github.com/hhrutter/tiff v1.0.4 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0= +github.com/hhrutter/tiff v1.0.4/go.mod h1:zU/dNgDm0cMIa8y8YwcYBeuEEveI4B0owqHyiPpJPHc= github.com/johnfercher/go-tree v1.0.5 h1:zpgVhJsChavzhKdxhQiCJJzcSY3VCT9oal2JoA2ZevY= github.com/johnfercher/go-tree v1.0.5/go.mod h1:DUO6QkXIFh1K7jeGBIkLCZaeUgnkdQAsB64FDSoHswg= github.com/johnfercher/maroto/v2 v2.3.3 h1:oeXsBnoecaMgRDwN0Cstjoe4rug3lKpOanuxuHKPqQE= github.com/johnfercher/maroto/v2 v2.3.3/go.mod h1:KNv102TwUrlVgZGukzlIbhkG6l/WaCD6pzu6aWGVjBI= -github.com/jung-kurt/gofpdf v1.0.3/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/jung-kurt/gofpdf v1.0.4/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/pdfcpu/pdfcpu v0.6.0 h1:z4kARP5bcWa39TTYMcN/kjBnm7MvhTWjXgeYmkdAGMI= github.com/pdfcpu/pdfcpu v0.6.0/go.mod h1:kmpD0rk8YnZj0l3qSeGBlAB+XszHUgNv//ORH/E7EYo= github.com/phpdave11/gofpdf v1.4.3 h1:M/zHvS8FO3zh9tUd2RCOPEjyuVcs281FCyF22Qlz/IA= github.com/phpdave11/gofpdf v1.4.3/go.mod h1:MAwzoUIgD3J55u0rxIG2eu37c+XWhBtXSpPAhnQXf/o= -github.com/phpdave11/gofpdi v1.0.34-0.20211212211723-1f10f9844311/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= -github.com/phpdave11/gofpdi v1.0.35 h1:iJazY1BQ07I9s7N5EWjBO1YbhmKfHGxNligUv/Rw4Lc= -github.com/phpdave11/gofpdi v1.0.35/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/phpdave11/gofpdi v1.0.44-0.20211212211723-1f10f9844311/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/phpdave11/gofpdi v1.0.45 h1:iJazY1BQ07I9s7N5EWjBO1YbhmKfHGxNligUv/Rw4Lc= +github.com/phpdave11/gofpdi v1.0.45/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.3 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.3/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.4 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.4/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= diff --git a/_examples/flatten/01_basic_flatten_test.go b/_examples/flatten/01_basic_flatten_test.go new file mode 100644 index 0000000..e6c2465 --- /dev/null +++ b/_examples/flatten/01_basic_flatten_test.go @@ -0,0 +1,163 @@ +package flatten_test + +import ( + "bytes" + "os" + "testing" + + gpdf "github.com/gpdf-dev/gpdf" + "github.com/gpdf-dev/gpdf/_examples/testutil" + "github.com/gpdf-dev/gpdf/pdf" +) + +// buildFormPDF creates a PDF with a text field and a checkbox for flatten testing. +func buildFormPDF(t *testing.T) []byte { + t.Helper() + var buf bytes.Buffer + w := pdf.NewWriter(&buf) + w.SetCompression(false) + + contentRef := w.AllocObject() + if err := w.WriteObject(contentRef, pdf.Stream{ + Dict: pdf.Dict{}, + Content: []byte("BT /F1 12 Tf 50 780 Td (Form Flatten Example) Tj ET"), + }); err != nil { + t.Fatal(err) + } + + apRef := w.AllocObject() + if err := w.WriteObject(apRef, pdf.Stream{ + Dict: pdf.Dict{ + pdf.Name("Type"): pdf.Name("XObject"), + pdf.Name("Subtype"): pdf.Name("Form"), + pdf.Name("BBox"): pdf.Array{pdf.Real(0), pdf.Real(0), pdf.Real(200), pdf.Real(20)}, + pdf.Name("Resources"): pdf.Dict{}, + }, + Content: []byte("BT /Helv 12 Tf 2 5 Td (John Doe) Tj ET"), + }); err != nil { + t.Fatal(err) + } + + textFieldRef := w.AllocObject() + if err := w.WriteObject(textFieldRef, pdf.Dict{ + pdf.Name("Type"): pdf.Name("Annot"), + pdf.Name("Subtype"): pdf.Name("Widget"), + pdf.Name("FT"): pdf.Name("Tx"), + pdf.Name("T"): pdf.LiteralString("Name"), + pdf.Name("V"): pdf.LiteralString("John Doe"), + pdf.Name("Rect"): pdf.Array{pdf.Real(100), pdf.Real(700), pdf.Real(300), pdf.Real(720)}, + pdf.Name("AP"): pdf.Dict{pdf.Name("N"): apRef}, + }); err != nil { + t.Fatal(err) + } + + yesRef := w.AllocObject() + if err := w.WriteObject(yesRef, pdf.Stream{ + Dict: pdf.Dict{ + pdf.Name("Type"): pdf.Name("XObject"), + pdf.Name("Subtype"): pdf.Name("Form"), + pdf.Name("BBox"): pdf.Array{pdf.Real(0), pdf.Real(0), pdf.Real(14), pdf.Real(14)}, + pdf.Name("Resources"): pdf.Dict{}, + }, + Content: []byte("0 0 14 14 re S 2 2 m 12 12 l S 12 2 m 2 12 l S"), + }); err != nil { + t.Fatal(err) + } + + offRef := w.AllocObject() + if err := w.WriteObject(offRef, pdf.Stream{ + Dict: pdf.Dict{ + pdf.Name("Type"): pdf.Name("XObject"), + pdf.Name("Subtype"): pdf.Name("Form"), + pdf.Name("BBox"): pdf.Array{pdf.Real(0), pdf.Real(0), pdf.Real(14), pdf.Real(14)}, + pdf.Name("Resources"): pdf.Dict{}, + }, + Content: []byte("0 0 14 14 re S"), + }); err != nil { + t.Fatal(err) + } + + checkboxRef := w.AllocObject() + if err := w.WriteObject(checkboxRef, pdf.Dict{ + pdf.Name("Type"): pdf.Name("Annot"), + pdf.Name("Subtype"): pdf.Name("Widget"), + pdf.Name("FT"): pdf.Name("Btn"), + pdf.Name("T"): pdf.LiteralString("Agree"), + pdf.Name("V"): pdf.Name("Yes"), + pdf.Name("AS"): pdf.Name("Yes"), + pdf.Name("Rect"): pdf.Array{pdf.Real(100), pdf.Real(650), pdf.Real(114), pdf.Real(664)}, + pdf.Name("AP"): pdf.Dict{ + pdf.Name("N"): pdf.Dict{ + pdf.Name("Yes"): yesRef, + pdf.Name("Off"): offRef, + }, + }, + }); err != nil { + t.Fatal(err) + } + + w.AddCatalogEntry(pdf.Name("AcroForm"), pdf.Dict{ + pdf.Name("Fields"): pdf.Array{textFieldRef, checkboxRef}, + }) + + pageRef := w.AllocObject() + if err := w.WriteObject(pageRef, pdf.Dict{ + pdf.Name("Type"): pdf.Name("Page"), + pdf.Name("Parent"): w.PageTreeRef(), + pdf.Name("MediaBox"): pdf.Array{pdf.Real(0), pdf.Real(0), pdf.Real(595), pdf.Real(842)}, + pdf.Name("Contents"): contentRef, + pdf.Name("Resources"): pdf.Dict{}, + pdf.Name("Annots"): pdf.Array{textFieldRef, checkboxRef}, + }); err != nil { + t.Fatal(err) + } + w.AddRawPage(pageRef) + + if err := w.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func TestExample_Flatten_01_BasicFlatten(t *testing.T) { + source, err := os.ReadFile(testdataDir + "/01_basic_flatten_before.pdf") + if err != nil { + t.Fatalf("read testdata: %v", err) + } + + doc, err := gpdf.Open(source) + if err != nil { + t.Fatalf("gpdf.Open: %v", err) + } + if err := doc.FlattenForms(); err != nil { + t.Fatalf("FlattenForms: %v", err) + } + + result, err := doc.Save() + if err != nil { + t.Fatalf("Save: %v", err) + } + + testutil.AssertValidPDF(t, result) + testutil.WritePDF(t, "01_basic_flatten_after.pdf", result) + + // Verify: AcroForm removed, annotations removed. + r, err := pdf.NewReader(result) + if err != nil { + t.Fatalf("re-read: %v", err) + } + catalog, err := r.ResolveDict(r.RootRef()) + if err != nil { + t.Fatalf("resolve catalog: %v", err) + } + if _, ok := catalog[pdf.Name("AcroForm")]; ok { + t.Error("AcroForm should be removed after flattening") + } + pageDict, err := r.PageDict(0) + if err != nil { + t.Fatalf("PageDict: %v", err) + } + if _, ok := pageDict[pdf.Name("Annots")]; ok { + t.Error("Annots should be removed after flattening") + } +} diff --git a/_examples/flatten/02_preserve_non_widget_test.go b/_examples/flatten/02_preserve_non_widget_test.go new file mode 100644 index 0000000..25b5404 --- /dev/null +++ b/_examples/flatten/02_preserve_non_widget_test.go @@ -0,0 +1,144 @@ +package flatten_test + +import ( + "bytes" + "os" + "testing" + + gpdf "github.com/gpdf-dev/gpdf" + "github.com/gpdf-dev/gpdf/_examples/testutil" + "github.com/gpdf-dev/gpdf/pdf" +) + +// buildMixedAnnotPDF creates a PDF with both a widget and a link annotation. +func buildMixedAnnotPDF(t *testing.T) []byte { + t.Helper() + var buf bytes.Buffer + w := pdf.NewWriter(&buf) + w.SetCompression(false) + + contentRef := w.AllocObject() + if err := w.WriteObject(contentRef, pdf.Stream{ + Dict: pdf.Dict{}, + Content: []byte("BT /F1 12 Tf 50 780 Td (Mixed Annotations) Tj ET"), + }); err != nil { + t.Fatal(err) + } + + apRef := w.AllocObject() + if err := w.WriteObject(apRef, pdf.Stream{ + Dict: pdf.Dict{ + pdf.Name("Type"): pdf.Name("XObject"), + pdf.Name("Subtype"): pdf.Name("Form"), + pdf.Name("BBox"): pdf.Array{pdf.Real(0), pdf.Real(0), pdf.Real(150), pdf.Real(20)}, + pdf.Name("Resources"): pdf.Dict{}, + }, + Content: []byte("BT /Helv 10 Tf 2 5 Td (form field) Tj ET"), + }); err != nil { + t.Fatal(err) + } + + widgetRef := w.AllocObject() + if err := w.WriteObject(widgetRef, pdf.Dict{ + pdf.Name("Type"): pdf.Name("Annot"), + pdf.Name("Subtype"): pdf.Name("Widget"), + pdf.Name("FT"): pdf.Name("Tx"), + pdf.Name("T"): pdf.LiteralString("Field1"), + pdf.Name("Rect"): pdf.Array{pdf.Real(100), pdf.Real(700), pdf.Real(250), pdf.Real(720)}, + pdf.Name("AP"): pdf.Dict{pdf.Name("N"): apRef}, + }); err != nil { + t.Fatal(err) + } + + linkRef := w.AllocObject() + if err := w.WriteObject(linkRef, pdf.Dict{ + pdf.Name("Type"): pdf.Name("Annot"), + pdf.Name("Subtype"): pdf.Name("Link"), + pdf.Name("Rect"): pdf.Array{pdf.Real(50), pdf.Real(400), pdf.Real(200), pdf.Real(420)}, + pdf.Name("A"): pdf.Dict{ + pdf.Name("Type"): pdf.Name("Action"), + pdf.Name("S"): pdf.Name("URI"), + pdf.Name("URI"): pdf.LiteralString("https://github.com/gpdf-dev/gpdf"), + }, + }); err != nil { + t.Fatal(err) + } + + w.AddCatalogEntry(pdf.Name("AcroForm"), pdf.Dict{ + pdf.Name("Fields"): pdf.Array{widgetRef}, + }) + + pageRef := w.AllocObject() + if err := w.WriteObject(pageRef, pdf.Dict{ + pdf.Name("Type"): pdf.Name("Page"), + pdf.Name("Parent"): w.PageTreeRef(), + pdf.Name("MediaBox"): pdf.Array{pdf.Real(0), pdf.Real(0), pdf.Real(595), pdf.Real(842)}, + pdf.Name("Contents"): contentRef, + pdf.Name("Resources"): pdf.Dict{}, + pdf.Name("Annots"): pdf.Array{widgetRef, linkRef}, + }); err != nil { + t.Fatal(err) + } + w.AddRawPage(pageRef) + + if err := w.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func TestExample_Flatten_02_PreserveNonWidget(t *testing.T) { + source, err := os.ReadFile("testdata/02_preserve_non_widget_before.pdf") + if err != nil { + t.Fatalf("read testdata: %v", err) + } + + doc, err := gpdf.Open(source) + if err != nil { + t.Fatalf("gpdf.Open: %v", err) + } + if err := doc.FlattenForms(); err != nil { + t.Fatalf("FlattenForms: %v", err) + } + + result, err := doc.Save() + if err != nil { + t.Fatalf("Save: %v", err) + } + + testutil.AssertValidPDF(t, result) + testutil.WritePDF(t, "02_preserve_non_widget_after.pdf", result) + + // Verify link annotation is preserved. + r, err := pdf.NewReader(result) + if err != nil { + t.Fatalf("re-read: %v", err) + } + pageDict, err := r.PageDict(0) + if err != nil { + t.Fatalf("PageDict: %v", err) + } + + annotsObj, ok := pageDict[pdf.Name("Annots")] + if !ok { + t.Fatal("Annots should still exist (link annotation preserved)") + } + resolved, err := r.Resolve(annotsObj) + if err != nil { + t.Fatalf("resolve annots: %v", err) + } + arr, ok := resolved.(pdf.Array) + if !ok { + t.Fatal("Annots should be an array") + } + if len(arr) != 1 { + t.Errorf("Annots length = %d, want 1", len(arr)) + } + annotDict, err := r.ResolveDict(arr[0]) + if err != nil { + t.Fatalf("resolve annot: %v", err) + } + if subtype, _ := annotDict[pdf.Name("Subtype")].(pdf.Name); string(subtype) != "Link" { + t.Errorf("remaining annotation subtype = %q, want Link", subtype) + } +} diff --git a/_examples/flatten/03_no_forms_test.go b/_examples/flatten/03_no_forms_test.go new file mode 100644 index 0000000..dfc91c9 --- /dev/null +++ b/_examples/flatten/03_no_forms_test.go @@ -0,0 +1,47 @@ +package flatten_test + +import ( + "testing" + + gpdf "github.com/gpdf-dev/gpdf" + "github.com/gpdf-dev/gpdf/_examples/testutil" + "github.com/gpdf-dev/gpdf/document" + "github.com/gpdf-dev/gpdf/template" +) + +func TestExample_Flatten_03_NoForms(t *testing.T) { + // Flattening a PDF with no forms should be a safe no-op. + doc := template.New( + template.WithPageSize(document.A4), + template.WithMargins(document.UniformEdges(document.Mm(20))), + ) + page := doc.AddPage() + page.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { + c.Text("This PDF has no forms.", template.FontSize(16), template.Bold()) + }) + }) + + source, err := doc.Generate() + if err != nil { + t.Fatalf("Generate: %v", err) + } + + existing, err := gpdf.Open(source) + if err != nil { + t.Fatalf("gpdf.Open: %v", err) + } + + // Should succeed without error (no-op). + if err := existing.FlattenForms(); err != nil { + t.Fatalf("FlattenForms: %v", err) + } + + result, err := existing.Save() + if err != nil { + t.Fatalf("Save: %v", err) + } + + testutil.AssertValidPDF(t, result) + testutil.WritePDF(t, "03_no_forms.pdf", result) +} diff --git a/_examples/flatten/04_filled_form_test.go b/_examples/flatten/04_filled_form_test.go new file mode 100644 index 0000000..06131f6 --- /dev/null +++ b/_examples/flatten/04_filled_form_test.go @@ -0,0 +1,240 @@ +package flatten_test + +import ( + "bytes" + "fmt" + "testing" + + gpdf "github.com/gpdf-dev/gpdf" + "github.com/gpdf-dev/gpdf/_examples/testutil" + "github.com/gpdf-dev/gpdf/pdf" +) + +// escapePDFString escapes special characters for PDF literal strings. +func escapePDFString(s string) string { + var out []byte + for _, ch := range []byte(s) { + switch ch { + case '(', ')', '\\': + out = append(out, '\\', ch) + default: + out = append(out, ch) + } + } + return string(out) +} + +// formField describes a form field to add to the test PDF. +type formField struct { + Name string + Type string // "Tx", "Btn" + Value string // text value or "Yes"/"Off" for checkboxes + X, Y float64 + W, H float64 + LabelX float64 +} + +// buildFilledFormPDF creates a PDF with multiple filled form fields. +func buildFilledFormPDF(t *testing.T) []byte { + t.Helper() + var buf bytes.Buffer + w := pdf.NewWriter(&buf) + w.SetCompression(false) + + fields := []formField{ + {Name: "Given Name", Type: "Tx", Value: "Taro", X: 200, Y: 700, W: 200, H: 20, LabelX: 50}, + {Name: "Family Name", Type: "Tx", Value: "Yamada", X: 200, Y: 665, W: 200, H: 20, LabelX: 50}, + {Name: "Address", Type: "Tx", Value: "1-2-3 Shibuya, Shibuya-ku", X: 200, Y: 630, W: 300, H: 20, LabelX: 50}, + {Name: "Postcode", Type: "Tx", Value: "150-0002", X: 200, Y: 595, W: 120, H: 20, LabelX: 50}, + {Name: "City", Type: "Tx", Value: "Tokyo", X: 380, Y: 595, W: 120, H: 20, LabelX: 330}, + {Name: "Country", Type: "Tx", Value: "Japan", X: 200, Y: 560, W: 200, H: 20, LabelX: 50}, + {Name: "Height", Type: "Tx", Value: "170", X: 200, Y: 525, W: 100, H: 20, LabelX: 50}, + {Name: "Shoe Size", Type: "Tx", Value: "26", X: 200, Y: 490, W: 100, H: 20, LabelX: 50}, + {Name: "Driving License", Type: "Btn", Value: "Yes", X: 200, Y: 450, W: 14, H: 14, LabelX: 50}, + {Name: "Language 1", Type: "Btn", Value: "Yes", X: 200, Y: 420, W: 14, H: 14, LabelX: 50}, + {Name: "Language 2", Type: "Btn", Value: "Off", X: 300, Y: 420, W: 14, H: 14, LabelX: 230}, + {Name: "Language 3", Type: "Btn", Value: "Yes", X: 400, Y: 420, W: 14, H: 14, LabelX: 330}, + } + + // Page labels. + var content bytes.Buffer + content.WriteString("BT /F1 16 Tf 50 770 Td (Form Flatten Test - Filled Fields) Tj ET\n") + for _, f := range fields { + label := f.Name + ":" + if f.Type == "Btn" { + label = f.Name + } + fmt.Fprintf(&content, "BT /F1 10 Tf %.1f %.1f Td (%s) Tj ET\n", f.LabelX, f.Y+4, label) + } + + contentRef := w.AllocObject() + if err := w.WriteObject(contentRef, pdf.Stream{ + Dict: pdf.Dict{}, + Content: content.Bytes(), + }); err != nil { + t.Fatal(err) + } + + // Form fields. + var annotRefs pdf.Array + var fieldRefs pdf.Array + + for _, f := range fields { + bbox := pdf.Array{pdf.Real(0), pdf.Real(0), pdf.Real(f.W), pdf.Real(f.H)} + rect := pdf.Array{pdf.Real(f.X), pdf.Real(f.Y), pdf.Real(f.X + f.W), pdf.Real(f.Y + f.H)} + + annotRef := w.AllocObject() + annotDict := pdf.Dict{ + pdf.Name("Type"): pdf.Name("Annot"), + pdf.Name("Subtype"): pdf.Name("Widget"), + pdf.Name("FT"): pdf.Name(f.Type), + pdf.Name("T"): pdf.LiteralString(f.Name), + pdf.Name("Rect"): rect, + } + + switch f.Type { + case "Tx": + annotDict[pdf.Name("V")] = pdf.LiteralString(f.Value) + + apRef := w.AllocObject() + apContent := fmt.Sprintf("/Tx BMC\nq\n0.95 0.95 0.95 rg\n0 0 %.1f %.1f re f\n0 0 0 rg\nBT /F1 10 Tf 2 5 Td (%s) Tj ET\nQ\nEMC", + f.W, f.H, escapePDFString(f.Value)) + if err := w.WriteObject(apRef, pdf.Stream{ + Dict: pdf.Dict{ + pdf.Name("Type"): pdf.Name("XObject"), + pdf.Name("Subtype"): pdf.Name("Form"), + pdf.Name("BBox"): bbox, + }, + Content: []byte(apContent), + }); err != nil { + t.Fatal(err) + } + annotDict[pdf.Name("AP")] = pdf.Dict{pdf.Name("N"): apRef} + + case "Btn": + isChecked := f.Value == "Yes" + if isChecked { + annotDict[pdf.Name("V")] = pdf.Name("Yes") + annotDict[pdf.Name("AS")] = pdf.Name("Yes") + } else { + annotDict[pdf.Name("V")] = pdf.Name("Off") + annotDict[pdf.Name("AS")] = pdf.Name("Off") + } + + yesRef := w.AllocObject() + yesContent := fmt.Sprintf("q\n0 0 %.1f %.1f re S\n2 2 m %.1f %.1f l S\n%.1f 2 m 2 %.1f l S\nQ", + f.W, f.H, f.W-2, f.H-2, f.W-2, f.H-2) + if err := w.WriteObject(yesRef, pdf.Stream{ + Dict: pdf.Dict{ + pdf.Name("Type"): pdf.Name("XObject"), + pdf.Name("Subtype"): pdf.Name("Form"), + pdf.Name("BBox"): bbox, + }, + Content: []byte(yesContent), + }); err != nil { + t.Fatal(err) + } + + offRef := w.AllocObject() + offContent := fmt.Sprintf("q\n0 0 %.1f %.1f re S\nQ", f.W, f.H) + if err := w.WriteObject(offRef, pdf.Stream{ + Dict: pdf.Dict{ + pdf.Name("Type"): pdf.Name("XObject"), + pdf.Name("Subtype"): pdf.Name("Form"), + pdf.Name("BBox"): bbox, + }, + Content: []byte(offContent), + }); err != nil { + t.Fatal(err) + } + + annotDict[pdf.Name("AP")] = pdf.Dict{ + pdf.Name("N"): pdf.Dict{ + pdf.Name("Yes"): yesRef, + pdf.Name("Off"): offRef, + }, + } + } + + if err := w.WriteObject(annotRef, annotDict); err != nil { + t.Fatal(err) + } + annotRefs = append(annotRefs, annotRef) + fieldRefs = append(fieldRefs, annotRef) + } + + w.AddCatalogEntry(pdf.Name("AcroForm"), pdf.Dict{ + pdf.Name("Fields"): fieldRefs, + }) + + pageRef := w.AllocObject() + if err := w.WriteObject(pageRef, pdf.Dict{ + pdf.Name("Type"): pdf.Name("Page"), + pdf.Name("Parent"): w.PageTreeRef(), + pdf.Name("MediaBox"): pdf.Array{pdf.Real(0), pdf.Real(0), pdf.Real(595), pdf.Real(842)}, + pdf.Name("Contents"): contentRef, + pdf.Name("Resources"): pdf.Dict{}, + pdf.Name("Annots"): annotRefs, + }); err != nil { + t.Fatal(err) + } + w.AddRawPage(pageRef) + + if err := w.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func TestExample_Flatten_04_FilledForm(t *testing.T) { + source := buildFilledFormPDF(t) + + doc, err := gpdf.Open(source) + if err != nil { + t.Fatalf("gpdf.Open: %v", err) + } + if err := doc.FlattenForms(); err != nil { + t.Fatalf("FlattenForms: %v", err) + } + + result, err := doc.Save() + if err != nil { + t.Fatalf("Save: %v", err) + } + + testutil.AssertValidPDF(t, result) + testutil.WritePDF(t, "04_filled_form_after.pdf", result) + + // Verify flattening. + r, err := pdf.NewReader(result) + if err != nil { + t.Fatalf("re-read: %v", err) + } + catalog, err := r.ResolveDict(r.RootRef()) + if err != nil { + t.Fatalf("resolve catalog: %v", err) + } + if _, ok := catalog[pdf.Name("AcroForm")]; ok { + t.Error("AcroForm should be removed after flattening") + } + pageDict, err := r.PageDict(0) + if err != nil { + t.Fatalf("PageDict: %v", err) + } + if _, ok := pageDict[pdf.Name("Annots")]; ok { + t.Error("Annots should be removed after flattening") + } + + // Verify XObject resources exist for flattened fields. + res, err := r.ResolveDict(pageDict[pdf.Name("Resources")]) + if err != nil { + t.Fatalf("resolve resources: %v", err) + } + xobjDict, err := r.ResolveDict(res[pdf.Name("XObject")]) + if err != nil { + t.Fatalf("resolve XObject: %v", err) + } + if len(xobjDict) < 12 { + t.Errorf("expected at least 12 XObjects, got %d", len(xobjDict)) + } +} diff --git a/_examples/flatten/generate_testdata_test.go b/_examples/flatten/generate_testdata_test.go new file mode 100644 index 0000000..63b0542 --- /dev/null +++ b/_examples/flatten/generate_testdata_test.go @@ -0,0 +1,44 @@ +package flatten_test + +import ( + "os" + "testing" +) + +// TestGenerateTestdata regenerates the testdata PDF files. +// Run with: go test -run TestGenerateTestdata -update-testdata +// +// This is not run in normal test execution. +func TestGenerateTestdata(t *testing.T) { + if os.Getenv("UPDATE_TESTDATA") == "" { + t.Skip("set UPDATE_TESTDATA=1 to regenerate testdata") + } + + if err := os.MkdirAll("testdata", 0755); err != nil { + t.Fatal(err) + } + + t.Run("01_basic_flatten_before", func(t *testing.T) { + data := buildFormPDF(t) + if err := os.WriteFile("testdata/01_basic_flatten_before.pdf", data, 0644); err != nil { + t.Fatal(err) + } + t.Logf("wrote testdata/01_basic_flatten_before.pdf (%d bytes)", len(data)) + }) + + t.Run("02_preserve_non_widget_before", func(t *testing.T) { + data := buildMixedAnnotPDF(t) + if err := os.WriteFile("testdata/02_preserve_non_widget_before.pdf", data, 0644); err != nil { + t.Fatal(err) + } + t.Logf("wrote testdata/02_preserve_non_widget_before.pdf (%d bytes)", len(data)) + }) + + t.Run("04_filled_form_before", func(t *testing.T) { + data := buildFilledFormPDF(t) + if err := os.WriteFile("testdata/04_filled_form_before.pdf", data, 0644); err != nil { + t.Fatal(err) + } + t.Logf("wrote testdata/04_filled_form_before.pdf (%d bytes)", len(data)) + }) +} diff --git a/_examples/flatten/helpers_test.go b/_examples/flatten/helpers_test.go new file mode 100644 index 0000000..c83b4e2 --- /dev/null +++ b/_examples/flatten/helpers_test.go @@ -0,0 +1,3 @@ +package flatten_test + +const testdataDir = "testdata" diff --git a/_examples/flatten/testdata/01_basic_flatten_before.pdf b/_examples/flatten/testdata/01_basic_flatten_before.pdf new file mode 100644 index 0000000..96c0559 Binary files /dev/null and b/_examples/flatten/testdata/01_basic_flatten_before.pdf differ diff --git a/_examples/flatten/testdata/02_preserve_non_widget_before.pdf b/_examples/flatten/testdata/02_preserve_non_widget_before.pdf new file mode 100644 index 0000000..c429cca Binary files /dev/null and b/_examples/flatten/testdata/02_preserve_non_widget_before.pdf differ diff --git a/_examples/flatten/testdata/04_filled_form_before.pdf b/_examples/flatten/testdata/04_filled_form_before.pdf new file mode 100644 index 0000000..0aca46e Binary files /dev/null and b/_examples/flatten/testdata/04_filled_form_before.pdf differ diff --git a/_validation/go.mod b/_validation/go.mod index 272c2e8..047b9fb 100644 --- a/_validation/go.mod +++ b/_validation/go.mod @@ -10,8 +10,8 @@ require ( ) require ( - github.com/hhrutter/lzw v1.0.3 // indirect - github.com/hhrutter/tiff v1.0.3 // indirect + github.com/hhrutter/lzw v1.0.4 // indirect + github.com/hhrutter/tiff v1.0.4 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect diff --git a/_validation/go.sum b/_validation/go.sum index 604f923..0f6841e 100644 --- a/_validation/go.sum +++ b/_validation/go.sum @@ -1,7 +1,7 @@ -github.com/hhrutter/lzw v1.0.3 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= -github.com/hhrutter/lzw v1.0.3/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= -github.com/hhrutter/tiff v1.0.3 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0= -github.com/hhrutter/tiff v1.0.3/go.mod h1:zU/dNgDm0cMIa8y8YwcYBeuEEveI4B0owqHyiPpJPHc= +github.com/hhrutter/lzw v1.0.4 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= +github.com/hhrutter/lzw v1.0.4/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= +github.com/hhrutter/tiff v1.0.4 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0= +github.com/hhrutter/tiff v1.0.4/go.mod h1:zU/dNgDm0cMIa8y8YwcYBeuEEveI4B0owqHyiPpJPHc= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/pdfcpu/pdfcpu v0.9.1 h1:q8/KlBdHjkE7ZJU4ofhKG5Rjf7M6L324CVM6BMDySao= diff --git a/internal/buildinfo/version.go b/internal/buildinfo/version.go index 9925f24..2d2139a 100644 --- a/internal/buildinfo/version.go +++ b/internal/buildinfo/version.go @@ -3,4 +3,4 @@ package buildinfo // Version is the library version. It is the single source of truth used by // the public gpdf.Version constant and the default PDF Producer metadata. -const Version = "1.0.3" +const Version = "1.0.4" diff --git a/pdf/flatten.go b/pdf/flatten.go new file mode 100644 index 0000000..f665256 --- /dev/null +++ b/pdf/flatten.go @@ -0,0 +1,377 @@ +package pdf + +import ( + "fmt" +) + +// FlattenForms flattens AcroForm fields into page content streams, +// making form data part of the static page content and removing +// all interactive form elements. +// +// For each widget annotation that has an appearance stream (/AP), +// the appearance is rendered onto the page at the annotation's /Rect +// position. The annotation is then removed from the page's /Annots +// array, and the /AcroForm entry is removed from the document catalog. +// +// Returns nil if the document has no AcroForm. +func (m *Modifier) FlattenForms() error { + r := m.reader + + catalog, err := r.ResolveDict(r.RootRef()) + if err != nil { + return fmt.Errorf("pdf: flatten: resolve catalog: %w", err) + } + + // Check for AcroForm. + acroFormObj, ok := catalog[Name("AcroForm")] + if !ok { + return nil // no forms + } + _, err = r.ResolveDict(acroFormObj) + if err != nil { + return nil // cannot resolve, treat as no forms + } + + // Process each page. + pageCount, err := r.PageCount() + if err != nil { + return fmt.Errorf("pdf: flatten: %w", err) + } + + for i := range pageCount { + if err := m.flattenPageAnnotations(i); err != nil { + return fmt.Errorf("pdf: flatten page %d: %w", i, err) + } + } + + // Remove /AcroForm from catalog. + newCatalog := make(Dict, len(catalog)) + for k, v := range catalog { + if k != Name("AcroForm") { + newCatalog[k] = v + } + } + m.SetObject(r.RootRef(), newCatalog) + + return nil +} + +// flattenPageAnnotations processes widget annotations on a single page. +func (m *Modifier) flattenPageAnnotations(pageIndex int) error { + r := m.reader + + info, err := r.Page(pageIndex) + if err != nil { + return err + } + + pageDict, err := r.ResolveDict(info.Ref) + if err != nil { + return err + } + + annotsObj, ok := pageDict[Name("Annots")] + if !ok { + return nil // no annotations on this page + } + + annotsResolved, err := r.Resolve(annotsObj) + if err != nil { + return err + } + annotsArr, ok := annotsResolved.(Array) + if !ok { + return nil + } + + var overlayContent []byte + overlayResources := Dict{ + Name("XObject"): Dict{}, + } + xobjDict := overlayResources[Name("XObject")].(Dict) + var remainingAnnots Array + xobjIndex := 0 + + for _, annotObj := range annotsArr { + ops, kept := m.flattenAnnotation(annotObj, xobjDict, &xobjIndex) + if kept { + remainingAnnots = append(remainingAnnots, annotObj) + } + overlayContent = append(overlayContent, ops...) + } + + if len(overlayContent) == 0 && len(remainingAnnots) == len(annotsArr) { + return nil // nothing changed + } + + // Update page dict. + newPageDict := make(Dict, len(pageDict)) + for k, v := range pageDict { + newPageDict[k] = v + } + + // Update or remove /Annots. + if len(remainingAnnots) == 0 { + delete(newPageDict, Name("Annots")) + } else { + newPageDict[Name("Annots")] = remainingAnnots + } + + // Overlay the flattened content onto the page. + if len(overlayContent) > 0 { + m.overlayFlattenedContent(info, newPageDict, overlayContent, &overlayResources) + } else { + m.SetObject(info.Ref, newPageDict) + } + + return nil +} + +// flattenAnnotation processes a single annotation for flattening. +// It returns the content stream operators (nil if none) and whether the annotation should be kept. +func (m *Modifier) flattenAnnotation(annotObj Object, xobjDict Dict, xobjIndex *int) (ops []byte, keep bool) { + annotDict, err := m.reader.ResolveDict(annotObj) + if err != nil { + return nil, true + } + + // Non-widget annotations are preserved. + if !m.isWidgetAnnotation(annotDict) { + return nil, true + } + + // Get appearance stream. + apStream, err := m.resolveAppearanceStream(annotDict) + if err != nil || apStream == nil { + // No appearance stream — just remove the annotation. + return nil, false + } + + // Get annotation rect. + rect, err := m.resolveAnnotRect(annotDict) + if err != nil { + return nil, true + } + + // Register the appearance stream as an XObject. + xobjName := Name(fmt.Sprintf("_Flat%d", *xobjIndex)) + *xobjIndex++ + + xobjRef := m.AllocObject() + formXObj := m.buildFormXObject(apStream, rect) + m.SetObject(xobjRef, formXObj) + xobjDict[xobjName] = xobjRef + + // Generate content stream operators to draw the XObject. + rectW := rect.URX - rect.LLX + rectH := rect.URY - rect.LLY + if rectW <= 0 || rectH <= 0 { + return nil, false + } + + // Determine scale from BBox to Rect. + sx, sy := 1.0, 1.0 + bbox := m.resolveFormBBox(formXObj) + bboxW := bbox.URX - bbox.LLX + bboxH := bbox.URY - bbox.LLY + if bboxW > 0 && bboxH > 0 { + sx = rectW / bboxW + sy = rectH / bboxH + } + + // Translate to rect origin, offset by BBox origin, scale to fit. + content := fmt.Sprintf("q %.4f 0 0 %.4f %.4f %.4f cm /%s Do Q\n", + sx, sy, + rect.LLX-bbox.LLX*sx, rect.LLY-bbox.LLY*sy, + string(xobjName)) + return []byte(content), false +} + +// isWidgetAnnotation checks if an annotation dict is a widget (form field). +func (m *Modifier) isWidgetAnnotation(d Dict) bool { + subtypeObj, ok := d[Name("Subtype")] + if ok { + if subtype, ok := subtypeObj.(Name); ok { + return string(subtype) == "Widget" + } + } + // Some form fields don't have /Subtype but have /FT (field type). + _, hasFT := d[Name("FT")] + return hasFT +} + +// resolveAppearanceStream gets the normal appearance stream for an annotation. +func (m *Modifier) resolveAppearanceStream(annotDict Dict) (*Stream, error) { + r := m.reader + + apObj, ok := annotDict[Name("AP")] + if !ok { + return nil, nil + } + + apDict, err := r.ResolveDict(apObj) + if err != nil { + return nil, err + } + + // Get /N (normal appearance). It can be a stream directly or a dict of states. + nObj, ok := apDict[Name("N")] + if !ok { + return nil, nil + } + + resolved, err := r.Resolve(nObj) + if err != nil { + return nil, err + } + + switch v := resolved.(type) { + case Stream: + return &v, nil + case Dict: + // It's a dict of appearance states. Use /AS to select the right one. + asObj, ok := annotDict[Name("AS")] + if !ok { + // Try "Yes" as common default for checkboxes. + if yesObj, ok := v[Name("Yes")]; ok { + resolved2, err := r.Resolve(yesObj) + if err != nil { + return nil, err + } + if s, ok := resolved2.(Stream); ok { + return &s, nil + } + } + return nil, nil + } + asName, ok := asObj.(Name) + if !ok { + return nil, nil + } + stateObj, ok := v[asName] + if !ok { + return nil, nil + } + resolved2, err := r.Resolve(stateObj) + if err != nil { + return nil, err + } + if s, ok := resolved2.(Stream); ok { + return &s, nil + } + return nil, nil + default: + return nil, nil + } +} + +// resolveAnnotRect extracts the /Rect from an annotation dict. +func (m *Modifier) resolveAnnotRect(annotDict Dict) (Rectangle, error) { + rectObj, ok := annotDict[Name("Rect")] + if !ok { + return Rectangle{}, fmt.Errorf("annotation missing /Rect") + } + return m.reader.parseRectangle(rectObj) +} + +// buildFormXObject creates a Form XObject stream from an appearance stream. +// If the appearance stream already has /Subtype /Form and a /BBox, it is +// used directly. Otherwise, a proper Form XObject wrapper is created. +func (m *Modifier) buildFormXObject(ap *Stream, rect Rectangle) Stream { + content := ap.Content + + // Decompress content if needed. + decoded, err := m.reader.decodeStreamContent(*ap) + if err == nil { + content = decoded + } + + // Build the Form XObject dict. + formDict := Dict{ + Name("Type"): Name("XObject"), + Name("Subtype"): Name("Form"), + Name("BBox"): Array{Real(0), Real(0), Real(rect.URX - rect.LLX), Real(rect.URY - rect.LLY)}, + } + + // Copy /Matrix if present. + if matrix, ok := ap.Dict[Name("Matrix")]; ok { + formDict[Name("Matrix")] = matrix + } + + // Use the original BBox if present. + if bbox, ok := ap.Dict[Name("BBox")]; ok { + formDict[Name("BBox")] = bbox + } + + // Copy /Resources from the appearance stream. + if res, ok := ap.Dict[Name("Resources")]; ok { + formDict[Name("Resources")] = res + } + + return Stream{ + Dict: formDict, + Content: content, + } +} + +// resolveFormBBox extracts the BBox rectangle from a Form XObject stream. +func (m *Modifier) resolveFormBBox(s Stream) Rectangle { + bboxObj, ok := s.Dict[Name("BBox")] + if !ok { + return Rectangle{} + } + r, err := m.reader.parseRectangle(bboxObj) + if err != nil { + return Rectangle{} + } + return r +} + +// overlayFlattenedContent merges flattened content onto a page, +// similar to OverlayPage but operating on an already-modified page dict. +func (m *Modifier) overlayFlattenedContent(info PageInfo, pageDict Dict, content []byte, resources *Dict) { + // Allocate streams. + qRef := m.AllocObject() + bigQRef := m.AllocObject() + contentRef := m.AllocObject() + + m.SetObject(qRef, Stream{ + Dict: Dict{}, + Content: []byte("q\n"), + }) + m.SetObject(bigQRef, Stream{ + Dict: Dict{}, + Content: []byte("\nQ\n"), + }) + m.SetObject(contentRef, Stream{ + Dict: Dict{}, + Content: content, + }) + + // Build new /Contents array. + var contentRefs Array + contentRefs = append(contentRefs, qRef) + + if origContents, ok := pageDict[Name("Contents")]; ok { + switch v := origContents.(type) { + case ObjectRef: + contentRefs = append(contentRefs, v) + case Array: + contentRefs = append(contentRefs, v...) + } + } + contentRefs = append(contentRefs, bigQRef, contentRef) + pageDict[Name("Contents")] = contentRefs + + // Merge resources. + if resources != nil { + existingRes, _ := m.reader.ResolveDict(pageDict[Name("Resources")]) + if existingRes == nil { + existingRes = make(Dict) + } + merged := mergeResources(existingRes, *resources) + pageDict[Name("Resources")] = merged + } + + m.SetObject(info.Ref, pageDict) +} diff --git a/pdf/flatten_test.go b/pdf/flatten_test.go new file mode 100644 index 0000000..a450cee --- /dev/null +++ b/pdf/flatten_test.go @@ -0,0 +1,539 @@ +package pdf + +import ( + "bytes" + "strings" + "testing" +) + +// buildTestPDFWithForm creates a simple PDF with an AcroForm text field. +func buildTestPDFWithForm(t *testing.T) []byte { + t.Helper() + var buf bytes.Buffer + w := NewWriter(&buf) + w.SetCompression(false) + + // Create page content stream. + contentRef := w.AllocObject() + content := Stream{ + Dict: Dict{}, + Content: []byte("BT /F1 12 Tf 100 700 Td (Page Content) Tj ET"), + } + if err := w.WriteObject(contentRef, content); err != nil { + t.Fatal(err) + } + + // Create appearance stream for the form field. + apRef := w.AllocObject() + apStream := Stream{ + Dict: Dict{ + Name("Type"): Name("XObject"), + Name("Subtype"): Name("Form"), + Name("BBox"): Array{Real(0), Real(0), Real(200), Real(20)}, + Name("Resources"): Dict{}, + }, + Content: []byte("BT /Helv 12 Tf 2 5 Td (Hello World) Tj ET"), + } + if err := w.WriteObject(apRef, apStream); err != nil { + t.Fatal(err) + } + + // Create widget annotation / form field. + annotRef := w.AllocObject() + annotDict := Dict{ + Name("Type"): Name("Annot"), + Name("Subtype"): Name("Widget"), + Name("FT"): Name("Tx"), + Name("T"): LiteralString("TextField1"), + Name("V"): LiteralString("Hello World"), + Name("Rect"): Array{Real(100), Real(600), Real(300), Real(620)}, + Name("AP"): Dict{ + Name("N"): apRef, + }, + } + if err := w.WriteObject(annotRef, annotDict); err != nil { + t.Fatal(err) + } + + // Add AcroForm to catalog. + w.AddCatalogEntry(Name("AcroForm"), Dict{ + Name("Fields"): Array{annotRef}, + }) + + // Add page with the annotation. + pageRef := w.AllocObject() + pageDict := Dict{ + Name("Type"): Name("Page"), + Name("MediaBox"): Array{Real(0), Real(0), Real(595), Real(842)}, + Name("Contents"): contentRef, + Name("Resources"): Dict{}, + Name("Annots"): Array{annotRef}, + } + pageDict[Name("Parent")] = w.PageTreeRef() + if err := w.WriteObject(pageRef, pageDict); err != nil { + t.Fatal(err) + } + w.AddRawPage(pageRef) + + if err := w.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// buildTestPDFWithCheckbox creates a PDF with a checkbox field. +func buildTestPDFWithCheckbox(t *testing.T) []byte { + t.Helper() + var buf bytes.Buffer + w := NewWriter(&buf) + w.SetCompression(false) + + contentRef := w.AllocObject() + if err := w.WriteObject(contentRef, Stream{ + Dict: Dict{}, + Content: []byte("BT /F1 12 Tf 100 700 Td (Checkbox Test) Tj ET"), + }); err != nil { + t.Fatal(err) + } + + // Appearance streams for checked and unchecked states. + yesRef := w.AllocObject() + if err := w.WriteObject(yesRef, Stream{ + Dict: Dict{ + Name("Type"): Name("XObject"), + Name("Subtype"): Name("Form"), + Name("BBox"): Array{Real(0), Real(0), Real(14), Real(14)}, + Name("Resources"): Dict{}, + }, + Content: []byte("0 0 14 14 re S 2 2 m 12 12 l S 12 2 m 2 12 l S"), + }); err != nil { + t.Fatal(err) + } + + offRef := w.AllocObject() + if err := w.WriteObject(offRef, Stream{ + Dict: Dict{ + Name("Type"): Name("XObject"), + Name("Subtype"): Name("Form"), + Name("BBox"): Array{Real(0), Real(0), Real(14), Real(14)}, + Name("Resources"): Dict{}, + }, + Content: []byte("0 0 14 14 re S"), + }); err != nil { + t.Fatal(err) + } + + annotRef := w.AllocObject() + annotDict := Dict{ + Name("Type"): Name("Annot"), + Name("Subtype"): Name("Widget"), + Name("FT"): Name("Btn"), + Name("T"): LiteralString("Checkbox1"), + Name("V"): Name("Yes"), + Name("AS"): Name("Yes"), + Name("Rect"): Array{Real(100), Real(500), Real(114), Real(514)}, + Name("AP"): Dict{ + Name("N"): Dict{ + Name("Yes"): yesRef, + Name("Off"): offRef, + }, + }, + } + if err := w.WriteObject(annotRef, annotDict); err != nil { + t.Fatal(err) + } + + w.AddCatalogEntry(Name("AcroForm"), Dict{ + Name("Fields"): Array{annotRef}, + }) + + pageRef := w.AllocObject() + pageDict := Dict{ + Name("Type"): Name("Page"), + Name("Parent"): w.PageTreeRef(), + Name("MediaBox"): Array{Real(0), Real(0), Real(595), Real(842)}, + Name("Contents"): contentRef, + Name("Resources"): Dict{}, + Name("Annots"): Array{annotRef}, + } + if err := w.WriteObject(pageRef, pageDict); err != nil { + t.Fatal(err) + } + w.AddRawPage(pageRef) + + if err := w.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func TestFlattenFormsNoForms(t *testing.T) { + data := buildTestPDF(t, 1) + r, err := NewReader(data) + if err != nil { + t.Fatalf("NewReader: %v", err) + } + m := NewModifier(r) + + // Should be a no-op. + if err := m.FlattenForms(); err != nil { + t.Fatalf("FlattenForms: %v", err) + } + + // Verify output is still valid. + result, err := m.Bytes() + if err != nil { + t.Fatalf("Bytes: %v", err) + } + r2, err := NewReader(result) + if err != nil { + t.Fatalf("re-read: %v", err) + } + count, _ := r2.PageCount() + if count != 1 { + t.Errorf("page count = %d, want 1", count) + } +} + +func TestFlattenFormsTextField(t *testing.T) { + data := buildTestPDFWithForm(t) + r, err := NewReader(data) + if err != nil { + t.Fatalf("NewReader: %v", err) + } + m := NewModifier(r) + + if err := m.FlattenForms(); err != nil { + t.Fatalf("FlattenForms: %v", err) + } + + result, err := m.Bytes() + if err != nil { + t.Fatalf("Bytes: %v", err) + } + + // Re-read and verify. + r2, err := NewReader(result) + if err != nil { + t.Fatalf("re-read: %v", err) + } + + // Verify page count. + count, _ := r2.PageCount() + if count != 1 { + t.Errorf("page count = %d, want 1", count) + } + + // Verify /AcroForm is removed from catalog. + catalog, err := r2.ResolveDict(r2.RootRef()) + if err != nil { + t.Fatalf("resolve catalog: %v", err) + } + if _, ok := catalog[Name("AcroForm")]; ok { + t.Error("AcroForm should be removed from catalog after flattening") + } + + // Verify /Annots is removed from the page. + pageDict, err := r2.PageDict(0) + if err != nil { + t.Fatalf("PageDict: %v", err) + } + if _, ok := pageDict[Name("Annots")]; ok { + t.Error("Annots should be removed from page after flattening") + } + + // Verify content has been added (Contents should be an array now). + contentsObj, ok := pageDict[Name("Contents")] + if !ok { + t.Fatal("page missing /Contents") + } + if _, ok := contentsObj.(Array); !ok { + t.Error("Contents should be an array after flattening") + } + + // Verify XObject resources were added. + res, err := r2.ResolveDict(pageDict[Name("Resources")]) + if err != nil { + t.Fatalf("resolve resources: %v", err) + } + xobjDict, err := r2.ResolveDict(res[Name("XObject")]) + if err != nil { + t.Fatalf("resolve XObject: %v", err) + } + if _, ok := xobjDict[Name("_Flat0")]; !ok { + t.Error("XObject /_Flat0 should exist in resources") + } +} + +func TestFlattenFormsCheckbox(t *testing.T) { + data := buildTestPDFWithCheckbox(t) + r, err := NewReader(data) + if err != nil { + t.Fatalf("NewReader: %v", err) + } + m := NewModifier(r) + + if err := m.FlattenForms(); err != nil { + t.Fatalf("FlattenForms: %v", err) + } + + result, err := m.Bytes() + if err != nil { + t.Fatalf("Bytes: %v", err) + } + + r2, err := NewReader(result) + if err != nil { + t.Fatalf("re-read: %v", err) + } + + catalog, err := r2.ResolveDict(r2.RootRef()) + if err != nil { + t.Fatalf("resolve catalog: %v", err) + } + if _, ok := catalog[Name("AcroForm")]; ok { + t.Error("AcroForm should be removed from catalog after flattening") + } + + pageDict, err := r2.PageDict(0) + if err != nil { + t.Fatalf("PageDict: %v", err) + } + if _, ok := pageDict[Name("Annots")]; ok { + t.Error("Annots should be removed from page after flattening") + } +} + +func TestFlattenFormsMixedAnnotations(t *testing.T) { + // Build a PDF with both a widget and a non-widget annotation. + data := buildTestPDFWithMixedAnnotations(t) + + r, err := NewReader(data) + if err != nil { + t.Fatalf("NewReader: %v", err) + } + m := NewModifier(r) + + if err := m.FlattenForms(); err != nil { + t.Fatalf("FlattenForms: %v", err) + } + + result, err := m.Bytes() + if err != nil { + t.Fatalf("Bytes: %v", err) + } + + r2, err := NewReader(result) + if err != nil { + t.Fatalf("re-read: %v", err) + } + + pageDict, err := r2.PageDict(0) + if err != nil { + t.Fatalf("PageDict: %v", err) + } + + // /Annots should still exist with only the link annotation. + annotsObj, ok := pageDict[Name("Annots")] + if !ok { + t.Fatal("Annots should still exist (link annotation)") + } + annotsResolved, err := r2.Resolve(annotsObj) + if err != nil { + t.Fatalf("resolve annots: %v", err) + } + annotsArr, ok := annotsResolved.(Array) + if !ok { + t.Fatal("Annots should be an array") + } + if len(annotsArr) != 1 { + t.Errorf("Annots length = %d, want 1 (only link)", len(annotsArr)) + } + + // Verify the remaining annotation is the link. + linkDict, err := r2.ResolveDict(annotsArr[0]) + if err != nil { + t.Fatalf("resolve remaining annot: %v", err) + } + if subtype, ok := linkDict[Name("Subtype")].(Name); !ok || string(subtype) != "Link" { + t.Error("remaining annotation should be the Link annotation") + } +} + +// buildTestPDFWithMixedAnnotations creates a PDF with a widget and a link annotation. +func buildTestPDFWithMixedAnnotations(t *testing.T) []byte { + t.Helper() + var buf bytes.Buffer + w := NewWriter(&buf) + w.SetCompression(false) + + contentRef := w.AllocObject() + writeObj(t, w, contentRef, Stream{ + Dict: Dict{}, + Content: []byte("BT /F1 12 Tf 100 700 Td (Mixed) Tj ET"), + }) + + apRef := w.AllocObject() + writeObj(t, w, apRef, Stream{ + Dict: Dict{ + Name("Type"): Name("XObject"), + Name("Subtype"): Name("Form"), + Name("BBox"): Array{Real(0), Real(0), Real(100), Real(20)}, + Name("Resources"): Dict{}, + }, + Content: []byte("BT /Helv 12 Tf 2 5 Td (Test) Tj ET"), + }) + + widgetRef := w.AllocObject() + writeObj(t, w, widgetRef, Dict{ + Name("Type"): Name("Annot"), + Name("Subtype"): Name("Widget"), + Name("FT"): Name("Tx"), + Name("T"): LiteralString("Field1"), + Name("Rect"): Array{Real(100), Real(600), Real(200), Real(620)}, + Name("AP"): Dict{Name("N"): apRef}, + }) + + linkRef := w.AllocObject() + writeObj(t, w, linkRef, Dict{ + Name("Type"): Name("Annot"), + Name("Subtype"): Name("Link"), + Name("Rect"): Array{Real(50), Real(400), Real(200), Real(420)}, + }) + + w.AddCatalogEntry(Name("AcroForm"), Dict{ + Name("Fields"): Array{widgetRef}, + }) + + pageRef := w.AllocObject() + writeObj(t, w, pageRef, Dict{ + Name("Type"): Name("Page"), + Name("Parent"): w.PageTreeRef(), + Name("MediaBox"): Array{Real(0), Real(0), Real(595), Real(842)}, + Name("Contents"): contentRef, + Name("Resources"): Dict{}, + Name("Annots"): Array{widgetRef, linkRef}, + }) + w.AddRawPage(pageRef) + + if err := w.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// writeObj is a test helper that writes an object and fails on error. +func writeObj(t *testing.T, w *Writer, ref ObjectRef, obj Object) { + t.Helper() + if err := w.WriteObject(ref, obj); err != nil { + t.Fatal(err) + } +} + +func TestFlattenFormsContentStream(t *testing.T) { + // Verify the flattened content stream references the XObject. + data := buildTestPDFWithForm(t) + r, err := NewReader(data) + if err != nil { + t.Fatalf("NewReader: %v", err) + } + m := NewModifier(r) + + if err := m.FlattenForms(); err != nil { + t.Fatalf("FlattenForms: %v", err) + } + + result, err := m.Bytes() + if err != nil { + t.Fatalf("Bytes: %v", err) + } + + // The flattened content should contain "/_Flat0 Do". + if !strings.Contains(string(result), "/_Flat0 Do") { + t.Error("flattened PDF should contain /_Flat0 Do operator") + } +} + +func TestFlattenFormsWidgetWithoutAppearance(t *testing.T) { + // Widget without /AP should be removed silently. + var buf bytes.Buffer + w := NewWriter(&buf) + w.SetCompression(false) + + contentRef := w.AllocObject() + if err := w.WriteObject(contentRef, Stream{ + Dict: Dict{}, + Content: []byte("BT /F1 12 Tf 100 700 Td (NoAP) Tj ET"), + }); err != nil { + t.Fatal(err) + } + + widgetRef := w.AllocObject() + if err := w.WriteObject(widgetRef, Dict{ + Name("Type"): Name("Annot"), + Name("Subtype"): Name("Widget"), + Name("FT"): Name("Tx"), + Name("T"): LiteralString("NoAppearance"), + Name("Rect"): Array{Real(100), Real(600), Real(200), Real(620)}, + // No /AP entry. + }); err != nil { + t.Fatal(err) + } + + w.AddCatalogEntry(Name("AcroForm"), Dict{ + Name("Fields"): Array{widgetRef}, + }) + + pageRef := w.AllocObject() + if err := w.WriteObject(pageRef, Dict{ + Name("Type"): Name("Page"), + Name("Parent"): w.PageTreeRef(), + Name("MediaBox"): Array{Real(0), Real(0), Real(595), Real(842)}, + Name("Contents"): contentRef, + Name("Resources"): Dict{}, + Name("Annots"): Array{widgetRef}, + }); err != nil { + t.Fatal(err) + } + w.AddRawPage(pageRef) + + if err := w.Close(); err != nil { + t.Fatal(err) + } + + r, err := NewReader(buf.Bytes()) + if err != nil { + t.Fatalf("NewReader: %v", err) + } + m := NewModifier(r) + + if err := m.FlattenForms(); err != nil { + t.Fatalf("FlattenForms: %v", err) + } + + result, err := m.Bytes() + if err != nil { + t.Fatalf("Bytes: %v", err) + } + + r2, err := NewReader(result) + if err != nil { + t.Fatalf("re-read: %v", err) + } + + catalog, err := r2.ResolveDict(r2.RootRef()) + if err != nil { + t.Fatalf("resolve catalog: %v", err) + } + if _, ok := catalog[Name("AcroForm")]; ok { + t.Error("AcroForm should be removed") + } + + pageDict, err := r2.PageDict(0) + if err != nil { + t.Fatalf("PageDict: %v", err) + } + if _, ok := pageDict[Name("Annots")]; ok { + t.Error("Annots should be removed") + } +} diff --git a/template/overlay.go b/template/overlay.go index 744bfb6..89b2e24 100644 --- a/template/overlay.go +++ b/template/overlay.go @@ -134,6 +134,13 @@ func (d *ExistingDocument) EachPage(fn func(pageIndex int, p *PageBuilder)) erro return nil } +// FlattenForms flattens AcroForm fields into page content streams, +// making form data part of the static page content and removing +// all interactive form elements. Returns nil if no forms are present. +func (d *ExistingDocument) FlattenForms() error { + return d.modifier.FlattenForms() +} + // Save generates the modified PDF as a byte slice. func (d *ExistingDocument) Save() ([]byte, error) { return d.modifier.Bytes()