Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
25 changes: 17 additions & 8 deletions form.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
92 changes: 92 additions & 0 deletions xfa.go
Original file line number Diff line number Diff line change
@@ -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
}
}
}
221 changes: 221 additions & 0 deletions xfa_test.go
Original file line number Diff line number Diff line change
@@ -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, "<template/>")})
})
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, "<template><subform/></template>"),
str("datasets"), packet(w, "<datasets><data><name>Ada</name></data></datasets>"),
})
})
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) != "<template><subform/></template>" {
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, "<xdp/>")
})
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) != "<xdp/>" {
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("<template/>")})
}, 0},
{"a list that stops on a name", func(w *reader.Writer) reader.Object {
return w.Add(reader.Array{str("template"), packet(w, "<t/>"), 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, "<template/>")}),
})
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()))
}
}