Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.80% covered (warning)
88.80%
452 / 509
87.50% covered (warning)
87.50%
42 / 48
CRAP
0.00% covered (danger)
0.00%
0 / 1
PdfWriter
88.80% covered (warning)
88.80%
452 / 509
87.50% covered (warning)
87.50%
42 / 48
175.93
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
60.00% covered (warning)
60.00%
72 / 120
0.00% covered (danger)
0.00%
0 / 1
124.62
 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            // IHDR byte 12: 0 = no interlace, 1 = Adam7.
587            $pngInterlace = strlen($data) >= 8 + 8 + 13
588                && substr($data, 12, 4) === 'IHDR'
589                ? ord($data[8 + 8 + 12])
590                : 0;
591            if ($pngColorType === 3) {
592                $decoded = \Phpdftk\ImageMetadata\PngParser::decodeIndexedPng($data);
593                if ($decoded !== null) {
594                    $colourCompressed = @gzcompress($decoded['colour']);
595                    if ($colourCompressed !== false) {
596                        $dict->set('Filter', new PdfName('FlateDecode'));
597                        $dict->set('ColorSpace', new PdfName('DeviceRGB'));
598                        // The decoded stream is per-pixel RGB at 8 bpc,
599                        // even when the source PNG was 1/2/4-bit packed.
600                        $dict->set('BitsPerComponent', new PdfNumber(8));
601                        $data = $colourCompressed;
602                        if ($decoded['alpha'] !== null) {
603                            $alphaCompressed = @gzcompress($decoded['alpha']);
604                            if ($alphaCompressed !== false) {
605                                $smaskDict = new PdfDictionary([
606                                    'Type' => new PdfName('XObject'),
607                                    'Subtype' => new PdfName('Image'),
608                                    'Width' => new PdfNumber($decoded['width']),
609                                    'Height' => new PdfNumber($decoded['height']),
610                                    'ColorSpace' => new PdfName('DeviceGray'),
611                                    'BitsPerComponent' => new PdfNumber(8),
612                                    'Filter' => new PdfName('FlateDecode'),
613                                ]);
614                                $smaskStream = new PdfStream($smaskDict, $alphaCompressed);
615                                $this->file->register($smaskStream);
616                                $smaskRef = new PdfReference($smaskStream->objectNumber);
617                            }
618                        }
619                    }
620                }
621            } elseif ($info->hasAlpha && $info->bitsPerComponent === 8) {
622                // Color types 4 / 6 â€” decode pixel data fully, split
623                // into colour + alpha, emit alpha as SMask. Skips
624                // gzcompress on the alpha stream when zlib refuses
625                // (very-tiny images can produce empty streams) and
626                // falls back to a raw embed in that case.
627                $decoded = \Phpdftk\ImageMetadata\PngParser::decodeAlphaPng($data);
628                if ($decoded !== null) {
629                    $colourCompressed = @gzcompress($decoded['colour']);
630                    $alphaCompressed = @gzcompress($decoded['alpha']);
631                    if ($colourCompressed !== false && $alphaCompressed !== false) {
632                        $smaskDict = new PdfDictionary([
633                            'Type' => new PdfName('XObject'),
634                            'Subtype' => new PdfName('Image'),
635                            'Width' => new PdfNumber($decoded['width']),
636                            'Height' => new PdfNumber($decoded['height']),
637                            'ColorSpace' => new PdfName('DeviceGray'),
638                            'BitsPerComponent' => new PdfNumber(8),
639                            'Filter' => new PdfName('FlateDecode'),
640                        ]);
641                        $smaskStream = new PdfStream($smaskDict, $alphaCompressed);
642                        $this->file->register($smaskStream);
643                        $smaskRef = new PdfReference($smaskStream->objectNumber);
644                        $dict->set('Filter', new PdfName('FlateDecode'));
645                        $data = $colourCompressed;
646                    }
647                }
648            } elseif ($info->bitsPerComponent === 8 && $pngInterlace === 1) {
649                // PNG spec Â§8.2 â€” an Adam7 IDAT is seven sub-images, not
650                // scanlines, so PDF's `/Predictor 15` pass-through below
651                // cannot read it. Decode here and re-emit un-predicted.
652                $decoded = \Phpdftk\ImageMetadata\PngParser::decodeOpaquePng($data);
653                if ($decoded !== null) {
654                    $recompressed = @gzcompress($decoded['colour']);
655                    if ($recompressed !== false) {
656                        $dict->set('Filter', new PdfName('FlateDecode'));
657                        $data = $recompressed;
658                    }
659                }
660            } elseif ($info->bitsPerComponent === 8) {
661                $idat = \Phpdftk\ImageMetadata\PngParser::extractIdatData($data);
662                if ($idat !== null) {
663                    $dict->set('Filter', new PdfName('FlateDecode'));
664                    $dict->set(
665                        'DecodeParms',
666                        new PdfDictionary([
667                            'Predictor' => new PdfNumber(15),
668                            'Colors' => new PdfNumber($components),
669                            'BitsPerComponent' => new PdfNumber(8),
670                            'Columns' => new PdfNumber($info->width),
671                        ]),
672                    );
673                    $data = $idat;
674                }
675            }
676        }
677        if ($smaskRef !== null) {
678            $dict->set('SMask', $smaskRef);
679        }
680
681        // If the image has an embedded ICC profile, replace the color space
682        // with an ICCBased color space reference
683        if ($info->iccProfile !== null) {
684            $nComponents = match ($info->colorSpace) {
685                'DeviceGray' => 1,
686                'DeviceCMYK' => 4,
687                default => 3, // DeviceRGB
688            };
689            $profileDict = new PdfDictionary([
690                'N' => new PdfNumber($nComponents),
691            ]);
692            $profileStream = new PdfStream($profileDict, $info->iccProfile);
693            $this->file->register($profileStream);
694            $profileRef = new PdfReference($profileStream->objectNumber);
695            $iccColorSpace = new ICCBased($profileRef);
696            $dict->set('ColorSpace', $iccColorSpace);
697        }
698
699        $xObject = new PdfStream($dict, $data);
700        $this->file->register($xObject);
701        $ref = new PdfReference($xObject->objectNumber);
702
703        // Add XObject resource to the page
704        if ($corePage->resources !== null) {
705            $corePage->resources->addXObject($name, $ref);
706        }
707
708        return $name;
709    }
710
711    /**
712     * @deprecated Use {@see PdfDoc::setOutline()} instead. This forwarder
713     *             is retained for one minor release and will be removed.
714     */
715    public function setOutline(Outline $outline): Outline
716    {
717        return $this->doc()->setOutline($outline);
718    }
719
720    /**
721     * @deprecated Use {@see PdfDoc::addOutlineItem()} instead. This
722     *             forwarder is retained for one minor release.
723     */
724    public function addOutlineItem(OutlineItem $item): PdfReference
725    {
726        return $this->doc()->addOutlineItem($item);
727    }
728
729    /**
730     * @deprecated Use {@see PdfDoc::setPageLabels()} instead. This
731     *             forwarder is retained for one minor release.
732     *
733     * @param array<int, PageLabel> $labels
734     */
735    public function setPageLabels(array $labels): void
736    {
737        $this->doc()->setPageLabels($labels);
738    }
739
740    /**
741     * @deprecated Use {@see PdfDoc::setNamedDestinations()} instead. This
742     *             forwarder is retained for one minor release.
743     *
744     * @param array<string, Destination> $destinations
745     */
746    public function setNamedDestinations(array $destinations): void
747    {
748        $this->doc()->setNamedDestinations($destinations);
749    }
750
751    /**
752     * Escape hatch to Level 0 â€” returns the underlying PdfFileWriter
753     * for direct object-model control.
754     */
755    public function fileWriter(): PdfFileWriter
756    {
757        return $this->file;
758    }
759
760    /**
761     * Register any arbitrary PdfObject (annotations, form fields, etc.).
762     */
763    public function register(PdfObject $object): PdfReference
764    {
765        return $this->file->register($object);
766    }
767
768    /**
769     * Add an image to a page as an XObject (internal â€” used by Writer\Page).
770     *
771     * @internal
772     * @return string Resource name (e.g. 'Im1')
773     */
774    public function addImageInternal(string $path, CorePage $page): string
775    {
776        return $this->addImage($path, $page);
777    }
778
779    /**
780     * Configure digital signing for this document.
781     *
782     * The signing lifecycle works in three phases:
783     *   1. **Placeholder:** A SignatureValue dictionary is emitted with a
784     *      zeroed /Contents hex string large enough to hold the final
785     *      PKCS#7 DER blob (`$placeholderBytes` controls the size).
786     *   2. **Byte-range:** After the full PDF is assembled, the /ByteRange
787     *      array is patched to cover everything except the /Contents value
788     *      itself, so the signature covers the entire file.
789     *   3. **Patch:** The Pkcs7Signer signs the byte-range data and the
790     *      resulting DER is written into the /Contents placeholder.
791     *
792     * @see PdfFileWriter::setSigner()
793     */
794    public function setSigner(
795        SignatureValue $signatureValue,
796        Pkcs7Signer $signer,
797        int $placeholderBytes = 8192,
798    ): void {
799        $this->file->setSigner($signatureValue, $signer, $placeholderBytes);
800    }
801
802    /**
803     * Configure a TSA client for RFC 3161 timestamping.
804     *
805     * @see PdfFileWriter::setTsaClient()
806     */
807    public function setTsaClient(TsaClient $tsaClient): void
808    {
809        $this->file->setTsaClient($tsaClient);
810    }
811
812    /**
813     * Configure a document-level timestamp using a TSA client.
814     *
815     * @see PdfFileWriter::setTimestamper()
816     */
817    public function setTimestamper(
818        SignatureValue $docTimeStamp,
819        TsaClient $tsaClient,
820        int $placeholderBytes = 16384,
821    ): void {
822        $this->file->setTimestamper($docTimeStamp, $tsaClient, $placeholderBytes);
823    }
824
825    /**
826     * Configure encryption for the generated PDF.
827     *
828     * Registers the encrypt dictionary, and during generation all
829     * strings and streams are encrypted per-object with the correct
830     * key derivation. The /Encrypt reference is added to the trailer
831     * automatically.
832     *
833     * @see PdfEncryptor::aes128()
834     * @see PdfEncryptor::aes256()
835     * @see PdfEncryptor::rc4128()
836     */
837    public function setEncryption(PdfEncryptor $encryptor): void
838    {
839        $this->file->setEncryption($encryptor);
840    }
841
842    public function getPdfVersion(): PdfVersion
843    {
844        return $this->file->getPdfVersion();
845    }
846
847    public function setStrictVersionMode(bool $strict = true): void
848    {
849        $this->file->setStrictVersionMode($strict);
850    }
851
852    public function setCeilingVersion(?PdfVersion $ceiling): void
853    {
854        $this->file->setCeilingVersion($ceiling);
855    }
856
857    public function setDeprecationHandler(\Closure $handler): void
858    {
859        $this->file->setDeprecationHandler($handler);
860    }
861
862    public function setStrictDeprecation(bool $strict = true): void
863    {
864        $this->file->setStrictDeprecation($strict);
865    }
866
867    /** @return list<string> */
868    public function getVersionWarnings(): array
869    {
870        return $this->file->getVersionWarnings();
871    }
872
873    /**
874     * Diagnostics for codepoints that were substituted with `?` because the
875     * font's encoding could not represent them. Empty when every glyph
876     * landed cleanly. Each entry names the font resource, the codepoint,
877     * and how many times it was requested.
878     *
879     * @return list<string>
880     */
881    public function getEncodingWarnings(): array
882    {
883        $warnings = [];
884        foreach ($this->fontEncoders as $resourceName => $encoder) {
885            $missing = $encoder->getMissingCodepoints();
886            if ($missing === []) {
887                continue;
888            }
889            $counts = array_count_values($missing);
890            foreach ($counts as $cp => $count) {
891                $warnings[] = sprintf(
892                    'Font %s: codepoint U+%04X has no WinAnsi mapping (substituted ? %dx)',
893                    $resourceName,
894                    $cp,
895                    $count,
896                );
897            }
898        }
899        return $warnings;
900    }
901
902    /**
903     * Enable or disable linearized (web-optimized) PDF output.
904     *
905     * When enabled, the generated PDF places the first page's objects at
906     * the front of the file, allowing a viewer to display it before
907     * downloading the rest (ISO 32000-2 Annex F).
908     */
909    public function setLinearized(bool $linearized = true): void
910    {
911        $this->linearized = $linearized;
912    }
913
914    /**
915     * Set one or more conformance profiles (e.g. PDF/A-1b, PDF/UA-1).
916     *
917     * When set, `generate()` will:
918     *   1. Auto-inject XMP identification metadata (if not already present)
919     *   2. Pin the PDF version to the profile minimum
920     *   3. Run all applicable constraint checks
921     *   4. In strict mode (default): throw ConformanceException on errors
922     *   5. In lenient mode: collect results in getConformanceResults()
923     *
924     * @param bool $strict Throw on conformance errors (default true)
925     */
926    public function setConformance(ConformanceProfile $profile, bool $strict = true): void
927    {
928        $this->conformanceMode = new ConformanceMode([$profile], $strict);
929    }
930
931    /**
932     * Set multiple conformance profiles at once (e.g. PDF/A-2a + PDF/UA-1).
933     *
934     * @param ConformanceProfile[] $profiles
935     * @param bool $strict Throw on conformance errors (default true)
936     */
937    public function setConformanceProfiles(array $profiles, bool $strict = true): void
938    {
939        $this->conformanceMode = new ConformanceMode($profiles, $strict);
940    }
941
942    /**
943     * Run conformance checks without generating the PDF.
944     *
945     * @return list<ConformanceResult>
946     */
947    public function checkConformance(): array
948    {
949        if ($this->conformanceMode === null) {
950            return [];
951        }
952
953        $inspector = new WriterDocumentInspector(
954            $this->catalog,
955            $this->file,
956            $this->fonts,
957        );
958
959        $validator = new ConformanceValidator();
960        return $validator->validateAll($inspector, $this->conformanceMode->profiles);
961    }
962
963    /**
964     * Get the conformance results from the last generate() call.
965     *
966     * @return list<ConformanceResult>
967     */
968    public function getConformanceResults(): array
969    {
970        return $this->conformanceResults;
971    }
972
973    /**
974     * Generate the complete PDF as a binary string.
975     */
976    public function generate(): string
977    {
978        if ($this->conformanceMode !== null) {
979            $this->applyConformance();
980        }
981
982        if ($this->linearized) {
983            return $this->file->generateLinearized($this->collectFirstPageObjectNumbers());
984        }
985        return $this->file->generate();
986    }
987
988    /**
989     * Alias for {@see generate()} â€” returns the raw PDF bytes as a string.
990     */
991    public function toBytes(): string
992    {
993        return $this->generate();
994    }
995
996    /**
997     * Write the generated PDF to an open stream resource.
998     *
999     * @param resource $stream
1000     */
1001    public function writeTo($stream): int
1002    {
1003        if (!is_resource($stream)) {
1004            throw new \InvalidArgumentException(
1005                'PdfWriter::writeTo() expects an open stream resource',
1006            );
1007        }
1008        $pdf = $this->generate();
1009        $written = fwrite($stream, $pdf);
1010        if ($written === false) {
1011            throw new \RuntimeException('Failed to write PDF bytes to stream');
1012        }
1013        return $written;
1014    }
1015
1016    /**
1017     * Write the PDF to a file, creating parent directories as needed.
1018     */
1019    public function save(string $path): void
1020    {
1021        $pdf = $this->generate();
1022        LocalFilesystem::writeFile($path, $pdf, createDirectories: true);
1023    }
1024
1025    /**
1026     * Collect object numbers belonging to the first page for linearization.
1027     *
1028     * Includes the catalog, page tree, first page, its content streams,
1029     * and all fonts/images referenced by the first page's resources.
1030     *
1031     * @return list<int>
1032     */
1033    private function collectFirstPageObjectNumbers(): array
1034    {
1035        $nums = [];
1036
1037        // Catalog and page tree are always first-page objects
1038        $nums[] = $this->catalog->objectNumber;
1039        $nums[] = $this->pageTree->objectNumber;
1040
1041        // First page and its content streams
1042        if (!empty($this->pages)) {
1043            $firstPage = $this->pages[0];
1044            $nums[] = $firstPage->objectNumber;
1045
1046            foreach ($firstPage->contents as $ref) {
1047                $nums[] = $ref->objectNumber;
1048            }
1049
1050            // Fonts and images registered on the first page's resources
1051            if ($firstPage->resources !== null) {
1052                foreach ($firstPage->resources->font as $ref) {
1053                    $nums[] = $ref->objectNumber;
1054                }
1055                foreach ($firstPage->resources->xObject as $ref) {
1056                    $nums[] = $ref->objectNumber;
1057                }
1058            }
1059        }
1060
1061        // Info dict if present
1062        if ($this->file->getInfo() !== null) {
1063            $nums[] = $this->file->getInfo()->objectNumber;
1064        }
1065
1066        return $nums;
1067    }
1068
1069    /**
1070     * @deprecated Use {@see PdfDoc::setMetadata()} instead. This
1071     *             forwarder is retained for one minor release.
1072     */
1073    public function setMetadata(string $xmpXml): void
1074    {
1075        $this->doc()->setMetadata($xmpXml);
1076    }
1077
1078    /**
1079     * @deprecated Use {@see PdfDoc::syncInfoToMetadata()} instead. This
1080     *             forwarder is retained for one minor release.
1081     */
1082    public function syncInfoToMetadata(): void
1083    {
1084        $this->doc()->syncInfoToMetadata();
1085    }
1086
1087    /**
1088     * Lazily-constructed PdfDoc view over this writer, used by the
1089     * deprecated forwarding stubs above. New code should use PdfDoc
1090     * directly.
1091     */
1092    private function doc(): PdfDoc
1093    {
1094        return $this->cachedDoc ??= PdfDoc::wrap($this);
1095    }
1096
1097    // -----------------------------------------------------------------------
1098    // Private helpers
1099    // -----------------------------------------------------------------------
1100
1101    /**
1102     * Apply conformance: auto-inject XMP, pin version, validate.
1103     */
1104    private function applyConformance(): void
1105    {
1106        $mode = $this->conformanceMode;
1107
1108        foreach ($mode->profiles as $profile) {
1109            // Pin PDF version to profile minimum
1110            $required = $profile->getPdfVersion();
1111            if ($required->isGreaterThan($this->file->getPdfVersion())) {
1112                $this->file->setVersion($required);
1113            }
1114
1115            // Auto-inject XMP identification if not already present
1116            if (!$this->catalog->metadata) {
1117                $info = $this->file->getInfo();
1118                $xmpWriter = new ConformanceXmpWriter();
1119                $xmp = $xmpWriter->buildXmp(
1120                    $profile,
1121                    title: $info?->title->value ?? '',
1122                    creator: $info?->author->value ?? '',
1123                    producer: $info?->producer->value ?? 'phpdftk',
1124                );
1125                $this->setMetadata($xmp);
1126            }
1127        }
1128
1129        // Run validation
1130        $this->conformanceResults = $this->checkConformance();
1131
1132        // In strict mode, throw on any non-compliant result
1133        if ($mode->strict) {
1134            $failures = array_filter(
1135                $this->conformanceResults,
1136                static fn(ConformanceResult $r) => !$r->isCompliant,
1137            );
1138            if ($failures !== []) {
1139                throw new ConformanceException(array_values($failures));
1140            }
1141        }
1142    }
1143
1144    private function embedTrueTypeFont(TrueTypeFont $font): void
1145    {
1146        $data = $font->parsedFontData;
1147
1148        // 1. Font program stream â€” subset if possible
1149        $fontBytes = $data->fontBytes;
1150        if (!empty($data->fullUnicodeToGid)) {
1151            // Subset to only WinAnsi-mapped glyphs
1152            $glyphIds = [];
1153            foreach ($data->unicodeMap as $unicode) {
1154                $gid = $data->fullUnicodeToGid[$unicode] ?? null;
1155                if ($gid !== null) {
1156                    $glyphIds[] = $gid;
1157                }
1158            }
1159            if (!empty($glyphIds)) {
1160                $fontBytes = (new TrueTypeSubsetter())->subset($fontBytes, $glyphIds, $data->fullUnicodeToGid);
1161            }
1162        }
1163        $streamDict = new PdfDictionary(['Length1' => new PdfNumber(strlen($fontBytes))]);
1164        $fontStream = new PdfStream($streamDict, $fontBytes);
1165        $this->file->register($fontStream);
1166
1167        // 2. FontDescriptor
1168        $descriptor = new FontDescriptor(new PdfName($data->postScriptName));
1169        $descriptor->flags = $data->flags;
1170        $descriptor->fontBBox = new PdfArray([
1171            new PdfNumber($data->fontBBox[0]),
1172            new PdfNumber($data->fontBBox[1]),
1173            new PdfNumber($data->fontBBox[2]),
1174            new PdfNumber($data->fontBBox[3]),
1175        ]);
1176        $descriptor->italicAngle = $data->italicAngle;
1177        $descriptor->ascent      = $data->ascent;
1178        $descriptor->descent     = $data->descent;
1179        $descriptor->capHeight   = $data->capHeight;
1180        $descriptor->xHeight     = $data->xHeight;
1181        $descriptor->stemV       = $data->stemV;
1182        $descriptor->fontFile2   = new PdfReference($fontStream->objectNumber);
1183        $this->file->register($descriptor);
1184
1185        // 3. ToUnicode CMap stream
1186        $cmapStream = new PdfStream(new PdfDictionary(), $this->buildToUnicodeCMap($data->unicodeMap));
1187        $this->file->register($cmapStream);
1188
1189        // 4. Wire back to font
1190        $font->fontDescriptor = new PdfReference($descriptor->objectNumber);
1191        $font->toUnicode      = new PdfReference($cmapStream->objectNumber);
1192        $font->encoding       = new PdfName('WinAnsiEncoding');
1193    }
1194
1195    /**
1196     * Embed a custom Type 1 font with its font program, descriptor, and ToUnicode CMap.
1197     */
1198    private function embedType1Font(Type1Font $font): void
1199    {
1200        $data = $font->parsedFontData;
1201
1202        // 1. Font program stream (Type1FontFile with /Length1, /Length2, /Length3)
1203        $fontStream = new Type1FontFile(
1204            $data->fontBytes,
1205            $data->length1,
1206            $data->length2,
1207            $data->length3,
1208        );
1209        $this->file->register($fontStream);
1210
1211        // 2. FontDescriptor
1212        $descriptor = new FontDescriptor(new PdfName($data->postScriptName));
1213        $descriptor->flags      = $data->flags;
1214        $descriptor->fontBBox   = new PdfArray([
1215            new PdfNumber($data->fontBBox[0]),
1216            new PdfNumber($data->fontBBox[1]),
1217            new PdfNumber($data->fontBBox[2]),
1218            new PdfNumber($data->fontBBox[3]),
1219        ]);
1220        $descriptor->italicAngle = $data->italicAngle;
1221        $descriptor->ascent      = $data->ascent;
1222        $descriptor->descent     = $data->descent;
1223        $descriptor->capHeight   = $data->capHeight;
1224        $descriptor->xHeight     = $data->xHeight;
1225        $descriptor->stemV       = $data->stemV;
1226        $descriptor->fontFile    = new PdfReference($fontStream->objectNumber);
1227        $this->file->register($descriptor);
1228
1229        // 3. ToUnicode CMap stream
1230        if (!empty($data->unicodeMap)) {
1231            $cmapStream = new PdfStream(new PdfDictionary(), $this->buildToUnicodeCMap($data->unicodeMap));
1232            $this->file->register($cmapStream);
1233            $font->toUnicode = new PdfReference($cmapStream->objectNumber);
1234        }
1235
1236        // 4. Wire back to font
1237        $font->fontDescriptor = new PdfReference($descriptor->objectNumber);
1238    }
1239
1240    /** @param array<int, int> $unicodeMap */
1241    private function buildToUnicodeCMap(array $unicodeMap): string
1242    {
1243        ksort($unicodeMap);
1244        $entries = [];
1245        foreach ($unicodeMap as $byte => $unicode) {
1246            $entries[] = sprintf('<%02X> <%04X>', $byte, $unicode);
1247        }
1248
1249        // PDF spec: max 100 entries per beginbfchar block
1250        $chunks = array_chunk($entries, 100);
1251        $blocks = '';
1252        foreach ($chunks as $chunk) {
1253            $blocks .= count($chunk) . " beginbfchar\n"
1254                     . implode("\n", $chunk) . "\n"
1255                     . "endbfchar\n";
1256        }
1257
1258        return "/CIDInit /ProcSet findresource begin\n"
1259             . "12 dict begin\n"
1260             . "begincmap\n"
1261             . "/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n"
1262             . "/CMapName /Adobe-Identity-UCS def\n"
1263             . "/CMapType 2 def\n"
1264             . "1 begincodespacerange\n"
1265             . "<20> <FF>\n"
1266             . "endcodespacerange\n"
1267             . $blocks
1268             . "endcmap\n"
1269             . "CMap end\n"
1270             . "end";
1271    }
1272}