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
62 changes: 61 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -693,7 +693,8 @@ 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.
fills it in and saves it; `pdfops addfields` puts new fields into a document
that has none, or adds to the form it already carries.

```
$ pdfops fields fw9.pdf
Expand All @@ -707,6 +708,65 @@ $ pdfops fill -set 'topmostSubform[0].Page1[0].f1_01[0]=Wolfgang Amadeus Mozart'
fw9.pdf filled.pdf
```

### Creating a form

`pdfops addfields` is the other half of `fill`, which can only change a field
the document already has. The fields are described in a JSON file, because the
thing being described is a layout and a command line is a poor place to put
one.

```
$ cat fields.json
[
{"name": "applicant", "kind": "text", "required": true,
"widgets": [{"page": 1, "rect": [72, 700, 300, 720]}]},
{"name": "agree", "kind": "checkbox", "value": "Yes",
"widgets": [{"page": 1, "rect": [72, 670, 86, 684], "on": "Yes"}]},
{"name": "size", "kind": "combo",
"options": [{"value": "s", "label": "Small"}, {"value": "l", "label": "Large"}],
"widgets": [{"page": 1, "rect": [72, 630, 220, 650]}]}
]

$ pdfops addfields -fields fields.json blank.pdf form.pdf
added 3 field(s)
$ pdfops fields form.pdf
applicant text "" required
agree checkbox "Yes" buttons [Yes]
size combo "" row "s" row "l"
```

The kinds are `text`, `checkbox`, `radio`, `button`, `combo`, `list` and
`signature` — the names `pdfops fields` prints, so a description can be written
from a listing. A field showing in several places is **one** field with several
widgets, which is what a radio group is.

It **refuses before it writes**, because each of these makes a file that opens
and is broken: a field with no name (a field is addressed by its name), a choice
field with no rows, a checkbox whose on-state is `Off` (the name the format
reserves for *not chosen*), two widgets of one radio group in the same state, a
comb field with no `maxLen`, an empty rectangle, a page the document does not
have, and **two fields of one name** — the reader keys fields by name, so the
second would silently shadow the first.

Unknown keys in the description are refused too: `"mutliline"` is a form missing
the thing it was written to ask for, and a parser that ignores what it does not
know says nothing about it.

Two details the format makes easy to get wrong, both held by tests:

- **A checkbox's drawing is not generated by the viewer.** `NeedAppearances`
covers text and choice fields, but a button shows whatever its own appearance
dictionary holds for the state it is in, so one written without them is a box
that never looks ticked however often it is clicked. One is drawn for each
state, with the path operators rather than with a font, so the document needs
no ZapfDingbats to show a tick.
- **A widget is marked Print.** Without it the field is on screen and gone from
the paper, which is where a filled form is most often looked at.

A document assembled from several files can still be given a form: two sources'
forms cannot be merged — their fields may be named the same — but fields the
caller *asked for* come from neither source and so are in no conflict.

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
Expand Down
217 changes: 217 additions & 0 deletions addfields.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
package ops

import (
"fmt"

"github.com/go-pdfkit/forms"
"github.com/go-pdfkit/reader"
)

// AddFields puts new form fields into the document: the other half of Fill,
// which can only change a field that is already there.
//
// ⛔ IT REFUSES BEFORE THE DOCUMENT IS WRITTEN. forms.CheckAll catches the
// descriptions that make a file which OPENS and is broken — a field with no
// name, a choice with no rows, a comb with no MaxLen, two fields of one name —
// and the page numbers are checked here, where the page count is known.
//
// The fields are held until the write, because a widget names the page object
// it sits on and the pages do not have numbers until then.
func (d *Doc) AddFields(fields ...forms.Field) error {
if err := forms.CheckAll(append(append([]forms.Field{}, d.addFields...), fields...)); err != nil {
return err
}
for _, f := range fields {
for i, wdg := range f.Widgets {
if wdg.Page > len(d.pages) {
return fmt.Errorf("widget %d of %q is on page %d and the document has %d",
i+1, f.Name, wdg.Page, len(d.pages))
}
}
}
d.addFields = append(d.addFields, fields...)
return nil
}

// addedFields writes the widgets of every added field onto the pages they name
// and returns the field objects, for the AcroForm to list.
//
// The widget and the field are ONE object when a field shows in a single place,
// which is what the format allows and what nearly every producer writes; a
// field showing on several pages becomes a parent with one Kid per place.
func (d *Doc) addedFields(w *reader.Writer, refs []reader.Ref, dicts []reader.Dict) reader.Array {
if len(d.addFields) == 0 {
return nil
}
var out reader.Array
for _, f := range d.addFields {
field := forms.Dict(f)
switch len(f.Widgets) {
case 0:
// A field with nowhere to show is still a field: it holds a value
// and can be filled, it simply cannot be clicked. Writing it keeps
// the document's own account of its fields complete.
out = append(out, w.Add(field))
case 1:
ref := w.Reserve()
mergeWidget(field, f, f.Widgets[0], refs, w)
w.Put(ref, field)
attach(dicts[f.Widgets[0].Page-1], ref)
out = append(out, ref)
default:
parent := w.Reserve()
kids := make(reader.Array, 0, len(f.Widgets))
for _, wdg := range f.Widgets {
kid := reader.Dict{"Parent": parent}
mergeWidget(kid, f, wdg, refs, w)
ref := w.Add(kid)
attach(dicts[wdg.Page-1], ref)
kids = append(kids, ref)
}
field["Kids"] = kids
w.Put(parent, field)
out = append(out, parent)
}
}
return out
}

// mergeWidget writes the annotation half of a widget into a dictionary, which
// is either the field itself or one of its Kids.
func mergeWidget(into reader.Dict, f forms.Field, wdg forms.Widget, refs []reader.Ref, w *reader.Writer) {
into["Type"] = reader.Name("Annot")
into["Subtype"] = reader.Name("Widget")
into["Rect"] = reader.Array{
reader.Real(wdg.Rect[0]), reader.Real(wdg.Rect[1]),
reader.Real(wdg.Rect[2]), reader.Real(wdg.Rect[3]),
}
if wdg.Page >= 1 && wdg.Page <= len(refs) {
into["P"] = refs[wdg.Page-1]
}
// Print, so the field is on paper as well as on screen. A widget without
// it is a field that vanishes when the form is printed, which is the one
// place a filled form is most often looked at.
flags := int64(1 << 2)
if wdg.Hidden {
flags |= 1 << 1
}
into["F"] = reader.Integer(flags)

// ⛔ A BUTTON'S DRAWING IS NOT GENERATED BY THE VIEWER. NeedAppearances
// covers text and choice fields; a checkbox or radio button shows whatever
// its own appearance dictionary holds for the state it is in, so one
// written without them is a box that never looks ticked however often it
// is clicked.
if f.Kind == forms.Checkbox || f.Kind == forms.Radio {
on := wdg.On
width := wdg.Rect[2] - wdg.Rect[0]
height := wdg.Rect[3] - wdg.Rect[1]
into["AP"] = reader.Dict{"N": reader.Dict{
reader.Name(on): w.Add(markStream(width, height, f.Kind)),
"Off": w.Add(emptyStream(width, height)),
}}
// The state this widget is in. A field whose value names this widget's
// on-state is the one that is chosen.
if f.Value == on && on != "" {
into["AS"] = reader.Name(on)
} else {
into["AS"] = reader.Name("Off")
}
into["MK"] = reader.Dict{"BC": reader.Array{reader.Real(0)}}
}
}

// attach adds one annotation to a page's list, making the list if the page has
// none yet.
//
// It takes the page's dictionary rather than the list and an index: AddFields
// has already refused a widget whose page the document does not have, and
// forms.Check one whose page is before the first, so a bounds test here would
// be a branch nothing can reach — dead code that reads like a safeguard.
func attach(page reader.Dict, ref reader.Ref) {
list, _ := reader.ToArray(page["Annots"])
page["Annots"] = append(list, ref)
}

// markStream is what a ticked box shows: a cross for a checkbox and a filled
// disc for a radio button, drawn with the operators rather than with a font so
// that the document needs no ZapfDingbats to show it.
func markStream(w, h float64, k forms.Kind) *reader.Stream {
var body []byte
if k == forms.Radio {
// A disc, as four Bézier arcs, inset from the edge.
cx, cy := w/2, h/2
r := min(w, h)/2 - 1.5
if r <= 0 {
r = min(w, h) / 2
}
c := r * 0.5523
body = []byte(fmt.Sprintf("0 g\n%.2f %.2f m\n"+
"%.2f %.2f %.2f %.2f %.2f %.2f c\n"+
"%.2f %.2f %.2f %.2f %.2f %.2f c\n"+
"%.2f %.2f %.2f %.2f %.2f %.2f c\n"+
"%.2f %.2f %.2f %.2f %.2f %.2f c\nf\n",
cx+r, cy,
cx+r, cy+c, cx+c, cy+r, cx, cy+r,
cx-c, cy+r, cx-r, cy+c, cx-r, cy,
cx-r, cy-c, cx-c, cy-r, cx, cy-r,
cx+c, cy-r, cx+r, cy-c, cx+r, cy))
} else {
in := 2.0
if w < 6 || h < 6 {
in = 1
}
body = []byte(fmt.Sprintf("0 G\n1.2 w\n%.2f %.2f m %.2f %.2f l S\n%.2f %.2f m %.2f %.2f l S\n",
in, in, w-in, h-in, in, h-in, w-in, in))
}
return appearance(w, h, body)
}

func emptyStream(w, h float64) *reader.Stream { return appearance(w, h, nil) }

// appearance is a form XObject the size of the widget it draws into.
func appearance(w, h float64, body []byte) *reader.Stream {
return &reader.Stream{
Dict: reader.Dict{
"Type": reader.Name("XObject"),
"Subtype": reader.Name("Form"),
"FormType": reader.Integer(1),
"BBox": reader.Array{reader.Real(0), reader.Real(0), reader.Real(w), reader.Real(h)},
"Resources": reader.Dict{},
},
Raw: body,
}
}

// acroFormFor is the form dictionary of a document that is having fields added
// to it: the one it already had with the new fields appended, or a new one.
//
// ⛔ NeedAppearances IS SET, and it is not a nicety. A text or choice field
// written here has no appearance stream — what a field SHOWS is a little
// content stream beside it, not its value — so without this the form opens
// with every box empty however it was filled in. The alternative is to draw
// them, which needs the font the viewer will use, and the viewer is better
// placed to know it than we are.
func acroFormFor(w *reader.Writer, existing reader.Dict, added reader.Array) reader.Dict {
form := existing
if form == nil {
form = reader.Dict{}
}
fields, _ := reader.ToArray(form["Fields"])
form["Fields"] = append(fields, added...)
form["NeedAppearances"] = reader.Bool(true)
if _, ok := form["DA"]; !ok {
form["DA"] = reader.String("/Helv 0 Tf 0 g")
}
if _, ok := form["DR"]; !ok {
form["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"),
}),
}}
}
return form
}
Loading
Loading