Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.84% covered (warning)
77.84%
130 / 167
83.33% covered (warning)
83.33%
10 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
AnnotationFlattener
77.84% covered (warning)
77.84%
130 / 167
83.33% covered (warning)
83.33%
10 / 12
90.11
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
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
 flattenAll
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 flattenType
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 flattenForms
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 save
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toBytes
76.00% covered (warning)
76.00%
114 / 150
0.00% covered (danger)
0.00%
0 / 1
70.76
 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
 toFloat
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Pdf\Toolkit;
6
7use Phpdftk\Pdf\Core\Content\ContentStream;
8use Phpdftk\Pdf\Core\File\IncrementalWriter;
9use Phpdftk\Filesystem\LocalFilesystem;
10use Phpdftk\Pdf\Core\PdfArray;
11use Phpdftk\Pdf\Core\PdfDictionary;
12use Phpdftk\Pdf\Core\PdfName;
13use Phpdftk\Pdf\Core\PdfNumber;
14use Phpdftk\Pdf\Core\PdfObject;
15use Phpdftk\Pdf\Core\PdfReference;
16use Phpdftk\Pdf\Core\PdfStream;
17use Phpdftk\Pdf\Reader\PdfReader;
18use Phpdftk\Pdf\Toolkit\Internal\PageResolver;
19
20/**
21 * Flatten annotations into page content, making them non-interactive.
22 *
23 * Usage:
24 *   AnnotationFlattener::open('form.pdf')
25 *       ->flattenAll()
26 *       ->save('flat.pdf');
27 *
28 * @api
29 */
30final class AnnotationFlattener
31{
32    private string $originalBytes;
33
34    /** @var list<array{type: string, args: array}> */
35    private array $operations = [];
36
37    /** @var list<string> */
38    private array $lastVersionWarnings = [];
39
40    private function __construct(
41        private readonly PdfReader $reader,
42        string $originalBytes,
43    ) {
44        $this->originalBytes = $originalBytes;
45    }
46
47    public static function open(string $path, string $password = ''): self
48    {
49        $bytes = LocalFilesystem::readFile($path);
50        return new self(PdfReader::fromString($bytes, $password), $bytes);
51    }
52
53    public static function openString(string $pdfBytes, string $password = ''): self
54    {
55        return new self(PdfReader::fromString($pdfBytes, $password), $pdfBytes);
56    }
57
58    // -----------------------------------------------------------------------
59    // Operations
60    // -----------------------------------------------------------------------
61
62    public function flattenAll(?PageSelector $pages = null): self
63    {
64        $this->operations[] = ['type' => 'all', 'args' => ['pages' => $pages]];
65        return $this;
66    }
67
68    public function flattenType(string ...$subtypes): self
69    {
70        $this->operations[] = ['type' => 'subtypes', 'args' => ['subtypes' => $subtypes, 'pages' => null]];
71        return $this;
72    }
73
74    public function flattenForms(?PageSelector $pages = null): self
75    {
76        $this->operations[] = ['type' => 'subtypes', 'args' => ['subtypes' => ['Widget'], 'pages' => $pages]];
77        return $this;
78    }
79
80    // -----------------------------------------------------------------------
81    // Output
82    // -----------------------------------------------------------------------
83
84    public function save(string $path): void
85    {
86        LocalFilesystem::writeFile($path, $this->toBytes(), createDirectories: true);
87    }
88
89    public function toBytes(): string
90    {
91        if (empty($this->operations)) {
92            return $this->originalBytes;
93        }
94
95        $writer = IncrementalWriter::fromReader($this->reader, $this->originalBytes);
96        $pageRefs = PageResolver::getPageReferences($this->reader);
97        $totalPages = count($pageRefs);
98
99        for ($i = 0; $i < $totalPages; $i++) {
100            $pageNum = $i + 1;
101
102            // Check if any operation targets this page
103            $shouldFlatten = false;
104            $allowedSubtypes = null; // null = all
105
106            foreach ($this->operations as $op) {
107                $selector = $op['args']['pages'] ?? null;
108                if ($selector !== null && !$selector->matches($pageNum, $totalPages)) {
109                    continue;
110                }
111                $shouldFlatten = true;
112                if ($op['type'] === 'subtypes' && $allowedSubtypes !== null) {
113                    $allowedSubtypes = array_merge($allowedSubtypes, $op['args']['subtypes']);
114                } elseif ($op['type'] === 'all') {
115                    $allowedSubtypes = null; // flatten everything
116                } elseif ($op['type'] === 'subtypes' && $allowedSubtypes === null) {
117                    // First subtype filter
118                    $allowedSubtypes = $op['args']['subtypes'];
119                }
120            }
121
122            if (!$shouldFlatten) {
123                continue;
124            }
125
126            $pageDict = $this->reader->getPage($i);
127            $annots = $pageDict->get('Annots');
128            if (!$annots instanceof PdfArray || empty($annots->items)) {
129                continue;
130            }
131
132            $flattenedOps = [];
133            $remainingAnnots = [];
134            $xObjectResources = [];
135            $xoCounter = 0;
136
137            foreach ($annots->items as $annotRef) {
138                if (!$annotRef instanceof PdfReference) {
139                    $remainingAnnots[] = $annotRef;
140                    continue;
141                }
142
143                $annotDict = $this->reader->resolveReference($annotRef);
144                if (!$annotDict instanceof PdfDictionary) {
145                    $remainingAnnots[] = $annotRef;
146                    continue;
147                }
148
149                // Check subtype filter
150                $subtype = $annotDict->get('Subtype');
151                $subtypeStr = $subtype instanceof PdfName ? $subtype->value : '';
152
153                if ($allowedSubtypes !== null && !in_array($subtypeStr, $allowedSubtypes, true)) {
154                    $remainingAnnots[] = $annotRef;
155                    continue;
156                }
157
158                // Get the annotation's normal appearance stream
159                $ap = $annotDict->get('AP');
160                if (!$ap instanceof PdfDictionary) {
161                    // No appearance â€” keep as-is
162                    $remainingAnnots[] = $annotRef;
163                    continue;
164                }
165
166                $normalAp = $ap->get('N');
167
168                // For checkboxes/radios, /N might be a dict of states; use /AS to pick
169                if ($normalAp instanceof PdfDictionary && !$normalAp->has('Type')) {
170                    $as = $annotDict->get('AS');
171                    if ($as instanceof PdfName && $normalAp->has($as->value)) {
172                        $normalAp = $normalAp->get($as->value);
173                    } else {
174                        // No matching state
175                        $remainingAnnots[] = $annotRef;
176                        continue;
177                    }
178                }
179
180                if (!$normalAp instanceof PdfReference) {
181                    $remainingAnnots[] = $annotRef;
182                    continue;
183                }
184
185                // Get the rect for positioning
186                $rect = $annotDict->get('Rect');
187                if (!$rect instanceof PdfArray || count($rect->items) < 4) {
188                    $remainingAnnots[] = $annotRef;
189                    continue;
190                }
191
192                $x1 = $this->toFloat($rect->items[0]);
193                $y1 = $this->toFloat($rect->items[1]);
194                $x2 = $this->toFloat($rect->items[2]);
195                $y2 = $this->toFloat($rect->items[3]);
196                $w = abs($x2 - $x1);
197                $h = abs($y2 - $y1);
198                $xMin = min($x1, $x2);
199                $yMin = min($y1, $y2);
200
201                // Register the appearance XObject and invoke it
202                $xoName = 'FlatXO' . $xoCounter++;
203                $xObjectResources[$xoName] = $normalAp;
204
205                // Resolve the appearance's BBox to compute the transformation matrix
206                $apStream = $this->reader->resolveReference($normalAp);
207                $apBBox = null;
208                if ($apStream instanceof PdfDictionary) {
209                    $bBox = $apStream->get('BBox');
210                    if ($bBox instanceof PdfArray && count($bBox->items) >= 4) {
211                        $apBBox = [
212                            $this->toFloat($bBox->items[0]),
213                            $this->toFloat($bBox->items[1]),
214                            $this->toFloat($bBox->items[2]),
215                            $this->toFloat($bBox->items[3]),
216                        ];
217                    }
218                } elseif ($apStream instanceof PdfStream) {
219                    $bBox = $apStream->dictionary->get('BBox');
220                    if ($bBox instanceof PdfArray && count($bBox->items) >= 4) {
221                        $apBBox = [
222                            $this->toFloat($bBox->items[0]),
223                            $this->toFloat($bBox->items[1]),
224                            $this->toFloat($bBox->items[2]),
225                            $this->toFloat($bBox->items[3]),
226                        ];
227                    }
228                }
229
230                // Build transformation matrix to map BBox to Rect
231                if ($apBBox !== null) {
232                    $bw = abs($apBBox[2] - $apBBox[0]);
233                    $bh = abs($apBBox[3] - $apBBox[1]);
234                    $sx = $bw > 0 ? $w / $bw : 1;
235                    $sy = $bh > 0 ? $h / $bh : 1;
236                    $tx = $xMin - $apBBox[0] * $sx;
237                    $ty = $yMin - $apBBox[1] * $sy;
238                    $flattenedOps[] = sprintf(
239                        "q %.4f 0 0 %.4f %.4f %.4f cm /%s Do Q",
240                        $sx,
241                        $sy,
242                        $tx,
243                        $ty,
244                        $xoName,
245                    );
246                } else {
247                    $flattenedOps[] = sprintf(
248                        "q 1 0 0 1 %.4f %.4f cm /%s Do Q",
249                        $xMin,
250                        $yMin,
251                        $xoName,
252                    );
253                }
254            }
255
256            if (empty($flattenedOps)) {
257                continue;
258            }
259
260            // Create new content stream with the flattened appearances
261            $cs = new ContentStream();
262            $cs->raw(implode("\n", $flattenedOps));
263            $csRef = $writer->addNewObject($cs);
264
265            // Add content stream to page
266            $existingContents = $pageDict->get('Contents');
267            $contentsArray = [];
268            if ($existingContents instanceof PdfReference) {
269                $contentsArray[] = $existingContents;
270            } elseif ($existingContents instanceof PdfArray) {
271                $contentsArray = $existingContents->items;
272            }
273            $contentsArray[] = $csRef;
274            $pageDict->set('Contents', new PdfArray($contentsArray));
275
276            // Update annotations
277            if (empty($remainingAnnots)) {
278                $pageDict->entries = array_filter(
279                    $pageDict->entries,
280                    fn($k) => $k !== 'Annots',
281                    ARRAY_FILTER_USE_KEY,
282                );
283            } else {
284                $pageDict->set('Annots', new PdfArray($remainingAnnots));
285            }
286
287            // Add XObject resources to page
288            $existingRes = $pageDict->get('Resources');
289            if ($existingRes instanceof PdfDictionary) {
290                $xoDict = $existingRes->get('XObject');
291                if (!$xoDict instanceof PdfDictionary) {
292                    $xoDict = new PdfDictionary();
293                    $existingRes->set('XObject', $xoDict);
294                }
295                foreach ($xObjectResources as $name => $ref) {
296                    $xoDict->set($name, $ref);
297                }
298            }
299
300            // Create modified page object
301            $pageObj = new class ($pageDict) extends PdfObject {
302                public function __construct(private readonly PdfDictionary $dict) {}
303                public function toPdf(): string
304                {
305                    return $this->dict->toPdf();
306                }
307            };
308            $pageObj->objectNumber = $pageRefs[$i]->objectNumber;
309            $pageObj->generationNumber = 0;
310            $writer->addModifiedObject($pageObj);
311        }
312
313        $result = $writer->generate();
314        $this->lastVersionWarnings = $writer->getVersionWarnings();
315        return $result;
316    }
317
318    // -----------------------------------------------------------------------
319    // Escape hatches
320    // -----------------------------------------------------------------------
321
322    /** @return list<string> */
323    public function getVersionWarnings(): array
324    {
325        return $this->lastVersionWarnings;
326    }
327
328    public function getReader(): PdfReader
329    {
330        return $this->reader;
331    }
332
333    public function getPageCount(): int
334    {
335        return $this->reader->getPageCount();
336    }
337
338    // -----------------------------------------------------------------------
339    // Internal
340    // -----------------------------------------------------------------------
341
342    private function toFloat(mixed $val): float
343    {
344        if ($val instanceof PdfNumber) {
345            return (float) $val->toPdf();
346        }
347        return (float) $val;
348    }
349}