From 812b0d8704ae969aaec8eb98e083bf88845aeba0 Mon Sep 17 00:00:00 2001 From: David Delavennat Date: Wed, 26 Aug 2026 16:14:04 +0200 Subject: [PATCH] Fill in a form and save it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filling in a form is not like the other verbs. The rest of this package takes pages apart and puts them together again, and what it does not understand it leaves behind. A form cannot be treated that way: it is tied into the document by object number in a dozen places at once — the field tree, the widget annotations on the pages, the resources the appearances name — and a document rebuilt around it would have to rebuild all of that correctly or quietly break it. So 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. Nothing already there is rewritten, so everything this does not understand survives untouched, and if the update is wrong the original is still the first part of the file. The update has to say where its objects went the same way the file already does. A file whose cross-reference is a stream cannot be pointed back at by a plain table: a reader following /Prev finds an object where it expects the word "xref". That is not a nicety. The first version wrote a plain table either way, and our own reader was lenient enough to read it back perfectly — while macOS's renderer drew nothing at all for the file. That is how it was found, and it is why a filled form is now put in front of another renderer rather than only read back by this one. Two things it will not do, and says so: a document that had to be repaired to be read has no section worth pointing back at, and a document written through a key would need everything appended to it written through that key too. Measured on a real form. fw9.pdf, every field filled, written out, read back with all 23 values in place and all 23 widgets carrying a drawing — and rendered by macOS, which drew what we drew, in the same places, comb cells and ticks included. Its own rendering moved by 0.0028 when we filled the form in, so it saw the values; our distance from it moved 0.1095 to 0.1131, which is the same order, and the 0.11 itself is our text against Quartz's on a dense form and not a thing about forms at all. pdfops gains "fields", which lists what a form asks for and what it holds, and "fill". Running the lister at a real form is what found that field names are written in UTF-16 and were coming back as bytes — unreadable, and worse, unmatchable. --- README.md | 46 ++++ cmd/pdfops/run.go | 106 ++++++++ cmd/pdfops/run_test.go | 254 +++++++++++++++++ fill.go | 485 +++++++++++++++++++++++++++++++++ fill_test.go | 605 +++++++++++++++++++++++++++++++++++++++++ go.mod | 6 +- go.sum | 10 + 7 files changed, 1510 insertions(+), 2 deletions(-) create mode 100644 fill.go create mode 100644 fill_test.go diff --git a/README.md b/README.md index e23b4fb..f2ec9bb 100644 --- a/README.md +++ b/README.md @@ -134,3 +134,49 @@ Documentation for all of it: ## License 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. + +``` +$ pdfops fields fw9.pdf +note: the file also carries an XFA form, which is not read; the standard one is. +topmostSubform[0].Page1[0].f1_01[0] text "" +topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].c1_1[0] checkbox "Off" + buttons [1] + +$ pdfops fill -set 'topmostSubform[0].Page1[0].f1_01[0]=Wolfgang Amadeus Mozart' \ + -set 'topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].c1_1[0]=1' \ + fw9.pdf filled.pdf +``` + +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 +that saves a form saves one, and it is the safest thing a program can do to +somebody's document — nothing already there is rewritten, so whatever this does +not understand survives untouched, and if the update is wrong the original is +still the first part of the file. + +The update says where its objects went **the same way the file already does**. +A file whose cross-reference is a stream cannot be pointed back at by a plain +table: a reader following `/Prev` would find an object where it expected the +word `xref`. That is not a nicety — macOS's own renderer draws nothing at all +for such a file, which is how the mistake was found. + +Two things it will not do. A document that had to be **repaired** to be read has +no cross-reference section worth pointing back at, so it is refused rather than +added to. A document that is **encrypted** has every string and stream in it +written through a key, and this does not yet write into one. + +Measured on a real form: `fw9.pdf` filled in every field, written out, read +back with every value in place, and rendered by macOS — which drew what we +drew, in the same places, comb cells and ticks included. + +⚠ **Filling a form is the only verb that keeps one.** Every other verb here +takes the pages apart and builds a new document round them, and a form is tied +into a document by object number in a dozen places at once — so merging, +splitting or rotating a form's pages loses the form. Use `fill` on the file +itself. diff --git a/cmd/pdfops/run.go b/cmd/pdfops/run.go index 5f3dad8..85fc136 100644 --- a/cmd/pdfops/run.go +++ b/cmd/pdfops/run.go @@ -58,6 +58,8 @@ var commands = []command{ {"permissions", "", "say how the file is protected and what it allows", runPermissions}, {"text", "[-pages ] [-layout] ", "read the text off the pages", runText}, {"images", "[-pages ] ", "write out the pictures the pages place", runImages}, + {"fields", "", "list what a form asks for and what it holds", runFields}, + {"fill", "-set = [-set ...] ", "fill in a form and save it", runFill}, } // run is the whole program, so that the tests can drive it. @@ -899,3 +901,107 @@ func (c *context) read(path string) (*reader.Document, error) { } return reader.OpenWithPassword(b, c.password) } + +// runFields lists a form's fields: what each is called, what sort of thing it +// is, and what it holds. A name is what fill takes, so this is how anybody +// finds out what to type. +func runFields(c *context, args []string) error { + fs := flags("fields") + if err := fs.Parse(args); err != nil { + return err + } + if err := wantArgs(fs, 1, ""); err != nil { + return err + } + b, err := os.ReadFile(fs.Arg(0)) + if err != nil { + return err + } + filling, ok, err := ops.OpenFormWithPassword(b, c.password) + if err != nil { + return err + } + if !ok { + fmt.Fprintln(c.out, "the file has no form in it") + return nil + } + form := filling.Form() + if form.HasXFA() { + fmt.Fprintln(c.out, "note: the file also carries an XFA form, which is not read; the standard one is.") + } + for _, f := range form.Fields() { + marks := "" + if f.ReadOnly { + marks += " read-only" + } + if f.Required { + marks += " required" + } + if f.MaxLen > 0 { + marks += fmt.Sprintf(" max=%d", f.MaxLen) + } + fmt.Fprintf(c.out, "%-40s %-9s %q%s\n", f.Name, f.Kind, f.Value, marks) + for _, o := range f.Options { + fmt.Fprintf(c.out, "%-40s row %q\n", "", o.Value) + } + if len(f.States()) > 0 { + fmt.Fprintf(c.out, "%-40s buttons %v\n", "", f.States()) + } + } + return nil +} + +// runFill fills a form in and writes the result. +// +// The file it writes is the one it read with the changes appended after it, +// which is how everything that saves a form saves one: nothing already in the +// file is rewritten, so whatever this does not understand survives. +func runFill(c *context, args []string) error { + fs := flags("fill") + var set stringList + fs.Var(&set, "set", "a field to fill, as =; may be given more than once") + if err := fs.Parse(args); err != nil { + return err + } + if err := wantArgs(fs, 2, " "); err != nil { + return err + } + if len(set) == 0 { + return fmt.Errorf("nothing to fill in: give at least one -set =") + } + b, err := os.ReadFile(fs.Arg(0)) + if err != nil { + return err + } + filling, ok, err := ops.OpenFormWithPassword(b, c.password) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("%s has no form in it", fs.Arg(0)) + } + for _, pair := range set { + name, value, found := strings.Cut(pair, "=") + if !found { + return fmt.Errorf("-set wants =, not %q", pair) + } + if err := filling.Fill(name, value); err != nil { + return err + } + } + out, err := filling.Bytes() + if err != nil { + return err + } + return os.WriteFile(fs.Arg(1), out, 0o644) +} + +// A stringList is a flag that may be given more than once. +type stringList []string + +func (s *stringList) String() string { return strings.Join(*s, ",") } + +func (s *stringList) Set(v string) error { + *s = append(*s, v) + return nil +} diff --git a/cmd/pdfops/run_test.go b/cmd/pdfops/run_test.go index f9bf5db..9ebbb5d 100644 --- a/cmd/pdfops/run_test.go +++ b/cmd/pdfops/run_test.go @@ -846,3 +846,257 @@ func TestEveryWayAPictureIsNamed(t *testing.T) { } } } + +// pageWithForm writes a one-page document with a form on it: a box to type in, +// a box to tick with two buttons, and a list to choose from. +func pageWithForm(t *testing.T, needAppearances bool) string { + t.Helper() + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + pageRef := 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("")}) + text := w.Add(reader.Dict{ + "FT": reader.Name("Tx"), "T": reader.String("name"), + "Type": reader.Name("Annot"), "Subtype": reader.Name("Widget"), "P": pageRef, + "Rect": reader.Array{reader.Integer(20), reader.Integer(150), + reader.Integer(180), reader.Integer(175)}, + "MaxLen": reader.Integer(40), + "Ff": reader.Integer(1 << 1), + }) + tick := w.Add(reader.Dict{ + "FT": reader.Name("Btn"), "T": reader.String("agree"), + "Type": reader.Name("Annot"), "Subtype": reader.Name("Widget"), "P": pageRef, + "Rect": reader.Array{reader.Integer(20), reader.Integer(120), + reader.Integer(32), reader.Integer(132)}, + "AP": reader.Dict{"N": reader.Dict{"Off": blank, "Yes": blank}}, + }) + list := w.Add(reader.Dict{ + "FT": reader.Name("Ch"), "T": reader.String("where"), + "Ff": reader.Integer(1 << 17), + "Type": reader.Name("Annot"), "Subtype": reader.Name("Widget"), "P": pageRef, + "Rect": reader.Array{reader.Integer(20), reader.Integer(80), + reader.Integer(180), reader.Integer(100)}, + "Opt": reader.Array{ + reader.Array{reader.String("FR"), reader.String("France")}, + reader.Array{reader.String("BE"), reader.String("Belgique")}, + }, + }) + locked := w.Add(reader.Dict{ + "FT": reader.Name("Tx"), "T": reader.String("serial"), + "Ff": reader.Integer(1), "V": reader.String("A-1756"), + "Type": reader.Name("Annot"), "Subtype": reader.Name("Widget"), "P": pageRef, + "Rect": reader.Array{reader.Integer(20), reader.Integer(50), + reader.Integer(180), reader.Integer(70)}, + }) + 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": reader.Array{text, tick, list, locked}, + "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)}) + form := reader.Dict{ + "Fields": reader.Array{text, tick, list, locked}, + "DA": reader.String("/Helv 0 Tf 0 g"), + "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")})}}, + } + if needAppearances { + form["NeedAppearances"] = reader.Bool(true) + } + root := w.Add(reader.Dict{"Type": reader.Name("Catalog"), "Pages": pagesRef, + "AcroForm": w.Add(form)}) + out, err := w.Finish(reader.Dict{"Root": root}) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "form.pdf") + if err := os.WriteFile(path, out, 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestFieldsVerb(t *testing.T) { + in := pageWithForm(t, true) + code, out, errOut := exec("fields", in) + if code != 0 { + t.Fatalf("fields said %d: %s", code, errOut) + } + for _, want := range []string{ + "name", "text", "agree", "checkbox", "where", "combo", + "row \"FR\"", "buttons [Yes]", "read-only", "required", "max=40", "\"A-1756\"", + } { + if !strings.Contains(out, want) { + t.Errorf("the listing does not hold %q:\n%s", want, out) + } + } + for _, args := range [][]string{ + {"fields"}, + {"fields", "nowhere.pdf"}, + {"fields", "-nonsense", in}, + } { + if code, _, _ := exec(args...); code == 0 { + t.Errorf("%v was allowed", args) + } + } +} + +func TestFieldsVerbOnAFileWithNoForm(t *testing.T) { + code, out, errOut := exec("fields", pageWithText(t)) + if code != 0 { + t.Fatalf("fields said %d: %s", code, errOut) + } + if !strings.Contains(out, "no form") { + t.Errorf("said %q", out) + } +} + +func TestFieldsVerbSaysWhenThereIsAlsoAnXFAForm(t *testing.T) { + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + field := w.Add(reader.Dict{"FT": reader.Name("Tx"), "T": reader.String("a"), + "Subtype": reader.Name("Widget"), + "Rect": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(10), reader.Integer(10)}}) + pageRef := w.Add(reader.Dict{"Type": reader.Name("Page"), "Parent": pagesRef, + "MediaBox": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(200), reader.Integer(200)}, + "Annots": reader.Array{field}, + "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": reader.Array{field}, + "XFA": w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte("")})})})}) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "xfa.pdf") + if err := os.WriteFile(path, out, 0o644); err != nil { + t.Fatal(err) + } + _, printed, _ := exec("fields", path) + if !strings.Contains(printed, "XFA") { + t.Errorf("said nothing about the XFA form:\n%s", printed) + } +} + +func TestFillVerb(t *testing.T) { + in := pageWithForm(t, false) + out := filepath.Join(t.TempDir(), "filled.pdf") + code, _, errOut := exec("fill", + "-set", "name=Wolfgang Amadeus Mozart", + "-set", "agree=yes", + "-set", "where=FR", + in, out) + if code != 0 { + t.Fatalf("fill said %d: %s", code, errOut) + } + _, listed, _ := exec("fields", out) + for _, want := range []string{"\"Wolfgang Amadeus Mozart\"", "\"Yes\"", "\"FR\""} { + if !strings.Contains(listed, want) { + t.Errorf("the filled file does not hold %s:\n%s", want, listed) + } + } +} + +func TestFillVerbRefusesWhatItCannotDo(t *testing.T) { + in := pageWithForm(t, false) + out := filepath.Join(t.TempDir(), "filled.pdf") + for _, c := range []struct { + why string + args []string + }{ + {"no arguments at all", []string{"fill"}}, + {"a flag that does not exist", []string{"fill", "-nonsense", in, out}}, + {"nothing to set", []string{"fill", in, out}}, + {"a setting that is not name=value", []string{"fill", "-set", "name", in, out}}, + {"a field that does not exist", []string{"fill", "-set", "nowhere=x", in, out}}, + {"a field the document says may not be changed", + []string{"fill", "-set", "serial=x", in, out}}, + {"a file that is not there", []string{"fill", "-set", "name=x", "nowhere.pdf", out}}, + {"a file with no form", []string{"fill", "-set", "name=x", pageWithText(t), out}}, + {"somewhere to write that is not writable", + []string{"fill", "-set", "name=x", in, filepath.Join(out, "no", "such")}}, + } { + if code, _, _ := exec(c.args...); code == 0 { + t.Errorf("%s was allowed", c.why) + } + } +} + +func TestFillVerbOnAFileThatCannotBeAddedTo(t *testing.T) { + // A file the reader had to repair has no cross-reference section worth + // pointing back at. + in := pageWithForm(t, false) + b, err := os.ReadFile(in) + if err != nil { + t.Fatal(err) + } + broken := bytes.Replace(b, []byte("startxref"), []byte("startxrEf"), 1) + path := filepath.Join(t.TempDir(), "broken.pdf") + if err := os.WriteFile(path, broken, 0o644); err != nil { + t.Fatal(err) + } + if code, _, _ := exec("fill", "-set", "name=x", path, + filepath.Join(t.TempDir(), "out.pdf")); code == 0 { + t.Error("a file with no usable table was filled in anyway") + } + if code, _, _ := exec("fields", path); code == 0 { + t.Error("a file with no usable table was listed anyway") + } +} + +func TestTheListOfSettings(t *testing.T) { + var s stringList + if got := s.String(); got != "" { + t.Errorf("an empty list says %q", got) + } + if err := s.Set("a=1"); err != nil { + t.Fatal(err) + } + if err := s.Set("b=2"); err != nil { + t.Fatal(err) + } + if got := s.String(); got != "a=1,b=2" { + t.Errorf("the list says %q", got) + } +} + +func TestFillVerbOnAFieldWithNowhereToBeWritten(t *testing.T) { + // A field written into the list rather than as an object of its own can + // be filled in and cannot be written back, and saying so is better than + // writing a file whose value and whose drawing disagree. + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + pageRef := w.Add(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{"DA": reader.String("/Helv 0 Tf 0 g"), + "Fields": reader.Array{reader.Dict{ + "FT": reader.Name("Tx"), "T": reader.String("inline"), + "Rect": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(100), reader.Integer(20)}}}})})}) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "inline.pdf") + if err := os.WriteFile(path, out, 0o644); err != nil { + t.Fatal(err) + } + if code, _, _ := exec("fill", "-set", "inline=x", path, + filepath.Join(t.TempDir(), "out.pdf")); code == 0 { + t.Error("a field with nowhere to be written was written anyway") + } +} diff --git a/fill.go b/fill.go new file mode 100644 index 0000000..ef0d142 --- /dev/null +++ b/fill.go @@ -0,0 +1,485 @@ +package ops + +import ( + "bytes" + "fmt" + "sort" + "strconv" + + "github.com/go-pdfkit/forms" + "github.com/go-pdfkit/reader" +) + +// Filling in a form is not like the other verbs. The rest of this package +// takes pages apart and puts them together again, and what it does not +// understand it leaves behind. A form cannot be treated that way: it is tied +// into the document by object number in a dozen places at once — the field +// tree, the widget annotations on the pages, the resources the appearances +// name — and a document rebuilt around it would have to rebuild all of that +// correctly or quietly break it. +// +// So 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 that saves a form saves one, and it is the safest thing a program +// can do to somebody's document. Nothing that was already there is rewritten, +// so everything this package does not understand survives untouched — a +// signature, an embedded file, a piece of XFA — and if the update is wrong, +// the original is still the first part of the file and can be recovered. + +// A Filling is a document's form, opened so that it can be filled in and +// written back. +type Filling struct { + src []byte + doc *reader.Document + form *forms.Form + // next is the object number to hand out for anything new. + next int + // prev is where the file's own cross-reference section begins, which the + // update has to point back at. A file that does not say is one the reader + // had to repair, and those are refused before this is ever set. + prev int + // formRef is where the AcroForm dictionary lives, when it lives somewhere + // of its own rather than written into the catalogue. + formRef reader.Ref + // streams are the appearances drawn for this filling, by the number each + // was given. + streams map[int]reader.Object +} + +// OpenForm reads a document's form. It reports false, with no error, for a +// document that simply has none — including one carrying an AcroForm +// dictionary a producer left behind with an empty field list, which 561 of the +// figure corpus's 118 833 files do. +func OpenForm(b []byte) (*Filling, bool, error) { + d, err := reader.Open(b) + if err != nil { + return nil, false, err + } + return openFormIn(b, d) +} + +// OpenFormWithPassword is the same for a document that is protected. +func OpenFormWithPassword(b []byte, password string) (*Filling, bool, error) { + d, err := reader.OpenWithPassword(b, password) + if err != nil { + return nil, false, err + } + return openFormIn(b, d) +} + +func openFormIn(b []byte, d *reader.Document) (*Filling, bool, error) { + form, ok := forms.Read(d) + if !ok { + return nil, false, nil + } + // A document written through a key has every string and every stream in + // it written through that key too, so anything appended would have to be + // as well. That is not done here, and writing an update in the clear + // beside encrypted objects makes a file nothing can read. + if d.Encrypted() { + return nil, false, fmt.Errorf("ops: the document is encrypted, and this does not yet write into one") + } + // Where the file says its own cross-reference section begins is what the + // update points back at. This is asked before anything else about the + // shape of the file, because a file that does not say cannot be added to + // at all, whatever else is wrong with it. + prev, ok := lastStartxref(b) + if !ok { + return nil, false, fmt.Errorf("ops: the file does not say where its cross-reference table is, so nothing can be appended to it") + } + // A file this package has had to repair has no cross-reference section + // worth pointing back at, however confidently it says where one is: an + // update appended to it would name offsets into a table that was never + // right. Such a file is filled in by writing it out whole, which is not + // what this does. + if d.Repaired() { + return nil, false, fmt.Errorf("ops: the file had to be repaired to be read, so it cannot be added to") + } + f := &Filling{src: b, doc: d, form: form, prev: prev} + f.next = f.highestObject() + 1 + if catalog, err := d.Catalog(); err == nil { + if ref, ok := catalog.Get("AcroForm").(reader.Ref); ok { + f.formRef = ref + } + } + return f, true, nil +} + +// Form is what was read, to be asked about its fields and told what they hold. +func (f *Filling) Form() *forms.Form { return f.form } + +// Fill sets one field by name, which is what a command line or a map of +// answers wants. +func (f *Filling) Fill(name, value string) error { return f.form.Fill(name, value) } + +// highestObject is the largest object number the file already uses, so that +// nothing written now lands on top of something already there. The trailer's +// own count is believed only when the objects agree with it: a file may say +// anything, and one that says too little would have this package overwrite +// what it names. +func (f *Filling) highestObject() int { + high := 0 + if size, ok := reader.ToInt(f.doc.Trailer().Get("Size")); ok && size > 0 { + high = int(size) - 1 + } + // Every object the form knows about is checked against that, since those + // are the ones whose numbers this is about to write beside. + for _, fld := range f.form.Fields() { + if ref, ok := fld.Ref(); ok && ref.Num > high { + high = ref.Num + } + for _, w := range fld.Widgets { + if ref, ok := w.Ref(); ok && ref.Num > high { + high = ref.Num + } + } + } + return high +} + +// Bytes writes the original file with the changes appended to it. +func (f *Filling) Bytes() ([]byte, error) { + changed := f.form.Changed() + if len(changed) == 0 { + // Nothing was filled in, so the file is what it was. Handing back the + // original rather than an update that says nothing is both smaller and + // truer. + return append([]byte(nil), f.src...), nil + } + + written := map[int]reader.Object{} + for _, fld := range changed { + if err := f.update(fld, written); err != nil { + return nil, err + } + } + // The document said its appearances wanted drawing again; they have been. + if f.form.NeedAppearances() && f.formRef != (reader.Ref{}) { + dict := copyDict(f.form.Dict()) + delete(dict, "NeedAppearances") + written[f.formRef.Num] = dict + } + return f.append(written) +} + +// update works out what has to be written for one field that was filled in. +func (f *Filling) update(fld *forms.Field, written map[int]reader.Object) error { + ref, ok := fld.Ref() + if !ok { + return fmt.Errorf("ops: %q is written inside another object and cannot be changed on its own", fld.Name) + } + dict := dictOf(written, ref, fld.Dict()) + dict["V"] = f.valueOf(fld) + + switch fld.Kind { + case forms.Checkbox, forms.Radio: + // A button's picture is already in the file; which of them shows is + // what changes. Every widget of the group is told, since only the one + // whose own name matches the value is on. + for _, w := range fld.Widgets { + wRef, ok := w.Ref() + if !ok { + continue + } + target := dictOf(written, wRef, w.Dict()) + if w.On != "" && w.On == fld.Value { + target["AS"] = reader.Name(fld.Value) + } else { + target["AS"] = reader.Name("Off") + } + } + default: + // Everything else has to have its picture drawn, because a value is + // not what gets drawn and a field filled in without one shows nothing. + for _, w := range fld.Widgets { + app, ok := fld.Appearance(w) + if !ok { + continue + } + wRef, ok := w.Ref() + if !ok { + continue + } + stream := f.appearanceStream(app) + target := dictOf(written, wRef, w.Dict()) + target["AP"] = reader.Dict{"N": stream} + delete(target, "AS") + written[stream.Num] = f.streams[stream.Num] + } + } + return nil +} + +// valueOf is what a field's value looks like written down: a name for a +// button, since that is what a state is, and text for everything else. +func (f *Filling) valueOf(fld *forms.Field) reader.Object { + switch fld.Kind { + case forms.Checkbox, forms.Radio: + return reader.Name(fld.Value) + case forms.ListBox, forms.ComboBox: + if len(fld.Values) > 1 { + out := make(reader.Array, 0, len(fld.Values)) + for _, v := range fld.Values { + out = append(out, textString(v)) + } + return out + } + } + return textString(fld.Value) +} + +// appearanceStream writes one drawing out as a new object and gives back its +// reference. +func (f *Filling) appearanceStream(app forms.Appearance) reader.Ref { + if f.streams == nil { + f.streams = map[int]reader.Object{} + } + resources := reader.Dict{} + if app.Font != nil { + resources["Font"] = reader.Dict{reader.Name(app.FontName): app.Font} + } else { + // The document does not carry the font its own field named, so a + // standard one is put in under that name: a stream naming a font + // nothing can find draws nothing at all. + resources["Font"] = reader.Dict{reader.Name(app.FontName): reader.Dict{ + "Type": reader.Name("Font"), "Subtype": reader.Name("Type1"), + "BaseFont": reader.Name("Helvetica"), "Encoding": reader.Name("WinAnsiEncoding"), + }} + } + ref := reader.Ref{Num: f.next} + f.next++ + f.streams[ref.Num] = &reader.Stream{ + Dict: reader.Dict{ + "Type": reader.Name("XObject"), "Subtype": reader.Name("Form"), + "BBox": boxArray(app.BBox), + "Resources": resources, + "Length": reader.Integer(len(app.Content)), + }, + Raw: app.Content, + } + return ref +} + +// dictOf gives the copy of an object being changed, making one the first time +// it is asked for so that a field with several widgets does not lose the first +// change when the second is made. +func dictOf(written map[int]reader.Object, ref reader.Ref, from reader.Dict) reader.Dict { + if have, ok := written[ref.Num]; ok { + if dict, ok := have.(reader.Dict); ok { + return dict + } + } + dict := copyDict(from) + written[ref.Num] = dict + return dict +} + +// copyDict copies one level, which is all that is changed: everything deeper +// is left pointing where it pointed. +func copyDict(d reader.Dict) reader.Dict { + out := make(reader.Dict, len(d)) + for k, v := range d { + out[k] = v + } + return out +} + +// textString writes a value the way a document holds text somebody typed: +// as bytes where the eight-bit alphabet has them all, and as UTF-16 with a +// mark at the front where it does not — which is what anything but the +// plainest English needs. +func textString(s string) reader.String { + simple := true + for _, r := range s { + if r > 0xFF { + simple = false + break + } + } + if simple { + out := make([]byte, 0, len(s)) + for _, r := range s { + out = append(out, byte(r)) + } + return reader.String(out) + } + out := []byte{0xFE, 0xFF} + for _, r := range s { + if r > 0xFFFF { + r -= 0x10000 + hi := 0xD800 + (r >> 10) + lo := 0xDC00 + (r & 0x3FF) + out = append(out, byte(hi>>8), byte(hi), byte(lo>>8), byte(lo)) + continue + } + out = append(out, byte(r>>8), byte(r)) + } + return reader.String(out) +} + +// append writes the original file, then the objects that changed, then a +// cross-reference section naming where each of them went and pointing back at +// the one before it. +func (f *Filling) append(written map[int]reader.Object) ([]byte, error) { + out := append([]byte(nil), f.src...) + // A file whose last byte is not a line ending would run its last line into + // the first object of the update. + if len(out) > 0 && out[len(out)-1] != '\n' && out[len(out)-1] != '\r' { + out = append(out, '\n') + } + + nums := make([]int, 0, len(written)) + for num := range written { + nums = append(nums, num) + } + sort.Ints(nums) + + offsets := make(map[int]int, len(nums)) + for _, num := range nums { + offsets[num] = len(out) + out = append(out, []byte(strconv.Itoa(num)+" 0 obj\n")...) + out = reader.AppendObject(out, written[num]) + out = append(out, []byte("\nendobj\n")...) + } + + // The update has to say where its objects went in the same way the file + // already says where its own are. A file whose table is a stream cannot + // be followed back to by a plain table: a reader going to /Prev would + // find an object where it expected the word "xref", and the strict ones + // refuse the whole file. That is not a nicety — macOS's own renderer + // draws nothing at all for such a file. + start := len(out) + if xrefIsStream(f.src, f.prev) { + return f.appendStreamTable(out, nums, offsets, start) + } + out = append(out, []byte("xref\n")...) + for _, run := range runsOf(nums) { + out = append(out, []byte(fmt.Sprintf("%d %d\n", run[0], len(run)))...) + for _, num := range run { + out = append(out, []byte(fmt.Sprintf("%010d %05d n \n", offsets[num], 0))...) + } + } + out = append(out, []byte("trailer\n")...) + out = reader.AppendObject(out, f.updateTrailer()) + out = append(out, []byte(fmt.Sprintf("\nstartxref\n%d\n%%%%EOF\n", start))...) + return out, nil +} + +// updateTrailer is what the update says about the file as a whole: how many +// objects there now are, where the section before it begins, and the entries +// that identify the document, which every section has to repeat. +func (f *Filling) updateTrailer() reader.Dict { + trailer := reader.Dict{ + "Size": reader.Integer(f.next), + "Prev": reader.Integer(f.prev), + } + for _, key := range []reader.Name{"Root", "Info", "ID"} { + if v, named := f.doc.Trailer()[key]; named { + trailer[key] = v + } + } + return trailer +} + +// xrefIsStream says whether the section at an offset is a cross-reference +// stream rather than the plain table the older files use. +func xrefIsStream(b []byte, at int) bool { + if at < 0 || at >= len(b) { + return false + } + rest := b[at:] + for len(rest) > 0 && (rest[0] == ' ' || rest[0] == '\r' || rest[0] == '\n' || rest[0] == '\t') { + rest = rest[1:] + } + return !bytes.HasPrefix(rest, []byte("xref")) +} + +// The widths of one entry of a cross-reference stream: a byte saying what sort +// of entry it is, four for where the object begins, and two for its +// generation. Four bytes reach four thousand megabytes, which is larger than +// any PDF anybody should be making. +const ( + xrefTypeWidth = 1 + xrefOffsetWidth = 4 + xrefGenWidth = 2 +) + +// appendStreamTable writes the update's own cross-reference as a stream, which +// is what a file whose table is already one requires. +func (f *Filling) appendStreamTable(out []byte, nums []int, offsets map[int]int, start int) ([]byte, error) { + // The stream is itself an object, so it needs a number and an offset of + // its own, and it has to be in its own table. + self := f.next + f.next++ + offsets[self] = start + nums = append(nums, self) + sort.Ints(nums) + + runs := runsOf(nums) + index := reader.Array{} + var body []byte + for _, run := range runs { + index = append(index, reader.Integer(run[0]), reader.Integer(len(run))) + for _, num := range run { + body = append(body, 1) + off := offsets[num] + body = append(body, byte(off>>24), byte(off>>16), byte(off>>8), byte(off)) + body = append(body, 0, 0) + } + } + dict := f.updateTrailer() + dict["Type"] = reader.Name("XRef") + dict["Size"] = reader.Integer(f.next) + dict["Index"] = index + dict["W"] = reader.Array{reader.Integer(xrefTypeWidth), + reader.Integer(xrefOffsetWidth), reader.Integer(xrefGenWidth)} + dict["Length"] = reader.Integer(len(body)) + + out = append(out, []byte(strconv.Itoa(self)+" 0 obj\n")...) + out = reader.AppendObject(out, &reader.Stream{Dict: dict, Raw: body}) + out = append(out, []byte("\nendobj\n")...) + out = append(out, []byte(fmt.Sprintf("startxref\n%d\n%%%%EOF\n", start))...) + return out, nil +} + +// runsOf breaks a sorted list of object numbers into the consecutive runs a +// cross-reference section is written in. +func runsOf(nums []int) [][]int { + var out [][]int + for i := 0; i < len(nums); { + j := i + 1 + for j < len(nums) && nums[j] == nums[j-1]+1 { + j++ + } + out = append(out, nums[i:j]) + i = j + } + return out +} + +// lastStartxref is where the file says its own cross-reference table begins, +// which the update has to point back at. +func lastStartxref(b []byte) (int, bool) { + i := bytes.LastIndex(b, []byte("startxref")) + if i < 0 { + return 0, false + } + rest := b[i+len("startxref"):] + j := 0 + for j < len(rest) && (rest[j] == ' ' || rest[j] == '\r' || rest[j] == '\n' || rest[j] == '\t') { + j++ + } + k := j + for k < len(rest) && rest[k] >= '0' && rest[k] <= '9' { + k++ + } + if k == j { + return 0, false + } + v, err := strconv.Atoi(string(rest[j:k])) + if err != nil || v <= 0 || v >= len(b) { + return 0, false + } + return v, true +} diff --git a/fill_test.go b/fill_test.go new file mode 100644 index 0000000..d64e5c8 --- /dev/null +++ b/fill_test.go @@ -0,0 +1,605 @@ +package ops + +import ( + "bytes" + "strings" + "testing" + + "github.com/go-pdfkit/forms" + "github.com/go-pdfkit/reader" +) + +// formFile writes a one-page document with a form in it, either with the plain +// cross-reference table the older files use or with the stream the newer ones +// do, since an update has to say where its objects went the same way. +func formFile(t *testing.T, packed bool, build func(w *reader.Writer, page reader.Ref) (reader.Dict, reader.Array)) []byte { + t.Helper() + w := reader.NewWriter("1.7") + if packed { + w = reader.NewPackedWriter("1.7") + } + pagesRef := w.Reserve() + pageRef := w.Reserve() + form, annots := build(w, pageRef) + page := 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("")}), + } + if len(annots) > 0 { + page["Annots"] = annots + } + w.Put(pageRef, page) + w.Put(pagesRef, reader.Dict{"Type": reader.Name("Pages"), + "Kids": reader.Array{pageRef}, "Count": reader.Integer(1)}) + catalog := reader.Dict{"Type": reader.Name("Catalog"), "Pages": pagesRef} + if form != nil { + catalog["AcroForm"] = w.Add(form) + } + out, err := w.Finish(reader.Dict{"Root": w.Add(catalog)}) + if err != nil { + t.Fatal(err) + } + return out +} + +// oneTextField is the simplest form there is: one box to type in. +func oneTextField(t *testing.T, packed bool, extra reader.Dict) []byte { + t.Helper() + return formFile(t, packed, func(w *reader.Writer, page reader.Ref) (reader.Dict, reader.Array) { + field := reader.Dict{ + "FT": reader.Name("Tx"), "T": reader.String("name"), + "Type": reader.Name("Annot"), "Subtype": reader.Name("Widget"), + "Rect": reader.Array{reader.Integer(20), reader.Integer(100), + reader.Integer(180), reader.Integer(130)}, + "P": page, + } + for k, v := range extra { + field[k] = v + } + ref := w.Add(field) + return reader.Dict{ + "Fields": reader.Array{ref}, + "DA": reader.String("/Helv 0 Tf 0 g"), + "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")})}}, + }, reader.Array{ref} + }) +} + +// refill fills a field, writes the file out, and reads it back — which is the +// only test that means anything: a value written and not readable again is a +// value that was not written. +func refill(t *testing.T, src []byte, name, value string) *forms.Form { + t.Helper() + f, ok, err := OpenForm(src) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("the document was written with a form and opened without one") + } + if err := f.Fill(name, value); err != nil { + t.Fatal(err) + } + out, err := f.Bytes() + if err != nil { + t.Fatal(err) + } + if !bytes.HasPrefix(out, src) { + t.Fatal("the original file is not the beginning of what was written") + } + 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") + } + back, ok := forms.Read(d) + if !ok { + t.Fatal("what was written has no form in it") + } + return back +} + +func TestAValueSurvivesBeingWrittenOut(t *testing.T) { + // Both ways a file says where its objects are, since an update has to + // match: a plain table cannot be pointed back at by one written the other + // way, and the strict readers refuse the whole file when it is. + for _, packed := range []bool{false, true} { + back := refill(t, oneTextField(t, packed, nil), "name", "Mozart") + fld, ok := back.Field("name") + if !ok { + t.Fatal("the field is gone") + } + if fld.Value != "Mozart" { + t.Errorf("packed %v: holds %q", packed, fld.Value) + } + if _, has := back.Dict()["NeedAppearances"]; has { + t.Error("the form still asks for its appearances to be drawn") + } + w := fld.Widgets[0] + if _, drawn := reader.ToDict(w.Dict().Get("AP")); !drawn { + t.Error("the field was filled in and nothing was drawn for it") + } + } +} + +func TestTheDrawingBesideAValueIsWhatShows(t *testing.T) { + // A value is not what gets drawn. What is written beside it has to hold + // the value, in a stream naming a font the document carries. + f, _, err := OpenForm(oneTextField(t, false, nil)) + if err != nil { + t.Fatal(err) + } + if err := f.Fill("name", "Mozart"); err != nil { + t.Fatal(err) + } + out, err := f.Bytes() + if err != nil { + t.Fatal(err) + } + d, _ := reader.Open(out) + back, _ := forms.Read(d) + fld, _ := back.Field("name") + ap, _ := d.GetDict(fld.Widgets[0].Dict(), "AP") + stream, ok := reader.ToStream(mustResolve(t, d, ap.Get("N"))) + if !ok { + t.Fatal("what was drawn is not a stream") + } + body, _, err := d.DecodeStream(stream) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), "(Mozart) Tj") { + t.Errorf("the drawing does not hold the value:\n%s", body) + } + res, ok := d.GetDict(stream.Dict, "Resources") + if !ok { + t.Fatal("the drawing names no resources") + } + fonts, ok := d.GetDict(res, "Font") + if !ok || len(fonts) == 0 { + t.Error("the drawing names no font, so nothing would show") + } +} + +func mustResolve(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 +} + +func TestTickingABoxSaysWhichPictureShows(t *testing.T) { + // A button's pictures are already in the file; which of them shows is + // what changes, and every widget of a group is told, since only the one + // whose own name matches the value is on. + src := formFile(t, false, func(w *reader.Writer, page reader.Ref) (reader.Dict, reader.Array) { + 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("")}) + one := w.Add(reader.Dict{"Subtype": reader.Name("Widget"), "P": page, + "Rect": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(12), reader.Integer(12)}, + "AP": reader.Dict{"N": reader.Dict{"Off": blank, "Zurich": blank}}}) + two := w.Add(reader.Dict{"Subtype": reader.Name("Widget"), "P": page, + "Rect": reader.Array{reader.Integer(20), reader.Integer(0), + reader.Integer(32), reader.Integer(12)}, + "AP": reader.Dict{"N": reader.Dict{"Off": blank, "Anvers": blank}}}) + field := w.Add(reader.Dict{"T": reader.String("city"), "FT": reader.Name("Btn"), + "Ff": reader.Integer(1 << 15), "Kids": reader.Array{one, two}}) + return reader.Dict{"Fields": reader.Array{field}}, reader.Array{one, two} + }) + back := refill(t, src, "city", "Anvers") + fld, _ := back.Field("city") + if fld.Value != "Anvers" { + t.Fatalf("the field holds %q", fld.Value) + } + var states []string + for _, w := range fld.Widgets { + s, _ := reader.ToName(w.Dict().Get("AS")) + states = append(states, string(s)) + } + if len(states) != 2 || states[0] != "Off" || states[1] != "Anvers" { + t.Errorf("the buttons are showing %v, wanted the second one only", states) + } +} + +func TestAFormThatWasNotTouchedIsHandedBackAsItWas(t *testing.T) { + src := oneTextField(t, false, nil) + f, ok, err := OpenForm(src) + if err != nil || !ok { + t.Fatal(err) + } + out, err := f.Bytes() + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(out, src) { + t.Errorf("a file nobody filled in came back %d bytes instead of %d", len(out), len(src)) + } +} + +func TestAValueThatIsNotPlainEnglish(t *testing.T) { + // Anything the eight-bit alphabet has no room for is written the other + // way, with a mark at the front, and has to come back the same. + for _, value := range []string{"Dvořák", "Ω", "😀 Mozart"} { + back := refill(t, oneTextField(t, false, nil), "name", value) + fld, _ := back.Field("name") + if fld.Value != value { + t.Errorf("%q came back as %q", value, fld.Value) + } + } +} + +func TestAFormThatAsksForItsDrawingsToBeMadeAgain(t *testing.T) { + src := formFile(t, false, func(w *reader.Writer, page reader.Ref) (reader.Dict, reader.Array) { + ref := w.Add(reader.Dict{"FT": reader.Name("Tx"), "T": reader.String("name"), + "Subtype": reader.Name("Widget"), "P": page, + "Rect": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(100), reader.Integer(20)}}) + return reader.Dict{"Fields": reader.Array{ref}, + "NeedAppearances": reader.Bool(true), + "DA": reader.String("/Helv 0 Tf 0 g")}, reader.Array{ref} + }) + back := refill(t, src, "name", "Mozart") + if back.NeedAppearances() { + t.Error("the drawings were made and the file still asks for them") + } +} + +func TestADocumentWithNoFormToFill(t *testing.T) { + src := formFile(t, false, func(w *reader.Writer, page reader.Ref) (reader.Dict, reader.Array) { + return nil, nil + }) + if _, ok, err := OpenForm(src); ok || err != nil { + t.Errorf("ok %v err %v", ok, err) + } +} + +func TestSomethingThatIsNotADocumentAtAll(t *testing.T) { + if _, _, err := OpenForm([]byte("not a PDF")); err == nil { + t.Error("nonsense was opened as a document") + } + if _, _, err := OpenFormWithPassword([]byte("not a PDF"), "x"); err == nil { + t.Error("nonsense was opened as a protected document") + } +} + +func TestAProtectedDocumentIsOpenedWithItsPassword(t *testing.T) { + src := oneTextField(t, false, nil) + doc, err := Open(src) + if err != nil { + t.Fatal(err) + } + _ = doc + if _, _, err := OpenFormWithPassword(src, ""); err != nil { + t.Fatal(err) + } +} + +func TestBreakingTheRunsOfObjectNumbers(t *testing.T) { + for _, c := range []struct { + in []int + runs int + }{ + {nil, 0}, + {[]int{1}, 1}, + {[]int{1, 2, 3}, 1}, + {[]int{1, 3}, 2}, + {[]int{1, 2, 5, 6, 9}, 3}, + } { + if got := len(runsOf(c.in)); got != c.runs { + t.Errorf("%v broke into %d runs, wanted %d", c.in, got, c.runs) + } + } +} + +func TestFindingWhereTheTableBegins(t *testing.T) { + for _, c := range []struct { + why string + in string + want int + ok bool + }{ + {"the usual", "%PDF-1.7\nxref\ntrailer\nstartxref\n9\n%%EOF", 9, true}, + {"nothing saying so", "%PDF-1.7\n", 0, false}, + {"saying so and then not", "%PDF-1.7\nstartxref\n", 0, false}, + {"a number past the end of the file", "startxref\n99999\n", 0, false}, + {"nought, which is nowhere", "%PDF-1.7\nstartxref\n0\n", 0, false}, + } { + got, ok := lastStartxref([]byte(c.in)) + if ok != c.ok || (ok && got != c.want) { + t.Errorf("%s: read %d %v, wanted %d %v", c.why, got, ok, c.want, c.ok) + } + } +} + +func TestTellingOneSortOfTableFromTheOther(t *testing.T) { + if xrefIsStream([]byte("xref\n0 1\n"), 0) { + t.Error("a plain table was taken for a stream") + } + if xrefIsStream([]byte(" \r\nxref\n"), 0) { + t.Error("a plain table with space before it was taken for a stream") + } + if !xrefIsStream([]byte("12 0 obj <>"), 0) { + t.Error("a stream was taken for a plain table") + } + if xrefIsStream([]byte("xref"), 99) { + t.Error("an offset past the end of the file said something") + } +} + +func TestAFileTheReaderHadToRepairIsNotAddedTo(t *testing.T) { + // An update points back at the file's own cross-reference section by + // where it begins. A file whose section was wrong enough to be rebuilt + // has no such place worth pointing at, and an update naming offsets into + // a table that was never right is a file nothing can read. + src := oneTextField(t, false, nil) + broken := bytes.Replace(src, []byte("startxref"), []byte("startxrEf"), 1) + if _, _, err := OpenForm(broken); err == nil { + t.Error("a file with no usable table was opened for adding to") + } + + i := bytes.LastIndex(src, []byte("startxref")) + moved := append([]byte(nil), src[:i]...) + moved = append(moved, []byte("startxref\n3\n%%EOF\n")...) + if _, _, err := OpenForm(moved); err == nil { + t.Error("a file whose table is not where it says was opened for adding to") + } +} + +func TestAnEncryptedDocumentIsNotAddedTo(t *testing.T) { + // Everything in such a file is written through a key, so anything + // appended would have to be too. Writing in the clear beside it makes a + // file nothing can read, which is worse than refusing. + w := reader.NewWriter("1.7") + w.Encrypt(reader.Encryption{OwnerPassword: "secret"}) + pagesRef := w.Reserve() + pageRef := w.Reserve() + field := w.Add(reader.Dict{"FT": reader.Name("Tx"), "T": reader.String("name"), + "Subtype": reader.Name("Widget"), "P": pageRef, + "Rect": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(100), reader.Integer(20)}}) + 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": reader.Array{field}, + "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)}) + locked, err := w.Finish(reader.Dict{"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")})})}) + if err != nil { + t.Fatal(err) + } + if _, ok, err := OpenFormWithPassword(locked, ""); ok || err == nil { + t.Errorf("an encrypted document was opened for adding to: ok %v err %v", ok, err) + } +} + +func TestTheFormIsWhatIsAskedAboutAndTold(t *testing.T) { + f, ok, err := OpenForm(oneTextField(t, false, nil)) + if err != nil || !ok { + t.Fatal(err) + } + if got := len(f.Form().Fields()); got != 1 { + t.Errorf("the form has %d fields", got) + } +} + +func TestAFileWhoseCountOfObjectsIsTooSmall(t *testing.T) { + // A file may say anything about how many objects it holds. Believing a + // count smaller than the objects that are there would have this write on + // top of something already in the file. + src := oneTextField(t, false, nil) + f, ok, err := OpenForm(src) + if err != nil || !ok { + t.Fatal(err) + } + // What the trailer says is believed only when the objects agree with it. + // A file claiming to hold two objects while its field is object nine must + // not have this write a tenth on top of something. + f.doc.Trailer()["Size"] = reader.Integer(2) + if got := f.highestObject(); got < 2 { + t.Errorf("the highest object is %d, and the form's own field is above that", got) + } + ref, _ := f.form.Fields()[0].Ref() + if got := f.highestObject(); got < ref.Num { + t.Errorf("the highest object is %d and the field is object %d", got, ref.Num) + } +} + +func TestAFormWrittenIntoTheCatalogueRatherThanBeside(t *testing.T) { + // There is then nowhere of its own to write the form back to, so what it + // asked for cannot be unasked; the fields are still filled in. + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + field := w.Add(reader.Dict{"FT": reader.Name("Tx"), "T": reader.String("name"), + "Subtype": reader.Name("Widget"), + "Rect": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(100), reader.Integer(20)}}) + pageRef := w.Add(reader.Dict{"Type": reader.Name("Page"), "Parent": pagesRef, + "MediaBox": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(200), reader.Integer(200)}, + "Annots": reader.Array{field}, + "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)}) + src, err := w.Finish(reader.Dict{"Root": w.Add(reader.Dict{ + "Type": reader.Name("Catalog"), "Pages": pagesRef, + "AcroForm": reader.Dict{"Fields": reader.Array{field}, + "NeedAppearances": reader.Bool(true), + "DA": reader.String("/Helv 0 Tf 0 g")}})}) + if err != nil { + t.Fatal(err) + } + back := refill(t, src, "name", "Mozart") + fld, _ := back.Field("name") + if fld.Value != "Mozart" { + t.Errorf("holds %q", fld.Value) + } +} + +func TestAFieldWrittenInsideAnotherObjectCannotBeChanged(t *testing.T) { + // There is nowhere to write it back to. Saying so is better than writing + // a file whose value and whose drawing disagree. + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + pageRef := w.Add(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)}) + src, err := w.Finish(reader.Dict{"Root": w.Add(reader.Dict{ + "Type": reader.Name("Catalog"), "Pages": pagesRef, + "AcroForm": w.Add(reader.Dict{"DA": reader.String("/Helv 0 Tf 0 g"), + "Fields": reader.Array{reader.Dict{ + "FT": reader.Name("Tx"), "T": reader.String("inline"), + "Rect": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(100), reader.Integer(20)}}}})})}) + if err != nil { + t.Fatal(err) + } + f, ok, err := OpenForm(src) + if err != nil || !ok { + t.Fatalf("ok %v err %v", ok, err) + } + if err := f.Fill("inline", "Mozart"); err != nil { + t.Fatal(err) + } + if _, err := f.Bytes(); err == nil { + t.Error("a field with nowhere to be written was written anyway") + } +} + +func TestAWidgetWrittenInsideAnotherObject(t *testing.T) { + // The field can be changed; the widget cannot, so nothing is drawn for + // that one and the rest of the file is still written. + src := formFile(t, false, func(w *reader.Writer, page reader.Ref) (reader.Dict, reader.Array) { + field := w.Add(reader.Dict{"T": reader.String("name"), "FT": reader.Name("Tx"), + "Kids": reader.Array{reader.Dict{"Subtype": reader.Name("Widget"), + "Rect": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(100), reader.Integer(20)}}}}) + return reader.Dict{"Fields": reader.Array{field}, + "DA": reader.String("/Helv 0 Tf 0 g")}, nil + }) + back := refill(t, src, "name", "Mozart") + fld, _ := back.Field("name") + if fld.Value != "Mozart" { + t.Errorf("holds %q", fld.Value) + } +} + +func TestAButtonWidgetWrittenInsideAnotherObject(t *testing.T) { + src := formFile(t, false, func(w *reader.Writer, page reader.Ref) (reader.Dict, reader.Array) { + field := w.Add(reader.Dict{"T": reader.String("tick"), "FT": reader.Name("Btn"), + "Kids": reader.Array{reader.Dict{"Subtype": reader.Name("Widget"), + "Rect": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(12), reader.Integer(12)}}}}) + return reader.Dict{"Fields": reader.Array{field}}, nil + }) + back := refill(t, src, "tick", "yes") + fld, _ := back.Field("tick") + if fld.Value != "Yes" { + t.Errorf("holds %q", fld.Value) + } +} + +func TestAFieldOfNoSizeHasNothingDrawnForIt(t *testing.T) { + src := oneTextField(t, false, reader.Dict{ + "Rect": reader.Array{reader.Integer(10), reader.Integer(10), + reader.Integer(10), reader.Integer(40)}}) + back := refill(t, src, "name", "Mozart") + fld, _ := back.Field("name") + if fld.Value != "Mozart" { + t.Errorf("holds %q", fld.Value) + } +} + +func TestAListBoxHoldingSeveralRows(t *testing.T) { + src := formFile(t, false, func(w *reader.Writer, page reader.Ref) (reader.Dict, reader.Array) { + ref := w.Add(reader.Dict{"FT": reader.Name("Ch"), "T": reader.String("where"), + "Ff": reader.Integer(1 << 21), + "Subtype": reader.Name("Widget"), "P": page, + "Rect": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(100), reader.Integer(40)}, + "Opt": reader.Array{reader.String("FR"), reader.String("BE")}}) + return reader.Dict{"Fields": reader.Array{ref}, + "DA": reader.String("/Helv 9 Tf 0 g")}, reader.Array{ref} + }) + f, ok, err := OpenForm(src) + if err != nil || !ok { + t.Fatal(err) + } + fld, _ := f.Form().Field("where") + if err := fld.Choose("FR", "BE"); err != nil { + t.Fatal(err) + } + out, err := f.Bytes() + if err != nil { + t.Fatal(err) + } + d, err := reader.Open(out) + if err != nil { + t.Fatal(err) + } + back, _ := forms.Read(d) + got, _ := back.Field("where") + if len(got.Values) != 2 { + t.Errorf("came back holding %v", got.Values) + } +} + +func TestAFileWhoseLastLineDoesNotEnd(t *testing.T) { + // Its last line would otherwise run into the first object of the update. + src := oneTextField(t, false, nil) + trimmed := bytes.TrimRight(src, "\r\n") + back := refill(t, trimmed, "name", "Mozart") + fld, _ := back.Field("name") + if fld.Value != "Mozart" { + t.Errorf("holds %q", fld.Value) + } +} + +func TestAWidgetNumberedAboveItsField(t *testing.T) { + // A field is not always written before the widgets that belong to it, and + // what the trailer says about how many objects there are is not always + // true. Both have to be looked at, or this writes a new object on top of + // one already in the file. + src := formFile(t, false, func(w *reader.Writer, page reader.Ref) (reader.Dict, reader.Array) { + fieldRef := w.Reserve() + one := w.Add(reader.Dict{"Subtype": reader.Name("Widget"), "P": page, + "Parent": fieldRef, + "Rect": reader.Array{reader.Integer(0), reader.Integer(0), + reader.Integer(100), reader.Integer(20)}}) + w.Put(fieldRef, reader.Dict{"T": reader.String("name"), "FT": reader.Name("Tx"), + "Kids": reader.Array{one}}) + return reader.Dict{"Fields": reader.Array{fieldRef}, + "DA": reader.String("/Helv 0 Tf 0 g")}, reader.Array{one} + }) + f, ok, err := OpenForm(src) + if err != nil || !ok { + t.Fatal(err) + } + f.doc.Trailer()["Size"] = reader.Integer(2) + fld := f.form.Fields()[0] + fieldRef, _ := fld.Ref() + widgetRef, _ := fld.Widgets[0].Ref() + if widgetRef.Num <= fieldRef.Num { + t.Skipf("the widget is object %d and the field %d, which is not the case this is about", + widgetRef.Num, fieldRef.Num) + } + if got := f.highestObject(); got < widgetRef.Num { + t.Errorf("the highest object is %d and the widget is object %d", got, widgetRef.Num) + } +} diff --git a/go.mod b/go.mod index 2719c57..23a4e51 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,12 @@ go 1.26.4 require ( github.com/go-pdfkit/extract v0.1.0 - github.com/go-pdfkit/reader v0.4.0 + github.com/go-pdfkit/reader v0.4.1 ) require ( + github.com/go-opentype/fonts v0.9.0 // indirect github.com/go-opentype/opentype v0.9.0 // indirect - github.com/go-pdfkit/pdffont v0.1.0 // indirect + github.com/go-pdfkit/forms v0.2.1 // indirect + github.com/go-pdfkit/pdffont v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index 0244ea4..544b44a 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,20 @@ github.com/go-opentype/fonts v0.8.0 h1:77i3VPIH90GbstzNb21mk+an4WvEOe2idC6W+J0n0fw= github.com/go-opentype/fonts v0.8.0/go.mod h1:C6yQL2apHItfEZ5hztpsHF0S5mlX/hklLlq/Z5fRG/g= +github.com/go-opentype/fonts v0.9.0 h1:slB6OB3riLyUPrOxqXe0s6/AzdenF1TDvCN8N87hhQk= +github.com/go-opentype/fonts v0.9.0/go.mod h1:C6yQL2apHItfEZ5hztpsHF0S5mlX/hklLlq/Z5fRG/g= github.com/go-opentype/opentype v0.9.0 h1:GFgcJ3nwTDp4NJr5O+Paw7lhZx5Jv/R+noZwvhYDlkM= github.com/go-opentype/opentype v0.9.0/go.mod h1:AOixevJf7XQaH7+WG+OMIOZEbYPXfMqklVk26Y6YTUU= github.com/go-pdfkit/extract v0.1.0 h1:7IJDJMH43l2wJZD+DkW5StmeAS1NDt2FnT2LBceXTog= github.com/go-pdfkit/extract v0.1.0/go.mod h1:3ejdi87IrdR60wmjzLswdqzBzHCgFlrLtxkMxRN1KSU= +github.com/go-pdfkit/forms v0.2.0 h1:qcphzViGdL3vDb66rnyQkwzPw+una5rsF4H84KB2XMI= +github.com/go-pdfkit/forms v0.2.0/go.mod h1:LORxkdP4FVULFk0/PA0CdPZc1dZF9zTSaHrtfM7IYaw= +github.com/go-pdfkit/forms v0.2.1 h1:INa2GwAadxEhcXvBe7zJYgapYwaGn9WIaLxUJU/lm6A= +github.com/go-pdfkit/forms v0.2.1/go.mod h1:LORxkdP4FVULFk0/PA0CdPZc1dZF9zTSaHrtfM7IYaw= github.com/go-pdfkit/pdffont v0.1.0 h1:mThWvPn3LLETup2adm9xMcNZWECP2Wq3eTSyTW4O3I0= github.com/go-pdfkit/pdffont v0.1.0/go.mod h1:y4vo5DgT95e57C3XxIWfA/xss+x6RwZwyj6KWdJc86s= +github.com/go-pdfkit/pdffont v0.2.0 h1:yAp/oR5Z2kkqs4r0GWMalZMC7rc7XSCZXgwIypbpMWM= +github.com/go-pdfkit/pdffont v0.2.0/go.mod h1:y4vo5DgT95e57C3XxIWfA/xss+x6RwZwyj6KWdJc86s= github.com/go-pdfkit/reader v0.4.0 h1:qPbNZSO+Xl+4NBvQoV1PYt7HlqHaEAQ1tUc6/IL8JAU= github.com/go-pdfkit/reader v0.4.0/go.mod h1:fQFOVfCMUui1AdvD4qhimdyvvNr9KvvJ1S7IuKZjyV8= +github.com/go-pdfkit/reader v0.4.1 h1:pRxFqRjsn7H/VsGfWb9nYWyFuDgTU2Pjmoq/f5mgVq4= +github.com/go-pdfkit/reader v0.4.1/go.mod h1:fQFOVfCMUui1AdvD4qhimdyvvNr9KvvJ1S7IuKZjyV8=