Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.33% covered (success)
94.33%
399 / 423
87.50% covered (warning)
87.50%
42 / 48
CRAP
0.00% covered (danger)
0.00%
0 / 1
PdfWriter
94.33% covered (success)
94.33%
399 / 423
87.50% covered (warning)
87.50%
42 / 48
121.59
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getCatalog
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPageTree
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFonts
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getContentStreams
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setInfo
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addPage
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
2
 addFont
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
10
 buildEncoderFor
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 addCompositeFont
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
4
 addOpenTypeFont
96.43% covered (success)
96.43%
108 / 112
0.00% covered (danger)
0.00%
0 / 1
18
 addContentStream
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 addImage
59.46% covered (warning)
59.46%
22 / 37
0.00% covered (danger)
0.00%
0 / 1
19.06
 setOutline
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addOutlineItem
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setPageLabels
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setNamedDestinations
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fileWriter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addImageInternal
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setSigner
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setTsaClient
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setTimestamper
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setEncryption
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPdfVersion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setStrictVersionMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCeilingVersion
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setDeprecationHandler
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setStrictDeprecation
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getVersionWarnings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getEncodingWarnings
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 setLinearized
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setConformance
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setConformanceProfiles
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkConformance
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 getConformanceResults
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 generate
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 toBytes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 writeTo
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 save
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 collectFirstPageObjectNumbers
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
7.10
 setMetadata
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 syncInfoToMetadata
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 doc
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 applyConformance
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
6
 embedTrueTypeFont
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
5
 embedType1Font
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
2
 buildToUnicodeCMap
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Pdf\Writer;
6
7use Phpdftk\Pdf\Core\Content\ContentStream;
8use Phpdftk\Pdf\Core\Content\Resources;
9use Phpdftk\Pdf\Core\Document\Catalog;
10use Phpdftk\Pdf\Core\Document\Destination;
11use Phpdftk\Pdf\Core\Document\Info;
12use Phpdftk\Pdf\Core\Document\Outline;
13use Phpdftk\Pdf\Core\Document\OutlineItem;
14use Phpdftk\Pdf\Core\Document\Page as CorePage;
15use Phpdftk\Pdf\Core\Document\PageLabel;
16use Phpdftk\Pdf\Core\Document\PageTree;
17use Phpdftk\Filesystem\LocalFilesystem;
18use Phpdftk\Pdf\Core\File\PdfFileWriter;
19use Phpdftk\Pdf\Core\Font\CIDFontType0Font;
20use Phpdftk\Pdf\Core\Font\CIDSystemInfo;
21use Phpdftk\Pdf\Core\Font\Font as CoreFont;
22use Phpdftk\Pdf\Core\Font\FontDescriptor;
23use Phpdftk\Pdf\Core\Font\FontFile\CFFFontFile;
24use Phpdftk\Pdf\Core\Font\FontFile\Type1FontFile;
25use Phpdftk\Pdf\Core\Font\TrueTypeFont;
26use Phpdftk\Pdf\Core\Font\Type1Font;
27use Phpdftk\FontParser\TrueTypeSubsetter;
28use Phpdftk\Encoding\TextEncoder;
29use Phpdftk\Encoding\WinAnsiEncoder;
30use Phpdftk\Pdf\Core\Font\Type0Font;
31use Phpdftk\Pdf\Core\Font\Type0FontFactory;
32use Phpdftk\Pdf\Core\Interactive\Signature\Pkcs7Signer;
33use Phpdftk\Pdf\Core\Interactive\Signature\SignatureValue;
34use Phpdftk\Pdf\Core\Interactive\Signature\TsaClient;
35use Phpdftk\Pdf\Conformance\ConformanceException;
36use Phpdftk\Pdf\Conformance\ConformanceMode;
37use Phpdftk\Pdf\Conformance\Inspection\WriterDocumentInspector;
38use Phpdftk\Pdf\Conformance\Metadata\ConformanceXmpWriter;
39use Phpdftk\Pdf\Conformance\Profile\ConformanceProfile;
40use Phpdftk\Pdf\Conformance\Result\ConformanceResult;
41use Phpdftk\Pdf\Conformance\Validator\ConformanceValidator;
42use Phpdftk\Pdf\Core\Security\PdfEncryptor;
43use Phpdftk\Pdf\Core\PdfArray;
44use Phpdftk\Pdf\Core\PdfDictionary;
45use Phpdftk\Pdf\Core\PdfName;
46use Phpdftk\Pdf\Core\PdfNumber;
47use Phpdftk\Pdf\Core\PdfObject;
48use Phpdftk\Pdf\Core\PdfReference;
49use Phpdftk\Pdf\Core\PdfStream;
50use Phpdftk\Pdf\Core\PdfVersion;
51use Phpdftk\Geometry\Rectangle;
52use Phpdftk\ImageMetadata\ImageParser;
53use Phpdftk\Pdf\Core\Graphics\ColorSpace\ICCBased;
54
55/**
56 * Ergonomic PDF document builder.
57 *
58 * `PdfWriter` is the friendly facade: it owns a {@see PdfFileWriter}
59 * under the hood and provides one method per "thing a user wants to
60 * put in a document" â€” pages, fonts, content streams, images,
61 * bookmarks, page labels, named destinations, signatures.
62 *
63 * The byte-level file-assembly logic (header, xref, trailer,
64 * signature patching) lives in `PdfFileWriter` in the core package.
65 *
66 * Usage:
67 *   $writer = new PdfWriter();
68 *   $page   = $writer->addPage(612, 792);
69 *   $font   = $writer->addFont(new Type1Font(StandardFont::Helvetica));
70 *   $cs     = $writer->addContentStream($page);
71 *   $cs->beginText()->setFont('F1', 12)->moveTextPosition(72, 720)->showText('Hi')->endText();
72 *   $writer->save('/path/to/output.pdf');
73 *
74 * @api
75 */
76class PdfWriter
77{
78    private PdfFileWriter $file;
79    private Catalog $catalog;
80    private PageTree $pageTree;
81
82    /** @var CorePage[] */
83    private array $pages = [];
84
85    /** @var array<string, CoreFont|Type0Font> keyed by resource name (F1, F2, â€¦) */
86    private array $fonts = [];
87
88    /**
89     * @var array<string, TextEncoder> resource name â†’ encoder.
90     * Tracked alongside $fonts so getEncodingWarnings() can collect missing
91     * codepoints across the whole document without holding onto Font handles.
92     */
93    private array $fontEncoders = [];
94
95    /** @var array<int, ContentStream> */
96    private array $contentStreams = [];
97
98    /** Running counter for font resource names */
99    private int $fontCounter = 0;
100
101    /** Running counter for image resource names */
102    private int $imageCounter = 0;
103
104    /** Whether to produce linearized (web-optimized) output */
105    private bool $linearized = false;
106
107    /** Active conformance mode, if any. */
108    private ?ConformanceMode $conformanceMode = null;
109
110    /** @var list<ConformanceResult> */
111    private array $conformanceResults = [];
112
113    /**
114     * Lazily-cached PdfDoc view of this writer, used by the deprecated
115     * forwarding stubs below.
116     */
117    private ?PdfDoc $cachedDoc = null;
118
119    public function __construct(bool $compressStreams = true, PdfVersion|string $version = PdfFileWriter::DEFAULT_PDF_VERSION)
120    {
121        $this->file = new PdfFileWriter($compressStreams, version: $version);
122        $this->catalog = new Catalog();
123        $this->file->setCatalog($this->catalog);
124
125        $this->pageTree = new PageTree();
126        $this->file->register($this->pageTree);
127
128        // Wire up catalog -> page tree
129        $this->catalog->pages = new PdfReference($this->pageTree->objectNumber);
130    }
131
132    // -----------------------------------------------------------------------
133    // Public API
134    // -----------------------------------------------------------------------
135
136    public function getCatalog(): Catalog
137    {
138        return $this->catalog;
139    }
140
141    public function getPageTree(): PageTree
142    {
143        return $this->pageTree;
144    }
145
146    /**
147     * Return all registered fonts, keyed by resource name.
148     *
149     * @return array<string, CoreFont|Type0Font>
150     */
151    public function getFonts(): array
152    {
153        return $this->fonts;
154    }
155
156    /**
157     * Return all content streams added to the document.
158     *
159     * @return array<int, ContentStream>
160     */
161    public function getContentStreams(): array
162    {
163        return $this->contentStreams;
164    }
165
166    /**
167     * @deprecated Use {@see PdfDoc::setInfo()} instead. This forwarder is
168     *             retained for one minor release and will be removed.
169     */
170    public function setInfo(Info $info): void
171    {
172        $this->doc()->setInfo($info);
173    }
174
175    /**
176     * Add a new page. Accepts either a Rectangle (from phpdftk/geometry) or
177     * explicit width/height floats. Default is US Letter (612×792 pt).
178     */
179    public function addPage(Rectangle|float $widthOrRect = 612, float $height = 792): Page
180    {
181        if ($widthOrRect instanceof Rectangle) {
182            $width  = $widthOrRect->width;
183            $height = $widthOrRect->height;
184        } else {
185            $width = $widthOrRect;
186        }
187
188        $corePage = new CorePage();
189        $corePage->parent = new PdfReference($this->pageTree->objectNumber);
190        $corePage->mediaBox = new PdfArray([
191            new PdfNumber(0),
192            new PdfNumber(0),
193            new PdfNumber($width),
194            new PdfNumber($height),
195        ]);
196        $corePage->resources = new Resources();
197
198        $this->file->register($corePage);
199        $this->pages[] = $corePage;
200
201        // Update page tree
202        $this->pageTree->kids[] = new PdfReference($corePage->objectNumber);
203        $this->pageTree->count  = count($this->pages);
204
205        return new Page($corePage, $this);
206    }
207
208    /**
209     * Register a font, auto-assign a resource name (F1, F2, â€¦), and return the name.
210     * The font is added to ALL existing pages' resources. For per-page fonts, add
211     * directly to page->resources.
212     */
213    public function addFont(CoreFont $font, CorePage|Page|null $page = null): Font
214    {
215        $this->fontCounter++;
216        $name = 'F' . $this->fontCounter;
217
218        $parsedData = null;
219        if ($font instanceof TrueTypeFont && $font->parsedFontData !== null) {
220            $this->embedTrueTypeFont($font);
221            $parsedData = $font->parsedFontData;
222        } elseif ($font instanceof Type1Font && $font->parsedFontData !== null) {
223            $this->embedType1Font($font);
224        }
225
226        $this->file->register($font);
227        $this->fonts[$name] = $font;
228        $ref = new PdfReference($font->objectNumber);
229
230        $corePage = $page instanceof Page ? $page->corePage() : $page;
231        if ($corePage !== null) {
232            $corePage->resources?->addFont($name, $ref);
233        } else {
234            // Add to all existing pages
235            foreach ($this->pages as $p) {
236                $p->resources?->addFont($name, $ref);
237            }
238        }
239
240        $family = $font->baseFont !== null ? $font->baseFont->value : 'Unknown';
241
242        $encoder = $this->buildEncoderFor($font);
243        if ($encoder !== null) {
244            $this->fontEncoders[$name] = $encoder;
245        }
246
247        return new Font($name, $family, $parsedData, $encoder);
248    }
249
250    /**
251     * Pick the right text encoder for a font being registered. WinAnsi for
252     * Latin-script Type1 standard fonts and any TrueType font (the writer
253     * embeds those with /Encoding /WinAnsiEncoding); null for everything
254     * else. Composite/CID fonts go through a separate registration path
255     * (addCompositeFont), so they never reach this method.
256     */
257    private function buildEncoderFor(CoreFont $font): ?TextEncoder
258    {
259        if ($font instanceof TrueTypeFont) {
260            return new WinAnsiEncoder();
261        }
262        if ($font instanceof Type1Font) {
263            $base = $font->baseFont?->value;
264            if ($base === 'Symbol' || $base === 'ZapfDingbats') {
265                return null;
266            }
267            return new WinAnsiEncoder();
268        }
269        return null;
270    }
271
272    /**
273     * Build and register a Type 0 composite font from TrueType font data.
274     *
275     * Creates the full CID font stack: Type0Font -> CIDFontType2 -> FontDescriptor -> FontFile2,
276     * plus a ToUnicode CMap. The font is subset to include only the glyphs needed for the
277     * given codepoints.
278     *
279     * @param \Phpdftk\FontParser\TrueTypeData $data      Parsed TrueType font data
280     * @param int[]                              $usedCodepoints Unicode codepoints used in the document
281     * @param CorePage|Page|null                 $page      If set, add font only to this page
282     * @return Font Opaque font handle
283     */
284    public function addCompositeFont(\Phpdftk\FontParser\TrueTypeData $data, array $usedCodepoints, CorePage|Page|null $page = null): Font
285    {
286        $this->fontCounter++;
287        $name = 'F' . $this->fontCounter;
288
289        [$type0Font, $additionalObjects, $fontStream, $descriptor, $cidFont, $toUnicodeStream, $unicodeToGid] =
290            Type0FontFactory::fromTrueTypeData($data, $usedCodepoints);
291
292        // Register all objects
293        $this->file->register($fontStream);
294        $descriptor->fontFile2 = new PdfReference($fontStream->objectNumber);
295
296        $this->file->register($descriptor);
297        $cidFont->fontDescriptor = new PdfReference($descriptor->objectNumber);
298
299        $this->file->register($cidFont);
300        $type0Font->descendantFonts = new PdfArray([new PdfReference($cidFont->objectNumber)]);
301
302        $this->file->register($toUnicodeStream);
303        $type0Font->toUnicode = new PdfReference($toUnicodeStream->objectNumber);
304
305        $this->file->register($type0Font);
306        $this->fonts[$name] = $type0Font;
307        $ref = new PdfReference($type0Font->objectNumber);
308
309        $corePage = $page instanceof Page ? $page->corePage() : $page;
310        if ($corePage !== null) {
311            $corePage->resources?->addFont($name, $ref);
312        } else {
313            foreach ($this->pages as $p) {
314                $p->resources?->addFont($name, $ref);
315            }
316        }
317
318        return new Font($name, $data->postScriptName, $data, unicodeToGid: $unicodeToGid);
319    }
320
321    /**
322     * Build and register an OpenType CFF composite font.
323     *
324     * Creates the Type 0 â†’ CIDFontType0 â†’ FontDescriptor â†’ CFFFontFile
325     * stack with a ToUnicode CMap for text extraction.
326     *
327     * @param \Phpdftk\FontParser\OpenTypeData $data Parsed OpenType font data
328     * @param int[] $usedCodepoints Unicode codepoints used in the document
329     * @param CorePage|Page|null $page If set, add font only to this page
330     * @return Font Opaque font handle
331     */
332    public function addOpenTypeFont(
333        \Phpdftk\FontParser\OpenTypeData $data,
334        array $usedCodepoints,
335        CorePage|Page|null $page = null,
336    ): Font {
337        $this->fontCounter++;
338        $name = 'F' . $this->fontCounter;
339
340        // Font descriptor
341        $descriptor = new FontDescriptor(new PdfName($data->postScriptName));
342        $descriptor->flags = $data->flags;
343        $descriptor->fontBBox = new PdfArray([
344            new PdfNumber($data->fontBBox[0]),
345            new PdfNumber($data->fontBBox[1]),
346            new PdfNumber($data->fontBBox[2]),
347            new PdfNumber($data->fontBBox[3]),
348        ]);
349        $descriptor->italicAngle = $data->italicAngle;
350        $descriptor->ascent = $data->ascent;
351        $descriptor->descent = $data->descent;
352        $descriptor->capHeight = $data->capHeight;
353        $descriptor->xHeight = $data->xHeight;
354        $descriptor->stemV = $data->stemV;
355
356        // Subset CFF table to only include used glyphs.
357        $usedGids = [];
358        $codepointsByOldGid = [];
359        foreach ($usedCodepoints as $cp) {
360            $gid = $data->fullUnicodeToGid[$cp] ?? null;
361            if ($gid !== null) {
362                $usedGids[] = $gid;
363                $codepointsByOldGid[$gid] = $cp;
364            }
365        }
366        $cffSubsetter = new \Phpdftk\FontParser\CffSubsetter();
367        $cffBytes = $cffSubsetter->subset($data->cffBytes, $usedGids);
368        $cffGidMap = $cffSubsetter->getGidMap();
369
370        // Post-subset Unicode â†’ new GID map. Drives the /W array, the
371        // ToUnicode CMap, and the Font handle accessor below.
372        $unicodeToGidSubset = [];
373        foreach ($codepointsByOldGid as $oldGid => $cp) {
374            $newGid = $cffGidMap[$oldGid] ?? null;
375            if ($newGid !== null) {
376                $unicodeToGidSubset[$cp] = $newGid;
377            }
378        }
379
380        // CFF font program stream (embed subsetted CFF table bytes)
381        $cffStream = new CFFFontFile($cffBytes, 'CIDFontType0C');
382        $this->file->register($cffStream);
383        $descriptor->fontFile3 = new PdfReference($cffStream->objectNumber);
384        $this->file->register($descriptor);
385
386        // CID font
387        $cidSystemInfo = new CIDSystemInfo('Adobe', 'Identity', 0);
388        $cidFont = new CIDFontType0Font($data->postScriptName, $cidSystemInfo);
389        $cidFont->fontDescriptor = new PdfReference($descriptor->objectNumber);
390
391        // Build /W widths array, indexed by post-subset CID/GID.
392        $scale = fn(int $v): int => (int) round($v * 1000 / $data->unitsPerEm);
393        $wEntries = [];
394        foreach ($codepointsByOldGid as $oldGid => $cp) {
395            $newGid = $cffGidMap[$oldGid] ?? null;
396            if ($newGid !== null && isset($data->glyphWidths[$oldGid])) {
397                $wEntries[$newGid] = new PdfNumber($scale($data->glyphWidths[$oldGid]));
398            }
399        }
400        if (!empty($wEntries)) {
401            ksort($wEntries);
402            $wArray = [];
403            $currentRun = [];
404            $currentStart = -1;
405            $lastGid = -2;
406            foreach ($wEntries as $gid => $width) {
407                if ($gid !== $lastGid + 1) {
408                    if (!empty($currentRun)) {
409                        $wArray[] = new PdfNumber($currentStart);
410                        $wArray[] = new PdfArray($currentRun);
411                    }
412                    $currentStart = $gid;
413                    $currentRun = [$width];
414                } else {
415                    $currentRun[] = $width;
416                }
417                $lastGid = $gid;
418            }
419            $wArray[] = new PdfNumber($currentStart);
420            $wArray[] = new PdfArray($currentRun);
421            $cidFont->w = new PdfArray($wArray);
422        }
423
424        $this->file->register($cidFont);
425
426        // ToUnicode CMap â€” keyed by post-subset GID so text extraction
427        // sees the same identifiers the viewer renders against.
428        $gidToUnicode = [];
429        foreach ($unicodeToGidSubset as $cp => $newGid) {
430            $gidToUnicode[$newGid] = $cp;
431        }
432        ksort($gidToUnicode);
433        $cmapEntries = [];
434        foreach ($gidToUnicode as $gid => $unicode) {
435            $cmapEntries[] = sprintf('<%04X> <%04X>', $gid, $unicode);
436        }
437        $cmapChunks = array_chunk($cmapEntries, 100);
438        $cmapBlocks = '';
439        foreach ($cmapChunks as $chunk) {
440            $cmapBlocks .= count($chunk) . " beginbfchar\n"
441                . implode("\n", $chunk) . "\n"
442                . "endbfchar\n";
443        }
444        $cmapProgram = "/CIDInit /ProcSet findresource begin\n"
445            . "12 dict begin\n"
446            . "begincmap\n"
447            . "/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n"
448            . "/CMapName /Adobe-Identity-UCS def\n"
449            . "/CMapType 2 def\n"
450            . "1 begincodespacerange\n"
451            . "<0000> <FFFF>\n"
452            . "endcodespacerange\n"
453            . $cmapBlocks
454            . "endcmap\n"
455            . "CMap end\n"
456            . "end";
457        $toUnicodeStream = new PdfStream(new PdfDictionary(), $cmapProgram);
458        $this->file->register($toUnicodeStream);
459
460        // Type 0 font
461        $type0Font = new Type0Font(
462            $data->postScriptName,
463            new PdfArray([new PdfReference($cidFont->objectNumber)]),
464            'Identity-H',
465        );
466        $type0Font->toUnicode = new PdfReference($toUnicodeStream->objectNumber);
467        $this->file->register($type0Font);
468
469        $this->fonts[$name] = $type0Font;
470        $ref = new PdfReference($type0Font->objectNumber);
471
472        $corePage = $page instanceof Page ? $page->corePage() : $page;
473        if ($corePage !== null) {
474            $corePage->resources?->addFont($name, $ref);
475        } else {
476            foreach ($this->pages as $p) {
477                $p->resources?->addFont($name, $ref);
478            }
479        }
480
481        return new Font(
482            $name,
483            $data->postScriptName,
484            $data,
485            unicodeToGid: $unicodeToGidSubset,
486            oldToNewGid: $cffGidMap,
487        );
488    }
489
490    /**
491     * Create a content stream, register it, and attach it to a page.
492     */
493    public function addContentStream(CorePage|Page $page): ContentStream
494    {
495        $corePage = $page instanceof Page ? $page->corePage() : $page;
496        $cs = new ContentStream();
497        $this->file->register($cs);
498        $corePage->contents[] = new PdfReference($cs->objectNumber);
499        $this->contentStreams[] = $cs;
500        return $cs;
501    }
502
503    /**
504     * Add an image to a page as an XObject, using ImageParser to detect format.
505     * Returns the resource name (e.g. 'Im1') for use in content streams.
506     */
507    public function addImage(string $path, CorePage|Page $page): string
508    {
509        $corePage = $page instanceof Page ? $page->corePage() : $page;
510        $info = ImageParser::parse($path);
511        $data = LocalFilesystem::readFile($path);
512
513        $this->imageCounter++;
514        $name = 'Im' . $this->imageCounter;
515
516        $dict = new PdfDictionary([
517            'Type'             => new PdfName('XObject'),
518            'Subtype'          => new PdfName('Image'),
519            'Width'            => new PdfNumber($info->width),
520            'Height'           => new PdfNumber($info->height),
521            'ColorSpace'       => new PdfName($info->colorSpace),
522            'BitsPerComponent' => new PdfNumber($info->bitsPerComponent),
523        ]);
524
525        // Set the appropriate decode filter for pass-through image formats
526        match ($info->format) {
527            'jpeg' => $dict->set('Filter', new PdfName('DCTDecode')),
528            'jpeg2000' => $dict->set('Filter', new PdfName('JPXDecode')),
529            'jbig2' => $dict->set('Filter', new PdfName('JBIG2Decode')),
530            default => null,
531        };
532
533        // If the image has an embedded ICC profile, replace the color space
534        // with an ICCBased color space reference
535        if ($info->iccProfile !== null) {
536            $nComponents = match ($info->colorSpace) {
537                'DeviceGray' => 1,
538                'DeviceCMYK' => 4,
539                default => 3, // DeviceRGB
540            };
541            $profileDict = new PdfDictionary([
542                'N' => new PdfNumber($nComponents),
543            ]);
544            $profileStream = new PdfStream($profileDict, $info->iccProfile);
545            $this->file->register($profileStream);
546            $profileRef = new PdfReference($profileStream->objectNumber);
547            $iccColorSpace = new ICCBased($profileRef);
548            $dict->set('ColorSpace', $iccColorSpace);
549        }
550
551        $xObject = new PdfStream($dict, $data);
552        $this->file->register($xObject);
553        $ref = new PdfReference($xObject->objectNumber);
554
555        // Add XObject resource to the page
556        if ($corePage->resources !== null) {
557            $corePage->resources->addXObject($name, $ref);
558        }
559
560        return $name;
561    }
562
563    /**
564     * @deprecated Use {@see PdfDoc::setOutline()} instead. This forwarder
565     *             is retained for one minor release and will be removed.
566     */
567    public function setOutline(Outline $outline): Outline
568    {
569        return $this->doc()->setOutline($outline);
570    }
571
572    /**
573     * @deprecated Use {@see PdfDoc::addOutlineItem()} instead. This
574     *             forwarder is retained for one minor release.
575     */
576    public function addOutlineItem(OutlineItem $item): PdfReference
577    {
578        return $this->doc()->addOutlineItem($item);
579    }
580
581    /**
582     * @deprecated Use {@see PdfDoc::setPageLabels()} instead. This
583     *             forwarder is retained for one minor release.
584     *
585     * @param array<int, PageLabel> $labels
586     */
587    public function setPageLabels(array $labels): void
588    {
589        $this->doc()->setPageLabels($labels);
590    }
591
592    /**
593     * @deprecated Use {@see PdfDoc::setNamedDestinations()} instead. This
594     *             forwarder is retained for one minor release.
595     *
596     * @param array<string, Destination> $destinations
597     */
598    public function setNamedDestinations(array $destinations): void
599    {
600        $this->doc()->setNamedDestinations($destinations);
601    }
602
603    /**
604     * Escape hatch to Level 0 â€” returns the underlying PdfFileWriter
605     * for direct object-model control.
606     */
607    public function fileWriter(): PdfFileWriter
608    {
609        return $this->file;
610    }
611
612    /**
613     * Register any arbitrary PdfObject (annotations, form fields, etc.).
614     */
615    public function register(PdfObject $object): PdfReference
616    {
617        return $this->file->register($object);
618    }
619
620    /**
621     * Add an image to a page as an XObject (internal â€” used by Writer\Page).
622     *
623     * @internal
624     * @return string Resource name (e.g. 'Im1')
625     */
626    public function addImageInternal(string $path, CorePage $page): string
627    {
628        return $this->addImage($path, $page);
629    }
630
631    /**
632     * Configure digital signing for this document.
633     *
634     * The signing lifecycle works in three phases:
635     *   1. **Placeholder:** A SignatureValue dictionary is emitted with a
636     *      zeroed /Contents hex string large enough to hold the final
637     *      PKCS#7 DER blob (`$placeholderBytes` controls the size).
638     *   2. **Byte-range:** After the full PDF is assembled, the /ByteRange
639     *      array is patched to cover everything except the /Contents value
640     *      itself, so the signature covers the entire file.
641     *   3. **Patch:** The Pkcs7Signer signs the byte-range data and the
642     *      resulting DER is written into the /Contents placeholder.
643     *
644     * @see PdfFileWriter::setSigner()
645     */
646    public function setSigner(
647        SignatureValue $signatureValue,
648        Pkcs7Signer $signer,
649        int $placeholderBytes = 8192,
650    ): void {
651        $this->file->setSigner($signatureValue, $signer, $placeholderBytes);
652    }
653
654    /**
655     * Configure a TSA client for RFC 3161 timestamping.
656     *
657     * @see PdfFileWriter::setTsaClient()
658     */
659    public function setTsaClient(TsaClient $tsaClient): void
660    {
661        $this->file->setTsaClient($tsaClient);
662    }
663
664    /**
665     * Configure a document-level timestamp using a TSA client.
666     *
667     * @see PdfFileWriter::setTimestamper()
668     */
669    public function setTimestamper(
670        SignatureValue $docTimeStamp,
671        TsaClient $tsaClient,
672        int $placeholderBytes = 16384,
673    ): void {
674        $this->file->setTimestamper($docTimeStamp, $tsaClient, $placeholderBytes);
675    }
676
677    /**
678     * Configure encryption for the generated PDF.
679     *
680     * Registers the encrypt dictionary, and during generation all
681     * strings and streams are encrypted per-object with the correct
682     * key derivation. The /Encrypt reference is added to the trailer
683     * automatically.
684     *
685     * @see PdfEncryptor::aes128()
686     * @see PdfEncryptor::aes256()
687     * @see PdfEncryptor::rc4128()
688     */
689    public function setEncryption(PdfEncryptor $encryptor): void
690    {
691        $this->file->setEncryption($encryptor);
692    }
693
694    public function getPdfVersion(): PdfVersion
695    {
696        return $this->file->getPdfVersion();
697    }
698
699    public function setStrictVersionMode(bool $strict = true): void
700    {
701        $this->file->setStrictVersionMode($strict);
702    }
703
704    public function setCeilingVersion(?PdfVersion $ceiling): void
705    {
706        $this->file->setCeilingVersion($ceiling);
707    }
708
709    public function setDeprecationHandler(\Closure $handler): void
710    {
711        $this->file->setDeprecationHandler($handler);
712    }
713
714    public function setStrictDeprecation(bool $strict = true): void
715    {
716        $this->file->setStrictDeprecation($strict);
717    }
718
719    /** @return list<string> */
720    public function getVersionWarnings(): array
721    {
722        return $this->file->getVersionWarnings();
723    }
724
725    /**
726     * Diagnostics for codepoints that were substituted with `?` because the
727     * font's encoding could not represent them. Empty when every glyph
728     * landed cleanly. Each entry names the font resource, the codepoint,
729     * and how many times it was requested.
730     *
731     * @return list<string>
732     */
733    public function getEncodingWarnings(): array
734    {
735        $warnings = [];
736        foreach ($this->fontEncoders as $resourceName => $encoder) {
737            $missing = $encoder->getMissingCodepoints();
738            if ($missing === []) {
739                continue;
740            }
741            $counts = array_count_values($missing);
742            foreach ($counts as $cp => $count) {
743                $warnings[] = sprintf(
744                    'Font %s: codepoint U+%04X has no WinAnsi mapping (substituted ? %dx)',
745                    $resourceName,
746                    $cp,
747                    $count,
748                );
749            }
750        }
751        return $warnings;
752    }
753
754    /**
755     * Enable or disable linearized (web-optimized) PDF output.
756     *
757     * When enabled, the generated PDF places the first page's objects at
758     * the front of the file, allowing a viewer to display it before
759     * downloading the rest (ISO 32000-2 Annex F).
760     */
761    public function setLinearized(bool $linearized = true): void
762    {
763        $this->linearized = $linearized;
764    }
765
766    /**
767     * Set one or more conformance profiles (e.g. PDF/A-1b, PDF/UA-1).
768     *
769     * When set, `generate()` will:
770     *   1. Auto-inject XMP identification metadata (if not already present)
771     *   2. Pin the PDF version to the profile minimum
772     *   3. Run all applicable constraint checks
773     *   4. In strict mode (default): throw ConformanceException on errors
774     *   5. In lenient mode: collect results in getConformanceResults()
775     *
776     * @param bool $strict Throw on conformance errors (default true)
777     */
778    public function setConformance(ConformanceProfile $profile, bool $strict = true): void
779    {
780        $this->conformanceMode = new ConformanceMode([$profile], $strict);
781    }
782
783    /**
784     * Set multiple conformance profiles at once (e.g. PDF/A-2a + PDF/UA-1).
785     *
786     * @param ConformanceProfile[] $profiles
787     * @param bool $strict Throw on conformance errors (default true)
788     */
789    public function setConformanceProfiles(array $profiles, bool $strict = true): void
790    {
791        $this->conformanceMode = new ConformanceMode($profiles, $strict);
792    }
793
794    /**
795     * Run conformance checks without generating the PDF.
796     *
797     * @return list<ConformanceResult>
798     */
799    public function checkConformance(): array
800    {
801        if ($this->conformanceMode === null) {
802            return [];
803        }
804
805        $inspector = new WriterDocumentInspector(
806            $this->catalog,
807            $this->file,
808            $this->fonts,
809        );
810
811        $validator = new ConformanceValidator();
812        return $validator->validateAll($inspector, $this->conformanceMode->profiles);
813    }
814
815    /**
816     * Get the conformance results from the last generate() call.
817     *
818     * @return list<ConformanceResult>
819     */
820    public function getConformanceResults(): array
821    {
822        return $this->conformanceResults;
823    }
824
825    /**
826     * Generate the complete PDF as a binary string.
827     */
828    public function generate(): string
829    {
830        if ($this->conformanceMode !== null) {
831            $this->applyConformance();
832        }
833
834        if ($this->linearized) {
835            return $this->file->generateLinearized($this->collectFirstPageObjectNumbers());
836        }
837        return $this->file->generate();
838    }
839
840    /**
841     * Alias for {@see generate()} â€” returns the raw PDF bytes as a string.
842     */
843    public function toBytes(): string
844    {
845        return $this->generate();
846    }
847
848    /**
849     * Write the generated PDF to an open stream resource.
850     *
851     * @param resource $stream
852     */
853    public function writeTo($stream): int
854    {
855        if (!is_resource($stream)) {
856            throw new \InvalidArgumentException(
857                'PdfWriter::writeTo() expects an open stream resource',
858            );
859        }
860        $pdf = $this->generate();
861        $written = fwrite($stream, $pdf);
862        if ($written === false) {
863            throw new \RuntimeException('Failed to write PDF bytes to stream');
864        }
865        return $written;
866    }
867
868    /**
869     * Write the PDF to a file, creating parent directories as needed.
870     */
871    public function save(string $path): void
872    {
873        $pdf = $this->generate();
874        LocalFilesystem::writeFile($path, $pdf, createDirectories: true);
875    }
876
877    /**
878     * Collect object numbers belonging to the first page for linearization.
879     *
880     * Includes the catalog, page tree, first page, its content streams,
881     * and all fonts/images referenced by the first page's resources.
882     *
883     * @return list<int>
884     */
885    private function collectFirstPageObjectNumbers(): array
886    {
887        $nums = [];
888
889        // Catalog and page tree are always first-page objects
890        $nums[] = $this->catalog->objectNumber;
891        $nums[] = $this->pageTree->objectNumber;
892
893        // First page and its content streams
894        if (!empty($this->pages)) {
895            $firstPage = $this->pages[0];
896            $nums[] = $firstPage->objectNumber;
897
898            foreach ($firstPage->contents as $ref) {
899                $nums[] = $ref->objectNumber;
900            }
901
902            // Fonts and images registered on the first page's resources
903            if ($firstPage->resources !== null) {
904                foreach ($firstPage->resources->font as $ref) {
905                    $nums[] = $ref->objectNumber;
906                }
907                foreach ($firstPage->resources->xObject as $ref) {
908                    $nums[] = $ref->objectNumber;
909                }
910            }
911        }
912
913        // Info dict if present
914        if ($this->file->getInfo() !== null) {
915            $nums[] = $this->file->getInfo()->objectNumber;
916        }
917
918        return $nums;
919    }
920
921    /**
922     * @deprecated Use {@see PdfDoc::setMetadata()} instead. This
923     *             forwarder is retained for one minor release.
924     */
925    public function setMetadata(string $xmpXml): void
926    {
927        $this->doc()->setMetadata($xmpXml);
928    }
929
930    /**
931     * @deprecated Use {@see PdfDoc::syncInfoToMetadata()} instead. This
932     *             forwarder is retained for one minor release.
933     */
934    public function syncInfoToMetadata(): void
935    {
936        $this->doc()->syncInfoToMetadata();
937    }
938
939    /**
940     * Lazily-constructed PdfDoc view over this writer, used by the
941     * deprecated forwarding stubs above. New code should use PdfDoc
942     * directly.
943     */
944    private function doc(): PdfDoc
945    {
946        return $this->cachedDoc ??= PdfDoc::wrap($this);
947    }
948
949    // -----------------------------------------------------------------------
950    // Private helpers
951    // -----------------------------------------------------------------------
952
953    /**
954     * Apply conformance: auto-inject XMP, pin version, validate.
955     */
956    private function applyConformance(): void
957    {
958        $mode = $this->conformanceMode;
959
960        foreach ($mode->profiles as $profile) {
961            // Pin PDF version to profile minimum
962            $required = $profile->getPdfVersion();
963            if ($required->isGreaterThan($this->file->getPdfVersion())) {
964                $this->file->setVersion($required);
965            }
966
967            // Auto-inject XMP identification if not already present
968            if (!$this->catalog->metadata) {
969                $info = $this->file->getInfo();
970                $xmpWriter = new ConformanceXmpWriter();
971                $xmp = $xmpWriter->buildXmp(
972                    $profile,
973                    title: $info?->title->value ?? '',
974                    creator: $info?->author->value ?? '',
975                    producer: $info?->producer->value ?? 'phpdftk',
976                );
977                $this->setMetadata($xmp);
978            }
979        }
980
981        // Run validation
982        $this->conformanceResults = $this->checkConformance();
983
984        // In strict mode, throw on any non-compliant result
985        if ($mode->strict) {
986            $failures = array_filter(
987                $this->conformanceResults,
988                static fn(ConformanceResult $r) => !$r->isCompliant,
989            );
990            if ($failures !== []) {
991                throw new ConformanceException(array_values($failures));
992            }
993        }
994    }
995
996    private function embedTrueTypeFont(TrueTypeFont $font): void
997    {
998        $data = $font->parsedFontData;
999
1000        // 1. Font program stream â€” subset if possible
1001        $fontBytes = $data->fontBytes;
1002        if (!empty($data->fullUnicodeToGid)) {
1003            // Subset to only WinAnsi-mapped glyphs
1004            $glyphIds = [];
1005            foreach ($data->unicodeMap as $unicode) {
1006                $gid = $data->fullUnicodeToGid[$unicode] ?? null;
1007                if ($gid !== null) {
1008                    $glyphIds[] = $gid;
1009                }
1010            }
1011            if (!empty($glyphIds)) {
1012                $fontBytes = (new TrueTypeSubsetter())->subset($fontBytes, $glyphIds, $data->fullUnicodeToGid);
1013            }
1014        }
1015        $streamDict = new PdfDictionary(['Length1' => new PdfNumber(strlen($fontBytes))]);
1016        $fontStream = new PdfStream($streamDict, $fontBytes);
1017        $this->file->register($fontStream);
1018
1019        // 2. FontDescriptor
1020        $descriptor = new FontDescriptor(new PdfName($data->postScriptName));
1021        $descriptor->flags = $data->flags;
1022        $descriptor->fontBBox = new PdfArray([
1023            new PdfNumber($data->fontBBox[0]),
1024            new PdfNumber($data->fontBBox[1]),
1025            new PdfNumber($data->fontBBox[2]),
1026            new PdfNumber($data->fontBBox[3]),
1027        ]);
1028        $descriptor->italicAngle = $data->italicAngle;
1029        $descriptor->ascent      = $data->ascent;
1030        $descriptor->descent     = $data->descent;
1031        $descriptor->capHeight   = $data->capHeight;
1032        $descriptor->xHeight     = $data->xHeight;
1033        $descriptor->stemV       = $data->stemV;
1034        $descriptor->fontFile2   = new PdfReference($fontStream->objectNumber);
1035        $this->file->register($descriptor);
1036
1037        // 3. ToUnicode CMap stream
1038        $cmapStream = new PdfStream(new PdfDictionary(), $this->buildToUnicodeCMap($data->unicodeMap));
1039        $this->file->register($cmapStream);
1040
1041        // 4. Wire back to font
1042        $font->fontDescriptor = new PdfReference($descriptor->objectNumber);
1043        $font->toUnicode      = new PdfReference($cmapStream->objectNumber);
1044        $font->encoding       = new PdfName('WinAnsiEncoding');
1045    }
1046
1047    /**
1048     * Embed a custom Type 1 font with its font program, descriptor, and ToUnicode CMap.
1049     */
1050    private function embedType1Font(Type1Font $font): void
1051    {
1052        $data = $font->parsedFontData;
1053
1054        // 1. Font program stream (Type1FontFile with /Length1, /Length2, /Length3)
1055        $fontStream = new Type1FontFile(
1056            $data->fontBytes,
1057            $data->length1,
1058            $data->length2,
1059            $data->length3,
1060        );
1061        $this->file->register($fontStream);
1062
1063        // 2. FontDescriptor
1064        $descriptor = new FontDescriptor(new PdfName($data->postScriptName));
1065        $descriptor->flags      = $data->flags;
1066        $descriptor->fontBBox   = new PdfArray([
1067            new PdfNumber($data->fontBBox[0]),
1068            new PdfNumber($data->fontBBox[1]),
1069            new PdfNumber($data->fontBBox[2]),
1070            new PdfNumber($data->fontBBox[3]),
1071        ]);
1072        $descriptor->italicAngle = $data->italicAngle;
1073        $descriptor->ascent      = $data->ascent;
1074        $descriptor->descent     = $data->descent;
1075        $descriptor->capHeight   = $data->capHeight;
1076        $descriptor->xHeight     = $data->xHeight;
1077        $descriptor->stemV       = $data->stemV;
1078        $descriptor->fontFile    = new PdfReference($fontStream->objectNumber);
1079        $this->file->register($descriptor);
1080
1081        // 3. ToUnicode CMap stream
1082        if (!empty($data->unicodeMap)) {
1083            $cmapStream = new PdfStream(new PdfDictionary(), $this->buildToUnicodeCMap($data->unicodeMap));
1084            $this->file->register($cmapStream);
1085            $font->toUnicode = new PdfReference($cmapStream->objectNumber);
1086        }
1087
1088        // 4. Wire back to font
1089        $font->fontDescriptor = new PdfReference($descriptor->objectNumber);
1090    }
1091
1092    /** @param array<int, int> $unicodeMap */
1093    private function buildToUnicodeCMap(array $unicodeMap): string
1094    {
1095        ksort($unicodeMap);
1096        $entries = [];
1097        foreach ($unicodeMap as $byte => $unicode) {
1098            $entries[] = sprintf('<%02X> <%04X>', $byte, $unicode);
1099        }
1100
1101        // PDF spec: max 100 entries per beginbfchar block
1102        $chunks = array_chunk($entries, 100);
1103        $blocks = '';
1104        foreach ($chunks as $chunk) {
1105            $blocks .= count($chunk) . " beginbfchar\n"
1106                     . implode("\n", $chunk) . "\n"
1107                     . "endbfchar\n";
1108        }
1109
1110        return "/CIDInit /ProcSet findresource begin\n"
1111             . "12 dict begin\n"
1112             . "begincmap\n"
1113             . "/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n"
1114             . "/CMapName /Adobe-Identity-UCS def\n"
1115             . "/CMapType 2 def\n"
1116             . "1 begincodespacerange\n"
1117             . "<20> <FF>\n"
1118             . "endcodespacerange\n"
1119             . $blocks
1120             . "endcmap\n"
1121             . "CMap end\n"
1122             . "end";
1123    }
1124}