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, "")})
+ })
+ f, ok := Read(d)
+ if !ok {
+ t.Fatal("the form was not read")
+ }
+ if !f.HasXFA() {
+ t.Error("the package was not noticed")
+ }
+ if f.Dynamic() != tc.wantDynamic {
+ t.Errorf("Dynamic() = %v", f.Dynamic())
+ }
+ })
+ }
+}
+
+func TestADocumentThatSaysItNeedsRenderingWithoutXFA(t *testing.T) {
+ // The flag alone is not an XFA form. Reading it as one would tell somebody
+ // their document is unshowable when it is merely odd.
+ d := xfaDoc(t, true, nil)
+ f, ok := Read(d)
+ if !ok {
+ t.Fatal("the form was not read")
+ }
+ if f.HasXFA() || f.Dynamic() || len(f.Packets()) != 0 {
+ t.Errorf("HasXFA=%v Dynamic=%v packets=%d", f.HasXFA(), f.Dynamic(), len(f.Packets()))
+ }
+}
+
+func TestThePartsOfThePackageComeBack(t *testing.T) {
+ // The form cannot be laid out from here, but the values can be read: what
+ // has been filled in lives in the datasets part, as ordinary XML.
+ d := xfaDoc(t, true, func(w *reader.Writer) reader.Object {
+ return w.Add(reader.Array{
+ str("template"), packet(w, ""),
+ str("datasets"), packet(w, "Ada"),
+ })
+ })
+ f, ok := Read(d)
+ if !ok {
+ t.Fatal("the form was not read")
+ }
+ got := f.Packets()
+ if len(got) != 2 {
+ t.Fatalf("%d parts, want 2", len(got))
+ }
+ if got[0].Name != "template" || string(got[0].Data) != "" {
+ t.Errorf("the first part is %+v", got[0])
+ }
+ if got[1].Name != "datasets" || !contains(string(got[1].Data), "Ada") {
+ t.Errorf("the second part is %+v", got[1])
+ }
+}
+
+func TestAPackageThatIsOneStream(t *testing.T) {
+ // A package may be a single stream rather than a list of parts, and then
+ // it has no name.
+ d := xfaDoc(t, false, func(w *reader.Writer) reader.Object {
+ return packet(w, "")
+ })
+ f, ok := Read(d)
+ if !ok {
+ t.Fatal("the form was not read")
+ }
+ got := f.Packets()
+ if len(got) != 1 || got[0].Name != "" || string(got[0].Data) != "" {
+ t.Errorf("got %+v", got)
+ }
+}
+
+func TestAPackageThatIsNotWhatItSays(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ xfa func(w *reader.Writer) reader.Object
+ parts int
+ }{
+ {"a list whose entries are not streams", func(w *reader.Writer) reader.Object {
+ return w.Add(reader.Array{str("template"), str("")})
+ }, 0},
+ {"a list that stops on a name", func(w *reader.Writer) reader.Object {
+ return w.Add(reader.Array{str("template"), packet(w, ""), str("datasets")})
+ }, 1},
+ {"a part still in a filter nothing here unpacks", func(w *reader.Writer) reader.Object {
+ return w.Add(reader.Array{str("template"), w.Add(&reader.Stream{
+ Dict: reader.Dict{"Filter": reader.Name("NoSuchDecode")}, Raw: []byte("x")})})
+ }, 0},
+ {"neither a list nor a stream", func(w *reader.Writer) reader.Object {
+ return reader.Integer(7)
+ }, 0},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ d := xfaDoc(t, false, tc.xfa)
+ f, ok := Read(d)
+ if !ok {
+ t.Fatal("the form was not read")
+ }
+ if len(f.Packets()) != tc.parts {
+ t.Errorf("%d parts, want %d", len(f.Packets()), tc.parts)
+ }
+ })
+ }
+}
+
+// contains is here rather than importing strings for one call.
+func contains(s, sub string) bool {
+ for i := 0; i+len(sub) <= len(s); i++ {
+ if s[i:i+len(sub)] == sub {
+ return true
+ }
+ }
+ return false
+}
+
+func TestAFormWhoseFieldsLiveInTheXML(t *testing.T) {
+ // An AcroForm with no fields is usually a leftover, and Read says there is
+ // no form — 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 is what made Dynamic answer for none of the fourteen
+ // dynamic forms in a corpus of 2 240.
+ 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)})
+ form := w.Add(reader.Dict{
+ "Fields": reader.Array{},
+ "XFA": w.Add(reader.Array{str("template"), packet(w, "")}),
+ })
+ out, err := w.Finish(reader.Dict{"Root": w.Add(reader.Dict{
+ "Type": reader.Name("Catalog"), "Pages": pagesRef,
+ "AcroForm": form, "NeedsRendering": reader.Bool(true)})})
+ if err != nil {
+ t.Fatal(err)
+ }
+ d, err := reader.Open(out)
+ if err != nil {
+ t.Fatal(err)
+ }
+ f, ok := Read(d)
+ if !ok {
+ t.Fatal("a form whose fields are in the XML was called no form at all")
+ }
+ if len(f.Fields()) != 0 {
+ t.Errorf("%d fields came from nowhere", len(f.Fields()))
+ }
+ if !f.Dynamic() || len(f.Packets()) != 1 {
+ t.Errorf("Dynamic=%v packets=%d", f.Dynamic(), len(f.Packets()))
+ }
+}