Pop PDF
Build

Forms & Fields

A form is a named container that lives on the Document, and fields are added to a Page. Each field names the form it belongs to, and when the document compiles, every field that names a registered form is written as a widget annotation and its object number is indexed into that form. HTML to PDF builds these same objects from <form> markup, so everything on this page describes what that conversion produces as well.

Disclaimer

Form support varies widely by reader — Acrobat implements the full field and flag set, while browser viewers, OS previewers and mobile apps each implement a subset. Pop PDF writes a field's structure and leaves the drawing of it to the reader, so test any form in the readers your audience uses.

Creating a Form#

Document\Form takes one argument, the name, and addForm() puts it on the document:

PHP
use Pop\Pdf\Document;
use Pop\Pdf\Document\Form;
use Pop\Pdf\Document\Page;

$form = new Form('signup');

$document = new Document(new Page(Page::LETTER));
$document->addForm($form);

Forms are keyed on the document by name:

  • getForm('signup') hands back the instance you added; getForm() returns null for a name never registered
  • getForms() returns the whole map, keyed by name
  • hasForms() reports whether the document carries any
  • a document with at least one form compiles a catalog carrying an /AcroForm reference; one that never sees addForm() compiles <</Pages 2 0 R/Type/Catalog>> and writes no form dictionary

getFieldIndices() and getNumberOfFields() read the indices the compiler wrote. Both are empty until the document is compiled, because a field's object number is not known until the objects around it have been laid out:

PHP
use Pop\Pdf\Document;
use Pop\Pdf\Document\Form;
use Pop\Pdf\Document\Page;
use Pop\Pdf\Document\Page\Field\Text;
use Pop\Pdf\Pdf;

$form = new Form('signup');
$form->getNumberOfFields(); // 0

$first = new Text('a');
$first->setWidth(200);
$first->setHeight(24);

$second = new Text('b');
$second->setWidth(200);
$second->setHeight(24);

$page = new Page(Page::LETTER);
$page->addField($first, 'signup', x: 72, y: 700);
$page->addField($second, 'signup', x: 72, y: 650);

$document = new Document($page);
$document->addForm($form);

Pdf::writeToFile($document, filename: __DIR__ . '/signup.pdf');

$form->getNumberOfFields(); // 2
$form->getFieldIndices();   // [5, 6]

Those two integers are PDF object numbers, and they are the numbers the compiled catalog references.

A document may hold as many named forms as needed; their indices combine into the one form dictionary the catalog points at. A contact form and a billing form with one field each produce <</Fields[5 0 R 6 0 R]>>, with getFieldIndices() returning [5] on the first and [6] on the second — the reader sees one flat set.

One form also gathers fields from as many pages as the document has, so a questionnaire running over several sheets is still one form:

PHP
use Pop\Pdf\Document;
use Pop\Pdf\Document\Form;
use Pop\Pdf\Document\Page;
use Pop\Pdf\Document\Page\Field\Text;
use Pop\Pdf\Pdf;

$name = new Text('full_name');
$name->setWidth(240);
$name->setHeight(24);

$comments = new Text('comments');
$comments->setWidth(240);
$comments->setHeight(72);
$comments->setMultiline();

$one = new Page(Page::LETTER);
$one->addField($name, 'signup', x: 72, y: 680);

$two = new Page(Page::LETTER);
$two->addField($comments, 'signup', x: 72, y: 680);

$form     = new Form('signup');
$document = new Document();
$document->addPage($one);
$document->addPage($two);
$document->addForm($form);

Pdf::writeToFile($document, filename: __DIR__ . '/signup.pdf');

$form->getFieldIndices(); // [5, 7]

Compiling that:

  • each page carries its own widget in its own /Annots array — /Annots[5 0 R] on the first, /Annots[7 0 R] on the second — each widget's /P pointing back at its page
  • the catalog collects both into <</Fields[5 0 R 7 0 R]>>
  • the indices aren't consecutive, because the second page object was written between the two widgets

Adding a Field to a Page#

addField($field, $form, $x, $y) takes the field, the name of the form it belongs to, and the coordinates of the widget rectangle's bottom left corner. Both coordinates default to 0, and the method returns the page, so fields chain:

PHP
use Pop\Pdf\Document;
use Pop\Pdf\Document\Form;
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page;
use Pop\Pdf\Document\Page\Field\Button;
use Pop\Pdf\Document\Page\Field\Choice;
use Pop\Pdf\Document\Page\Field\Text;
use Pop\Pdf\Pdf;

$name = new Text('full_name', font: Font::HELVETICA, size: 12);
$name->setWidth(240);
$name->setHeight(24);

$notes = new Text('notes', font: Font::HELVETICA, size: 12);
$notes->setWidth(240);
$notes->setHeight(72);
$notes->setMultiline();

$country = new Choice('country', font: Font::HELVETICA, size: 12);
$country->setWidth(240);
$country->setHeight(24);
$country->addOption('Canada')->addOption('Mexico')->addOption('United States');
$country->setCombo();

$agree = new Button('agree', font: Font::HELVETICA, size: 12);
$agree->setWidth(16);
$agree->setHeight(16);
$agree->addOption('Yes');

$page = new Page(Page::LETTER);
$page->addField($name, 'signup', x: 72, y: 680)
     ->addField($notes, 'signup', x: 72, y: 560)
     ->addField($country, 'signup', x: 72, y: 500)
     ->addField($agree, 'signup', x: 72, y: 460);

$form     = new Form('signup');
$document = new Document($page);
$document->addFont(Font::HELVETICA);
$document->addForm($form);

Pdf::writeToFile($document, filename: __DIR__ . '/signup.pdf');

$form->getNumberOfFields(); // 4

That writes a 2,222-byte single-page letter document at PDF version 1.7, whose catalog carries <</Fields[6 0 R 7 0 R 8 0 R 11 0 R]>> and whose page carries the matching /Annots[6 0 R 7 0 R 8 0 R 11 0 R]. The four widgets compile to:

Object /FT /T /Rect /Ff /Opt
6 /Tx full_name [72 680 312 704] — —
7 /Tx notes [72 560 312 632] 4096 —
8 /Ch country [72 500 312 524] 131072 [ (Canada) (Mexico) (United States) ]
11 /Btn agree [72 460 88 476] — [ (Yes) ]

Objects 9 and 10 are the checkbox's own on and off appearance streams, written just before the widget that references them — which is why the checkbox lands on 11 rather than following 8.

The second argument must match a registered form name exactly. A field naming a form that was never registered is dropped silently at compile time — no widget object, no /Annots entry — and a form that is registered but never named by a field compiles with an empty <</Fields[]>>.

Written out in full, the first field is:

TEXT
/Type /Annot
/Subtype /Widget
/FT /Tx
/Rect [72 680 312 704]
/P 5 0 R
/DA(/MF1 12 Tf 0 g)
/T(full_name)/TU(full_name)/TM(full_name)

That shape holds for every field:

  • the /Rect runs from the addField() coordinates to those coordinates plus the field's width and height — the 240 by 24 field at 72, 680 covers x 72–312, y 680–704; the 16 by 16 checkbox at 72, 460 covers [72 460 88 476]
  • a field with no setWidth()/setHeight() call compiles with a degenerate /Rect [72 680 72 680] — four corners on one point, no error
  • the name reaches the dictionary three times: the partial name /T, the tooltip /TU and the mapping name /TM
  • /P points back at the page object the widget sits on

Options Every Field Has#

Every field type extends Field\AbstractField and takes the same constructor — __construct($name, $font = null, $size = 12) — and the same setters. All of them return the field, so a whole configuration is one chain:

PHP
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page\Field\Text;
use Pop\Color\Color\Rgb;

$field = new Text('full_name', font: Font::HELVETICA, size: 14);
$field->setWidth(240)
      ->setHeight(24)
      ->setValue('Alex Doe')
      ->setDefaultValue('Alex Doe')
      ->setFontColor(new Rgb(0, 51, 102))
      ->setRequired();

setValue() and setDefaultValue() write /V and /DV into the dictionary:

  • on a text field, hand each one the plain string — Field\Text quotes and escapes it for you, so the chain above emits /V (Alex Doe) and /DV (Alex Doe)
  • on a choice field, hand it the option's export value the same way — setValue('Canada') emits /V (Canada)
  • on a checkbox or radio, setValue() carries the export name for the box's checked state rather than the state itself; see Checked State below
  • getValue() and getDefaultValue() read them back, and both start out null

setFont() names a font, setSize() sets the point size and setFontColor() takes any Pop\Color implementation. The three travel together into the field's default appearance string, /DA, and that string is written once the named font is registered on the document with addFont():

PHP
use Pop\Pdf\Document;
use Pop\Pdf\Document\Form;
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page;
use Pop\Pdf\Document\Page\Field\Text;
use Pop\Pdf\Pdf;
use Pop\Color\Color\Rgb;

$field = new Text('full_name', font: Font::HELVETICA);
$field->setWidth(240);
$field->setHeight(24);
$field->setSize(30);
$field->setFontColor(new Rgb(255, 0, 0));

$page = new Page(Page::LETTER);
$page->addField($field, 'signup', x: 72, y: 680);

$document = new Document($page);
$document->addFont(Font::HELVETICA);
$document->addForm(new Form('signup'));

Pdf::writeToFile($document, filename: __DIR__ . '/signup.pdf');

That field compiles with /DA(/MF1 30 Tf 1 0 0 rg) — the document's reference for Helvetica, the size, and the color, each color space rendering in its own operator:

  • in the same field, new Color\Cmyk(100, 0, 0, 0) renders 1 0 0 0 k and new Color\Grayscale(50) renders 0.5 g
  • registering a second font shifts the reference — a field naming Helvetica on a document that registered Arial first compiles with /MF2
  • every font a field names must be registered — compiling a field whose font is absent raises Pop\Pdf\Build\Exception naming it
  • a field with no setFont() call compiles with no /DA entry at all, taking the reader's own default appearance
  • the default color, where a font is set but a color is not, is 0 g, black

getFont(), getSize(), getFontColor(), getWidth() and getHeight() read the current values. Size starts at 12; font, color, width and height start at null.

Three flags are shared by every field type, and each writes one bit into /Ff:

Setter /Ff value Flag bit
setReadOnly() 1 1
setRequired() 2 2
setNoExport() 4 3

Flags accumulate into a single integer, so setReadOnly()->setRequired()->setNoExport() emits /Ff 7. Calling one of them more than once sets the bit once — three setMultiline() calls emit /Ff 4096, the same as one. A field with no flag setter called on it emits no /Ff entry at all.

Borders and Backgrounds#

Every field type takes a border and a background of its own, so the box a reader sees comes from the field rather than from shapes drawn beside it:

PHP
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page\Field\Text;

$field = new Text('full_name', font: Font::HELVETICA, size: 12);
$field->setWidth(240)
      ->setHeight(24)
      ->setBorderWidth(1)
      ->setBorderColor([153, 153, 153])
      ->setBackgroundColor([255, 255, 255]);

That compiles to /MK << /BC [0.6 0.6 0.6] /BG [1 1 1] >> and /BS << /W 1 >> — the appearance characteristics a reader draws the box from, and the border style that gives it a width:

  • both color setters take an [r, g, b] array, 0–255 per channel, and render as PDF's 0–1 range
  • border width is the gate: 0, the default, writes neither /BC nor /BS, so a field given only setBackgroundColor() compiles with /MK << /BG [0.933 0.933 0.933] >> and nothing else
  • getBorderWidth() starts at 0, and getBorderColor() and getBackgroundColor() start at null
  • a checkbox or radio draws its border and background into its own appearance streams, so the box looks the same whether it is currently checked or clear

Text Fields#

Field\Text compiles to /FT /Tx. Give one a width and a height and call nothing else, and it compiles with a /Rect and no /Ff at all — the plain single-line entry box. Each setter below turns that box into a variant by adding one flag bit, and each returns the field.

Multiline#

setMultiline() emits /Ff 4096, flag bit 13, which is the bit a reader checks to wrap typing onto a second line instead of scrolling it sideways. Pair it with a height that has room for the lines:

PHP
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page\Field\Text;

$notes = new Text('notes', font: Font::HELVETICA, size: 12);
$notes->setWidth(240);
$notes->setHeight(72);
$notes->setMultiline();

isMultiline() reports whether the bit is set, and returns false on a field straight from the constructor.

Password#

setPassword() emits /Ff 8192, flag bit 14, the bit that has the reader echo each typed character as a bullet:

PHP
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page\Field\Text;

$secret = new Text('passphrase', font: Font::HELVETICA, size: 12);
$secret->setWidth(240);
$secret->setHeight(24);
$secret->setPassword();

isPassword() reads the bit back.

Comb Fields#

setComb() emits /Ff 16777216, flag bit 25 — the bit that lays the field out as evenly spaced cells, one character per cell, which is the shape a serial number or a postal code wants:

PHP
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page\Field\Text;

$zip = new Text('postal_code', font: Font::HELVETICA, size: 12);
$zip->setWidth(120);
$zip->setHeight(24);
$zip->setComb();

File Select#

setFileSelect() emits /Ff 1048576, flag bit 21, which marks the field's contents as the path of a file to submit rather than text to submit literally:

PHP
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page\Field\Text;

$upload = new Text('attachment', font: Font::HELVETICA, size: 12);
$upload->setWidth(240);
$upload->setHeight(24);
$upload->setFileSelect();

Rich Text#

setRichText() emits /Ff 33554432, flag bit 26, which lets the field carry styled text instead of one uniform run:

PHP
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page\Field\Text;

$bio = new Text('biography', font: Font::HELVETICA, size: 12);
$bio->setWidth(240);
$bio->setHeight(96);
$bio->setMultiline();
$bio->setRichText();

Flags combine freely, and that pair emits /Ff 33558528.

Spell Check and Scrolling#

setDoNotSpellCheck() emits /Ff 4194304, flag bit 23, and setDoNotScroll() emits /Ff 8388608, flag bit 24. The first has the reader accept the entry as typed rather than underlining it, which suits a part number or a reference code; the second caps entry at what the rectangle can display:

PHP
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page\Field\Text;

$sku = new Text('sku', font: Font::HELVETICA, size: 12);
$sku->setWidth(160);
$sku->setHeight(24);
$sku->setDoNotSpellCheck();
$sku->setDoNotScroll();
$sku->setComb();

The three together emit /Ff 29360128.

Choice Fields#

Field\Choice compiles to /FT /Ch and covers both dropdowns and list boxes. addOption() appends to the field's option list, which compiles to /Opt:

PHP
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page\Field\Choice;

$country = new Choice('country', font: Font::HELVETICA, size: 12);
$country->setWidth(240);
$country->setHeight(24);
$country->addOption('Canada');
$country->addOption('Mexico');
$country->addOption('United States');

That code:

  • emits /Opt [ (Canada) (Mexico) (United States) ], in the order the options were added
  • hasOptions() reports whether any have been added, and returns false on a new field
  • a Field\Choice with a width and a height and nothing else compiles to /FT /Ch with a /Rect, no /Opt and no /Ff — add options to give the field its list

Give addOption() a second argument to separate what a reader submits from what it shows:

PHP
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page\Field\Choice;

$plan = new Choice('plan', font: Font::HELVETICA, size: 12);
$plan->setWidth(240);
$plan->setHeight(24);
$plan->addOption('mo', 'Monthly');
$plan->addOption('yr', 'Annual');
$plan->setValue('yr');

That emits /Opt [ [(mo) (Monthly)] [(yr) (Annual)] ] — each pair the export value followed by its display label — and /V (yr), preselecting Annual. An option added with one argument, or with a label matching its value, compiles as the plain string form above.

setCombo() emits /Ff 131072, flag bit 18, which turns the list into a dropdown that shows one row until it is opened. setEdit() emits /Ff 262144, flag bit 19, which lets the reader type a value the list does not offer:

PHP
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page\Field\Choice;

$country = new Choice('country', font: Font::HELVETICA, size: 12);
$country->setWidth(240);
$country->setHeight(24);
$country->addOption('Canada')->addOption('Mexico')->addOption('United States');
$country->setCombo();
$country->setEdit();

The pair emits /Ff 393216. isCombo() reads bit 18 back.

List Boxes#

Leave setCombo() off and the field is a list box showing as many rows as its height allows. setMultiSelect() emits /Ff 2097152, flag bit 22, which lets more than one row be chosen at once, and setSort() emits /Ff 524288, flag bit 20, which has the reader present the options in alphabetical order:

PHP
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page\Field\Choice;

$topics = new Choice('topics', font: Font::HELVETICA, size: 12);
$topics->setWidth(240);
$topics->setHeight(96);
$topics->addOption('Releases')->addOption('Events')->addOption('Announcements');
$topics->setMultiSelect();
$topics->setSort();

/Opt keeps the insertion order you gave it and the sort flag is what asks the reader to reorder the display, so addOption('Zebra')->addOption('Aardvark')->setSort() compiles to /Opt [ (Zebra) (Aardvark) ] with /Ff 524288. isMultiSelect() reads bit 22 back.

Commit on Change#

setCommitOnSelChange() emits /Ff 67108864, flag bit 27, which commits the new selection the moment it is picked rather than when focus moves off the field. setDoNotSpellCheck() is available here too and emits the same /Ff 4194304 it does on a text field:

PHP
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page\Field\Choice;

$plan = new Choice('plan', font: Font::HELVETICA, size: 12);
$plan->setWidth(240);
$plan->setHeight(96);
$plan->addOption('Monthly')->addOption('Annual');
$plan->setMultiSelect();
$plan->setCommitOnSelChange();

Those two emit /Ff 69206016.

Buttons#

Field\Button compiles to /FT /Btn and covers checkboxes, radio groups and push buttons:

  • addOption($option, $xOffset = 0, $yOffset = 0) takes the option label and two offsets that default to 0
  • the label is what reaches /Opt
  • the two offsets are recorded on the option only — neither reaches the compiled document nor comes back from a getter
PHP
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page\Field\Button;

$agree = new Button('agree', font: Font::HELVETICA, size: 12);
$agree->setWidth(16);
$agree->setHeight(16);
$agree->addOption('Yes');

With no flag setter called, that is a checkbox: /FT /Btn, a 16 by 16 rectangle and /Opt [ (Yes) ], with no /Ff. It compiles clear — /V /Off and /AS /Off — with an /AP pair holding a /Yes state ready for the reader. hasOptions() reports whether any options have been added.

Checked State#

setValue() names the state a reader reports for the widget when it is checked — its export value — and setChecked() says whether it starts out checked. The two are independent, and each is read back by its own getter:

PHP
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page\Field\Button;

$agree = new Button('agree', font: Font::HELVETICA, size: 12);
$agree->setWidth(16);
$agree->setHeight(16);
$agree->setValue('Yes');    // the export value, reported once the box is checked
$agree->setChecked();       // and check it

$agree->isChecked();        // true
$agree->getValue();         // 'Yes'

That compiles to /V /Yes and /AS /Yes alongside /AP << /N << /Yes 6 0 R /Off 7 0 R >> >>, the pair of appearance streams the widget draws itself from — a filled square for a checkbox, a filled circle for a radio. Leave setChecked() off and the same field compiles with /V /Off and /AS /Off, its /Yes on-state ready for the reader to switch to.

Give the export value as a plain word such as Yes or email: it is written as a PDF name, and anything outside letters, digits and _ becomes _.

A Field\Choice takes setValue() too, and writes it as a text string rather than a name — setValue('Canada') emits /V (Canada), preselecting that row of the list.

Radio Groups#

Three flags apply to a Field\Button used as a radio option:

  • setRadio() emits /Ff 32768, flag bit 16 — treats the field as one choice among several
  • setNoToggleToOff() emits /Ff 16384, flag bit 15 — keeps a chosen option chosen when clicked a second time
  • setRadiosInUnison() emits /Ff 33554432, flag bit 26 — turns every box sharing an export value on together

Build the group from one Field\Button per option, each marked setRadio(), all sharing a field name and a form, each with its own export value and its own coordinates:

PHP
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page\Field\Button;

$email = new Button('contact_method', font: Font::HELVETICA, size: 12);
$email->setWidth(16);
$email->setHeight(16);
$email->setRadio();
$email->setValue('email');
$email->setChecked();

$phone = new Button('contact_method', font: Font::HELVETICA, size: 12);
$phone->setWidth(16);
$phone->setHeight(16);
$phone->setRadio();
$phone->setValue('phone');

Two or more such widgets compile to one grouped field — a shared, non-visual parent plus one child widget each — so choosing one option clears the rest:

  • the parent carries the group's identity: /FT /Btn /T(contact_method) /Ff 32768, plus /V /email naming the option that starts out chosen
  • each child carries /Parent, its own /Rect and its own /AP on/off pair, and no /T or /Ff of its own — added at 72, 600 and 72, 570, the two above compile to /Rect [72 600 88 616] and /Rect [72 570 88 586]
  • the form counts the group as one field: getNumberOfFields() reports the parent, and getFieldIndices() carries the parent's object number, not the children's
  • give every option its own setValue(), including the ones that start out clear, so each has a distinct export name; call setChecked() on the single option that starts out chosen
  • a lone setRadio() field with no same-named sibling stays a top-level field of its own, /T and /Ff 32768 included
  • isRadio() reads bit 16 back

Push Buttons#

setPushButton() emits /Ff 65536, flag bit 17, which makes the field an action button that holds no value of its own:

PHP
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page\Field\Button;

$submit = new Button('submit', font: Font::HELVETICA, size: 12);
$submit->setWidth(80);
$submit->setHeight(24);
$submit->addOption('Submit');
$submit->setPushButton();

isPushButton() reads bit 17 back. setCaption() gives the button its label, drawn into an appearance stream of its own using the field's registered font:

PHP
use Pop\Pdf\Document\Font;
use Pop\Pdf\Document\Page\Field\Button;

$submit = new Button('submit', font: Font::HELVETICA, size: 12);
$submit->setWidth(80);
$submit->setHeight(24);
$submit->setPushButton();
$submit->setCaption('Send');

That compiles to /MK << /CA (Send) >> and /AP << /N 6 0 R >>, the stream that centers the caption in the widget using the font's own metrics. getCaption() reads it back, and starts out null. Give the button a setFont() and register that font on the document, so the caption has a resource to draw with; the button holds no action of its own.

See Also#

  • Adding Text — drawing the labels that sit beside each field
  • Fonts — registering the fonts a field's /DA string references
  • HTML to PDF — building the same fields from <form> markup instead of by hand
  • Modifying Imported Pages — adding fields to a page that came from an existing PDF