diff --git a/docs/de/USAGE.md b/docs/de/USAGE.md index 5416139..2bfb008 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,67 @@ 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, 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) { + 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..f6ec9ab 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, 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) { + 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..9ede8d6 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()` +возвращает для каждого поля его полное имя с точками, тип, текущее значение, +подсказку `/TU` (если есть) и (для чекбокса/радио) имена включённого +состояния, что позволяет узнать структуру шаблона перед заполнением: + +```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..7f59ffa 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,56 @@ 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()` 会 +返回每个字段的完整点分名称、类型、当前值、`/TU` 提示文字(如果有),以及 +(对复选框/单选按钮而言)其选中状态的导出名称,方便调用方在填写前了解 +模板的结构: + +```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 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" 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 new file mode 100644 index 0000000..cdabc41 --- /dev/null +++ b/src/Pdf/Forms/ExistingFormFiller.php @@ -0,0 +1,378 @@ +setValue('full_name', 'Jane Roe') + * ->setValue('subscribe', 'Yes') + * ->toBytes(); + * ``` + */ +final class ExistingFormFiller +{ + private readonly PdfSource $source; + + /** @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; + } + + 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, + tu: $node->tu, + ); + } + + 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; + } + + /** + * 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; + } + + /** + * 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(); + + 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 + { + return $this->fieldTree ??= (new FieldTree())->build($this->document()); + } + + 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 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 + if ($this->catalogId === null) { + throw new \LogicException('Catalog was not imported'); + } + + return $this->catalogId; + } +} diff --git a/src/Pdf/Forms/FieldInfo.php b/src/Pdf/Forms/FieldInfo.php new file mode 100644 index 0000000..f429c94 --- /dev/null +++ b/src/Pdf/Forms/FieldInfo.php @@ -0,0 +1,31 @@ + $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, + /** 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 new file mode 100644 index 0000000..cf7ba8d --- /dev/null +++ b/src/Pdf/Forms/FieldNode.php @@ -0,0 +1,36 @@ + $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 $da, + public ?string $tu, + 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..8435f79 --- /dev/null +++ b/src/Pdf/Forms/FieldTree.php @@ -0,0 +1,222 @@ + 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; + } + + $partial = $this->decodeTextString($dict->get('T')); + $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; + $tu = $this->decodeTextString($dict->get('TU')); + + $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, + da: $da, + tu: $tu, + 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; + } + + /** + * 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) { + return $value->bytes; + } + if ($value instanceof PdfName) { + return $value->value; + } + + return ''; + } +} 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/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/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 new file mode 100644 index 0000000..87093e9 --- /dev/null +++ b/tests/Pdf/Forms/ExistingFormFillerTest.php @@ -0,0 +1,372 @@ +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 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 + { + $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); + } + + /** @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 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 + { + $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 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 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 + { + $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); + } + } +} diff --git a/tests/fixtures/forms/parent-inherited.pdf b/tests/fixtures/forms/parent-inherited.pdf new file mode 100644 index 0000000..9a06cc9 Binary files /dev/null and b/tests/fixtures/forms/parent-inherited.pdf differ