Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.54% covered (warning)
83.54%
132 / 158
72.00% covered (warning)
72.00%
18 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
FormFiller
83.54% covered (warning)
83.54%
132 / 158
72.00% covered (warning)
72.00%
18 / 25
101.74
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 open
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 openString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFieldNames
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFieldInfo
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
3.00
 getFieldValues
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 hasField
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fill
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 fillMany
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 check
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 select
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 save
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toBytes
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
6
 getVersionWarnings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getReader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPageCount
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 discoverFields
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
6.20
 walkField
75.00% covered (warning)
75.00%
21 / 28
0.00% covered (danger)
0.00%
0 / 1
20.00
 resolve
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 resolveFieldType
40.00% covered (danger)
40.00%
4 / 10
0.00% covered (danger)
0.00%
0 / 1
10.40
 extractValue
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 extractFlags
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 extractRect
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
11.10
 extractMaxLen
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 extractOptions
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
7.99
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Pdf\Toolkit;
6
7use Phpdftk\Pdf\Core\File\IncrementalWriter;
8use Phpdftk\Filesystem\LocalFilesystem;
9use Phpdftk\Pdf\Core\PdfArray;
10use Phpdftk\Pdf\Core\PdfDictionary;
11use Phpdftk\Pdf\Core\PdfName;
12use Phpdftk\Pdf\Core\PdfNumber;
13use Phpdftk\Pdf\Core\PdfObject;
14use Phpdftk\Pdf\Core\PdfReference;
15use Phpdftk\Pdf\Core\PdfString;
16use Phpdftk\Pdf\Core\Serializable;
17use Phpdftk\Pdf\Reader\PdfReader;
18use Phpdftk\Pdf\Toolkit\Form\FieldInfo;
19use Phpdftk\Pdf\Toolkit\Form\FieldType;
20
21/**
22 * Fill interactive PDF form fields (AcroForm).
23 *
24 * Uses incremental updates to preserve the original PDF structure,
25 * existing signatures, and other content.
26 *
27 * Usage:
28 *   FormFiller::open('form.pdf')
29 *       ->fill('name', 'Jane Doe')
30 *       ->check('subscribe')
31 *       ->select('country', 'Canada')
32 *       ->save('filled.pdf');
33 *
34 * @api
35 */
36final class FormFiller
37{
38    private string $originalBytes;
39
40    /** @var list<string> */
41    private array $lastVersionWarnings = [];
42
43    /**
44     * Resolved field data: fully-qualified name => [objNum, dict].
45     * @var array<string, array{int, PdfDictionary}>
46     */
47    private array $fields = [];
48
49    /** @var array<string, mixed> Pending modifications: field name => new value */
50    private array $modifications = [];
51
52    private function __construct(
53        private readonly PdfReader $reader,
54        string $originalBytes,
55    ) {
56        $this->originalBytes = $originalBytes;
57        $this->discoverFields();
58    }
59
60    public static function open(string $path, string $password = ''): self
61    {
62        $bytes = LocalFilesystem::readFile($path);
63        return new self(PdfReader::fromString($bytes, $password), $bytes);
64    }
65
66    public static function openString(string $pdfBytes, string $password = ''): self
67    {
68        return new self(PdfReader::fromString($pdfBytes, $password), $pdfBytes);
69    }
70
71    // -----------------------------------------------------------------------
72    // Read
73    // -----------------------------------------------------------------------
74
75    /**
76     * Get all field names in the form.
77     *
78     * @return list<string>
79     */
80    public function getFieldNames(): array
81    {
82        return array_keys($this->fields);
83    }
84
85    /**
86     * Get detailed information about a specific field.
87     */
88    public function getFieldInfo(string $name): ?FieldInfo
89    {
90        if (!isset($this->fields[$name])) {
91            return null;
92        }
93
94        [, $dict] = $this->fields[$name];
95
96        $type = $this->resolveFieldType($dict);
97        if ($type === null) {
98            return null;
99        }
100
101        return new FieldInfo(
102            name: $name,
103            type: $type,
104            value: $this->extractValue($dict),
105            flags: $this->extractFlags($dict),
106            rect: $this->extractRect($dict),
107            maxLen: $this->extractMaxLen($dict),
108            options: $this->extractOptions($dict),
109        );
110    }
111
112    /**
113     * Get all field values as an associative array.
114     *
115     * @return array<string, string|null>
116     */
117    public function getFieldValues(): array
118    {
119        $values = [];
120        foreach ($this->fields as $name => [, $dict]) {
121            $values[$name] = $this->extractValue($dict);
122        }
123        return $values;
124    }
125
126    /**
127     * Check whether a field exists in the form.
128     */
129    public function hasField(string $name): bool
130    {
131        return isset($this->fields[$name]);
132    }
133
134    // -----------------------------------------------------------------------
135    // Write (fluent)
136    // -----------------------------------------------------------------------
137
138    /**
139     * Set the value of a text or choice field.
140     */
141    public function fill(string $fieldName, string $value): self
142    {
143        if (!isset($this->fields[$fieldName])) {
144            throw new \InvalidArgumentException("Field not found: $fieldName");
145        }
146        $this->modifications[$fieldName] = ['type' => 'text', 'value' => $value];
147        return $this;
148    }
149
150    /**
151     * Fill multiple fields at once.
152     *
153     * @param array<string, string> $values Field name => value
154     */
155    public function fillMany(array $values): self
156    {
157        foreach ($values as $name => $value) {
158            $this->fill($name, $value);
159        }
160        return $this;
161    }
162
163    /**
164     * Check or uncheck a checkbox field.
165     */
166    public function check(string $fieldName, bool $checked = true): self
167    {
168        if (!isset($this->fields[$fieldName])) {
169            throw new \InvalidArgumentException("Field not found: $fieldName");
170        }
171        $this->modifications[$fieldName] = ['type' => 'check', 'checked' => $checked];
172        return $this;
173    }
174
175    /**
176     * Select an option in a choice field.
177     */
178    public function select(string $fieldName, string $option): self
179    {
180        if (!isset($this->fields[$fieldName])) {
181            throw new \InvalidArgumentException("Field not found: $fieldName");
182        }
183        $this->modifications[$fieldName] = ['type' => 'select', 'value' => $option];
184        return $this;
185    }
186
187    // -----------------------------------------------------------------------
188    // Output
189    // -----------------------------------------------------------------------
190
191    public function save(string $path): void
192    {
193        LocalFilesystem::writeFile($path, $this->toBytes(), createDirectories: true);
194    }
195
196    public function toBytes(): string
197    {
198        if (empty($this->modifications)) {
199            return $this->originalBytes;
200        }
201
202        $writer = IncrementalWriter::fromReader($this->reader, $this->originalBytes);
203
204        foreach ($this->modifications as $fieldName => $mod) {
205            [$objNum, $dict] = $this->fields[$fieldName];
206
207            // Clone the dictionary so we don't mutate the cached version
208            $modifiedDict = new PdfDictionary($dict->entries);
209
210            match ($mod['type']) {
211                'text', 'select' => $modifiedDict->set('V', new PdfString($mod['value'])),
212                'check' => $modifiedDict->set(
213                    'V',
214                    $mod['checked'] ? new PdfName('Yes') : new PdfName('Off'),
215                ),
216            };
217
218            // Create a PdfObject wrapper with the original object number
219            $wrapper = new class ($modifiedDict) extends PdfObject {
220                public function __construct(private readonly PdfDictionary $dict) {}
221                public function toPdf(): string
222                {
223                    return $this->dict->toPdf();
224                }
225            };
226            $wrapper->objectNumber = $objNum;
227            $wrapper->generationNumber = 0;
228
229            $writer->addModifiedObject($wrapper);
230        }
231
232        $result = $writer->generate();
233        $this->lastVersionWarnings = $writer->getVersionWarnings();
234        return $result;
235    }
236
237    // -----------------------------------------------------------------------
238    // Escape hatches
239    // -----------------------------------------------------------------------
240
241    /** @return list<string> */
242    public function getVersionWarnings(): array
243    {
244        return $this->lastVersionWarnings;
245    }
246
247    public function getReader(): PdfReader
248    {
249        return $this->reader;
250    }
251
252    public function getPageCount(): int
253    {
254        return $this->reader->getPageCount();
255    }
256
257    // -----------------------------------------------------------------------
258    // Internal: field discovery
259    // -----------------------------------------------------------------------
260
261    /**
262     * Walk the AcroForm /Fields array and build the field index.
263     */
264    private function discoverFields(): void
265    {
266        $trailer = $this->reader->getTrailer();
267        $rootRef = $trailer->get('Root');
268        if (!$rootRef instanceof PdfReference) {
269            return;
270        }
271
272        $catalog = $this->reader->resolveReference($rootRef);
273        if (!$catalog instanceof PdfDictionary) {
274            return;
275        }
276
277        $acroFormVal = $catalog->get('AcroForm');
278        $acroForm = $this->resolve($acroFormVal);
279        if (!$acroForm instanceof PdfDictionary) {
280            return;
281        }
282
283        $fieldsVal = $acroForm->get('Fields');
284        $fieldsArray = $this->resolve($fieldsVal);
285        if (!$fieldsArray instanceof PdfArray) {
286            return;
287        }
288
289        foreach ($fieldsArray->items as $fieldRef) {
290            $this->walkField($fieldRef, '');
291        }
292    }
293
294    /**
295     * Recursively walk a field and its /Kids, building fully-qualified names.
296     */
297    private function walkField(mixed $fieldRefOrDict, string $parentName): void
298    {
299        $fieldDict = $this->resolve($fieldRefOrDict);
300        if (!$fieldDict instanceof PdfDictionary) {
301            return;
302        }
303
304        // Determine object number for this field
305        $objNum = 0;
306        if ($fieldRefOrDict instanceof PdfReference) {
307            $objNum = $fieldRefOrDict->objectNumber;
308        }
309
310        // Build fully-qualified name
311        $partialName = '';
312        $tVal = $fieldDict->get('T');
313        if ($tVal instanceof PdfString) {
314            $partialName = $tVal->value;
315        }
316
317        $fullName = $parentName !== '' && $partialName !== ''
318            ? $parentName . '.' . $partialName
319            : ($partialName !== '' ? $partialName : $parentName);
320
321        // Check for /Kids
322        $kidsVal = $fieldDict->get('Kids');
323        $kids = $this->resolve($kidsVal);
324
325        if ($kids instanceof PdfArray && !empty($kids->items)) {
326            // Check if kids are widget annotations (have /Subtype /Widget but no /T)
327            // or child fields (have /T)
328            $hasFieldKids = false;
329            foreach ($kids->items as $kidRef) {
330                $kidDict = $this->resolve($kidRef);
331                if ($kidDict instanceof PdfDictionary && $kidDict->has('T')) {
332                    $hasFieldKids = true;
333                    break;
334                }
335            }
336
337            if ($hasFieldKids) {
338                // Recurse into child fields
339                foreach ($kids->items as $kidRef) {
340                    $this->walkField($kidRef, $fullName);
341                }
342                return;
343            }
344        }
345
346        // This is a terminal field (leaf node) — index it
347        if ($fullName !== '' && $objNum > 0) {
348            $this->fields[$fullName] = [$objNum, $fieldDict];
349        }
350    }
351
352    /**
353     * Resolve a value that might be a PdfReference to the actual object.
354     */
355    private function resolve(mixed $val): mixed
356    {
357        if ($val instanceof PdfReference) {
358            return $this->reader->resolveReference($val);
359        }
360        return $val;
361    }
362
363    // -----------------------------------------------------------------------
364    // Internal: field property extraction
365    // -----------------------------------------------------------------------
366
367    private function resolveFieldType(PdfDictionary $dict): ?FieldType
368    {
369        $ft = $dict->get('FT');
370
371        // If not on this dict, check /Parent (inherited field type)
372        if ($ft === null) {
373            $parentRef = $dict->get('Parent');
374            if ($parentRef instanceof PdfReference) {
375                $parent = $this->resolve($parentRef);
376                if ($parent instanceof PdfDictionary) {
377                    $ft = $parent->get('FT');
378                }
379            }
380        }
381
382        if ($ft instanceof PdfName) {
383            return FieldType::tryFrom($ft->value);
384        }
385
386        return null;
387    }
388
389    private function extractValue(PdfDictionary $dict): ?string
390    {
391        $v = $dict->get('V');
392        if ($v instanceof PdfString) {
393            return $v->value;
394        }
395        if ($v instanceof PdfName) {
396            return $v->value;
397        }
398        return null;
399    }
400
401    private function extractFlags(PdfDictionary $dict): int
402    {
403        $ff = $dict->get('Ff');
404        if ($ff instanceof PdfNumber) {
405            return (int) $ff->toPdf();
406        }
407        return 0;
408    }
409
410    private function extractRect(PdfDictionary $dict): ?array
411    {
412        $rect = $dict->get('Rect');
413        if (!$rect instanceof PdfArray) {
414            return null;
415        }
416
417        $floats = [];
418        foreach ($rect->items as $item) {
419            if ($item instanceof PdfNumber) {
420                $floats[] = (float) $item->toPdf();
421            }
422        }
423
424        return count($floats) === 4 ? $floats : null;
425    }
426
427    private function extractMaxLen(PdfDictionary $dict): ?int
428    {
429        $ml = $dict->get('MaxLen');
430        if ($ml instanceof PdfNumber) {
431            return (int) $ml->toPdf();
432        }
433        return null;
434    }
435
436    /**
437     * @return string[]|null
438     */
439    private function extractOptions(PdfDictionary $dict): ?array
440    {
441        $opt = $dict->get('Opt');
442        if (!$opt instanceof PdfArray) {
443            return null;
444        }
445
446        $options = [];
447        foreach ($opt->items as $item) {
448            if ($item instanceof PdfString) {
449                $options[] = $item->value;
450            } elseif ($item instanceof PdfArray && count($item->items) >= 2) {
451                // [export_value, display_value] pairs — use display value
452                $display = $item->items[1] ?? $item->items[0];
453                $options[] = $display instanceof PdfString ? $display->value : '';
454            }
455        }
456
457        return $options;
458    }
459}