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 {