Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.96% covered (warning)
89.96%
448 / 498
87.50% covered (warning)
87.50%
42 / 48
CRAP
0.00% covered (danger)
0.00%
0 / 1
PdfWriter
89.96% covered (warning)
89.96%
448 / 498
87.50% covered (warning)
87.50%
42 / 48
159.84
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.52% covered (success)
96.52%
111 / 115
0.00% covered (danger)
0.00%
0 / 1
19
 addContentStream
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 addImage
62.39% covered (warning)
62.39%
68 / 109
0.00% covered (danger)
0.00%
0 / 1
82.14
 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, $oldToNewGid] =
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, oldToNewGid: $oldToNewGid);
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     * @param int[] $extraGids Extra pre-subset GIDs to retain even when no
331     *        codepoint maps to them. Math fonts use this to keep stretchy
332     *        variants and assembly parts in the subset - those glyphs have
333     *        no Unicode codepoint and would otherwise be dropped by the
334     *        CFF subsetter.
335     * @return Font Opaque font handle
336     */
337    public function addOpenTypeFont(
338        \Phpdftk\FontParser\OpenTypeData $data,
339        array $usedCodepoints,
340        CorePage|Page|null $page = null,
341        array $extraGids = [],
342    ): Font {
343        $this->fontCounter++;
344        $name = 'F' . $this->fontCounter;
345
346        // Font descriptor
347        $descriptor = new FontDescriptor(new PdfName($data->postScriptName));
348        $descriptor->flags = $data->flags;
349        $descriptor->fontBBox = new PdfArray([
350            new PdfNumber($data->fontBBox[0]),
351            new PdfNumber($data->fontBBox[1]),
352            new PdfNumber($data->fontBBox[2]),
353            new PdfNumber($data->fontBBox[3]),
354        ]);
355        $descriptor->italicAngle = $data->italicAngle;
356        $descriptor->ascent = $data->ascent;
357        $descriptor->descent = $data->descent;
358        $descriptor->capHeight = $data->capHeight;
359        $descriptor->xHeight = $data->xHeight;
360        $descriptor->stemV = $data->stemV;
361
362        // Subset CFF table to only include used glyphs.
363        $usedGids = [];
364        $codepointsByOldGid = [];
365        foreach ($usedCodepoints as $cp) {
366            $gid = $data->fullUnicodeToGid[$cp] ?? null;
367            if ($gid !== null) {
368                $usedGids[] = $gid;
369                $codepointsByOldGid[$gid] = $cp;
370            }
371        }
372        // Add codepoint-less glyphs (math variants, assembly parts).
373        // Dedupe via array_values + array_unique so the subsetter sees
374        // each GID once even when callers double-register.
375        foreach ($extraGids as $gid) {
376            $usedGids[] = $gid;
377        }
378        $usedGids = array_values(array_unique($usedGids));
379        $cffSubsetter = new \Phpdftk\FontParser\CffSubsetter();
380        $cffBytes = $cffSubsetter->subset($data->cffBytes, $usedGids);
381        $cffGidMap = $cffSubsetter->getGidMap();
382
383        // Post-subset Unicode â†’ new GID map. Drives the /W array, the
384        // ToUnicode CMap, and the Font handle accessor below.
385        $unicodeToGidSubset = [];
386        foreach ($codepointsByOldGid as $oldGid => $cp) {
387            $newGid = $cffGidMap[$oldGid] ?? null;
388            if ($newGid !== null) {
389                $unicodeToGidSubset[$cp] = $newGid;
390            }
391        }
392
393        // CFF font program stream (embed subsetted CFF table bytes)
394        $cffStream = new CFFFontFile($cffBytes, 'CIDFontType0C');
395        $this->file->register($cffStream);
396        $descriptor->fontFile3 = new PdfReference($cffStream->objectNumber);
397        $this->file->register($descriptor);
398
399        // CID font
400        $cidSystemInfo = new CIDSystemInfo('Adobe', 'Identity', 0);
401        $cidFont = new CIDFontType0Font($data->postScriptName, $cidSystemInfo);
402        $cidFont->fontDescriptor = new PdfReference($descriptor->objectNumber);
403
404        // Build /W widths array, indexed by post-subset CID/GID.
405        $scale = fn(int $v): int => (int) round($v * 1000 / $data->unitsPerEm);
406        $wEntries = [];
407        foreach ($codepointsByOldGid as $oldGid => $cp) {
408            $newGid = $cffGidMap[$oldGid] ?? null;
409            if ($newGid !== null && isset($data->glyphWidths[$oldGid])) {
410                $wEntries[$newGid] = new PdfNumber($scale($data->glyphWidths[$oldGid]));
411            }
412        }
413        if (!empty($wEntries)) {
414            ksort($wEntries);
415            $wArray = [];
416            $currentRun = [];
417            $currentStart = -1;
418            $lastGid = -2;
419            foreach ($wEntries as $gid => $width) {
420                if ($gid !== $lastGid + 1) {
421                    if (!empty($currentRun)) {
422                        $wArray[] = new PdfNumber($currentStart);
423                        $wArray[] = new PdfArray($currentRun);
424                    }
425                    $currentStart = $gid;
426                    $currentRun = [$width];
427                } else {
428                    $currentRun[] = $width;
429                }
430                $lastGid = $gid;
431            }
432            $wArray[] = new PdfNumber($currentStart);
433            $wArray[] = new PdfArray($currentRun);
434            $cidFont->w = new PdfArray($wArray);
435        }
436
437        $this->file->register($cidFont);
438
439        // ToUnicode CMap â€” keyed by post-subset GID so text extraction
440        // sees the same identifiers the viewer renders against.
441        $gidToUnicode = [];
442        foreach ($unicodeToGidSubset as $cp => $newGid) {
443            $gidToUnicode[$newGid] = $cp;
444        }
445        ksort($gidToUnicode);
446        $cmapEntries = [];
447        foreach ($gidToUnicode as $gid => $unicode) {
448            $cmapEntries[] = sprintf('<%04X> <%04X>', $gid, $unicode);
449        }
450        $cmapChunks = array_chunk($cmapEntries, 100);
451        $cmapBlocks = '';
452        foreach ($cmapChunks as $chunk) {
453            $cmapBlocks .= count($chunk) . " beginbfchar\n"
454                . implode("\n", $chunk) . "\n"
455                . "endbfchar\n";
456        }
457        $cmapProgram = "/CIDInit /ProcSet findresource begin\n"
458            . "12 dict begin\n"
459            . "begincmap\n"
460            . "/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n"
461            . "/CMapName /Adobe-Identity-UCS def\n"
462            . "/CMapType 2 def\n"
463            . "1 begincodespacerange\n"
464            . "<0000> <FFFF>\n"
465            . "endcodespacerange\n"
466            . $cmapBlocks
467            . "endcmap\n"
468            . "CMap end\n"
469            . "end";
470        $toUnicodeStream = new PdfStream(new PdfDictionary(), $cmapProgram);
471        $this->file->register($toUnicodeStream);
472
473        // Type 0 font
474        $type0Font = new Type0Font(
475            $data->postScriptName,
476            new PdfArray([new PdfReference($cidFont->objectNumber)]),
477            'Identity-H',
478        );
479        $type0Font->toUnicode = new PdfReference($toUnicodeStream->objectNumber);
480        $this->file->register($type0Font);
481
482        $this->fonts[$name] = $type0Font;
483        $ref = new PdfReference($type0Font->objectNumber);
484
485        $corePage = $page instanceof Page ? $page->corePage() : $page;
486        if ($corePage !== null) {
487            $corePage->resources?->addFont($name, $ref);
488        } else {
489            foreach ($this->pages as $p) {
490                $p->resources?->addFont($name, $ref);
491            }
492        }
493
494        return new Font(
495            $name,
496            $data->postScriptName,
497            $data,
498            unicodeToGid: $unicodeToGidSubset,
499            oldToNewGid: $cffGidMap,
500        );
501    }
502
503    /**
504     * Create a content stream, register it, and attach it to a page.
505     */
506    public function addContentStream(CorePage|Page $page): ContentStream
507    {
508        $corePage = $page instanceof Page ? $page->corePage() : $page;
509        $cs = new ContentStream();
510        $this->file->register($cs);
511        $corePage->contents[] = new PdfReference($cs->objectNumber);
512        $this->contentStreams[] = $cs;
513        return $cs;
514    }
515
516    /**
517     * Add an image to a page as an XObject, using ImageParser to detect format.
518     * Returns the resource name (e.g. 'Im1') for use in content streams.
519     */
520    public function addImage(string $path, CorePage|Page $page): string
521    {
522        $corePage = $page instanceof Page ? $page->corePage() : $page;
523        $info = ImageParser::parse($path);
524        // SVG is not a raster format. The image-metadata parser
525        // recognises it (so callers can read intrinsic dimensions
526        // for layout), but PDF can't reference SVG bytes as an
527        // Image XObject. Reject here so the caller (typically the
528        // HTML painter) catches and routes through the SVG painter
529        // instead. Matches the pre-SVG-parser behaviour where
530        // ImageParser threw on unknown bytes.
531        if ($info->format === 'svg') {
532            throw new \RuntimeException('SVG cannot be embedded as a raster image XObject; route through the SVG painter');
533        }
534        $data = LocalFilesystem::readFile($path);
535
536        $this->imageCounter++;
537        $name = 'Im' . $this->imageCounter;
538
539        $dict = new PdfDictionary([
540            'Type'             => new PdfName('XObject'),
541            'Subtype'          => new PdfName('Image'),
542            'Width'            => new PdfNumber($info->width),
543            'Height'           => new PdfNumber($info->height),
544            'ColorSpace'       => new PdfName($info->colorSpace),
545            'BitsPerComponent' => new PdfNumber($info->bitsPerComponent),
546        ]);
547
548        // Set the appropriate decode filter for pass-through image formats
549        match ($info->format) {
550            'jpeg' => $dict->set('Filter', new PdfName('DCTDecode')),
551            'jpeg2000' => $dict->set('Filter', new PdfName('JPXDecode')),
552            'jbig2' => $dict->set('Filter', new PdfName('JBIG2Decode')),
553            default => null,
554        };
555
556        // PNG: extract the DEFLATE-compressed IDAT data and tell the
557        // PDF reader to FlateDecode it with the PNG-style predictor
558        // (15 = "PNG up", which PNG IDAT effectively uses). PDF
559        // doesn't natively grok the PNG container, so we strip the
560        // PNG wrapper but keep the compressed pixel data verbatim â€”
561        // no intermediate raw-RGB buffer needed.
562        //
563        // The fast pass-through path is gated on 8-bit; the indexed
564        // path handles sub-byte depths (1/2/4) by expanding packed
565        // palette indices into per-pixel RGB before re-flating. Other
566        // unsupported depths fall back to the raw-embed path the
567        // historical writer used; rendering is broken but the XObject
568        // still exists for refcount / metadata.
569        $smaskRef = null;
570        if ($info->format === 'png') {
571            $components = match ($info->colorSpace) {
572                'DeviceGray' => 1,
573                'DeviceCMYK' => 4,
574                default => 3, // DeviceRGB
575            };
576            // Detect indexed (color type 3) â€” the parser maps its
577            // colourspace to DeviceRGB for metadata purposes but the
578            // IDAT data is palette indices, not RGB samples. Route
579            // through the indexed decoder so the palette lookup
580            // happens here instead of being misinterpreted as raw
581            // pixel data.
582            $pngColorType = strlen($data) >= 8 + 8 + 10
583                && substr($data, 12, 4) === 'IHDR'
584                ? ord($data[8 + 8 + 9])
585                : null;
586            if ($pngColorType === 3) {
587                $decoded = \Phpdftk\ImageMetadata\PngParser::decodeIndexedPng($data);
588                if ($decoded !== null) {
589                    $colourCompressed = @gzcompress($decoded['colour']);
590                    if ($colourCompressed !== false) {
591                        $dict->set('Filter', new PdfName('FlateDecode'));
592                        $dict->set('ColorSpace', new PdfName('DeviceRGB'));
593                        // The decoded stream is per-pixel RGB at 8 bpc,
594                        // even when the source PNG was 1/2/4-bit packed.
595                        $dict->set('BitsPerComponent', new PdfNumber(8));
596                        $data = $colourCompressed;
597                        if ($decoded['alpha'] !== null) {
598                            $alphaCompressed = @gzcompress($decoded['alpha']);
599                            if ($alphaCompressed !== false) {
600                                $smaskDict = new PdfDictionary([
601                                    'Type' => new PdfName('XObject'),
602                                    'Subtype' => new PdfName('Image'),
603                                    'Width' => new PdfNumber($decoded['width']),
604                                    'Height' => new PdfNumber($decoded['height']),
605                                    'ColorSpace' => new PdfName('DeviceGray'),
606                                    'BitsPerComponent' => new PdfNumber(8),
607                                    'Filter' => new PdfName('FlateDecode'),
608                                ]);
609                                $smaskStream = new PdfStream($smaskDict, $alphaCompressed);
610                                $this->file->register($smaskStream);
611                                $smaskRef = new PdfReference($smaskStream->objectNumber);
612                            }
613                        }
614                    }
615                }
616            } elseif ($info->hasAlpha && $info->bitsPerComponent === 8) {
617                // Color types 4 / 6 â€” decode pixel data fully, split
618                // into colour + alpha, emit alpha as SMask. Skips
619                // gzcompress on the alpha stream when zlib refuses
620                // (very-tiny images can produce empty streams) and
621                // falls back to a raw embed in that case.
622                $decoded = \Phpdftk\ImageMetadata\PngParser::decodeAlphaPng($data);
623                if ($decoded !== null) {
624                    $colourCompressed = @gzcompress($decoded['colour']);
625                    $alphaCompressed = @gzcompress($decoded['alpha']);
626                    if ($colourCompressed !== false && $alphaCompressed !== false) {
627                        $smaskDict = new PdfDictionary([
628                            'Type' => new PdfName('XObject'),
629                            'Subtype' => new PdfName('Image'),
630                            'Width' => new PdfNumber($decoded['width']),
631                            'Height' => new PdfNumber($decoded['height']),
632                            'ColorSpace' => new PdfName('DeviceGray'),
633                            'BitsPerComponent' => new PdfNumber(8),
634                            'Filter' => new PdfName('FlateDecode'),
635                        ]);
636                        $smaskStream = new PdfStream($smaskDict, $alphaCompressed);
637                        $this->file->register($smaskStream);
638                        $smaskRef = new PdfReference($smaskStream->objectNumber);
639                        $dict->set('Filter', new PdfName('FlateDecode'));
640                        $data = $colourCompressed;
641                    }
642                }
643            } elseif ($info->bitsPerComponent === 8) {
644                $idat = \Phpdftk\ImageMetadata\PngParser::extractIdatData($data);
645                if ($idat !== null) {
646                    $dict->set('Filter', new PdfName('FlateDecode'));
647                    $dict->set(
648                        'DecodeParms',
649                        new PdfDictionary([
650                            'Predictor' => new PdfNumber(15),
651                            'Colors' => new PdfNumber($components),
652                            'BitsPerComponent' => new PdfNumber(8),
653                            'Columns' => new PdfNumber($info->width),
654                        ]),
655                    );
656                    $data = $idat;
657                }
658            }
659        }
660        if ($smaskRef !== null) {
661            $dict->set('SMask', $smaskRef);
662        }
663
664        // If the image has an embedded ICC profile, replace the color space
665        // with an ICCBased color space reference
666        if ($info->iccProfile !== null) {
667            $nComponents = match ($info->colorSpace) {
668                'DeviceGray' => 1,
669                'DeviceCMYK' => 4,
670                default => 3, // DeviceRGB
671            };
672            $profileDict = new PdfDictionary([
673                'N' => new PdfNumber($nComponents),
674            ]);
675            $profileStream = new PdfStream($profileDict, $info->iccProfile);
676            $this->file->register($profileStream);
677            $profileRef = new PdfReference($profileStream->objectNumber);
678            $iccColorSpace = new ICCBased($profileRef);
679            $dict->set('ColorSpace', $iccColorSpace);
680        }
681
682        $xObject = new PdfStream($dict, $data);
683        $this->file->register($xObject);
684        $ref = new PdfReference($xObject->objectNumber);
685
686        // Add XObject resource to the page
687        if ($corePage->resources !== null) {
688            $corePage->resources->addXObject($name, $ref);
689        }
690
691        return $name;
692    }
693
694    /**
695     * @deprecated Use {@see PdfDoc::setOutline()} instead. This forwarder
696     *             is retained for one minor release and will be removed.
697     */
698    public function setOutline(Outline $outline): Outline
699    {
700        return $this->doc()->setOutline($outline);
701    }
702
703    /**
704     * @deprecated Use {@see PdfDoc::addOutlineItem()} instead. This
705     *             forwarder is retained for one minor release.
706     */
707    public function addOutlineItem(OutlineItem $item): PdfReference
708    {
709        return $this->doc()->addOutlineItem($item);
710    }
711
712    /**
713     * @deprecated Use {@see PdfDoc::setPageLabels()} instead. This
714     *             forwarder is retained for one minor release.
715     *
716     * @param array<int, PageLabel> $labels
717     */
718    public function setPageLabels(array $labels): void
719    {
720        $this->doc()->setPageLabels($labels);
721    }
722
723    /**
724     * @deprecated Use {@see PdfDoc::setNamedDestinations()} instead. This
725     *             forwarder is retained for one minor release.
726     *
727     * @param array<string, Destination> $destinations
728     */
729    public function setNamedDestinations(array $destinations): void
730    {
731        $this->doc()->setNamedDestinations($destinations);
732    }
733
734    /**
735     * Escape hatch to Level 0 â€” returns the underlying PdfFileWriter
736     * for direct object-model control.
737     */
738    public function fileWriter(): PdfFileWriter
739    {
740        return $this->file;
741    }
742
743    /**
744     * Register any arbitrary PdfObject (annotations, form fields, etc.).
745     */
746    public function register(PdfObject $object): PdfReference
747    {
748        return $this->file->register($object);
749    }
750
751    /**
752     * Add an image to a page as an XObject (internal â€” used by Writer\Page).
753     *
754     * @internal
755     * @return string Resource name (e.g. 'Im1')
756     */
757    public function addImageInternal(string $path, CorePage $page): string
758    {
759        return $this->addImage($path, $page);
760    }
761
762    /**
763     * Configure digital signing for this document.
764     *
765     * The signing lifecycle works in three phases:
766     *   1. **Placeholder:** A SignatureValue dictionary is emitted with a
767     *      zeroed /Contents hex string large enough to hold the final
768     *      PKCS#7 DER blob (`$placeholderBytes` controls the size).
769     *   2. **Byte-range:** After the full PDF is assembled, the /ByteRange
770     *      array is patched to cover everything except the /Contents value
771     *      itself, so the signature covers the entire file.
772     *   3. **Patch:** The Pkcs7Signer signs the byte-range data and the
773     *      resulting DER is written into the /Contents placeholder.
774     *
775     * @see PdfFileWriter::setSigner()
776     */
777    public function setSigner(
778        SignatureValue $signatureValue,
779        Pkcs7Signer $signer,
780        int $placeholderBytes = 8192,
781    ): void {
782        $this->file->setSigner($signatureValue, $signer, $placeholderBytes);
783    }
784
785    /**
786     * Configure a TSA client for RFC 3161 timestamping.
787     *
788     * @see PdfFileWriter::setTsaClient()
789     */
790    public function setTsaClient(TsaClient $tsaClient): void
791    {
792        $this->file->setTsaClient($tsaClient);
793    }
794
795    /**
796     * Configure a document-level timestamp using a TSA client.
797     *
798     * @see PdfFileWriter::setTimestamper()
799     */
800    public function setTimestamper(
801        SignatureValue $docTimeStamp,
802        TsaClient $tsaClient,
803        int $placeholderBytes = 16384,
804    ): void {
805        $this->file->setTimestamper($docTimeStamp, $tsaClient, $placeholderBytes);
806    }
807
808    /**
809     * Configure encryption for the generated PDF.
810     *
811     * Registers the encrypt dictionary, and during generation all
812     * strings and streams are encrypted per-object with the correct
813     * key derivation. The /Encrypt reference is added to the trailer
814     * automatically.
815     *
816     * @see PdfEncryptor::aes128()
817     * @see PdfEncryptor::aes256()
818     * @see PdfEncryptor::rc4128()
819     */
820    public function setEncryption(PdfEncryptor $encryptor): void
821    {
822        $this->file->setEncryption($encryptor);
823    }
824
825    public function getPdfVersion(): PdfVersion
826    {
827        return $this->file->getPdfVersion();
828    }
829
830    public function setStrictVersionMode(bool $strict = true): void
831    {
832        $this->file->setStrictVersionMode($strict);
833    }
834
835    public function setCeilingVersion(?PdfVersion $ceiling): void
836    {
837        $this->file->setCeilingVersion($ceiling);
838    }
839
840    public function setDeprecationHandler(\Closure $handler): void
841    {
842        $this->file->setDeprecationHandler($handler);
843    }
844
845    public function setStrictDeprecation(bool $strict = true): void
846    {
847        $this->file->setStrictDeprecation($strict);
848    }
849
850    /** @return list<string> */
851    public function getVersionWarnings(): array
852    {
853        return $this->file->getVersionWarnings();
854    }
855
856    /**
857     * Diagnostics for codepoints that were substituted with `?` because the
858     * font's encoding could not represent them. Empty when every glyph
859     * landed cleanly. Each entry names the font resource, the codepoint,
860     * and how many times it was requested.
861     *
862     * @return list<string>
863     */
864    public function getEncodingWarnings(): array
865    {
866        $warnings = [];
867        foreach ($this->fontEncoders as $resourceName => $encoder) {
868            $missing = $encoder->getMissingCodepoints();
869            if ($missing === []) {
870                continue;
871            }
872            $counts = array_count_values($missing);
873            foreach ($counts as $cp => $count) {
874                $warnings[] = sprintf(
875                    'Font %s: codepoint U+%04X has no WinAnsi mapping (substituted ? %dx)',
876                    $resourceName,
877                    $cp,
878                    $count,
879                );
880            }
881        }
882        return $warnings;
883    }
884
885    /**
886     * Enable or disable linearized (web-optimized) PDF output.
887     *
888     * When enabled, the generated PDF places the first page's objects at
889     * the front of the file, allowing a viewer to display it before
890     * downloading the rest (ISO 32000-2 Annex F).
891     */
892    public function setLinearized(bool $linearized = true): void
893    {
894        $this->linearized = $linearized;
895    }
896
897    /**
898     * Set one or more conformance profiles (e.g. PDF/A-1b, PDF/UA-1).
899     *
900     * When set, `generate()` will:
901     *   1. Auto-inject XMP identification metadata (if not already present)
902     *   2. Pin the PDF version to the profile minimum
903     *   3. Run all applicable constraint checks
904     *   4. In strict mode (default): throw ConformanceException on errors
905     *   5. In lenient mode: collect results in getConformanceResults()
906     *
907     * @param bool $strict Throw on conformance errors (default true)
908     */
909    public function setConformance(ConformanceProfile $profile, bool $strict = true): void
910    {
911        $this->conformanceMode = new ConformanceMode([$profile], $strict);
912    }
913
914    /**
915     * Set multiple conformance profiles at once (e.g. PDF/A-2a + PDF/UA-1).
916     *
917     * @param ConformanceProfile[] $profiles
918     * @param bool $strict Throw on conformance errors (default true)
919     */
920    public function setConformanceProfiles(array $profiles, bool $strict = true): void
921    {
922        $this->conformanceMode = new ConformanceMode($profiles, $strict);
923    }
924
925    /**
926     * Run conformance checks without generating the PDF.
927     *
928     * @return list<ConformanceResult>
929     */
930    public function checkConformance(): array
931    {
932        if ($this->conformanceMode === null) {
933            return [];
934        }
935
936        $inspector = new WriterDocumentInspector(
937            $this->catalog,
938            $this->file,
939            $this->fonts,
940        );
941
942        $validator = new ConformanceValidator();
943        return $validator->validateAll($inspector, $this->conformanceMode->profiles);
944    }
945
946    /**
947     * Get the conformance results from the last generate() call.
948     *
949     * @return list<ConformanceResult>
950     */
951    public function getConformanceResults(): array
952    {
953        return $this->conformanceResults;
954    }
955
956    /**
957     * Generate the complete PDF as a binary string.
958     */
959    public function generate(): string
960    {
961        if ($this->conformanceMode !== null) {
962            $this->applyConformance();
963        }
964
965        if ($this->linearized) {
966            return $this->file->generateLinearized($this->collectFirstPageObjectNumbers());
967        }
968        return $this->file->generate();
969    }
970
971    /**
972     * Alias for {@see generate()} â€” returns the raw PDF bytes as a string.
973     */
974    public function toBytes(): string
975    {
976        return $this->generate();
977    }
978
979    /**
980     * Write the generated PDF to an open stream resource.
981     *
982     * @param resource $stream
983     */
984    public function writeTo($stream): int
985    {
986        if (!is_resource($stream)) {
987            throw new \InvalidArgumentException(
988                'PdfWriter::writeTo() expects an open stream resource',
989            );
990        }
991        $pdf = $this->generate();
992        $written = fwrite($stream, $pdf);
993        if ($written === false) {
994            throw new \RuntimeException('Failed to write PDF bytes to stream');
995        }
996        return $written;
997    }
998
999    /**
1000     * Write the PDF to a file, creating parent directories as needed.
1001     */
1002    public function save(string $path): void
1003    {
1004        $pdf = $this->generate();
1005        LocalFilesystem::writeFile($path, $pdf, createDirectories: true);
1006    }
1007
1008    /**
1009     * Collect object numbers belonging to the first page for linearization.
1010     *
1011     * Includes the catalog, page tree, first page, its content streams,
1012     * and all fonts/images referenced by the first page's resources.
1013     *
1014     * @return list<int>
1015     */
1016    private function collectFirstPageObjectNumbers(): array
1017    {
1018        $nums = [];
1019
1020        // Catalog and page tree are always first-page objects
1021        $nums[] = $this->catalog->objectNumber;
1022        $nums[] = $this->pageTree->objectNumber;
1023
1024        // First page and its content streams
1025        if (!empty($this->pages)) {
1026            $firstPage = $this->pages[0];
1027            $nums[] = $firstPage->objectNumber;
1028
1029            foreach ($firstPage->contents as $ref) {
1030                $nums[] = $ref->objectNumber;
1031            }
1032
1033            // Fonts and images registered on the first page's resources
1034            if ($firstPage->resources !== null) {
1035                foreach ($firstPage->resources->font as $ref) {
1036                    $nums[] = $ref->objectNumber;
1037                }
1038                foreach ($firstPage->resources->xObject as $ref) {
1039                    $nums[] = $ref->objectNumber;
1040                }
1041            }
1042        }
1043
1044        // Info dict if present
1045        if ($this->file->getInfo() !== null) {
1046            $nums[] = $this->file->getInfo()->objectNumber;
1047        }
1048
1049        return $nums;
1050    }
1051
1052    /**
1053     * @deprecated Use {@see PdfDoc::setMetadata()} instead. This
1054     *             forwarder is retained for one minor release.
1055     */
1056    public function setMetadata(string $xmpXml): void
1057    {
1058        $this->doc()->setMetadata($xmpXml);
1059    }
1060
1061    /**
1062     * @deprecated Use {@see PdfDoc::syncInfoToMetadata()} instead. This
1063     *             forwarder is retained for one minor release.
1064     */
1065    public function syncInfoToMetadata(): void
1066    {
1067        $this->doc()->syncInfoToMetadata();
1068    }
1069
1070    /**
1071     * Lazily-constructed PdfDoc view over this writer, used by the
1072     * deprecated forwarding stubs above. New code should use PdfDoc
1073     * directly.
1074     */
1075    private function doc(): PdfDoc
1076    {
1077        return $this->cachedDoc ??= PdfDoc::wrap($this);
1078    }
1079
1080    // -----------------------------------------------------------------------
1081    // Private helpers
1082    // -----------------------------------------------------------------------
1083
1084    /**
1085     * Apply conformance: auto-inject XMP, pin version, validate.
1086     */
1087    private function applyConformance(): void
1088    {
1089        $mode = $this->conformanceMode;
1090
1091        foreach ($mode->profiles as $profile) {
1092            // Pin PDF version to profile minimum
1093            $required = $profile->getPdfVersion();
1094            if ($required->isGreaterThan($this->file->getPdfVersion())) {
1095                $this->file->setVersion($required);
1096            }
1097
1098            // Auto-inject XMP identification if not already present
1099            if (!$this->catalog->metadata) {
1100                $info = $this->file->getInfo();
1101                $xmpWriter = new ConformanceXmpWriter();
1102                $xmp = $xmpWriter->buildXmp(
1103                    $profile,
1104                    title: $info?->title->value ?? '',
1105                    creator: $info?->author->value ?? '',
1106                    producer: $info?->producer->value ?? 'phpdftk',
1107                );
1108                $this->setMetadata($xmp);
1109            }
1110        }
1111
1112        // Run validation
1113        $this->conformanceResults = $this->checkConformance();
1114
1115        // In strict mode, throw on any non-compliant result
1116        if ($mode->strict) {
1117            $failures = array_filter(
1118                $this->conformanceResults,
1119                static fn(ConformanceResult $r) => !$r->isCompliant,
1120            );
1121            if ($failures !== []) {
1122                throw new ConformanceException(array_values($failures));
1123            }
1124        }
1125    }
1126
1127    private function embedTrueTypeFont(TrueTypeFont $font): void
1128    {
1129        $data = $font->parsedFontData;
1130
1131        // 1. Font program stream â€” subset if possible
1132        $fontBytes = $data->fontBytes;
1133        if (!empty($data->fullUnicodeToGid)) {
1134            // Subset to only WinAnsi-mapped glyphs
1135            $glyphIds = [];
1136            foreach ($data->unicodeMap as $unicode) {
1137                $gid = $data->fullUnicodeToGid[$unicode] ?? null;
1138                if ($gid !== null) {
1139                    $glyphIds[] = $gid;
1140                }
1141            }
1142            if (!empty($glyphIds)) {
1143                $fontBytes = (new TrueTypeSubsetter())->subset($fontBytes, $glyphIds, $data->fullUnicodeToGid);
1144            }
1145        }
1146        $streamDict = new PdfDictionary(['Length1' => new PdfNumber(strlen($fontBytes))]);
1147        $fontStream = new PdfStream($streamDict, $fontBytes);
1148        $this->file->register($fontStream);
1149
1150        // 2. FontDescriptor
1151        $descriptor = new FontDescriptor(new PdfName($data->postScriptName));
1152        $descriptor->flags = $data->flags;
1153        $descriptor->fontBBox = new PdfArray([
1154            new PdfNumber($data->fontBBox[0]),
1155            new PdfNumber($data->fontBBox[1]),
1156            new PdfNumber($data->fontBBox[2]),
1157            new PdfNumber($data->fontBBox[3]),
1158        ]);
1159        $descriptor->italicAngle = $data->italicAngle;
1160        $descriptor->ascent      = $data->ascent;
1161        $descriptor->descent     = $data->descent;
1162        $descriptor->capHeight   = $data->capHeight;
1163        $descriptor->xHeight     = $data->xHeight;
1164        $descriptor->stemV       = $data->stemV;
1165        $descriptor->fontFile2   = new PdfReference($fontStream->objectNumber);
1166        $this->file->register($descriptor);
1167
1168        // 3. ToUnicode CMap stream
1169        $cmapStream = new PdfStream(new PdfDictionary(), $this->buildToUnicodeCMap($data->unicodeMap));
1170        $this->file->register($cmapStream);
1171
1172        // 4. Wire back to font
1173        $font->fontDescriptor = new PdfReference($descriptor->objectNumber);
1174        $font->toUnicode      = new PdfReference($cmapStream->objectNumber);
1175        $font->encoding       = new PdfName('WinAnsiEncoding');
1176    }
1177
1178    /**
1179     * Embed a custom Type 1 font with its font program, descriptor, and ToUnicode CMap.
1180     */
1181    private function embedType1Font(Type1Font $font): void
1182    {
1183        $data = $font->parsedFontData;
1184
1185        // 1. Font program stream (Type1FontFile with /Length1, /Length2, /Length3)
1186        $fontStream = new Type1FontFile(
1187            $data->fontBytes,
1188            $data->length1,
1189            $data->length2,
1190            $data->length3,
1191        );
1192        $this->file->register($fontStream);
1193
1194        // 2. FontDescriptor
1195        $descriptor = new FontDescriptor(new PdfName($data->postScriptName));
1196        $descriptor->flags      = $data->flags;
1197        $descriptor->fontBBox   = new PdfArray([
1198            new PdfNumber($data->fontBBox[0]),
1199            new PdfNumber($data->fontBBox[1]),
1200            new PdfNumber($data->fontBBox[2]),
1201            new PdfNumber($data->fontBBox[3]),
1202        ]);
1203        $descriptor->italicAngle = $data->italicAngle;
1204        $descriptor->ascent      = $data->ascent;
1205        $descriptor->descent     = $data->descent;
1206        $descriptor->capHeight   = $data->capHeight;
1207        $descriptor->xHeight     = $data->xHeight;
1208        $descriptor->stemV       = $data->stemV;
1209        $descriptor->fontFile    = new PdfReference($fontStream->objectNumber);
1210        $this->file->register($descriptor);
1211
1212        // 3. ToUnicode CMap stream
1213        if (!empty($data->unicodeMap)) {
1214            $cmapStream = new PdfStream(new PdfDictionary(), $this->buildToUnicodeCMap($data->unicodeMap));
1215            $this->file->register($cmapStream);
1216            $font->toUnicode = new PdfReference($cmapStream->objectNumber);
1217        }
1218
1219        // 4. Wire back to font
1220        $font->fontDescriptor = new PdfReference($descriptor->objectNumber);
1221    }
1222
1223    /** @param array<int, int> $unicodeMap */
1224    private function buildToUnicodeCMap(array $unicodeMap): string
1225    {
1226        ksort($unicodeMap);
1227        $entries = [];
1228        foreach ($unicodeMap as $byte => $unicode) {
1229            $entries[] = sprintf('<%02X> <%04X>', $byte, $unicode);
1230        }
1231
1232        // PDF spec: max 100 entries per beginbfchar block
1233        $chunks = array_chunk($entries, 100);
1234        $blocks = '';
1235        foreach ($chunks as $chunk) {
1236            $blocks .= count($chunk) . " beginbfchar\n"
1237                     . implode("\n", $chunk) . "\n"
1238                     . "endbfchar\n";
1239        }
1240
1241        return "/CIDInit /ProcSet findresource begin\n"
1242             . "12 dict begin\n"
1243             . "begincmap\n"
1244             . "/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n"
1245             . "/CMapName /Adobe-Identity-UCS def\n"
1246             . "/CMapType 2 def\n"
1247             . "1 begincodespacerange\n"
1248             . "<20> <FF>\n"
1249             . "endcodespacerange\n"
1250             . $blocks
1251             . "endcmap\n"
1252             . "CMap end\n"
1253             . "end";
1254    }
1255}