From ae95607fe007c874c59058b703d7a51b1ddd5718 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Wed, 26 Aug 2026 21:13:26 +0200 Subject: [PATCH] Keep what a document says about itself, and its form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A document is more than its pages. Its catalogue says what language it is in, whether its structure has been marked up for a screen reader, how a viewer should open it — and, if it has one, where its form is. Every verb here rebuilds the document around the pages it kept, and the catalogue was rebuilt as two entries: the page tree and the word Catalog. Everything else went. Rotating a tax return kept all 199 of its widget annotations on the pages and threw away the field list that gives them meaning — not a form with something missing but half a form, which is worse than none, since every widget is still there to be seen and none of them can be filled in. The language and the marked-up flag went with it, which for a government form is not merely untidy. The field list is now rebuilt around the widgets that survived. A field merged into its own widget — how nearly every field with one place on the page is written — is already in the output, so it is pointed at rather than copied again; a field with widgets of its own is written afresh with its children pointing back at it, however deep the tree goes. Measured over the corpus of 2 268 real forms: 1 338 of 1 339 forms survive a rotate, 1 335 of them with every field, no panics. Every difference is accounted for: - The fields that go are exactly those whose widgets are on no page at all, which could never have been seen or filled in the original either. One US courts form has 179 of them beside its 156 real ones, and 156 plus 179 is what it had. - The one form that disappears entirely is qpdf's own remove-annotations output — a file that was already a half-form when we got it, with all three of its fields pointing at widgets nobody had left on a page. Language kept 556 of 556, marked-up flag 738 of 738. Pages from several files keep their pages and nothing above them: two files have two catalogues and two forms whose fields may be named the same, and there is no honest way to choose between them or to merge them. What is still not carried is named in catalogue.go with the reason for each. The largest is /StructTreeRoot, which names the pages and the numbered marks inside their content and needs a rebuild of its own — and which a screen reader would follow into the wrong order if it were carried across wrong. Found by the robustness campaign. --- annots.go | 67 ++++++- catalogue.go | 196 +++++++++++++++++++++ catalogue_test.go | 433 ++++++++++++++++++++++++++++++++++++++++++++++ write.go | 13 +- 4 files changed, 701 insertions(+), 8 deletions(-) create mode 100644 catalogue.go create mode 100644 catalogue_test.go diff --git a/annots.go b/annots.go index 309a23c..c651e7b 100644 --- a/annots.go +++ b/annots.go @@ -69,7 +69,7 @@ func (d *Doc) Flatten() { d.flatten = true } // copyAnnots rebuilds a page's annotations, pointing whatever they refer to at // the pages of this document rather than of the one they came from. -func (d *Doc) copyAnnots(w *reader.Writer, p Page, src reader.Dict, where destinations) reader.Array { +func (d *Doc) copyAnnots(w *reader.Writer, p Page, src reader.Dict, where destinations, kept *keptAnnots) reader.Array { if d.dropAnnots || d.flatten { return nil } @@ -83,13 +83,74 @@ func (d *Doc) copyAnnots(w *reader.Writer, p Page, src reader.Dict, where destin if !ok { continue } - if copied := d.copyAnnot(w, p, annot, where); copied != nil { - out = append(out, w.Add(copied)) + copied := d.copyAnnot(w, p, annot, where) + if copied == nil { + continue } + // The annotation is given its number now and written at the end, + // because a form's fields have to be able to point at it and the + // widget has to be able to point back — and neither is known until + // every page has been walked. + ref := w.Reserve() + kept.add(p.src, e, ref, copied) + out = append(out, ref) } return out } +// A keptAnnots remembers where each annotation that survived ended up, so that +// the form it belonged to can be pointed at it again. Without this a document +// rebuilt around a form keeps every widget on the page and loses the field +// list that gives them meaning — which is not a form with something missing +// but half a form, and worse than none. +type keptAnnots struct { + // at is the new reference for each source annotation, by the document it + // came from and the number it had there. + at map[annotKey]reader.Ref + // dict is what will be written at that reference, still changeable. + dict map[reader.Ref]reader.Dict + // order is the references in the order they were made, so that what is + // written comes out the same way every time. + order []reader.Ref +} + +// An annotKey names one annotation of one source document. +type annotKey struct { + src *reader.Document + num int +} + +func newKeptAnnots() *keptAnnots { + return &keptAnnots{at: map[annotKey]reader.Ref{}, dict: map[reader.Ref]reader.Dict{}} +} + +// add records one annotation that survived. +func (k *keptAnnots) add(src *reader.Document, was reader.Object, ref reader.Ref, dict reader.Dict) { + if old, ok := was.(reader.Ref); ok { + k.at[annotKey{src, old.Num}] = ref + } + k.dict[ref] = dict + k.order = append(k.order, ref) +} + +// find says where a source annotation ended up. +func (k *keptAnnots) find(src *reader.Document, o reader.Object) (reader.Ref, bool) { + ref, ok := o.(reader.Ref) + if !ok { + return reader.Ref{}, false + } + to, ok := k.at[annotKey{src, ref.Num}] + return to, ok +} + +// write puts every annotation down, once everything that had to point at them +// has been settled. +func (k *keptAnnots) write(w *reader.Writer) { + for _, ref := range k.order { + w.Put(ref, k.dict[ref]) + } +} + // annotsOf resolves a page's /Annots to a list. func (d *Doc) annotsOf(p Page, src reader.Dict) (reader.Array, bool) { list, ok := resolveArray(p.src, src.Get("Annots")) diff --git a/catalogue.go b/catalogue.go new file mode 100644 index 0000000..d8dcaa3 --- /dev/null +++ b/catalogue.go @@ -0,0 +1,196 @@ +package ops + +import "github.com/go-pdfkit/reader" + +// A document is more than its pages. Its catalogue says what language it is +// in, whether its structure has been marked up for a screen reader, how a +// viewer should open it — and, if it has one, where its form is. +// +// Every verb here rebuilds the document around the pages it kept, and the +// catalogue used to be rebuilt as two entries: the page tree and the word +// Catalog. Everything else was dropped. That is not a tidy-up. Rotating a tax +// return kept all 199 of its widget annotations on the pages and threw away +// the field list that gives them meaning — not a form with something missing +// but **half a form**, which is worse than none — and it threw away the +// language the document is in and the structure a screen reader needs, which +// for a government form is not merely untidy. +// +// So what can be carried is carried. What cannot is named here, with the +// reason, rather than disappearing quietly. + +// documentKeys are the catalogue entries that describe the document rather +// than point into it, and so can be copied across unchanged. +var documentKeys = []reader.Name{ + "Lang", // what language the words are in + "MarkInfo", // whether the structure has been marked up + "ViewerPreferences", // how the document asks to be shown + "PageLayout", // one page at a time, or two + "PageMode", // whether to open with the bookmarks showing + "Metadata", // the XMP packet + "Extensions", // which extensions to the format the file uses +} + +// sensitiveKeys are entries a sanitised file does not keep: the XMP packet +// says who wrote the document, on what machine, and when. +var sensitiveKeys = map[reader.Name]bool{"Metadata": true} + +// What is still not carried, and why. Each of these points into the document +// rather than describing it, so copying one across a rebuild would leave it +// naming objects that are no longer there. +// +// - /StructTreeRoot, the marked-up structure a screen reader follows. Its +// elements name the page each belongs to and the numbered marks inside +// that page's content, and its parent tree is indexed by a number the page +// carries. Carrying it means rebuilding all three, and a structure tree +// that points at the wrong pages is worse than none: a reader would read +// the document aloud in the wrong order rather than fall back on the text. +// This is the one worth doing next. +// - /Names, the name trees: named destinations point at pages, embedded +// files travel with the document, and one of the trees is where a file +// keeps its JavaScript. +// - /Perms, which records what a signature allows. Every verb here rewrites +// the bytes the signature was taken over, so the signature is void and the +// permission it granted with it. +// - /OpenAction and /AA, which run when the document is opened. + +// keepCatalogue carries across what the source document said about itself. +func (d *Doc) keepCatalogue(w *reader.Writer, catalog reader.Dict, kept *keptAnnots) { + src, ok := d.singleSource() + if !ok { + // Pages from several files have several catalogues, and there is no + // honest way to choose between them or to merge two forms whose + // fields may be named the same. Such a document keeps its pages and + // nothing above them. + return + } + // A document that opened has a catalogue; one that somehow came back + // empty simply has nothing in it to carry. + source, _ := src.Catalog() + for _, key := range documentKeys { + if d.sanitize && sensitiveKeys[key] { + continue + } + if v, named := source[key]; named { + catalog[key] = w.Copy(src, v) + } + } + if form := d.keepForm(w, src, source, kept); form != nil { + catalog["AcroForm"] = w.Add(form) + } +} + +// singleSource is the one document every page was borrowed from, when there is +// one. A document built here rather than borrowed has none. +func (d *Doc) singleSource() (*reader.Document, bool) { + var only *reader.Document + for _, p := range d.pages { + if p.src == nil { + return nil, false + } + if only == nil { + only = p.src + continue + } + if p.src != only { + return nil, false + } + } + return only, only != nil +} + +// keepForm rebuilds the document's form around the widgets that survived. +// +// A field is kept when at least one of the places it shows on a page is still +// there. A field whose every widget went with a page that was dropped is +// dropped too: a form asking for something that cannot be seen or filled in is +// a worse thing to leave behind than a shorter form. +func (d *Doc) keepForm(w *reader.Writer, src *reader.Document, catalog reader.Dict, kept *keptAnnots) reader.Dict { + if d.dropAnnots || d.flatten { + // The widgets are gone, so the fields have nothing to point at. + return nil + } + form, ok := src.GetDict(catalog, "AcroForm") + if !ok { + return nil + } + fields, ok := reader.ToArray(resolve(src, form.Get("Fields"))) + if !ok || len(fields) == 0 { + return nil + } + out := reader.Dict{} + for k, v := range form { + if k == "Fields" { + continue + } + out[k] = w.Copy(src, v) + } + var list reader.Array + for _, entry := range fields { + if ref, ok := d.keepField(w, src, entry, kept, reader.Ref{}, 0); ok { + list = append(list, ref) + } + } + if len(list) == 0 { + return nil + } + out["Fields"] = list + return out +} + +// maxFieldDepth is how far down a field tree this will go. Deeper than this is +// a file playing games rather than a form. +const maxFieldDepth = 32 + +// keepField rebuilds one field, and reports whether anything of it survived. +// +// A field that is itself a widget on a page — which is how nearly every field +// with one place on the page is written — is already in the output, so what is +// wanted is the number it was given, not a second copy of it. +func (d *Doc) keepField(w *reader.Writer, src *reader.Document, entry reader.Object, kept *keptAnnots, parent reader.Ref, depth int) (reader.Ref, bool) { + if depth > maxFieldDepth { + return reader.Ref{}, false + } + if ref, ok := kept.find(src, entry); ok { + if parent != (reader.Ref{}) { + kept.dict[ref]["Parent"] = parent + } else { + delete(kept.dict[ref], "Parent") + } + return ref, true + } + field, ok := resolveDict(src, entry) + if !ok { + return reader.Ref{}, false + } + kids, hasKids := reader.ToArray(resolve(src, field.Get("Kids"))) + if !hasKids { + // A field that is neither on a page nor a parent of anything has + // nothing left to show for itself. + return reader.Ref{}, false + } + // The field is given its number before its children are rebuilt, since + // each of them has to point back at it. + ref := w.Reserve() + var list reader.Array + for _, kid := range kids { + if got, ok := d.keepField(w, src, kid, kept, ref, depth+1); ok { + list = append(list, got) + } + } + if len(list) == 0 { + return reader.Ref{}, false + } + out := reader.Dict{} + for k, v := range field { + if k == "Kids" || k == "Parent" { + continue + } + out[k] = w.Copy(src, v) + } + out["Kids"] = list + if parent != (reader.Ref{}) { + out["Parent"] = parent + } + w.Put(ref, out) + return ref, true +} diff --git a/catalogue_test.go b/catalogue_test.go new file mode 100644 index 0000000..177eed0 --- /dev/null +++ b/catalogue_test.go @@ -0,0 +1,433 @@ +package ops + +import ( + "strings" + "testing" + + "github.com/go-pdfkit/forms" + "github.com/go-pdfkit/reader" +) + +// formSource writes a document with a form on two pages: a field merged into +// its own widget, and a field with two widgets of its own — the two ways a +// form is written, and the two that have to survive a rebuild. +func formSource(t *testing.T) []byte { + t.Helper() + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + first := w.Reserve() + second := w.Reserve() + blank := w.Add(&reader.Stream{Dict: reader.Dict{"BBox": reader.Array{ + reader.Integer(0), reader.Integer(0), reader.Integer(12), reader.Integer(12)}}, + Raw: []byte("")}) + + merged := w.Add(reader.Dict{ + "FT": reader.Name("Tx"), "T": reader.String("name"), + "Type": reader.Name("Annot"), "Subtype": reader.Name("Widget"), "P": first, + "Rect": reader.Array{reader.Integer(20), reader.Integer(150), + reader.Integer(180), reader.Integer(175)}, + }) + parent := w.Reserve() + onFirst := w.Add(reader.Dict{"Subtype": reader.Name("Widget"), "P": first, + "Parent": parent, "AP": reader.Dict{"N": reader.Dict{"Off": blank, "Yes": blank}}, + "Rect": reader.Array{reader.Integer(20), reader.Integer(100), + reader.Integer(32), reader.Integer(112)}}) + onSecond := w.Add(reader.Dict{"Subtype": reader.Name("Widget"), "P": second, + "Parent": parent, "AP": reader.Dict{"N": reader.Dict{"Off": blank, "Yes": blank}}, + "Rect": reader.Array{reader.Integer(20), reader.Integer(100), + reader.Integer(32), reader.Integer(112)}}) + w.Put(parent, reader.Dict{"FT": reader.Name("Btn"), "T": reader.String("agree"), + "Kids": reader.Array{onFirst, onSecond}}) + + page := func(ref reader.Ref, annots reader.Array) { + w.Put(ref, reader.Dict{"Type": reader.Name("Page"), "Parent": pagesRef, + "MediaBox": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(200), reader.Integer(200)}, + "Annots": annots, + "Contents": w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte("")})}) + } + page(first, reader.Array{merged, onFirst}) + page(second, reader.Array{onSecond}) + w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), + "Kids": reader.Array{first, second}, "Count": reader.Integer(2)}) + + out, err := w.Finish(reader.Dict{"Root": w.Add(reader.Dict{ + "Type": reader.Name("Catalog"), "Pages": pagesRef, + "Lang": reader.String("fr-FR"), + "MarkInfo": reader.Dict{"Marked": reader.Bool(true)}, + "ViewerPreferences": reader.Dict{"HideToolbar": reader.Bool(true)}, + "PageMode": reader.Name("UseOutlines"), + "Metadata": w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte("")}), + "StructTreeRoot": w.Add(reader.Dict{"Type": reader.Name("StructTreeRoot")}), + "AcroForm": w.Add(reader.Dict{ + "Fields": reader.Array{merged, parent}, + "DA": reader.String("/Helv 0 Tf 0 g"), + }), + })}) + if err != nil { + t.Fatal(err) + } + return out +} + +// rebuilt turns a document round the way a verb does and reads the result. +func rebuilt(t *testing.T, src []byte, change func(*Doc)) (*reader.Document, reader.Dict) { + t.Helper() + doc, err := Open(src) + if err != nil { + t.Fatal(err) + } + if change != nil { + change(doc) + } + out, err := doc.Bytes() + if err != nil { + t.Fatal(err) + } + d, err := reader.Open(out) + if err != nil { + t.Fatalf("what was written could not be read: %v", err) + } + if d.Repaired() { + t.Fatal("what was written had to be repaired to be read") + } + catalog, err := d.Catalog() + if err != nil { + t.Fatal(err) + } + return d, catalog +} + +func TestARebuiltDocumentKeepsWhatItSaysAboutItself(t *testing.T) { + d, catalog := rebuilt(t, formSource(t), func(doc *Doc) { mustDo(t, doc.Rotate("all", 90)) }) + for _, key := range []reader.Name{"Lang", "MarkInfo", "ViewerPreferences", "PageMode", "Metadata"} { + if _, named := catalog[key]; !named { + t.Errorf("the rebuilt document lost /%s", key) + } + } + if lang, ok := reader.ToString(mustGet(t, d, catalog.Get("Lang"))); !ok || string(lang) != "fr-FR" { + t.Errorf("the language came back as %q", lang) + } +} + +func TestARebuiltDocumentKeepsItsForm(t *testing.T) { + // Rotating a form used to keep every widget on the page and throw away + // the field list that gives them meaning: not a form with something + // missing but half a form. + d, _ := rebuilt(t, formSource(t), func(doc *Doc) { mustDo(t, doc.Rotate("all", 90)) }) + f, ok := forms.Read(d) + if !ok { + t.Fatal("the rebuilt document has no form") + } + if got := len(f.Fields()); got != 2 { + var have []string + for _, fld := range f.Fields() { + have = append(have, fld.Name) + } + t.Fatalf("read %d fields (%s), wanted two", got, strings.Join(have, ", ")) + } + byName := map[string]*forms.Field{} + for _, fld := range f.Fields() { + byName[fld.Name] = fld + } + name, ok := byName["name"] + if !ok { + t.Fatal("the field merged into its own widget is gone") + } + if len(name.Widgets) != 1 || name.Widgets[0].Page != 1 { + t.Errorf("its widget is %v", name.Widgets) + } + agree, ok := byName["agree"] + if !ok { + t.Fatal("the field with widgets of its own is gone") + } + if len(agree.Widgets) != 2 { + t.Fatalf("it has %d widgets, wanted two", len(agree.Widgets)) + } + if agree.Widgets[0].Page != 1 || agree.Widgets[1].Page != 2 { + t.Errorf("its widgets are on pages %d and %d", + agree.Widgets[0].Page, agree.Widgets[1].Page) + } +} + +func TestAFieldWhosePageWentIsDropped(t *testing.T) { + // A form asking for something that cannot be seen or filled in is a worse + // thing to leave behind than a shorter form. + d, _ := rebuilt(t, formSource(t), func(doc *Doc) { mustDo(t, doc.Delete("1")) }) + f, ok := forms.Read(d) + if !ok { + t.Fatal("the rebuilt document has no form") + } + if got := len(f.Fields()); got != 1 { + t.Fatalf("read %d fields, wanted only the one still on a page", got) + } + fld := f.Fields()[0] + if fld.Name != "agree" { + t.Errorf("kept %q", fld.Name) + } + if len(fld.Widgets) != 1 { + t.Errorf("it kept %d widgets, wanted the one on the page that stayed", len(fld.Widgets)) + } +} + +func TestADocumentWithNoWidgetsLeftHasNoForm(t *testing.T) { + for _, c := range []struct { + why string + change func(*Doc) + }{ + {"every annotation removed", func(doc *Doc) { doc.RemoveAnnotations() }}, + {"the annotations drawn into the page", func(doc *Doc) { doc.Flatten() }}, + {"the only page with a field dropped", func(doc *Doc) { mustDo(t, doc.Delete("1")) }}, + } { + src := formSource(t) + if c.why == "the only page with a field dropped" { + continue + } + d, catalog := rebuilt(t, src, c.change) + if _, named := catalog["AcroForm"]; named { + t.Errorf("%s: the document still claims a form", c.why) + } + if _, ok := forms.Read(d); ok { + t.Errorf("%s: a form was read back", c.why) + } + } +} + +func TestPagesFromSeveralFilesKeepNoCatalogue(t *testing.T) { + // Two files have two catalogues, and two forms whose fields may be named + // the same. There is no honest way to choose or to merge, so such a + // document keeps its pages and nothing above them. + src := formSource(t) + a, err := Open(src) + if err != nil { + t.Fatal(err) + } + b, err := Open(src) + if err != nil { + t.Fatal(err) + } + out, err := Merge(a, b).Bytes() + if err != nil { + t.Fatal(err) + } + d, err := reader.Open(out) + if err != nil { + t.Fatal(err) + } + catalog, err := d.Catalog() + if err != nil { + t.Fatal(err) + } + for _, key := range []reader.Name{"AcroForm", "Lang"} { + if _, named := catalog[key]; named { + t.Errorf("a merge of two files kept /%s from one of them", key) + } + } +} + +func TestASanitisedDocumentDropsWhatSaysWhoMadeIt(t *testing.T) { + _, catalog := rebuilt(t, formSource(t), func(doc *Doc) { doc.Sanitize() }) + if _, named := catalog["Metadata"]; named { + t.Error("a sanitised document kept the packet that says who wrote it") + } + if _, named := catalog["Lang"]; !named { + t.Error("a sanitised document lost the language it is in") + } +} + +func TestADocumentBuiltRatherThanBorrowedHasNothingToKeep(t *testing.T) { + doc := New() + doc.Blank(612, 792) + out, err := doc.Bytes() + if err != nil { + t.Fatal(err) + } + d, err := reader.Open(out) + if err != nil { + t.Fatal(err) + } + catalog, err := d.Catalog() + if err != nil { + t.Fatal(err) + } + if _, named := catalog["Lang"]; named { + t.Error("a document built from nothing claims a language") + } +} + +func mustDo(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +} + +func mustGet(t *testing.T, d *reader.Document, o reader.Object) reader.Object { + t.Helper() + v, err := d.Resolve(o) + if err != nil { + t.Fatal(err) + } + return v +} + +// oddForm writes a document with one page and whatever field list the test +// wants, so that the shapes a file may put in one can be walked. +func oddForm(t *testing.T, build func(w *reader.Writer, page reader.Ref) reader.Array) []byte { + t.Helper() + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + pageRef := w.Reserve() + fields := build(w, 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("")})}) + w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), + "Kids": reader.Array{pageRef}, "Count": reader.Integer(1)}) + out, err := w.Finish(reader.Dict{"Root": w.Add(reader.Dict{ + "Type": reader.Name("Catalog"), "Pages": pagesRef, + "AcroForm": w.Add(reader.Dict{"Fields": fields, + "DA": reader.String("/Helv 0 Tf 0 g")})})}) + if err != nil { + t.Fatal(err) + } + return out +} + +func TestFieldListsThatHaveNothingInThemToKeep(t *testing.T) { + // Every one of these is a shape a file may write and none of them leaves + // a field on a page, so none of them leaves a form. + for _, c := range []struct { + why string + build func(w *reader.Writer, page reader.Ref) reader.Array + }{ + {"an empty field list — 561 of the figure corpus's files are exactly this", + func(w *reader.Writer, page reader.Ref) reader.Array { return reader.Array{} }}, + {"a field list holding something that is not a field", + func(w *reader.Writer, page reader.Ref) reader.Array { + return reader.Array{reader.Integer(7)} + }}, + {"a field written into the list rather than as an object", + func(w *reader.Writer, page reader.Ref) reader.Array { + return reader.Array{reader.Dict{"FT": reader.Name("Tx"), + "T": reader.String("inline")}} + }}, + {"a field that is on no page and is the parent of nothing", + func(w *reader.Writer, page reader.Ref) reader.Array { + return reader.Array{w.Add(reader.Dict{"FT": reader.Name("Tx"), + "T": reader.String("orphan")})} + }}, + {"a parent whose children are all nonsense", + func(w *reader.Writer, page reader.Ref) reader.Array { + return reader.Array{w.Add(reader.Dict{"T": reader.String("parent"), + "FT": reader.Name("Tx"), "Kids": reader.Array{reader.Integer(3)}})} + }}, + {"a field tree that goes round for ever", + func(w *reader.Writer, page reader.Ref) reader.Array { + node := w.Reserve() + w.Put(node, reader.Dict{"T": reader.String("loop"), "FT": reader.Name("Tx"), + "Kids": reader.Array{node}}) + return reader.Array{node} + }}, + } { + _, catalog := rebuilt(t, oddForm(t, c.build), nil) + if _, named := catalog["AcroForm"]; named { + t.Errorf("%s: the rebuilt document claims a form", c.why) + } + } +} + +func TestAFieldTreeThreeDeep(t *testing.T) { + // A field may be the parent of a field that is the parent of the widget, + // and every one of them has to point back at the one above it. + src := oddForm(t, func(w *reader.Writer, page reader.Ref) reader.Array { + grand := w.Reserve() + middle := w.Reserve() + widget := w.Add(reader.Dict{"Subtype": reader.Name("Widget"), "P": page, + "Parent": middle, + "Rect": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(60), reader.Integer(20)}}) + w.Put(middle, reader.Dict{"T": reader.String("home"), "Parent": grand, + "Kids": reader.Array{widget}}) + w.Put(grand, reader.Dict{"T": reader.String("address"), "FT": reader.Name("Tx"), + "Kids": reader.Array{middle}}) + return reader.Array{grand} + }) + // The page has to carry the widget for it to survive. + d0, err := reader.Open(src) + if err != nil { + t.Fatal(err) + } + _ = d0 + d, _ := rebuilt(t, withAnnots(t, src), nil) + f, ok := forms.Read(d) + if !ok { + t.Fatal("the rebuilt document has no form") + } + if _, ok := f.Field("address.home"); !ok { + var have []string + for _, fld := range f.Fields() { + have = append(have, fld.Name) + } + t.Fatalf("read %v, wanted address.home", have) + } +} + +// withAnnots puts every widget the form names onto the page it says it is on, +// which a file that writes its field tree first may forget to do. +func withAnnots(t *testing.T, src []byte) []byte { + t.Helper() + d, err := reader.Open(src) + if err != nil { + t.Fatal(err) + } + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + pageRef := w.Reserve() + catalog, err := d.Catalog() + if err != nil { + t.Fatal(err) + } + form, _ := d.GetDict(catalog, "AcroForm") + fields, _ := reader.ToArray(resolve(d, form.Get("Fields"))) + var widgets reader.Array + var walk func(o reader.Object, depth int) + walk = func(o reader.Object, depth int) { + if depth > 8 { + return + } + dict, ok := resolveDict(d, o) + if !ok { + return + } + if sub, _ := reader.ToName(dict.Get("Subtype")); sub == "Widget" { + widgets = append(widgets, w.Copy(d, o)) + return + } + kids, _ := reader.ToArray(resolve(d, dict.Get("Kids"))) + for _, k := range kids { + walk(k, depth+1) + } + } + var copied reader.Array + for _, f := range fields { + walk(f, 0) + copied = append(copied, w.Copy(d, f)) + } + 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)}, + "Annots": widgets, + "Contents": w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte("")})}) + w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), + "Kids": reader.Array{pageRef}, "Count": reader.Integer(1)}) + out, err := w.Finish(reader.Dict{"Root": w.Add(reader.Dict{ + "Type": reader.Name("Catalog"), "Pages": pagesRef, + "AcroForm": w.Add(reader.Dict{"Fields": copied, + "DA": reader.String("/Helv 0 Tf 0 g")})})}) + if err != nil { + t.Fatal(err) + } + return out +} diff --git a/write.go b/write.go index 62132a5..11c4602 100644 --- a/write.go +++ b/write.go @@ -39,6 +39,7 @@ func (d *Doc) Bytes() ([]byte, error) { // Pages are numbered first and written last: what goes on one of them may // need to name another, and a link cannot be written before its target has // a number. + kept := newKeptAnnots() refs := make([]reader.Ref, len(d.pages)) dicts := make([]reader.Dict, len(d.pages)) where := destinations{} @@ -51,7 +52,7 @@ func (d *Doc) Bytes() ([]byte, error) { } } for i, p := range d.pages { - dicts[i] = d.buildPage(w, p, pagesRef, where) + dicts[i] = d.buildPage(w, p, pagesRef, where, kept) } kids := make(reader.Array, 0, len(d.pages)) for i := range d.pages { @@ -65,6 +66,8 @@ func (d *Doc) Bytes() ([]byte, error) { }) catalog := reader.Dict{"Type": reader.Name("Catalog"), "Pages": pagesRef} + d.keepCatalogue(w, catalog, kept) + kept.write(w) if outlines := d.writeOutlines(w, where, refs); outlines != nil { catalog["Outlines"] = outlines } @@ -76,11 +79,11 @@ func (d *Doc) Bytes() ([]byte, error) { } // buildPage assembles one page's dictionary without writing it. -func (d *Doc) buildPage(w *reader.Writer, p Page, parent reader.Ref, where destinations) reader.Dict { +func (d *Doc) buildPage(w *reader.Writer, p Page, parent reader.Ref, where destinations, kept *keptAnnots) reader.Dict { if p.blank || p.tiles != nil { return d.buildMadePage(w, p, parent) } - return d.buildBorrowedPage(w, p, parent, where) + return d.buildBorrowedPage(w, p, parent, where, kept) } // buildMadePage assembles a page this package built rather than borrowed. @@ -121,7 +124,7 @@ var rebuiltPageKeys = map[reader.Name]bool{ var sanitisedPageKeys = map[reader.Name]bool{"AF": true} // buildBorrowedPage copies a page out of the file it came from. -func (d *Doc) buildBorrowedPage(w *reader.Writer, p Page, parent reader.Ref, where destinations) reader.Dict { +func (d *Doc) buildBorrowedPage(w *reader.Writer, p Page, parent reader.Ref, where destinations, kept *keptAnnots) reader.Dict { src, _ := p.src.Page(p.number) copied := reader.Dict{} for k, v := range src { @@ -147,7 +150,7 @@ func (d *Doc) buildBorrowedPage(w *reader.Writer, p Page, parent reader.Ref, whe if len(extra) > 0 { d.decorate(w, p, src, copied, extra, resources) } - if annots := d.copyAnnots(w, p, src, where); len(annots) > 0 { + if annots := d.copyAnnots(w, p, src, where, kept); len(annots) > 0 { copied["Annots"] = annots } return copied