From 7410fea6091aa917c5ad09f981a547f4caeffcd8 Mon Sep 17 00:00:00 2001 From: Christiaan Baartse Date: Wed, 2 Sep 2026 12:13:28 +0200 Subject: [PATCH 1/7] Add ExistingFormFiller: read AcroForm fields from an existing PDF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The library can already author new fillable forms from scratch, but has no way to open a PDF someone else produced (an uploaded template, a government form) and read its already-defined fields by name — that is the entry point for filling in an existing template's values. Field names are resolved through the /Parent chain into their fully qualified dotted form, since real-world AcroForms commonly nest fields (radio groups, grouped sections) rather than keep them flat. --- src/Pdf/Forms/ExistingFormFiller.php | 83 +++++++++ src/Pdf/Forms/FieldInfo.php | 29 +++ src/Pdf/Forms/FieldNode.php | 34 ++++ src/Pdf/Forms/FieldTree.php | 199 +++++++++++++++++++++ tests/Pdf/Forms/ExistingFormFillerTest.php | 85 +++++++++ 5 files changed, 430 insertions(+) create mode 100644 src/Pdf/Forms/ExistingFormFiller.php create mode 100644 src/Pdf/Forms/FieldInfo.php create mode 100644 src/Pdf/Forms/FieldNode.php create mode 100644 src/Pdf/Forms/FieldTree.php create mode 100644 tests/Pdf/Forms/ExistingFormFillerTest.php diff --git a/src/Pdf/Forms/ExistingFormFiller.php b/src/Pdf/Forms/ExistingFormFiller.php new file mode 100644 index 0000000..bde486d --- /dev/null +++ b/src/Pdf/Forms/ExistingFormFiller.php @@ -0,0 +1,83 @@ +setValue('full_name', 'Jane Roe') + * ->setValue('subscribe', 'Yes') + * ->toBytes(); + * ``` + */ +final class ExistingFormFiller +{ + private readonly PdfSource $source; + + /** @var array|null */ + private ?array $fieldTree = null; + + private function __construct(PdfSource $source) + { + $this->source = $source; + } + + public static function fromFile(string $path, string $password = ''): self + { + return new self(PdfSource::fromFile($path, $password)); + } + + public static function fromBytes(string $bytes, string $password = ''): self + { + return new self(PdfSource::fromBytes($bytes, $password)); + } + + /** + * Enumerate the source document's AcroForm fields by fully-qualified + * name. Always reflects the original source, not pending edits. + * + * @return array + */ + public function fields(): array + { + $out = []; + foreach ($this->tree() as $name => $node) { + $out[$name] = new FieldInfo( + name: $node->name, + type: $node->type, + value: $node->value, + options: $node->options, + required: $node->required, + readOnly: $node->readOnly, + ); + } + + return $out; + } + + /** @return array */ + private function tree(): array + { + return $this->fieldTree ??= (new FieldTree())->build($this->document()); + } + + private function document(): ReaderDocument + { + return $this->source->document(); + } +} diff --git a/src/Pdf/Forms/FieldInfo.php b/src/Pdf/Forms/FieldInfo.php new file mode 100644 index 0000000..2095cb8 --- /dev/null +++ b/src/Pdf/Forms/FieldInfo.php @@ -0,0 +1,29 @@ + $options On-state export names for checkbox/radio, + * read from the widget(s)' own `/AP/N` keys. + */ + public function __construct( + public string $name, + public string $type, + public string $value, + public array $options, + public bool $required, + public bool $readOnly, + ) { + } +} diff --git a/src/Pdf/Forms/FieldNode.php b/src/Pdf/Forms/FieldNode.php new file mode 100644 index 0000000..4a8b06c --- /dev/null +++ b/src/Pdf/Forms/FieldNode.php @@ -0,0 +1,34 @@ + $widgetObjNums Source object numbers of the field's + * own widget annotation(s) — a single + * entry for a plain field, or one per + * option for a radio group. + */ + public function __construct( + public string $name, + public string $type, + public int $fieldObjNum, + public array $widgetObjNums, + public ?int $parentObjNum, + public string $value, + /** @var list */ + public array $options, + public bool $required, + public bool $readOnly, + ) { + } +} diff --git a/src/Pdf/Forms/FieldTree.php b/src/Pdf/Forms/FieldTree.php new file mode 100644 index 0000000..d0eefc3 --- /dev/null +++ b/src/Pdf/Forms/FieldTree.php @@ -0,0 +1,199 @@ + keyed by fully-qualified name */ + public function build(ReaderDocument $doc): array + { + $acroForm = $doc->deref($doc->catalog()->get('AcroForm')); + if (!$acroForm instanceof PdfDictionary) { + return []; + } + + $fields = $doc->deref($acroForm->get('Fields')); + if (!is_array($fields)) { + return []; + } + + $out = []; + foreach ($fields as $ref) { + $this->walk($doc, $ref, null, null, null, null, null, $out, 0); + } + + return $out; + } + + /** + * @param array $out + */ + private function walk( + ReaderDocument $doc, + mixed $ref, + ?string $parentName, + ?int $parentObjNum, + ?string $inheritedFt, + ?string $inheritedDa, + ?int $inheritedFf, + array &$out, + int $depth, + ): void { + if ($depth > self::MAX_DEPTH || !$ref instanceof PdfReference) { + return; + } + $dict = $doc->deref($ref); + if (!$dict instanceof PdfDictionary) { + return; + } + + $ownT = $dict->get('T'); + $partial = $ownT instanceof PdfString ? $this->decodeName($ownT) : null; + $name = $partial !== null + ? ($parentName !== null ? $parentName . '.' . $partial : $partial) + : $parentName; + + $ft = $this->nameValue($dict->get('FT')) ?? $inheritedFt; + $da = $dict->get('DA') instanceof PdfString ? $this->decodeName($dict->get('DA')) : $inheritedDa; + $ff = is_int($dict->get('Ff')) ? $dict->get('Ff') : $inheritedFf; + + $kids = $doc->deref($dict->get('Kids')); + $childFieldRefs = []; + $widgetRefs = []; + if (is_array($kids)) { + foreach ($kids as $kidRef) { + $kidDict = $doc->deref($kidRef); + if ($kidDict instanceof PdfDictionary && $kidDict->has('T')) { + $childFieldRefs[] = $kidRef; + } else { + $widgetRefs[] = $kidRef; + } + } + } + + if ($childFieldRefs !== []) { + foreach ($childFieldRefs as $kidRef) { + $this->walk($doc, $kidRef, $name, $ref->number, $ft, $da, $ff, $out, $depth + 1); + } + return; + } + + if ($name === null || $ft === null) { + return; + } + + $widgetObjNums = []; + if ($widgetRefs !== []) { + foreach ($widgetRefs as $widgetRef) { + if ($widgetRef instanceof PdfReference) { + $widgetObjNums[] = $widgetRef->number; + } + } + } else { + $widgetObjNums = [$ref->number]; + } + + $type = $this->resolveType($ft, $ff ?? 0); + $value = $this->stringValue($doc->deref($dict->get('V'))); + $options = $this->collectOptions($doc, $widgetObjNums); + + $out[$name] = new FieldNode( + name: $name, + type: $type, + fieldObjNum: $ref->number, + widgetObjNums: $widgetObjNums, + parentObjNum: $parentObjNum, + value: $value, + options: $options, + required: (($ff ?? 0) & 2) !== 0, + readOnly: (($ff ?? 0) & 1) !== 0, + ); + } + + private function resolveType(string $ft, int $ff): string + { + return match ($ft) { + 'Tx' => ($ff & 4096) !== 0 ? 'text-multiline' : 'text', + 'Btn' => match (true) { + ($ff & 32768) !== 0 => 'radio', + ($ff & 65536) !== 0 => 'push', + default => 'checkbox', + }, + 'Ch' => ($ff & 131072) !== 0 ? 'combo' : 'list', + default => $ft, + }; + } + + /** + * @param list $widgetObjNums + * @return list + */ + private function collectOptions(ReaderDocument $doc, array $widgetObjNums): array + { + $options = []; + foreach ($widgetObjNums as $objNum) { + $widget = $doc->deref(new PdfReference($objNum, 0)); + if (!$widget instanceof PdfDictionary) { + continue; + } + $ap = $doc->deref($widget->get('AP')); + if (!$ap instanceof PdfDictionary) { + continue; + } + $n = $doc->deref($ap->get('N')); + if (!$n instanceof PdfDictionary) { + continue; + } + foreach (array_keys($n->all()) as $key) { + if ($key !== 'Off' && !in_array($key, $options, true)) { + $options[] = $key; + } + } + } + + return $options; + } + + private function nameValue(mixed $value): ?string + { + return $value instanceof PdfName ? $value->value : null; + } + + private function decodeName(mixed $value): ?string + { + return $value instanceof PdfString ? $value->bytes : null; + } + + private function stringValue(mixed $value): string + { + if ($value instanceof PdfString) { + return $value->bytes; + } + if ($value instanceof PdfName) { + return $value->value; + } + + return ''; + } +} diff --git a/tests/Pdf/Forms/ExistingFormFillerTest.php b/tests/Pdf/Forms/ExistingFormFillerTest.php new file mode 100644 index 0000000..3409f78 --- /dev/null +++ b/tests/Pdf/Forms/ExistingFormFillerTest.php @@ -0,0 +1,85 @@ +useObjectStreams(true); + } + $page = $pdf->addPage(); + $page->addFormField('text', 'full_name', 100, 700, 200, 20, defaultValue: 'Jane Roe'); + + return $pdf->toBytes(); + } + + #[Test] + public function enumerates_a_text_field(): void + { + $filler = ExistingFormFiller::fromBytes($this->textFieldPdf()); + + $fields = $filler->fields(); + + self::assertArrayHasKey('full_name', $fields); + $field = $fields['full_name']; + self::assertSame('full_name', $field->name); + self::assertSame('text', $field->type); + self::assertSame('Jane Roe', $field->value); + self::assertFalse($field->required); + self::assertFalse($field->readOnly); + } + + #[Test] + public function enumerates_fields_from_a_compressed_xref_source(): void + { + $filler = ExistingFormFiller::fromBytes($this->textFieldPdf(objStm: true)); + + $fields = $filler->fields(); + + self::assertArrayHasKey('full_name', $fields); + self::assertSame('Jane Roe', $fields['full_name']->value); + } + + #[Test] + public function reads_a_required_readonly_field(): void + { + $pdf = PdfDocument::new(compressStreams: false); + $page = $pdf->addPage(); + $page->addFormField('text', 'locked', 0, 0, 100, 20, required: true, readOnly: true); + + $filler = ExistingFormFiller::fromBytes($pdf->toBytes()); + $field = $filler->fields()['locked']; + + self::assertTrue($field->required); + self::assertTrue($field->readOnly); + } + + #[Test] + public function opens_from_a_file_path(): void + { + $path = tempnam(sys_get_temp_dir(), 'php-pdf-forms-test-'); + self::assertNotFalse($path); + try { + file_put_contents($path, $this->textFieldPdf()); + $filler = ExistingFormFiller::fromFile($path); + self::assertArrayHasKey('full_name', $filler->fields()); + } finally { + unlink($path); + } + } +} From 0267cebaf94c96a459de9f3c02b118e4af1fce4b Mon Sep 17 00:00:00 2001 From: Christiaan Baartse Date: Wed, 2 Sep 2026 10:47:19 +0200 Subject: [PATCH 2/7] Fill existing text, checkbox and radio fields, incl. /Parent names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setValue()/setValues() now mutate a copy of the field, never the source, so the same call site can safely reuse a filler across multiple documents without any risk of the input template changing underneath it. Checkbox and radio correctness needs more than writing /V: each widget's /AS must match one of the on-state keys actually present in that widget's own /AP/N sub-dictionary, since authoring tools are free to name those states however they like — guessing a fixed convention like "Yes" would silently produce an unrendered field in real viewers. A field can also have more than one widget annotation (the same checkbox or radio repeated across pages), so every widget is checked against the value and given a consistent /AS rather than only the first one. Radio requires an exact match against a widget's export name, since the generic yes/true/1 aliases meant for a single on/off checkbox would otherwise spuriously match whichever radio option happened to be checked last. A field reachable only through /Parent chain inheritance (grouped fields, as most real-world AcroForms use) previously had no way to be addressed at all. Co-Authored-By: Claude Sonnet 5 --- .../generate-parent-inherited-form.php | 63 +++++++ src/Pdf/Forms/ExistingFormFiller.php | 104 ++++++++++++ src/Pdf/Forms/FieldValueSetter.php | 156 ++++++++++++++++++ tests/Pdf/Forms/ExistingFormFillerTest.php | 125 ++++++++++++++ tests/fixtures/forms/parent-inherited.pdf | Bin 0 -> 1026 bytes 5 files changed, 448 insertions(+) create mode 100644 scripts/fixtures/generate-parent-inherited-form.php create mode 100644 src/Pdf/Forms/FieldValueSetter.php create mode 100644 tests/fixtures/forms/parent-inherited.pdf diff --git a/scripts/fixtures/generate-parent-inherited-form.php b/scripts/fixtures/generate-parent-inherited-form.php new file mode 100644 index 0000000..5f8c8e8 --- /dev/null +++ b/scripts/fixtures/generate-parent-inherited-form.php @@ -0,0 +1,63 @@ +reserveObject(); +$pagesId = $writer->reserveObject(); +$catalogId = $writer->reserveObject(); +$acroFormId = $writer->reserveObject(); +$parentId = $writer->reserveObject(); +$childId = $writer->reserveObject(); + +$contentsId = $writer->addObject("<< /Length 0 >>\nstream\n\nendstream"); + +// Non-terminal field node: only /T + /Kids, no /FT/value of its own. +$writer->setObject($parentId, "<< /FT /Tx /T (employer) /Kids [{$childId} 0 R] >>"); + +// Terminal field, itself the sole widget annotation: partial /T "name" + +// /Parent -> fully-qualified name resolves to "employer.name". +$writer->setObject($childId, sprintf( + '<< /Type /Annot /Subtype /Widget /FT /Tx /Rect [100 700 300 720] ' + .'/T (name) /Parent %d 0 R /P %d 0 R /F 4 /V () /DA (/Helv 10 Tf 0 g) >>', + $parentId, + $pageId, +)); + +$writer->setObject($acroFormId, sprintf( + '<< /Fields [%d 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> >>', + $parentId, +)); + +$writer->setObject($pageId, sprintf( + '<< /Type /Page /Parent %d 0 R /MediaBox [0 0 612 792] /Contents %d 0 R ' + .'/Resources << /Font << /Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> ' + .'/Annots [%d 0 R] >>', + $pagesId, + $contentsId, + $childId, +)); + +$writer->setObject($pagesId, sprintf('<< /Type /Pages /Kids [%d 0 R] /Count 1 >>', $pageId)); +$writer->setObject($catalogId, sprintf('<< /Type /Catalog /Pages %d 0 R /AcroForm %d 0 R >>', $pagesId, $acroFormId)); +$writer->setRoot($catalogId); + +$outPath = __DIR__.'/../../tests/fixtures/forms/parent-inherited.pdf'; +file_put_contents($outPath, $writer->toBytes()); + +echo "Wrote {$outPath}\n"; diff --git a/src/Pdf/Forms/ExistingFormFiller.php b/src/Pdf/Forms/ExistingFormFiller.php index bde486d..1a332bc 100644 --- a/src/Pdf/Forms/ExistingFormFiller.php +++ b/src/Pdf/Forms/ExistingFormFiller.php @@ -4,7 +4,11 @@ namespace Dskripchenko\PhpPdf\Pdf\Forms; +use Dskripchenko\PhpPdf\Pdf\Merge\MergeSerializer; +use Dskripchenko\PhpPdf\Pdf\Merge\ObjectImporter; use Dskripchenko\PhpPdf\Pdf\Merge\PdfSource; +use Dskripchenko\PhpPdf\Pdf\Reader\PdfDictionary; +use Dskripchenko\PhpPdf\Pdf\Reader\PdfReference; use Dskripchenko\PhpPdf\Pdf\Reader\ReaderDocument; /** @@ -32,6 +36,15 @@ final class ExistingFormFiller /** @var array|null */ private ?array $fieldTree = null; + private ?ObjectImporter $importer = null; + + private ?int $catalogId = null; + + /** Source object number of `/AcroForm`, or null when the source has none. */ + private ?int $acroFormObjNum = null; + + private bool $mutated = false; + private function __construct(PdfSource $source) { $this->source = $source; @@ -70,6 +83,65 @@ public function fields(): array return $out; } + /** + * Set one field's value by its fully-qualified dotted name (see + * {@see fields()}). For a checkbox, any of `on`/`yes`/`true`/`1` (case + * insensitive) or the exact on-state export name checks it, anything + * else unchecks it. For a radio group, the value must match one of the + * group's option export names (case insensitive) to select it. + */ + public function setValue(string $name, string $value): self + { + $node = $this->tree()[$name] ?? null; + if ($node === null) { + throw new \InvalidArgumentException("Unknown field: {$name}"); + } + + $importer = $this->importer(); + (new FieldValueSetter())->apply($importer, $node, $value); + $this->mutated = true; + + return $this; + } + + /** @param array $values */ + public function setValues(array $values): self + { + foreach ($values as $name => $value) { + $this->setValue($name, $value); + } + + return $this; + } + + public function toBytes(): string + { + $importer = $this->importer(); + + if ($this->mutated && $this->acroFormObjNum !== null) { + $acroFormId = $importer->importObject($this->acroFormObjNum)->number; + $acroForm = $importer->get($acroFormId); + if ($acroForm instanceof PdfDictionary) { + $items = $acroForm->all(); + $items['NeedAppearances'] = true; + $importer->set($acroFormId, new PdfDictionary($items)); + } + } + + return (new MergeSerializer())->serialize($importer->objects(), $this->catalogId()); + } + + public function toFile(string $path): int + { + $bytes = $this->toBytes(); + $written = @file_put_contents($path, $bytes); + if ($written === false) { + throw new \RuntimeException("Cannot write PDF file: {$path}"); + } + + return $written; + } + /** @return array */ private function tree(): array { @@ -80,4 +152,36 @@ private function document(): ReaderDocument { return $this->source->document(); } + + private function importer(): ObjectImporter + { + if ($this->importer !== null) { + return $this->importer; + } + + $doc = $this->document(); + $rootRef = $doc->trailer()->get('Root'); + if (!$rootRef instanceof PdfReference) { + throw new \RuntimeException('Document catalog (/Root) is not an indirect reference'); + } + + $importer = new ObjectImporter(); + $importer->useSource($doc); + $this->catalogId = $importer->importObject($rootRef->number)->number; + + $acroFormRaw = $doc->catalog()->get('AcroForm'); + $this->acroFormObjNum = $acroFormRaw instanceof PdfReference ? $acroFormRaw->number : null; + + return $this->importer = $importer; + } + + private function catalogId(): int + { + $this->importer(); // ensure catalogId is populated + if ($this->catalogId === null) { + throw new \LogicException('Catalog was not imported'); + } + + return $this->catalogId; + } } diff --git a/src/Pdf/Forms/FieldValueSetter.php b/src/Pdf/Forms/FieldValueSetter.php new file mode 100644 index 0000000..d19dd4d --- /dev/null +++ b/src/Pdf/Forms/FieldValueSetter.php @@ -0,0 +1,156 @@ +importObject()` deduplicates, so + * re-requesting a field or widget already copied returns the same new id. + */ +final class FieldValueSetter +{ + /** + * Values considered "checked" for a checkbox when no exact export-name + * match is found among the widget's own `/AP/N` keys. + */ + private const TRUTHY = ['on', 'yes', 'true', '1']; + + public function apply(ObjectImporter $importer, FieldNode $node, string $value): void + { + match ($node->type) { + 'checkbox' => $this->applyCheckbox($importer, $node, $value), + 'radio' => $this->applyRadio($importer, $node, $value), + default => $this->applyText($importer, $node, $value), + }; + } + + private function applyText(ObjectImporter $importer, FieldNode $node, string $value): void + { + $fieldId = $importer->importObject($node->fieldObjNum)->number; + $this->setItems($importer, $fieldId, ['V' => new PdfString($value)]); + // Drop any stale appearance so /NeedAppearances forces the reader to + // regenerate it from the new /V rather than showing the old value. + $this->removeAp($importer, $fieldId); + + foreach ($node->widgetObjNums as $widgetObjNum) { + if ($widgetObjNum === $node->fieldObjNum) { + continue; + } + $this->removeAp($importer, $importer->importObject($widgetObjNum)->number); + } + } + + private function applyCheckbox(ObjectImporter $importer, FieldNode $node, string $value): void + { + $state = 'Off'; + foreach ($node->widgetObjNums as $widgetObjNum) { + $widgetId = $importer->importObject($widgetObjNum)->number; + $onKey = $this->onStateKey($importer, $widgetId); + if ($onKey !== null && $this->matches($value, $onKey)) { + $state = $onKey; + } + } + + // A second pass, since every widget must agree on the same state + // (e.g. the same checkbox repeated on several pages) even though + // only one of them may own the matching /AP/N export name. + foreach ($node->widgetObjNums as $widgetObjNum) { + $widgetId = $importer->importObject($widgetObjNum)->number; + $onKey = $this->onStateKey($importer, $widgetId); + $this->setItems($importer, $widgetId, ['AS' => new PdfName($onKey === $state ? $state : 'Off')]); + } + + $fieldId = $importer->importObject($node->fieldObjNum)->number; + $this->setItems($importer, $fieldId, ['V' => new PdfName($state)]); + } + + private function applyRadio(ObjectImporter $importer, FieldNode $node, string $value): void + { + $selected = null; + foreach ($node->widgetObjNums as $widgetObjNum) { + $widgetId = $importer->importObject($widgetObjNum)->number; + $onKey = $this->onStateKey($importer, $widgetId); + // Exact match only: unlike a checkbox, a radio group has more + // than one "on" state, so the generic yes/true/1 TRUTHY aliases + // (meant for a single on/off toggle) must not spuriously match + // whichever option happens to be checked last in widget order. + if ($onKey !== null && strcasecmp($value, $onKey) === 0) { + $selected = $onKey; + } + } + + foreach ($node->widgetObjNums as $widgetObjNum) { + $widgetId = $importer->importObject($widgetObjNum)->number; + $onKey = $this->onStateKey($importer, $widgetId); + $state = ($selected !== null && $onKey === $selected) ? $selected : 'Off'; + $this->setItems($importer, $widgetId, ['AS' => new PdfName($state)]); + } + + $fieldId = $importer->importObject($node->fieldObjNum)->number; + $this->setItems($importer, $fieldId, ['V' => new PdfName($selected ?? 'Off')]); + } + + private function matches(string $value, string $onKey): bool + { + if (strcasecmp($value, $onKey) === 0) { + return true; + } + + return in_array(strtolower($value), self::TRUTHY, true); + } + + /** + * The single non-`Off` key of a button widget's own `/AP/N` sub-dictionary + * — the export name a reader shows when that widget is selected. Not read + * from a fixed convention, since it's author-defined per widget. + */ + private function onStateKey(ObjectImporter $importer, int $widgetId): ?string + { + $widget = $importer->get($widgetId); + if (!$widget instanceof PdfDictionary) { + return null; + } + $ap = $widget->get('AP'); + if (!$ap instanceof PdfDictionary) { + return null; + } + $n = $ap->get('N'); + if (!$n instanceof PdfDictionary) { + return null; + } + foreach (array_keys($n->all()) as $key) { + if ($key !== 'Off') { + return $key; + } + } + + return null; + } + + private function removeAp(ObjectImporter $importer, int $objId): void + { + $dict = $importer->get($objId); + if (!$dict instanceof PdfDictionary || !$dict->has('AP')) { + return; + } + $items = $dict->all(); + unset($items['AP']); + $importer->set($objId, new PdfDictionary($items)); + } + + /** @param array $changes */ + private function setItems(ObjectImporter $importer, int $objId, array $changes): void + { + $dict = $importer->get($objId); + $items = $dict instanceof PdfDictionary ? $dict->all() : []; + $importer->set($objId, new PdfDictionary(array_merge($items, $changes))); + } +} diff --git a/tests/Pdf/Forms/ExistingFormFillerTest.php b/tests/Pdf/Forms/ExistingFormFillerTest.php index 3409f78..f683bfe 100644 --- a/tests/Pdf/Forms/ExistingFormFillerTest.php +++ b/tests/Pdf/Forms/ExistingFormFillerTest.php @@ -6,6 +6,10 @@ use Dskripchenko\PhpPdf\Pdf\Document as PdfDocument; use Dskripchenko\PhpPdf\Pdf\Forms\ExistingFormFiller; +use Dskripchenko\PhpPdf\Pdf\Forms\FieldTree; +use Dskripchenko\PhpPdf\Pdf\Reader\PdfName; +use Dskripchenko\PhpPdf\Pdf\Reader\PdfReference; +use Dskripchenko\PhpPdf\Pdf\Reader\ReaderDocument; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -69,6 +73,127 @@ public function reads_a_required_readonly_field(): void self::assertTrue($field->readOnly); } + /** @return list AS state of every widget of $name, in fixture order */ + private function widgetStates(ReaderDocument $doc, string $name): array + { + $node = (new FieldTree())->build($doc)[$name]; + $states = []; + foreach ($node->widgetObjNums as $objNum) { + $widget = $doc->deref(new PdfReference($objNum, 0)); + $as = $widget->get('AS'); + $states[] = $as instanceof PdfName ? $as->value : ''; + } + + return $states; + } + + #[Test] + public function sets_a_text_field_value(): void + { + $filler = ExistingFormFiller::fromBytes($this->textFieldPdf()); + $out = $filler->setValue('full_name', 'John Doe')->toBytes(); + + $result = ExistingFormFiller::fromBytes($out); + self::assertSame('John Doe', $result->fields()['full_name']->value); + } + + #[Test] + public function fill_sets_need_appearances_on_the_acroform(): void + { + $filler = ExistingFormFiller::fromBytes($this->textFieldPdf()); + $out = $filler->setValue('full_name', 'John Doe')->toBytes(); + + self::assertStringContainsString('/NeedAppearances true', $out); + } + + #[Test] + public function sets_a_field_reachable_only_through_parent_inheritance(): void + { + $path = __DIR__.'/../../fixtures/forms/parent-inherited.pdf'; + if (!is_file($path)) { + self::markTestSkipped('Fixture parent-inherited.pdf not present'); + } + + $filler = ExistingFormFiller::fromFile($path); + $out = $filler->setValue('employer.name', 'Acme Corp')->toBytes(); + + $result = ExistingFormFiller::fromBytes($out); + self::assertSame('Acme Corp', $result->fields()['employer.name']->value); + } + + #[Test] + public function setting_an_unknown_field_throws(): void + { + $filler = ExistingFormFiller::fromBytes($this->textFieldPdf()); + $this->expectException(\InvalidArgumentException::class); + $filler->setValue('does_not_exist', 'x'); + } + + #[Test] + public function checks_a_checkbox(): void + { + $pdf = PdfDocument::new(compressStreams: false); + $page = $pdf->addPage(); + $page->addFormField('checkbox', 'subscribe', 100, 700, 14, 14); + $bytes = $pdf->toBytes(); + + $out = ExistingFormFiller::fromBytes($bytes)->setValue('subscribe', 'yes')->toBytes(); + + $result = ExistingFormFiller::fromBytes($out); + self::assertSame('Yes', $result->fields()['subscribe']->value); + self::assertSame(['Yes'], $this->widgetStates(ReaderDocument::fromBytes($out), 'subscribe')); + } + + #[Test] + public function unchecks_a_checkbox(): void + { + $pdf = PdfDocument::new(compressStreams: false); + $page = $pdf->addPage(); + $page->addFormField('checkbox', 'subscribe', 100, 700, 14, 14, defaultValue: 'on'); + $bytes = $pdf->toBytes(); + + $out = ExistingFormFiller::fromBytes($bytes)->setValue('subscribe', 'no')->toBytes(); + + $result = ExistingFormFiller::fromBytes($out); + self::assertSame('Off', $result->fields()['subscribe']->value); + self::assertSame(['Off'], $this->widgetStates(ReaderDocument::fromBytes($out), 'subscribe')); + } + + #[Test] + public function selects_one_radio_option_and_unselects_the_rest(): void + { + $pdf = PdfDocument::new(compressStreams: false); + $page = $pdf->addPage(); + $page->addFormField('radio-group', 'size', 100, 700, 60, 14, + options: ['S', 'M', 'L'], + radioWidgets: [ + ['x' => 100, 'y' => 700, 'w' => 14, 'h' => 14], + ['x' => 120, 'y' => 700, 'w' => 14, 'h' => 14], + ['x' => 140, 'y' => 700, 'w' => 14, 'h' => 14], + ], + ); + $bytes = $pdf->toBytes(); + + $out = ExistingFormFiller::fromBytes($bytes)->setValue('size', 'M')->toBytes(); + + $result = ExistingFormFiller::fromBytes($out); + self::assertSame('M', $result->fields()['size']->value); + self::assertSame(['Off', 'M', 'Off'], $this->widgetStates(ReaderDocument::fromBytes($out), 'size')); + } + + #[Test] + public function does_not_mutate_the_original_source_document(): void + { + $bytes = $this->textFieldPdf(); + $filler = ExistingFormFiller::fromBytes($bytes); + $filler->setValue('full_name', 'Changed')->toBytes(); + + // A fresh filler over the same original bytes must still see the + // original value — the mutation must not have touched the source. + $original = ExistingFormFiller::fromBytes($bytes); + self::assertSame('Jane Roe', $original->fields()['full_name']->value); + } + #[Test] public function opens_from_a_file_path(): void { diff --git a/tests/fixtures/forms/parent-inherited.pdf b/tests/fixtures/forms/parent-inherited.pdf new file mode 100644 index 0000000000000000000000000000000000000000..9a06cc9ca9a7698a34ec9fa203be29cb926a01b7 GIT binary patch literal 1026 zcmb_bOK#gR5Z&t(voYW-QtD%gGGG{R;tCDgqK0B53nL3k8mlT~F`($e=_R}94cd!z zNXZ{W1-c2L5BX-uH}hsVS}l`n?;UqWzyJLF<#^zN%)U9Z8L;)sGXh(Q(tZ^-8hkRR zz&>M9i5q!R9(4v-QCqaA6eUU4fNVS8BIqKp7vpzB>71o9>?CF*Z7&Du;=fZkRXn6%}nZC6y zXB`alDVjAE^FS|*ara!y7gWUF2{I7S0LP78eXQ1SF3@9Dl&Gnm{jn+Lnv`{2;PeLO z@4KWi(KTX+M70vOd#oC?BHe8pC8QyHfU%L{Ujm)8G5vH5>Jv7l{z?FuTdh#+98>&$ z+PA?$;nr~>cHGl|C=7!Tws68c3djs-PMGh;Lo6El1p#&btGyFW&G!lm{pkRU_yvbn zqN-8Z3wK}rK!|~*Qfe^$NLTG{37eT0Pxv&8attyQS-`^}k0((W@vzwX#nhj}|6S}Q SX|+(=YQufH@}u`(66Y_B?+|JL literal 0 HcmV?d00001 From 6bd690723334d8ca6ca36f155a76eb06aecd8dd2 Mon Sep 17 00:00:00 2001 From: Christiaan Baartse Date: Wed, 2 Sep 2026 12:13:41 +0200 Subject: [PATCH 3/7] Add flatten() to bake AcroForm values into static page content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downstream, a filled form sometimes needs to stop being interactive (archival copies, signed submissions where the recipient's viewer shouldn't let anyone edit the answers afterward). Interactive fill alone (/V + /NeedAppearances) isn't enough for that — the field removal and baked appearance need to happen for real. Flattening reads each field's *current* value (including one set earlier in the same call chain, not just the source), draws it with the field's own /DA font pulled from the AcroForm's /DR so no font gets redeclared, then removes the widget annotation and the field entry itself. Supports flattening a named subset so unrelated fields stay interactive, and drops /AcroForm entirely once nothing is left in it, including once a grouped parent's last child is flattened. A page's /Resources and /Annots, and the AcroForm's /DR, are read through a shared helper that dereferences an indirect object first, since ISO 32000-1 allows any of these to be stored as their own indirect object rather than inlined — which Acrobat-produced forms commonly do — and treating that as absent would otherwise discard the page's existing fonts or leave a flattened widget's annotation behind. The baked `Tf` operator is only emitted once a font actually resolves against /DR, and baked text escapes bytes outside printable ASCII, so a non-ASCII field value survives as a valid PDF literal string. Co-Authored-By: Claude Sonnet 5 --- src/Pdf/Forms/ExistingFormFiller.php | 160 +++++++++++ src/Pdf/Forms/FieldNode.php | 1 + src/Pdf/Forms/FieldTree.php | 1 + src/Pdf/Forms/FormFlattener.php | 299 +++++++++++++++++++++ tests/Pdf/Forms/ExistingFormFillerTest.php | 72 +++++ 5 files changed, 533 insertions(+) create mode 100644 src/Pdf/Forms/FormFlattener.php diff --git a/src/Pdf/Forms/ExistingFormFiller.php b/src/Pdf/Forms/ExistingFormFiller.php index 1a332bc..2cf1250 100644 --- a/src/Pdf/Forms/ExistingFormFiller.php +++ b/src/Pdf/Forms/ExistingFormFiller.php @@ -9,6 +9,7 @@ use Dskripchenko\PhpPdf\Pdf\Merge\PdfSource; use Dskripchenko\PhpPdf\Pdf\Reader\PdfDictionary; use Dskripchenko\PhpPdf\Pdf\Reader\PdfReference; +use Dskripchenko\PhpPdf\Pdf\Reader\PdfString; use Dskripchenko\PhpPdf\Pdf\Reader\ReaderDocument; /** @@ -114,6 +115,40 @@ public function setValues(array $values): self return $this; } + /** + * Bake the current value of the given fields into their page content and + * remove their interactive widgets. `null` (default) flattens every + * field. When flattening leaves no fields behind, `/AcroForm` is dropped + * from the output catalog entirely. + * + * @param list|null $fieldNames + */ + public function flatten(?array $fieldNames = null): self + { + $tree = $this->tree(); + $names = $fieldNames ?? array_keys($tree); + $importer = $this->importer(); + $doc = $this->document(); + $flattener = new FormFlattener(); + // Both depend only on the fixed /AcroForm object, not on the field + // being flattened — compute them once rather than per field. + $dr = $this->importedDr($importer); + $acroFormDa = $this->acroFormLevelDa($doc); + + foreach ($names as $name) { + $node = $tree[$name] ?? null; + if ($node === null) { + throw new \InvalidArgumentException("Unknown field: {$name}"); + } + $flattener->flatten($importer, $doc, $node, $dr, $acroFormDa); + $this->removeFromAcroForm($importer, $node); + } + + $this->dropAcroFormIfEmpty($importer); + + return $this; + } + public function toBytes(): string { $importer = $this->importer(); @@ -175,6 +210,131 @@ private function importer(): ObjectImporter return $this->importer = $importer; } + private function importedDr(ObjectImporter $importer): ?PdfDictionary + { + if ($this->acroFormObjNum === null) { + return null; + } + $acroForm = $importer->get($importer->importObject($this->acroFormObjNum)->number); + if (!$acroForm instanceof PdfDictionary) { + return null; + } + $dr = $acroForm->get('DR'); + if ($dr instanceof PdfReference) { + $dr = $importer->get($dr->number); + } + + return $dr instanceof PdfDictionary ? $dr : null; + } + + private function acroFormLevelDa(ReaderDocument $doc): ?string + { + if ($this->acroFormObjNum === null) { + return null; + } + $acroForm = $doc->deref(new PdfReference($this->acroFormObjNum, 0)); + if (!$acroForm instanceof PdfDictionary) { + return null; + } + $da = $acroForm->get('DA'); + + return $da instanceof PdfString ? $da->bytes : null; + } + + private function removeFromAcroForm(ObjectImporter $importer, FieldNode $node): void + { + $fieldNewId = $importer->importObject($node->fieldObjNum)->number; + + if ($node->parentObjNum !== null) { + $parentId = $importer->importObject($node->parentObjNum)->number; + $this->removeRefFromArrayKey($importer, $parentId, 'Kids', $fieldNewId); + + // A non-terminal parent left with no /Kids is a dangling field + // node — drop it from /AcroForm /Fields too, or dropAcroFormIfEmpty() + // would never see the AcroForm as empty once every terminal field + // reachable only through it has been flattened. + $parentDict = $importer->get($parentId); + $kids = $parentDict instanceof PdfDictionary ? $parentDict->get('Kids') : null; + if ($kids instanceof PdfReference) { + $kids = $importer->get($kids->number); + } + if (is_array($kids) && $kids === [] && $this->acroFormObjNum !== null) { + $acroFormId = $importer->importObject($this->acroFormObjNum)->number; + $this->removeRefFromArrayKey($importer, $acroFormId, 'Fields', $parentId); + } + + return; + } + + if ($this->acroFormObjNum === null) { + return; + } + $acroFormId = $importer->importObject($this->acroFormObjNum)->number; + $this->removeRefFromArrayKey($importer, $acroFormId, 'Fields', $fieldNewId); + } + + private function removeRefFromArrayKey(ObjectImporter $importer, int $objId, string $key, int $targetNewId): void + { + $dict = $importer->get($objId); + if (!$dict instanceof PdfDictionary) { + return; + } + $arr = $dict->get($key); + + // The array itself may be an indirect object (ISO 32000-1 allows any + // value to be indirect) rather than inlined into the dictionary. + $arrObjId = null; + if ($arr instanceof PdfReference) { + $arrObjId = $arr->number; + $arr = $importer->get($arrObjId); + } + if (!is_array($arr)) { + return; + } + + $filtered = array_values(array_filter( + $arr, + static fn ($ref) => !($ref instanceof PdfReference && $ref->number === $targetNewId), + )); + + if ($arrObjId !== null) { + $importer->set($arrObjId, $filtered); + + return; + } + + $items = $dict->all(); + $items[$key] = $filtered; + $importer->set($objId, new PdfDictionary($items)); + } + + private function dropAcroFormIfEmpty(ObjectImporter $importer): void + { + if ($this->acroFormObjNum === null) { + return; + } + $acroFormId = $importer->importObject($this->acroFormObjNum)->number; + $acroForm = $importer->get($acroFormId); + if (!$acroForm instanceof PdfDictionary) { + return; + } + $fields = $acroForm->get('Fields'); + if ($fields instanceof PdfReference) { + $fields = $importer->get($fields->number); + } + if (!is_array($fields) || $fields !== []) { + return; + } + + $catalog = $importer->get($this->catalogId()); + if ($catalog instanceof PdfDictionary) { + $items = $catalog->all(); + unset($items['AcroForm']); + $importer->set($this->catalogId(), new PdfDictionary($items)); + } + $this->acroFormObjNum = null; + } + private function catalogId(): int { $this->importer(); // ensure catalogId is populated diff --git a/src/Pdf/Forms/FieldNode.php b/src/Pdf/Forms/FieldNode.php index 4a8b06c..f9f45c5 100644 --- a/src/Pdf/Forms/FieldNode.php +++ b/src/Pdf/Forms/FieldNode.php @@ -24,6 +24,7 @@ public function __construct( public int $fieldObjNum, public array $widgetObjNums, public ?int $parentObjNum, + public ?string $da, public string $value, /** @var list */ public array $options, diff --git a/src/Pdf/Forms/FieldTree.php b/src/Pdf/Forms/FieldTree.php index d0eefc3..3f1fad5 100644 --- a/src/Pdf/Forms/FieldTree.php +++ b/src/Pdf/Forms/FieldTree.php @@ -124,6 +124,7 @@ private function walk( fieldObjNum: $ref->number, widgetObjNums: $widgetObjNums, parentObjNum: $parentObjNum, + da: $da, value: $value, options: $options, required: (($ff ?? 0) & 2) !== 0, diff --git a/src/Pdf/Forms/FormFlattener.php b/src/Pdf/Forms/FormFlattener.php new file mode 100644 index 0000000..cc32a3d --- /dev/null +++ b/src/Pdf/Forms/FormFlattener.php @@ -0,0 +1,299 @@ +get($importer->importObject($node->fieldObjNum)->number); + $currentValue = $fieldDict instanceof PdfDictionary ? $this->stringValue($fieldDict->get('V')) : $node->value; + + foreach ($node->widgetObjNums as $widgetObjNum) { + $this->flattenWidget($importer, $doc, $node, $currentValue, $widgetObjNum, $importedDr, $acroFormDa); + } + } + + private function flattenWidget( + ObjectImporter $importer, + ReaderDocument $doc, + FieldNode $node, + string $currentValue, + int $widgetObjNum, + ?PdfDictionary $importedDr, + ?string $acroFormDa, + ): void { + $sourcePageObjNum = $this->findSourcePage($doc, $widgetObjNum); + if ($sourcePageObjNum === null) { + return; + } + $pageId = $importer->importObject($sourcePageObjNum)->number; + $widgetId = $importer->importObject($widgetObjNum)->number; + $widget = $importer->get($widgetId); + if (!$widget instanceof PdfDictionary) { + return; + } + + if (in_array($node->type, ['text', 'text-multiline'], true) && $currentValue !== '') { + $rect = $this->rect($widget); + if ($rect !== null) { + // buildContentStream() always returns a stream (it draws + // whatever font it could resolve, or none at all), so there + // is no null case to guard against here. + [$content, $fontKey, $fontRef] = $this->buildContentStream($node, $currentValue, $rect, $node->da ?? $acroFormDa, $importedDr); + $this->appendContent($importer, $pageId, $content, $fontKey, $fontRef); + } + } + + $this->removeAnnotation($importer, $pageId, $widgetId); + } + + private function stringValue(mixed $value): string + { + if ($value instanceof PdfString) { + return $value->bytes; + } + if ($value instanceof PdfName) { + return $value->value; + } + + return ''; + } + + private function findSourcePage(ReaderDocument $doc, int $widgetObjNum): ?int + { + $widget = $doc->deref(new PdfReference($widgetObjNum, 0)); + if ($widget instanceof PdfDictionary) { + $p = $widget->get('P'); + if ($p instanceof PdfReference) { + return $p->number; + } + } + foreach ($doc->pages() as $page) { + $annots = $doc->deref($page->dict->get('Annots')); + if (!is_array($annots)) { + continue; + } + foreach ($annots as $ref) { + if ($ref instanceof PdfReference && $ref->number === $widgetObjNum) { + return $page->objectNumber; + } + } + } + + return null; + } + + /** @return array{float,float,float,float}|null */ + private function rect(PdfDictionary $widget): ?array + { + $rect = $widget->get('Rect'); + if (!is_array($rect) || count($rect) !== 4) { + return null; + } + $values = array_map(static fn ($v) => is_numeric($v) ? (float) $v : null, $rect); + if (in_array(null, $values, true)) { + return null; + } + + return [$values[0], $values[1], $values[2], $values[3]]; + } + + /** + * @param array{float,float,float,float} $rect + * @return array{string, ?string, ?PdfReference} + */ + private function buildContentStream(FieldNode $node, string $value, array $rect, ?string $da, ?PdfDictionary $importedDr): array + { + [$fontKey, $fontSize] = $this->parseDa($da); + $fontRef = null; + if ($fontKey !== null && $importedDr instanceof PdfDictionary) { + $fonts = $importedDr->get('Font'); + if ($fonts instanceof PdfDictionary) { + $ref = $fonts->get($fontKey); + if ($ref instanceof PdfReference) { + $fontRef = $ref; + } + } + } + + [$llx, $lly, , $ury] = $rect; + $lines = $node->type === 'text-multiline' ? explode("\n", $value) : [$value]; + $leading = $fontSize * 1.15; + + $ops = ['q', 'BT']; + if ($fontKey !== null && $fontRef !== null) { + $ops[] = sprintf('/%s %s Tf', $fontKey, $this->fmt($fontSize)); + } + $ops[] = '0 g'; + $x = $llx + 2.0; + $y = $node->type === 'text-multiline' + ? $ury - $fontSize - 2.0 + : $lly + max(0.0, ($ury - $lly - $fontSize) / 2) + 1.0; + $ops[] = sprintf('%s %s Td', $this->fmt($x), $this->fmt($y)); + foreach ($lines as $i => $line) { + if ($i > 0) { + $ops[] = sprintf('0 %s Td', $this->fmt(-$leading)); + } + $ops[] = sprintf('(%s) Tj', $this->escape($line)); + } + $ops[] = 'ET'; + $ops[] = 'Q'; + + return [implode("\n", $ops), $fontKey, $fontRef]; + } + + /** @return array{?string, float} */ + private function parseDa(?string $da): array + { + if ($da !== null && preg_match('@/(\S+)\s+([\d.eE+-]+)\s+Tf@', $da, $m) === 1) { + return [$m[1], (float) $m[2]]; + } + + return [null, 9.0]; + } + + private function escape(string $text): string + { + $out = ''; + for ($i = 0; $i < strlen($text); $i++) { + $c = $text[$i]; + $ord = ord($c); + if ($c === '\\' || $c === '(' || $c === ')') { + $out .= '\\'.$c; + } elseif ($ord < 0x20 || $ord > 0x7E) { + $out .= sprintf('\\%03o', $ord); + } else { + $out .= $c; + } + } + + return $out; + } + + private function fmt(float $v): string + { + if ($v === floor($v) && abs($v) < 1e9) { + return (string) (int) $v; + } + + return rtrim(rtrim(sprintf('%.4f', $v), '0'), '.'); + } + + private function appendContent(ObjectImporter $importer, int $pageId, string $content, ?string $fontKey, ?PdfReference $fontRef): void + { + $dict = $importer->get($pageId); + if (!$dict instanceof PdfDictionary) { + return; + } + $items = $dict->all(); + + $contents = $items['Contents'] ?? null; + $list = match (true) { + is_array($contents) => array_values($contents), + $contents !== null => [$contents], + default => [], + }; + $list[] = new PdfReference($importer->allocate(new PdfStream(new PdfDictionary([]), $content)), 0); + $items['Contents'] = $list; + + if ($fontKey !== null && $fontRef !== null) { + $resources = $this->resolveDict($importer, $items['Resources'] ?? null) ?? new PdfDictionary([]); + $fonts = $this->resolveDict($importer, $resources->get('Font')) ?? new PdfDictionary([]); + $fontItems = $fonts->all(); + if (!isset($fontItems[$fontKey])) { + $fontItems[$fontKey] = $fontRef; + $resItems = $resources->all(); + $resItems['Font'] = new PdfDictionary($fontItems); + $resources = new PdfDictionary($resItems); + } + $items['Resources'] = $resources; + } + + $importer->set($pageId, new PdfDictionary($items)); + } + + private function removeAnnotation(ObjectImporter $importer, int $pageId, int $widgetId): void + { + $dict = $importer->get($pageId); + if (!$dict instanceof PdfDictionary) { + return; + } + $annots = $this->resolveArray($importer, $dict->get('Annots')); + if ($annots === null) { + return; + } + $filtered = array_values(array_filter( + $annots, + static fn ($ref) => !($ref instanceof PdfReference && $ref->number === $widgetId), + )); + $items = $dict->all(); + $items['Annots'] = $filtered; + $importer->set($pageId, new PdfDictionary($items)); + } + + /** + * A dictionary value already read off an imported object may itself be an + * indirect reference (ISO 32000-1 allows any value to be indirect) rather + * than inlined — resolve it against the importer's own object map before + * treating an absent match as "no such dictionary". + */ + private function resolveDict(ObjectImporter $importer, mixed $value): ?PdfDictionary + { + if ($value instanceof PdfReference) { + $value = $importer->get($value->number); + } + + return $value instanceof PdfDictionary ? $value : null; + } + + /** + * Same as {@see resolveDict()}, but for an array-valued entry (e.g. /Annots). + * + * @return list|null + */ + private function resolveArray(ObjectImporter $importer, mixed $value): ?array + { + if ($value instanceof PdfReference) { + $value = $importer->get($value->number); + } + + return is_array($value) ? $value : null; + } +} diff --git a/tests/Pdf/Forms/ExistingFormFillerTest.php b/tests/Pdf/Forms/ExistingFormFillerTest.php index f683bfe..2ef0a34 100644 --- a/tests/Pdf/Forms/ExistingFormFillerTest.php +++ b/tests/Pdf/Forms/ExistingFormFillerTest.php @@ -9,6 +9,7 @@ use Dskripchenko\PhpPdf\Pdf\Forms\FieldTree; use Dskripchenko\PhpPdf\Pdf\Reader\PdfName; use Dskripchenko\PhpPdf\Pdf\Reader\PdfReference; +use Dskripchenko\PhpPdf\Pdf\Reader\PdfStream; use Dskripchenko\PhpPdf\Pdf\Reader\ReaderDocument; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -121,6 +122,23 @@ public function sets_a_field_reachable_only_through_parent_inheritance(): void self::assertSame('Acme Corp', $result->fields()['employer.name']->value); } + #[Test] + public function flattening_a_parent_inherited_field_removes_the_acroform(): void + { + $path = __DIR__.'/../../fixtures/forms/parent-inherited.pdf'; + if (!is_file($path)) { + self::markTestSkipped('Fixture parent-inherited.pdf not present'); + } + + $out = ExistingFormFiller::fromFile($path) + ->setValue('employer.name', 'Acme Corp') + ->flatten() + ->toBytes(); + + self::assertSame([], ExistingFormFiller::fromBytes($out)->fields()); + self::assertStringNotContainsString('/AcroForm', $out); + } + #[Test] public function setting_an_unknown_field_throws(): void { @@ -194,6 +212,60 @@ public function does_not_mutate_the_original_source_document(): void self::assertSame('Jane Roe', $original->fields()['full_name']->value); } + #[Test] + public function flattening_all_fields_removes_the_acroform(): void + { + $filler = ExistingFormFiller::fromBytes($this->textFieldPdf()); + $out = $filler->setValue('full_name', 'Baked In')->flatten()->toBytes(); + + self::assertSame([], ExistingFormFiller::fromBytes($out)->fields()); + self::assertStringNotContainsString('/AcroForm', $out); + } + + private function pageText(ReaderDocument $doc, int $index): string + { + $contents = $doc->pages()[$index]->dict->get('Contents'); + $streams = is_array($contents) ? $contents : [$contents]; + $parts = []; + foreach ($streams as $entry) { + $stream = $doc->deref($entry); + if ($stream instanceof PdfStream) { + $parts[] = $doc->streamData($stream); + } + } + + return implode("\n", $parts); + } + + #[Test] + public function flattened_value_is_drawn_on_the_page(): void + { + $filler = ExistingFormFiller::fromBytes($this->textFieldPdf()); + $out = $filler->setValue('full_name', 'Baked In')->flatten()->toBytes(); + + self::assertStringContainsString('(Baked In) Tj', $this->pageText(ReaderDocument::fromBytes($out), 0)); + } + + #[Test] + public function flattening_a_subset_leaves_other_fields_interactive(): void + { + $pdf = PdfDocument::new(compressStreams: false); + $page = $pdf->addPage(); + $page->addFormField('text', 'first', 100, 700, 200, 20, defaultValue: 'A'); + $page->addFormField('text', 'second', 100, 650, 200, 20, defaultValue: 'B'); + + $out = ExistingFormFiller::fromBytes($pdf->toBytes()) + ->setValue('first', 'Baked') + ->flatten(['first']) + ->toBytes(); + + $result = ExistingFormFiller::fromBytes($out); + $fields = $result->fields(); + self::assertArrayNotHasKey('first', $fields); + self::assertArrayHasKey('second', $fields); + self::assertSame('B', $fields['second']->value); + } + #[Test] public function opens_from_a_file_path(): void { From d267061414853fa785d6d610b78707fca6c0f86e Mon Sep 17 00:00:00 2001 From: Christiaan Baartse Date: Wed, 2 Sep 2026 10:52:15 +0200 Subject: [PATCH 4/7] Add standalone image stamping and combined fill+flatten+stamp support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Templates commonly need a signature or photo placed at a fixed spot alongside the answered fields, and that placement doesn't always map to a form field at all — so this is a plain page/x/y/w/h primitive, not something bolted onto the field API, and it composes with setValue()/flatten() on the same document. Reuses the existing pure-PHP PNG/JPEG decoder rather than adding an Imagick dependency, matching how the rest of the reader/merge stack already avoids one. Reads a page's existing /Resources through the same indirect-reference-aware helper flatten() uses, since Acrobat- produced forms commonly store it as its own indirect object, and treating that as absent would silently discard the page's existing fonts and XObjects instead of adding the stamp alongside them. Co-Authored-By: Claude Sonnet 5 --- src/Pdf/Forms/ExistingFormFiller.php | 30 +++++ src/Pdf/Forms/ImageStamper.php | 134 +++++++++++++++++++++ tests/Pdf/Forms/ExistingFormFillerTest.php | 58 +++++++++ 3 files changed, 222 insertions(+) create mode 100644 src/Pdf/Forms/ImageStamper.php diff --git a/src/Pdf/Forms/ExistingFormFiller.php b/src/Pdf/Forms/ExistingFormFiller.php index 2cf1250..ddfa751 100644 --- a/src/Pdf/Forms/ExistingFormFiller.php +++ b/src/Pdf/Forms/ExistingFormFiller.php @@ -4,6 +4,7 @@ namespace Dskripchenko\PhpPdf\Pdf\Forms; +use Dskripchenko\PhpPdf\Image\PdfImage; use Dskripchenko\PhpPdf\Pdf\Merge\MergeSerializer; use Dskripchenko\PhpPdf\Pdf\Merge\ObjectImporter; use Dskripchenko\PhpPdf\Pdf\Merge\PdfSource; @@ -149,6 +150,35 @@ public function flatten(?array $fieldNames = null): self return $this; } + /** + * Place a PNG/JPEG image (with alpha preserved for PNG) onto the given + * 0-based page, at `$x,$y,$w,$h` in top-left-origin points. Independent + * of any AcroForm field — composes with {@see setValue()} and + * {@see flatten()} on the same page. + */ + public function stampImage(int $pageIndex, string $imagePath, float $x, float $y, float $width, float $height): self + { + return $this->stampImageBytes($pageIndex, (string) file_get_contents($imagePath), $x, $y, $width, $height); + } + + public function stampImageBytes(int $pageIndex, string $bytes, float $x, float $y, float $width, float $height): self + { + $doc = $this->document(); + $pages = $doc->pages(); + if ($pageIndex < 0 || $pageIndex >= count($pages)) { + throw new \OutOfRangeException("Page {$pageIndex} does not exist (document has " . count($pages) . ' pages)'); + } + $sourcePage = $pages[$pageIndex]; + + $importer = $this->importer(); + $pageId = $importer->importObject($sourcePage->objectNumber)->number; + $image = PdfImage::fromBytes($bytes); + + (new ImageStamper())->stamp($importer, $pageId, $image, $x, $y, $width, $height, $sourcePage->height()); + + return $this; + } + public function toBytes(): string { $importer = $this->importer(); diff --git a/src/Pdf/Forms/ImageStamper.php b/src/Pdf/Forms/ImageStamper.php new file mode 100644 index 0000000..fe51883 --- /dev/null +++ b/src/Pdf/Forms/ImageStamper.php @@ -0,0 +1,134 @@ +registerImage($importer, $image); + $name = 'Stamp' . $imageId; + + $pdfY = $pageHeight - $y - $height; + $cm = sprintf( + '%s 0 0 %s %s %s cm', + $this->fmt($width), $this->fmt($height), $this->fmt($x), $this->fmt($pdfY), + ); + $content = "q\n{$cm}\n/{$name} Do\nQ"; + + $this->appendToPage($importer, $pageId, $content, $name, new PdfReference($imageId, 0)); + } + + private function registerImage(ObjectImporter $importer, PdfImage $image): int + { + $items = [ + 'Type' => new PdfName('XObject'), + 'Subtype' => new PdfName('Image'), + 'Width' => $image->widthPx, + 'Height' => $image->heightPx, + 'ColorSpace' => new PdfName(ltrim($image->colorSpace, '/')), + 'BitsPerComponent' => $image->bitsPerComponent, + 'Filter' => new PdfName(ltrim($image->filter, '/')), + ]; + + if ($image->alphaData !== null && $image->alphaData !== '') { + $maskDict = new PdfDictionary([ + 'Type' => new PdfName('XObject'), + 'Subtype' => new PdfName('Image'), + 'Width' => $image->widthPx, + 'Height' => $image->heightPx, + 'ColorSpace' => new PdfName('DeviceGray'), + 'BitsPerComponent' => 8, + 'Filter' => new PdfName('FlateDecode'), + ]); + $maskId = $importer->allocate(new PdfStream($maskDict, $image->alphaData)); + $items['SMask'] = new PdfReference($maskId, 0); + } + + return $importer->allocate(new PdfStream(new PdfDictionary($items), $image->imageData)); + } + + private function appendToPage(ObjectImporter $importer, int $pageId, string $content, string $name, PdfReference $imageRef): void + { + $dict = $importer->get($pageId); + if (!$dict instanceof PdfDictionary) { + throw new \InvalidArgumentException("Page object {$pageId} was not imported"); + } + $items = $dict->all(); + + $contents = $items['Contents'] ?? null; + $list = match (true) { + is_array($contents) => array_values($contents), + $contents !== null => [$contents], + default => [], + }; + $list[] = new PdfReference($importer->allocate(new PdfStream(new PdfDictionary([]), $content)), 0); + $items['Contents'] = $list; + + $resources = $this->resolveDict($importer, $items['Resources'] ?? null) ?? new PdfDictionary([]); + $xobjects = $this->resolveDict($importer, $resources->get('XObject')) ?? new PdfDictionary([]); + $xobjItems = $xobjects->all(); + $xobjItems[$name] = $imageRef; + $resItems = $resources->all(); + $resItems['XObject'] = new PdfDictionary($xobjItems); + $items['Resources'] = new PdfDictionary($resItems); + + $importer->set($pageId, new PdfDictionary($items)); + } + + /** + * A dictionary value already read off an imported object may itself be an + * indirect reference (ISO 32000-1 allows any value to be indirect) rather + * than inlined — resolve it against the importer's own object map before + * treating an absent match as "no such dictionary". + */ + private function resolveDict(ObjectImporter $importer, mixed $value): ?PdfDictionary + { + if ($value instanceof PdfReference) { + $value = $importer->get($value->number); + } + + return $value instanceof PdfDictionary ? $value : null; + } + + private function fmt(float $v): string + { + if ($v === floor($v) && abs($v) < 1e9) { + return (string) (int) $v; + } + + return rtrim(rtrim(sprintf('%.4f', $v), '0'), '.'); + } +} diff --git a/tests/Pdf/Forms/ExistingFormFillerTest.php b/tests/Pdf/Forms/ExistingFormFillerTest.php index 2ef0a34..7f42906 100644 --- a/tests/Pdf/Forms/ExistingFormFillerTest.php +++ b/tests/Pdf/Forms/ExistingFormFillerTest.php @@ -266,6 +266,64 @@ public function flattening_a_subset_leaves_other_fields_interactive(): void self::assertSame('B', $fields['second']->value); } + #[Test] + public function stamps_an_opaque_image_standalone(): void + { + $pdf = PdfDocument::new(compressStreams: false); + $pdf->addPage(); + $bytes = $pdf->toBytes(); + + $out = ExistingFormFiller::fromBytes($bytes) + ->stampImage(0, __DIR__ . '/../../fixtures/sample.png', 10, 20, 40, 30) + ->toBytes(); + + self::assertStringContainsString('/Subtype /Image', $out); + self::assertMatchesRegularExpression('@/Stamp\d+ Do@', $out); + self::assertStringNotContainsString('/SMask', $out); + } + + #[Test] + public function stamps_a_transparent_image_with_an_smask(): void + { + $pdf = PdfDocument::new(compressStreams: false); + $pdf->addPage(); + $bytes = $pdf->toBytes(); + + $out = ExistingFormFiller::fromBytes($bytes) + ->stampImage(0, __DIR__ . '/../../fixtures/1x1.png', 0, 0, 10, 10) + ->toBytes(); + + self::assertStringContainsString('/SMask', $out); + } + + #[Test] + public function composes_fill_flatten_and_stamp(): void + { + $bytes = $this->textFieldPdf(); + + $out = ExistingFormFiller::fromBytes($bytes) + ->setValue('full_name', 'Combined') + ->flatten() + ->stampImage(0, __DIR__ . '/../../fixtures/sample.png', 10, 10, 20, 15) + ->toBytes(); + + $doc = ReaderDocument::fromBytes($out); + self::assertSame(1, $doc->pageCount()); + self::assertStringContainsString('(Combined) Tj', $this->pageText($doc, 0)); + self::assertStringContainsString('/Subtype /Image', $out); + } + + #[Test] + public function rejects_an_out_of_range_page(): void + { + $pdf = PdfDocument::new(compressStreams: false); + $pdf->addPage(); + + $this->expectException(\OutOfRangeException::class); + ExistingFormFiller::fromBytes($pdf->toBytes()) + ->stampImage(1, __DIR__ . '/../../fixtures/sample.png', 0, 0, 10, 10); + } + #[Test] public function opens_from_a_file_path(): void { From 56864ff31ccf940d92b1a619ddf13ea5b6162c24 Mon Sep 17 00:00:00 2001 From: Christiaan Baartse Date: Wed, 2 Sep 2026 11:04:28 +0200 Subject: [PATCH 5/7] Wire a fill/flatten/stamp fixture into the visual regression check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structural tests already cover field/annotation correctness, but nothing catches a rendering regression in the new flatten() drawing path or the image stamp. The existing visual-check.sh harness already renders reference PDFs and diffs them against committed goldens for pdfa-1b/pdfx-4 — this adds a form-fill document exercising the whole fill + flatten + stamp pipeline through the same check. The golden PNG itself is not committed here: this repo's own harness warns against generating goldens outside CI, since poppler builds anti-alias differently across platforms — the first CI run against this branch needs to establish it via visual-check.sh --update. --- scripts/conformance/generate.php | 26 ++++++++++++++++++++++++++ scripts/conformance/visual-check.sh | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/scripts/conformance/generate.php b/scripts/conformance/generate.php index a7fbd65..ec2bf7f 100644 --- a/scripts/conformance/generate.php +++ b/scripts/conformance/generate.php @@ -22,6 +22,8 @@ use Dskripchenko\PhpPdf\Element\Table; use Dskripchenko\PhpPdf\Font\Ttf\TtfFile; use Dskripchenko\PhpPdf\Layout\Engine; +use Dskripchenko\PhpPdf\Pdf\Document as PdfDocument; +use Dskripchenko\PhpPdf\Pdf\Forms\ExistingFormFiller; use Dskripchenko\PhpPdf\Pdf\PdfAConfig; use Dskripchenko\PhpPdf\Pdf\PdfFont; use Dskripchenko\PhpPdf\Pdf\PdfXConfig; @@ -185,4 +187,28 @@ $failures += $ok ? 0 : 1; } +// Visual fixture for ExistingFormFiller: a small template with a text field +// and a checkbox, filled, flattened, and stamped with a signature image — +// exercises the whole fill/flatten/stamp pipeline in one rendered page. +$template = PdfDocument::new(compressStreams: false); +$templatePage = $template->addPage(); +$templatePage->addFormField('text', 'full_name', 72, 700, 250, 20, defaultValue: ''); +$templatePage->addFormField('checkbox', 'agree', 72, 660, 14, 14); + +$formFillOk = true; +try { + $bytes = ExistingFormFiller::fromBytes($template->toBytes()) + ->setValue('full_name', 'Jane Roe') + ->setValue('agree', 'yes') + ->flatten() + ->stampImage(0, $root.'/tests/fixtures/1x1.png', 72, 600, 40, 40) + ->toBytes(); + file_put_contents($outDir.'/form-fill.pdf', $bytes); + echo "generated $outDir/form-fill.pdf\n"; +} catch (\Throwable $e) { + fwrite(STDERR, "FAILED form-fill: {$e->getMessage()}\n"); + $formFillOk = false; +} +$failures += $formFillOk ? 0 : 1; + exit($failures > 0 ? 1 : 0); diff --git a/scripts/conformance/visual-check.sh b/scripts/conformance/visual-check.sh index 77e477c..e1ced2d 100755 --- a/scripts/conformance/visual-check.sh +++ b/scripts/conformance/visual-check.sh @@ -33,7 +33,7 @@ FUZZ="5%" MAX_DIFF_PCT="0.5" # First page of each document is the visual fixture. -DOCS=(pdfa-1b pdfx-4) +DOCS=(pdfa-1b pdfx-4 form-fill) mkdir -p "$RENDER" "$GOLDEN" From 6dca22eab968b648aa5af8ea573eed71eeef16ef Mon Sep 17 00:00:00 2001 From: Christiaan Baartse Date: Wed, 2 Sep 2026 11:06:35 +0200 Subject: [PATCH 6/7] Document ExistingFormFiller in USAGE.md (en/de/ru/zh) Fill-existing-form is a distinct entry point from the from-scratch FormField authoring already documented, and downstream users need to find it the same way they'd find the existing Forms section. Kept in lockstep with the other three translated USAGE.md files, same as the rest of the guide. --- docs/de/USAGE.md | 61 ++++++++++++++++++++++++++++++++++++++++++++++++ docs/en/USAGE.md | 56 ++++++++++++++++++++++++++++++++++++++++++++ docs/ru/USAGE.md | 61 ++++++++++++++++++++++++++++++++++++++++++++++++ docs/zh/USAGE.md | 50 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 228 insertions(+) diff --git a/docs/de/USAGE.md b/docs/de/USAGE.md index 5416139..874e518 100644 --- a/docs/de/USAGE.md +++ b/docs/de/USAGE.md @@ -24,6 +24,7 @@ einen Rundgang oder springen Sie direkt zum benötigten Feature. - [SVG](#svg) - [Hyperlinks und Lesezeichen](#hyperlinks-und-lesezeichen) - [Formulare (AcroForm)](#formulare-acroform) +- [Ein bestehendes Formular ausfüllen (AcroForm)](#ein-bestehendes-formular-ausfüllen-acroform) - [Annotationen](#annotationen) - [Verschlüsselung](#verschlüsselung) - [Digitale Signatur](#digitale-signatur) @@ -519,6 +520,66 @@ Dokumentebene: `WC` (WillClose), `WS` (WillSave), `DS` (DidSave), `WP` --- +## Ein bestehendes Formular ausfüllen (AcroForm) + +Das Obige erstellt ein neues Formular. Um ein von jemand anderem erzeugtes +PDF zu öffnen — eine hochgeladene Vorlage, ein Behördenformular — und die +Werte seiner bereits definierten Felder namentlich auszufüllen, verwenden +Sie stattdessen `ExistingFormFiller`: + +```php +use Dskripchenko\PhpPdf\Pdf\Forms\ExistingFormFiller; + +ExistingFormFiller::fromFile('template.pdf') + ->setValues([ + 'full_name' => 'Jane Roe', + 'agree' => 'yes', + 'employer.name' => 'Acme Corp', // über /Parent-Vererbung aufgelöst + ]) + ->stampImage(0, 'signature.png', x: 100, y: 600, width: 120, height: 40) + ->flatten() + ->toFile('filled.pdf'); +``` + +Das Quelldokument wird nie verändert — jeder Aufruf arbeitet auf einer +tiefen Kopie des *gesamten* Objektgraphen, sodass alles Unberührte +(Lesezeichen, Metadaten, andere Annotationen) unverändert in die Ausgabe +übernommen wird. `fields()` liefert für jedes Feld seinen vollständig +qualifizierten, durch Punkte getrennten Namen, Typ, aktuellen Wert und (bei +Checkbox/Radio) seine Ein-Zustand-Optionsnamen, sodass ein Aufrufer die Form +einer Vorlage vor dem Ausfüllen ermitteln kann: + +```php +foreach (ExistingFormFiller::fromFile('template.pdf')->fields() as $name => $field) { + echo "{$name}: {$field->type} = {$field->value}\n"; +} +``` + +Unterstützte Feldtypen: `text`, `text-multiline`, `checkbox`, `radio`. Eine +Checkbox akzeptiert `on`/`yes`/`true`/`1` (ohne Groß-/Kleinschreibung) oder +ihren exakten Ein-Zustand-Exportnamen; alles andere hakt sie ab. Der Wert +einer Radiogruppe muss einem ihrer Options-Exportnamen entsprechen (ohne +Groß-/Kleinschreibung). + +`flatten(?array $fieldNames = null)` bäckt den aktuellen Wert in den +Seiteninhalt ein und entfernt das interaktive Widget — übergeben Sie eine +Liste von Namen, um eine Teilmenge zu flatten und den Rest interaktiv zu +lassen, oder lassen Sie es weg, um alles zu flatten (was auch `/AcroForm` +entfernt, sobald nichts mehr darin übrig ist). Nur `text`/`text-multiline`- +Werte werden gezeichnet; andere Feldtypen verlieren nur ihr Widget. Das +Platzhalter-Erscheinungsbild eines nicht ausgefüllten geflatteten Feldes +(z. B. ein grauer Hintergrund) wird nicht eingebacken — nur Felder mit +einem Wert erhalten an ihrer Stelle etwas Gezeichnetes. Lassen Sie Felder +ungeflatteten (Standard), um das Ergebnis ein gewöhnliches interaktives PDF +bleiben zu lassen. + +`stampImage()`/`stampImageBytes()` platziert ein PNG (mit Alphakanal) oder +JPEG an einer gegebenen Seite/x/y/Breite/Höhe, unabhängig von jedem Feld — +nützlich für eine Signatur oder ein Foto, das selbst kein Formularfeld ist. +Koordinaten sind Punkte mit Ursprung oben links. + +--- + ## Annotationen ```php diff --git a/docs/en/USAGE.md b/docs/en/USAGE.md index 840ca20..6d51c7b 100644 --- a/docs/en/USAGE.md +++ b/docs/en/USAGE.md @@ -24,6 +24,7 @@ the feature you need. - [SVG](#svg) - [Hyperlinks and bookmarks](#hyperlinks-and-bookmarks) - [Forms (AcroForm)](#forms-acroform) +- [Fill an existing form (AcroForm)](#fill-an-existing-form-acroform) - [Annotations](#annotations) - [Encryption](#encryption) - [Digital signing](#digital-signing) @@ -507,6 +508,61 @@ events: `WC` (WillClose), `WS` (WillSave), `DS` (DidSave), `WP` --- +## Fill an existing form (AcroForm) + +The above authors a brand-new form. To open a PDF someone else produced — +an uploaded template, a government form — and fill in the values of its +already-defined fields by name, use `ExistingFormFiller` instead: + +```php +use Dskripchenko\PhpPdf\Pdf\Forms\ExistingFormFiller; + +ExistingFormFiller::fromFile('template.pdf') + ->setValues([ + 'full_name' => 'Jane Roe', + 'agree' => 'yes', + 'employer.name' => 'Acme Corp', // resolved via /Parent inheritance + ]) + ->stampImage(0, 'signature.png', x: 100, y: 600, width: 120, height: 40) + ->flatten() + ->toFile('filled.pdf'); +``` + +The source document is never mutated — every call works on a deep copy of +the *entire* object graph, so anything not touched (outlines, metadata, +other annotations) survives untouched into the output. `fields()` returns +each field's fully-qualified dotted name, type, current value and (for +checkbox/radio) its on-state option names, so a caller can discover a +template's shape before filling it: + +```php +foreach (ExistingFormFiller::fromFile('template.pdf')->fields() as $name => $field) { + echo "{$name}: {$field->type} = {$field->value}\n"; +} +``` + +Supported field types: `text`, `text-multiline`, `checkbox`, `radio`. A +checkbox accepts `on`/`yes`/`true`/`1` (case insensitive) or its exact +on-state export name; anything else unchecks it. A radio group's value must +match one of its option export names (case insensitive). + +`flatten(?array $fieldNames = null)` bakes the current value into the page +content and removes the interactive widget — pass a list of names to +flatten a subset and leave the rest interactive, or omit it to flatten +everything (which also removes `/AcroForm` once nothing is left in it). +Only `text`/`text-multiline` values are drawn; other field types just lose +their widget. An unfilled flattened field's placeholder appearance (e.g. a +grey background) is not baked in — only fields with a value get anything +drawn in their place. Leave fields unflattened (the default) to keep the +result an ordinary interactive PDF instead. + +`stampImage()`/`stampImageBytes()` place a PNG (with alpha) or JPEG at a +given page/x/y/width/height, independent of any field — useful for a +signature or photo that isn't itself a form field. Coordinates are +top-left-origin points. + +--- + ## Annotations ```php diff --git a/docs/ru/USAGE.md b/docs/ru/USAGE.md index 2ffa2b1..3a5ea5b 100644 --- a/docs/ru/USAGE.md +++ b/docs/ru/USAGE.md @@ -24,6 +24,7 @@ - [SVG](#svg) - [Гиперссылки и закладки](#гиперссылки-и-закладки) - [Формы (AcroForm)](#формы-acroform) +- [Заполнение существующей формы (AcroForm)](#заполнение-существующей-формы-acroform) - [Аннотации](#аннотации) - [Шифрование](#шифрование) - [Цифровая подпись](#цифровая-подпись) @@ -519,6 +520,66 @@ JavaScript-хуки на уровне поля: `keystrokeScript`, `validateScri --- +## Заполнение существующей формы (AcroForm) + +Выше описано создание новой формы. Чтобы открыть PDF, созданный кем-то +другим — загруженный шаблон, государственную форму — и заполнить значения +уже определённых в нём полей по имени, используйте вместо этого +`ExistingFormFiller`: + +```php +use Dskripchenko\PhpPdf\Pdf\Forms\ExistingFormFiller; + +ExistingFormFiller::fromFile('template.pdf') + ->setValues([ + 'full_name' => 'Jane Roe', + 'agree' => 'yes', + 'employer.name' => 'Acme Corp', // разрешается через наследование /Parent + ]) + ->stampImage(0, 'signature.png', x: 100, y: 600, width: 120, height: 40) + ->flatten() + ->toFile('filled.pdf'); +``` + +Исходный документ никогда не изменяется — каждый вызов работает с глубокой +копией *всего* графа объектов, поэтому всё нетронутое (закладки, метаданные, +другие аннотации) переносится в результат без изменений. `fields()` +возвращает для каждого поля его полное имя с точками, тип, текущее значение +и (для чекбокса/радио) имена включённого состояния, что позволяет узнать +структуру шаблона перед заполнением: + +```php +foreach (ExistingFormFiller::fromFile('template.pdf')->fields() as $name => $field) { + echo "{$name}: {$field->type} = {$field->value}\n"; +} +``` + +Поддерживаемые типы полей: `text`, `text-multiline`, `checkbox`, `radio`. +Чекбокс принимает `on`/`yes`/`true`/`1` (без учёта регистра) или точное имя +экспорта включённого состояния; любое другое значение снимает отметку. +Значение группы радиокнопок должно совпадать с одним из имён экспорта +опций (без учёта регистра). + +`flatten(?array $fieldNames = null)` впечатывает текущее значение в +содержимое страницы и удаляет интерактивный виджет — передайте список +имён, чтобы «сплющить» подмножество и оставить остальные поля +интерактивными, либо не передавайте ничего, чтобы «сплющить» всё (это +также удаляет `/AcroForm`, когда в нём ничего не остаётся). Отрисовываются +только значения `text`/`text-multiline`; остальные типы полей просто +теряют свой виджет. Внешний вид незаполненного «сплющенного» поля +(например, серый фон-плейсхолдер) не впечатывается — что-то рисуется на +его месте только для полей со значением. Оставьте поля не «сплющенными» +(поведение по умолчанию), чтобы результат остался обычным интерактивным +PDF. + +`stampImage()`/`stampImageBytes()` размещает PNG (с альфа-каналом) или +JPEG по заданным странице/x/y/ширине/высоте, независимо от каких-либо +полей — полезно для подписи или фотографии, которая сама по себе не +является полем формы. Координаты заданы в точках с началом координат +в левом верхнем углу. + +--- + ## Аннотации ```php diff --git a/docs/zh/USAGE.md b/docs/zh/USAGE.md index 8c4b4a0..37d931a 100644 --- a/docs/zh/USAGE.md +++ b/docs/zh/USAGE.md @@ -23,6 +23,7 @@ HTML 转 PDF 一直深入到最底层的页面发射。每个章节都是自包 - [SVG](#svg) - [超链接与书签](#超链接与书签) - [表单(AcroForm)](#表单acroform) +- [填写已有表单(AcroForm)](#填写已有表单acroform) - [标注](#标注) - [加密](#加密) - [数字签名](#数字签名) @@ -498,6 +499,55 @@ DocumentBuilder::new() --- +## 填写已有表单(AcroForm) + +以上是从零创建新表单。若要打开他人生成的 PDF——上传的模板、政府表格—— +并按名称填写其中已定义字段的值,请改用 `ExistingFormFiller`: + +```php +use Dskripchenko\PhpPdf\Pdf\Forms\ExistingFormFiller; + +ExistingFormFiller::fromFile('template.pdf') + ->setValues([ + 'full_name' => 'Jane Roe', + 'agree' => 'yes', + 'employer.name' => 'Acme Corp', // 通过 /Parent 继承解析 + ]) + ->stampImage(0, 'signature.png', x: 100, y: 600, width: 120, height: 40) + ->flatten() + ->toFile('filled.pdf'); +``` + +源文档永远不会被修改——每次调用都作用于*整个*对象图的深拷贝,因此未被 +触及的内容(大纲、元数据、其他标注)会原样保留到输出中。`fields()` 会 +返回每个字段的完整点分名称、类型、当前值,以及(对复选框/单选按钮而言) +其选中状态的导出名称,方便调用方在填写前了解模板的结构: + +```php +foreach (ExistingFormFiller::fromFile('template.pdf')->fields() as $name => $field) { + echo "{$name}: {$field->type} = {$field->value}\n"; +} +``` + +支持的字段类型:`text`、`text-multiline`、`checkbox`、`radio`。复选框接受 +`on`/`yes`/`true`/`1`(不区分大小写)或其精确的选中状态导出名称;其他任何 +值都会取消勾选。单选按钮组的值必须匹配其某个选项的导出名称(不区分大小 +写)。 + +`flatten(?array $fieldNames = null)` 会将当前值绘制进页面内容并移除交互 +式部件——传入字段名称列表可只压平其中一部分、其余字段保持交互,省略参数 +则压平全部字段(这也会在 `/AcroForm` 中不再留有任何字段时将其整体移除)。 +仅 `text`/`text-multiline` 的值会被绘制;其他字段类型只是失去其部件。未 +填写字段被压平后,其占位外观(例如灰色背景)不会被绘制保留——只有有值的 +字段才会在原位置绘制内容。保持默认(不压平)可使结果仍是一份普通的交互 +式 PDF。 + +`stampImage()`/`stampImageBytes()` 可在指定的页面/x/y/宽/高处放置一张 PNG +(保留透明度)或 JPEG 图片,与任何字段无关——适用于签名或照片这类本身并 +非表单字段的内容。坐标以左上角为原点。 + +--- + ## 标注 ```php From ede839977cb25c3f8a63b183e8ade1337c15d967 Mon Sep 17 00:00:00 2001 From: Christiaan Baartse Date: Wed, 2 Sep 2026 13:47:10 +0200 Subject: [PATCH 7/7] Expose a field's /TU tooltip on ExistingFormFiller::fields() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some AcroForm templates carry a hidden field whose /TU is repurposed to hold structured metadata (e.g. a JSON placement hint for stamping an image onto the filled template) rather than a literal tooltip — a convention older pdftk-based fillers exposed via their own field dump. ExistingFormFiller had no way to read it back, blocking a migration off such tooling. Tooltips are stored as PDF text strings, which non-ASCII values encode as UTF-16BE with a byte-order mark rather than raw bytes (ISO 32000-1 §7.9.2.2), so the value is decoded accordingly to stay readable for anything beyond plain ASCII. A field's /T name segment is decoded through that same text-string helper, since forms use the same UTF-16BE convention there too. Co-Authored-By: Claude Sonnet 5 --- docs/de/USAGE.md | 7 +++-- docs/en/USAGE.md | 6 ++-- docs/ru/USAGE.md | 6 ++-- docs/zh/USAGE.md | 5 ++-- src/Pdf/Forms/ExistingFormFiller.php | 1 + src/Pdf/Forms/FieldInfo.php | 2 ++ src/Pdf/Forms/FieldNode.php | 1 + src/Pdf/Forms/FieldTree.php | 26 ++++++++++++++++-- tests/Pdf/Forms/ExistingFormFillerTest.php | 32 ++++++++++++++++++++++ 9 files changed, 73 insertions(+), 13 deletions(-) diff --git a/docs/de/USAGE.md b/docs/de/USAGE.md index 874e518..2bfb008 100644 --- a/docs/de/USAGE.md +++ b/docs/de/USAGE.md @@ -545,9 +545,10 @@ Das Quelldokument wird nie verändert — jeder Aufruf arbeitet auf einer tiefen Kopie des *gesamten* Objektgraphen, sodass alles Unberührte (Lesezeichen, Metadaten, andere Annotationen) unverändert in die Ausgabe übernommen wird. `fields()` liefert für jedes Feld seinen vollständig -qualifizierten, durch Punkte getrennten Namen, Typ, aktuellen Wert und (bei -Checkbox/Radio) seine Ein-Zustand-Optionsnamen, sodass ein Aufrufer die Form -einer Vorlage vor dem Ausfüllen ermitteln kann: +qualifizierten, durch Punkte getrennten Namen, Typ, aktuellen Wert, seinen +`/TU`-Tooltip (falls vorhanden) und (bei Checkbox/Radio) seine +Ein-Zustand-Optionsnamen, sodass ein Aufrufer die Form einer Vorlage vor +dem Ausfüllen ermitteln kann: ```php foreach (ExistingFormFiller::fromFile('template.pdf')->fields() as $name => $field) { diff --git a/docs/en/USAGE.md b/docs/en/USAGE.md index 6d51c7b..f6ec9ab 100644 --- a/docs/en/USAGE.md +++ b/docs/en/USAGE.md @@ -531,9 +531,9 @@ ExistingFormFiller::fromFile('template.pdf') The source document is never mutated — every call works on a deep copy of the *entire* object graph, so anything not touched (outlines, metadata, other annotations) survives untouched into the output. `fields()` returns -each field's fully-qualified dotted name, type, current value and (for -checkbox/radio) its on-state option names, so a caller can discover a -template's shape before filling it: +each field's fully-qualified dotted name, type, current value, its `/TU` +tooltip (if any) and (for checkbox/radio) its on-state option names, so a +caller can discover a template's shape before filling it: ```php foreach (ExistingFormFiller::fromFile('template.pdf')->fields() as $name => $field) { diff --git a/docs/ru/USAGE.md b/docs/ru/USAGE.md index 3a5ea5b..9ede8d6 100644 --- a/docs/ru/USAGE.md +++ b/docs/ru/USAGE.md @@ -544,9 +544,9 @@ ExistingFormFiller::fromFile('template.pdf') Исходный документ никогда не изменяется — каждый вызов работает с глубокой копией *всего* графа объектов, поэтому всё нетронутое (закладки, метаданные, другие аннотации) переносится в результат без изменений. `fields()` -возвращает для каждого поля его полное имя с точками, тип, текущее значение -и (для чекбокса/радио) имена включённого состояния, что позволяет узнать -структуру шаблона перед заполнением: +возвращает для каждого поля его полное имя с точками, тип, текущее значение, +подсказку `/TU` (если есть) и (для чекбокса/радио) имена включённого +состояния, что позволяет узнать структуру шаблона перед заполнением: ```php foreach (ExistingFormFiller::fromFile('template.pdf')->fields() as $name => $field) { diff --git a/docs/zh/USAGE.md b/docs/zh/USAGE.md index 37d931a..7f59ffa 100644 --- a/docs/zh/USAGE.md +++ b/docs/zh/USAGE.md @@ -520,8 +520,9 @@ ExistingFormFiller::fromFile('template.pdf') 源文档永远不会被修改——每次调用都作用于*整个*对象图的深拷贝,因此未被 触及的内容(大纲、元数据、其他标注)会原样保留到输出中。`fields()` 会 -返回每个字段的完整点分名称、类型、当前值,以及(对复选框/单选按钮而言) -其选中状态的导出名称,方便调用方在填写前了解模板的结构: +返回每个字段的完整点分名称、类型、当前值、`/TU` 提示文字(如果有),以及 +(对复选框/单选按钮而言)其选中状态的导出名称,方便调用方在填写前了解 +模板的结构: ```php foreach (ExistingFormFiller::fromFile('template.pdf')->fields() as $name => $field) { diff --git a/src/Pdf/Forms/ExistingFormFiller.php b/src/Pdf/Forms/ExistingFormFiller.php index ddfa751..cdabc41 100644 --- a/src/Pdf/Forms/ExistingFormFiller.php +++ b/src/Pdf/Forms/ExistingFormFiller.php @@ -79,6 +79,7 @@ public function fields(): array options: $node->options, required: $node->required, readOnly: $node->readOnly, + tu: $node->tu, ); } diff --git a/src/Pdf/Forms/FieldInfo.php b/src/Pdf/Forms/FieldInfo.php index 2095cb8..f429c94 100644 --- a/src/Pdf/Forms/FieldInfo.php +++ b/src/Pdf/Forms/FieldInfo.php @@ -24,6 +24,8 @@ public function __construct( public array $options, public bool $required, public bool $readOnly, + /** The field's `/TU` (alternate field name / tooltip), if any. */ + public ?string $tu = null, ) { } } diff --git a/src/Pdf/Forms/FieldNode.php b/src/Pdf/Forms/FieldNode.php index f9f45c5..cf7ba8d 100644 --- a/src/Pdf/Forms/FieldNode.php +++ b/src/Pdf/Forms/FieldNode.php @@ -25,6 +25,7 @@ public function __construct( public array $widgetObjNums, public ?int $parentObjNum, public ?string $da, + public ?string $tu, public string $value, /** @var list */ public array $options, diff --git a/src/Pdf/Forms/FieldTree.php b/src/Pdf/Forms/FieldTree.php index 3f1fad5..8435f79 100644 --- a/src/Pdf/Forms/FieldTree.php +++ b/src/Pdf/Forms/FieldTree.php @@ -68,8 +68,7 @@ private function walk( return; } - $ownT = $dict->get('T'); - $partial = $ownT instanceof PdfString ? $this->decodeName($ownT) : null; + $partial = $this->decodeTextString($dict->get('T')); $name = $partial !== null ? ($parentName !== null ? $parentName . '.' . $partial : $partial) : $parentName; @@ -77,6 +76,7 @@ private function walk( $ft = $this->nameValue($dict->get('FT')) ?? $inheritedFt; $da = $dict->get('DA') instanceof PdfString ? $this->decodeName($dict->get('DA')) : $inheritedDa; $ff = is_int($dict->get('Ff')) ? $dict->get('Ff') : $inheritedFf; + $tu = $this->decodeTextString($dict->get('TU')); $kids = $doc->deref($dict->get('Kids')); $childFieldRefs = []; @@ -125,6 +125,7 @@ private function walk( widgetObjNums: $widgetObjNums, parentObjNum: $parentObjNum, da: $da, + tu: $tu, value: $value, options: $options, required: (($ff ?? 0) & 2) !== 0, @@ -186,6 +187,27 @@ private function decodeName(mixed $value): ?string return $value instanceof PdfString ? $value->bytes : null; } + /** + * Decodes a PDF text string (ISO 32000-1 §7.9.2.2): UTF-16BE with a + * `\xFE\xFF` byte order mark, or PDFDocEncoding (ASCII-compatible for + * the characters this library writes) when the BOM is absent. + */ + private function decodeTextString(mixed $value): ?string + { + if (!$value instanceof PdfString) { + return null; + } + + $bytes = $value->bytes; + if (!str_starts_with($bytes, "\xFE\xFF")) { + return $bytes; + } + + $decoded = @iconv('UTF-16BE', 'UTF-8', substr($bytes, 2)); + + return $decoded !== false ? $decoded : $bytes; + } + private function stringValue(mixed $value): string { if ($value instanceof PdfString) { diff --git a/tests/Pdf/Forms/ExistingFormFillerTest.php b/tests/Pdf/Forms/ExistingFormFillerTest.php index 7f42906..87093e9 100644 --- a/tests/Pdf/Forms/ExistingFormFillerTest.php +++ b/tests/Pdf/Forms/ExistingFormFillerTest.php @@ -60,6 +60,38 @@ public function enumerates_fields_from_a_compressed_xref_source(): void self::assertSame('Jane Roe', $fields['full_name']->value); } + #[Test] + public function exposes_the_tu_tooltip(): void + { + $pdf = PdfDocument::new(compressStreams: false); + $page = $pdf->addPage(); + $page->addFormField('text', 'hint', 0, 0, 100, 20, tooltip: '{"width":"auto","height":140,"left":50,"top":770,"page":1}'); + + $filler = ExistingFormFiller::fromBytes($pdf->toBytes()); + self::assertSame( + '{"width":"auto","height":140,"left":50,"top":770,"page":1}', + $filler->fields()['hint']->tu, + ); + } + + #[Test] + public function decodes_a_non_ascii_tu_tooltip(): void + { + $pdf = PdfDocument::new(compressStreams: false); + $page = $pdf->addPage(); + $page->addFormField('text', 'hint', 0, 0, 100, 20, tooltip: 'Naam invöeren'); + + $filler = ExistingFormFiller::fromBytes($pdf->toBytes()); + self::assertSame('Naam invöeren', $filler->fields()['hint']->tu); + } + + #[Test] + public function tu_is_null_when_not_set(): void + { + $filler = ExistingFormFiller::fromBytes($this->textFieldPdf()); + self::assertNull($filler->fields()['full_name']->tu); + } + #[Test] public function reads_a_required_readonly_field(): void {