diff --git a/CHANGELOG.md b/CHANGELOG.md index c9b7d36..bed92ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All notable changes to `dskripchenko/php-pdf` are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **`ExistingFormFiller` — fill, flatten and stamp a PDF form produced + elsewhere** (contributed by Christiaan Baartse, #1). Reads the AcroForm's + fields by fully-qualified name (`fields()`), sets text, check box, radio, + combo and list box values (`setValue()`), bakes fields into the page + (`flatten()`), and places an image on a page (`stampImage()`). + - Filled fields get a generated appearance stream — the `/DA` font, size + (auto size included), colour and alignment, the `/MK` background and + border, comb cells, multi-line wrapping — so values show in every viewer. + - Values the form's fonts cannot show are drawn with an embedded TrueType + font: `useFont()`, `useFontProvider()`, or Liberation picked up + automatically when `dskripchenko/php-pdf-fonts-liberation` is installed. + - `flatten()` draws each widget's own appearance, as Acrobat does, so check + marks, backgrounds and borders survive. + - Values are validated: `/MaxLen`, radio and choice options; signature and + push-button fields refuse a value. + - Inherited page resources, the page's graphics state, `/Info`, `/ID`, the + CropBox and `/Rotate` are respected; an XFA definition is dropped once + values change. +- `ObjectImporter::withSource()` — import objects from a second document into + a graph without losing the current source's deduplication. + ## [1.9.4] — 2026-08-17 ### Changed diff --git a/docs/de/USAGE.md b/docs/de/USAGE.md index 5416139..4eb5d59 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,89 @@ Dokumentebene: `WC` (WillClose), `WS` (WillSave), `DS` (DidSave), `WP` --- +## Ein bestehendes Formular ausfüllen (AcroForm) + +Oben wird ein neues Formular erstellt. Um ein PDF zu öffnen, das jemand +anderes erzeugt hat — eine hochgeladene Vorlage, ein Behördenformular — und +die Werte seiner bereits definierten Felder per Name auszufüllen, verwenden +Sie stattdessen `ExistingFormFiller`: + +```php +use Dskripchenko\PhpPdf\Pdf\Forms\ExistingFormFiller; + +ExistingFormFiller::fromFile('template.pdf') + ->useFont('fonts/LiberationSans-Regular.ttf') // für nicht-lateinische Werte + ->setValues([ + 'full_name' => 'Erika Mustermann', + 'agree' => true, + 'employer.name' => 'Acme GmbH', // über /Parent-Vererbung aufgelöst + 'country' => 'Deutschland', // Combo-Box-Option (Exportwert oder Beschriftung) + ]) + ->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 den vollqualifizierten +Punktnamen, Typ, aktuellen Wert, zulässige Optionen, `/MaxLen` und den +`/TU`-Tooltip, sodass man den Aufbau einer Vorlage vor dem Ausfüllen +erkunden kann: + +```php +foreach (ExistingFormFiller::fromFile('template.pdf')->fields() as $name => $field) { + echo "{$name}: {$field->type} = ".json_encode($field->value)."\n"; +} +``` + +Werte je Feldtyp: + +| Typ | Akzeptierter Wert | +|---|---| +| `text`, `text-multiline` | ein String; `/MaxLen` wird durchgesetzt | +| `checkbox` | `true`/`false`, `on`/`yes`/`true`/`1`, `off`/`no`/`false`/`0` oder sein Exportwert | +| `radio` | eine der `options` (ohne Groß-/Kleinschreibung) oder `Off` | +| `combo`, `list` | Exportwert oder Beschriftung einer Option; eine Liste davon bei Mehrfachauswahl | +| `signature`, `push` | nicht setzbar — `LogicException` | + +Eine unbekannte Option wird mit `InvalidArgumentException` abgelehnt, statt +stillschweigend ignoriert zu werden. + +Ausgefüllte Text- und Auswahlfelder erhalten einen neu erzeugten +Appearance-Stream — Schrift, Größe (inklusive Auto-Größe), Farbe und +Ausrichtung aus `/DA`, Hintergrund und Rahmen aus `/MK`, Comb-Zellen, +mehrzeiliger Umbruch —, sodass der Wert in jedem Viewer erscheint, nicht nur +in solchen, die `/NeedAppearances` beachten. Kann die formulareigene Schrift +einen Wert nicht darstellen (Kyrillisch, Griechisch, CJK, …), wird er mit +einer eingebetteten TrueType-Schrift gezeichnet: Übergeben Sie eine an +`useFont()` (oder einen `FontProvider` an `useFontProvider()`), oder +installieren Sie `dskripchenko/php-pdf-fonts-liberation`, das dann +automatisch verwendet wird. Ohne Schrift löst ein solcher Wert eine +`RuntimeException` mit dem Feldnamen aus. + +`flatten(?array $fieldNames = null)` macht Felder so zu statischem +Seiteninhalt, wie Acrobat es tut: Das eigene Erscheinungsbild jedes Widgets +— Häkchen, Radio-Punkte, Hintergründe, Rahmen — wird auf die Seite gezeichnet +und das Widget entfernt. Übergeben Sie eine Namensliste, um nur einen Teil +zu glätten und den Rest interaktiv zu lassen, oder lassen Sie sie weg, um +alle Widgets zu glätten und `/AcroForm` zu entfernen. + +`stampImage()`/`stampImageBytes()` platzieren ein PNG (mit Alpha) oder JPEG +unabhängig von Feldern — nützlich für eine Unterschrift oder ein Foto, das +selbst kein Formularfeld ist. `x`/`y` ist die linke obere Ecke des Bildes in +Punkt, gemessen von der linken oberen Ecke der Seite *so, wie ein Viewer sie +zeigt* (CropBox, nach `/Rotate`); `pageSize($index)` liefert diese Größe. + +Die Ausgabe ist eine vollständige, unverschlüsselte Neuschreibung des +Dokuments. Bestehende digitale Signaturen sind danach nicht mehr gültig, und +sobald sich etwas ändert, wird eine XFA-Formulardefinition entfernt +(XFA-fähige Viewer würden sonst die XFA-Ebene statt der ausgefüllten Werte +anzeigen). + +--- + ## Annotationen ```php diff --git a/docs/en/USAGE.md b/docs/en/USAGE.md index 840ca20..3ac82a4 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,84 @@ 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') + ->useFont('fonts/LiberationSans-Regular.ttf') // for non-Latin values + ->setValues([ + 'full_name' => 'Jane Roe', + 'agree' => true, + 'employer.name' => 'Acme Corp', // resolved via /Parent inheritance + 'country' => 'Germany', // combo box option (export value or label) + ]) + ->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, accepted +options, `/MaxLen` and `/TU` tooltip, 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} = ".json_encode($field->value)."\n"; +} +``` + +Values by field type: + +| Type | Accepted value | +|---|---| +| `text`, `text-multiline` | a string; `/MaxLen` is enforced | +| `checkbox` | `true`/`false`, `on`/`yes`/`true`/`1`, `off`/`no`/`false`/`0`, or its export value | +| `radio` | one of `options` (case insensitive), or `Off` | +| `combo`, `list` | an option's export value or its label; a list of them for a multi-select list box | +| `signature`, `push` | cannot be set — `LogicException` | + +An unknown option is rejected with an `InvalidArgumentException` rather than +silently ignored. + +Filled text and choice fields get a freshly generated appearance stream — +the field's `/DA` font, size (auto size included), colour and alignment, its +`/MK` background and border, comb cells, multi-line wrapping — so the value +shows in every viewer, not only those that honour `/NeedAppearances`. When +the form's own font cannot show a value (Cyrillic, Greek, CJK, …), the +value is drawn with an embedded TrueType font: pass one to `useFont()` (or a +`FontProvider` to `useFontProvider()`), or install +`dskripchenko/php-pdf-fonts-liberation`, which is then picked up +automatically. Without one, such a value throws a `RuntimeException` naming +the field. + +`flatten(?array $fieldNames = null)` turns fields into static page content +the way Acrobat does: each widget's own appearance — check marks, radio +dots, backgrounds, borders — is drawn on the page and the widget is +removed. Pass a list of names to flatten a subset and leave the rest +interactive, or omit it to flatten every widget and drop `/AcroForm`. + +`stampImage()`/`stampImageBytes()` place a PNG (with alpha) or JPEG, +independent of any field — useful for a signature or photo that isn't +itself a form field. `x`/`y` is the image's top-left corner in points from +the top-left of the page *as a viewer shows it* (the CropBox, after +`/Rotate`); `pageSize($index)` returns that size. + +The output is a full rewrite of the document, unencrypted. Existing digital +signatures no longer verify, and once anything changes an XFA form +definition is removed (XFA-aware viewers would otherwise show the XFA layer +instead of the filled values). + +--- + ## Annotations ```php diff --git a/docs/ru/USAGE.md b/docs/ru/USAGE.md index 2ffa2b1..77ceac4 100644 --- a/docs/ru/USAGE.md +++ b/docs/ru/USAGE.md @@ -24,6 +24,7 @@ - [SVG](#svg) - [Гиперссылки и закладки](#гиперссылки-и-закладки) - [Формы (AcroForm)](#формы-acroform) +- [Заполнение существующей формы (AcroForm)](#заполнение-существующей-формы-acroform) - [Аннотации](#аннотации) - [Шифрование](#шифрование) - [Цифровая подпись](#цифровая-подпись) @@ -519,6 +520,86 @@ JavaScript-хуки на уровне поля: `keystrokeScript`, `validateScri --- +## Заполнение существующей формы (AcroForm) + +Выше описано создание новой формы. Чтобы открыть PDF, созданный кем-то +другим — загруженный шаблон, государственную форму — и заполнить значения +уже определённых в нём полей по имени, используйте вместо этого +`ExistingFormFiller`: + +```php +use Dskripchenko\PhpPdf\Pdf\Forms\ExistingFormFiller; + +ExistingFormFiller::fromFile('template.pdf') + ->useFont('fonts/LiberationSans-Regular.ttf') // для кириллицы и прочего не-Latin + ->setValues([ + 'full_name' => 'Иван Петров', + 'agree' => true, + 'employer.name' => 'ООО «Ромашка»', // разрешается через наследование /Parent + 'country' => 'Россия', // вариант combo box (экспортное значение или подпись) + ]) + ->stampImage(0, 'signature.png', x: 100, y: 600, width: 120, height: 40) + ->flatten() + ->toFile('filled.pdf'); +``` + +Исходный документ никогда не изменяется — каждый вызов работает с глубокой +копией *всего* графа объектов, поэтому всё, что не затронуто (оглавление, +метаданные, прочие аннотации), переходит в результат без изменений. +`fields()` возвращает для каждого поля полное имя через точку, тип, текущее +значение, допустимые варианты, `/MaxLen` и подсказку `/TU` — так можно +изучить устройство шаблона до заполнения: + +```php +foreach (ExistingFormFiller::fromFile('template.pdf')->fields() as $name => $field) { + echo "{$name}: {$field->type} = ".json_encode($field->value, JSON_UNESCAPED_UNICODE)."\n"; +} +``` + +Значения по типам полей: + +| Тип | Что принимает | +|---|---| +| `text`, `text-multiline` | строку; `/MaxLen` соблюдается | +| `checkbox` | `true`/`false`, `on`/`yes`/`true`/`1`, `off`/`no`/`false`/`0` или своё экспортное значение | +| `radio` | один из `options` (без учёта регистра) или `Off` | +| `combo`, `list` | экспортное значение варианта или его подпись; для list box с множественным выбором — список | +| `signature`, `push` | задать нельзя — `LogicException` | + +Неизвестный вариант отклоняется с `InvalidArgumentException`, а не +игнорируется молча. + +Заполненные текстовые поля и поля выбора получают заново сгенерированный +внешний вид — шрифт, размер (включая авторазмер), цвет и выравнивание из +`/DA`, фон и рамку из `/MK`, comb-ячейки, перенос строк — поэтому значение +видно в любом просмотрщике, а не только в тех, что учитывают +`/NeedAppearances`. Если собственный шрифт формы не может показать значение +(кириллица, греческий, CJK…), оно рисуется встроенным TrueType-шрифтом: +передайте его в `useFont()` (или `FontProvider` в `useFontProvider()`), либо +установите `dskripchenko/php-pdf-fonts-liberation` — тогда он подхватится +автоматически. Без шрифта такое значение приводит к `RuntimeException` с +именем поля. + +`flatten(?array $fieldNames = null)` превращает поля в статичное содержимое +страницы так же, как это делает Acrobat: собственный внешний вид каждого +виджета — галочки, точки радиокнопок, фон, рамки — рисуется на странице, а +виджет удаляется. Передайте список имён, чтобы «запечь» только часть полей и +оставить остальные интерактивными, или не передавайте ничего — тогда +обрабатываются все виджеты и `/AcroForm` удаляется. + +`stampImage()`/`stampImageBytes()` ставят PNG (с альфа-каналом) или JPEG +независимо от полей — например, подпись или фото, которые сами полем формы +не являются. `x`/`y` — левый верхний угол картинки в пунктах от левого +верхнего угла страницы *в том виде, как её показывает просмотрщик* (CropBox, +с учётом `/Rotate`); этот размер возвращает `pageSize($index)`. + +Результат — полностью пересобранный документ без шифрования. Существующие +цифровые подписи перестают проходить проверку, а при любом изменении +удаляется XFA-описание формы (иначе просмотрщики с поддержкой XFA показали бы +XFA-слой вместо заполненных значений). + +--- + ## Аннотации ```php diff --git a/docs/zh/USAGE.md b/docs/zh/USAGE.md index 8c4b4a0..57e84d8 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') + ->useFont('fonts/NotoSansSC-Regular.ttf') // 用于非拉丁文字的值 + ->setValues([ + 'full_name' => '张伟', + 'agree' => true, + 'employer.name' => 'Acme Corp', // 通过 /Parent 继承解析 + 'country' => 'China', // 组合框选项(导出值或显示文本) + ]) + ->stampImage(0, 'signature.png', x: 100, y: 600, width: 120, height: 40) + ->flatten() + ->toFile('filled.pdf'); +``` + +源文档永远不会被修改——每次调用都作用于*整个*对象图的深拷贝,因此未触及的内容(书签、元数据、其他注释)会原样保留到输出中。`fields()` 返回每个字段以点分隔的完整名称、类型、当前值、可接受的选项、`/MaxLen` 以及 `/TU` 提示,便于在填写前了解模板结构: + +```php +foreach (ExistingFormFiller::fromFile('template.pdf')->fields() as $name => $field) { + echo "{$name}: {$field->type} = ".json_encode($field->value, JSON_UNESCAPED_UNICODE)."\n"; +} +``` + +各字段类型接受的值: + +| 类型 | 可接受的值 | +|---|---| +| `text`、`text-multiline` | 字符串;会校验 `/MaxLen` | +| `checkbox` | `true`/`false`、`on`/`yes`/`true`/`1`、`off`/`no`/`false`/`0`,或其导出值 | +| `radio` | `options` 之一(不区分大小写),或 `Off` | +| `combo`、`list` | 选项的导出值或显示文本;多选列表框可传入数组 | +| `signature`、`push` | 不可设置——抛出 `LogicException` | + +未知选项会抛出 `InvalidArgumentException`,而不会被静默忽略。 + +填写后的文本字段和选择字段会获得新生成的外观流——使用 `/DA` 中的字体、字号(含自动字号)、颜色和对齐方式,`/MK` 中的背景和边框,以及梳状单元格和多行换行——因此值在任何阅读器中都能显示,而不仅限于支持 `/NeedAppearances` 的阅读器。当表单自带字体无法显示某个值(西里尔文、希腊文、中日韩文字等)时,会改用嵌入的 TrueType 字体绘制:可通过 `useFont()` 传入字体(或通过 `useFontProvider()` 传入 `FontProvider`),也可以安装 `dskripchenko/php-pdf-fonts-liberation`,届时会自动使用。若没有可用字体,此类值会抛出带有字段名的 `RuntimeException`。 + +`flatten(?array $fieldNames = null)` 以 Acrobat 的方式把字段变为静态页面内容:将每个控件自身的外观(勾选标记、单选圆点、背景、边框)绘制到页面上,并移除该控件。传入名称列表可只扁平化部分字段、保留其余字段可交互;省略参数则扁平化所有控件并删除 `/AcroForm`。 + +`stampImage()`/`stampImageBytes()` 可在任意字段之外放置 PNG(含透明通道)或 JPEG——适用于本身不是表单字段的签名或照片。`x`/`y` 为图像左上角,以点为单位,从*阅读器所显示页面*(CropBox,已应用 `/Rotate`)的左上角起算;`pageSize($index)` 返回该尺寸。 + +输出是对文档的完整重写,且不加密。已有的数字签名将不再有效;一旦有任何改动,XFA 表单定义会被移除(否则支持 XFA 的阅读器会显示 XFA 层而不是已填写的值)。 + +--- + ## 标注 ```php diff --git a/scripts/conformance/generate.php b/scripts/conformance/generate.php index a7fbd65..9f08ccf 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,31 @@ $failures += $ok ? 0 : 1; } +// Visual fixture for ExistingFormFiller: a small template with Latin and +// Cyrillic text fields and a checkbox, filled, flattened, and stamped with an +// 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('text', 'city', 72, 670, 250, 20, defaultValue: ''); +$templatePage->addFormField('checkbox', 'agree', 72, 640, 14, 14); + +$formFillOk = true; +try { + $bytes = ExistingFormFiller::fromBytes($template->toBytes()) + ->useFont($fontDir.'/LiberationSans-Regular.ttf') + ->setValue('full_name', 'Jane Roe') + ->setValue('city', 'Москва') + ->setValue('agree', true) + ->flatten() + ->stampImage(0, $root.'/tests/fixtures/sample.png', 72, 200, 80, 60) + ->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/scripts/fixtures/generate-standard-font-metrics.php b/scripts/fixtures/generate-standard-font-metrics.php new file mode 100644 index 0000000..1100b38 --- /dev/null +++ b/scripts/fixtures/generate-standard-font-metrics.php @@ -0,0 +1,87 @@ + 'LiberationSans-Regular', + 'Helvetica-Bold' => 'LiberationSans-Bold', + 'Helvetica-Oblique' => 'LiberationSans-Italic', + 'Helvetica-BoldOblique' => 'LiberationSans-BoldItalic', + 'Times-Roman' => 'LiberationSerif-Regular', + 'Times-Bold' => 'LiberationSerif-Bold', + 'Times-Italic' => 'LiberationSerif-Italic', + 'Times-BoldItalic' => 'LiberationSerif-BoldItalic', +]; + +$rows = []; +foreach ($map as $base14 => $file) { + $ttf = TtfFile::fromFile("{$dir}/{$file}.ttf"); + $upem = $ttf->unitsPerEm(); + $widths = []; + for ($code = 32; $code <= 255; $code++) { + $cp = WinAnsi::codepoint($code); + $gid = $cp === null ? 0 : $ttf->glyphIdForChar($cp); + $widths[] = $gid === 0 ? 0 : (int) round($ttf->advanceWidth($gid) * 1000 / $upem); + } + $rows[] = sprintf(" '%s' => [%s],", $base14, implode(',', $widths)); +} + +$body = <<= 32 && \$code <= 255 ? \$table[\$code - 32] : 0; + } +} + +PHP; + +$out = __DIR__.'/../../src/Pdf/Forms/Appearance/StandardFontMetrics.php'; +file_put_contents($out, sprintf($body, implode("\n", $rows))); +echo "Wrote {$out}\n"; diff --git a/src/Pdf/Forms/Appearance/AppearanceBuilder.php b/src/Pdf/Forms/Appearance/AppearanceBuilder.php new file mode 100644 index 0000000..f4eb8b8 --- /dev/null +++ b/src/Pdf/Forms/Appearance/AppearanceBuilder.php @@ -0,0 +1,392 @@ + 846, 'l' => 791, '8' => 759, 'u' => 759, 'n' => 761, 'H' => 816]; + + public function __construct( + private readonly FormGraph $graph, + private readonly FontResolver $fonts, + ) { + } + + /** + * The normal appearance of a text or choice widget showing its current + * value, or null when the widget has no usable `/Rect`. + */ + public function variableText(FieldAttributes $field, PdfDictionary $widget, ?PdfDictionary $dr, string $fieldName): ?PdfReference + { + $box = $this->box($widget); + if ($box === null) { + return null; + } + [$w, $h, $matrix] = $box; + $mk = $this->graph->dict($widget->get('MK')); + $border = $this->borderWidth($widget, $mk); + $da = DefaultAppearance::parse($field->da); + + $isList = $field->ft === 'Ch' && !$field->has(FieldAttributes::FF_COMBO); + $text = $isList ? '' : $field->displayValue(); + if ($field->has(FieldAttributes::FF_PASSWORD)) { + $text = str_repeat('*', mb_strlen($text, 'UTF-8')); + } + $sample = (string) preg_replace('/[\r\n]+/', '', $isList ? implode('', array_column($field->options, 1)) : $text); + $font = $this->fonts->resolve($da->fontName, $dr, $sample, $fieldName); + + $ops = $this->decoration($w, $h, $mk, $widget, $border); + $ops[] = '/Tx BMC'; + $ops[] = 'q'; + $pad = max(2.0, 2 * $border); + $ops[] = sprintf('%s %s %s %s re W n', self::num($border), self::num($border), self::num(max(0.0, $w - 2 * $border)), self::num(max(0.0, $h - 2 * $border))); + + if ($isList) { + array_push($ops, ...$this->listBox($field, $font, $da, $w, $h, $pad)); + } elseif ($text !== '') { + $textOps = match (true) { + $field->ft === 'Tx' && $field->has(FieldAttributes::FF_MULTILINE) => $this->multiLine($field, $font, $da, $text, $w, $h, $pad), + $field->ft === 'Tx' && $field->has(FieldAttributes::FF_COMB) && ($field->maxLength ?? 0) > 0 => $this->comb($field, $font, $da, $text, $w, $h, $border), + default => $this->singleLine($field, $font, $da, $text, $w, $h, $pad, $border), + }; + array_push($ops, ...$textOps); + } + $ops[] = 'Q'; + $ops[] = 'EMC'; + + return $this->formXObject($w, $h, $matrix, implode("\n", $ops), ['Font' => new PdfDictionary(['F0' => $font->reference()])]); + } + + /** + * `[onAppearance, offAppearance]` for a check box or radio button without + * an appearance of its own. + * + * @return array{PdfReference,PdfReference}|null + */ + public function button(PdfDictionary $widget, bool $radio): ?array + { + $box = $this->box($widget); + if ($box === null) { + return null; + } + [$w, $h, $matrix] = $box; + $mk = $this->graph->dict($widget->get('MK')); + $border = $this->borderWidth($widget, $mk); + $caption = $mk?->get('CA') instanceof PdfString ? $mk->get('CA')->bytes : ''; + $mark = $caption !== '' ? $caption[0] : ($radio ? 'l' : '4'); + + $decoration = $this->decoration($w, $h, $mk, $widget, $border); + $size = max(1.0, (min($w, $h) - 2 * $border) * 0.8); + $markWidth = (self::DINGBAT_WIDTHS[$mark] ?? 800) * $size / 1000; + $on = $decoration; + $on[] = 'q'; + $on[] = 'BT'; + $on[] = sprintf('/ZaDb %s Tf', self::num($size)); + $on[] = $this->color($mk?->get('BC'), fill: true) ?? '0 g'; + $on[] = sprintf('%s %s Td', self::num(($w - $markWidth) / 2), self::num(($h - 0.705 * $size) / 2)); + $on[] = sprintf('(%s) Tj', addcslashes($mark, '()\\')); + $on[] = 'ET'; + $on[] = 'Q'; + $resources = ['Font' => new PdfDictionary(['ZaDb' => $this->fonts->dingbats()])]; + + return [ + $this->formXObject($w, $h, $matrix, implode("\n", $on), $resources), + $this->formXObject($w, $h, $matrix, implode("\n", $decoration), []), + ]; + } + + /** @return list */ + private function singleLine(FieldAttributes $field, AppearanceFont $font, DefaultAppearance $da, string $text, float $w, float $h, float $pad, float $border): array + { + $size = $da->fontSize; + if ($size <= 0) { + $size = ($h - 2 * $border - 2) / self::LINE_HEIGHT; + $unit = $font->width($text, 1.0); + if ($unit > 0) { + $size = min($size, ($w - 2 * $pad) / $unit); + } + $size = max(self::AUTO_MIN, min($size, $h)); + } + $width = $font->width($text, $size); + $x = match ($field->quadding) { + 1 => ($w - $width) / 2, + 2 => $w - $pad - $width, + default => $pad, + }; + $y = ($h - $font->capHeight() / 1000 * $size) / 2; + + return [ + 'BT', + sprintf('/F0 %s Tf', self::num($size)), + $da->color, + sprintf('%s %s Td', self::num($x), self::num($y)), + $font->encode($text).' Tj', + 'ET', + ]; + } + + /** @return list */ + private function multiLine(FieldAttributes $field, AppearanceFont $font, DefaultAppearance $da, string $text, float $w, float $h, float $pad): array + { + $size = $da->fontSize; + $lines = []; + if ($size <= 0) { + for ($size = self::AUTO_MAX; $size > self::AUTO_MIN; $size -= 0.5) { + $lines = $this->wrap($font, $text, $size, $w - 2 * $pad); + if (count($lines) * $size * self::LINE_HEIGHT <= $h - 2 * $pad) { + break; + } + } + } + if ($lines === []) { + $lines = $this->wrap($font, $text, $size, $w - 2 * $pad); + } + + $leading = $size * self::LINE_HEIGHT; + $ops = ['BT', sprintf('/F0 %s Tf', self::num($size)), $da->color]; + $y = $h - $pad - $font->ascent() / 1000 * $size; + foreach ($lines as $line) { + $width = $font->width($line, $size); + $x = match ($field->quadding) { + 1 => ($w - $width) / 2, + 2 => $w - $pad - $width, + default => $pad, + }; + $ops[] = sprintf('1 0 0 1 %s %s Tm', self::num($x), self::num($y)); + $ops[] = $font->encode($line).' Tj'; + $y -= $leading; + } + $ops[] = 'ET'; + + return $ops; + } + + /** @return list */ + private function comb(FieldAttributes $field, AppearanceFont $font, DefaultAppearance $da, string $text, float $w, float $h, float $border): array + { + $cells = (int) $field->maxLength; + $cell = $w / $cells; + $chars = array_slice(mb_str_split($text, 1, 'UTF-8'), 0, $cells); + $size = $da->fontSize; + if ($size <= 0) { + $size = max(self::AUTO_MIN, min(($h - 2 * $border - 2) / self::LINE_HEIGHT, $cell * 1.2)); + } + $y = ($h - $font->capHeight() / 1000 * $size) / 2; + $ops = ['BT', sprintf('/F0 %s Tf', self::num($size)), $da->color]; + foreach ($chars as $i => $char) { + $x = $i * $cell + ($cell - $font->width($char, $size)) / 2; + $ops[] = sprintf('1 0 0 1 %s %s Tm', self::num($x), self::num($y)); + $ops[] = $font->encode($char).' Tj'; + } + $ops[] = 'ET'; + + return $ops; + } + + /** @return list */ + private function listBox(FieldAttributes $field, AppearanceFont $font, DefaultAppearance $da, float $w, float $h, float $pad): array + { + $size = $da->fontSize > 0 ? $da->fontSize : self::AUTO_MAX; + $leading = $size * self::LINE_HEIGHT; + $selected = is_array($field->value) ? $field->value : [$field->value]; + $ops = []; + $y = $h - $pad; + foreach (array_slice($field->options, $field->topIndex) as [$export, $display]) { + if ($y - $leading < 0) { + break; + } + if (in_array($export, $selected, true)) { + $ops[] = sprintf('0.6 0.75 0.875 rg 0 %s %s %s re f', self::num($y - $leading), self::num($w), self::num($leading)); + } + $ops[] = 'BT'; + $ops[] = sprintf('/F0 %s Tf', self::num($size)); + $ops[] = $da->color; + $ops[] = sprintf('%s %s Td', self::num($pad), self::num($y - $leading + ($leading - $font->capHeight() / 1000 * $size) / 2)); + $ops[] = $font->encode($display).' Tj'; + $ops[] = 'ET'; + $y -= $leading; + } + + return $ops; + } + + /** @return list */ + private function wrap(AppearanceFont $font, string $text, float $size, float $maxWidth): array + { + $lines = []; + foreach (preg_split('/\r\n|\r|\n/', $text) ?: [] as $paragraph) { + $line = ''; + foreach (preg_split('/(\s+)/u', $paragraph, -1, PREG_SPLIT_DELIM_CAPTURE) ?: [] as $token) { + $candidate = $line.$token; + if ($line === '' || $font->width(rtrim($candidate), $size) <= $maxWidth) { + $line = $candidate; + continue; + } + $lines[] = rtrim($line); + $line = ltrim($token); + } + // Hard-break a word that alone is wider than the field. + while ($line !== '' && $font->width($line, $size) > $maxWidth && mb_strlen($line, 'UTF-8') > 1) { + $chars = mb_str_split($line, 1, 'UTF-8'); + $head = ''; + foreach ($chars as $char) { + if ($head !== '' && $font->width($head.$char, $size) > $maxWidth) { + break; + } + $head .= $char; + } + $lines[] = $head; + $line = mb_substr($line, mb_strlen($head, 'UTF-8'), null, 'UTF-8'); + } + $lines[] = rtrim($line); + } + + return $lines; + } + + /** + * Background and border from `/MK` and `/BS` (§12.5.6.19, §12.5.4). + * + * @return list + */ + private function decoration(float $w, float $h, ?PdfDictionary $mk, PdfDictionary $widget, float $border): array + { + $ops = []; + $bg = $this->color($mk?->get('BG'), fill: true); + if ($bg !== null) { + $ops[] = sprintf('%s 0 0 %s %s re f', $bg, self::num($w), self::num($h)); + } + $bc = $this->color($mk?->get('BC'), fill: false); + if ($bc !== null && $border > 0) { + $style = $this->graph->dict($widget->get('BS'))?->get('S'); + $style = $style instanceof PdfName ? $style->value : 'S'; + $half = $border / 2; + $ops[] = sprintf('%s %s w', $bc, self::num($border)); + if ($style === 'D') { + $ops[] = '[3] 0 d'; + } + $ops[] = $style === 'U' + ? sprintf('0 %s m %s %s l S', self::num($half), self::num($w), self::num($half)) + : sprintf('%s %s %s %s re S', self::num($half), self::num($half), self::num($w - $border), self::num($h - $border)); + } + + return $ops; + } + + private function borderWidth(PdfDictionary $widget, ?PdfDictionary $mk): float + { + $bs = $this->graph->dict($widget->get('BS')); + $width = $bs !== null ? $this->graph->resolve($bs->get('W')) : null; + if ($width === null) { + $legacy = $this->graph->array($widget->get('Border')); + $width = $legacy !== null && count($legacy) >= 3 ? $this->graph->resolve($legacy[2]) : null; + } + if (!is_int($width) && !is_float($width)) { + $width = $mk?->has('BC') ? 1 : 0; + } + + return max(0.0, (float) $width); + } + + private function color(mixed $value, bool $fill): ?string + { + $components = $this->graph->array($value); + if ($components === null) { + return null; + } + $components = array_map(fn ($c) => $this->graph->resolve($c), $components); + if (array_filter($components, static fn ($c) => is_int($c) || is_float($c)) !== $components) { + return null; + } + $operator = match (count($components)) { + 1 => 'g', + 3 => 'rg', + 4 => 'k', + default => null, + }; + if ($operator === null) { + return null; + } + + return implode(' ', array_map(fn ($c) => self::num((float) $c), $components)).' '.($fill ? $operator : strtoupper($operator)); + } + + /** + * Width, height and matrix of the appearance box: `/MK /R` rotates the + * content, so a 90° field lays its text along the rectangle's height. + * + * @return array{float,float,list|null}|null + */ + private function box(PdfDictionary $widget): ?array + { + $rect = $this->graph->array($widget->get('Rect')); + if ($rect === null || count($rect) !== 4) { + return null; + } + $rect = array_map(fn ($v) => $this->graph->resolve($v), $rect); + if (array_filter($rect, static fn ($v) => is_int($v) || is_float($v)) !== $rect) { + return null; + } + $w = abs((float) $rect[2] - (float) $rect[0]); + $h = abs((float) $rect[3] - (float) $rect[1]); + if ($w <= 0 || $h <= 0) { + return null; + } + $rotation = $this->graph->resolve($this->graph->dict($widget->get('MK'))?->get('R')); + $rotation = is_int($rotation) ? (($rotation % 360) + 360) % 360 : 0; + + return match ($rotation) { + 90 => [$h, $w, [0.0, 1.0, -1.0, 0.0, $w, 0.0]], + 180 => [$w, $h, [-1.0, 0.0, 0.0, -1.0, $w, $h]], + 270 => [$h, $w, [0.0, -1.0, 1.0, 0.0, 0.0, $h]], + default => [$w, $h, null], + }; + } + + /** + * @param list|null $matrix + * @param array $resources + */ + private function formXObject(float $w, float $h, ?array $matrix, string $content, array $resources): PdfReference + { + $items = [ + 'Type' => new PdfName('XObject'), + 'Subtype' => new PdfName('Form'), + 'BBox' => [0.0, 0.0, $w, $h], + 'Resources' => new PdfDictionary($resources), + ]; + if ($matrix !== null) { + $items['Matrix'] = $matrix; + } + + return $this->graph->allocateStream(new PdfDictionary($items), $content); + } + + private static function num(float $v): string + { + return FormGraph::number($v, 3); + } +} diff --git a/src/Pdf/Forms/Appearance/AppearanceFont.php b/src/Pdf/Forms/Appearance/AppearanceFont.php new file mode 100644 index 0000000..1c7e159 --- /dev/null +++ b/src/Pdf/Forms/Appearance/AppearanceFont.php @@ -0,0 +1,33 @@ += 2) { + $fontName = ltrim((string) $operands[count($operands) - 2], '/'); + $fontSize = (float) $operands[count($operands) - 1]; + } + break; + case 'g': + case 'rg': + case 'k': + $arity = ['g' => 1, 'rg' => 3, 'k' => 4][$token]; + $values = array_slice($operands, -$arity); + if (count($values) === $arity && array_filter($values, 'is_numeric') === $values) { + $color = implode(' ', $values).' '.$token; + } + break; + default: + $operands[] = $token; + continue 2; + } + $operands = []; + } + + return new self($fontName !== '' ? $fontName : null, max(0.0, $fontSize), $color); + } +} diff --git a/src/Pdf/Forms/Appearance/FontResolver.php b/src/Pdf/Forms/Appearance/FontResolver.php new file mode 100644 index 0000000..1c61dbd --- /dev/null +++ b/src/Pdf/Forms/Appearance/FontResolver.php @@ -0,0 +1,225 @@ + 'Helvetica', 'HeBo' => 'Helvetica-Bold', 'HeOb' => 'Helvetica-Oblique', + 'HeBO' => 'Helvetica-BoldOblique', 'TiRo' => 'Times-Roman', 'TiBo' => 'Times-Bold', + 'TiIt' => 'Times-Italic', 'TiBI' => 'Times-BoldItalic', 'Cour' => 'Courier', + 'CoBo' => 'Courier-Bold', 'CoOb' => 'Courier-Oblique', 'CoBO' => 'Courier-BoldOblique', + ]; + + /** @var array */ + private array $standard = []; + + /** @var array */ + private array $unicode = []; + + public function __construct( + private readonly FormGraph $graph, + private readonly ?TtfFile $font = null, + private readonly ?FontProvider $provider = null, + ) { + } + + public function resolve(?string $daFontName, ?PdfDictionary $dr, string $text, string $fieldName): AppearanceFont + { + $fontRaw = $daFontName !== null ? $this->graph->dict($dr?->get('Font'))?->get($daFontName) : null; + $fontDict = $this->graph->dict($fontRaw); + $baseFont = $fontDict?->get('BaseFont') instanceof PdfName ? $fontDict->get('BaseFont')->value : null; + $style = $this->style($baseFont ?? ($daFontName !== null ? (self::ALIASES[$daFontName] ?? $daFontName) : 'Helvetica')); + + if ($fontDict !== null) { + $own = $this->simpleFromDict($fontRaw, $fontDict, $baseFont); + if ($own !== null && $own->canShow($text)) { + return $own; + } + } + + $standard = $this->standardFont($style); + if ($standard->canShow($text)) { + return $standard; + } + + $unicode = $this->unicodeFont($style); + if ($unicode === null) { + throw new \RuntimeException(sprintf( + 'Field "%s": the value needs a Unicode font the form does not provide. ' + .'Pass one to ExistingFormFiller::useFont(), or install dskripchenko/php-pdf-fonts-liberation.', + $fieldName, + )); + } + if (!$unicode->canShow($text)) { + throw new \RuntimeException(sprintf('Field "%s": the configured font has no glyphs for part of the value.', $fieldName)); + } + + return $unicode; + } + + /** A ZapfDingbats font for generated check-box and radio marks. */ + public function dingbats(): PdfReference + { + return $this->standardFont('ZapfDingbats')->reference(); + } + + /** Embed every TrueType subset used so far. */ + public function embed(): void + { + foreach ($this->unicode as $font) { + $font->embed(); + } + } + + private function simpleFromDict(mixed $raw, PdfDictionary $dict, ?string $baseFont): ?SimpleFont + { + $subtype = $dict->get('Subtype') instanceof PdfName ? $dict->get('Subtype')->value : null; + if (!in_array($subtype, ['Type1', 'TrueType', 'MMType1'], true) || $baseFont === null) { + return null; + } + // A subset only carries the glyphs of the text it was made for. + if (preg_match('/^[A-Z]{6}\+/', $baseFont) === 1) { + return null; + } + + $encoding = $this->graph->resolve($dict->get('Encoding')); + if ($encoding instanceof PdfDictionary) { + if ($encoding->has('Differences')) { + return null; + } + $encoding = $encoding->get('BaseEncoding'); + } + $encodingName = $encoding instanceof PdfName ? $encoding->value : null; + if ($encodingName !== null && $encodingName !== 'WinAnsiEncoding') { + return null; + } + + $reference = $raw instanceof PdfReference ? $raw : $this->graph->allocate($dict); + $widths = $this->graph->array($dict->get('Widths')); + $firstChar = $this->graph->resolve($dict->get('FirstChar')); + if ($widths !== null && is_int($firstChar)) { + $map = []; + foreach ($widths as $i => $w) { + $w = $this->graph->resolve($w); + if (is_int($w) || is_float($w)) { + $map[$firstChar + $i] = (float) $w; + } + } + + return new SimpleFont($reference, $baseFont, $map, asciiOnly: $encodingName === null); + } + if (StandardFontMetrics::has($baseFont)) { + return new SimpleFont($reference, $baseFont, asciiOnly: $encodingName === null); + } + + return null; + } + + private function standardFont(string $baseFont): SimpleFont + { + if (isset($this->standard[$baseFont])) { + return $this->standard[$baseFont]; + } + $items = [ + 'Type' => new PdfName('Font'), + 'Subtype' => new PdfName('Type1'), + 'BaseFont' => new PdfName($baseFont), + ]; + if ($baseFont !== 'ZapfDingbats') { + $items['Encoding'] = new PdfName('WinAnsiEncoding'); + } + + return $this->standard[$baseFont] = new SimpleFont($this->graph->allocate(new PdfDictionary($items)), $baseFont); + } + + private function unicodeFont(string $style): ?UnicodeFont + { + $ttf = $this->font; + if ($ttf === null) { + $family = match (true) { + str_starts_with($style, 'Times') => 'LiberationSerif', + str_starts_with($style, 'Courier') => 'LiberationMono', + default => 'LiberationSans', + }; + $variant = match (true) { + str_ends_with($style, 'BoldOblique'), str_ends_with($style, 'BoldItalic') => 'BoldItalic', + str_ends_with($style, 'Bold') => 'Bold', + str_ends_with($style, 'Oblique'), str_ends_with($style, 'Italic') => 'Italic', + default => 'Regular', + }; + $provider = $this->provider ?? $this->defaultProvider(); + $ttf = $provider?->resolve("{$family}-{$variant}") ?? $provider?->resolve("{$family}-Regular"); + } + if ($ttf === null) { + return null; + } + $key = spl_object_hash($ttf); + + return $this->unicode[$key] ??= new UnicodeFont($this->graph, $ttf); + } + + private function defaultProvider(): ?FontProvider + { + $class = 'Dskripchenko\\PhpPdfFontsLiberation\\LiberationFontProvider'; + if (!class_exists($class)) { + return null; + } + $provider = new $class(); + + return $provider instanceof FontProvider ? $provider : null; + } + + /** The base-14 font matching a font name's family and weight. */ + private function style(string $name): string + { + $name = (string) preg_replace('/^[A-Z]{6}\+/', '', $name); + if (isset(self::ALIASES[$name])) { + return self::ALIASES[$name]; + } + $family = match (true) { + preg_match('/times|serif|roman|georgia|garamond|cambria|libertine|book/i', $name) === 1 + && preg_match('/sans/i', $name) !== 1 => 'Times', + preg_match('/courier|mono|consol/i', $name) === 1 => 'Courier', + default => 'Helvetica', + }; + $bold = preg_match('/bold|black|heavy|semibold|,?bd\b/i', $name) === 1; + $italic = preg_match('/italic|oblique/i', $name) === 1; + + return match ($family) { + 'Times' => 'Times-'.match (true) { + $bold && $italic => 'BoldItalic', + $bold => 'Bold', + $italic => 'Italic', + default => 'Roman', + }, + default => $family.match (true) { + $bold && $italic => '-BoldOblique', + $bold => '-Bold', + $italic => '-Oblique', + default => '', + }, + }; + } +} diff --git a/src/Pdf/Forms/Appearance/SimpleFont.php b/src/Pdf/Forms/Appearance/SimpleFont.php new file mode 100644 index 0000000..e5358d7 --- /dev/null +++ b/src/Pdf/Forms/Appearance/SimpleFont.php @@ -0,0 +1,86 @@ +|null $widths code → width (1/1000 em) from the + * font's own /Widths, or null to use + * the base-14 metrics of `$baseFont` + */ + public function __construct( + private readonly PdfReference $reference, + private readonly string $baseFont, + private readonly ?array $widths = null, + private readonly bool $asciiOnly = false, + ) { + } + + public function reference(): PdfReference + { + return $this->reference; + } + + public function canShow(string $utf8): bool + { + if ($this->asciiOnly && preg_match('/^[\x20-\x7E]*$/', $utf8) !== 1) { + return false; + } + + return WinAnsi::encode($utf8) !== null; + } + + public function encode(string $utf8): string + { + $bytes = WinAnsi::encode($utf8) ?? ''; + $out = '('; + $length = strlen($bytes); + for ($i = 0; $i < $length; $i++) { + $c = $bytes[$i]; + $code = ord($c); + $out .= match (true) { + $c === '\\' || $c === '(' || $c === ')' => '\\'.$c, + $code < 0x20 || $code > 0x7E => sprintf('\\%03o', $code), + default => $c, + }; + } + + return $out.')'; + } + + public function width(string $utf8, float $size): float + { + $bytes = WinAnsi::encode($utf8) ?? ''; + $units = 0.0; + $length = strlen($bytes); + for ($i = 0; $i < $length; $i++) { + $code = ord($bytes[$i]); + $units += $this->widths !== null + ? ($this->widths[$code] ?? 500.0) + : StandardFontMetrics::width($this->baseFont, $code); + } + + return $units * $size / 1000; + } + + public function capHeight(): float + { + return str_starts_with($this->baseFont, 'Times') ? 662.0 : (str_starts_with($this->baseFont, 'Courier') ? 571.0 : 718.0); + } + + public function ascent(): float + { + return str_starts_with($this->baseFont, 'Times') ? 683.0 : (str_starts_with($this->baseFont, 'Courier') ? 629.0 : 718.0); + } +} diff --git a/src/Pdf/Forms/Appearance/StandardFontMetrics.php b/src/Pdf/Forms/Appearance/StandardFontMetrics.php new file mode 100644 index 0000000..f37a44f --- /dev/null +++ b/src/Pdf/Forms/Appearance/StandardFontMetrics.php @@ -0,0 +1,45 @@ + [278,278,355,556,556,889,667,191,333,333,389,584,278,333,278,278,556,556,556,556,556,556,556,556,556,556,278,278,584,584,584,556,1015,667,667,722,722,667,611,778,722,278,500,667,556,833,722,778,667,778,722,667,611,722,667,944,667,667,611,278,278,278,469,556,333,556,556,500,556,556,278,556,556,222,222,500,222,833,556,556,556,556,333,500,278,556,500,722,500,500,500,334,260,334,584,0,556,0,222,556,333,1000,556,556,333,1000,667,333,1000,0,611,0,0,222,222,333,333,350,556,1000,333,1000,500,333,944,0,500,667,278,333,556,556,556,556,260,556,333,737,370,556,584,333,737,552,400,549,333,333,333,576,537,333,333,333,365,556,834,834,834,611,667,667,667,667,667,667,1000,722,667,667,667,667,278,278,278,278,722,722,778,778,778,778,778,584,778,722,722,722,722,667,667,611,556,556,556,556,556,556,889,500,556,556,556,556,278,278,278,278,556,556,556,556,556,556,556,549,611,556,556,556,556,500,556,500], + 'Helvetica-Bold' => [278,333,474,556,556,889,722,238,333,333,389,584,278,333,278,278,556,556,556,556,556,556,556,556,556,556,333,333,584,584,584,611,975,722,722,722,722,667,611,778,722,278,556,722,611,833,722,778,667,778,722,667,611,722,667,944,667,667,611,333,278,333,584,556,333,556,611,556,611,556,333,611,611,278,278,556,278,889,611,611,611,611,389,556,333,611,556,778,556,556,500,389,280,389,584,0,556,0,278,556,500,1000,556,556,333,1000,667,333,1000,0,611,0,0,278,278,500,500,350,556,1000,333,1000,556,333,944,0,500,667,278,333,556,556,556,556,280,556,333,737,370,556,584,333,737,552,400,549,333,333,333,576,556,333,333,333,365,556,834,834,834,611,722,722,722,722,722,722,1000,722,667,667,667,667,278,278,278,278,722,722,778,778,778,778,778,584,778,722,722,722,722,667,667,611,556,556,556,556,556,556,889,556,556,556,556,556,278,278,278,278,611,611,611,611,611,611,611,549,611,611,611,611,611,556,611,556], + 'Helvetica-Oblique' => [278,278,355,556,556,889,667,191,333,333,389,584,278,333,278,278,556,556,556,556,556,556,556,556,556,556,278,278,584,584,584,556,1015,667,667,722,722,667,611,778,722,278,500,667,556,833,722,778,667,778,722,667,611,722,667,944,667,667,611,278,278,278,469,556,333,556,556,500,556,556,278,556,556,222,222,500,222,833,556,556,556,556,333,500,278,556,500,722,500,500,500,334,260,334,584,0,556,0,222,556,333,1000,556,556,333,1000,667,333,1000,0,611,0,0,222,222,333,333,350,556,1000,333,1000,500,333,944,0,500,667,278,333,556,556,556,556,260,556,333,737,370,556,584,333,737,552,400,549,333,333,333,576,537,333,333,333,365,556,834,834,834,611,667,667,667,667,667,667,1000,722,667,667,667,667,278,278,278,278,722,722,778,778,778,778,778,584,778,722,722,722,722,667,667,611,556,556,556,556,556,556,889,500,556,556,556,556,278,278,278,278,556,556,556,556,556,556,556,549,611,556,556,556,556,500,556,500], + 'Helvetica-BoldOblique' => [278,333,474,556,556,889,722,238,333,333,389,584,278,333,278,278,556,556,556,556,556,556,556,556,556,556,333,333,584,584,584,611,975,722,722,722,722,667,611,778,722,278,556,722,611,833,722,778,667,778,722,667,611,722,667,944,667,667,611,333,278,333,584,556,333,556,611,556,611,556,333,611,611,278,278,556,278,889,611,611,611,611,389,556,333,611,556,778,556,556,500,389,280,389,584,0,556,0,278,556,500,1000,556,556,333,1000,667,333,1000,0,611,0,0,278,278,500,500,350,556,1000,333,1000,556,333,944,0,500,667,278,333,556,556,556,556,280,556,333,737,370,556,584,333,737,552,400,549,333,333,333,576,556,333,333,333,365,556,834,834,834,611,722,722,722,722,722,722,1000,722,667,667,667,667,278,278,278,278,722,722,778,778,778,778,778,584,778,722,722,722,722,667,667,611,556,556,556,556,556,556,889,556,556,556,556,556,278,278,278,278,611,611,611,611,611,611,611,549,611,611,611,611,611,556,611,556], + 'Times-Roman' => [250,333,408,500,500,833,778,180,333,333,500,564,250,333,250,278,500,500,500,500,500,500,500,500,500,500,278,278,564,564,564,444,921,722,667,667,722,611,556,722,722,333,389,722,611,889,722,722,556,722,667,556,611,722,722,944,722,722,611,333,278,333,469,500,333,444,500,444,500,444,333,500,500,278,278,500,278,778,500,500,500,500,333,389,278,500,500,722,500,500,444,480,200,480,541,0,500,0,333,500,444,1000,500,500,333,1000,556,333,889,0,611,0,0,333,333,444,444,350,500,1000,333,980,389,333,722,0,444,722,250,333,500,500,500,500,200,500,333,760,276,500,564,333,760,500,400,549,300,300,333,576,453,333,333,300,310,500,750,750,750,444,722,722,722,722,722,722,889,667,611,611,611,611,333,333,333,333,722,722,722,722,722,722,722,564,722,722,722,722,722,722,556,500,444,444,444,444,444,444,667,444,444,444,444,444,278,278,278,278,500,500,500,500,500,500,500,549,500,500,500,500,500,500,500,500], + 'Times-Bold' => [250,333,555,500,500,1000,833,278,333,333,500,570,250,333,250,278,500,500,500,500,500,500,500,500,500,500,333,333,570,570,570,500,930,722,667,722,722,667,611,778,778,389,500,778,667,944,722,778,611,778,722,556,667,722,722,1000,722,722,667,333,278,333,581,500,333,500,556,444,556,444,333,500,556,278,333,556,278,833,556,500,556,556,444,389,333,556,500,722,500,500,444,394,220,394,520,0,500,0,333,500,500,1000,500,500,333,1000,556,333,1000,0,667,0,0,333,333,500,500,350,500,1000,333,1000,389,333,722,0,444,722,250,333,500,500,500,500,220,500,333,747,300,500,570,333,747,500,400,549,300,300,333,576,540,333,333,300,330,500,750,750,750,500,722,722,722,722,722,722,1000,722,667,667,667,667,389,389,389,389,722,722,778,778,778,778,778,570,778,722,722,722,722,722,611,556,500,500,500,500,500,500,722,444,444,444,444,444,278,278,278,278,500,556,500,500,500,500,500,549,500,556,556,556,556,500,556,500], + 'Times-Italic' => [250,333,420,500,500,833,778,214,333,333,500,675,250,333,250,278,500,500,500,500,500,500,500,500,500,500,333,333,675,675,675,500,920,611,611,667,722,611,611,722,722,333,444,667,556,833,667,722,611,722,611,500,556,722,611,833,611,556,556,389,278,389,422,500,333,500,500,444,500,444,278,500,500,278,278,444,278,722,500,500,500,500,389,389,278,500,444,667,444,444,389,400,275,400,541,0,500,0,333,500,556,889,500,500,333,1000,500,333,944,0,556,0,0,333,333,556,556,350,500,889,333,980,389,333,667,0,389,556,250,389,500,500,500,500,275,500,333,760,276,500,675,333,760,500,400,549,300,300,333,576,523,250,333,300,310,500,750,750,750,500,611,611,611,611,611,611,889,667,611,611,611,611,333,333,333,333,722,667,722,722,722,722,722,675,722,722,722,722,722,556,611,500,500,500,500,500,500,500,667,444,444,444,444,444,278,278,278,278,500,500,500,500,500,500,500,549,500,500,500,500,500,444,500,444], + 'Times-BoldItalic' => [250,389,555,500,500,833,778,278,333,333,500,570,250,333,250,278,500,500,500,500,500,500,500,500,500,500,333,333,570,570,570,500,832,667,667,667,722,667,667,722,778,389,500,667,611,889,722,722,611,722,667,556,611,722,667,889,667,611,611,333,278,333,570,500,333,500,500,444,500,444,333,500,556,278,278,500,278,778,556,500,500,500,389,389,278,556,444,667,500,444,389,348,220,348,570,0,500,0,333,500,500,1000,500,500,333,1000,556,333,944,0,611,0,0,333,333,500,500,350,500,1000,333,1000,389,333,722,0,389,611,250,389,500,500,500,500,220,500,333,747,266,500,606,333,747,500,400,549,300,300,333,576,500,250,333,300,300,500,750,750,750,500,667,667,667,667,667,667,944,667,667,667,667,667,389,389,389,389,722,722,722,722,722,722,722,570,722,722,722,722,722,611,611,500,500,500,500,500,500,500,722,444,444,444,444,444,278,278,278,278,500,556,500,500,500,500,500,549,500,556,556,556,556,444,500,444], + ]; + + /** Every Courier glyph has the same advance. */ + private const COURIER_WIDTH = 600; + + public static function has(string $baseFont): bool + { + return isset(self::WIDTHS[$baseFont]) || str_starts_with($baseFont, 'Courier'); + } + + /** Advance width of a WinAnsi code, in 1/1000 em. */ + public static function width(string $baseFont, int $code): int + { + if (str_starts_with($baseFont, 'Courier')) { + return self::COURIER_WIDTH; + } + $table = self::WIDTHS[$baseFont] ?? self::WIDTHS['Helvetica']; + + return $code >= 32 && $code <= 255 ? $table[$code - 32] : 0; + } +} diff --git a/src/Pdf/Forms/Appearance/UnicodeFont.php b/src/Pdf/Forms/Appearance/UnicodeFont.php new file mode 100644 index 0000000..fc3e8ff --- /dev/null +++ b/src/Pdf/Forms/Appearance/UnicodeFont.php @@ -0,0 +1,89 @@ +font = new PdfFont($ttf); + $this->font->disableLigatures(); + } + + public function reference(): PdfReference + { + return $this->reference ??= $this->graph->allocate(PdfNull::instance()); + } + + public function canShow(string $utf8): bool + { + foreach (mb_str_split($utf8, 1, 'UTF-8') as $char) { + $cp = mb_ord($char, 'UTF-8'); + if ($cp >= 0x20 && $this->ttf->glyphIdForChar($cp) === 0) { + return false; + } + } + + return true; + } + + public function encode(string $utf8): string + { + return $this->font->encodeText($utf8); + } + + public function width(string $utf8, float $size): float + { + return (new TextMeasurer($this->font, $size, useKerning: false))->widthPt($utf8); + } + + public function capHeight(): float + { + return $this->ascent() * 0.72; + } + + public function ascent(): float + { + return $this->ttf->ascent() * 1000 / $this->ttf->unitsPerEm(); + } + + /** Embed the subset of every glyph encoded so far into the graph. */ + public function embed(): void + { + if ($this->reference === null) { + return; + } + $writer = new Writer(); + $fontId = $this->font->registerWith($writer); + $writer->setRoot($writer->addObject('<< /Type /Catalog >>')); + $source = ReaderDocument::fromBytes($writer->toBytes()); + + $imported = $this->graph->importer->withSource( + $source, + static fn ($importer) => $importer->importObject($fontId), + ); + $this->graph->set($this->reference->number, $this->graph->get($imported->number)); + } +} diff --git a/src/Pdf/Forms/Appearance/WinAnsi.php b/src/Pdf/Forms/Appearance/WinAnsi.php new file mode 100644 index 0000000..6a37161 --- /dev/null +++ b/src/Pdf/Forms/Appearance/WinAnsi.php @@ -0,0 +1,65 @@ + 0x20AC, 0x82 => 0x201A, 0x83 => 0x0192, 0x84 => 0x201E, 0x85 => 0x2026, + 0x86 => 0x2020, 0x87 => 0x2021, 0x88 => 0x02C6, 0x89 => 0x2030, 0x8A => 0x0160, + 0x8B => 0x2039, 0x8C => 0x0152, 0x8E => 0x017D, 0x91 => 0x2018, 0x92 => 0x2019, + 0x93 => 0x201C, 0x94 => 0x201D, 0x95 => 0x2022, 0x96 => 0x2013, 0x97 => 0x2014, + 0x98 => 0x02DC, 0x99 => 0x2122, 0x9A => 0x0161, 0x9B => 0x203A, 0x9C => 0x0153, + 0x9E => 0x017E, 0x9F => 0x0178, + ]; + + /** @var array|null */ + private static ?array $reverse = null; + + /** Unicode codepoint of a WinAnsi code, or null when the code is unassigned. */ + public static function codepoint(int $code): ?int + { + if ($code >= 0x80 && $code <= 0x9F) { + return self::HIGH[$code] ?? null; + } + + return $code >= 0x20 && $code <= 0xFF ? $code : null; + } + + /** + * Encode UTF-8 text as WinAnsi bytes, or null when some character has no + * WinAnsi code. + */ + public static function encode(string $utf8): ?string + { + if (self::$reverse === null) { + self::$reverse = []; + for ($code = 0x20; $code <= 0xFF; $code++) { + $cp = self::codepoint($code); + if ($cp !== null) { + self::$reverse[$cp] = $code; + } + } + } + + $out = ''; + foreach (mb_str_split($utf8, 1, 'UTF-8') as $char) { + $code = self::$reverse[mb_ord($char, 'UTF-8')] ?? null; + if ($code === null) { + return null; + } + $out .= chr($code); + } + + return $out; + } +} diff --git a/src/Pdf/Forms/ExistingFormFiller.php b/src/Pdf/Forms/ExistingFormFiller.php new file mode 100644 index 0000000..211d7b2 --- /dev/null +++ b/src/Pdf/Forms/ExistingFormFiller.php @@ -0,0 +1,432 @@ +setValue('full_name', 'Jane Roe') + * ->setValue('subscribe', true) + * ->flatten() + * ->toBytes(); + * ``` + */ +final class ExistingFormFiller +{ + private readonly PdfSource $source; + + /** @var array|null */ + private ?array $fieldTree = null; + + private ?FormGraph $graph = null; + + private ?PageEditor $pageEditor = null; + + private ?FontResolver $fontResolver = null; + + private ?AppearanceBuilder $appearanceBuilder = null; + + private ?int $catalogId = null; + + /** New id of the (indirect) `/AcroForm`, or null when there is none. */ + private ?int $acroFormId = null; + + private bool $mutated = false; + + private ?TtfFile $font = null; + + private ?FontProvider $fontProvider = 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)); + } + + /** + * The TrueType font for values the form's own fonts cannot show. Call it + * before setting values. + */ + public function useFont(TtfFile|string $font): self + { + $this->assertNotStarted(__FUNCTION__); + $this->font = is_string($font) ? TtfFile::fromFile($font) : $font; + + return $this; + } + + /** + * Resolve the fallback font by family instead (`LiberationSans-Regular`, + * `LiberationSerif-Bold`, … following the field's own font style). + */ + public function useFontProvider(FontProvider $provider): self + { + $this->assertNotStarted(__FUNCTION__); + $this->fontProvider = $provider; + + return $this; + } + + /** + * 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->has(FieldAttributes::FF_REQUIRED), + readOnly: $node->has(FieldAttributes::FF_READ_ONLY), + tooltip: $node->tooltip, + maxLength: $node->maxLength, + optionLabels: $node->optionLabels, + ); + } + + return $out; + } + + public function pageCount(): int + { + return count($this->document()->pages()); + } + + /** + * The size of a 0-based page as a viewer shows it (CropBox, after + * `/Rotate`) — the coordinate space of {@see stampImage()}. + * + * @return array{width: float, height: float} + */ + public function pageSize(int $pageIndex): array + { + return PageEditor::displaySize($this->page($pageIndex)); + } + + /** + * Set one field's value by its fully-qualified dotted name (see + * {@see fields()}). + * + * - text: a string (`/MaxLen` is enforced; a single-line field turns line + * breaks into spaces); + * - check box: `true`/`false`, `on`/`yes`/`true`/`1`, `off`/`no`/`false`/ + * `0`/`''`, or its export value; + * - radio group: one of its options (case-insensitive), or `Off`; + * - combo / list box: an option's export value or its displayed label + * (a list of them for a multi-select list box; an editable combo box + * takes any text). + * + * @param string|bool|list $value + */ + public function setValue(string $name, string|bool|array $value): self + { + $node = $this->tree()[$name] ?? null; + if ($node === null) { + throw new \InvalidArgumentException("Unknown field: {$name}"); + } + + (new FieldValueSetter($this->graph(), $this->appearances(), $this->acroForm(), $this->dr()))->apply($node, $value); + $this->markMutated(); + + return $this; + } + + /** @param array> $values */ + public function setValues(array $values): self + { + foreach ($values as $name => $value) { + $this->setValue((string) $name, $value); + } + + return $this; + } + + /** + * Bake the given fields into static page content and remove them from the + * form. `null` (default) flattens every field — every widget annotation of + * the document — and drops `/AcroForm` altogether. + * + * @param list|null $fieldNames + */ + public function flatten(?array $fieldNames = null): self + { + $tree = $this->tree(); + $nodes = []; + foreach ($fieldNames ?? [] as $name) { + $nodes[] = $tree[$name] ?? throw new \InvalidArgumentException("Unknown field: {$name}"); + } + + $acroForm = $this->acroForm(); + $flattener = new FormFlattener( + $this->graph(), + $this->pages(), + $this->appearances(), + $acroForm, + $this->dr(), + $acroForm?->get('NeedAppearances') === true, + ); + + if ($fieldNames === null) { + $flattener->flattenAll(); + $this->dropAcroForm(); + } else { + $flattener->flattenFields($nodes); + foreach ($nodes as $node) { + $this->detachField($node); + } + $fields = $this->graph()->array($this->acroForm()?->get('Fields')); + if ($fields === []) { + $this->dropAcroForm(); + } + } + $this->markMutated(); + + return $this; + } + + /** + * Place a PNG/JPEG image (with alpha preserved for PNG) onto the given + * 0-based page. `$x,$y` is the image's top-left corner in points from the + * top-left of the page as displayed (see {@see pageSize()}). 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 + { + $bytes = @file_get_contents($imagePath); + if ($bytes === false) { + throw new \RuntimeException("Cannot read image file: {$imagePath}"); + } + + return $this->stampImageBytes($pageIndex, $bytes, $x, $y, $width, $height); + } + + public function stampImageBytes(int $pageIndex, string $bytes, float $x, float $y, float $width, float $height): self + { + $page = $this->page($pageIndex); + (new ImageStamper($this->graph(), $this->pages()))->stamp($page, PdfImage::fromBytes($bytes), $x, $y, $width, $height); + + return $this; + } + + public function toBytes(): string + { + $graph = $this->graph(); + $this->fonts()->embed(); + + $trailer = []; + $info = $this->document()->trailer()->get('Info'); + if ($info !== null) { + $trailer['Info'] = $graph->importer->importValue($info); + } + // Keep the permanent identifier; a changed file gets a new second one (§14.4). + $id = $this->document()->deref($this->document()->trailer()->get('ID')); + $first = is_array($id) && ($id[0] ?? null) instanceof PdfString ? $id[0]->bytes : random_bytes(16); + $second = is_array($id) && ($id[1] ?? null) instanceof PdfString ? $id[1]->bytes : $first; + $trailer['ID'] = [new PdfString($first), new PdfString($this->mutated ? random_bytes(16) : $second)]; + + return (new MergeSerializer())->serialize($graph->importer->objects(), $this->catalogId(), $trailer); + } + + 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 page(int $pageIndex): ReaderPage + { + $pages = $this->document()->pages(); + if ($pageIndex < 0 || $pageIndex >= count($pages)) { + throw new \OutOfRangeException("Page {$pageIndex} does not exist (document has ".count($pages).' pages)'); + } + + return $pages[$pageIndex]; + } + + private function graph(): FormGraph + { + if ($this->graph !== null) { + return $this->graph; + } + + $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); + $graph = new FormGraph($importer, $doc); + $this->catalogId = $graph->id($rootRef->number); + + // Keep /AcroForm indirect so it can be edited in one place. + $acroForm = $graph->dict($this->catalogId)?->get('AcroForm'); + if ($acroForm instanceof PdfReference) { + $this->acroFormId = $acroForm->number; + } elseif ($acroForm instanceof PdfDictionary) { + $ref = $graph->allocate($acroForm); + $graph->update($this->catalogId, ['AcroForm' => $ref]); + $this->acroFormId = $ref->number; + } + + return $this->graph = $graph; + } + + private function pages(): PageEditor + { + return $this->pageEditor ??= new PageEditor($this->graph()); + } + + private function fonts(): FontResolver + { + return $this->fontResolver ??= new FontResolver($this->graph(), $this->font, $this->fontProvider); + } + + private function appearances(): AppearanceBuilder + { + return $this->appearanceBuilder ??= new AppearanceBuilder($this->graph(), $this->fonts()); + } + + private function acroForm(): ?PdfDictionary + { + $graph = $this->graph(); + + return $this->acroFormId !== null ? $graph->dict($this->acroFormId) : null; + } + + private function dr(): ?PdfDictionary + { + return $this->graph()->dict($this->acroForm()?->get('DR')); + } + + /** + * Once values change, an XFA definition (§12.7.8) would make XFA-aware + * viewers show stale data — drop it so the AcroForm is authoritative. + */ + private function markMutated(): void + { + $this->mutated = true; + if ($this->acroFormId !== null && $this->acroForm()?->has('XFA')) { + $this->graph()->update($this->acroFormId, ['XFA' => null]); + } + if ($this->graph()->dict($this->catalogId())?->has('NeedsRendering')) { + $this->graph()->update($this->catalogId(), ['NeedsRendering' => null]); + } + } + + /** + * Remove a flattened field from the field hierarchy, pruning parents left + * without kids all the way up to `/AcroForm /Fields`. + */ + private function detachField(FieldNode $node): void + { + $graph = $this->graph(); + $current = $graph->id($node->fieldObjNum); + if ($this->acroFormId !== null) { + $graph->removeFromArray($this->acroFormId, 'CO', $current); + } + foreach ($node->ancestorObjNums as $ancestorObjNum) { + $parentId = $graph->id($ancestorObjNum); + $left = $graph->removeFromArray($parentId, 'Kids', $current); + if ($left !== 0) { + return; + } + $current = $parentId; + } + if ($this->acroFormId !== null) { + $graph->removeFromArray($this->acroFormId, 'Fields', $current); + } + } + + private function dropAcroForm(): void + { + if ($this->acroFormId === null) { + return; + } + $this->graph()->update($this->catalogId(), ['AcroForm' => null, 'NeedsRendering' => null]); + $this->acroFormId = null; + } + + private function assertNotStarted(string $method): void + { + if ($this->fontResolver !== null) { + throw new \LogicException("{$method}() must be called before any value is set."); + } + } + + private function catalogId(): int + { + $this->graph(); + + return $this->catalogId ?? throw new \LogicException('Catalog was not imported'); + } +} diff --git a/src/Pdf/Forms/FieldAttributes.php b/src/Pdf/Forms/FieldAttributes.php new file mode 100644 index 0000000..2b1dabf --- /dev/null +++ b/src/Pdf/Forms/FieldAttributes.php @@ -0,0 +1,130 @@ + $options choice options as [export, display] + * @param list|string|null $value + */ + public function __construct( + public int $widgetId, + public int $fieldId, + public ?string $ft, + public int $flags, + public ?string $da, + public int $quadding, + public ?int $maxLength, + public array|string|null $value, + public array $options, + public int $topIndex, + ) { + } + + public static function forWidget(FormGraph $graph, int $widgetId, ?PdfDictionary $acroForm): self + { + $found = []; + $fieldId = null; + $id = $widgetId; + for ($depth = 0; $id !== null && $depth < 64; $depth++) { + $dict = $graph->dict($id); + if ($dict === null) { + break; + } + if ($fieldId === null && ($dict->has('T') || $dict->has('FT') || !$dict->has('Parent'))) { + $fieldId = $id; + } + foreach (['FT', 'Ff', 'DA', 'Q', 'MaxLen', 'V', 'Opt', 'TI'] as $key) { + if (!array_key_exists($key, $found) && $dict->has($key)) { + $found[$key] = $graph->resolve($dict->get($key)); + } + } + $parent = $dict->get('Parent'); + $id = $parent instanceof PdfReference ? $parent->number : null; + } + + foreach (['DA', 'Q'] as $key) { + if (!array_key_exists($key, $found) && $acroForm?->has($key)) { + $found[$key] = $graph->resolve($acroForm->get($key)); + } + } + + $value = $found['V'] ?? null; + $value = match (true) { + is_array($value) => array_values(array_filter(array_map( + static fn ($v) => TextString::decode($graph->resolve($v)), + $value, + ), 'is_string')), + default => TextString::decode($value), + }; + + $options = []; + foreach ($graph->array($found['Opt'] ?? null) ?? [] as $opt) { + $opt = $graph->resolve($opt); + if (is_array($opt) && count($opt) >= 2) { + $export = TextString::decode($graph->resolve($opt[0])) ?? ''; + $options[] = [$export, TextString::decode($graph->resolve($opt[1])) ?? $export]; + } elseif (($text = TextString::decode($opt)) !== null) { + $options[] = [$text, $text]; + } + } + + return new self( + widgetId: $widgetId, + fieldId: $fieldId ?? $widgetId, + ft: ($found['FT'] ?? null) instanceof PdfName ? $found['FT']->value : null, + flags: is_int($found['Ff'] ?? null) ? $found['Ff'] : 0, + da: ($found['DA'] ?? null) instanceof PdfString ? $found['DA']->bytes : null, + quadding: is_int($found['Q'] ?? null) ? $found['Q'] : 0, + maxLength: is_int($found['MaxLen'] ?? null) ? $found['MaxLen'] : null, + value: $value, + options: $options, + topIndex: is_int($found['TI'] ?? null) ? $found['TI'] : 0, + ); + } + + public function has(int $flag): bool + { + return ($this->flags & $flag) !== 0; + } + + /** The value as one display string (a choice shows its option label). */ + public function displayValue(): string + { + $value = is_array($this->value) ? ($this->value[0] ?? '') : ($this->value ?? ''); + foreach ($this->options as [$export, $display]) { + if ($export === $value) { + return $display; + } + } + + return $value; + } +} diff --git a/src/Pdf/Forms/FieldInfo.php b/src/Pdf/Forms/FieldInfo.php new file mode 100644 index 0000000..0bf9789 --- /dev/null +++ b/src/Pdf/Forms/FieldInfo.php @@ -0,0 +1,40 @@ + $value The current value — a list for a + * multi-select list box; the on-state + * name (or `Off`) for a button. + * @param list $options Accepted values: export names of a check + * box / radio group, export values of a + * combo / list box. + * @param array $optionLabels Choice export value → the + * text the box displays. + */ + public function __construct( + public string $name, + public string $type, + public string|array $value, + public array $options, + public bool $required, + public bool $readOnly, + /** The field's `/TU` (alternate field name / tooltip), if any. */ + public ?string $tooltip = null, + /** `/MaxLen` of a text field, if any. */ + public ?int $maxLength = null, + public array $optionLabels = [], + ) { + } +} diff --git a/src/Pdf/Forms/FieldNode.php b/src/Pdf/Forms/FieldNode.php new file mode 100644 index 0000000..8507c52 --- /dev/null +++ b/src/Pdf/Forms/FieldNode.php @@ -0,0 +1,48 @@ + $widgetObjNums the field's widget annotation(s): one + * for a plain field, one per option for + * a radio group, one per page for a + * repeated field + * @param list $ancestorObjNums non-terminal parents, nearest first + * @param string|list $value + * @param list $options export values (button or choice) + * @param array $optionLabels choice export value → display text + * @param array $widgetExports button widget → its export value + */ + public function __construct( + public string $name, + public string $type, + public int $fieldObjNum, + public array $widgetObjNums, + public array $ancestorObjNums, + public ?string $tooltip, + public string|array $value, + public array $options, + public array $optionLabels, + public array $widgetExports, + public int $flags, + public ?int $maxLength, + ) { + } + + public function has(int $flag): bool + { + return ($this->flags & $flag) !== 0; + } +} diff --git a/src/Pdf/Forms/FieldTree.php b/src/Pdf/Forms/FieldTree.php new file mode 100644 index 0000000..9f789f0 --- /dev/null +++ b/src/Pdf/Forms/FieldTree.php @@ -0,0 +1,227 @@ + 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 = []; + $seen = []; + foreach ($fields as $ref) { + $this->walk($doc, $ref, null, [], [], $out, $seen, 0); + } + + return $out; + } + + /** + * @param list $ancestors source object numbers, nearest first + * @param array $inherited inheritable entries from ancestors + * @param array $out + * @param array $seen + */ + private function walk( + ReaderDocument $doc, + mixed $ref, + ?string $parentName, + array $ancestors, + array $inherited, + array &$out, + array &$seen, + int $depth, + ): void { + if ($depth > self::MAX_DEPTH || !$ref instanceof PdfReference || isset($seen[$ref->number])) { + return; + } + $seen[$ref->number] = true; + $dict = $doc->deref($ref); + if (!$dict instanceof PdfDictionary) { + return; + } + + $partial = TextString::decode($dict->get('T')); + $name = $partial !== null + ? ($parentName !== null ? $parentName.'.'.$partial : $partial) + : $parentName; + + foreach (['FT', 'Ff', 'V', 'MaxLen', 'Opt'] as $key) { + if ($dict->has($key)) { + $inherited[$key] = $doc->deref($dict->get($key)); + } + } + + $kids = $doc->deref($dict->get('Kids')); + $childFieldRefs = []; + $widgetObjNums = []; + if (is_array($kids)) { + foreach ($kids as $kidRef) { + $kidDict = $doc->deref($kidRef); + if ($kidDict instanceof PdfDictionary && $kidDict->has('T')) { + $childFieldRefs[] = $kidRef; + } elseif ($kidRef instanceof PdfReference && $kidDict instanceof PdfDictionary) { + $widgetObjNums[] = $kidRef->number; + } + } + } else { + $widgetObjNums[] = $ref->number; + } + + foreach ($childFieldRefs as $kidRef) { + $this->walk($doc, $kidRef, $name, [$ref->number, ...$ancestors], $inherited, $out, $seen, $depth + 1); + } + + $ft = ($inherited['FT'] ?? null) instanceof PdfName ? $inherited['FT']->value : null; + if ($name === null || $ft === null || $widgetObjNums === [] || isset($out[$name])) { + return; + } + + $ff = is_int($inherited['Ff'] ?? null) ? $inherited['Ff'] : 0; + $type = $this->resolveType($ft, $ff); + $choiceOptions = $this->choiceOptions($doc, $inherited['Opt'] ?? null); + $widgetExports = in_array($type, ['checkbox', 'radio'], true) + ? $this->widgetExports($doc, $widgetObjNums, $choiceOptions) + : []; + + $out[$name] = new FieldNode( + name: $name, + type: $type, + fieldObjNum: $ref->number, + widgetObjNums: $widgetObjNums, + ancestorObjNums: $ancestors, + tooltip: TextString::decode($dict->get('TU')), + value: $this->value($doc, $inherited['V'] ?? null), + options: match (true) { + $widgetExports !== [] => array_values(array_unique(array_values($widgetExports))), + default => array_column($choiceOptions, 0), + }, + optionLabels: array_column($choiceOptions, 1, 0), + widgetExports: $widgetExports, + flags: $ff, + maxLength: is_int($inherited['MaxLen'] ?? null) ? $inherited['MaxLen'] : null, + ); + } + + private function resolveType(string $ft, int $ff): string + { + return match ($ft) { + 'Tx' => ($ff & FieldAttributes::FF_MULTILINE) !== 0 ? 'text-multiline' : 'text', + 'Btn' => match (true) { + ($ff & FieldAttributes::FF_PUSHBUTTON) !== 0 => 'push', + ($ff & FieldAttributes::FF_RADIO) !== 0 => 'radio', + default => 'checkbox', + }, + 'Ch' => ($ff & FieldAttributes::FF_COMBO) !== 0 ? 'combo' : 'list', + 'Sig' => 'signature', + default => $ft, + }; + } + + /** @return list [export, display] */ + private function choiceOptions(ReaderDocument $doc, mixed $opt): array + { + $out = []; + foreach (is_array($opt) ? $opt : [] as $entry) { + $entry = $doc->deref($entry); + if (is_array($entry) && count($entry) >= 2) { + $export = TextString::decode($doc->deref($entry[0])) ?? ''; + $out[] = [$export, TextString::decode($doc->deref($entry[1])) ?? $export]; + } elseif (($text = TextString::decode($entry)) !== null) { + $out[] = [$text, $text]; + } + } + + return $out; + } + + /** + * The export value of each check box / radio widget: its on-state name in + * `/AP /N`, or — when the field has `/Opt` — the option at the widget's + * index (§12.7.4.2.3), since on-states are then just "0", "1", …. + * + * @param list $widgetObjNums + * @param list $options + * @return array widget object number → export value + */ + private function widgetExports(ReaderDocument $doc, array $widgetObjNums, array $options): array + { + $out = []; + foreach ($widgetObjNums as $i => $objNum) { + $onKey = self::onState($doc->deref(new PdfReference($objNum, 0)), $doc); + if ($onKey === null) { + continue; + } + $out[$objNum] = count($options) === count($widgetObjNums) ? $options[$i][0] : $onKey; + } + + return $out; + } + + /** The non-`Off` appearance state of a button widget, if any. */ + public static function onState(mixed $widget, ReaderDocument $doc): ?string + { + if (!$widget instanceof PdfDictionary) { + return null; + } + $ap = $doc->deref($widget->get('AP')); + $n = $ap instanceof PdfDictionary ? $doc->deref($ap->get('N')) : null; + if ($n instanceof PdfDictionary) { + foreach (array_keys($n->all()) as $key) { + if ($key !== 'Off') { + return (string) $key; + } + } + } + // A widget without an appearance still names its on-state in /AS. + $as = $widget->get('AS'); + + return $as instanceof PdfName && $as->value !== 'Off' ? $as->value : null; + } + + /** @return string|list */ + private function value(ReaderDocument $doc, mixed $value): string|array + { + if (is_array($value)) { + return array_values(array_filter( + array_map(static fn ($v) => TextString::decode($doc->deref($v)), $value), + 'is_string', + )); + } + + return TextString::decode($value instanceof PdfString || $value instanceof PdfName ? $value : null) ?? ''; + } +} diff --git a/src/Pdf/Forms/FieldValueSetter.php b/src/Pdf/Forms/FieldValueSetter.php new file mode 100644 index 0000000..f66cf38 --- /dev/null +++ b/src/Pdf/Forms/FieldValueSetter.php @@ -0,0 +1,211 @@ + $value */ + public function apply(FieldNode $node, string|bool|array $value): void + { + match ($node->type) { + 'checkbox' => $this->applyCheckbox($node, $value), + 'radio' => $this->applyRadio($node, $value), + 'combo', 'list' => $this->applyChoice($node, $value), + 'text', 'text-multiline' => $this->applyText($node, $value), + default => throw new \LogicException(sprintf('Field "%s" is a %s field and cannot be given a value.', $node->name, $node->type)), + }; + } + + /** @param string|bool|list $value */ + private function applyText(FieldNode $node, string|bool|array $value): void + { + if (!is_string($value)) { + throw new \InvalidArgumentException(sprintf('Field "%s" takes a string value.', $node->name)); + } + if ($node->type === 'text') { + $value = str_replace(["\r\n", "\r", "\n"], ' ', $value); + } + if ($node->maxLength !== null && mb_strlen($value, 'UTF-8') > $node->maxLength) { + throw new \InvalidArgumentException(sprintf( + 'Field "%s" accepts at most %d characters, got %d.', + $node->name, + $node->maxLength, + mb_strlen($value, 'UTF-8'), + )); + } + $this->graph->update($this->graph->id($node->fieldObjNum), ['V' => TextString::encode($value)]); + $this->refreshAppearances($node); + } + + /** @param string|bool|list $value */ + private function applyChoice(FieldNode $node, string|bool|array $value): void + { + if (is_bool($value)) { + throw new \InvalidArgumentException(sprintf('Field "%s" takes a string value.', $node->name)); + } + $values = is_array($value) ? array_values($value) : [$value]; + if (count($values) > 1 && ($node->type !== 'list' || !$node->has(FieldAttributes::FF_MULTI_SELECT))) { + throw new \InvalidArgumentException(sprintf('Field "%s" does not allow multiple selections.', $node->name)); + } + + $indices = []; + $normalized = []; + $editable = $node->type === 'combo' && $node->has(FieldAttributes::FF_EDIT); + foreach ($values as $v) { + if (!is_string($v)) { + throw new \InvalidArgumentException(sprintf('Field "%s" takes string values.', $node->name)); + } + $index = array_search($v, $node->options, true); + if ($index === false) { + // Accept the displayed label too — that is what users see. + $export = array_search($v, $node->optionLabels, true); + $index = $export !== false ? array_search((string) $export, $node->options, true) : false; + $v = $export !== false ? (string) $export : $v; + } + if ($index === false && !$editable && $node->options !== []) { + throw new \InvalidArgumentException(sprintf( + 'Field "%s" has no option "%s" (options: %s).', + $node->name, + $v, + implode(', ', $node->options), + )); + } + if ($index !== false) { + $indices[] = (int) $index; + } + $normalized[] = $v; + } + + $changes = [ + 'V' => count($normalized) === 1 ? TextString::encode($normalized[0]) : array_map(TextString::encode(...), $normalized), + ]; + if ($node->type === 'list') { + sort($indices); + $changes['I'] = $indices !== [] ? $indices : null; + } + $this->graph->update($this->graph->id($node->fieldObjNum), $changes); + $this->refreshAppearances($node); + } + + /** @param string|bool|list $value */ + private function applyCheckbox(FieldNode $node, string|bool|array $value): void + { + if (is_array($value)) { + throw new \InvalidArgumentException(sprintf('Field "%s" takes a single value.', $node->name)); + } + $export = null; + if (is_bool($value)) { + $export = $value ? ($node->options[0] ?? 'Yes') : null; + } elseif (in_array($value, $node->options, true)) { + $export = $value; + } elseif (in_array(strtolower($value), self::TRUTHY, true)) { + $export = $node->options[0] ?? 'Yes'; + } elseif (!in_array(strtolower($value), self::FALSY, true)) { + throw new \InvalidArgumentException(sprintf( + 'Field "%s" is a check box: pass true/false, "Off" or its export value (%s).', + $node->name, + implode(', ', $node->options), + )); + } + $this->applyButtonState($node, $export); + } + + /** @param string|bool|list $value */ + private function applyRadio(FieldNode $node, string|bool|array $value): void + { + if (!is_string($value)) { + throw new \InvalidArgumentException(sprintf('Field "%s" takes one of its option names.', $node->name)); + } + $export = null; + if (!in_array(strtolower($value), ['off', ''], true)) { + foreach ($node->options as $option) { + if (strcasecmp($option, $value) === 0) { + $export = $option; + break; + } + } + if ($export === null) { + throw new \InvalidArgumentException(sprintf( + 'Field "%s" has no option "%s" (options: %s).', + $node->name, + $value, + implode(', ', $node->options), + )); + } + } + $this->applyButtonState($node, $export); + } + + /** + * Turn on the widget(s) exporting `$export` and every other one off; `/V` + * is the on-state name (with `/Opt`, the index-named state of the first + * matching widget). + */ + private function applyButtonState(FieldNode $node, ?string $export): void + { + $state = 'Off'; + foreach ($node->widgetObjNums as $widgetObjNum) { + $widgetId = $this->graph->id($widgetObjNum); + $onKey = $this->onKey($widgetId) ?? 'Yes'; + $on = $export !== null && ($node->widgetExports[$widgetObjNum] ?? $onKey) === $export; + if ($on && $state === 'Off') { + $state = $onKey; + } + $this->graph->update($widgetId, ['AS' => new PdfName($on ? $onKey : 'Off')]); + } + $this->graph->update($this->graph->id($node->fieldObjNum), ['V' => new PdfName($state)]); + } + + private function onKey(int $widgetId): ?string + { + $widget = $this->graph->dict($widgetId); + $n = $this->graph->dict($this->graph->dict($widget?->get('AP'))?->get('N')); + foreach (array_keys($n?->all() ?? []) as $key) { + if ($key !== 'Off') { + return (string) $key; + } + } + $as = $widget?->get('AS'); + + return $as instanceof PdfName && $as->value !== 'Off' ? $as->value : null; + } + + private function refreshAppearances(FieldNode $node): void + { + foreach ($node->widgetObjNums as $widgetObjNum) { + $widgetId = $this->graph->id($widgetObjNum); + $widget = $this->graph->dict($widgetId); + if ($widget === null) { + continue; + } + $attributes = FieldAttributes::forWidget($this->graph, $widgetId, $this->acroForm); + $ap = $this->appearances->variableText($attributes, $widget, $this->dr, $node->name); + $this->graph->update($widgetId, ['AP' => $ap !== null ? new PdfDictionary(['N' => $ap]) : null]); + } + } +} diff --git a/src/Pdf/Forms/FormFlattener.php b/src/Pdf/Forms/FormFlattener.php new file mode 100644 index 0000000..f287aa2 --- /dev/null +++ b/src/Pdf/Forms/FormFlattener.php @@ -0,0 +1,260 @@ +|null source widget object number → page */ + private ?array $widgetPages = null; + + public function __construct( + private readonly FormGraph $graph, + private readonly PageEditor $pages, + private readonly AppearanceBuilder $appearances, + private readonly ?PdfDictionary $acroForm, + private readonly ?PdfDictionary $dr, + private readonly bool $needAppearances, + ) { + } + + /** + * Flatten every widget annotation of the document. + */ + public function flattenAll(): void + { + $byPage = []; + foreach ($this->widgetPages() as $widgetObjNum => $page) { + $byPage[$page->objectNumber][] = $widgetObjNum; + } + foreach ($byPage as $widgetObjNums) { + $this->flattenWidgets($widgetObjNums, null); + } + } + + /** + * Flatten the widgets of the given fields only. + * + * @param list $nodes + */ + public function flattenFields(array $nodes): void + { + $byPage = []; + $map = $this->widgetPages(); + foreach ($nodes as $node) { + foreach ($node->widgetObjNums as $widgetObjNum) { + if (isset($map[$widgetObjNum])) { + $byPage[$map[$widgetObjNum]->objectNumber][$widgetObjNum] = $node->name; + } + } + } + foreach ($byPage as $widgets) { + $this->flattenWidgets(array_keys($widgets), $widgets); + } + } + + /** + * @param list $widgetObjNums all on the same page + * @param array|null $names widget → field name, for messages + */ + private function flattenWidgets(array $widgetObjNums, ?array $names): void + { + $page = $this->widgetPages()[$widgetObjNums[0]]; + $pageId = $this->graph->id($page->objectNumber); + $ops = []; + foreach ($widgetObjNums as $widgetObjNum) { + $widgetId = $this->graph->id($widgetObjNum); + $draw = $this->drawOps($page, $widgetId, $names[$widgetObjNum] ?? null); + if ($draw !== null) { + $ops[] = $draw; + } + $this->graph->removeFromArray($pageId, 'Annots', $widgetId); + } + if ($ops !== []) { + $this->pages->appendContent($page, implode("\n", $ops)); + } + } + + private function drawOps(ReaderPage $page, int $widgetId, ?string $fieldName): ?string + { + $widget = $this->graph->dict($widgetId); + if ($widget === null) { + return null; + } + $flags = $this->graph->resolve($widget->get('F')); + if (is_int($flags) && ($flags & self::FLAG_HIDDEN) !== 0) { + return null; + } + + $appearance = $this->appearance($widgetId, $widget, $fieldName); + $stream = $appearance !== null ? $this->graph->get($appearance->number) : null; + $rect = $this->numbers($widget->get('Rect'), 4); + if (!$stream instanceof PdfStream || $rect === null) { + return null; + } + $bbox = $this->numbers($stream->dict->get('BBox'), 4); + if ($bbox === null) { + return null; + } + if (!$stream->dict->get('Subtype') instanceof PdfName) { + $this->graph->update($appearance->number, ['Type' => new PdfName('XObject'), 'Subtype' => new PdfName('Form')]); + } + + // §12.5.5 algorithm: transform BBox by Matrix, then fit the result to Rect. + $m = $this->numbers($stream->dict->get('Matrix'), 6) ?? [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + $xs = $ys = []; + foreach ([[$bbox[0], $bbox[1]], [$bbox[2], $bbox[1]], [$bbox[0], $bbox[3]], [$bbox[2], $bbox[3]]] as [$x, $y]) { + $xs[] = $m[0] * $x + $m[2] * $y + $m[4]; + $ys[] = $m[1] * $x + $m[3] * $y + $m[5]; + } + [$bx0, $bx1, $by0, $by1] = [min($xs), max($xs), min($ys), max($ys)]; + [$rx0, $rx1] = [min($rect[0], $rect[2]), max($rect[0], $rect[2])]; + [$ry0, $ry1] = [min($rect[1], $rect[3]), max($rect[1], $rect[3])]; + if ($bx1 - $bx0 <= 0 || $by1 - $by0 <= 0) { + return null; + } + $sx = ($rx1 - $rx0) / ($bx1 - $bx0); + $sy = ($ry1 - $ry0) / ($by1 - $by0); + + $name = $this->pages->addResource($page, 'XObject', 'FlatAP', $appearance); + + return sprintf( + "q %s 0 0 %s %s %s cm /%s Do Q", + self::num($sx), + self::num($sy), + self::num($rx0 - $bx0 * $sx), + self::num($ry0 - $by0 * $sy), + $name, + ); + } + + /** The appearance stream to draw for a widget, generating one when needed. */ + private function appearance(int $widgetId, PdfDictionary $widget, ?string $fieldName): ?PdfReference + { + $attributes = FieldAttributes::forWidget($this->graph, $widgetId, $this->acroForm); + $normal = $this->graph->dict($widget->get('AP'))?->get('N'); + $normalValue = $this->graph->resolve($normal); + + if (in_array($attributes->ft, ['Tx', 'Ch'], true) + && ($this->needAppearances || !$normalValue instanceof PdfStream)) { + return $this->appearances->variableText($attributes, $widget, $this->dr, $fieldName ?? $this->fallbackName($widgetId)); + } + + if ($normalValue instanceof PdfStream) { + return $normal instanceof PdfReference ? $normal : $this->graph->allocate($normalValue); + } + + $state = $widget->get('AS'); + $state = $state instanceof PdfName ? $state->value : 'Off'; + if ($normalValue instanceof PdfDictionary) { + $chosen = $normalValue->get($state); + if ($chosen instanceof PdfReference && $this->graph->get($chosen->number) instanceof PdfStream) { + return $chosen; + } + + return null; + } + + if ($attributes->ft === 'Btn' && ($attributes->flags & FieldAttributes::FF_PUSHBUTTON) === 0) { + $generated = $this->appearances->button($widget, ($attributes->flags & FieldAttributes::FF_RADIO) !== 0); + + return $generated === null ? null : ($state !== 'Off' ? $generated[0] : $generated[1]); + } + + return null; + } + + /** + * Every widget annotation reachable from a page's `/Annots`, mapped to its + * page (source object numbers). + * + * @return array + */ + private function widgetPages(): array + { + if ($this->widgetPages !== null) { + return $this->widgetPages; + } + $this->widgetPages = []; + $doc = $this->graph->source; + foreach ($this->pages->pages() as $page) { + $annots = $doc->deref($page->dict->get('Annots')); + foreach (is_array($annots) ? $annots : [] as $ref) { + if (!$ref instanceof PdfReference || isset($this->widgetPages[$ref->number])) { + continue; + } + $annot = $doc->deref($ref); + $subtype = $annot instanceof PdfDictionary ? $annot->get('Subtype') : null; + if ($subtype instanceof PdfName && $subtype->value === 'Widget') { + $this->widgetPages[$ref->number] = $page; + } + } + } + + return $this->widgetPages; + } + + private function fallbackName(int $widgetId): string + { + $parts = []; + $id = $widgetId; + for ($depth = 0; $id !== null && $depth < 64; $depth++) { + $dict = $this->graph->dict($id); + $partial = TextString::decode($dict?->get('T')); + if ($partial !== null) { + array_unshift($parts, $partial); + } + $parent = $dict?->get('Parent'); + $id = $parent instanceof PdfReference ? $parent->number : null; + } + + return implode('.', $parts); + } + + /** @return list|null */ + private function numbers(mixed $value, int $count): ?array + { + $array = $this->graph->array($value); + if ($array === null || count($array) !== $count) { + return null; + } + $out = []; + foreach ($array as $v) { + $v = $this->graph->resolve($v); + if (!is_int($v) && !is_float($v)) { + return null; + } + $out[] = (float) $v; + } + + return $out; + } + + private static function num(float $v): string + { + return FormGraph::number($v, 5); + } +} diff --git a/src/Pdf/Forms/FormGraph.php b/src/Pdf/Forms/FormGraph.php new file mode 100644 index 0000000..4900327 --- /dev/null +++ b/src/Pdf/Forms/FormGraph.php @@ -0,0 +1,150 @@ +importer->importObject($sourceObjNum)->number; + } + + public function get(int $id): mixed + { + return $this->importer->get($id); + } + + /** Follow indirect references within the copied graph. */ + public function resolve(mixed $value): mixed + { + for ($i = 0; $value instanceof PdfReference && $i < self::MAX_DEREF; $i++) { + $value = $this->importer->get($value->number); + } + + return $value instanceof PdfReference ? null : $value; + } + + public function dict(mixed $value): ?PdfDictionary + { + $value = is_int($value) ? $this->importer->get($value) : $this->resolve($value); + if ($value instanceof PdfStream) { + return $value->dict; + } + + return $value instanceof PdfDictionary ? $value : null; + } + + /** @return list|null */ + public function array(mixed $value): ?array + { + $value = $this->resolve($value); + + return is_array($value) ? array_values($value) : null; + } + + /** + * Merge entries into the dictionary (or stream dictionary) stored at `$id`; + * a null value removes the key. + * + * @param array $changes + */ + public function update(int $id, array $changes): void + { + $object = $this->importer->get($id); + $dict = $object instanceof PdfStream ? $object->dict : $object; + $items = $dict instanceof PdfDictionary ? $dict->all() : []; + foreach ($changes as $key => $value) { + if ($value === null) { + unset($items[$key]); + } else { + $items[$key] = $value; + } + } + $updated = new PdfDictionary($items); + $this->importer->set($id, $object instanceof PdfStream ? new PdfStream($updated, $object->raw) : $updated); + } + + public function set(int $id, mixed $value): void + { + $this->importer->set($id, $value); + } + + public function allocate(mixed $value): PdfReference + { + return new PdfReference($this->importer->allocate($value), 0); + } + + /** A flate-compressed stream object. */ + public function allocateStream(PdfDictionary $dict, string $content): PdfReference + { + $items = $dict->all(); + $items['Filter'] = new PdfName('FlateDecode'); + + return $this->allocate(new PdfStream(new PdfDictionary($items), (string) gzcompress($content))); + } + + /** + * Remove every reference to `$targetId` from the array stored under `$key` + * of the dictionary at `$ownerId` (the array may itself be indirect). + * Returns the number of entries left, or null when there is no such array. + */ + public function removeFromArray(int $ownerId, string $key, int $targetId): ?int + { + $owner = $this->dict($ownerId); + if ($owner === null) { + return null; + } + $raw = $owner->get($key); + $array = $this->array($raw); + if ($array === null) { + return null; + } + $filtered = array_values(array_filter( + $array, + static fn ($ref) => !($ref instanceof PdfReference && $ref->number === $targetId), + )); + if ($raw instanceof PdfReference) { + $this->importer->set($raw->number, $filtered); + } else { + $this->update($ownerId, [$key => $filtered]); + } + + return count($filtered); + } + + /** A number as a content-stream operand: integers bare, others trimmed. */ + public static function number(float $value, int $precision = 4): string + { + if (abs($value - round($value)) < 1e-6) { + return (string) (int) round($value); + } + + return rtrim(rtrim(sprintf("%.{$precision}F", $value), '0'), '.'); + } +} diff --git a/src/Pdf/Forms/ImageStamper.php b/src/Pdf/Forms/ImageStamper.php new file mode 100644 index 0000000..5c4f3aa --- /dev/null +++ b/src/Pdf/Forms/ImageStamper.php @@ -0,0 +1,83 @@ +pages->addResource($page, 'XObject', 'Stamp', $this->register($image)); + + // Unit square (u right, v up) → display box (y down) → user space. + [$a, $b, $c, $d, $e, $f] = PageEditor::displayToUser($page); + $point = static fn (float $dx, float $dy): array => [$a * $dx + $c * $dy + $e, $b * $dx + $d * $dy + $f]; + [$ox, $oy] = $point($x, $y + $height); + [$ux, $uy] = $point($x + $width, $y + $height); + [$vx, $vy] = $point($x, $y); + + $this->pages->appendContent($page, sprintf( + "q %s %s %s %s %s %s cm /%s Do Q", + FormGraph::number($ux - $ox), + FormGraph::number($uy - $oy), + FormGraph::number($vx - $ox), + FormGraph::number($vy - $oy), + FormGraph::number($ox), + FormGraph::number($oy), + $name, + )); + } + + private function register(PdfImage $image): PdfReference + { + $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 !== '') { + $items['SMask'] = $this->graph->allocate(new PdfStream(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'), + ]), $image->alphaData)); + } + + return $this->graph->allocate(new PdfStream(new PdfDictionary($items), $image->imageData)); + } +} diff --git a/src/Pdf/Forms/PageEditor.php b/src/Pdf/Forms/PageEditor.php new file mode 100644 index 0000000..b83cdfd --- /dev/null +++ b/src/Pdf/Forms/PageEditor.php @@ -0,0 +1,163 @@ + new page ids whose content is already wrapped */ + private array $wrapped = []; + + /** @var array|null source page object number → page */ + private ?array $pagesByObjNum = null; + + public function __construct(private readonly FormGraph $graph) + { + } + + /** @return list */ + public function pages(): array + { + return $this->graph->source->pages(); + } + + public function page(int $sourceObjNum): ?ReaderPage + { + if ($this->pagesByObjNum === null) { + $this->pagesByObjNum = []; + foreach ($this->pages() as $page) { + $this->pagesByObjNum[$page->objectNumber] = $page; + } + } + + return $this->pagesByObjNum[$sourceObjNum] ?? null; + } + + /** + * Register `$value` in the page's `$category` resources (`Font`, + * `XObject`, …) under a fresh name starting with `$prefix`. + */ + public function addResource(ReaderPage $page, string $category, string $prefix, PdfReference $value): string + { + $pageId = $this->graph->id($page->objectNumber); + $resourcesRaw = $this->ownResources($page, $pageId); + $resourcesId = $resourcesRaw instanceof PdfReference ? $resourcesRaw->number : null; + $resources = $this->graph->dict($resourcesRaw) ?? new PdfDictionary([]); + + $categoryRaw = $resources->get($category); + $entries = $this->graph->dict($categoryRaw)?->all() ?? []; + for ($n = 0; isset($entries[$prefix.$n]); $n++) { + } + $name = $prefix.$n; + $entries[$name] = $value; + + if ($categoryRaw instanceof PdfReference) { + $this->graph->set($categoryRaw->number, new PdfDictionary($entries)); + + return $name; + } + $items = $resources->all(); + $items[$category] = new PdfDictionary($entries); + if ($resourcesId !== null) { + $this->graph->set($resourcesId, new PdfDictionary($items)); + } else { + $this->graph->update($pageId, ['Resources' => new PdfDictionary($items)]); + } + + return $name; + } + + /** Append a content stream to the page (wrapping the existing content first). */ + public function appendContent(ReaderPage $page, string $content): void + { + $pageId = $this->graph->id($page->objectNumber); + $dict = $this->graph->dict($pageId); + if ($dict === null) { + return; + } + $raw = $dict->get('Contents'); + $streams = match (true) { + $raw instanceof PdfReference && is_array($this->graph->get($raw->number)) => $this->graph->array($raw) ?? [], + $raw instanceof PdfReference => [$raw], + is_array($raw) => array_values($raw), + default => [], + }; + + if (!isset($this->wrapped[$pageId])) { + $this->wrapped[$pageId] = true; + if ($streams !== []) { + array_unshift($streams, $this->graph->allocateStream(new PdfDictionary([]), "q\n")); + $content = "Q\n".$content; + } + } + $streams[] = $this->graph->allocateStream(new PdfDictionary([]), $content); + $this->graph->update($pageId, ['Contents' => $streams]); + } + + /** + * The affine transform `[a b c d e f]` from the page *as displayed* — top- + * left origin, y down, in points, over the visible CropBox and after + * `/Rotate` — to the page's default user space. + * + * @return array{float,float,float,float,float,float} + */ + public static function displayToUser(ReaderPage $page): array + { + [$x0, $y0, $x1, $y1] = $page->cropBox; + [$x0, $x1] = [min($x0, $x1), max($x0, $x1)]; + [$y0, $y1] = [min($y0, $y1), max($y0, $y1)]; + + // (dx, dy) on the displayed page → (X, Y) = (a·dx + c·dy + e, b·dx + d·dy + f) + return match ($page->rotate) { + 90 => [0.0, 1.0, 1.0, 0.0, $x0, $y0], + 180 => [-1.0, 0.0, 0.0, 1.0, $x1, $y0], + 270 => [0.0, -1.0, -1.0, 0.0, $x1, $y1], + default => [1.0, 0.0, 0.0, -1.0, $x0, $y1], + }; + } + + /** @return array{width: float, height: float} the page size as displayed */ + public static function displaySize(ReaderPage $page): array + { + $w = abs($page->cropBox[2] - $page->cropBox[0]); + $h = abs($page->cropBox[3] - $page->cropBox[1]); + + return $page->rotate % 180 === 0 ? ['width' => $w, 'height' => $h] : ['width' => $h, 'height' => $w]; + } + + /** + * The page's own `/Resources` value (inline dictionary or reference), + * materialising inherited resources onto the page first. + */ + private function ownResources(ReaderPage $page, int $pageId): mixed + { + $dict = $this->graph->dict($pageId); + $own = $dict?->get('Resources'); + if ($own !== null) { + return $own; + } + $inherited = $page->resources !== null + ? $this->graph->importer->importValue($page->resources) + : new PdfDictionary([]); + $this->graph->update($pageId, ['Resources' => $inherited]); + + return $inherited; + } +} diff --git a/src/Pdf/Forms/TextString.php b/src/Pdf/Forms/TextString.php new file mode 100644 index 0000000..1a0325f --- /dev/null +++ b/src/Pdf/Forms/TextString.php @@ -0,0 +1,72 @@ + 0x02D8, 0x19 => 0x02C7, 0x1A => 0x02C6, 0x1B => 0x02D9, 0x1C => 0x02DD, + 0x1D => 0x02DB, 0x1E => 0x02DA, 0x1F => 0x02DC, 0x80 => 0x2022, 0x81 => 0x2020, + 0x82 => 0x2021, 0x83 => 0x2026, 0x84 => 0x2014, 0x85 => 0x2013, 0x86 => 0x0192, + 0x87 => 0x2044, 0x88 => 0x2039, 0x89 => 0x203A, 0x8A => 0x2212, 0x8B => 0x2030, + 0x8C => 0x201E, 0x8D => 0x201C, 0x8E => 0x201D, 0x8F => 0x2018, 0x90 => 0x2019, + 0x91 => 0x201A, 0x92 => 0x2122, 0x93 => 0xFB01, 0x94 => 0xFB02, 0x95 => 0x0141, + 0x96 => 0x0152, 0x97 => 0x0160, 0x98 => 0x0178, 0x99 => 0x017D, 0x9A => 0x0131, + 0x9B => 0x0142, 0x9C => 0x0153, 0x9D => 0x0161, 0x9E => 0x017E, 0xA0 => 0x20AC, + ]; + + /** Decode a text string (or a name, as some writers store values) to UTF-8. */ + public static function decode(mixed $value): ?string + { + if ($value instanceof PdfName) { + return $value->value; + } + if (!$value instanceof PdfString) { + return null; + } + $bytes = $value->bytes; + + if (str_starts_with($bytes, "\xFE\xFF")) { + return (string) mb_convert_encoding(substr($bytes, 2), 'UTF-8', 'UTF-16BE'); + } + if (str_starts_with($bytes, "\xEF\xBB\xBF")) { + return substr($bytes, 3); + } + + $out = ''; + $length = strlen($bytes); + for ($i = 0; $i < $length; $i++) { + $code = ord($bytes[$i]); + $out .= mb_chr(self::PDF_DOC[$code] ?? $code, 'UTF-8'); + } + + return $out; + } + + /** + * Encode UTF-8 text as a text string: plain bytes while it is ASCII (the + * PDFDocEncoding subset every reader agrees on), UTF-16BE with a BOM + * otherwise. + */ + public static function encode(string $utf8): PdfString + { + if (preg_match('/^[\x09\x0A\x0D\x20-\x7E]*$/', $utf8) === 1) { + return new PdfString($utf8); + } + + return new PdfString("\xFE\xFF".mb_convert_encoding($utf8, 'UTF-16BE', 'UTF-8')); + } +} diff --git a/src/Pdf/Merge/ObjectImporter.php b/src/Pdf/Merge/ObjectImporter.php index d845dec..3dec2c7 100644 --- a/src/Pdf/Merge/ObjectImporter.php +++ b/src/Pdf/Merge/ObjectImporter.php @@ -45,6 +45,28 @@ public function useSource(ReaderDocument $source): void $this->sourceMap = []; } + /** + * Run `$import` against another source, then restore the current one with + * its dedup map intact — for pulling a few foreign objects (an embedded + * font, say) into a graph that is still being edited. + * + * @template T + * @param callable(self):T $import + * @return T + */ + public function withSource(ReaderDocument $source, callable $import): mixed + { + $savedSource = $this->source; + $savedMap = $this->sourceMap; + $this->useSource($source); + try { + return $import($this); + } finally { + $this->source = $savedSource; + $this->sourceMap = $savedMap; + } + } + /** * Reserve the next object ID and store a value at it directly (used for the * merger's own catalog / page-tree nodes). diff --git a/tests/Pdf/Forms/ExistingFormFillerShapesTest.php b/tests/Pdf/Forms/ExistingFormFillerShapesTest.php new file mode 100644 index 0000000..098ad48 --- /dev/null +++ b/tests/Pdf/Forms/ExistingFormFillerShapesTest.php @@ -0,0 +1,503 @@ +>\nstream\n0 0 14 14 re f\nendstream"; + + #[Test] + public function flatten_keeps_resources_the_page_inherits(): void + { + $pdf = new FormPdf(); + $pdf->pagesAttributes = '/MediaBox [0 0 612 792] /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >> >> >>'; + $pdf->pageAttributes = ''; + $pdf->textField('name'); + + $out = ExistingFormFiller::fromBytes($pdf->toBytes()) + ->setValue('name', 'Jane') + ->flatten() + ->stampImage(0, __DIR__.'/../../fixtures/sample.png', 10, 10, 20, 20) + ->toBytes(); + + $resources = ReaderDocument::fromBytes($out)->pages()[0]->resources; + $doc = ReaderDocument::fromBytes($out); + $fonts = $doc->deref($resources?->get('Font')); + self::assertInstanceOf(PdfDictionary::class, $fonts); + self::assertTrue($fonts->has('F1'), 'the inherited font must survive'); + $xobjects = $doc->deref($resources->get('XObject')); + self::assertInstanceOf(PdfDictionary::class, $xobjects); + self::assertCount(2, $xobjects->all(), 'the flattened field and the stamp'); + } + + #[Test] + public function existing_content_is_isolated_from_appended_drawing(): void + { + $pdf = new FormPdf(); + // Leaves a scaling transformation active, as some generators do. + $pdf->content = '0.5 0 0 0.5 0 0 cm BT /F1 12 Tf 72 760 Td (Scaled) Tj ET'; + $pdf->textField('name'); + + $out = ExistingFormFiller::fromBytes($pdf->toBytes())->setValue('name', 'Jane')->flatten()->toBytes(); + $doc = ReaderDocument::fromBytes($out); + $contents = $doc->deref($doc->pages()[0]->dict->get('Contents')); + self::assertIsArray($contents); + + $first = $doc->deref($contents[0]); + $last = $doc->deref($contents[count($contents) - 1]); + self::assertInstanceOf(PdfStream::class, $first); + self::assertInstanceOf(PdfStream::class, $last); + self::assertSame('q', trim($doc->streamData($first))); + self::assertStringStartsWith("Q\n", $doc->streamData($last)); + } + + #[Test] + public function auto_size_da_gets_a_real_font_size(): void + { + $pdf = new FormPdf(); + $pdf->textField('name', extra: '/DA (/Helv 0 Tf 0 g)'); + + $out = ExistingFormFiller::fromBytes($pdf->toBytes())->setValue('name', 'AutoSize')->flatten()->toBytes(); + $drawn = FormPdf::drawn(ReaderDocument::fromBytes($out)); + + self::assertMatchesRegularExpression('@/F0 (\d+(\.\d+)?) Tf@', $drawn); + preg_match('@/F0 (\d+(?:\.\d+)?) Tf@', $drawn, $m); + self::assertGreaterThan(4.0, (float) $m[1]); + self::assertStringContainsString('(AutoSize) Tj', $drawn); + } + + #[Test] + public function an_inline_dr_font_is_registered_with_the_appearance(): void + { + $pdf = new FormPdf(); + $pdf->acroFormAttributes = '/DR << /Font << /Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >>'; + $pdf->textField('name'); + + $out = ExistingFormFiller::fromBytes($pdf->toBytes())->setValue('name', 'Jane')->toBytes(); + $doc = ReaderDocument::fromBytes($out); + $ap = $doc->deref($doc->deref(self::annot($doc, 0)->get('AP'))->get('N')); + + self::assertInstanceOf(PdfStream::class, $ap); + $fonts = $doc->deref($doc->deref($ap->dict->get('Resources'))->get('Font')); + self::assertInstanceOf(PdfDictionary::class, $fonts->get('F0') instanceof PdfReference ? $doc->deref($fonts->get('F0')) : null); + self::assertStringContainsString('/F0 10 Tf', $doc->streamData($ap)); + } + + #[Test] + public function a_missing_dr_falls_back_to_the_standard_font(): void + { + $pdf = new FormPdf(); + $pdf->acroFormAttributes = ''; + $pdf->textField('name', extra: '/DA (/TiRo 11 Tf 0 g)'); + + $out = ExistingFormFiller::fromBytes($pdf->toBytes())->setValue('name', 'Jane')->flatten()->toBytes(); + + self::assertStringContainsString('/BaseFont /Times-Roman', $out); + self::assertStringContainsString('(Jane) Tj', FormPdf::drawn(ReaderDocument::fromBytes($out))); + } + + #[Test] + public function non_ascii_values_are_stored_as_utf16_text_strings(): void + { + $font = FormPdf::liberationSans() ?? self::markTestSkipped('Liberation fonts not fetched'); + $pdf = new FormPdf(); + $pdf->textField('name'); + + $out = ExistingFormFiller::fromBytes($pdf->toBytes())->useFont($font)->setValue('name', 'Иван Петров')->toBytes(); + $doc = ReaderDocument::fromBytes($out); + + $v = self::annot($doc, 0)->get('V'); + self::assertInstanceOf(PdfString::class, $v); + self::assertStringStartsWith("\xFE\xFF", $v->bytes); + self::assertSame('Иван Петров', ExistingFormFiller::fromBytes($out)->fields()['name']->value); + } + + #[Test] + public function non_ascii_values_are_drawn_with_an_embedded_unicode_font(): void + { + $font = FormPdf::liberationSans() ?? self::markTestSkipped('Liberation fonts not fetched'); + $pdf = new FormPdf(); + $pdf->textField('name'); + + $out = ExistingFormFiller::fromBytes($pdf->toBytes())->useFont($font)->setValue('name', 'Иван')->flatten()->toBytes(); + + self::assertStringContainsString('/Subtype /Type0', $out); + self::assertStringContainsString('/Encoding /Identity-H', $out); + self::assertMatchesRegularExpression('@<[0-9A-F]{16}> Tj@', FormPdf::drawn(ReaderDocument::fromBytes($out))); + } + + #[Test] + public function non_ascii_values_without_a_unicode_font_fail_clearly(): void + { + if (class_exists('Dskripchenko\\PhpPdfFontsLiberation\\LiberationFontProvider')) { + self::markTestSkipped('php-pdf-fonts-liberation is installed and supplies the font'); + } + $pdf = new FormPdf(); + $pdf->textField('name'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('useFont()'); + ExistingFormFiller::fromBytes($pdf->toBytes())->setValue('name', 'Иван'); + } + + #[Test] + public function latin1_values_stay_in_the_form_font(): void + { + $pdf = new FormPdf(); + $pdf->textField('name'); + + $out = ExistingFormFiller::fromBytes($pdf->toBytes())->setValue('name', 'Zoë Müller')->flatten()->toBytes(); + + self::assertStringNotContainsString('/Type0', $out); + self::assertStringContainsString('(Zo\\353 M\\374ller) Tj', FormPdf::drawn(ReaderDocument::fromBytes($out))); + } + + #[Test] + public function flatten_draws_the_checked_appearance_of_a_checkbox(): void + { + $pdf = new FormPdf(); + $on = $pdf->add(self::CHECKBOX_AP); + $off = $pdf->add("<< /Type /XObject /Subtype /Form /BBox [0 0 14 14] /Length 0 >>\nstream\n\nendstream"); + $box = $pdf->add(sprintf( + '<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /Rect [100 650 114 664] /P %d 0 R /F 4 /V /Off /AS /Off /AP << /N << /Ja %d 0 R /Off %d 0 R >> >> >>', + $pdf->page, + $on, + $off, + )); + $pdf->field($box)->widget($box); + + $out = ExistingFormFiller::fromBytes($pdf->toBytes())->setValue('agree', true)->flatten()->toBytes(); + $drawn = FormPdf::drawn(ReaderDocument::fromBytes($out)); + + self::assertStringContainsString('0 0 14 14 re f', $drawn, 'the /Ja (on) appearance is painted'); + self::assertMatchesRegularExpression('@q 1 0 0 1 100 650 cm /FlatAP\d+ Do Q@', $drawn); + } + + #[Test] + public function a_checkbox_without_appearance_gets_a_generated_mark(): void + { + $pdf = new FormPdf(); + $box = $pdf->add(sprintf( + '<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /Rect [100 650 114 664] /P %d 0 R /F 4 /V /Yes /AS /Yes /MK << /BC [0] >> >>', + $pdf->page, + )); + $pdf->field($box)->widget($box); + + $out = ExistingFormFiller::fromBytes($pdf->toBytes())->flatten()->toBytes(); + + self::assertStringContainsString('/BaseFont /ZapfDingbats', $out); + self::assertStringContainsString('(4) Tj', FormPdf::drawn(ReaderDocument::fromBytes($out))); + } + + #[Test] + public function hidden_widgets_are_not_drawn(): void + { + $pdf = new FormPdf(); + $pdf->textField('name', extra: '/DA (/Helv 10 Tf 0 g) /V (Secret)'); + $hidden = $pdf->add(sprintf( + '<< /Type /Annot /Subtype /Widget /FT /Tx /T (hidden) /Rect [100 600 300 620] /P %d 0 R /F 6 /DA (/Helv 10 Tf 0 g) /V (Invisible) >>', + $pdf->page, + )); + $pdf->field($hidden)->widget($hidden); + $pdf->acroFormAttributes .= ' /NeedAppearances true'; + + $drawn = FormPdf::drawn(ReaderDocument::fromBytes(ExistingFormFiller::fromBytes($pdf->toBytes())->flatten()->toBytes())); + + self::assertStringContainsString('(Secret) Tj', $drawn); + self::assertStringNotContainsString('Invisible', $drawn); + } + + #[Test] + public function stale_appearances_are_regenerated_when_the_form_asks_for_it(): void + { + $pdf = new FormPdf(); + $stale = $pdf->add("<< /Type /XObject /Subtype /Form /BBox [0 0 200 20] /Length 27 >>\nstream\nBT (Old value) Tj ET 0 0 m\nendstream"); + $pdf->textField('name', extra: sprintf('/DA (/Helv 10 Tf 0 g) /V (New value) /AP << /N %d 0 R >>', $stale)); + $pdf->acroFormAttributes .= ' /NeedAppearances true'; + + $drawn = FormPdf::drawn(ReaderDocument::fromBytes(ExistingFormFiller::fromBytes($pdf->toBytes())->flatten()->toBytes())); + + self::assertStringContainsString('(New value) Tj', $drawn); + self::assertStringNotContainsString('Old value', $drawn); + } + + #[Test] + public function an_existing_appearance_is_mapped_through_its_matrix(): void + { + $pdf = new FormPdf(); + // BBox 0..100 x 0..10 scaled 2x by /Matrix, placed on a 100x10 rect. + $ap = $pdf->add("<< /Type /XObject /Subtype /Form /BBox [0 0 100 10] /Matrix [2 0 0 2 0 0] /Length 13 >>\nstream\n0 0 10 10 re f\nendstream"); + $pdf->textField('name', '50 50 150 60', sprintf('/DA (/Helv 10 Tf 0 g) /AP << /N %d 0 R >>', $ap)); + + $drawn = FormPdf::drawn(ReaderDocument::fromBytes(ExistingFormFiller::fromBytes($pdf->toBytes())->flatten()->toBytes())); + + self::assertMatchesRegularExpression('@q 0\.5 0 0 0\.5 50 50 cm /FlatAP\d+ Do Q@', $drawn); + } + + #[Test] + public function comb_fields_place_one_character_per_cell(): void + { + $pdf = new FormPdf(); + $pdf->textField('code', '0 0 100 20', '/DA (/Helv 10 Tf 0 g) /Ff 16777216 /MaxLen 4'); + + $out = ExistingFormFiller::fromBytes($pdf->toBytes())->setValue('code', '1234')->flatten()->toBytes(); + $drawn = FormPdf::drawn(ReaderDocument::fromBytes($out)); + + foreach (['(1) Tj', '(2) Tj', '(3) Tj', '(4) Tj'] as $glyph) { + self::assertStringContainsString($glyph, $drawn); + } + // Helvetica digits are 556/1000 wide: cell 25pt, (25 - 5.56) / 2 = 9.72. + self::assertStringContainsString('1 0 0 1 9.72', $drawn); + self::assertStringContainsString('1 0 0 1 34.72', $drawn); + } + + #[Test] + public function max_length_is_enforced(): void + { + $pdf = new FormPdf(); + $pdf->textField('state', extra: '/DA (/Helv 10 Tf 0 g) /MaxLen 2'); + + $this->expectException(\InvalidArgumentException::class); + ExistingFormFiller::fromBytes($pdf->toBytes())->setValue('state', 'Texas'); + } + + #[Test] + public function multiline_text_is_wrapped_inside_the_field(): void + { + $pdf = new FormPdf(); + $pdf->textField('notes', '0 0 120 80', '/DA (/Helv 10 Tf 0 g) /Ff 4096'); + + $out = ExistingFormFiller::fromBytes($pdf->toBytes()) + ->setValue('notes', "First paragraph that is long enough to wrap\nSecond") + ->flatten() + ->toBytes(); + + self::assertGreaterThanOrEqual(3, substr_count(FormPdf::drawn(ReaderDocument::fromBytes($out)), ' Tj')); + } + + #[Test] + public function a_rotated_widget_gets_a_rotated_appearance(): void + { + $pdf = new FormPdf(); + $pdf->textField('vertical', '0 0 20 100', '/DA (/Helv 10 Tf 0 g) /MK << /R 90 >>'); + + $doc = ReaderDocument::fromBytes(ExistingFormFiller::fromBytes($pdf->toBytes())->setValue('vertical', 'Up')->toBytes()); + $ap = $doc->deref($doc->deref(self::annot($doc, 0)->get('AP'))->get('N')); + + self::assertInstanceOf(PdfStream::class, $ap); + self::assertSame([0, 0, 100, 20], $ap->dict->get('BBox')); + self::assertSame([0, 1, -1, 0, 20, 0], $ap->dict->get('Matrix')); + } + + #[Test] + public function choice_values_are_validated_against_opt(): void + { + $pdf = new FormPdf(); + $pdf->textField('car', extra: '/FT /Ch /Ff 131072 /DA (/Helv 10 Tf 0 g) /Opt [[(hon) (Honda)] [(toy) (Toyota)]]'); + $filler = ExistingFormFiller::fromBytes($pdf->toBytes()); + + self::assertSame(['hon', 'toy'], $filler->fields()['car']->options); + self::assertSame(['hon' => 'Honda', 'toy' => 'Toyota'], $filler->fields()['car']->optionLabels); + + $out = $filler->setValue('car', 'Toyota')->flatten()->toBytes(); + self::assertStringContainsString('(Toyota) Tj', FormPdf::drawn(ReaderDocument::fromBytes($out))); + + $this->expectException(\InvalidArgumentException::class); + ExistingFormFiller::fromBytes($pdf->toBytes())->setValue('car', 'Lada'); + } + + #[Test] + public function a_multi_select_list_box_takes_several_values(): void + { + $pdf = new FormPdf(); + $pdf->textField('langs', '0 0 100 60', '/FT /Ch /Ff 2097152 /DA (/Helv 10 Tf 0 g) /Opt [(en) (de) (ru)]'); + + $out = ExistingFormFiller::fromBytes($pdf->toBytes())->setValue('langs', ['ru', 'en'])->toBytes(); + $doc = ReaderDocument::fromBytes($out); + $widget = self::annot($doc, 0); + + self::assertSame([0, 2], $widget->get('I')); + self::assertSame(['ru', 'en'], ExistingFormFiller::fromBytes($out)->fields()['langs']->value); + } + + #[Test] + public function radio_options_come_from_opt_when_present(): void + { + $pdf = new FormPdf(); + $on = $pdf->add(self::CHECKBOX_AP); + $group = $pdf->reserve(); + $kids = []; + foreach (['0', '1'] as $i => $state) { + $kids[] = $kid = $pdf->add(sprintf( + '<< /Type /Annot /Subtype /Widget /Parent %d 0 R /Rect [%d 650 %d 664] /P %d 0 R /F 4 /AS /Off /AP << /N << /%s %d 0 R /Off %d 0 R >> >> >>', + $group, + 100 + 20 * $i, + 114 + 20 * $i, + $pdf->page, + $state, + $on, + $on, + )); + $pdf->widget($kid); + } + $pdf->set($group, sprintf('<< /FT /Btn /Ff 49152 /T (plan) /V /Off /Opt [(Basic) (Premium)] /Kids [%d 0 R %d 0 R] >>', ...$kids)); + $pdf->field($group); + + $filler = ExistingFormFiller::fromBytes($pdf->toBytes()); + self::assertSame(['Basic', 'Premium'], $filler->fields()['plan']->options); + + $doc = ReaderDocument::fromBytes($filler->setValue('plan', 'premium')->toBytes()); + self::assertSame('Off', self::annot($doc, 0)->get('AS')->value); + self::assertSame('1', self::annot($doc, 1)->get('AS')->value); + } + + #[Test] + public function unknown_button_values_are_rejected(): void + { + $pdf = new FormPdf(); + $on = $pdf->add(self::CHECKBOX_AP); + $box = $pdf->add(sprintf( + '<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /Rect [100 650 114 664] /P %d 0 R /AS /Off /AP << /N << /Yes %d 0 R /Off %d 0 R >> >> >>', + $pdf->page, + $on, + $on, + )); + $pdf->field($box)->widget($box); + + $this->expectException(\InvalidArgumentException::class); + ExistingFormFiller::fromBytes($pdf->toBytes())->setValue('agree', 'maybe'); + } + + #[Test] + public function signature_and_push_button_fields_cannot_be_set(): void + { + $pdf = new FormPdf(); + $sig = $pdf->add(sprintf('<< /Type /Annot /Subtype /Widget /FT /Sig /T (sig) /Rect [0 0 100 30] /P %d 0 R >>', $pdf->page)); + $pdf->field($sig)->widget($sig); + $filler = ExistingFormFiller::fromBytes($pdf->toBytes()); + + self::assertSame('signature', $filler->fields()['sig']->type); + $this->expectException(\LogicException::class); + $filler->setValue('sig', 'x'); + } + + #[Test] + public function flattening_every_leaf_of_a_deep_hierarchy_drops_the_acroform(): void + { + $pdf = new FormPdf(); + $root = $pdf->reserve(); + $middle = $pdf->reserve(); + $leaf = $pdf->add(sprintf( + '<< /Type /Annot /Subtype /Widget /FT /Tx /T (street) /Parent %d 0 R /Rect [0 0 100 20] /P %d 0 R /DA (/Helv 10 Tf 0 g) >>', + $middle, + $pdf->page, + )); + $pdf->set($middle, sprintf('<< /T (home) /Parent %d 0 R /Kids [%d 0 R] >>', $root, $leaf)); + $pdf->set($root, sprintf('<< /T (address) /Kids [%d 0 R] >>', $middle)); + $pdf->field($root)->widget($leaf); + + $filler = ExistingFormFiller::fromBytes($pdf->toBytes()); + self::assertArrayHasKey('address.home.street', $filler->fields()); + + $out = $filler->setValue('address.home.street', 'Main St')->flatten(['address.home.street'])->toBytes(); + + self::assertSame([], ExistingFormFiller::fromBytes($out)->fields()); + self::assertArrayNotHasKey('AcroForm', ReaderDocument::fromBytes($out)->catalog()->all()); + } + + #[Test] + public function xfa_is_dropped_once_values_change(): void + { + $pdf = new FormPdf(); + $pdf->textField('name'); + $pdf->acroFormAttributes .= ' /XFA [(template) (