From 4e765e9eefdef9ba70da0b1321d3d96d3b982d76 Mon Sep 17 00:00:00 2001 From: Taiki Noda Date: Tue, 7 Apr 2026 21:33:25 +0900 Subject: [PATCH 1/8] feat: add AcroForm flatten support (#17) --- _examples/flatten/01_basic_flatten_test.go | 163 ++++++ .../flatten/02_preserve_non_widget_test.go | 144 +++++ _examples/flatten/03_no_forms_test.go | 47 ++ _examples/flatten/04_filled_form_test.go | 244 ++++++++ _examples/flatten/generate_testdata_test.go | 44 ++ .../testdata/01_basic_flatten_before.pdf | Bin 0 -> 1369 bytes .../02_preserve_non_widget_before.pdf | Bin 0 -> 1007 bytes .../testdata/04_filled_form_before.pdf | Bin 0 -> 6214 bytes pdf/flatten.go | 373 ++++++++++++ pdf/flatten_test.go | 537 ++++++++++++++++++ template/overlay.go | 7 + 11 files changed, 1559 insertions(+) create mode 100644 _examples/flatten/01_basic_flatten_test.go create mode 100644 _examples/flatten/02_preserve_non_widget_test.go create mode 100644 _examples/flatten/03_no_forms_test.go create mode 100644 _examples/flatten/04_filled_form_test.go create mode 100644 _examples/flatten/generate_testdata_test.go create mode 100644 _examples/flatten/testdata/01_basic_flatten_before.pdf create mode 100644 _examples/flatten/testdata/02_preserve_non_widget_before.pdf create mode 100644 _examples/flatten/testdata/04_filled_form_before.pdf create mode 100644 pdf/flatten.go create mode 100644 pdf/flatten_test.go diff --git a/_examples/flatten/01_basic_flatten_test.go b/_examples/flatten/01_basic_flatten_test.go new file mode 100644 index 0000000..af9d274 --- /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("testdata/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..77c9d36 --- /dev/null +++ b/_examples/flatten/04_filled_form_test.go @@ -0,0 +1,244 @@ +package flatten_test + +import ( + "bytes" + "fmt" + "os" + "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, err := os.ReadFile("testdata/04_filled_form_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, "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/testdata/01_basic_flatten_before.pdf b/_examples/flatten/testdata/01_basic_flatten_before.pdf new file mode 100644 index 0000000000000000000000000000000000000000..96c05596bb8beed425398f14cd8283fcab828ba2 GIT binary patch literal 1369 zcmbW1ZHwAK5XavK`W@ztU|+P^P28w(2^^?S-*>$b80h8BySoGbvnsas5d??9z$>j$a_@~&@M8`sT|+z8W)E>LtyKj~j&lE$tGAHuV3K;OSXa^bMvHf9Nk$`mgat!O!07&Y$s6{- zPgJQ7W~E9%puqtA`C(~)KPiNugnsJYzfYGtwXzM&4=Vpvfd(KP z8o)&#Qfa!`0MRAj|G;jcN;=7?XNL2W(I@#vwy5hDVGna8jv^}YOZpABM!g`QOLLX3 zUk4+ROIT1dB|yjtu07TT&Ia0P#1Qj~T4(ao{Gu<|yCAy$E+gM*9Z^Opet&MZ+l!jjr;h;8bVgJ@o z;DOfki|`n0+Ik@>bzaXJndQp3b1~64!&Es`ai0BAxKG-9X{E7l=SUZNt=423dw&50 C;Ak2E literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c429ccacba7fcf2b3c7fbb16f601ddeb4a4f7856 GIT binary patch literal 1007 zcmZ{jQE%EX5Xavm?K|8H5g88Iq=; zMY3dF?(YBd|LorUOK>A@J!=RY)M{hh-vfJ;WvrDI*FvK~ORfrrSFWqK~f9d&j(3e%}N!jfY|U@x1$zKe2Tp{&)R zN@NX4Gadt599H_zULt|2cEBRD|Mk4u$V6W-ewEud5XhE+4X#1Vs3)fj==EWBo2VVj_i1*M$~Uta7|IhJLOBG7%lQcRSd_op_B5~# zj@QMZe>E)0ln@9s#QDrrsbz_Ax+x*BCz%#;J4W};1>dnTZyr~ehUS~Bt5Ke2H;;4K zQK#hQ^3Bu%%LTCSMT!svM)L1AJ08TXqryc&B*vu?Of?Pjr#w<_;6 zJXSKZIQSNCKNz|0(1q+%6Z1G)Kp&KCY~-L%7ctK7u401YfiCjw(RII}cb(_j_+Xk^ lUB$&#R_48i3TH`H;8+&LUkT@j8&}$#$MYSl*L!#ltbYW-43Yo< literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..6733f1f283e8459c1cc33d3d67b059a6dd5a2c6b GIT binary patch literal 6214 zcmc&(S#R4$5Pm1n|FACx5}>lU7mp$cU^`Ze)`=ZSXd4)PpeERTy~i*6Yw{MRFUbMcfayV9hNTQ955m^O#VpG*ft3#F(N-~_RZpx=`G2|)cdDy!3S2CN&B?#*U^R{vHBuz8;@lW!mG6_nzM$p&x--4k3N(g?5 zmeDNQfuX1Q&|-*=^uLwIf#z2WEjrr^Er%8eZhI07&<1VYa4oQIF|tFNQq80e zYRWchKGb&qF{DBDAxKMi;JR+x<9h0zVmE0>wMAAr*9WbHhQz^5;NBi+8-B83+nS|8 zsT2DaD4#EB4})@=eSO;1KPj`~h@RPS!>a z+XzT9C}l)a^p$co=UD($_Gs|gna6;SzY=`R+l8K$V(2!G5usb5NBi=x9ephTqD38k ztcOknp`x20J+?P|FgZf$P@Iv~8;pq)hY?1Wgfd*jw*{=J3tVR1+Gle>WO{?sn)JGX zAk~rWcDl0~XxrGzP&lAo-rW&p)|d>x#060ps&0!T>&c{ARkQ19nd-xp_J)M6Ynb!=R-kN%I>m@>GWt28_XjW5tEz2~ehsC*BdPp?_LdW+D9n7vq z=umA}bui?2(`zp>uNXSMS6hFH(CK_^RGl)b4xPeqXVu}OK88;07dkjAF+zteS__@d zC#tI!EPwHmiPPinyVTlLCqDOGIc)aE~rtHP%``@ znSm!-RE7jeVJmbM%IsqGc+iwK 0 && bboxH > 0 { + sx = rectW / bboxW + sy = rectH / bboxH + } + + // Translate to rect origin, offset by BBox origin, scale to fit. + ops := 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)) + overlayContent = append(overlayContent, []byte(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 +} + +// 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..c4be120 --- /dev/null +++ b/pdf/flatten_test.go @@ -0,0 +1,537 @@ +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. + 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 (Mixed) Tj ET"), + }); err != nil { + t.Fatal(err) + } + + // Widget annotation. + apRef := w.AllocObject() + if err := w.WriteObject(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"), + }); 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("Field1"), + Name("Rect"): Array{Real(100), Real(600), Real(200), Real(620)}, + Name("AP"): Dict{Name("N"): apRef}, + }); err != nil { + t.Fatal(err) + } + + // Link annotation (non-widget, should be preserved). + linkRef := w.AllocObject() + if err := w.WriteObject(linkRef, Dict{ + Name("Type"): Name("Annot"), + Name("Subtype"): Name("Link"), + Name("Rect"): Array{Real(50), Real(400), Real(200), Real(420)}, + }); 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, linkRef}, + }); err != nil { + t.Fatal(err) + } + w.AddRawPage(pageRef) + + if err := w.Close(); err != nil { + t.Fatal(err) + } + data := buf.Bytes() + + 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") + } +} + +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() From d4d3294d509f7f88b7f6def45d8b4108a86c0b3d Mon Sep 17 00:00:00 2001 From: Taiki Noda Date: Tue, 7 Apr 2026 22:50:03 +0900 Subject: [PATCH 2/8] fix: use buildFilledFormPDF directly in flatten test to avoid stale testdata --- _examples/flatten/01_basic_flatten_test.go | 2 +- _examples/flatten/04_filled_form_test.go | 18 +++++++----------- _examples/flatten/helpers_test.go | 3 +++ .../testdata/04_filled_form_before.pdf | Bin 6214 -> 26176 bytes 4 files changed, 11 insertions(+), 12 deletions(-) create mode 100644 _examples/flatten/helpers_test.go diff --git a/_examples/flatten/01_basic_flatten_test.go b/_examples/flatten/01_basic_flatten_test.go index af9d274..e6c2465 100644 --- a/_examples/flatten/01_basic_flatten_test.go +++ b/_examples/flatten/01_basic_flatten_test.go @@ -120,7 +120,7 @@ func buildFormPDF(t *testing.T) []byte { } func TestExample_Flatten_01_BasicFlatten(t *testing.T) { - source, err := os.ReadFile("testdata/01_basic_flatten_before.pdf") + source, err := os.ReadFile(testdataDir + "/01_basic_flatten_before.pdf") if err != nil { t.Fatalf("read testdata: %v", err) } diff --git a/_examples/flatten/04_filled_form_test.go b/_examples/flatten/04_filled_form_test.go index 77c9d36..06131f6 100644 --- a/_examples/flatten/04_filled_form_test.go +++ b/_examples/flatten/04_filled_form_test.go @@ -3,7 +3,6 @@ package flatten_test import ( "bytes" "fmt" - "os" "testing" gpdf "github.com/gpdf-dev/gpdf" @@ -27,12 +26,12 @@ func escapePDFString(s string) string { // 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 + 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. @@ -188,10 +187,7 @@ func buildFilledFormPDF(t *testing.T) []byte { } func TestExample_Flatten_04_FilledForm(t *testing.T) { - source, err := os.ReadFile("testdata/04_filled_form_before.pdf") - if err != nil { - t.Fatalf("read testdata: %v", err) - } + source := buildFilledFormPDF(t) doc, err := gpdf.Open(source) if err != nil { 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/04_filled_form_before.pdf b/_examples/flatten/testdata/04_filled_form_before.pdf index 6733f1f283e8459c1cc33d3d67b059a6dd5a2c6b..0aca46e2fb5e08717ba6107b86478ab7350fe41a 100644 GIT binary patch literal 26176 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ℜ?P90a6SG}A{d!Ry4T8z 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 Date: Tue, 7 Apr 2026 13:51:17 +0000 Subject: [PATCH 3/8] chore: bump version to v1.0.4 --- internal/buildinfo/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From dc45fd93d8aa1ebc57dd15062f1e229692ebdff9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 13:52:46 +0000 Subject: [PATCH 4/8] update coverage badge to 86.1% --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 24a7295..e6828e9 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/) From f5d82df586133e68ba1fbad7ae1ff9d47b9f0d85 Mon Sep 17 00:00:00 2001 From: Taiki Noda Date: Tue, 7 Apr 2026 22:56:32 +0900 Subject: [PATCH 5/8] refactor: reduce cyclomatic complexity in flatten.go and flatten_test.go --- pdf/flatten.go | 122 +++++++++++++++++++------------------- pdf/flatten_test.go | 140 ++++++++++++++++++++++---------------------- 2 files changed, 134 insertions(+), 128 deletions(-) diff --git a/pdf/flatten.go b/pdf/flatten.go index 63b5fec..f665256 100644 --- a/pdf/flatten.go +++ b/pdf/flatten.go @@ -93,67 +93,11 @@ func (m *Modifier) flattenPageAnnotations(pageIndex int) error { xobjIndex := 0 for _, annotObj := range annotsArr { - annotDict, err := r.ResolveDict(annotObj) - if err != nil { - remainingAnnots = append(remainingAnnots, annotObj) - continue - } - - // Check if this is a widget annotation (form field). - if !m.isWidgetAnnotation(annotDict) { - remainingAnnots = append(remainingAnnots, annotObj) - continue - } - - // Get appearance stream. - apStream, err := m.resolveAppearanceStream(annotDict) - if err != nil || apStream == nil { - // No appearance stream — just remove the annotation. - continue - } - - // Get annotation rect. - rect, err := m.resolveAnnotRect(annotDict) - if err != nil { + ops, kept := m.flattenAnnotation(annotObj, xobjDict, &xobjIndex) + if kept { remainingAnnots = append(remainingAnnots, annotObj) - continue } - - // Register the appearance stream as an XObject. - xobjName := Name(fmt.Sprintf("_Flat%d", xobjIndex)) - xobjIndex++ - - xobjRef := m.AllocObject() - - // Build the Form XObject from the appearance stream. - formXObj := m.buildFormXObject(apStream, rect) - m.SetObject(xobjRef, formXObj) - xobjDict[xobjName] = xobjRef - - // Generate content stream operators to draw the XObject. - // Position at the annotation's rect origin, scaling BBox to fit Rect. - rectW := rect.URX - rect.LLX - rectH := rect.URY - rect.LLY - if rectW <= 0 || rectH <= 0 { - continue - } - - // 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. - ops := 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)) - overlayContent = append(overlayContent, []byte(ops)...) + overlayContent = append(overlayContent, ops...) } if len(overlayContent) == 0 && len(remainingAnnots) == len(annotsArr) { @@ -183,6 +127,66 @@ func (m *Modifier) flattenPageAnnotations(pageIndex int) error { 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")] diff --git a/pdf/flatten_test.go b/pdf/flatten_test.go index c4be120..a450cee 100644 --- a/pdf/flatten_test.go +++ b/pdf/flatten_test.go @@ -306,75 +306,7 @@ func TestFlattenFormsCheckbox(t *testing.T) { func TestFlattenFormsMixedAnnotations(t *testing.T) { // Build a PDF with both a widget and a non-widget annotation. - 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 (Mixed) Tj ET"), - }); err != nil { - t.Fatal(err) - } - - // Widget annotation. - apRef := w.AllocObject() - if err := w.WriteObject(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"), - }); 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("Field1"), - Name("Rect"): Array{Real(100), Real(600), Real(200), Real(620)}, - Name("AP"): Dict{Name("N"): apRef}, - }); err != nil { - t.Fatal(err) - } - - // Link annotation (non-widget, should be preserved). - linkRef := w.AllocObject() - if err := w.WriteObject(linkRef, Dict{ - Name("Type"): Name("Annot"), - Name("Subtype"): Name("Link"), - Name("Rect"): Array{Real(50), Real(400), Real(200), Real(420)}, - }); 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, linkRef}, - }); err != nil { - t.Fatal(err) - } - w.AddRawPage(pageRef) - - if err := w.Close(); err != nil { - t.Fatal(err) - } - data := buf.Bytes() + data := buildTestPDFWithMixedAnnotations(t) r, err := NewReader(data) if err != nil { @@ -428,6 +360,76 @@ func TestFlattenFormsMixedAnnotations(t *testing.T) { } } +// 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) From 782bd45487e68be8bb53850e87b2afa89b784533 Mon Sep 17 00:00:00 2001 From: Taiki Noda Date: Tue, 7 Apr 2026 23:10:14 +0900 Subject: [PATCH 6/8] docs: sync multilingual READMEs with English and add form flattening section --- README.md | 18 ++++ README_es.md | 251 +++++++++++++++++++++++++++++++++++++++++++++++++++ README_ja.md | 117 ++++++++++++++++++++++++ README_ko.md | 243 +++++++++++++++++++++++++++++++++++++++++++++++++ README_pt.md | 251 +++++++++++++++++++++++++++++++++++++++++++++++++++ README_zh.md | 243 +++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 1123 insertions(+) diff --git a/README.md b/README.md index e6828e9..5b03256 100644 --- a/README.md +++ b/README.md @@ -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)` | 设置签名时间 | + ### 模板生成 | 函数 | 说明 | From 50ef5265bdfbfa0b2b37da4e9e4638b2d6197f0e Mon Sep 17 00:00:00 2001 From: Taiki Noda Date: Tue, 7 Apr 2026 23:18:20 +0900 Subject: [PATCH 7/8] docs: sync multilingual READMEs and add form flattening documentation --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- _benchmark/go.mod | 12 +++++------ _benchmark/go.sum | 30 +++++++++++++-------------- _validation/go.mod | 4 ++-- _validation/go.sum | 8 +++---- 5 files changed, 28 insertions(+), 28 deletions(-) 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/_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/_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= From 67991331b194e598253f7640fb1e8669b7a63334 Mon Sep 17 00:00:00 2001 From: Taiki Noda Date: Tue, 7 Apr 2026 23:21:28 +0900 Subject: [PATCH 8/8] docs: update CHANGELOG.md for v1.0.4 release --- CHANGELOG.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) 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