diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 03d520c..debb2ad 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.0" + placeholder: "v1.0.2" validations: required: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f6b9e3..c0c518b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [1.0.2] - 2026-03-23 + +### Added +- PDF document merging — combine pages from multiple PDFs into one (#11) + - `pdf.MergePDFs()`: Core merge engine with object reference remapping + - `gpdf.Merge()`: High-level facade with `Source`, `PageRange`, `WithMergeMetadata()` + - `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 + ### Added - Existing PDF overlay — open, read, and modify existing PDFs - `pdf.Reader`: PDF parser with XRef table/stream parsing, page tree traversal, object caching @@ -91,8 +102,10 @@ 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.0...HEAD -[0.9.0]: https://github.com/gpdf-dev/gpdf/compare/v0.8.0...v1.0.0 +[Unreleased]: https://github.com/gpdf-dev/gpdf/compare/v1.0.2...HEAD +[1.0.2]: https://github.com/gpdf-dev/gpdf/compare/v1.0.0...v1.0.2 +[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 [0.7.0]: https://github.com/gpdf-dev/gpdf/releases/tag/v0.7.0 [0.6.0]: https://github.com/gpdf-dev/gpdf/releases/tag/v0.6.0 diff --git a/README.md b/README.md index 2329c59..e67916a 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.8%25-green) +![coverage](https://img.shields.io/badge/coverage-86.5%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 +- **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 - **PDF/A** — PDF/A-1b and PDF/A-2b conformance with ICC profiles and XMP metadata @@ -498,6 +499,22 @@ doc.EachPage(func(i int, p *template.PageBuilder) { result, _ := doc.Save() ``` +### PDF Merging + +Combine multiple PDFs into a single document with optional page range selection: + +```go +// Merge multiple PDFs +merged, _ := gpdf.Merge( + []gpdf.Source{ + {Data: coverPage}, + {Data: report}, + {Data: appendix, Pages: gpdf.PageRange{From: 1, To: 3}}, // only first 3 pages + }, + gpdf.WithMergeMetadata("My Document", "Author", ""), +) +``` + ### JSON Schema Define documents entirely in JSON: @@ -780,6 +797,8 @@ doc.Render(f) | `doc.Overlay(page, fn)` | Add content on top of a specific page | | `doc.EachPage(fn)` | Apply overlay to every page | | `doc.Save()` | Save the modified PDF | +| `gpdf.Merge(sources, opts...)` | Merge multiple PDFs into one | +| `WithMergeMetadata(title, author, producer)` | Set metadata on merged output | ### Text Options diff --git a/README_es.md b/README_es.md index c2e3322..70708bd 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 +- **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 - **PDF/A** — conformidad PDF/A-1b y PDF/A-2b con perfiles ICC y metadatos XMP @@ -415,6 +416,22 @@ doc.EachPage(func(i int, p *template.PageBuilder) { result, _ := doc.Save() ``` +### Fusión de PDF + +Combine múltiples PDFs en un solo documento con selección opcional de rango de páginas: + +```go +// Fusionar múltiples PDFs +merged, _ := gpdf.Merge( + []gpdf.Source{ + {Data: coverPage}, + {Data: report}, + {Data: appendix, Pages: gpdf.PageRange{From: 1, To: 3}}, // solo las primeras 3 páginas + }, + gpdf.WithMergeMetadata("My Document", "Author", ""), +) +``` + ### Metadatos del documento ```go @@ -519,6 +536,8 @@ doc.Render(f) | `doc.Overlay(page, fn)` | Superponer contenido en una página específica | | `doc.EachPage(fn)` | Aplicar superposición a todas las páginas | | `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 | ### Opciones de texto diff --git a/README_ja.md b/README_ja.md index 48706d9..b30cca3 100644 --- a/README_ja.md +++ b/README_ja.md @@ -32,6 +32,7 @@ - **画像** — JPEGとPNGの埋め込み(フィットオプション対応) - **絶対位置指定** — ページ上の任意のXY座標に要素を配置 - **既存PDFオーバーレイ** — 既存PDFを開いてテキスト、画像、スタンプを上に追加 +- **PDFマージ** — 複数のPDFをページ範囲指定付きで1つに結合 - **ドキュメントメタデータ** — タイトル、著者、件名、作成者 - **暗号化** — AES-256暗号化(ISO 32000-2, Rev 6)、オーナー/ユーザーパスワードと権限制御 - **PDF/A** — PDF/A-1bおよびPDF/A-2b準拠、ICCプロファイルとXMPメタデータ対応 @@ -424,6 +425,22 @@ doc.EachPage(func(i int, p *template.PageBuilder) { result, _ := doc.Save() ``` +### PDFマージ + +複数のPDFをページ範囲指定付きで1つのドキュメントに結合: + +```go +// 複数のPDFを結合 +merged, _ := gpdf.Merge( + []gpdf.Source{ + {Data: coverPage}, + {Data: report}, + {Data: appendix, Pages: gpdf.PageRange{From: 1, To: 3}}, // 最初の3ページのみ + }, + gpdf.WithMergeMetadata("My Document", "Author", ""), +) +``` + ### JSONスキーマ JSONのみでドキュメントを定義: @@ -638,6 +655,8 @@ doc.Render(f) | `doc.Overlay(page, fn)` | 特定ページにコンテンツを重ねて配置 | | `doc.EachPage(fn)` | 全ページにオーバーレイを適用 | | `doc.Save()` | 変更したPDFを保存 | +| `gpdf.Merge(sources, opts...)` | 複数のPDFを1つに結合 | +| `WithMergeMetadata(title, author, producer)` | 結合後のメタデータを設定 | ### テキストオプション diff --git a/README_ko.md b/README_ko.md index bce68af..2f60d40 100644 --- a/README_ko.md +++ b/README_ko.md @@ -32,6 +32,7 @@ - **이미지** — JPEG 및 PNG 임베딩 (맞춤 옵션 지원) - **절대 위치 지정** — 페이지의 정확한 XY 좌표에 요소 배치 - **기존 PDF 오버레이** — 기존 PDF를 열어 텍스트, 이미지, 스탬프를 위에 추가 +- **PDF 병합** — 여러 PDF를 페이지 범위 선택으로 하나로 결합 - **문서 메타데이터** — 제목, 저자, 주제, 작성자 - **암호화** — AES-256 암호화 (ISO 32000-2, Rev 6), 소유자/사용자 비밀번호 및 권한 제어 - **PDF/A** — PDF/A-1b 및 PDF/A-2b 준수, ICC 프로파일 및 XMP 메타데이터 포함 @@ -415,6 +416,22 @@ doc.EachPage(func(i int, p *template.PageBuilder) { result, _ := doc.Save() ``` +### PDF 병합 + +여러 PDF를 페이지 범위 선택으로 하나의 문서로 결합: + +```go +// 여러 PDF 합치기 +merged, _ := gpdf.Merge( + []gpdf.Source{ + {Data: coverPage}, + {Data: report}, + {Data: appendix, Pages: gpdf.PageRange{From: 1, To: 3}}, // 처음 3페이지만 + }, + gpdf.WithMergeMetadata("My Document", "Author", ""), +) +``` + ### 문서 메타데이터 ```go @@ -519,6 +536,8 @@ doc.Render(f) | `doc.Overlay(page, fn)` | 특정 페이지에 콘텐츠 오버레이 | | `doc.EachPage(fn)` | 모든 페이지에 오버레이 적용 | | `doc.Save()` | 수정된 PDF 저장 | +| `gpdf.Merge(sources, opts...)` | 여러 PDF를 하나로 병합 | +| `WithMergeMetadata(title, author, producer)` | 병합된 출력의 메타데이터 설정 | ### 텍스트 옵션 diff --git a/README_pt.md b/README_pt.md index dddef76..ead2f0f 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 +- **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 - **PDF/A** — conformidade PDF/A-1b e PDF/A-2b com perfis ICC e metadados XMP @@ -466,6 +467,22 @@ doc.EachPage(func(i int, p *template.PageBuilder) { result, _ := doc.Save() ``` +### Fusão de PDF + +Combine múltiplos PDFs em um único documento com seleção opcional de intervalo de páginas: + +```go +// Fundir múltiplos PDFs +merged, _ := gpdf.Merge( + []gpdf.Source{ + {Data: coverPage}, + {Data: report}, + {Data: appendix, Pages: gpdf.PageRange{From: 1, To: 3}}, // apenas as primeiras 3 páginas + }, + gpdf.WithMergeMetadata("My Document", "Author", ""), +) +``` + ## Referência API ### Opções do documento @@ -519,6 +536,8 @@ result, _ := doc.Save() | `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.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 | ### Opções de texto diff --git a/README_zh.md b/README_zh.md index 69dfa67..cc2f9fd 100644 --- a/README_zh.md +++ b/README_zh.md @@ -32,6 +32,7 @@ - **图片** — JPEG 和 PNG 嵌入(支持缩放选项) - **绝对定位** — 在页面上以精确 XY 坐标放置元素 - **现有 PDF 叠加** — 打开现有 PDF 并在上面添加文字、图片、印章 +- **PDF 合并** — 将多个 PDF 合并为一个,支持页面范围选择 - **文档元数据** — 标题、作者、主题、创建者 - **加密** — AES-256 加密(ISO 32000-2, Rev 6),支持所有者/用户密码和权限控制 - **PDF/A** — PDF/A-1b 和 PDF/A-2b 合规,包含 ICC 配置文件和 XMP 元数据 @@ -415,6 +416,22 @@ doc.EachPage(func(i int, p *template.PageBuilder) { result, _ := doc.Save() ``` +### PDF 合并 + +将多个 PDF 合并为一个文档,支持页面范围选择: + +```go +// 合并多个 PDF +merged, _ := gpdf.Merge( + []gpdf.Source{ + {Data: coverPage}, + {Data: report}, + {Data: appendix, Pages: gpdf.PageRange{From: 1, To: 3}}, // 仅前 3 页 + }, + gpdf.WithMergeMetadata("My Document", "Author", ""), +) +``` + ### 文档元数据 ```go @@ -519,6 +536,8 @@ doc.Render(f) | `doc.Overlay(page, fn)` | 在指定页上叠加内容 | | `doc.EachPage(fn)` | 对每页应用叠加 | | `doc.Save()` | 保存修改后的 PDF | +| `gpdf.Merge(sources, opts...)` | 将多个 PDF 合并为一个 | +| `WithMergeMetadata(title, author, producer)` | 设置合并后的元数据 | ### 文本选项 diff --git a/_benchmark/go.mod b/_benchmark/go.mod index f800c8c..d0e979b 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.0 + github.com/go-pdf/fpdf v1.0.2 github.com/johnfercher/maroto/v2 v2.3.3 - github.com/gpdf-dev/gpdf v1.0.1 + github.com/gpdf-dev/gpdf v1.0.2 github.com/signintech/gopdf v0.36.0 golang.org/x/image v0.18.0 ) require ( - github.com/boombuler/barcode v1.0.1 // indirect + github.com/boombuler/barcode v1.0.2 // 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.0 // indirect - github.com/hhrutter/tiff v1.0.1 // indirect + github.com/hhrutter/lzw v1.0.2 // indirect + github.com/hhrutter/tiff v1.0.2 // 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.15 // indirect + github.com/phpdave11/gofpdi v1.0.25 // 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 62c9eed..c24384d 100644 --- a/_benchmark/go.sum +++ b/_benchmark/go.sum @@ -1,38 +1,38 @@ -github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= -github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= -github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.0.2/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.0.2 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= +github.com/boombuler/barcode v1.0.2/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.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw= -github.com/go-pdf/fpdf v1.0.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y= +github.com/go-pdf/fpdf v1.0.2 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw= +github.com/go-pdf/fpdf v1.0.2/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.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= -github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= -github.com/hhrutter/tiff v1.0.1 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0= -github.com/hhrutter/tiff v1.0.1/go.mod h1:zU/dNgDm0cMIa8y8YwcYBeuEEveI4B0owqHyiPpJPHc= +github.com/hhrutter/lzw v1.0.2 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= +github.com/hhrutter/lzw v1.0.2/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= +github.com/hhrutter/tiff v1.0.2 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0= +github.com/hhrutter/tiff v1.0.2/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.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/jung-kurt/gofpdf v1.0.2/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.14-0.20211212211723-1f10f9844311/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= -github.com/phpdave11/gofpdi v1.0.15 h1:iJazY1BQ07I9s7N5EWjBO1YbhmKfHGxNligUv/Rw4Lc= -github.com/phpdave11/gofpdi v1.0.15/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/phpdave11/gofpdi v1.0.24-0.20211212211723-1f10f9844311/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/phpdave11/gofpdi v1.0.25 h1:iJazY1BQ07I9s7N5EWjBO1YbhmKfHGxNligUv/Rw4Lc= +github.com/phpdave11/gofpdi v1.0.25/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.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.2 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.2/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/merge/01_basic_merge_test.go b/_examples/merge/01_basic_merge_test.go new file mode 100644 index 0000000..c419c28 --- /dev/null +++ b/_examples/merge/01_basic_merge_test.go @@ -0,0 +1,74 @@ +package merge_test + +import ( + "fmt" + "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" +) + +// generateDoc creates a PDF with the given number of pages and a title prefix. +func generateDoc(t *testing.T, numPages int, prefix string) []byte { + t.Helper() + doc := template.New( + template.WithPageSize(document.A4), + template.WithMargins(document.UniformEdges(document.Mm(20))), + template.WithMetadata(document.DocumentMetadata{ + Title: prefix, + Author: "gpdf", + }), + ) + + for i := 1; i <= numPages; i++ { + page := doc.AddPage() + page.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { + c.Text(fmt.Sprintf("%s — Page %d", prefix, i), + template.FontSize(18), template.Bold()) + }) + }) + page.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { + c.Spacer(document.Mm(5)) + c.Line() + c.Spacer(document.Mm(5)) + }) + }) + for j := 0; j < 3; j++ { + page.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { + c.Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. " + + "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + ) + }) + }) + } + } + + data, err := doc.Generate() + if err != nil { + t.Fatalf("generate %s: %v", prefix, err) + } + return data +} + +func TestExample_Merge_01_Basic(t *testing.T) { + // Generate two separate PDFs. + cover := generateDoc(t, 1, "Cover Page") + body := generateDoc(t, 3, "Report Body") + + // Merge them into a single document. + merged, err := gpdf.Merge([]gpdf.Source{ + {Data: cover}, + {Data: body}, + }) + if err != nil { + t.Fatalf("Merge: %v", err) + } + + testutil.AssertValidPDF(t, merged) + testutil.WritePDF(t, "01_basic_merge.pdf", merged) +} diff --git a/_examples/merge/02_page_range_test.go b/_examples/merge/02_page_range_test.go new file mode 100644 index 0000000..c0368cc --- /dev/null +++ b/_examples/merge/02_page_range_test.go @@ -0,0 +1,24 @@ +package merge_test + +import ( + "testing" + + gpdf "github.com/gpdf-dev/gpdf" + "github.com/gpdf-dev/gpdf/_examples/testutil" +) + +func TestExample_Merge_02_PageRange(t *testing.T) { + // Generate a 5-page document. + full := generateDoc(t, 5, "Full Document") + + // Extract only pages 2-4 (1-based). + extracted, err := gpdf.Merge([]gpdf.Source{ + {Data: full, Pages: gpdf.PageRange{From: 2, To: 4}}, + }) + if err != nil { + t.Fatalf("Merge: %v", err) + } + + testutil.AssertValidPDF(t, extracted) + testutil.WritePDF(t, "02_page_range.pdf", extracted) +} diff --git a/_examples/merge/03_metadata_test.go b/_examples/merge/03_metadata_test.go new file mode 100644 index 0000000..cde544a --- /dev/null +++ b/_examples/merge/03_metadata_test.go @@ -0,0 +1,30 @@ +package merge_test + +import ( + "testing" + + gpdf "github.com/gpdf-dev/gpdf" + "github.com/gpdf-dev/gpdf/_examples/testutil" +) + +func TestExample_Merge_03_Metadata(t *testing.T) { + cover := generateDoc(t, 1, "Cover") + body := generateDoc(t, 2, "Body") + appendix := generateDoc(t, 1, "Appendix") + + // Merge with custom metadata. + merged, err := gpdf.Merge( + []gpdf.Source{ + {Data: cover}, + {Data: body}, + {Data: appendix}, + }, + gpdf.WithMergeMetadata("Policy Bundle", "Example Ltd", "gpdf"), + ) + if err != nil { + t.Fatalf("Merge: %v", err) + } + + testutil.AssertValidPDF(t, merged) + testutil.WritePDF(t, "03_metadata.pdf", merged) +} diff --git a/_examples/merge/04_merge_and_overlay_test.go b/_examples/merge/04_merge_and_overlay_test.go new file mode 100644 index 0000000..9247e75 --- /dev/null +++ b/_examples/merge/04_merge_and_overlay_test.go @@ -0,0 +1,56 @@ +package merge_test + +import ( + "fmt" + "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/pdf" + "github.com/gpdf-dev/gpdf/template" +) + +func TestExample_Merge_04_MergeAndOverlay(t *testing.T) { + // Generate two PDFs and merge them. + part1 := generateDoc(t, 2, "Part 1") + part2 := generateDoc(t, 2, "Part 2") + + merged, err := gpdf.Merge([]gpdf.Source{ + {Data: part1}, + {Data: part2}, + }) + if err != nil { + t.Fatalf("Merge: %v", err) + } + + // Open the merged PDF and add page numbers on every page. + doc, err := gpdf.Open(merged) + if err != nil { + t.Fatalf("Open: %v", err) + } + + pageCount, err := doc.PageCount() + if err != nil { + t.Fatalf("PageCount: %v", err) + } + err = doc.EachPage(func(pageIndex int, p *template.PageBuilder) { + p.Absolute(document.Mm(95), document.Mm(285), func(c *template.ColBuilder) { + c.Text(fmt.Sprintf("Page %d / %d", pageIndex+1, pageCount), + template.FontSize(9), + template.TextColor(pdf.Gray(0.4)), + ) + }) + }) + if err != nil { + t.Fatalf("EachPage: %v", err) + } + + result, err := doc.Save() + if err != nil { + t.Fatalf("Save: %v", err) + } + + testutil.AssertValidPDF(t, result) + testutil.WritePDF(t, "04_merge_and_overlay.pdf", result) +} diff --git a/_examples/merge/05_issue11_example_test.go b/_examples/merge/05_issue11_example_test.go new file mode 100644 index 0000000..9f24143 --- /dev/null +++ b/_examples/merge/05_issue11_example_test.go @@ -0,0 +1,60 @@ +package merge_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_Merge_05_Issue11(t *testing.T) { + // 1. Read existing PDFs (simulated with generated docs) + coverPage := generateDoc(t, 1, "Cover Page") + attachment := generateDoc(t, 5, "Terms and Conditions") + + // 2. Generate a document with gpdf as normal + doc := template.New( + template.WithPageSize(document.A4), + template.WithMargins(document.Edges{ + Top: document.Pt(40), + Left: document.Pt(40), + Right: document.Pt(40), + Bottom: document.Pt(60), + }), + ) + page := doc.AddPage() + page.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { + c.Text("Policy Certificate", template.FontSize(18), template.Bold()) + }) + }) + page.AutoRow(func(r *template.RowBuilder) { + r.Col(12, func(c *template.ColBuilder) { + c.Text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. " + + "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + ) + }) + }) + generated, err := doc.Generate() + if err != nil { + t.Fatalf("Generate: %v", err) + } + + // 3. Merge: cover + generated + attachment + merged, err := gpdf.Merge( + []gpdf.Source{ + {Data: coverPage}, + {Data: generated}, + {Data: attachment, Pages: gpdf.PageRange{From: 1, To: 3}}, // only first 3 pages + }, + gpdf.WithMergeMetadata("Policy Bundle", "Example Ltd", ""), + ) + if err != nil { + t.Fatalf("Merge: %v", err) + } + + testutil.AssertValidPDF(t, merged) + testutil.WritePDF(t, "05_issue11_example.pdf", merged) +} diff --git a/_validation/go.mod b/_validation/go.mod index 43e6710..26a11bd 100644 --- a/_validation/go.mod +++ b/_validation/go.mod @@ -10,8 +10,8 @@ require ( ) require ( - github.com/hhrutter/lzw v1.0.0 // indirect - github.com/hhrutter/tiff v1.0.1 // indirect + github.com/hhrutter/lzw v1.0.2 // indirect + github.com/hhrutter/tiff v1.0.2 // 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 c641ca2..7482956 100644 --- a/_validation/go.sum +++ b/_validation/go.sum @@ -1,7 +1,7 @@ -github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= -github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= -github.com/hhrutter/tiff v1.0.1 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0= -github.com/hhrutter/tiff v1.0.1/go.mod h1:zU/dNgDm0cMIa8y8YwcYBeuEEveI4B0owqHyiPpJPHc= +github.com/hhrutter/lzw v1.0.2 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= +github.com/hhrutter/lzw v1.0.2/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= +github.com/hhrutter/tiff v1.0.2 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0= +github.com/hhrutter/tiff v1.0.2/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/gpdf.go b/gpdf.go index 7587563..1118c4c 100644 --- a/gpdf.go +++ b/gpdf.go @@ -151,6 +151,83 @@ func Open(data []byte, opts ...template.Option) (*template.ExistingDocument, err return template.OpenExisting(data, opts...) } +// --------------------------------------------------------------------------- +// PDF Merge +// --------------------------------------------------------------------------- + +// PageRange specifies a 1-based inclusive range of pages. +// Zero values mean "from start" / "to end". +type PageRange struct { + From int // 1-based; 0 means first page + To int // 1-based; 0 means last page +} + +// Source represents one input PDF in a merge operation. +type Source struct { + Data []byte // raw PDF bytes + Pages PageRange // which pages to include; zero value = all pages +} + +// MergeOption configures the merge operation. +type MergeOption func(*mergeConfig) + +type mergeConfig struct { + title string + author string + producer string +} + +// WithMergeMetadata sets the document info on the merged output. +func WithMergeMetadata(title, author, producer string) MergeOption { + return func(c *mergeConfig) { + c.title = title + c.author = author + c.producer = producer + } +} + +// Merge combines pages from multiple PDF sources into a single PDF. +// +// merged, err := gpdf.Merge( +// []gpdf.Source{ +// {Data: coverPage}, +// {Data: generated}, +// {Data: attachment, Pages: gpdf.PageRange{From: 1, To: 3}}, +// }, +// gpdf.WithMergeMetadata("Policy Bundle", "Example Ltd", ""), +// ) +func Merge(sources []Source, opts ...MergeOption) ([]byte, error) { + cfg := mergeConfig{producer: "gpdf " + Version} + for _, opt := range opts { + opt(&cfg) + } + + pdfSources := make([]pdf.MergeSource, len(sources)) + for i, s := range sources { + from := s.Pages.From - 1 + if s.Pages.From <= 0 { + from = 0 + } + to := s.Pages.To - 1 + if s.Pages.To <= 0 { + to = -1 + } + pdfSources[i] = pdf.MergeSource{ + Data: s.Data, + FromPage: from, + ToPage: to, + } + } + + return pdf.MergePDFs(pdfSources, pdf.MergeConfig{ + Info: pdf.DocumentInfo{ + Title: cfg.title, + Author: cfg.author, + Producer: cfg.producer, + }, + }) +} + // NewDocumentFromJSON is an alias for FromJSON that creates a Document // from a JSON schema, optionally resolving Go template expressions with data. func NewDocumentFromJSON(schema []byte, data any, opts ...template.Option) (*template.Document, error) { diff --git a/pdf/merger.go b/pdf/merger.go new file mode 100644 index 0000000..bdbda09 --- /dev/null +++ b/pdf/merger.go @@ -0,0 +1,190 @@ +package pdf + +import ( + "bytes" + "fmt" +) + +// MergeSource represents one input PDF in a merge operation. +type MergeSource struct { + Data []byte // raw PDF bytes + FromPage int // 0-based inclusive start page + ToPage int // 0-based inclusive end page; -1 means last page +} + +// MergeConfig configures the merge operation. +type MergeConfig struct { + Info DocumentInfo +} + +// MergePDFs combines pages from multiple PDF sources into a single PDF. +func MergePDFs(sources []MergeSource, cfg MergeConfig) ([]byte, error) { + if len(sources) == 0 { + return nil, fmt.Errorf("pdf: merge requires at least one source") + } + + var buf bytes.Buffer + w := NewWriter(&buf) + w.SetInfo(cfg.Info) + + for i, src := range sources { + reader, err := NewReader(src.Data) + if err != nil { + return nil, fmt.Errorf("pdf: source %d: %w", i, err) + } + + pageCount, err := reader.PageCount() + if err != nil { + return nil, fmt.Errorf("pdf: source %d: %w", i, err) + } + + from := src.FromPage + to := src.ToPage + if to < 0 { + to = pageCount - 1 + } + + if from < 0 || from >= pageCount { + return nil, fmt.Errorf("pdf: source %d: from page %d out of range [0, %d)", i, from, pageCount) + } + if to < from || to >= pageCount { + return nil, fmt.Errorf("pdf: source %d: to page %d out of range [%d, %d)", i, to, from, pageCount) + } + + mapper := newObjectMapper(reader) + for p := from; p <= to; p++ { + if err := mapper.copyPage(p, w); err != nil { + return nil, fmt.Errorf("pdf: source %d, page %d: %w", i, p, err) + } + } + } + + if err := w.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// objectMapper tracks object number remapping from one source PDF to the output. +type objectMapper struct { + reader *Reader + mapping map[int]int // source obj number -> output obj number +} + +func newObjectMapper(r *Reader) *objectMapper { + return &objectMapper{ + reader: r, + mapping: make(map[int]int, r.MaxObjectNumber()), + } +} + +// copyPage copies the i-th page from the source PDF into the writer. +func (m *objectMapper) copyPage(pageIndex int, w *Writer) error { + pageDict, err := m.reader.PageDict(pageIndex) + if err != nil { + return err + } + + // Deep copy the page dict, remapping all object references. + copied, err := m.copyObject(pageDict, w) + if err != nil { + return err + } + + dict := copied.(Dict) + + // Replace /Parent with the output page tree. + delete(dict, Name("Parent")) + dict[Name("Type")] = Name("Page") + dict[Name("Parent")] = w.PageTreeRef() + + // Allocate and write the page object. + pageRef := w.AllocObject() + if err := w.WriteObject(pageRef, dict); err != nil { + return err + } + w.AddRawPage(pageRef) + return nil +} + +// copyObject recursively copies a PDF object, remapping all indirect references +// to new object numbers in the output writer. +func (m *objectMapper) copyObject(obj Object, w *Writer) (Object, error) { + switch v := obj.(type) { + case ObjectRef: + return m.copyRef(v, w) + case Dict: + return m.copyDict(v, w) + case Array: + return m.copyArray(v, w) + case Stream: + return m.copyStream(v, w) + default: + // Integer, Real, Boolean, Name, LiteralString, HexString, Null, Rectangle + return obj, nil + } +} + +func (m *objectMapper) copyRef(ref ObjectRef, w *Writer) (ObjectRef, error) { + // Already mapped? + if newNum, ok := m.mapping[ref.Number]; ok { + return ObjectRef{Number: newNum}, nil + } + + // Allocate new object number before recursing (handles cycles). + newRef := w.AllocObject() + m.mapping[ref.Number] = newRef.Number + + // Resolve the source object. + resolved, err := m.reader.GetObject(ref.Number) + if err != nil { + return ObjectRef{}, fmt.Errorf("resolve object %d: %w", ref.Number, err) + } + + // Deep copy the resolved object. + copied, err := m.copyObject(resolved, w) + if err != nil { + return ObjectRef{}, err + } + + // Write to output. + if err := w.WriteObject(newRef, copied); err != nil { + return ObjectRef{}, err + } + return newRef, nil +} + +func (m *objectMapper) copyDict(d Dict, w *Writer) (Dict, error) { + newDict := make(Dict, len(d)) + for key, val := range d { + copied, err := m.copyObject(val, w) + if err != nil { + return nil, err + } + newDict[key] = copied + } + return newDict, nil +} + +func (m *objectMapper) copyArray(a Array, w *Writer) (Array, error) { + newArr := make(Array, len(a)) + for i, item := range a { + copied, err := m.copyObject(item, w) + if err != nil { + return nil, err + } + newArr[i] = copied + } + return newArr, nil +} + +func (m *objectMapper) copyStream(s Stream, w *Writer) (Stream, error) { + newDict, err := m.copyDict(s.Dict, w) + if err != nil { + return Stream{}, err + } + // Copy content bytes verbatim (no decompression/recompression). + content := make([]byte, len(s.Content)) + copy(content, s.Content) + return Stream{Dict: newDict, Content: content}, nil +} diff --git a/pdf/merger_test.go b/pdf/merger_test.go new file mode 100644 index 0000000..d00f2bd --- /dev/null +++ b/pdf/merger_test.go @@ -0,0 +1,162 @@ +package pdf + +import ( + "testing" +) + +func TestMergeTwoPDFs(t *testing.T) { + pdf1 := buildTestPDF(t, 2) + pdf2 := buildTestPDF(t, 3) + + merged, err := MergePDFs([]MergeSource{ + {Data: pdf1, FromPage: 0, ToPage: -1}, + {Data: pdf2, FromPage: 0, ToPage: -1}, + }, MergeConfig{}) + if err != nil { + t.Fatalf("MergePDFs: %v", err) + } + + r, err := NewReader(merged) + if err != nil { + t.Fatalf("NewReader on merged: %v", err) + } + count, err := r.PageCount() + if err != nil { + t.Fatalf("PageCount: %v", err) + } + if count != 5 { + t.Errorf("PageCount = %d, want 5", count) + } +} + +func TestMergePageRange(t *testing.T) { + pdf1 := buildTestPDF(t, 5) + + merged, err := MergePDFs([]MergeSource{ + {Data: pdf1, FromPage: 1, ToPage: 3}, // pages 2-4 (0-based) + }, MergeConfig{}) + if err != nil { + t.Fatalf("MergePDFs: %v", err) + } + + r, err := NewReader(merged) + if err != nil { + t.Fatalf("NewReader on merged: %v", err) + } + count, err := r.PageCount() + if err != nil { + t.Fatalf("PageCount: %v", err) + } + if count != 3 { + t.Errorf("PageCount = %d, want 3", count) + } +} + +func TestMergeEmptySources(t *testing.T) { + _, err := MergePDFs(nil, MergeConfig{}) + if err == nil { + t.Error("expected error for empty sources") + } +} + +func TestMergeSingleSource(t *testing.T) { + pdf1 := buildTestPDF(t, 3) + + merged, err := MergePDFs([]MergeSource{ + {Data: pdf1, FromPage: 0, ToPage: -1}, + }, MergeConfig{}) + if err != nil { + t.Fatalf("MergePDFs: %v", err) + } + + r, err := NewReader(merged) + if err != nil { + t.Fatalf("NewReader on merged: %v", err) + } + count, err := r.PageCount() + if err != nil { + t.Fatalf("PageCount: %v", err) + } + if count != 3 { + t.Errorf("PageCount = %d, want 3", count) + } +} + +func TestMergeWithMetadata(t *testing.T) { + pdf1 := buildTestPDF(t, 1) + + merged, err := MergePDFs([]MergeSource{ + {Data: pdf1, FromPage: 0, ToPage: -1}, + }, MergeConfig{ + Info: DocumentInfo{Title: "Test Title", Author: "Test Author"}, + }) + if err != nil { + t.Fatalf("MergePDFs: %v", err) + } + + r, err := NewReader(merged) + if err != nil { + t.Fatalf("NewReader on merged: %v", err) + } + + // Verify the Info dict exists in the trailer. + infoObj, ok := r.Trailer()[Name("Info")] + if !ok { + t.Fatal("merged PDF has no /Info in trailer") + } + infoDict, err := r.ResolveDict(infoObj) + if err != nil { + t.Fatalf("resolve Info: %v", err) + } + if title, ok := infoDict[Name("Title")]; !ok { + t.Error("Info dict missing /Title") + } else if title.(LiteralString) != "Test Title" { + t.Errorf("Title = %v, want 'Test Title'", title) + } +} + +func TestMergeInvalidPageRange(t *testing.T) { + pdf1 := buildTestPDF(t, 2) + + _, err := MergePDFs([]MergeSource{ + {Data: pdf1, FromPage: 0, ToPage: 5}, + }, MergeConfig{}) + if err == nil { + t.Error("expected error for out-of-range page") + } +} + +func TestMergeRoundTrip(t *testing.T) { + // Merge, read, merge again to verify output is valid PDF. + pdf1 := buildTestPDF(t, 2) + pdf2 := buildTestPDF(t, 1) + + merged1, err := MergePDFs([]MergeSource{ + {Data: pdf1, FromPage: 0, ToPage: -1}, + {Data: pdf2, FromPage: 0, ToPage: -1}, + }, MergeConfig{}) + if err != nil { + t.Fatalf("first merge: %v", err) + } + + // Merge the merged PDF with another. + merged2, err := MergePDFs([]MergeSource{ + {Data: merged1, FromPage: 0, ToPage: -1}, + {Data: pdf2, FromPage: 0, ToPage: -1}, + }, MergeConfig{}) + if err != nil { + t.Fatalf("second merge: %v", err) + } + + r, err := NewReader(merged2) + if err != nil { + t.Fatalf("NewReader on double-merged: %v", err) + } + count, err := r.PageCount() + if err != nil { + t.Fatalf("PageCount: %v", err) + } + if count != 4 { + t.Errorf("PageCount = %d, want 4", count) + } +} diff --git a/pdf/writer.go b/pdf/writer.go index 8e0a26e..25128c5 100644 --- a/pdf/writer.go +++ b/pdf/writer.go @@ -152,6 +152,18 @@ func (pw *Writer) AddPage(page PageObject) error { return nil } +// AddRawPage registers a pre-written page object reference in the page tree. +// The caller is responsible for writing the page dict with the correct /Parent. +func (pw *Writer) AddRawPage(ref ObjectRef) { + pw.pages = append(pw.pages, ref) +} + +// PageTreeRef returns the page tree object reference. +// This is used as the /Parent value for pages added via AddRawPage. +func (pw *Writer) PageTreeRef() ObjectRef { + return pw.pageTree +} + // ReserveFontRef reserves a font resource name and object reference for the // given font name without writing any PDF objects. This allows Type0/CIDFont // structures to be written later via OnBeforeClose hooks.