diff --git a/doc.go b/doc.go index 0c7e802..9551072 100644 --- a/doc.go +++ b/doc.go @@ -17,9 +17,25 @@ // is a fragment of content stream naming a font, a size and a colour, and in // which a size of zero means "as large as fits". // -// What it does not do is XFA, Adobe's XML form language: 66 of the 68 forms in -// the corpus this was measured against carry an XFA copy alongside the standard -// one, and every one of them is fillable through the standard one. XFA is a -// second, proprietary description of the same form, and a file that has both -// is not made more readable by reading the harder one. +// What it does not do is lay out XFA, Adobe's XML form language — and the +// reason has two halves, because the forms do. +// +// Measured over 2 240 real government forms: 1 499 carry a form, 560 of those +// carry an XFA package, and 546 of THOSE are static. A static one is a second, +// proprietary description of a form that is already there: the pages are +// drawn, the widgets exist, and everything here works on it. Such a file is +// not made more readable by reading the harder description. +// +// The other fourteen are dynamic. Their pages hold a panel reading "Please +// wait... your PDF viewer may not be able to display this type of document", +// and the form is laid out from the XML when it is opened — by Adobe's reader +// and by nothing else, the format having been removed from PDF 2.0. Laying one +// out means an XFA layout engine; the reference implementation, pdf.js, spends +// 395 kilobytes of JavaScript on it. +// +// So this says which kind a document is, through [Form.Dynamic], and hands +// back the XML through [Form.Packets] — the values a form has been filled with +// live in its datasets part, as ordinary XML. What it will not do is draw +// nothing and say nothing, which is what every tool including this one did +// before: a document that looks blank and is not. package forms diff --git a/form.go b/form.go index b7826fb..c112469 100644 --- a/form.go +++ b/form.go @@ -22,6 +22,10 @@ type Form struct { resources reader.Dict // quadding is the alignment the whole form asks for, when it asks. quadding int + // dynamic says the pages are a placeholder and the form exists only as + // XML, and packets are that XML. See xfa.go. + dynamic bool + packets []Packet // hasXFA says the document carries Adobe's XML form description as well. // Nothing here reads it; it is worth being able to say so. hasXFA bool @@ -175,16 +179,21 @@ func Read(d *reader.Document) (*Form, bool) { if q, ok := reader.ToInt(resolve(d, dict.Get("Q"))); ok { f.quadding = int(q) } - if x := resolve(d, dict.Get("XFA")); x != nil { - if _, isArray := reader.ToArray(x); isArray { - f.hasXFA = true - } else if _, isStream := reader.ToStream(x); isStream { - f.hasXFA = true - } - } + f.readXFA(d, dict) roots, ok := reader.ToArray(resolve(d, dict.Get("Fields"))) if !ok || len(roots) == 0 { - return nil, false + // An AcroForm with no fields is usually a leftover: 561 of the 118 833 + // files in the figure corpus carry an empty one a producer forgot. + // + // Unless it carries an XFA package, and then it is the opposite — a + // form whose fields live in the XML because that is where the whole + // form lives. Those are exactly the documents a caller most needs told + // about, and refusing them here is what made Dynamic answer for none + // of the fourteen dynamic forms in a corpus of 2 240. + if !f.hasXFA { + return nil, false + } + return f, true } pages := f.pageNumbers() for _, entry := range roots { diff --git a/xfa.go b/xfa.go new file mode 100644 index 0000000..859750f --- /dev/null +++ b/xfa.go @@ -0,0 +1,92 @@ +// Copyright (c) 2026, the go-pdfkit/forms authors +// All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package forms + +import "github.com/go-pdfkit/reader" + +// Packet is one part of a document's XFA package: a name and the XML under it. +// +// A package is split into parts — template, datasets, config, localeSet — and +// the two worth reading are template, which describes the form, and datasets, +// which holds what has been filled in. +type Packet struct { + Name string + Data []byte +} + +// Dynamic says the document's pages are a placeholder and its real form exists +// only as XML. +// +// This is the difference that matters about XFA, and it is not the same +// question as [Form.HasXFA]. +// +// A STATIC XFA form carries a full standard form beside the XML: the pages are +// drawn, the widgets are there, and everything in this package works on it. Of +// 1 499 forms in a corpus of 2 240 real documents, 560 carry XFA and 546 are +// of this kind — the XML is a second description of a form that is already +// readable, and reading the harder one gains nothing. +// +// A DYNAMIC one is different in kind. Its pages hold a panel reading "Please +// wait... your PDF viewer may not be able to display this type of document", +// and the form is laid out from the XML when it is opened. Fourteen of those +// 2 240 are like this. Every viewer but Adobe's own shows the panel: the +// format was removed from PDF 2.0, and neither poppler nor any browser lays it +// out. +// +// So a caller meeting one of these has a document that looks blank and is not. +// Saying so is worth more than drawing nothing quietly, which is what every +// tool including this one did before. +func (f *Form) Dynamic() bool { return f.dynamic } + +// Packets are the parts of the XFA package, in the order the document names +// them, or nil when there is no package. +// +// The form cannot be laid out from here — that wants a layout engine this +// package does not have — but the values can be read: what has been filled in +// lives in the datasets part, as ordinary XML. +func (f *Form) Packets() []Packet { return f.packets } + +// readXFA reads the package and whether the document says its pages are only a +// placeholder for it. +func (f *Form) readXFA(d *reader.Document, dict reader.Dict) { + x := resolve(d, dict.Get("XFA")) + switch v := x.(type) { + case *reader.Stream: + f.hasXFA = true + if data, filter, err := reader.DecodeStream(v, d.Get); err == nil && filter == "" { + f.packets = []Packet{{Name: "", Data: data}} + } + case reader.Array: + f.hasXFA = true + // The array runs name, stream, name, stream. A malformed one is read + // as far as it makes sense rather than refused: a package missing its + // config part still has its template. + for i := 0; i+1 < len(v); i += 2 { + name, _ := reader.ToString(resolve(d, v[i])) + st, ok := reader.ToStream(resolve(d, v[i+1])) + if !ok { + continue + } + data, filter, err := reader.DecodeStream(st, d.Get) + if err != nil || filter != "" { + // Bytes still in a filter nothing here unpacks are not XML, + // and handing them over as XML would be handing over noise. + continue + } + f.packets = append(f.packets, Packet{Name: string(name), Data: data}) + } + default: + return + } + // /NeedsRendering on the catalogue is the document saying its pages are + // not the form. It is the only thing in the file that distinguishes the + // two kinds. + if cat, err := d.Catalog(); err == nil { + if b, ok := reader.ToBool(resolve(d, cat.Get("NeedsRendering"))); ok && bool(b) { + f.dynamic = true + } + } +} diff --git a/xfa_test.go b/xfa_test.go new file mode 100644 index 0000000..882d3d0 --- /dev/null +++ b/xfa_test.go @@ -0,0 +1,221 @@ +// Copyright (c) 2026, the go-pdfkit/forms authors +// All rights reserved. +// +// SPDX-License-Identifier: BSD-3-Clause + +package forms + +import ( + "testing" + + "github.com/go-pdfkit/reader" +) + +// xfaDoc writes a document whose form carries an XFA package, and which may +// say its pages are only a placeholder for it. formDoc cannot: the flag is on +// the catalogue rather than on the form. +func xfaDoc(t *testing.T, needsRendering bool, xfa func(w *reader.Writer) reader.Object) *reader.Document { + t.Helper() + w := reader.NewWriter("1.7") + pagesRef := w.Reserve() + pageRef := w.Add(reader.Dict{"Type": reader.Name("Page"), "Parent": pagesRef, + "MediaBox": nums(0, 0, 200, 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)}) + field := w.Add(reader.Dict{"FT": reader.Name("Tx"), "T": str("a"), "Rect": nums(0, 0, 10, 10)}) + form := reader.Dict{"Fields": reader.Array{field}} + if xfa != nil { + form["XFA"] = xfa(w) + } + catalog := reader.Dict{"Type": reader.Name("Catalog"), "Pages": pagesRef, + "AcroForm": w.Add(form)} + if needsRendering { + catalog["NeedsRendering"] = reader.Bool(true) + } + out, err := w.Finish(reader.Dict{"Root": w.Add(catalog)}) + if err != nil { + t.Fatal(err) + } + d, err := reader.Open(out) + if err != nil { + t.Fatal(err) + } + return d +} + +// packet is a part of an XFA package as a document really carries one. +func packet(w *reader.Writer, body string) reader.Ref { + return w.Add(&reader.Stream{Dict: reader.Dict{}, Raw: []byte(body)}) +} + +func TestTheTwoKindsOfXFA(t *testing.T) { + // This is the difference that matters, and it is not the same question as + // HasXFA. A static form carries a full standard form beside the XML and + // everything here works on it; a dynamic one's pages are a panel saying + // the viewer cannot show the document. + for _, tc := range []struct { + name string + needsRendering bool + wantDynamic bool + }{ + {"static: the XML is a second copy of a readable form", false, false}, + {"dynamic: the pages are a placeholder", true, true}, + } { + t.Run(tc.name, func(t *testing.T) { + d := xfaDoc(t, tc.needsRendering, func(w *reader.Writer) reader.Object { + return w.Add(reader.Array{str("template"), packet(w, "