From 49d66b652ca0f494c02c96e9b62df53b499ba1d7 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Thu, 22 Oct 2026 10:03:42 +0200 Subject: [PATCH] A form can be added to a document Doc.AddFields and the addfields verb: the other half of Fill, which can only change a field the document already has. This is what BentoPDF's form-creator needs, and the fleet had every other piece already. Fields are held until the write, because a widget names the page OBJECT it sits on and the pages have no object numbers until then. A field showing in one place is one object, which is what nearly every producer writes; a field showing on several pages becomes a parent with one Kid per place. Two details the format makes easy to get wrong, both held by tests: A checkbox's drawing is not generated by the viewer. NeedAppearances covers text and choice fields, but a button shows whatever its own appearance dictionary holds for the state it is in, so one written without them is a box that never looks ticked however often it is clicked. One is drawn per state with the path operators rather than with a font, so the document needs no ZapfDingbats to show a tick. A widget is marked Print. Without it the field is on screen and gone from the paper, which is where a filled form is most often looked at. A document that already has a form is joined rather than replaced: replacing it would drop every field the file came with, and the file would still open. A document assembled from several files can still be given a form, because fields the caller asked for come from neither source and so are in no conflict. The description the verb reads is JSON, since the thing being described is a layout and a command line is a poor place to put one. Unknown keys are refused: "mutliline" is a form missing the thing it was written to ask for, and a parser that ignores what it does not know says nothing about it. The list lengths are bounded, because a small file otherwise asks for an enormous document and all the memory to build it. The oracle throughout is the reading half -- forms.Read and the fields verb -- rather than these tests' own expectations. Measured: 100% of statements and 0 uncovered blocks in both packages, counted on the profile rather than the rounded percentage. Three mutations caught (the checkbox appearance never written; NeedAppearances not set; the existing form replaced instead of joined). runAddFields uses the c.open and save helpers every other verb uses rather than its own copy of the password, the write and their error paths. Co-Authored-By: Claude Opus 5 --- README.md | 62 ++++- addfields.go | 217 +++++++++++++++++ addfields_test.go | 437 +++++++++++++++++++++++++++++++++++ catalogue.go | 14 +- cmd/pdfops/addfields.go | 154 ++++++++++++ cmd/pdfops/addfields_test.go | 228 ++++++++++++++++++ cmd/pdfops/run.go | 1 + doc.go | 6 + go.mod | 2 +- go.sum | 4 +- write.go | 7 +- 11 files changed, 1125 insertions(+), 7 deletions(-) create mode 100644 addfields.go create mode 100644 addfields_test.go create mode 100644 cmd/pdfops/addfields.go create mode 100644 cmd/pdfops/addfields_test.go diff --git a/README.md b/README.md index fdbf484..3969a21 100644 --- a/README.md +++ b/README.md @@ -693,7 +693,8 @@ BSD-3-Clause — see [LICENSE](LICENSE). Copyright the go-pdfkit/ops authors. ## Forms `pdfops fields` lists what a form asks for and what it holds; `pdfops fill` -fills it in and saves it. +fills it in and saves it; `pdfops addfields` puts new fields into a document +that has none, or adds to the form it already carries. ``` $ pdfops fields fw9.pdf @@ -707,6 +708,65 @@ $ pdfops fill -set 'topmostSubform[0].Page1[0].f1_01[0]=Wolfgang Amadeus Mozart' fw9.pdf filled.pdf ``` +### Creating a form + +`pdfops addfields` is the other half of `fill`, which can only change a field +the document already has. The fields are described in a JSON file, because the +thing being described is a layout and a command line is a poor place to put +one. + +``` +$ cat fields.json +[ + {"name": "applicant", "kind": "text", "required": true, + "widgets": [{"page": 1, "rect": [72, 700, 300, 720]}]}, + {"name": "agree", "kind": "checkbox", "value": "Yes", + "widgets": [{"page": 1, "rect": [72, 670, 86, 684], "on": "Yes"}]}, + {"name": "size", "kind": "combo", + "options": [{"value": "s", "label": "Small"}, {"value": "l", "label": "Large"}], + "widgets": [{"page": 1, "rect": [72, 630, 220, 650]}]} +] + +$ pdfops addfields -fields fields.json blank.pdf form.pdf +added 3 field(s) +$ pdfops fields form.pdf +applicant text "" required +agree checkbox "Yes" buttons [Yes] +size combo "" row "s" row "l" +``` + +The kinds are `text`, `checkbox`, `radio`, `button`, `combo`, `list` and +`signature` — the names `pdfops fields` prints, so a description can be written +from a listing. A field showing in several places is **one** field with several +widgets, which is what a radio group is. + +It **refuses before it writes**, because each of these makes a file that opens +and is broken: a field with no name (a field is addressed by its name), a choice +field with no rows, a checkbox whose on-state is `Off` (the name the format +reserves for *not chosen*), two widgets of one radio group in the same state, a +comb field with no `maxLen`, an empty rectangle, a page the document does not +have, and **two fields of one name** — the reader keys fields by name, so the +second would silently shadow the first. + +Unknown keys in the description are refused too: `"mutliline"` is a form missing +the thing it was written to ask for, and a parser that ignores what it does not +know says nothing about it. + +Two details the format makes easy to get wrong, both held by tests: + +- **A checkbox's drawing is not generated by the viewer.** `NeedAppearances` + covers text and choice fields, but a button shows whatever its own appearance + dictionary holds for the state it is in, so one written without them is a box + that never looks ticked however often it is clicked. One is drawn for each + state, with the path operators rather than with a font, so the document needs + no ZapfDingbats to show a tick. +- **A widget is marked Print.** Without it the field is on screen and gone from + the paper, which is where a filled form is most often looked at. + +A document assembled from several files can still be given a form: two sources' +forms cannot be merged — their fields may be named the same — but fields the +caller *asked for* come from neither source and so are in no conflict. + A filled form is written as an **incremental update**: the original file, byte for byte, with the objects that changed appended after it and a new cross-reference section pointing back at the old one. That is how every program diff --git a/addfields.go b/addfields.go new file mode 100644 index 0000000..76b570c --- /dev/null +++ b/addfields.go @@ -0,0 +1,217 @@ +package ops + +import ( + "fmt" + + "github.com/go-pdfkit/forms" + "github.com/go-pdfkit/reader" +) + +// AddFields puts new form fields into the document: the other half of Fill, +// which can only change a field that is already there. +// +// ⛔ IT REFUSES BEFORE THE DOCUMENT IS WRITTEN. forms.CheckAll catches the +// descriptions that make a file which OPENS and is broken — a field with no +// name, a choice with no rows, a comb with no MaxLen, two fields of one name — +// and the page numbers are checked here, where the page count is known. +// +// The fields are held until the write, because a widget names the page object +// it sits on and the pages do not have numbers until then. +func (d *Doc) AddFields(fields ...forms.Field) error { + if err := forms.CheckAll(append(append([]forms.Field{}, d.addFields...), fields...)); err != nil { + return err + } + for _, f := range fields { + for i, wdg := range f.Widgets { + if wdg.Page > len(d.pages) { + return fmt.Errorf("widget %d of %q is on page %d and the document has %d", + i+1, f.Name, wdg.Page, len(d.pages)) + } + } + } + d.addFields = append(d.addFields, fields...) + return nil +} + +// addedFields writes the widgets of every added field onto the pages they name +// and returns the field objects, for the AcroForm to list. +// +// The widget and the field are ONE object when a field shows in a single place, +// which is what the format allows and what nearly every producer writes; a +// field showing on several pages becomes a parent with one Kid per place. +func (d *Doc) addedFields(w *reader.Writer, refs []reader.Ref, dicts []reader.Dict) reader.Array { + if len(d.addFields) == 0 { + return nil + } + var out reader.Array + for _, f := range d.addFields { + field := forms.Dict(f) + switch len(f.Widgets) { + case 0: + // A field with nowhere to show is still a field: it holds a value + // and can be filled, it simply cannot be clicked. Writing it keeps + // the document's own account of its fields complete. + out = append(out, w.Add(field)) + case 1: + ref := w.Reserve() + mergeWidget(field, f, f.Widgets[0], refs, w) + w.Put(ref, field) + attach(dicts[f.Widgets[0].Page-1], ref) + out = append(out, ref) + default: + parent := w.Reserve() + kids := make(reader.Array, 0, len(f.Widgets)) + for _, wdg := range f.Widgets { + kid := reader.Dict{"Parent": parent} + mergeWidget(kid, f, wdg, refs, w) + ref := w.Add(kid) + attach(dicts[wdg.Page-1], ref) + kids = append(kids, ref) + } + field["Kids"] = kids + w.Put(parent, field) + out = append(out, parent) + } + } + return out +} + +// mergeWidget writes the annotation half of a widget into a dictionary, which +// is either the field itself or one of its Kids. +func mergeWidget(into reader.Dict, f forms.Field, wdg forms.Widget, refs []reader.Ref, w *reader.Writer) { + into["Type"] = reader.Name("Annot") + into["Subtype"] = reader.Name("Widget") + into["Rect"] = reader.Array{ + reader.Real(wdg.Rect[0]), reader.Real(wdg.Rect[1]), + reader.Real(wdg.Rect[2]), reader.Real(wdg.Rect[3]), + } + if wdg.Page >= 1 && wdg.Page <= len(refs) { + into["P"] = refs[wdg.Page-1] + } + // Print, so the field is on paper as well as on screen. A widget without + // it is a field that vanishes when the form is printed, which is the one + // place a filled form is most often looked at. + flags := int64(1 << 2) + if wdg.Hidden { + flags |= 1 << 1 + } + into["F"] = reader.Integer(flags) + + // ⛔ A BUTTON'S DRAWING IS NOT GENERATED BY THE VIEWER. NeedAppearances + // covers text and choice fields; a checkbox or radio button shows whatever + // its own appearance dictionary holds for the state it is in, so one + // written without them is a box that never looks ticked however often it + // is clicked. + if f.Kind == forms.Checkbox || f.Kind == forms.Radio { + on := wdg.On + width := wdg.Rect[2] - wdg.Rect[0] + height := wdg.Rect[3] - wdg.Rect[1] + into["AP"] = reader.Dict{"N": reader.Dict{ + reader.Name(on): w.Add(markStream(width, height, f.Kind)), + "Off": w.Add(emptyStream(width, height)), + }} + // The state this widget is in. A field whose value names this widget's + // on-state is the one that is chosen. + if f.Value == on && on != "" { + into["AS"] = reader.Name(on) + } else { + into["AS"] = reader.Name("Off") + } + into["MK"] = reader.Dict{"BC": reader.Array{reader.Real(0)}} + } +} + +// attach adds one annotation to a page's list, making the list if the page has +// none yet. +// +// It takes the page's dictionary rather than the list and an index: AddFields +// has already refused a widget whose page the document does not have, and +// forms.Check one whose page is before the first, so a bounds test here would +// be a branch nothing can reach — dead code that reads like a safeguard. +func attach(page reader.Dict, ref reader.Ref) { + list, _ := reader.ToArray(page["Annots"]) + page["Annots"] = append(list, ref) +} + +// markStream is what a ticked box shows: a cross for a checkbox and a filled +// disc for a radio button, drawn with the operators rather than with a font so +// that the document needs no ZapfDingbats to show it. +func markStream(w, h float64, k forms.Kind) *reader.Stream { + var body []byte + if k == forms.Radio { + // A disc, as four Bézier arcs, inset from the edge. + cx, cy := w/2, h/2 + r := min(w, h)/2 - 1.5 + if r <= 0 { + r = min(w, h) / 2 + } + c := r * 0.5523 + body = []byte(fmt.Sprintf("0 g\n%.2f %.2f m\n"+ + "%.2f %.2f %.2f %.2f %.2f %.2f c\n"+ + "%.2f %.2f %.2f %.2f %.2f %.2f c\n"+ + "%.2f %.2f %.2f %.2f %.2f %.2f c\n"+ + "%.2f %.2f %.2f %.2f %.2f %.2f c\nf\n", + cx+r, cy, + cx+r, cy+c, cx+c, cy+r, cx, cy+r, + cx-c, cy+r, cx-r, cy+c, cx-r, cy, + cx-r, cy-c, cx-c, cy-r, cx, cy-r, + cx+c, cy-r, cx+r, cy-c, cx+r, cy)) + } else { + in := 2.0 + if w < 6 || h < 6 { + in = 1 + } + body = []byte(fmt.Sprintf("0 G\n1.2 w\n%.2f %.2f m %.2f %.2f l S\n%.2f %.2f m %.2f %.2f l S\n", + in, in, w-in, h-in, in, h-in, w-in, in)) + } + return appearance(w, h, body) +} + +func emptyStream(w, h float64) *reader.Stream { return appearance(w, h, nil) } + +// appearance is a form XObject the size of the widget it draws into. +func appearance(w, h float64, body []byte) *reader.Stream { + return &reader.Stream{ + Dict: reader.Dict{ + "Type": reader.Name("XObject"), + "Subtype": reader.Name("Form"), + "FormType": reader.Integer(1), + "BBox": reader.Array{reader.Real(0), reader.Real(0), reader.Real(w), reader.Real(h)}, + "Resources": reader.Dict{}, + }, + Raw: body, + } +} + +// acroFormFor is the form dictionary of a document that is having fields added +// to it: the one it already had with the new fields appended, or a new one. +// +// ⛔ NeedAppearances IS SET, and it is not a nicety. A text or choice field +// written here has no appearance stream — what a field SHOWS is a little +// content stream beside it, not its value — so without this the form opens +// with every box empty however it was filled in. The alternative is to draw +// them, which needs the font the viewer will use, and the viewer is better +// placed to know it than we are. +func acroFormFor(w *reader.Writer, existing reader.Dict, added reader.Array) reader.Dict { + form := existing + if form == nil { + form = reader.Dict{} + } + fields, _ := reader.ToArray(form["Fields"]) + form["Fields"] = append(fields, added...) + form["NeedAppearances"] = reader.Bool(true) + if _, ok := form["DA"]; !ok { + form["DA"] = reader.String("/Helv 0 Tf 0 g") + } + if _, ok := form["DR"]; !ok { + form["DR"] = reader.Dict{"Font": reader.Dict{ + "Helv": w.Add(reader.Dict{ + "Type": reader.Name("Font"), + "Subtype": reader.Name("Type1"), + "BaseFont": reader.Name("Helvetica"), + "Encoding": reader.Name("WinAnsiEncoding"), + }), + }} + } + return form +} diff --git a/addfields_test.go b/addfields_test.go new file mode 100644 index 0000000..402cb29 --- /dev/null +++ b/addfields_test.go @@ -0,0 +1,437 @@ +package ops + +import ( + "strings" + "testing" + + "github.com/go-pdfkit/forms" + "github.com/go-pdfkit/reader" +) + +// readBackForm writes the document and reads its form with the package that +// reads every form in the fleet. +// +// ⛔ THE ORACLE IS THE READER, not this file's expectations. A field written +// as something the format spells differently comes back as the wrong kind or +// not at all, rather than as a passing test. +func readBackForm(t *testing.T, d *Doc) *forms.Form { + t.Helper() + b, err := d.Bytes() + if err != nil { + t.Fatalf("writing the document: %v", err) + } + doc, err := reader.Open(b) + if err != nil { + t.Fatalf("the document this wrote cannot be opened: %v", err) + } + f, ok := forms.Read(doc) + if !ok { + t.Fatal("a document that was given fields has no form in it") + } + return f +} + +func rect(x, y, w, h float64) [4]float64 { return [4]float64{x, y, x + w, y + h} } + +// pdfWithAForm is a one-page document that already carries a form, so that +// adding to it can be told apart from replacing it. +func pdfWithAForm(t *testing.T) []byte { + t.Helper() + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + pageRef := w.Reserve() + field := w.Add(reader.Dict{ + "FT": reader.Name("Tx"), "T": reader.String("name"), + "Type": reader.Name("Annot"), "Subtype": reader.Name("Widget"), + "Rect": reader.Array{reader.Real(20), reader.Real(100), reader.Real(180), reader.Real(130)}, + "P": pageRef, + }) + w.Put(pageRef, reader.Dict{ + "Type": reader.Name("Page"), "Parent": pagesRef, + "MediaBox": reader.Array{reader.Integer(0), reader.Integer(0), reader.Integer(200), reader.Integer(200)}, + "Contents": w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte("")}), + "Annots": reader.Array{field}, + }) + w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), + "Kids": reader.Array{pageRef}, "Count": reader.Integer(1)}) + root := w.Add(reader.Dict{"Type": reader.Name("Catalog"), "Pages": pagesRef, + "AcroForm": w.Add(reader.Dict{ + "Fields": reader.Array{field}, + "DA": reader.String("/Helv 0 Tf 0 g"), + })}) + out, err := w.Finish(reader.Dict{"Root": root}) + if err != nil { + t.Fatal(err) + } + return out +} + +func TestFieldsAddedToADocumentWithNoFormCanBeReadBack(t *testing.T) { + d, err := Open(buildPDF(t, 2, nil)) + if err != nil { + t.Fatal(err) + } + want := []forms.Field{ + {Name: "name", Kind: forms.Text, Widgets: []forms.Widget{{Page: 1, Rect: rect(20, 100, 160, 20)}}}, + {Name: "agree", Kind: forms.Checkbox, Value: "Yes", + Widgets: []forms.Widget{{Page: 1, Rect: rect(20, 60, 14, 14), On: "Yes"}}}, + {Name: "size", Kind: forms.ComboBox, + Options: []forms.Option{{Value: "s", Label: "Small"}, {Value: "l", Label: "Large"}}, + Widgets: []forms.Widget{{Page: 2, Rect: rect(20, 100, 120, 20)}}}, + } + if err := d.AddFields(want...); err != nil { + t.Fatalf("AddFields refused: %v", err) + } + f := readBackForm(t, d) + + if got := len(f.Fields()); got != len(want) { + t.Fatalf("added %d fields and read %d back", len(want), got) + } + for _, w := range want { + got, ok := f.Field(w.Name) + if !ok { + t.Errorf("%q was added and cannot be found", w.Name) + continue + } + if got.Kind != w.Kind { + t.Errorf("%q was added as a %s and read back as a %s", w.Name, w.Kind, got.Kind) + } + if len(got.Widgets) != 1 { + t.Errorf("%q has %d widgets, wanted one", w.Name, len(got.Widgets)) + continue + } + if got.Widgets[0].Page != w.Widgets[0].Page { + t.Errorf("%q is on page %d, was put on page %d", + w.Name, got.Widgets[0].Page, w.Widgets[0].Page) + } + if got.Widgets[0].Rect != w.Widgets[0].Rect { + t.Errorf("%q is at %v, was put at %v", w.Name, got.Widgets[0].Rect, w.Widgets[0].Rect) + } + } +} + +// ⛔ A FIELD ADDED TO A DOCUMENT THAT ALREADY HAS A FORM JOINS IT. Replacing +// the form would drop every field the file came with — and the file would open, +// with the old fields simply gone. +func TestAFieldAddedToADocumentThatHasAFormJoinsIt(t *testing.T) { + d, err := Open(pdfWithAForm(t)) + if err != nil { + t.Fatal(err) + } + before, err := Open(pdfWithAForm(t)) + if err != nil { + t.Fatal(err) + } + was := len(readBackForm(t, before).Fields()) + if was == 0 { + t.Fatal("the fixture has no form, so this proves nothing") + } + + if err := d.AddFields(forms.Field{ + Name: "added", Kind: forms.Text, + Widgets: []forms.Widget{{Page: 1, Rect: rect(10, 10, 80, 16)}}, + }); err != nil { + t.Fatal(err) + } + f := readBackForm(t, d) + if got := len(f.Fields()); got != was+1 { + t.Errorf("the form had %d fields and has %d after adding one", was, got) + } + if _, ok := f.Field("added"); !ok { + t.Error("the added field is not there") + } + if _, ok := f.Field("name"); !ok { + t.Error("a field the document came with was lost") + } +} + +// A field showing in several places is one field with several widgets, which +// is a parent and its Kids in the file. +func TestAFieldShowingOnTwoPagesIsOneFieldWithTwoWidgets(t *testing.T) { + d, err := Open(buildPDF(t, 2, nil)) + if err != nil { + t.Fatal(err) + } + if err := d.AddFields(forms.Field{ + Name: "initials", Kind: forms.Text, + Widgets: []forms.Widget{ + {Page: 1, Rect: rect(10, 10, 60, 16)}, + {Page: 2, Rect: rect(10, 10, 60, 16)}, + }, + }); err != nil { + t.Fatal(err) + } + f := readBackForm(t, d) + if got := len(f.Fields()); got != 1 { + t.Fatalf("read %d fields, wanted one", got) + } + got, ok := f.Field("initials") + if !ok { + t.Fatal("the field is gone") + } + if len(got.Widgets) != 2 { + t.Fatalf("read %d widgets, wanted two", len(got.Widgets)) + } + if got.Widgets[0].Page == got.Widgets[1].Page { + t.Errorf("both widgets are on page %d", got.Widgets[0].Page) + } +} + +// ⛔ A BUTTON'S DRAWING IS NOT GENERATED BY THE VIEWER. NeedAppearances covers +// text and choice fields; a checkbox shows whatever its own appearance +// dictionary holds for the state it is in, so one written without them is a +// box that never looks ticked however often it is clicked. +func TestACheckboxIsWrittenWithADrawingForEachState(t *testing.T) { + d, err := Open(buildPDF(t, 1, nil)) + if err != nil { + t.Fatal(err) + } + if err := d.AddFields(forms.Field{ + Name: "agree", Kind: forms.Checkbox, Value: "Yes", + Widgets: []forms.Widget{{Page: 1, Rect: rect(20, 20, 14, 14), On: "Yes"}}, + }); err != nil { + t.Fatal(err) + } + b, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + doc, err := reader.Open(b) + if err != nil { + t.Fatal(err) + } + page, err := doc.Page(1) + if err != nil { + t.Fatal(err) + } + annots, ok := reader.ToArray(mustResolve(t, doc, page.Get("Annots"))) + if !ok || len(annots) != 1 { + t.Fatalf("the page carries %v, wanted one annotation", annots) + } + w, ok := reader.ToDict(mustResolve(t, doc, annots[0])) + if !ok { + t.Fatal("the annotation is not a dictionary") + } + ap, ok := reader.ToDict(mustResolve(t, doc, w.Get("AP"))) + if !ok { + t.Fatal("the checkbox has no appearance dictionary, so it can never look ticked") + } + n, ok := reader.ToDict(mustResolve(t, doc, ap.Get("N"))) + if !ok { + t.Fatal("the appearance dictionary has no normal appearance") + } + for _, state := range []string{"Yes", "Off"} { + if n.Get(reader.Name(state)) == nil { + t.Errorf("there is no drawing for the state %q", state) + } + } + if got := w.Get("AS"); got != reader.Name("Yes") { + t.Errorf("the widget is in state %v, and the field's value chose Yes", got) + } +} + +// The form a viewer is asked to draw for itself: a text field written here has +// no appearance stream, so without this the form opens with every box empty +// however it was filled in. +func TestTheFormAsksTheViewerToDrawTheAppearances(t *testing.T) { + d, err := Open(buildPDF(t, 1, nil)) + if err != nil { + t.Fatal(err) + } + if err := d.AddFields(forms.Field{Name: "a", Kind: forms.Text, + Widgets: []forms.Widget{{Page: 1, Rect: rect(10, 10, 80, 16)}}}); err != nil { + t.Fatal(err) + } + b, err := d.Bytes() + if err != nil { + t.Fatal(err) + } + doc, err := reader.Open(b) + if err != nil { + t.Fatal(err) + } + cat, err := doc.Catalog() + if err != nil { + t.Fatal(err) + } + form, ok := doc.GetDict(cat, "AcroForm") + if !ok { + t.Fatal("there is no AcroForm") + } + if v, _ := reader.ToBool(mustResolve(t, doc, form.Get("NeedAppearances"))); !v { + t.Error("NeedAppearances is not set, so a filled text field shows nothing") + } + if form.Get("DA") == nil { + t.Error("the form names no default appearance, so there is no font to draw a value with") + } +} + +// A field with nowhere to show is still a field: it holds a value and can be +// filled, it simply cannot be clicked. Writing it keeps the document's own +// account of its fields complete. +func TestAFieldWithNoWidgetIsStillWritten(t *testing.T) { + d, err := Open(buildPDF(t, 1, nil)) + if err != nil { + t.Fatal(err) + } + if err := d.AddFields(forms.Field{Name: "hidden", Kind: forms.Text, Value: "kept"}); err != nil { + t.Fatal(err) + } + f := readBackForm(t, d) + got, ok := f.Field("hidden") + if !ok { + t.Fatal("a field with no widget was not written") + } + if got.Value != "kept" { + t.Errorf("its value came back %q", got.Value) + } +} + +// A radio button's mark is a disc rather than a cross, and a box too small for +// the inset still gets one that fits. +func TestARadioButtonAndATinyBoxBothGetAMark(t *testing.T) { + d, err := Open(buildPDF(t, 1, nil)) + if err != nil { + t.Fatal(err) + } + if err := d.AddFields( + forms.Field{Name: "size", Kind: forms.Radio, Value: "S", Widgets: []forms.Widget{ + {Page: 1, Rect: rect(10, 10, 12, 12), On: "S"}, + {Page: 1, Rect: rect(30, 10, 2, 2), On: "L"}, // smaller than the inset + }}, + forms.Field{Name: "tiny", Kind: forms.Checkbox, + Widgets: []forms.Widget{{Page: 1, Rect: rect(60, 10, 4, 4), On: "Yes"}}}, + ); err != nil { + t.Fatal(err) + } + f := readBackForm(t, d) + if got, ok := f.Field("size"); !ok || len(got.Widgets) != 2 { + t.Fatalf("the radio group came back as %v", got) + } + if _, ok := f.Field("tiny"); !ok { + t.Error("the small checkbox is gone") + } +} + +// A widget the document asks not to draw is written with the hidden flag, and +// the reader reports it as hidden. +func TestAHiddenWidgetIsWrittenHidden(t *testing.T) { + d, err := Open(buildPDF(t, 1, nil)) + if err != nil { + t.Fatal(err) + } + if err := d.AddFields(forms.Field{Name: "a", Kind: forms.Text, + Widgets: []forms.Widget{{Page: 1, Rect: rect(10, 10, 80, 16), Hidden: true}}}); err != nil { + t.Fatal(err) + } + f := readBackForm(t, d) + got, ok := f.Field("a") + if !ok || len(got.Widgets) != 1 { + t.Fatal("the field is gone") + } + if !got.Widgets[0].Hidden { + t.Error("the widget came back visible") + } +} + +// A checkbox whose value does not name this widget's state is in Off, which is +// the other arm of the branch that chooses the state. +func TestACheckboxThatIsNotTickedIsInTheOffState(t *testing.T) { + d, err := Open(buildPDF(t, 1, nil)) + if err != nil { + t.Fatal(err) + } + if err := d.AddFields(forms.Field{Name: "agree", Kind: forms.Checkbox, + Widgets: []forms.Widget{{Page: 1, Rect: rect(10, 10, 14, 14), On: "Yes"}}}); err != nil { + t.Fatal(err) + } + f := readBackForm(t, d) + got, ok := f.Field("agree") + if !ok { + t.Fatal("the field is gone") + } + if got.Value != "Off" { + t.Errorf("an unticked checkbox holds %q", got.Value) + } +} + +func TestAddFieldsRefusesWhatCannotBeWritten(t *testing.T) { + ok := forms.Widget{Page: 1, Rect: rect(10, 10, 80, 16)} + for _, c := range []struct { + why string + fields []forms.Field + want string + }{ + {"a page the document does not have", + []forms.Field{{Name: "a", Kind: forms.Text, + Widgets: []forms.Widget{{Page: 9, Rect: ok.Rect}}}}, "has 2"}, + {"no name", []forms.Field{{Kind: forms.Text, Widgets: []forms.Widget{ok}}}, "no name"}, + {"two fields of one name", []forms.Field{ + {Name: "a", Kind: forms.Text, Widgets: []forms.Widget{ok}}, + {Name: "a", Kind: forms.Text, Widgets: []forms.Widget{ok}}}, "both named"}, + {"a choice with no rows", + []forms.Field{{Name: "a", Kind: forms.ComboBox, Widgets: []forms.Widget{ok}}}, "no options"}, + } { + d, err := Open(buildPDF(t, 2, nil)) + if err != nil { + t.Fatal(err) + } + err = d.AddFields(c.fields...) + if err == nil { + t.Errorf("%s was accepted", c.why) + continue + } + if !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: the refusal says %q, which does not mention %q", c.why, err, c.want) + } + } +} + +// ⛔ A SECOND CALL IS CHECKED AGAINST THE FIRST. Two fields of one name added +// in two calls are the same silent shadowing as two in one call, and checking +// only the new ones would let it through. +func TestASecondCallCannotRepeatANameTheFirstUsed(t *testing.T) { + d, err := Open(buildPDF(t, 1, nil)) + if err != nil { + t.Fatal(err) + } + f := forms.Field{Name: "a", Kind: forms.Text, + Widgets: []forms.Widget{{Page: 1, Rect: rect(10, 10, 80, 16)}}} + if err := d.AddFields(f); err != nil { + t.Fatal(err) + } + if err := d.AddFields(f); err == nil { + t.Fatal("a name used by an earlier call was accepted") + } + // And the refusal left the first call's field alone. + if got := len(readBackForm(t, d).Fields()); got != 1 { + t.Errorf("the document has %d fields after a refused call, wanted one", got) + } +} + +// Fields can be added to a document assembled from several files, where there +// is no source form to join and no honest way to merge two. +func TestFieldsCanBeAddedToADocumentMergedFromTwoFiles(t *testing.T) { + a, err := Open(buildPDF(t, 1, nil)) + if err != nil { + t.Fatal(err) + } + b, err := Open(buildPDF(t, 1, nil)) + if err != nil { + t.Fatal(err) + } + a.Append(b) + if err := a.AddFields(forms.Field{Name: "a", Kind: forms.Text, + Widgets: []forms.Widget{{Page: 2, Rect: rect(10, 10, 80, 16)}}}); err != nil { + t.Fatal(err) + } + f := readBackForm(t, a) + got, ok := f.Field("a") + if !ok { + t.Fatal("the field is not there") + } + if len(got.Widgets) != 1 || got.Widgets[0].Page != 2 { + t.Errorf("the widget came back as %v", got.Widgets) + } +} + diff --git a/catalogue.go b/catalogue.go index 894cd83..bce7b4c 100644 --- a/catalogue.go +++ b/catalogue.go @@ -103,7 +103,7 @@ var sensitiveKeys = map[reader.Name]bool{"Metadata": true} // nothing above them, as it already did for the catalogue and the form. // keepCatalogue carries across what the source document said about itself. -func (d *Doc) keepCatalogue(w *reader.Writer, catalog reader.Dict, kept *keptAnnots, built []builtPage) { +func (d *Doc) keepCatalogue(w *reader.Writer, catalog reader.Dict, kept *keptAnnots, built []builtPage, added reader.Array) { src, ok := d.singleSource() if !ok { // Pages from several files have several catalogues, and there is no @@ -112,6 +112,12 @@ func (d *Doc) keepCatalogue(w *reader.Writer, catalog reader.Dict, kept *keptAnn // nothing above them — except the files it was handed, which belong to // no page and so cannot be in conflict. d.keepAttachments(w, catalog) + // Fields this package was ASKED for are not in that conflict: they + // come from the caller rather than from any source, so a merged + // document can still be given a form of its own. + if len(added) > 0 { + catalog["AcroForm"] = w.Add(acroFormFor(w, nil, added)) + } return } // A document that opened has a catalogue; one that somehow came back @@ -125,7 +131,11 @@ func (d *Doc) keepCatalogue(w *reader.Writer, catalog reader.Dict, kept *keptAnn catalog[key] = w.Copy(src, v) } } - if form := d.keepForm(w, src, source, kept); form != nil { + form := d.keepForm(w, src, source, kept) + if len(added) > 0 { + form = acroFormFor(w, form, added) + } + if form != nil { catalog["AcroForm"] = w.Add(form) } if tree := d.keepStructure(w, src, source, kept, built); tree != nil { diff --git a/cmd/pdfops/addfields.go b/cmd/pdfops/addfields.go new file mode 100644 index 0000000..e57623f --- /dev/null +++ b/cmd/pdfops/addfields.go @@ -0,0 +1,154 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + + "github.com/go-pdfkit/forms" +) + +// The description a person writes to create a form. It is the shape of +// forms.Field with the parts a file cannot carry left out, named as JSON +// rather than as Go so that the file reads like what it describes. +type fieldFile struct { + Name string `json:"name"` + Kind string `json:"kind"` + Value string `json:"value,omitempty"` + Values []string `json:"values,omitempty"` + Options []optionFile `json:"options,omitempty"` + MaxLen int `json:"maxLen,omitempty"` + Quadding int `json:"align,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` + Required bool `json:"required,omitempty"` + NoExport bool `json:"noExport,omitempty"` + Multiline bool `json:"multiline,omitempty"` + Password bool `json:"password,omitempty"` + Comb bool `json:"comb,omitempty"` + Sorted bool `json:"sorted,omitempty"` + MultiSel bool `json:"multiSelect,omitempty"` + Editable bool `json:"editable,omitempty"` + Widgets []widgetFile `json:"widgets"` +} + +type optionFile struct { + Value string `json:"value"` + Label string `json:"label,omitempty"` +} + +type widgetFile struct { + Page int `json:"page"` + Rect [4]float64 `json:"rect"` + On string `json:"on,omitempty"` + Hidden bool `json:"hidden,omitempty"` +} + +// kinds maps what a person writes to what the format calls it. The names are +// the ones Kind.String prints, so a description can be written from a listing. +var kinds = map[string]forms.Kind{ + "text": forms.Text, + "checkbox": forms.Checkbox, + "radio": forms.Radio, + "button": forms.PushButton, + "combo": forms.ComboBox, + "list": forms.ListBox, + "signature": forms.Signature, +} + +// ⛔ BOUNDS ON A DESCRIPTION FILE. The input is a file somebody hands this, and +// a list is as long as the file says: ten million fields, or a choice field +// with ten million rows, is a small JSON file that asks for an enormous PDF +// and all the memory to build it. Both are larger than any real form by orders +// of magnitude. +const ( + maxFields = 10000 + maxOptions = 10000 + maxWidgets = 1000 +) + +// runAddFields puts new fields into a document from a description. +// +// ⛔ It is the other half of `fill`, which can only change a field the document +// already has. +func runAddFields(c *context, args []string) error { + fs := flags("addfields") + desc := fs.String("fields", "", "a JSON file describing the fields to add") + if err := fs.Parse(args); err != nil { + return err + } + if err := wantArgs(fs, 2, " "); err != nil { + return err + } + if *desc == "" { + return fmt.Errorf("nothing to add: give -fields ") + } + fields, err := readFieldFile(*desc) + if err != nil { + return err + } + // c.open and save are the ones every other verb uses: the password, the + // write and their error paths in one place rather than in fifty-four. + d, err := c.open(fs.Arg(0)) + if err != nil { + return err + } + if err := d.AddFields(fields...); err != nil { + return err + } + if err := save(d, fs.Arg(1)); err != nil { + return err + } + fmt.Fprintf(c.out, "added %d field(s)\n", len(fields)) + return nil +} + +func readFieldFile(path string) ([]forms.Field, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var in []fieldFile + // ⛔ DisallowUnknownFields: a description with "multiline" spelled wrong is + // a form missing the thing it was written to ask for, and a parser that + // ignores what it does not know says nothing about it. + dec := json.NewDecoder(bytes.NewReader(b)) + dec.DisallowUnknownFields() + if err := dec.Decode(&in); err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + if len(in) > maxFields { + return nil, fmt.Errorf("%s describes %d fields; the most this adds at once is %d", path, len(in), maxFields) + } + out := make([]forms.Field, 0, len(in)) + for i, f := range in { + kind, ok := kinds[f.Kind] + if !ok { + return nil, fmt.Errorf("field %d (%q) is of kind %q; the kinds are text, checkbox, radio, button, combo, list and signature", + i+1, f.Name, f.Kind) + } + if len(f.Options) > maxOptions { + return nil, fmt.Errorf("field %q has %d options; the most is %d", f.Name, len(f.Options), maxOptions) + } + if len(f.Widgets) > maxWidgets { + return nil, fmt.Errorf("field %q shows in %d places; the most is %d", f.Name, len(f.Widgets), maxWidgets) + } + fld := forms.Field{ + Name: f.Name, Kind: kind, Value: f.Value, Values: f.Values, + MaxLen: f.MaxLen, Quadding: f.Quadding, + ReadOnly: f.ReadOnly, Required: f.Required, NoExport: f.NoExport, + Multiline: f.Multiline, Password: f.Password, Comb: f.Comb, + Sorted: f.Sorted, MultiSelect: f.MultiSel, Editable: f.Editable, + } + for _, o := range f.Options { + fld.Options = append(fld.Options, forms.Option{Value: o.Value, Label: o.Label}) + } + for _, wdg := range f.Widgets { + fld.Widgets = append(fld.Widgets, forms.Widget{ + Page: wdg.Page, Rect: wdg.Rect, On: wdg.On, Hidden: wdg.Hidden, + }) + } + out = append(out, fld) + } + return out, nil +} diff --git a/cmd/pdfops/addfields_test.go b/cmd/pdfops/addfields_test.go new file mode 100644 index 0000000..821a371 --- /dev/null +++ b/cmd/pdfops/addfields_test.go @@ -0,0 +1,228 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// describe writes a description file and returns its path. +func describe(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "fields.json") + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +func TestAddFieldsVerbCreatesAFormThatFieldsCanList(t *testing.T) { + in := pageWithText(t) + out := filepath.Join(t.TempDir(), "form.pdf") + desc := describe(t, `[ + {"name": "applicant", "kind": "text", "required": true, + "widgets": [{"page": 1, "rect": [72, 700, 300, 720]}]}, + {"name": "agree", "kind": "checkbox", "value": "Yes", + "widgets": [{"page": 1, "rect": [72, 670, 86, 684], "on": "Yes"}]}, + {"name": "size", "kind": "combo", + "options": [{"value": "s", "label": "Small"}, {"value": "l", "label": "Large"}], + "widgets": [{"page": 1, "rect": [72, 630, 220, 650]}]} + ]`) + + code, stdout, errOut := exec("addfields", "-fields", desc, in, out) + if code != 0 { + t.Fatalf("addfields said %d: %s", code, errOut) + } + if !strings.Contains(stdout, "added 3 field(s)") { + t.Errorf("it did not say what it added: %q", stdout) + } + + // ⛔ The oracle is the verb that READS forms, not this test's idea of one. + code, listing, errOut := exec("fields", out) + if code != 0 { + t.Fatalf("fields said %d on the document addfields wrote: %s", code, errOut) + } + for _, want := range []string{ + "applicant", "text", "required", + "agree", "checkbox", + "size", "combo", + } { + if !strings.Contains(listing, want) { + t.Errorf("the listing does not hold %q:\n%s", want, listing) + } + } +} + +// A document that already has a form gains the new fields and keeps its own. +func TestAddFieldsVerbAddsToAFormThatIsAlreadyThere(t *testing.T) { + in := pageWithForm(t, true) + out := filepath.Join(t.TempDir(), "more.pdf") + desc := describe(t, `[{"name": "added", "kind": "text", + "widgets": [{"page": 1, "rect": [10, 10, 90, 26]}]}]`) + + if code, _, errOut := exec("addfields", "-fields", desc, in, out); code != 0 { + t.Fatalf("addfields said %d: %s", code, errOut) + } + code, listing, errOut := exec("fields", out) + if code != 0 { + t.Fatalf("fields said %d: %s", code, errOut) + } + if !strings.Contains(listing, "added") { + t.Errorf("the new field is not listed:\n%s", listing) + } + if !strings.Contains(listing, "name") { + t.Errorf("a field the document came with was lost:\n%s", listing) + } +} + +func TestAddFieldsVerbRefusesWhatItCannotDo(t *testing.T) { + in := pageWithText(t) + out := filepath.Join(t.TempDir(), "out.pdf") + good := `[{"name": "a", "kind": "text", "widgets": [{"page": 1, "rect": [10, 10, 90, 26]}]}]` + + for _, c := range []struct { + why string + args []string + want string + }{ + {"no arguments at all", []string{"addfields"}, ""}, + {"no description", []string{"addfields", in, out}, "nothing to add"}, + {"a description that is not there", + []string{"addfields", "-fields", filepath.Join(t.TempDir(), "nope.json"), in, out}, ""}, + {"a description that is not JSON", + []string{"addfields", "-fields", describe(t, "not json"), in, out}, "reading"}, + {"a kind that does not exist", + []string{"addfields", "-fields", describe(t, + `[{"name":"a","kind":"barcode","widgets":[{"page":1,"rect":[1,1,9,9]}]}]`), in, out}, "kinds are"}, + {"a key that is spelled wrong", + []string{"addfields", "-fields", describe(t, + `[{"name":"a","kind":"text","mutliline":true,"widgets":[{"page":1,"rect":[1,1,9,9]}]}]`), in, out}, "reading"}, + {"a field with no name", + []string{"addfields", "-fields", describe(t, + `[{"name":"","kind":"text","widgets":[{"page":1,"rect":[1,1,9,9]}]}]`), in, out}, "no name"}, + {"two fields of one name", + []string{"addfields", "-fields", describe(t, + `[{"name":"a","kind":"text","widgets":[{"page":1,"rect":[1,1,9,9]}]}, + {"name":"a","kind":"text","widgets":[{"page":1,"rect":[1,20,9,29]}]}]`), in, out}, "both named"}, + {"a page the document does not have", + []string{"addfields", "-fields", describe(t, + `[{"name":"a","kind":"text","widgets":[{"page":99,"rect":[1,1,9,9]}]}]`), in, out}, "page 99"}, + {"a choice field with no rows", + []string{"addfields", "-fields", describe(t, + `[{"name":"a","kind":"combo","widgets":[{"page":1,"rect":[1,1,9,9]}]}]`), in, out}, "no options"}, + {"a checkbox whose state is the reserved name", + []string{"addfields", "-fields", describe(t, + `[{"name":"a","kind":"checkbox","widgets":[{"page":1,"rect":[1,1,9,9],"on":"Off"}]}]`), in, out}, "reserves"}, + {"an input that is not there", + []string{"addfields", "-fields", describe(t, good), + filepath.Join(t.TempDir(), "nope.pdf"), out}, ""}, + {"a flag that does not exist", []string{"addfields", "-nonsense", in, out}, ""}, + } { + code, _, errOut := exec(c.args...) + if code == 0 { + t.Errorf("%s was accepted", c.why) + continue + } + if c.want != "" && !strings.Contains(errOut, c.want) { + t.Errorf("%s: it said %q, which does not mention %q", c.why, errOut, c.want) + } + } +} + +// ⛔ A DESCRIPTION FILE IS INPUT. A list is as long as the file says, so a +// small file can ask for an enormous document; both bounds are far above any +// real form. +func TestAddFieldsVerbRefusesADescriptionThatIsTooLarge(t *testing.T) { + in := pageWithText(t) + out := filepath.Join(t.TempDir(), "out.pdf") + + var b strings.Builder + b.WriteString(`[{"name":"a","kind":"combo","widgets":[{"page":1,"rect":[1,1,9,9]}],"options":[`) + for i := 0; i < 10001; i++ { + if i > 0 { + b.WriteByte(',') + } + b.WriteString(`{"value":"x"}`) + } + b.WriteString(`]}]`) + + code, _, errOut := exec("addfields", "-fields", describe(t, b.String()), in, out) + if code == 0 { + t.Fatal("a choice field with ten thousand and one rows was accepted") + } + if !strings.Contains(errOut, "the most is") { + t.Errorf("it said %q, which does not say what the limit is", errOut) + } +} + +// The paths that are not about the description: a file that is not a PDF, and +// a place the result cannot be written. +func TestAddFieldsVerbOnAFileItCannotUse(t *testing.T) { + good := describe(t, `[{"name":"a","kind":"text","widgets":[{"page":1,"rect":[10,10,90,26]}]}]`) + dir := t.TempDir() + + notAPDF := filepath.Join(dir, "notes.txt") + if err := os.WriteFile(notAPDF, []byte("not a pdf at all"), 0o644); err != nil { + t.Fatal(err) + } + if code, _, _ := exec("addfields", "-fields", good, notAPDF, filepath.Join(dir, "out.pdf")); code == 0 { + t.Error("a file that is not a PDF was accepted") + } + + // A destination inside a directory that is not there. + nowhere := filepath.Join(dir, "no", "such", "place", "out.pdf") + if code, _, _ := exec("addfields", "-fields", good, pageWithText(t), nowhere); code == 0 { + t.Error("a destination that cannot be written was accepted") + } +} + +// ⛔ The other two bounds on a description file, each a small file asking for +// an enormous document. +func TestAddFieldsVerbRefusesTooManyFieldsOrWidgets(t *testing.T) { + in := pageWithText(t) + out := filepath.Join(t.TempDir(), "out.pdf") + + var many strings.Builder + many.WriteByte('[') + for i := 0; i < maxFields+1; i++ { + if i > 0 { + many.WriteByte(',') + } + fmt.Fprintf(&many, `{"name":"f%d","kind":"text","widgets":[{"page":1,"rect":[1,1,9,9]}]}`, i) + } + many.WriteByte(']') + code, _, errOut := exec("addfields", "-fields", describe(t, many.String()), in, out) + if code == 0 || !strings.Contains(errOut, "the most this adds at once") { + t.Errorf("a description of %d fields was answered with %d: %s", maxFields+1, code, errOut) + } + + var wide strings.Builder + wide.WriteString(`[{"name":"a","kind":"text","widgets":[`) + for i := 0; i < maxWidgets+1; i++ { + if i > 0 { + wide.WriteByte(',') + } + wide.WriteString(`{"page":1,"rect":[1,1,9,9]}`) + } + wide.WriteString(`]}]`) + code, _, errOut = exec("addfields", "-fields", describe(t, wide.String()), in, out) + if code == 0 || !strings.Contains(errOut, "shows in") { + t.Errorf("a field with %d widgets was answered with %d: %s", maxWidgets+1, code, errOut) + } +} + +// A refusal leaves no half-written document behind. +func TestARefusedAddFieldsWritesNothing(t *testing.T) { + in := pageWithText(t) + out := filepath.Join(t.TempDir(), "out.pdf") + desc := describe(t, `[{"name":"","kind":"text","widgets":[{"page":1,"rect":[1,1,9,9]}]}]`) + + if code, _, _ := exec("addfields", "-fields", desc, in, out); code == 0 { + t.Fatal("it was accepted") + } + if _, err := os.Stat(out); err == nil { + t.Error("a refused run left a document behind") + } +} diff --git a/cmd/pdfops/run.go b/cmd/pdfops/run.go index e7ee6c6..a18e064 100644 --- a/cmd/pdfops/run.go +++ b/cmd/pdfops/run.go @@ -86,6 +86,7 @@ var commands = []command{ {"detach", "-name ", "drop a file the document carries", runDetach}, {"fields", "", "list what a form asks for and what it holds", runFields}, {"fill", "-set = [-set ...] ", "fill in a form and save it", runFill}, + {"addfields", "-fields ", "put new form fields into a document", runAddFields}, {"xfa", " ", "draw the form inside a file whose pages are a placeholder", runXFA}, } diff --git a/doc.go b/doc.go index 314249c..7084b6b 100644 --- a/doc.go +++ b/doc.go @@ -12,6 +12,7 @@ package ops import ( "fmt" + "github.com/go-pdfkit/forms" "github.com/go-pdfkit/reader" ) @@ -32,6 +33,11 @@ type Doc struct { // of its caller's rather than of anybody else's. outline []Bookmark + // addFields are form fields to put into the document, held until the write + // because a widget names the page OBJECT it sits on and the pages have no + // object numbers until then. See [Doc.AddFields]. + addFields []forms.Field + // labels is one page label per page, and labelsSet says the caller has // decided them — including deciding there are none, which is why the two // are separate: a nil labels with labelsSet is "remove the labels" and a diff --git a/go.mod b/go.mod index 191a6f7..e6ff573 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/go-ooxml/opc v0.2.1 github.com/go-pdfkit/document v0.1.0 github.com/go-pdfkit/extract v0.9.0 - github.com/go-pdfkit/forms v0.5.0 + github.com/go-pdfkit/forms v0.6.0 github.com/go-pdfkit/reader v0.9.0 github.com/go-pdfkit/render v0.69.0 github.com/go-pdfkit/xfa v0.22.0 diff --git a/go.sum b/go.sum index f7cb6cc..e8dcee1 100644 --- a/go.sum +++ b/go.sum @@ -18,8 +18,8 @@ github.com/go-pdfkit/document v0.1.0 h1:PjkXDH4ZBCpfSZ7VL9J+ygKR8TAqPR/JqXS/aRx3 github.com/go-pdfkit/document v0.1.0/go.mod h1:fho9Q82CBLpxtuv6b0bR46uw8iEbscb/uGmln524CvI= github.com/go-pdfkit/extract v0.9.0 h1:gmyIFqZBxBBx6ccpUbF0Sw67jUmAp7q9vQ8mEsTyuFA= github.com/go-pdfkit/extract v0.9.0/go.mod h1:pUzGzNMx0WTsBOzfZ2XKT5ewqgv8cP/I6WpU4isPsmc= -github.com/go-pdfkit/forms v0.5.0 h1:+P3WZBaLKPWV49YN23DQIHnhQeftWuosRRNBc1ltG9A= -github.com/go-pdfkit/forms v0.5.0/go.mod h1:d8WR2fiAz/BHVbWcRcUBciGHao9gHxU6dMEgEh9xVR0= +github.com/go-pdfkit/forms v0.6.0 h1:FJ5oHoIMMdbtVYWsyEEbZ8WFoJtSCdlYrjHE9hTSqxY= +github.com/go-pdfkit/forms v0.6.0/go.mod h1:WZD05vH5x34pL7kOp4lVf1yOFsW7wpffj/ZqnHfsVUs= github.com/go-pdfkit/pdffont v0.4.0 h1:3CV2HnmUGHiIYuZ5dJWRLKvi7hMHGWS1RjgNYkEZ0Gk= github.com/go-pdfkit/pdffont v0.4.0/go.mod h1:l7es3IPbKewQF+sSgPh/bQL18jSewoI0cMjttk7uPlc= github.com/go-pdfkit/reader v0.9.0 h1:4U2oZXozJl+1bRyzK90sb6pQB+Qm6dCPjBAk4c71tqU= diff --git a/write.go b/write.go index 3a72894..3afe846 100644 --- a/write.go +++ b/write.go @@ -75,8 +75,13 @@ func (d *Doc) Bytes() ([]byte, error) { for i, p := range d.pages { built[i] = builtPage{src: p.src, num: p.number, ref: refs[i], dict: dicts[i]} } + // The widgets of any added fields go onto the page dictionaries before they + // are put down, and their field objects are handed to the catalogue, which + // is the only place that knows whether there is already a form to join. + added := d.addedFields(w, refs, dicts) + catalog := reader.Dict{"Type": reader.Name("Catalog"), "Pages": pagesRef} - d.keepCatalogue(w, catalog, kept, built) + d.keepCatalogue(w, catalog, kept, built, added) kids := make(reader.Array, 0, len(d.pages)) for i := range d.pages {