Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.30% covered (warning)
79.30%
1195 / 1507
25.37% covered (danger)
25.37%
17 / 67
CRAP
0.00% covered (danger)
0.00%
0 / 1
Renderer
79.30% covered (warning)
79.30%
1195 / 1507
25.37% covered (danger)
25.37%
17 / 67
3841.41
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 render
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 renderInto
91.75% covered (success)
91.75%
189 / 206
0.00% covered (danger)
0.00%
0 / 1
29.47
 hasContainment
66.67% covered (warning)
66.67%
10 / 15
0.00% covered (danger)
0.00%
0 / 1
8.81
 expandChildNavigables
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
9
 generateEmbeddedDocument
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 propagateBodyWritingModeToRoot
85.19% covered (warning)
85.19%
23 / 27
0.00% covered (danger)
0.00%
0 / 1
17.94
 collectHeadings
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
7
 collectTextContent
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 emitOutline
100.00% covered (success)
100.00%
73 / 73
100.00% covered (success)
100.00%
1 / 1
19
 collectAnchors
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
11.03
 parse
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseStylesheet
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 collectStylesheets
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 expandImports
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
9.02
 loadImport
66.67% covered (warning)
66.67%
22 / 33
0.00% covered (danger)
0.00%
0 / 1
23.33
 fetchImportSource
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
4.12
 resourceLoader
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 emitLinkAnnotations
94.44% covered (success)
94.44%
34 / 36
0.00% covered (danger)
0.00%
0 / 1
10.02
 resolveFragmentDestination
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 applyBaseHref
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
6.00
 findFirstBaseHref
54.55% covered (warning)
54.55%
6 / 11
0.00% covered (danger)
0.00%
0 / 1
11.60
 rewriteRelativeSrcAttributes
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 rewriteRelativeSrcInSubtree
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
110
 applyDocumentMetadata
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
5.01
 formatPdfDate
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
4.54
 findTextOfFirstElement
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
9.02
 findMetaContent
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
10.03
 extractAuthorCss
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
12
 collectCodepoints
84.31% covered (warning)
84.31%
43 / 51
0.00% covered (danger)
0.00%
0 / 1
12.56
 countUnpaintableImages
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
9.02
 characterReferenceCodepoints
20.69% covered (danger)
20.69%
6 / 29
0.00% covered (danger)
0.00%
0 / 1
97.31
 isPaintableImageSrc
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
6.10
 documentHasText
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
8.02
 loadFontFaces
60.47% covered (warning)
60.47%
52 / 86
0.00% covered (danger)
0.00%
0 / 1
67.77
 fontFaceWeight
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 fontFaceStyle
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 fontFaceStretch
28.57% covered (danger)
28.57%
4 / 14
0.00% covered (danger)
0.00%
0 / 1
64.48
 registerFontWithPdfWriter
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
4.12
 parseFontFace
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
3
 flattenRules
38.89% covered (danger)
38.89%
7 / 18
0.00% covered (danger)
0.00%
0 / 1
38.62
 staticallyMatchingMediaPrelude
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
30
 fontFamilyName
35.29% covered (danger)
35.29%
6 / 17
0.00% covered (danger)
0.00%
0 / 1
43.78
 splitSrcList
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
7
 extractFormatHint
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
7.99
 loadLinkedStylesheet
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
12.67
 rebaseStylesheetUrls
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
9
 stylesheetBasePrefix
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 isAbsoluteResourceUrl
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 mediaPreludeMatches
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
8.70
 fetchFontSource
37.04% covered (danger)
37.04%
10 / 27
0.00% covered (danger)
0.00%
0 / 1
79.90
 fetchHttpResource
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
20
 collectPageMarginBoxes
88.12% covered (warning)
88.12%
89 / 101
0.00% covered (danger)
0.00%
0 / 1
40.42
 resolvePageSize
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
10
 resolvePageBackground
82.61% covered (warning)
82.61%
19 / 23
0.00% covered (danger)
0.00%
0 / 1
13.89
 pageSelectorAppliesTo
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
9.49
 resolvePageNames
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
9.02
 resolvePageMargins
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
13.13
 parsePageSize
82.69% covered (warning)
82.69%
43 / 52
0.00% covered (danger)
0.00%
0 / 1
23.29
 parseFontWeight
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
15.40
 parseFontStyle
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 normalisePageSelector
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 resolvePageMarginBoxes
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
7
 parseContentValue
95.24% covered (success)
95.24%
40 / 42
0.00% covered (danger)
0.00%
0 / 1
15
 splitCounterArgs
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 paintPageMarginBoxes
96.34% covered (success)
96.34%
79 / 82
0.00% covered (danger)
0.00%
0 / 1
32
 maybeThrow
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\HtmlToPdf;
6
7use Phpdftk\Css\Cascade\Cascade;
8use Phpdftk\Css\Cascade\LengthContext;
9use Phpdftk\Css\Cascade\PropertyRegistry;
10use Phpdftk\Css\Parser as CssParser;
11use Phpdftk\Css\Sheet\Origin;
12use Phpdftk\Css\Sheet\Stylesheet;
13use Phpdftk\Html\Dom\Document;
14use Phpdftk\Html\Parser as HtmlParser;
15use Phpdftk\Html\Tokenizer\NamedCharacterReferences;
16use Phpdftk\HtmlToPdf\Box\BoxGenerator;
17use Phpdftk\HtmlToPdf\Layout\BlockLayout;
18use Phpdftk\HtmlToPdf\Layout\LayoutContext;
19use Phpdftk\HtmlToPdf\Painter\Painter;
20use Phpdftk\Pdf\Writer\PdfWriter;
21
22/**
23 * Top-level façade for `phpdftk/html-to-pdf`. Wires parse → cascade →
24 * box generation → layout → paint into one call. Holds no state between
25 * invocations — every `render()` produces a fresh `PdfWriter`.
26 *
27 * Usage:
28 *
29 *     $result = (new Renderer())->render($html, $css);
30 *     $result->writer->save('out.pdf');
31 *
32 * Or render into an existing writer (the path `Pdf::addHtml` will use):
33 *
34 *     $warnings = (new Renderer())->renderInto($writer, $html, $css);
35 *
36 * Phase-1 simplifications: text is only painted when `RendererOptions`
37 * carries a `defaultFont`; without one the renderer still produces a
38 * structurally-valid PDF with background + border content. `@font-face`
39 * resolution and font-family matching land in 1M. Multi-page paginated
40 * output lands in 1I (paged media); for now the renderer fits the
41 * document onto a single page sized by `RendererOptions`.
42 */
43final class Renderer
44{
45    private readonly HtmlParser $htmlParser;
46    private readonly CssParser $cssParser;
47    private readonly Cascade $cascade;
48    private readonly BoxGenerator $boxGenerator;
49    private readonly BlockLayout $layout;
50    private ?\Phpdftk\Filesystem\ResourceLoader $cachedResourceLoader = null;
51
52    public function __construct(
53        public readonly RendererOptions $options = new RendererOptions(),
54    ) {
55        $this->htmlParser = new HtmlParser();
56        $this->cssParser = new CssParser();
57        // Wire the page viewport into the cascade so `@media`
58        // feature queries (`(min-width: ...)`, `orientation`, etc.)
59        // evaluate against the rendered page dimensions in CSS px.
60        $cssPxPerPt = 96.0 / 72.0;
61        $this->cascade = (new Cascade(PropertyRegistry::default()))
62            ->withViewport(
63                $this->options->pageWidth * $cssPxPerPt,
64                $this->options->pageHeight * $cssPxPerPt,
65            )
66            ->withMatchingMediaTypes($this->options->matchingMediaTypes);
67        $this->boxGenerator = new BoxGenerator(
68            $this->cascade,
69            $this->options->baseDir,
70            $this->options->sandboxRoot,
71        );
72        $this->layout = new BlockLayout(
73            $this->cascade,
74            new \Phpdftk\HtmlToPdf\Layout\InlineLayout(),
75            // Inline `<math>` is the one replaced element whose
76            // intrinsic size has to be MEASURED rather than read off an
77            // attribute, so layout gets a measurer instead of computing
78            // it. Lazily builds its scratch writer, so documents with
79            // no MathML pay nothing.
80            new \Phpdftk\HtmlToPdf\Mathml\MathmlIntrinsicSizer(),
81        );
82    }
83
84    /**
85     * Render `$html` (with optional author CSS) into a fresh `PdfWriter`.
86     * Returns a {@see RenderResult} carrying both the writer and any
87     * diagnostics that came up.
88     */
89    public function render(string $html, ?string $css = null): RenderResult
90    {
91        $writer = new PdfWriter();
92        $warnings = $this->renderInto($writer, $html, $css);
93        return new RenderResult($writer, $warnings);
94    }
95
96    /**
97     * Render into an existing `PdfWriter`. Returns the diagnostics
98     * emitted; the writer mutation is the visible side effect.
99     *
100     * @return list<Warning>
101     */
102    public function renderInto(PdfWriter $writer, string $html, ?string $css = null): array
103    {
104        $warnings = [];
105
106        $document = $this->htmlParser->parseDocument($html);
107        $this->applyDocumentMetadata($document, $writer);
108        // HTML 5 §4.2.3 — `<base href="...">` in the document head
109        // sets the document base URL for relative resolution of
110        // `src` / `href` etc. We rewrite the affected attributes in
111        // place so the rest of the pipeline (BoxGenerator's
112        // `naturalImageSize`, the painter's `resolveImageSrc`)
113        // consumes the post-base form without needing to know
114        // about the `<base>` element.
115        $this->applyBaseHref($document);
116        $sheets = $this->collectStylesheets($css, $document);
117        // @font-face parsing: walk every sheet for `@font-face` rules,
118        // decode their `data:font/*` sources, and merge the parsed
119        // OpenTypeData into a copy of the configured fontMap before the
120        // FontResolver gets built. Authored `@font-face` wins over any
121        // entry in `RendererOptions::fontMap` that shares its family.
122        $fontMap = $this->options->fontMap;
123        $faceMap = $this->options->faceMap;
124        $faceWarnings = [];
125        foreach ($this->loadFontFaces($sheets, $faceWarnings) as $name => $face) {
126            $key = strtolower($name);
127            // Authored faces feed the multi-face resolver (§6 matching)
128            // AND the single-face fontMap fallback. The fontMap slot is
129            // last-wins (legacy behaviour); the faceMap accumulates every
130            // face so weight/style/stretch selection can discriminate.
131            $fontMap[$key] = $face->data;
132            $faceMap[$key][] = $face;
133        }
134        $warnings = array_merge($warnings, $faceWarnings);
135        // CSS Paged Media 3 §6.1: `@page { size: ... }` overrides the
136        // renderer's default page dimensions when set. Read it before
137        // building the layout context so block layout sees the right
138        // containing-block width / height for `%` resolution and the
139        // pagination math works against the actual page slot.
140        $pageSize = $this->resolvePageSize($sheets);
141        $pageWidth = $pageSize['width'];
142        $pageHeight = $pageSize['height'];
143        // CSS Paged Media 3 §6.2: `@page { margin: ... }` declares the
144        // page margins. Phase-1 uses the margin only for positioning the
145        // running headers/footers — the body still gets its layout origin
146        // from `body { margin }` so existing fixtures stay stable.
147        $pageMargins = $this->resolvePageMargins($sheets);
148        $root = $this->boxGenerator->generate($document, $sheets);
149        // CSS GCPM 3 §5 — capture the named-string store populated
150        // during box generation. Page-margin painting reads it to
151        // resolve `content: string(name)` references.
152        $namedStrings = $this->boxGenerator->getNamedStrings();
153        $runningElements = $this->boxGenerator->getRunningElements();
154        if ($root === null) {
155            // No paintable root box — either the document has no <html>
156            // element at all, or `html { display: none }` / `display:
157            // contents` collapsed the principal box. CSS Backgrounds 3
158            // §3.11 treats this as "no special background propagation"
159            // but the viewport itself still exists, so emit a single
160            // blank page so downstream consumers (and the WPT rasteriser)
161            // see a valid PDF. Browsers' `blank.html` reference renders
162            // exactly this shape. We still surface the warning + honour
163            // strict mode so callers that treat "no root" as malformed
164            // input keep their existing escalation path.
165            $warnings[] = new Warning(
166                WarningCode::UnsupportedDisplayType,
167                'Document has no <html> root element',
168                WarningSeverity::Error,
169            );
170            $this->maybeThrow($warnings);
171            $writer->addPage($pageWidth, $pageHeight);
172            return $warnings;
173        }
174
175        $fontResolver = new \Phpdftk\HtmlToPdf\Layout\FontResolver(
176            $fontMap,
177            $this->options->defaultFont,
178            $faceMap,
179        );
180        // Plumb the default font's x-height and `0` glyph-width into
181        // the LengthContext so `ex` / `ch` resolve against real font
182        // metrics (CSS Values 4 §6.1.1). When no default font is
183        // wired in, the LengthContext's 0.5em fallback applies.
184        $defaultFont = $this->options->defaultFont;
185        // CSS Values 4 §6.2 — `vw` / `vh` resolve against the VIEWPORT,
186        // which for paged output is the page box. LengthContext's own
187        // defaults (816x1056) are the DEVICE-pixel equivalents of a
188        // US-Letter page, but layout runs in CSS px where that page is
189        // 612x792 — so every viewport unit resolved a third too large.
190        $lengthContext = new LengthContext(
191            viewportWidth: $pageWidth,
192            viewportHeight: $pageHeight,
193        );
194        if ($defaultFont !== null && $defaultFont->unitsPerEm > 0) {
195            $upem = (float) $defaultFont->unitsPerEm;
196            $xHeightRatio = $defaultFont->xHeight > 0
197                ? $defaultFont->xHeight / $upem
198                : 0.5;
199            $zeroWidth = $defaultFont->charWidths[0x30] ?? null;
200            $chWidthRatio = $zeroWidth !== null && $zeroWidth > 0
201                ? $zeroWidth / $upem
202                : 0.5;
203            $capHeightRatio = $defaultFont->capHeight > 0
204                ? $defaultFont->capHeight / $upem
205                : 0.7;
206            $lengthContext = $lengthContext->withFontMetrics($xHeightRatio, $chWidthRatio, $capHeightRatio);
207        }
208        $layoutCtx = new LayoutContext(
209            containingBlockWidth: $pageWidth,
210            containingBlockHeight: $pageHeight,
211            originX: 0.0,
212            originY: 0.0,
213            lengthContext: $lengthContext,
214            defaultFont: $this->options->defaultFont,
215            fontResolver: $fontResolver,
216            // Real page height for pagination — kept distinct from the
217            // containing-block height so nested definite-height blocks
218            // don't masquerade as fragmentainers (see LayoutContext).
219            pageHeight: $pageHeight,
220        );
221        // CSS Writing Modes 4 §3.1 — when the root element's
222        // `writing-mode` / `direction` are at the initial value but
223        // the first `<body>` child sets them to something else, the
224        // body's *used* values propagate to the root for layout
225        // purposes. Mirrors the CSS Backgrounds 3 §3.11.2 propagation
226        // pattern already applied by the painter for background
227        // colour. Only the root box's used style changes; computed
228        // values stay as-cascaded so descendants keep inheriting
229        // from the original chain.
230        $this->propagateBodyWritingModeToRoot($root);
231        // HTML §4.8.5 / §7.3 — an `<iframe>` hosts a CHILD NAVIGABLE: a
232        // second document laid out inside the frame's content box. Expand
233        // those now, between box generation and layout, so the embedded
234        // trees are ordinary children of the frame box by the time layout
235        // and paint walk the tree — the frame's definite 300x150 content
236        // box is the nested viewport, and its UA `overflow: clip` is what
237        // crops the embedded page to the frame.
238        //
239        // `$embeddedMarkup` collects each embedded document's source so
240        // the font subset further down can see its codepoints. That
241        // subset is built from `strip_tags()` of the HOST source, and a
242        // frame's document lives in an attribute value `strip_tags`
243        // deletes wholesale — so without this the embedded text's
244        // characters are absent from the subset, and an absent codepoint
245        // paints whatever glyph occupies that slot rather than notdef.
246        // That is how `left/right` came out as `left;` in one document
247        // and `leftA` in another.
248        /** @var list<string> $embeddedMarkup */
249        $embeddedMarkup = [];
250        $this->expandChildNavigables($root, 0, $embeddedMarkup);
251        $this->layout->layout($root, $layoutCtx);
252
253        // If the document contains non-whitespace text but no font was
254        // wired in, warn — the text won't render. Lenient mode still
255        // produces a valid PDF (background + border content only).
256        if ($this->options->defaultFont === null && $this->documentHasText($document)) {
257            $warnings[] = new Warning(
258                WarningCode::MissingFont,
259                'No default font configured — text content will not render. '
260                . 'Pass a font via RendererOptions::withDefaultFont().',
261                WarningSeverity::Warning,
262            );
263        }
264
265        // `<img>` without a paintable `data:image/png|jpeg` URL or `alt`
266        // fallback won't appear in the output — emit a warning per failing
267        // image so callers can surface the missing-resource state.
268        $unpaintableImgs = $this->countUnpaintableImages($document);
269        if ($unpaintableImgs > 0) {
270            $warnings[] = new Warning(
271                WarningCode::MissingResource,
272                sprintf(
273                    '%d <img> element%s without an embeddable data: URL — '
274                    . 'remote / file:// image fetching lands with Phase 1L\'s '
275                    . 'resource loader. Add `alt="..."` so the fallback flows.',
276                    $unpaintableImgs,
277                    $unpaintableImgs === 1 ? '' : 's',
278                ),
279                WarningSeverity::Warning,
280            );
281        }
282
283        $totalHeight = max($pageHeight, $root->geometry->outerHeight());
284        $pageCount = (int) max(1, ceil($totalHeight / $pageHeight));
285        // Cap the page count at a sane maximum so adversarial CSS
286        // (`height: 12345678901234px`, deeply nested multicol balance
287        // with extreme dimensions) can't paginate into hundreds of
288        // thousands of pages — each Page allocates closures + a per-
289        // page resolver and the cumulative cost OOMs the renderer
290        // long before the PDF would be useful. Real documents
291        // dwarfed by this number genuinely have a bug or attack
292        // upstream. See phpdftk/phpdftk#28.
293        $MAX_PAGE_COUNT = 10000;
294        if ($pageCount > $MAX_PAGE_COUNT) {
295            $warnings[] = new Warning(
296                WarningCode::UnsupportedDisplayType,
297                sprintf(
298                    'Computed %d pages exceeds the %d-page safety cap; truncating. '
299                    . 'This usually means an unbounded height / aspect-ratio fed into layout.',
300                    $pageCount,
301                    $MAX_PAGE_COUNT,
302                ),
303                WarningSeverity::Warning,
304            );
305            $pageCount = $MAX_PAGE_COUNT;
306        }
307
308        // CSS Paged Media 3 §3.4: when a block declares `page: foo`,
309        // the page containing its first fragment is tagged "foo" and
310        // picks up `@page foo` overrides (background / margins /
311        // margin-boxes). Walk the laid-out box tree once to build
312        // `pageIndex → name`; later, per-page resolvers overlay the
313        // named rules on top of the defaults.
314        $pageNames = $this->resolvePageNames($root, $pageHeight, $pageCount);
315
316        // Build an `id → layoutY` map so `<a href="#anchor">` links can
317        // resolve to PDF named destinations. Walk the post-layout box tree
318        // once.
319        $anchorMap = $this->collectAnchors($root);
320
321        // Collect heading boxes ahead of pagination so we can emit a PDF
322        // outline once page refs are known.
323        $headings = $this->collectHeadings($root);
324
325        // Pre-add all pages up-front so we have a stable PdfReference for
326        // every page before any annotation is emitted — a link on page 1
327        // may target an anchor on page 3.
328        /** @var list<\Phpdftk\Pdf\Writer\Page> $pages */
329        $pages = [];
330        for ($i = 0; $i < $pageCount; $i++) {
331            $pages[] = $writer->addPage($pageWidth, $pageHeight);
332        }
333
334        // Register the outline now that page refs exist. Build a flat list
335        // of headings — nesting under their level is a Phase-2 follow-up.
336        $this->emitOutline($headings, $pages, $pageHeight, $writer);
337
338        // CSS Paged Media 3 §3 + Generated Content for Paged Media 3 §2:
339        // collect `@page { @<position> { content: "..." } }` blocks once
340        // up-front. Phase-1 subset supports static `content: <string>` in
341        // the 6 corner / centre margin boxes (top-left / top-center /
342        // top-right / bottom-left / bottom-center / bottom-right). The
343        // other 10 positions (corner + side rails) and `counter(page)` /
344        // `element()` substitution land in follow-ups.
345        $pageMarginBoxes = $this->collectPageMarginBoxes($sheets);
346
347        for ($i = 0; $i < $pageCount; $i++) {
348            $page = $pages[$i];
349            $stream = $writer->addContentStream($page);
350
351            $codepoints = $this->collectCodepoints(
352                $embeddedMarkup === [] ? $html : $html . "\n" . implode("\n", $embeddedMarkup),
353            );
354            $registeredFont = null;
355            /** @var array<string, \Phpdftk\Pdf\Core\Font\RegisteredFont> $registeredMap */
356            $registeredMap = [];
357            if ($this->options->defaultFont !== null) {
358                $registeredFont = $this->registerFontWithPdfWriter(
359                    $writer,
360                    $this->options->defaultFont,
361                    $codepoints,
362                    $page,
363                );
364                $registeredMap[$this->options->defaultFont->postScriptName] = $registeredFont;
365            }
366            // Register every alternate font in the map so per-fragment
367            // font-family switching has a `Tf` resource to reference.
368            foreach ($fontMap as $alt) {
369                if ($alt === $this->options->defaultFont) {
370                    continue;
371                }
372                if (isset($registeredMap[$alt->postScriptName])) {
373                    continue;
374                }
375                $registeredMap[$alt->postScriptName] = $this->registerFontWithPdfWriter(
376                    $writer,
377                    $alt,
378                    $codepoints,
379                    $page,
380                );
381            }
382            // Register every weight/style face the resolver might pick.
383            // Same key as defaultFont/fontMap so the painter looks up the
384            // right RegisteredFont by postScriptName. Uses the merged
385            // faceMap (options + authored `@font-face`) so a face the
386            // resolver selects from a multi-face family is actually
387            // embedded — otherwise its glyph IDs reference an unregistered
388            // font and the run paints blank.
389            foreach ($faceMap as $faces) {
390                foreach ($faces as $face) {
391                    if (isset($registeredMap[$face->data->postScriptName])) {
392                        continue;
393                    }
394                    $registeredMap[$face->data->postScriptName] = $this->registerFontWithPdfWriter(
395                        $writer,
396                        $face->data,
397                        $codepoints,
398                        $page,
399                    );
400                }
401            }
402
403            // Clip every page's drawing to its own MediaBox so the
404            // "paint the whole tree, let the viewport drop what's
405            // off-page" pagination strategy doesn't leak content past
406            // the page boundaries in viewers that don't crop automatically.
407            $stream->rectangle(0, 0, $pageWidth, $pageHeight);
408            $stream->clip();
409            $stream->endPath();
410            // CSS Paged Media 3 §3.1: `@page { background-color }`
411            // fills the entire page sheet before any content paints.
412            // Sits inside the clip so the colour stays on this page
413            // even when later content draws over it. When this page
414            // is tagged with a name (via a `page: foo` block on it),
415            // overlay `@page foo` onto the default rule.
416            $pageBgForThis = $this->resolvePageBackground($sheets, $pageNames[$i] ?? null);
417            if ($pageBgForThis !== null) {
418                $stream->saveGraphicsState();
419                $stream->setFillColorRGB(
420                    $pageBgForThis->r,
421                    $pageBgForThis->g,
422                    $pageBgForThis->b,
423                );
424                $stream->rectangle(0, 0, $pageWidth, $pageHeight);
425                $stream->fill();
426                $stream->restoreGraphicsState();
427            }
428
429            // Page i wants layout-Y rows [i*pageHeight .. (i+1)*pageHeight)
430            // to appear at the top of the PDF page. The painter computes
431            // PDF Y as `pageHeightConstant - layoutY`; setting the constant
432            // to `(i+1)*pageHeight` makes layoutY=i*pageHeight land at
433            // PDF Y = pageHeight (top of MediaBox), and layoutY=(i+1)*pageHeight
434            // land at PDF Y = 0 (bottom).
435            $painter = new Painter(
436                ($i + 1) * $pageHeight,
437                $registeredFont,
438                $page,
439                pageRangeStart: $i * $pageHeight,
440                pageRangeEnd: ($i + 1) * $pageHeight,
441                writer: $writer,
442                baseDir: $this->options->baseDir,
443                sandboxRoot: $this->options->sandboxRoot,
444                registeredFonts: $registeredMap,
445                fontDataByFamily: $fontMap,
446                pageWidth: $pageWidth,
447                resourceLoader: $this->options->resourceLoader,
448                fontResolver: $fontResolver,
449            );
450            $painter->paint($root, $stream);
451            // Per-page link annotations — emit one /Link per `<a href>` rect
452            // the painter collected on this page, clipping to MediaBox so
453            // multi-page paint passes don't leak annotations onto unrelated
454            // pages.
455            $this->emitLinkAnnotations(
456                $painter->collectedLinks,
457                $writer,
458                $page,
459                $pageHeight,
460                $anchorMap,
461                $pages,
462            );
463            // Paint `@page` margin boxes (running headers / footers) once
464            // per page, after the main content stream so they sit on top.
465            // Uses the writer's default-font GID map so text shapes against
466            // the same font subset as the rest of the document. Per-page
467            // selector resolution happens here so `@page :first` only
468            // applies to page 0 and `@page :left`/`:right` alternate.
469            if ($pageMarginBoxes !== [] && $registeredFont !== null && $this->options->defaultFont !== null) {
470                $resolved = $this->resolvePageMarginBoxes($pageMarginBoxes, $i, $pageNames[$i] ?? null);
471                if ($resolved !== []) {
472                    $this->paintPageMarginBoxes(
473                        $stream,
474                        $resolved,
475                        $pageWidth,
476                        $pageHeight,
477                        $this->options->defaultFont,
478                        $registeredFont,
479                        pageIndex: $i,
480                        pageCount: $pageCount,
481                        fontResolver: $fontResolver,
482                        registeredMap: $registeredMap,
483                        marginTop: $pageMargins['top'],
484                        marginRight: $pageMargins['right'],
485                        marginBottom: $pageMargins['bottom'],
486                        marginLeft: $pageMargins['left'],
487                        namedStrings: $namedStrings,
488                        runningElements: $runningElements,
489                    );
490                }
491            }
492        }
493
494        return $warnings;
495    }
496
497    /**
498     * Collect every `<h1>`–`<h6>` box in document order, capturing the
499     * heading level, the rendered text content, and the box's top-edge Y
500     * in layout space. Headings without text content are skipped.
501     *
502     * CSS Writing Modes 4 §3.1 — body-to-root propagation. When the
503     * root element's `writing-mode` / `direction` are at the initial
504     * value (`horizontal-tb` / `ltr`) but the first in-flow `<body>`
505     * child sets either to a non-initial value, the body's used
506     * values propagate up to the root for layout. Only the root's
507     * own style is mutated; descendants keep inheriting from the
508     * original cascade chain.
509     */
510    /**
511     * CSS Contain §2 — does this box's `contain` establish any of the
512     * containment types that make it an independent layout root (layout /
513     * paint / size / style, plus the `content` / `strict` shorthands and
514     * the size sub-keywords)? Used to gate the body-to-root writing-mode
515     * propagation quirk.
516     */
517    private function hasContainment(\Phpdftk\HtmlToPdf\Box\Box $box): bool
518    {
519        $value = $box->style->get('contain');
520        $names = [];
521        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
522            $names[] = strtolower($value->name);
523        } elseif ($value instanceof \Phpdftk\Css\Value\ValueList) {
524            foreach ($value->values as $v) {
525                if ($v instanceof \Phpdftk\Css\Value\Keyword) {
526                    $names[] = strtolower($v->name);
527                }
528            }
529        }
530        foreach ($names as $n) {
531            if (in_array($n, [
532                'layout', 'paint', 'size', 'style', 'content', 'strict',
533                'inline-size', 'block-size',
534            ], true)) {
535                return true;
536            }
537        }
538        return false;
539    }
540
541    /**
542     * Maximum nesting of child navigables. A frame whose `srcdoc`
543     * embeds itself, or two frames that embed each other's file, would
544     * otherwise recurse until the stack gives out — and unlike a real
545     * browser there is no navigation budget to stop it. Four levels is
546     * past anything a printable document does deliberately.
547     */
548    private const int MAX_FRAME_DEPTH = 4;
549
550    /**
551     * Walk `$box`'s subtree and give every `<iframe>` / `<frame>` box the
552     * box tree of the document it embeds, as its own child.
553     *
554     * Attaching the embedded root as an ORDINARY CHILD is the whole
555     * trick: the frame box already has a definite content box (the
556     * §15.3.3 default object size, 300x150, unless width/height say
557     * otherwise), so block layout lays the embedded `<html>` out against
558     * exactly the viewport a nested browsing context is supposed to
559     * have, and the frame's UA `overflow: clip` crops it. No second
560     * layout pass, no parallel paint path.
561     *
562     * Each embedded document gets a FRESH {@see BoxGenerator}: counters,
563     * list scopes and named strings are per-document state, and a
564     * document that shares its counters with the page that framed it
565     * would number its own lists off the host's `<ol>`.
566     *
567     * @param list<string> $markupOut Collects each embedded document's
568     *     markup for the font-subset scan.
569     */
570    private function expandChildNavigables(
571        \Phpdftk\HtmlToPdf\Box\Box $box,
572        int $depth,
573        array &$markupOut,
574    ): void {
575        if ($depth >= self::MAX_FRAME_DEPTH) {
576            return;
577        }
578        $element = $box->element;
579        $tag = $element === null ? '' : strtolower($element->localName);
580        if (($tag === 'iframe' || $tag === 'frame') && $box->children === []) {
581            $markup = (new \Phpdftk\HtmlToPdf\FrameSource($this->resourceLoader()))
582                ->markupFor($element);
583            if ($markup !== null) {
584                $embedded = $this->generateEmbeddedDocument($markup, $element);
585                if ($embedded !== null) {
586                    $markupOut[] = $markup;
587                    $box->hostsChildNavigable = true;
588                    $box->addChild($embedded);
589                    $this->expandChildNavigables($embedded, $depth + 1, $markupOut);
590                }
591            }
592            return;
593        }
594        foreach ($box->children as $child) {
595            $this->expandChildNavigables($child, $depth, $markupOut);
596        }
597    }
598
599    /**
600     * Parse `$markup` as a standalone document and build its box tree,
601     * with `$frameOwner` recorded so the embedded `<body>` can inherit
602     * the frame's `marginwidth` / `marginheight` (HTML §15.3.3).
603     */
604    private function generateEmbeddedDocument(
605        string $markup,
606        \Phpdftk\Html\Dom\Element $frameOwner,
607    ): ?\Phpdftk\HtmlToPdf\Box\Box {
608        $document = $this->htmlParser->parseDocument($markup);
609        $this->applyBaseHref($document);
610        // The embedded document's OWN stylesheets — UA rules plus
611        // whatever it carries inline. The framing page's author CSS is
612        // deliberately absent: styles do not cross a navigable boundary,
613        // so a host `p { color: red }` must not reach into the frame.
614        $sheets = $this->collectStylesheets(null, $document);
615        $generator = new BoxGenerator(
616            $this->cascade,
617            $this->options->baseDir,
618            $this->options->sandboxRoot,
619        );
620        return $generator->generate($document, $sheets, $frameOwner);
621    }
622
623    private function propagateBodyWritingModeToRoot(\Phpdftk\HtmlToPdf\Box\Box $root): void
624    {
625        $body = null;
626        foreach ($root->children as $child) {
627            if ($child->element !== null && strtolower($child->element->localName) === 'body') {
628                $body = $child;
629                break;
630            }
631        }
632        if ($body === null) {
633            return;
634        }
635        // CSS Contain §3.1 — layout / paint / size / style containment on
636        // the root or the propagating body suppresses the body-to-root
637        // writing-mode / direction quirk (the contained element no longer
638        // propagates its used values to the initial containing block).
639        if ($this->hasContainment($root) || $this->hasContainment($body)) {
640            return;
641        }
642        $rootWm = $root->style->get('writing-mode');
643        $rootIsHtb = $rootWm === null
644            || ($rootWm instanceof \Phpdftk\Css\Value\Keyword
645                && strtolower($rootWm->name) === 'horizontal-tb');
646        if ($rootIsHtb) {
647            $bodyWm = $body->style->get('writing-mode');
648            if ($bodyWm instanceof \Phpdftk\Css\Value\Keyword
649                && strtolower($bodyWm->name) !== 'horizontal-tb'
650            ) {
651                $root->style->set('writing-mode', $bodyWm);
652            }
653        }
654        $rootDir = $root->style->get('direction');
655        $rootIsLtr = $rootDir === null
656            || ($rootDir instanceof \Phpdftk\Css\Value\Keyword
657                && strtolower($rootDir->name) === 'ltr');
658        if ($rootIsLtr) {
659            $bodyDir = $body->style->get('direction');
660            if ($bodyDir instanceof \Phpdftk\Css\Value\Keyword
661                && strtolower($bodyDir->name) !== 'ltr'
662            ) {
663                $root->style->set('direction', $bodyDir);
664            }
665        }
666    }
667
668    /**
669     * @return list<array{level: int, text: string, layoutY: float}>
670     */
671    private function collectHeadings(\Phpdftk\HtmlToPdf\Box\Box $root): array
672    {
673        $out = [];
674        // Pre-order DFS in document order via a stack with reverse-pushed
675        // children (so the first child is processed before its siblings).
676        // Only match on `BlockBox` so TextBox / InlineBox children (which
677        // share their parent's element ref) don't get double-counted.
678        $stack = [$root];
679        while ($stack !== []) {
680            $node = array_shift($stack);
681            $element = $node->element;
682            if ($node instanceof \Phpdftk\HtmlToPdf\Box\BlockBox
683                && $element !== null
684                && preg_match('/^h([1-6])$/', strtolower($element->localName), $m) === 1
685            ) {
686                $text = trim($this->collectTextContent($node));
687                if ($text !== '') {
688                    $out[] = [
689                        'level' => (int) $m[1],
690                        'text' => $text,
691                        'layoutY' => $node->geometry->y,
692                    ];
693                }
694            }
695            $children = $node->children;
696            foreach (array_reverse($children) as $c) {
697                array_unshift($stack, $c);
698            }
699        }
700        return $out;
701    }
702
703    /** Recursively collect TextBox content under a box. */
704    private function collectTextContent(\Phpdftk\HtmlToPdf\Box\Box $box): string
705    {
706        $out = '';
707        if ($box instanceof \Phpdftk\HtmlToPdf\Box\TextBox) {
708            return $box->text;
709        }
710        foreach ($box->children as $c) {
711            $out .= $this->collectTextContent($c);
712        }
713        return $out;
714    }
715
716    /**
717     * Register a PDF outline (bookmarks tree) from the collected headings,
718     * nesting `<hN>` under the most recent heading of lower N (so `<h2>`s
719     * appear under their preceding `<h1>`, etc.). Headings that open at a
720     * deeper level than any prior sibling create an implicit parent chain
721     * at the outline root — matching what browsers do for "reader mode"
722     * outlines.
723     *
724     * @param list<array{level: int, text: string, layoutY: float}> $headings
725     * @param list<\Phpdftk\Pdf\Writer\Page> $pages
726     */
727    private function emitOutline(array $headings, array $pages, float $pageHeight, PdfWriter $writer): void
728    {
729        if ($headings === [] || $pages === []) {
730            return;
731        }
732        $outline = new \Phpdftk\Pdf\Core\Document\Outline();
733        $writer->register($outline);
734        $outlineRef = new \Phpdftk\Pdf\Core\PdfReference($outline->objectNumber);
735
736        /**
737         * Each entry: ['item' => OutlineItem, 'level' => int, 'children' =>
738         * list<int> indices into $entries].
739         *
740         * @var list<array{item: \Phpdftk\Pdf\Core\Document\OutlineItem, level: int, parent: ?int, children: list<int>}> $entries
741         */
742        $entries = [];
743        /** @var list<int> $stack stack of $entries indices being descended into */
744        $stack = [];
745        foreach ($headings as $h) {
746            $pageIdx = max(0, min(count($pages) - 1, (int) floor($h['layoutY'] / $pageHeight)));
747            $localY = $h['layoutY'] - $pageIdx * $pageHeight;
748            $top = max(0.0, min($pageHeight, $pageHeight - $localY));
749            $pageRef = new \Phpdftk\Pdf\Core\PdfReference($pages[$pageIdx]->corePage()->objectNumber);
750            $item = new \Phpdftk\Pdf\Core\Document\OutlineItem($h['text']);
751            $item->dest = \Phpdftk\Pdf\Core\Document\Destination::xyz($pageRef, null, $top);
752            $writer->register($item);
753            // Pop stack while top.level >= this.level so we ascend to the
754            // appropriate parent.
755            while ($stack !== [] && $entries[$stack[array_key_last($stack)]]['level'] >= $h['level']) {
756                array_pop($stack);
757            }
758            $parentIdx = $stack === [] ? null : $stack[array_key_last($stack)];
759            $idx = count($entries);
760            $entries[] = [
761                'item' => $item,
762                'level' => $h['level'],
763                'parent' => $parentIdx,
764                'children' => [],
765            ];
766            if ($parentIdx !== null) {
767                $entries[$parentIdx]['children'][] = $idx;
768            }
769            $stack[] = $idx;
770        }
771
772        // Wire references now that every item has an object number.
773        $rootChildren = [];
774        foreach ($entries as $idx => $entry) {
775            $item = $entry['item'];
776            $parentIdx = $entry['parent'];
777            $item->parent = $parentIdx === null
778                ? $outlineRef
779                : new \Phpdftk\Pdf\Core\PdfReference($entries[$parentIdx]['item']->objectNumber);
780            $children = $entry['children'];
781            if ($children !== []) {
782                $first = $entries[$children[0]]['item'];
783                $last = $entries[$children[array_key_last($children)]]['item'];
784                $item->first = new \Phpdftk\Pdf\Core\PdfReference($first->objectNumber);
785                $item->last = new \Phpdftk\Pdf\Core\PdfReference($last->objectNumber);
786                $item->count = count($children); // direct children only — collapsed by default
787            }
788            if ($parentIdx === null) {
789                $rootChildren[] = $idx;
790            }
791        }
792
793        // Sibling prev/next chains per parent.
794        $linkSiblings = static function (array $sibIdxs) use ($entries): void {
795            $n = count($sibIdxs);
796            for ($i = 0; $i < $n; $i++) {
797                $item = $entries[$sibIdxs[$i]]['item'];
798                if ($i > 0) {
799                    $item->prev = new \Phpdftk\Pdf\Core\PdfReference(
800                        $entries[$sibIdxs[$i - 1]]['item']->objectNumber,
801                    );
802                }
803                if ($i < $n - 1) {
804                    $item->next = new \Phpdftk\Pdf\Core\PdfReference(
805                        $entries[$sibIdxs[$i + 1]]['item']->objectNumber,
806                    );
807                }
808            }
809        };
810        $linkSiblings($rootChildren);
811        foreach ($entries as $entry) {
812            if ($entry['children'] !== []) {
813                $linkSiblings($entry['children']);
814            }
815        }
816
817        if ($rootChildren !== []) {
818            $outline->first = new \Phpdftk\Pdf\Core\PdfReference(
819                $entries[$rootChildren[0]]['item']->objectNumber,
820            );
821            $outline->last = new \Phpdftk\Pdf\Core\PdfReference(
822                $entries[$rootChildren[array_key_last($rootChildren)]]['item']->objectNumber,
823            );
824        }
825        $outline->count = count($rootChildren);
826        $catalog = $writer->getCatalog();
827        $catalog->outlines = $outlineRef;
828        // Open the outline pane by default so users see the bookmark tree
829        // when the PDF opens. Authors can post-hoc override the page mode
830        // via `PdfWriter`'s catalog accessor.
831        if ($catalog->pageMode === null) {
832            $catalog->pageMode = new \Phpdftk\Pdf\Core\PdfName('UseOutlines');
833        }
834    }
835
836    /**
837     * Walk the laid-out box tree and record `id → layoutY` for every box
838     * whose originating element has an `id` attribute. Also captures the
839     * legacy HTML4 `<a name="...">` form. The Y is the top edge of the
840     * box's content area in layout-space (top-down) — that's what we want
841     * to scroll-to for a `#anchor` jump.
842     *
843     * @return array<string, float>
844     */
845    private function collectAnchors(\Phpdftk\HtmlToPdf\Box\Box $root): array
846    {
847        $map = [];
848        $stack = [$root];
849        while ($stack !== []) {
850            $node = array_pop($stack);
851            $element = $node->element;
852            if ($element !== null) {
853                $id = $element->getAttribute('id');
854                if ($id !== null && $id !== '' && !isset($map[$id])) {
855                    $map[$id] = $node->geometry->y;
856                }
857                if (strtolower($element->localName) === 'a') {
858                    $name = $element->getAttribute('name');
859                    if ($name !== null && $name !== '' && !isset($map[$name])) {
860                        $map[$name] = $node->geometry->y;
861                    }
862                }
863            }
864            foreach ($node->children as $c) {
865                $stack[] = $c;
866            }
867        }
868        return $map;
869    }
870
871    /**
872     * Parse the HTML into a DOM for inspection / manipulation by callers.
873     * Useful for hand-tweaking output before re-rendering.
874     */
875    public function parse(string $html): Document
876    {
877        return $this->htmlParser->parseDocument($html);
878    }
879
880    public function parseStylesheet(string $css): Stylesheet
881    {
882        return $this->cssParser->parseStylesheet($css);
883    }
884
885    /**
886     * Build the cascade-ordered list: UA, then the caller-supplied author
887     * CSS (when non-empty), then every embedded `<style>` element's content
888     * in document order. Document `<style>` rules win over the explicit
889     * `$authorCss` (later source wins per CSS Cascade 5 §6.3) so authors
890     * can use `$authorCss` to inject defaults the document can override.
891     *
892     * @return list<Stylesheet>
893     */
894    private function collectStylesheets(?string $authorCss, Document $document): array
895    {
896        $uaSheet = $this->cssParser->parseStylesheet(
897            $this->options->effectiveUserAgentStylesheet(),
898            Origin::UserAgent,
899        );
900        $sheets = [$this->expandImports($uaSheet)];
901        if ($authorCss !== null && $authorCss !== '') {
902            $sheets[] = $this->expandImports(
903                $this->cssParser->parseStylesheet($authorCss, Origin::Author),
904            );
905        }
906        foreach ($this->extractAuthorCss($document) as $css) {
907            $sheets[] = $this->expandImports(
908                $this->cssParser->parseStylesheet($css, Origin::Author),
909            );
910        }
911        return $sheets;
912    }
913
914    /**
915     * Resolve every top-of-sheet `@import url(...) [media]` at-rule by
916     * fetching the target CSS, parsing it, recursing for nested imports,
917     * and splicing the imported rules in at the `@import` position so
918     * cascade source-order is preserved. Per CSS Cascade 5 §6.3 the
919     * imported sheet's rules behave as if pasted at the import point,
920     * with later rules in the importing sheet still winning ties.
921     *
922     * Recursion depth-capped at 16 per `docs/plans/html-and-svg.md`
923     * Security defaults to prevent `a.css → b.css → a.css` loops blowing
924     * the stack. Unloadable imports drop silently — the renderer keeps
925     * going with the remaining rules.
926     */
927    private function expandImports(\Phpdftk\Css\Sheet\Stylesheet $sheet, int $depth = 0): \Phpdftk\Css\Sheet\Stylesheet
928    {
929        if ($depth >= 16) {
930            return $sheet;
931        }
932        $newRules = [];
933        $importsAllowed = true;
934        foreach ($sheet->rules as $rule) {
935            if ($rule instanceof \Phpdftk\Css\Sheet\AtRule
936                && strtolower($rule->name) === 'import'
937                && $importsAllowed
938            ) {
939                $imported = $this->loadImport($rule, $sheet->origin);
940                if ($imported !== null) {
941                    $expanded = $this->expandImports($imported, $depth + 1);
942                    foreach ($expanded->rules as $r) {
943                        $newRules[] = $r;
944                    }
945                }
946                continue;
947            }
948            // CSS Syntax 3: `@import` must precede every other rule
949            // (except `@charset` and other `@import`s). Once we hit a
950            // non-import at top level, the import window closes.
951            if ($rule instanceof \Phpdftk\Css\Sheet\StyleRule) {
952                $importsAllowed = false;
953            }
954            $newRules[] = $rule;
955        }
956        return new \Phpdftk\Css\Sheet\Stylesheet($newRules, $sheet->origin);
957    }
958
959    /**
960     * Load a single `@import` at-rule's target CSS and return the
961     * parsed Stylesheet, or null when the URL fails to resolve, the
962     * fetch fails, or a media-query filter rejects the sheet.
963     *
964     * The prelude is hand-extracted with a small grammar:
965     *   - `url("…")` / `url('…')` / `url(…)` (quoted or bare)
966     *   - bare `"…"` / `'…'`
967     *   - optional trailing media-query list
968     */
969    private function loadImport(
970        \Phpdftk\Css\Sheet\AtRule $rule,
971        \Phpdftk\Css\Sheet\Origin $origin,
972    ): ?\Phpdftk\Css\Sheet\Stylesheet {
973        $prelude = trim($rule->prelude);
974        $href = null;
975        $remainder = '';
976        if (preg_match('~^url\(\s*("([^"]*)"|\'([^\']*)\'|([^)\s]*))\s*\)\s*(.*)$~i', $prelude, $m) === 1) {
977            $href = $m[2] !== '' ? $m[2] : ($m[3] !== '' ? $m[3] : $m[4]);
978            $remainder = $m[5];
979        } elseif (preg_match('~^"([^"]*)"\s*(.*)$~', $prelude, $m) === 1) {
980            $href = $m[1];
981            $remainder = $m[2];
982        } elseif (preg_match("~^'([^']*)'\\s*(.*)\$~", $prelude, $m) === 1) {
983            $href = $m[1];
984            $remainder = $m[2];
985        }
986        if ($href === null || $href === '') {
987            return null;
988        }
989        // CSS Cascade 5 §3.1 — an `@import` may carry a `supports(<condition>)`
990        // and/or a media-query list; both must match. Route them through the
991        // cascade's real feature-query / supports evaluators (min/max-width
992        // aware) rather than the coarse print/all token matcher.
993        $remainder = trim($remainder);
994        if ($remainder !== '') {
995            $condition = $remainder;
996            if (preg_match('~^supports\(\s*(.+?)\s*\)\s*(.*)$~is', $condition, $sm) === 1) {
997                // A bare `<declaration>` inside `supports(...)` (e.g.
998                // `supports(display:block)`) is a supports-condition once
999                // wrapped in parens, which is what supportsPreludeMatches wants.
1000                $inner = $sm[1];
1001                if ($inner[0] !== '(') {
1002                    $inner = '(' . $inner . ')';
1003                }
1004                if (!$this->cascade->supportsPreludeMatches($inner)) {
1005                    return null;
1006                }
1007                $condition = trim($sm[2]);
1008            }
1009            if ($condition !== '' && !$this->cascade->mediaPreludeMatches($condition)) {
1010                return null;
1011            }
1012        }
1013        // Resolve the URL (data:text/css… or relative under baseDir).
1014        $css = $this->fetchImportSource($href);
1015        if ($css === null) {
1016            return null;
1017        }
1018
1019        return $this->cssParser->parseStylesheet(
1020            $this->rebaseStylesheetUrls($css, $href),
1021            $origin,
1022        );
1023    }
1024
1025    /**
1026     * Resolve an `@import` href to its raw CSS bytes via the unified
1027     * `Phpdftk\Filesystem\ResourceLoader`. `data:` URLs must declare a
1028     * `text/css` MIME for `@import`; the loader's MIME allowlist
1029     * enforces that. Filesystem paths use the same realpath-escape +
1030     * stream-wrapper gates as every other resource.
1031     */
1032    private function fetchImportSource(string $href): ?string
1033    {
1034        // http(s) routes through phpdftk/resource-loader when one
1035        // is attached; otherwise drops with a missing-resource
1036        // warning consistent with @font-face http behaviour.
1037        if (str_starts_with($href, 'http://') || str_starts_with($href, 'https://')) {
1038            $sink = [];
1039            return $this->fetchHttpResource($href, $sink, '@import');
1040        }
1041        return $this->resourceLoader()->load($href, allowedMimes: ['text/css']);
1042    }
1043
1044    /**
1045     * Cached `ResourceLoader` bound to the renderer's `baseDir`.
1046     * Created lazily so renderers without `baseDir` still construct
1047     * cleanly — the loader handles a null base by rejecting filesystem
1048     * paths while still decoding data: URLs.
1049     */
1050    private function resourceLoader(): \Phpdftk\Filesystem\ResourceLoader
1051    {
1052        return $this->cachedResourceLoader
1053            ??= new \Phpdftk\Filesystem\ResourceLoader(
1054                $this->options->baseDir,
1055                $this->options->sandboxRoot,
1056            );
1057    }
1058
1059    /**
1060     * Emit `/Link` annotations for the painter's collected link rects on
1061     * this page. Each rect is clipped to the page's MediaBox (`[0, 0,
1062     * pageWidth, pageHeight]`); rects entirely outside the page are
1063     * dropped — this handles the multi-page paint pass cleanly because
1064     * the painter walks the full box tree per page with a shifted Y
1065     * constant.
1066     *
1067     * @param list<array{href: string, llx: float, lly: float, urx: float, ury: float}> $links
1068     */
1069    /**
1070     * @param list<array{href: string, llx: float, lly: float, urx: float, ury: float, title: ?string}> $links
1071     * @param array<string, float> $anchorMap
1072     * @param list<\Phpdftk\Pdf\Writer\Page> $allPages
1073     */
1074    private function emitLinkAnnotations(
1075        array $links,
1076        PdfWriter $writer,
1077        \Phpdftk\Pdf\Writer\Page $page,
1078        float $pageHeight,
1079        array $anchorMap,
1080        array $allPages,
1081    ): void {
1082        if ($links === []) {
1083            return;
1084        }
1085        $corePage = $page->corePage();
1086        foreach ($links as $link) {
1087            // Drop rects entirely above or below the page box.
1088            if ($link['ury'] <= 0.0 || $link['lly'] >= $pageHeight) {
1089                continue;
1090            }
1091            $llx = $link['llx'];
1092            $urx = $link['urx'];
1093            $lly = max(0.0, $link['lly']);
1094            $ury = min($pageHeight, $link['ury']);
1095            if ($ury - $lly <= 0.0 || $urx - $llx <= 0.0) {
1096                continue;
1097            }
1098            $rect = new \Phpdftk\Pdf\Core\PdfArray([
1099                new \Phpdftk\Pdf\Core\PdfNumber($llx),
1100                new \Phpdftk\Pdf\Core\PdfNumber($lly),
1101                new \Phpdftk\Pdf\Core\PdfNumber($urx),
1102                new \Phpdftk\Pdf\Core\PdfNumber($ury),
1103            ]);
1104            $annotation = new \Phpdftk\Pdf\Core\Annotation\LinkAnnotation($rect);
1105            // Suppress the default 1-unit black border that PDF readers
1106            // overlay on `/Link` annotations — browser print output never
1107            // shows a frame around links and our text already carries
1108            // the styling (`color`, `text-decoration: underline`).
1109            $annotation->border = new \Phpdftk\Pdf\Core\PdfArray([
1110                new \Phpdftk\Pdf\Core\PdfNumber(0),
1111                new \Phpdftk\Pdf\Core\PdfNumber(0),
1112                new \Phpdftk\Pdf\Core\PdfNumber(0),
1113            ]);
1114            if (($link['title'] ?? null) !== null && $link['title'] !== '') {
1115                $annotation->contents = new \Phpdftk\Pdf\Core\PdfString($link['title']);
1116            }
1117
1118            $dest = $this->resolveFragmentDestination($link['href'], $anchorMap, $allPages, $pageHeight);
1119            if ($dest !== null) {
1120                $annotation->dest = $dest;
1121            } else {
1122                $actionDict = new \Phpdftk\Pdf\Core\PdfDictionary();
1123                $actionDict->set('Type', new \Phpdftk\Pdf\Core\PdfName('Action'));
1124                $actionDict->set('S', new \Phpdftk\Pdf\Core\PdfName('URI'));
1125                $actionDict->set('URI', new \Phpdftk\Pdf\Core\PdfString($link['href']));
1126                $annotation->a = $actionDict;
1127            }
1128
1129            $writer->register($annotation);
1130            $corePage->annots[] = new \Phpdftk\Pdf\Core\PdfReference($annotation->objectNumber);
1131        }
1132    }
1133
1134    /**
1135     * If `$href` is a fragment of the form `#anchor` and the anchor is in
1136     * the document, return the matching {@see Destination::xyz} pointing
1137     * at the right page + Y. Otherwise return null and the caller will
1138     * fall back to a URI action.
1139     *
1140     * @param array<string, float> $anchorMap
1141     * @param list<\Phpdftk\Pdf\Writer\Page> $allPages
1142     */
1143    private function resolveFragmentDestination(
1144        string $href,
1145        array $anchorMap,
1146        array $allPages,
1147        float $pageHeight,
1148    ): ?\Phpdftk\Pdf\Core\Document\Destination {
1149        if (!str_starts_with($href, '#')) {
1150            return null;
1151        }
1152        $anchor = substr($href, 1);
1153        if (!isset($anchorMap[$anchor])) {
1154            return null;
1155        }
1156        $layoutY = $anchorMap[$anchor];
1157        $pageIdx = (int) floor($layoutY / $pageHeight);
1158        if ($pageIdx < 0 || $pageIdx >= count($allPages)) {
1159            return null;
1160        }
1161        $localY = $layoutY - $pageIdx * $pageHeight;
1162        $top = $pageHeight - $localY;
1163        $pageRef = new \Phpdftk\Pdf\Core\PdfReference($allPages[$pageIdx]->corePage()->objectNumber);
1164        return \Phpdftk\Pdf\Core\Document\Destination::xyz($pageRef, null, $top);
1165    }
1166
1167    /**
1168     * Map the document's `<title>` element + key `<meta>` tags onto the
1169     * PDF's `/Info` dictionary. Supported author conventions:
1170     *  - `<title>` → /Title
1171     *  - `<meta name="author">` → /Author
1172     *  - `<meta name="description">` → /Subject
1173     *  - `<meta name="keywords">` → /Keywords
1174     *
1175     * Skips entries that are missing or empty so the renderer doesn't
1176     * stomp on an `/Info` already populated by the caller via
1177     * `PdfWriter::setInfo`.
1178     */
1179    /**
1180     * HTML 5 §4.2.3 — `<base href="...">` in the document head
1181     * sets the document base URL. We walk the tree once: if a
1182     * `<base href>` is present, prepend its (relative-form) value
1183     * to any `src` attribute that doesn't already carry a scheme
1184     * or an absolute path. The first `<base href>` wins per spec.
1185     *
1186     * Out-of-scope: absolute `<base href>` URLs (e.g.
1187     * `https://example.com/`) — our pipeline is local-file based
1188     * and the painter rejects HTTP src paths anyway, so a full
1189     * URL base would simply skip resolution. Relative bases
1190     * (`resources/`) are by far the common WPT pattern.
1191     */
1192    private function applyBaseHref(Document $document): void
1193    {
1194        $base = $this->findFirstBaseHref($document);
1195        if ($base === null || $base === '') {
1196            return;
1197        }
1198        // Only relative bases without a scheme or leading slash —
1199        // those are the ones we can fold into the existing
1200        // baseDir-relative resolver.
1201        if (preg_match('~^([a-z][a-z0-9+.-]*:|/)~i', $base) === 1) {
1202            return;
1203        }
1204        $this->rewriteRelativeSrcAttributes($document, $base);
1205    }
1206
1207    private function findFirstBaseHref(Document $document): ?string
1208    {
1209        $head = $document->head;
1210        if ($head === null) {
1211            return null;
1212        }
1213        for ($n = $head->firstChild; $n !== null; $n = $n->nextSibling) {
1214            if (!($n instanceof \Phpdftk\Html\Dom\Element)) {
1215                continue;
1216            }
1217            if (strtolower($n->localName) === 'base') {
1218                $href = $n->getAttribute('href');
1219                if ($href !== null && $href !== '') {
1220                    return $href;
1221                }
1222            }
1223        }
1224        return null;
1225    }
1226
1227    private function rewriteRelativeSrcAttributes(Document $document, string $base): void
1228    {
1229        $body = $document->body;
1230        if ($body !== null) {
1231            $this->rewriteRelativeSrcInSubtree($body, $base);
1232        }
1233        // `<img>` etc. in `<head>` is uncommon but spec-legal; also
1234        // walk the head for completeness so a `<link rel="icon">`
1235        // (when we later honour it) sees the same resolved URL.
1236        $head = $document->head;
1237        if ($head !== null) {
1238            $this->rewriteRelativeSrcInSubtree($head, $base);
1239        }
1240    }
1241
1242    private function rewriteRelativeSrcInSubtree(\Phpdftk\Html\Dom\Element $root, string $base): void
1243    {
1244        $stack = [$root];
1245        while ($stack !== []) {
1246            $node = array_pop($stack);
1247            for ($n = $node->firstChild; $n !== null; $n = $n->nextSibling) {
1248                if (!($n instanceof \Phpdftk\Html\Dom\Element)) {
1249                    continue;
1250                }
1251                $tag = strtolower($n->localName);
1252                $attrName = match ($tag) {
1253                    'img', 'embed', 'iframe', 'video', 'audio', 'source', 'track', 'script' => 'src',
1254                    default => null,
1255                };
1256                if ($attrName !== null) {
1257                    $value = $n->getAttribute($attrName);
1258                    if ($value !== null && $value !== ''
1259                        && preg_match('~^([a-z][a-z0-9+.-]*:|/)~i', $value) !== 1
1260                    ) {
1261                        $n->setAttribute($attrName, $base . $value);
1262                    }
1263                }
1264                $stack[] = $n;
1265            }
1266        }
1267    }
1268
1269    private function applyDocumentMetadata(Document $document, PdfWriter $writer): void
1270    {
1271        $title = $this->findTextOfFirstElement($document, 'title');
1272        $author = $this->findMetaContent($document, 'author');
1273        $description = $this->findMetaContent($document, 'description');
1274        $keywords = $this->findMetaContent($document, 'keywords');
1275        $info = new \Phpdftk\Pdf\Core\Document\Info();
1276        if ($title !== null) {
1277            $info->title = new \Phpdftk\Pdf\Core\PdfString($title);
1278        }
1279        if ($author !== null) {
1280            $info->author = new \Phpdftk\Pdf\Core\PdfString($author);
1281        }
1282        if ($description !== null) {
1283            $info->subject = new \Phpdftk\Pdf\Core\PdfString($description);
1284        }
1285        if ($keywords !== null) {
1286            $info->keywords = new \Phpdftk\Pdf\Core\PdfString($keywords);
1287        }
1288        // Always identify the renderer in the standard /Creator +
1289        // /Producer entries so downstream tooling (verapdf, qpdf, etc.)
1290        // can trace the pipeline a PDF came from — including docs that
1291        // don't carry <title> / <meta> tags themselves.
1292        $info->creator = new \Phpdftk\Pdf\Core\PdfString('phpdftk/html-to-pdf');
1293        $info->producer = new \Phpdftk\Pdf\Core\PdfString('phpdftk');
1294        // ISO 32000-2 §7.9.4 PDF date format:
1295        // `(D:YYYYMMDDHHmmSSOHH'mm')` with O ∈ {Z, +, -}.
1296        $info->creationDate = new \Phpdftk\Pdf\Core\PdfString($this->formatPdfDate(new \DateTimeImmutable()));
1297        $writer->setInfo($info);
1298    }
1299
1300    private function formatPdfDate(\DateTimeImmutable $dt): string
1301    {
1302        $offset = $dt->getOffset();
1303        if ($offset === 0) {
1304            $tz = 'Z';
1305        } else {
1306            $sign = $offset >= 0 ? '+' : '-';
1307            $absOffset = abs($offset);
1308            $hours = intdiv($absOffset, 3600);
1309            $minutes = intdiv($absOffset % 3600, 60);
1310            $tz = sprintf("%s%02d'%02d'", $sign, $hours, $minutes);
1311        }
1312        return 'D:' . $dt->format('YmdHis') . $tz;
1313    }
1314
1315    private function findTextOfFirstElement(Document $document, string $localName): ?string
1316    {
1317        $stack = [$document->documentElement];
1318        while ($stack !== []) {
1319            $node = array_shift($stack);
1320            if ($node === null) {
1321                continue;
1322            }
1323            if (strtolower($node->localName) === $localName) {
1324                $text = '';
1325                for ($t = $node->firstChild; $t !== null; $t = $t->nextSibling) {
1326                    if ($t instanceof \Phpdftk\Html\Dom\Text) {
1327                        $text .= $t->data;
1328                    }
1329                }
1330                $text = trim($text);
1331                return $text === '' ? null : $text;
1332            }
1333            for ($child = $node->firstChild; $child !== null; $child = $child->nextSibling) {
1334                if ($child instanceof \Phpdftk\Html\Dom\Element) {
1335                    $stack[] = $child;
1336                }
1337            }
1338        }
1339        return null;
1340    }
1341
1342    private function findMetaContent(Document $document, string $nameAttr): ?string
1343    {
1344        $stack = [$document->documentElement];
1345        while ($stack !== []) {
1346            $node = array_shift($stack);
1347            if ($node === null) {
1348                continue;
1349            }
1350            if (strtolower($node->localName) === 'meta') {
1351                $name = $node->getAttribute('name');
1352                if ($name !== null && strtolower($name) === $nameAttr) {
1353                    $content = $node->getAttribute('content');
1354                    if ($content !== null && trim($content) !== '') {
1355                        return trim($content);
1356                    }
1357                }
1358            }
1359            for ($child = $node->firstChild; $child !== null; $child = $child->nextSibling) {
1360                if ($child instanceof \Phpdftk\Html\Dom\Element) {
1361                    $stack[] = $child;
1362                }
1363            }
1364        }
1365        return null;
1366    }
1367
1368    /**
1369     * Walk the document for both `<style>…</style>` element contents
1370     * AND `<link rel="stylesheet" href="…">` external sheets, yielding
1371     * each loaded CSS chunk in document order so the cascade's
1372     * later-source-wins rule operates on the right ordering. External
1373     * `<link>` sheets resolve via:
1374     *   - `data:text/css[;base64],…` payloads (decoded)
1375     *   - relative-or-absolute paths under `RendererOptions::baseDir`
1376     *     (with `realpath` escape rejection + stream-wrapper rejection
1377     *     mirroring `@font-face` / `<img src>` resolution)
1378     *
1379     * Unloadable `<link>` hrefs silently skip (no Warning yet — the
1380     * resource loader gate in 1L proper will surface them).
1381     *
1382     * @return list<string>
1383     */
1384    private function extractAuthorCss(Document $document): array
1385    {
1386        $out = [];
1387        $stack = [$document->documentElement];
1388        while ($stack !== []) {
1389            $node = array_shift($stack);
1390            if ($node === null) {
1391                continue;
1392            }
1393            // Depth-first in document order: push children in reverse so the
1394            // first child is processed next.
1395            $children = [];
1396            for ($child = $node->firstChild; $child !== null; $child = $child->nextSibling) {
1397                if ($child instanceof \Phpdftk\Html\Dom\Element) {
1398                    $children[] = $child;
1399                }
1400            }
1401            foreach (array_reverse($children) as $c) {
1402                array_unshift($stack, $c);
1403            }
1404            $local = strtolower($node->localName);
1405            if ($local === 'style') {
1406                $text = '';
1407                for ($t = $node->firstChild; $t !== null; $t = $t->nextSibling) {
1408                    if ($t instanceof \Phpdftk\Html\Dom\Text) {
1409                        $text .= $t->data;
1410                    }
1411                }
1412                if (trim($text) !== '') {
1413                    $out[] = $text;
1414                }
1415            } elseif ($local === 'link') {
1416                $css = $this->loadLinkedStylesheet($node);
1417                if ($css !== null) {
1418                    $out[] = $css;
1419                }
1420            }
1421        }
1422        return $out;
1423    }
1424
1425    /**
1426     * Collect every codepoint in the HTML so the font registration can
1427     * subset to just the used glyphs. Done by stripping tags and walking
1428     * UTF-8 codepoints — fast enough for Phase 1; a proper text-node
1429     * walk over the DOM lands in 1N-bis.
1430     *
1431     * @return list<int>
1432     */
1433    private function collectCodepoints(string $html): array
1434    {
1435        // `strip_tags()` leaves CHARACTER REFERENCES untouched: the
1436        // source `&gt;` contributes `&`, `g`, `t`, `;` to the scan and
1437        // never `>`. Every entity-written character was therefore absent
1438        // from the subset — and an absent codepoint does not fall back to
1439        // notdef, it paints whatever glyph happens to occupy that subset
1440        // slot. `&gt;` came out as a letter, and the letter DIFFERED
1441        // between two documents whose subsets were packed differently,
1442        // which is what made the bidi reftests (written almost entirely
1443        // in `&#x05D0;`-style references) compare unequal. Decode first.
1444        $stripped = html_entity_decode(
1445            strip_tags($html),
1446            ENT_QUOTES | ENT_HTML5,
1447            'UTF-8',
1448        );
1449        $seen = [];
1450        // Always include the characters that may be needed for counter-style
1451        // list markers (decimal / alpha / roman) and basic punctuation —
1452        // even if the document body doesn't use them — so `<ol>` markers
1453        // can shape against the registered font subset.
1454        foreach (range(ord('0'), ord('9')) as $cp) {
1455            $seen[$cp] = true;
1456        }
1457        foreach (range(ord('a'), ord('z')) as $cp) {
1458            $seen[$cp] = true;
1459        }
1460        foreach (range(ord('A'), ord('Z')) as $cp) {
1461            $seen[$cp] = true;
1462        }
1463        foreach ([ord('.'), ord(','), ord(':'), ord(';'), ord(' ')] as $cp) {
1464            $seen[$cp] = true;
1465        }
1466        // Characters the box tree SYNTHESIZES rather than copying from the
1467        // source: form-control markers `[ ]` / `[x]` / `( )` / `(o)` (see
1468        // BoxGenerator's checkbox / radio lowering). This subset is built
1469        // from `strip_tags($html)`, so a synthesized codepoint is absent
1470        // from it — and an absent codepoint does not fall back to notdef,
1471        // it paints whatever glyph happens to occupy that subset slot.
1472        foreach ([ord('['), ord(']'), ord('('), ord(')')] as $cp) {
1473            $seen[$cp] = true;
1474        }
1475        // U+2022 BULLET — the `<input type=password>` mask character, and
1476        // the default `list-style-type: disc` marker. The three that
1477        // follow are the rest of the CSS Counter Styles 3 §7.2 fixed
1478        // marker glyphs an `inside` marker shapes as text: WHITE BULLET
1479        // (`circle`), BLACK SMALL SQUARE (`square`), and the two
1480        // disclosure triangles the UA sheet gives `<summary>`.
1481        $seen[0x2022] = true;
1482        $seen[0x25E6] = true;
1483        $seen[0x25AA] = true;
1484        $seen[0x25B6] = true;
1485        $seen[0x25BC] = true;
1486        // U+2026 HORIZONTAL ELLIPSIS — emitted by `text-overflow: ellipsis`
1487        // and useful punctuation in body text.
1488        $seen[0x2026] = true;
1489        // U+200B ZERO-WIDTH SPACE — emitted by the `<wbr>` lowering as
1490        // a soft-break opportunity that fonts may or may not support;
1491        // request the glyph so the subset captures it when present.
1492        $seen[0x200B] = true;
1493        // WHATWG HTML §13.5 — this scan runs over the RAW source, where a
1494        // character reference is still the literal text `&nbsp;` /
1495        // `&#160;`. Its codepoint therefore never reached the subset, and
1496        // the viewer drew the font's `.notdef` box for what the author
1497        // wrote as a blank. Resolve every reference in the source and fold
1498        // the codepoints it produces in. Purely additive — a false
1499        // positive (`&` followed by a word in prose) only widens the
1500        // subset by one glyph.
1501        foreach (self::characterReferenceCodepoints($stripped) as $cp) {
1502            $seen[$cp] = true;
1503        }
1504        $i = 0;
1505        $bytes = strlen($stripped);
1506        while ($i < $bytes) {
1507            $b = ord($stripped[$i]);
1508            if ($b < 0x80) {
1509                $seen[$b] = true;
1510                $i++;
1511            } elseif ($b < 0xC0) {
1512                $i++;
1513            } elseif ($b < 0xE0) {
1514                $cp = (($b & 0x1F) << 6) | (ord($stripped[$i + 1] ?? "\x00") & 0x3F);
1515                $seen[$cp] = true;
1516                $i += 2;
1517            } elseif ($b < 0xF0) {
1518                $cp = (($b & 0x0F) << 12)
1519                    | ((ord($stripped[$i + 1] ?? "\x00") & 0x3F) << 6)
1520                    | (ord($stripped[$i + 2] ?? "\x00") & 0x3F);
1521                $seen[$cp] = true;
1522                $i += 3;
1523            } else {
1524                $cp = (($b & 0x07) << 18)
1525                    | ((ord($stripped[$i + 1] ?? "\x00") & 0x3F) << 12)
1526                    | ((ord($stripped[$i + 2] ?? "\x00") & 0x3F) << 6)
1527                    | (ord($stripped[$i + 3] ?? "\x00") & 0x3F);
1528                $seen[$cp] = true;
1529                $i += 4;
1530            }
1531        }
1532        return array_keys($seen);
1533    }
1534
1535    /**
1536     * Count `<img>` elements (post-parse, ignoring scripted dynamism since
1537     * we don't run JS). Drives the MissingResource warning emitted when
1538     * image painting is unsupported in the current phase.
1539     */
1540    private function countUnpaintableImages(Document $document): int
1541    {
1542        $count = 0;
1543        $stack = [$document->documentElement];
1544        while ($stack !== []) {
1545            $node = array_pop($stack);
1546            if ($node === null) {
1547                continue;
1548            }
1549            if (strtolower($node->localName) === 'img') {
1550                $src = $node->getAttribute('src');
1551                $alt = $node->getAttribute('alt');
1552                if (!$this->isPaintableImageSrc($src) && ($alt === null || $alt === '')) {
1553                    $count++;
1554                }
1555            }
1556            for ($c = $node->firstChild; $c !== null; $c = $c->nextSibling) {
1557                if ($c instanceof \Phpdftk\Html\Dom\Element) {
1558                    $stack[] = $c;
1559                }
1560            }
1561        }
1562        return $count;
1563    }
1564
1565    /**
1566     * Every codepoint produced by the character references in `$text`.
1567     *
1568     * Named references resolve through the tokenizer's generated WHATWG
1569     * table (including the historical no-semicolon forms); numeric
1570     * references go through the spec's replacement table so `&#128;`
1571     * yields U+20AC rather than a C1 control. Unknown names are ignored.
1572     *
1573     * @return list<int>
1574     */
1575    private static function characterReferenceCodepoints(string $text): array
1576    {
1577        if (preg_match_all(
1578            '/&(#[0-9]+|#[xX][0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);?/',
1579            $text,
1580            $matches,
1581        ) === 0) {
1582            return [];
1583        }
1584        $out = [];
1585        foreach ($matches[0] as $index => $whole) {
1586            $name = $matches[1][$index];
1587            if ($name[0] === '#') {
1588                $number = ($name[1] === 'x' || $name[1] === 'X')
1589                    ? (int) hexdec(substr($name, 2))
1590                    : (int) substr($name, 1);
1591                $number = NamedCharacterReferences::NUMERIC_REPLACEMENTS[$number] ?? $number;
1592                if ($number > 0 && $number <= 0x10FFFF) {
1593                    $out[] = $number;
1594                }
1595                continue;
1596            }
1597            // The generated table keys both the `name;` form and the
1598            // historical semicolon-less form, so one lookup covers both.
1599            $resolved = NamedCharacterReferences::TABLE[$name . ';']
1600                ?? NamedCharacterReferences::TABLE[$name]
1601                ?? null;
1602            // §13.5's longest-match rule lets a reference end without a
1603            // semicolon, so `&notit;` is `&not` followed by the text
1604            // `it;`. Walk the prefixes down when the whole run names
1605            // nothing — only the semicolon-less names key a bare prefix,
1606            // so this cannot match a `name;`-only entry by accident.
1607            for ($len = strlen($name) - 1; $resolved === null && $len > 0; $len--) {
1608                $resolved = NamedCharacterReferences::TABLE[substr($name, 0, $len)] ?? null;
1609            }
1610            if ($resolved === null) {
1611                continue;
1612            }
1613            foreach (mb_str_split($resolved, 1, 'UTF-8') as $char) {
1614                $cp = mb_ord($char, 'UTF-8');
1615                if ($cp !== false) {
1616                    $out[] = $cp;
1617                }
1618            }
1619        }
1620        return $out;
1621    }
1622
1623    /**
1624     * Mirror the painter's "can this `<img src>` be drawn?" decision so the
1625     * MissingResource warning doesn't false-positive on local-file paths
1626     * the painter actually handles. Accepts `data:image/{png,jpeg}` URLs
1627     * unconditionally; `http(s)://` when the options carry an HTTP
1628     * ResourceLoader (4F.5); for filesystem paths, requires `baseDir`
1629     * and that `realpath()` resolves under it.
1630     */
1631    private function isPaintableImageSrc(?string $src): bool
1632    {
1633        if ($src === null || $src === '') {
1634            return false;
1635        }
1636        if (preg_match('~^data:image/(png|jpeg|jpg);~', $src) === 1) {
1637            return true;
1638        }
1639        if (str_starts_with($src, 'http://') || str_starts_with($src, 'https://')) {
1640            return $this->options->resourceLoader !== null;
1641        }
1642        return $this->resourceLoader()->resolveLocalPath($src) !== null;
1643    }
1644
1645    /**
1646     * Walk the parsed DOM looking for any non-whitespace text content.
1647     * Used to decide whether a missing default-font is worth warning about.
1648     */
1649    private function documentHasText(Document $document): bool
1650    {
1651        $stack = [$document->documentElement];
1652        while ($stack !== []) {
1653            $node = array_pop($stack);
1654            if ($node === null) {
1655                continue;
1656            }
1657            for ($child = $node->firstChild; $child !== null; $child = $child->nextSibling) {
1658                if ($child instanceof \Phpdftk\Html\Dom\Text) {
1659                    if (trim($child->data) !== '') {
1660                        return true;
1661                    }
1662                    continue;
1663                }
1664                if ($child instanceof \Phpdftk\Html\Dom\Element) {
1665                    // Skip head, script, style — they don't render.
1666                    $local = strtolower($child->localName);
1667                    if (in_array($local, ['head', 'script', 'style', 'title', 'meta', 'link', 'base'], true)) {
1668                        continue;
1669                    }
1670                    $stack[] = $child;
1671                }
1672            }
1673        }
1674        return false;
1675    }
1676
1677    /**
1678     * Walk every supplied stylesheet for `@font-face` rules, decode each
1679     * rule's `src: url(...)` into raw font bytes (via `data:` URLs or
1680     * `file://` paths resolved against `RendererOptions::baseDir`), parse
1681     * the bytes with `OpenTypeParser`, and yield `family-name => OpenTypeData`.
1682     *
1683     * Phase-1 scope: OTF/CFF only (matches what `OpenTypeParser` accepts),
1684     * `data:` and resolved-local sources only — remote `http(s)://` fetch
1685     * lands in Phase 2 behind the same `ResourceLoader` gate. Per-face
1686     * failures emit a Warning and the face is dropped; the renderer keeps
1687     * going with the rest of the document. Multi-value `src` lists are
1688     * walked left-to-right; the first source that parses wins. The CSS
1689     * `format(...)` hint is accepted but never trusted — magic-number
1690     * detection on the decoded bytes is the actual gate.
1691     *
1692     * Each yielded value is a {@see \Phpdftk\HtmlToPdf\Layout\FontFace}
1693     * carrying the parsed `font-weight` / `font-style` / `font-stretch`
1694     * descriptors so multi-face families match per CSS Fonts 4 §6.
1695     *
1696     * @param list<Stylesheet> $sheets
1697     * @param list<Warning> $warnings
1698     * @return iterable<string, \Phpdftk\HtmlToPdf\Layout\FontFace>
1699     */
1700    private function loadFontFaces(array $sheets, array &$warnings): iterable
1701    {
1702        foreach ($sheets as $sheet) {
1703            foreach ($this->flattenRules($sheet->rules) as $rule) {
1704                if (!$rule instanceof \Phpdftk\Css\Sheet\AtRule) {
1705                    continue;
1706                }
1707                if (strtolower($rule->name) !== 'font-face') {
1708                    continue;
1709                }
1710                if ($rule->block === null) {
1711                    continue;
1712                }
1713                $family = null;
1714                /** @var list<\Phpdftk\Css\Value\Value> $srcCandidates */
1715                $srcCandidates = [];
1716                // CSS Fonts 4 §3 face descriptors. Captured so multi-face
1717                // families (same family-name, different weight/style/
1718                // stretch) build a proper faceMap the resolver can match
1719                // over per §6 — without these, all faces collapse onto one
1720                // fontMap slot (last-wins) and `font-stretch` / bold-vs-
1721                // regular selection silently picks the wrong file.
1722                $faceWeight = 400;
1723                $faceStyle = 'normal';
1724                $faceStretch = 100.0;
1725                foreach ($rule->block->contents as $item) {
1726                    if (!$item instanceof \Phpdftk\Css\Sheet\Declaration) {
1727                        continue;
1728                    }
1729                    if ($item->property === 'font-family') {
1730                        $family = $this->fontFamilyName($item->value);
1731                    } elseif ($item->property === 'src') {
1732                        $srcCandidates = $this->splitSrcList($item->value);
1733                    } elseif ($item->property === 'font-weight') {
1734                        $faceWeight = $this->fontFaceWeight($item->value);
1735                    } elseif ($item->property === 'font-style') {
1736                        $faceStyle = $this->fontFaceStyle($item->value);
1737                    } elseif ($item->property === 'font-stretch') {
1738                        $faceStretch = $this->fontFaceStretch($item->value);
1739                    }
1740                }
1741                if ($family === null || $family === '' || $srcCandidates === []) {
1742                    $warnings[] = new Warning(
1743                        WarningCode::UnsupportedCssValue,
1744                        '@font-face rule missing `font-family` or `src` — face dropped.',
1745                        WarningSeverity::Warning,
1746                    );
1747                    continue;
1748                }
1749                $data = null;
1750                foreach ($srcCandidates as $candidate) {
1751                    // Honour the CSS Fonts 4 §4.3 `format()` hint when one
1752                    // is supplied. An unsupported hint skips the source
1753                    // without touching the fetch path — useful for authors
1754                    // shipping `url(font.woff2) format("woff2"),
1755                    // url(font.otf) format("opentype")` fallback chains.
1756                    if ($candidate['format'] !== null
1757                        && !in_array($candidate['format'], self::SUPPORTED_FONT_FORMATS, true)
1758                    ) {
1759                        continue;
1760                    }
1761                    $bytes = $this->fetchFontSource($candidate['url'], $warnings);
1762                    if ($bytes === null) {
1763                        continue;
1764                    }
1765                    // WOFF 1.0 wraps OTF/TTF in a zlib-compressed
1766                    // container; transparently unwrap so the downstream
1767                    // OpenTypeParser sees the original SFNT.
1768                    // WOFF 2.0 wraps the SFNT in a Brotli-compressed,
1769                    // table-transformed container. Decoding needs
1770                    // ext-brotli; without it `decompressBytes` throws and
1771                    // we skip this source so the next `src` candidate (a
1772                    // plain .ttf/.otf, typically) still gets its chance.
1773                    if (\Phpdftk\FontParser\Woff2Parser::isWoff2($bytes)) {
1774                        try {
1775                            $bytes = \Phpdftk\FontParser\Woff2Parser::decompressBytes($bytes);
1776                        } catch (\Throwable $e) {
1777                            $warnings[] = new Warning(
1778                                WarningCode::UnsupportedCssValue,
1779                                sprintf(
1780                                    '@font-face `%s` WOFF2 source failed to decode: %s',
1781                                    $family,
1782                                    $e->getMessage(),
1783                                ),
1784                                WarningSeverity::Warning,
1785                            );
1786                            continue;
1787                        }
1788                    } elseif (\Phpdftk\FontParser\WoffParser::isWoff($bytes)) {
1789                        try {
1790                            $bytes = \Phpdftk\FontParser\WoffParser::decompressBytes($bytes);
1791                        } catch (\Throwable $e) {
1792                            $warnings[] = new Warning(
1793                                WarningCode::UnsupportedCssValue,
1794                                sprintf(
1795                                    '@font-face `%s` WOFF source failed to decompress: %s',
1796                                    $family,
1797                                    $e->getMessage(),
1798                                ),
1799                                WarningSeverity::Warning,
1800                            );
1801                            continue;
1802                        }
1803                    }
1804                    $data = $this->parseFontFace($bytes, $family, $warnings);
1805                    if ($data !== null) {
1806                        break;
1807                    }
1808                }
1809                if ($data === null) {
1810                    $warnings[] = new Warning(
1811                        WarningCode::MissingResource,
1812                        sprintf(
1813                            '@font-face `%s` has no loadable source — face dropped.',
1814                            $family,
1815                        ),
1816                        WarningSeverity::Warning,
1817                    );
1818                    continue;
1819                }
1820                yield $family => new \Phpdftk\HtmlToPdf\Layout\FontFace(
1821                    $data,
1822                    $faceWeight,
1823                    $faceStyle,
1824                    $faceStretch,
1825                );
1826            }
1827        }
1828    }
1829
1830    /**
1831     * Parse a `@font-face` `font-weight` descriptor to the CSS 1–1000
1832     * numeric axis (CSS Fonts 4 §3.2). A two-value range keeps the lower
1833     * bound — the resolver matches against a single representative weight.
1834     * Unrecognised input defaults to 400 (normal).
1835     */
1836    private function fontFaceWeight(\Phpdftk\Css\Value\Value $value): int
1837    {
1838        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
1839            return match (strtolower($value->name)) {
1840                'bold' => 700,
1841                default => 400,
1842            };
1843        }
1844        if ($value instanceof \Phpdftk\Css\Value\Integer
1845            || $value instanceof \Phpdftk\Css\Value\Number
1846        ) {
1847            return max(1, min(1000, (int) $value->value));
1848        }
1849        return 400;
1850    }
1851
1852    /**
1853     * Parse a `@font-face` `font-style` descriptor to the
1854     * `normal|italic|oblique` set (CSS Fonts 4 §3.3). Unrecognised input
1855     * defaults to `normal`.
1856     */
1857    private function fontFaceStyle(\Phpdftk\Css\Value\Value $value): string
1858    {
1859        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
1860            $lc = strtolower($value->name);
1861            if (in_array($lc, ['italic', 'oblique'], true)) {
1862                return $lc;
1863            }
1864        }
1865        return 'normal';
1866    }
1867
1868    /**
1869     * Parse a `@font-face` `font-stretch` descriptor to a percentage on
1870     * the CSS Fonts 4 §3.4 [50, 200] axis. Accepts the named keywords and
1871     * bare percentages; a two-value range keeps the lower bound. Values
1872     * outside the axis are clamped; unrecognised input defaults to 100.
1873     */
1874    private function fontFaceStretch(\Phpdftk\Css\Value\Value $value): float
1875    {
1876        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
1877            return match (strtolower($value->name)) {
1878                'ultra-condensed' => 50.0,
1879                'extra-condensed' => 62.5,
1880                'condensed' => 75.0,
1881                'semi-condensed' => 87.5,
1882                'semi-expanded' => 112.5,
1883                'expanded' => 125.0,
1884                'extra-expanded' => 150.0,
1885                'ultra-expanded' => 200.0,
1886                default => 100.0,
1887            };
1888        }
1889        if ($value instanceof \Phpdftk\Css\Value\Percentage) {
1890            return max(50.0, min(200.0, (float) $value->value));
1891        }
1892        return 100.0;
1893    }
1894
1895    /**
1896     * Register `$font` with the writer through the right Type 0 path:
1897     * `addOpenTypeFont` for CFF outlines, `addCompositeFont` for
1898     * TrueType glyf outlines. The two subtypes share the metric and
1899     * glyph-mapping fields the renderer needs, but the PDF embed paths
1900     * differ — CFF gets a `/FontFile3 /Subtype /CIDFontType0C` stream
1901     * with a CFF subsetter, TrueType gets a `/FontFile2` stream with
1902     * a glyf subsetter.
1903     *
1904     * @param array<int>|list<int> $codepoints
1905     */
1906    private function registerFontWithPdfWriter(
1907        \Phpdftk\Pdf\Writer\PdfWriter $writer,
1908        \Phpdftk\FontParser\FontFaceData $font,
1909        array $codepoints,
1910        \Phpdftk\Pdf\Writer\Page $page,
1911    ): \Phpdftk\Pdf\Core\Font\RegisteredFont {
1912        if ($font instanceof \Phpdftk\FontParser\TrueTypeData) {
1913            return $writer->addCompositeFont($font, $codepoints, $page);
1914        }
1915        if ($font instanceof \Phpdftk\FontParser\OpenTypeData) {
1916            return $writer->addOpenTypeFont($font, $codepoints, $page);
1917        }
1918        throw new \LogicException(sprintf(
1919            'Unsupported font type: %s',
1920            $font::class,
1921        ));
1922    }
1923
1924    /**
1925     * Parse the bytes of an `@font-face` source into a {@see FontFaceData},
1926     * trying the OpenType (CFF) parser first and falling back to the
1927     * TrueType (glyf) parser when the sfVersion tag says it isn't CFF.
1928     *
1929     * The two formats share `sfnt` table layout; what differs is which
1930     * outline table the font carries (`CFF ` for OpenType vs `glyf`/`loca`
1931     * for TrueType). The downstream PDF embed path checks the concrete
1932     * subclass and routes through the matching subset+stream emit.
1933     *
1934     * Returns null when both parsers reject the bytes — the caller falls
1935     * back to the next `src` candidate.
1936     *
1937     * @param list<Warning> $warnings
1938     */
1939    private function parseFontFace(string $bytes, string $family, array &$warnings): ?\Phpdftk\FontParser\FontFaceData
1940    {
1941        $otError = null;
1942        try {
1943            return \Phpdftk\FontParser\OpenTypeParser::fromBytes($bytes)->parse();
1944        } catch (\Throwable $e) {
1945            $otError = $e;
1946        }
1947        try {
1948            return \Phpdftk\FontParser\TrueTypeParser::fromBytes($bytes)->parse();
1949        } catch (\Throwable $ttError) {
1950            $warnings[] = new Warning(
1951                WarningCode::UnsupportedCssValue,
1952                sprintf(
1953                    '@font-face `%s` source failed to parse: OpenType: %s; TrueType: %s',
1954                    $family,
1955                    $otError->getMessage(),
1956                    $ttError->getMessage(),
1957                ),
1958                WarningSeverity::Warning,
1959            );
1960            return null;
1961        }
1962    }
1963
1964    /**
1965     * Extract the family name from a `font-family` value inside an
1966     * Walk a list of stylesheet rules, descending into nested
1967     * `@media all` / `@supports (cond)` / `@layer` blocks so a
1968     * `@font-face` (or any other rule the caller cares about) inside
1969     * one of those conditional groups still surfaces at the top
1970     * level. WPT at-supports-content-002 / -003 / -004 and the
1971     * matching at-media-content-* fixtures all nest `@font-face`,
1972     * `@keyframes`, or `@counter-style` inside `@media all`.
1973     *
1974     * We intentionally treat unconditional groups (`@media all`,
1975     * `@media`, `@supports` whose prelude trivially passes) as
1976     * passthrough. Anything that wouldn't statically pass the
1977     * cascade's media/supports matcher gets dropped — we don't
1978     * want a `@media not all { @font-face { … } }` block's face
1979     * silently loading.
1980     *
1981     * @param list<\Phpdftk\Css\Sheet\Rule> $rules
1982     * @return iterable<\Phpdftk\Css\Sheet\Rule>
1983     */
1984    private function flattenRules(array $rules): iterable
1985    {
1986        foreach ($rules as $rule) {
1987            yield $rule;
1988            if (!$rule instanceof \Phpdftk\Css\Sheet\AtRule || $rule->block === null) {
1989                continue;
1990            }
1991            $name = strtolower($rule->name);
1992            if (!in_array($name, ['media', 'supports', 'layer', 'scope', 'starting-style', 'container'], true)) {
1993                continue;
1994            }
1995            $shouldRecurse = match ($name) {
1996                'media' => $this->staticallyMatchingMediaPrelude($rule->prelude),
1997                'supports' => $this->cascade->supportsPreludeMatches($rule->prelude),
1998                default => true,
1999            };
2000            if (!$shouldRecurse) {
2001                continue;
2002            }
2003            $nested = [];
2004            foreach ($rule->block->contents as $item) {
2005                if ($item instanceof \Phpdftk\Css\Sheet\Rule) {
2006                    $nested[] = $item;
2007                }
2008            }
2009            yield from $this->flattenRules($nested);
2010        }
2011    }
2012
2013    /**
2014     * Cheap statically-true check for a `@media` prelude — accepts
2015     * `all`, `print`, and any comma-separated list mentioning either.
2016     * Anything that depends on viewport state (`(min-width: …)`) is
2017     * conservatively treated as "true" so author CSS that gates
2018     * behind it still surfaces.
2019     */
2020    private function staticallyMatchingMediaPrelude(string $prelude): bool
2021    {
2022        $prelude = trim(strtolower($prelude));
2023        if ($prelude === '' || $prelude === 'all') {
2024            return true;
2025        }
2026        if (str_starts_with($prelude, 'not all') || str_starts_with($prelude, 'not (')) {
2027            // Conservative — `not all` is the canonical "drop this
2028            // block" shape used by WPT to pair the active and dropped
2029            // alternatives (see at-supports-content-002).
2030            return false;
2031        }
2032        return true;
2033    }
2034
2035    /**
2036     * `@font-face` block. Accepts a `StringValue` (`"My Font"`) or a
2037     * `Keyword` or a space-separated `ValueList` of keywords (the
2038     * unquoted-multi-word form `font-family: My Font`).
2039     */
2040    private function fontFamilyName(\Phpdftk\Css\Value\Value $value): ?string
2041    {
2042        if ($value instanceof \Phpdftk\Css\Value\StringValue) {
2043            $name = trim($value->value);
2044            return $name === '' ? null : $name;
2045        }
2046        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
2047            $name = trim($value->name);
2048            return $name === '' ? null : $name;
2049        }
2050        if ($value instanceof \Phpdftk\Css\Value\ValueList
2051            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Space
2052        ) {
2053            $parts = [];
2054            foreach ($value->values as $v) {
2055                if ($v instanceof \Phpdftk\Css\Value\Keyword) {
2056                    $parts[] = $v->name;
2057                } elseif ($v instanceof \Phpdftk\Css\Value\StringValue) {
2058                    $parts[] = $v->value;
2059                }
2060            }
2061            $name = trim(implode(' ', $parts));
2062            return $name === '' ? null : $name;
2063        }
2064        return null;
2065    }
2066
2067    /**
2068     * Split a `src:` value into its candidate sources (comma-separated by
2069     * the CSS Fonts 4 grammar). Each element is a `{url, format}` tuple:
2070     * `url` is the bare `Url` or `url()`/`local()` `CssFunction`; `format`
2071     * is the lower-cased identifier from the optional trailing
2072     * `format(<keyword|string>)` sibling, or null when no hint is given.
2073     * CSS Fonts 4 §4.3: the format hint is advisory, not load-blocking,
2074     * but it lets the resolver skip sources it can't decode without
2075     * attempting the parse.
2076     *
2077     * @return list<array{url: \Phpdftk\Css\Value\Value, format: ?string}>
2078     */
2079    private function splitSrcList(\Phpdftk\Css\Value\Value $value): array
2080    {
2081        $candidates = $value instanceof \Phpdftk\Css\Value\ValueList
2082            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Comma
2083                ? $value->values
2084                : [$value];
2085        $out = [];
2086        foreach ($candidates as $c) {
2087            if ($c instanceof \Phpdftk\Css\Value\ValueList
2088                && $c->separator === \Phpdftk\Css\Value\ListSeparator::Space
2089                && $c->values !== []
2090            ) {
2091                $out[] = [
2092                    'url' => $c->values[0],
2093                    'format' => $this->extractFormatHint($c->values),
2094                ];
2095                continue;
2096            }
2097            $out[] = ['url' => $c, 'format' => null];
2098        }
2099        return $out;
2100    }
2101
2102    /**
2103     * Find a `format(...)` `CssFunction` in the space-list and return its
2104     * first argument as a lower-cased string. Tolerates the function's
2105     * argument being either a `Keyword` or a `StringValue` (both spec
2106     * variants). Returns null when no `format()` sibling is present.
2107     *
2108     * @param list<\Phpdftk\Css\Value\Value> $siblings
2109     */
2110    private function extractFormatHint(array $siblings): ?string
2111    {
2112        foreach ($siblings as $s) {
2113            if (!$s instanceof \Phpdftk\Css\Value\CssFunction
2114                || strtolower($s->name) !== 'format'
2115                || $s->arguments === []
2116            ) {
2117                continue;
2118            }
2119            $first = $s->arguments[0];
2120            if ($first instanceof \Phpdftk\Css\Value\StringValue) {
2121                return strtolower($first->value);
2122            }
2123            if ($first instanceof \Phpdftk\Css\Value\Keyword) {
2124                return strtolower($first->name);
2125            }
2126        }
2127        return null;
2128    }
2129
2130    /**
2131     * The set of `format(...)` hints we can decode. TrueType (glyf) goes
2132     * through `TrueTypeParser`, OTF/CFF through `OpenTypeParser` (parseFontFace
2133     * tries both), and WOFF 1.0 is transparently decompressed to its inner
2134     * SFNT first. Everything else (WOFF2 — needs ext-brotli, EOT, SVG, TTC)
2135     * needs decompression or parsers that haven't landed yet. Hints outside
2136     * this set make the resolver skip the source without attempting a fetch.
2137     */
2138    private const SUPPORTED_FONT_FORMATS = [
2139        'truetype',
2140        'truetype-variations',
2141        'opentype',
2142        'opentype-variations',
2143        'woff',
2144        // WOFF 2.0 needs Brotli, which is an OPTIONAL ext-brotli
2145        // dependency (see composer.json `suggest`). The format is
2146        // advertised unconditionally and the source is skipped at
2147        // decode time when the extension is absent, so a stylesheet
2148        // listing `woff2` first still falls through to its next
2149        // `src` entry rather than losing the face entirely.
2150        'woff2',
2151    ];
2152
2153    /**
2154     * Load the CSS text for a `<link rel="stylesheet" href="…">`
2155     * element, or null when the link doesn't apply (`rel` not
2156     * stylesheet, href missing, fetch fails, media query unmatched).
2157     * Supports `data:text/css[;base64],…` payloads and relative paths
2158     * resolved under `RendererOptions::baseDir` (with realpath escape
2159     * rejection — same posture as `<img src>` / `@font-face`).
2160     *
2161     * Honours `<link media="…">`: the same Phase-1 prelude matcher used
2162     * for `@media` rule cascade. Drops the sheet when `media` is
2163     * present and doesn't include `print` / `all`.
2164     */
2165    private function loadLinkedStylesheet(\Phpdftk\Html\Dom\Element $link): ?string
2166    {
2167        $relAttr = $link->getAttribute('rel');
2168        if ($relAttr === null) {
2169            return null;
2170        }
2171        // `rel` is a space-separated token list; pick stylesheet
2172        // anywhere in it. Case-insensitive per HTML 5.
2173        $rels = preg_split('/\s+/', strtolower(trim($relAttr))) ?: [];
2174        if (!in_array('stylesheet', $rels, true)) {
2175            return null;
2176        }
2177        // HTML 5 §4.6.7.10 "link types": `rel="alternate stylesheet"` is
2178        // an alternate stylesheet — not applied by default. The user
2179        // opts in via a stylesheet selection UI we don't have, so the
2180        // sheet stays inert (matches browser behaviour).
2181        if (in_array('alternate', $rels, true)) {
2182            return null;
2183        }
2184        $href = $link->getAttribute('href');
2185        if ($href === null || $href === '') {
2186            return null;
2187        }
2188        // CSS Media Queries 5: a `<link media="…">` filters the sheet
2189        // per the same media-type matcher we use for `@media` rules.
2190        $media = $link->getAttribute('media');
2191        if ($media !== null && $media !== '' && !$this->mediaPreludeMatches($media)) {
2192            return null;
2193        }
2194        // `data:` URLs must declare `text/css`; filesystem paths take
2195        // any extension. The ResourceLoader's allowlist enforces the
2196        // MIME check for the former.
2197        if (str_starts_with($href, 'data:')) {
2198            return $this->resourceLoader()->load($href, allowedMimes: ['text/css']);
2199        }
2200        $css = $this->resourceLoader()->load($href);
2201
2202        return $css === null ? null : $this->rebaseStylesheetUrls($css, $href);
2203    }
2204
2205    /**
2206     * Rewrite relative `url()` / `@import` references inside a fetched
2207     * stylesheet so they resolve against THAT stylesheet's location
2208     * rather than the document's (CSS Values 4 §4.2: "relative URLs are
2209     * resolved against the base URL of the style sheet").
2210     *
2211     * Everything downstream resolves against the document's `baseDir`,
2212     * so rebasing means prefixing the sheet's own directory. A WPT
2213     * fixture importing `support/fonts.css`, whose `src: url(x.ttf)`
2214     * means `support/x.ttf`, was looking for `x.ttf` next to the test
2215     * and silently falling back to the default font.
2216     *
2217     * Absolute references (`/…`, `scheme://…`, `data:`, fragments) are
2218     * already unambiguous and pass through untouched.
2219     */
2220    private function rebaseStylesheetUrls(string $css, string $href): string
2221    {
2222        $prefix = $this->stylesheetBasePrefix($href);
2223        if ($prefix === '') {
2224            return $css;
2225        }
2226        $rebase = function (string $url) use ($prefix): string {
2227            if ($url === '' || $this->isAbsoluteResourceUrl($url)) {
2228                return $url;
2229            }
2230
2231            return $prefix . $url;
2232        };
2233        // Re-emitted URLs are always double-quoted, so a literal quote in
2234        // the value has to be escaped or it would terminate the token.
2235        $quote = static fn(string $url): string => 'url("' . str_replace(
2236            ['\\', '"'],
2237            ['\\\\', '\\"'],
2238            $url,
2239        ) . '")';
2240        $out = preg_replace_callback(
2241            '~\burl\(\s*(?:"([^"]*)"|\'([^\']*)\'|([^)\s"\']*))\s*\)~i',
2242            static function (array $m) use ($rebase, $quote): string {
2243                $url = $m[1] !== '' ? $m[1] : ($m[2] !== '' ? $m[2] : ($m[3] ?? ''));
2244                $rebased = $rebase($url);
2245                // A URL that already carries its own base comes back
2246                // byte-for-byte — no re-quoting, so the only tokens this
2247                // pass can disturb are the ones it genuinely moves.
2248                return $rebased === $url ? $m[0] : $quote($rebased);
2249            },
2250            $css,
2251        ) ?? $css;
2252
2253        // `@import "sheet.css"` has no `url()` wrapper, so it needs its
2254        // own pass or a nested import resolves against the wrong dir.
2255        return preg_replace_callback(
2256            '~(@import\s+)(?:"([^"]*)"|\'([^\']*)\')~i',
2257            static function (array $m) use ($rebase, $quote): string {
2258                $url = $m[2] !== '' ? $m[2] : ($m[3] ?? '');
2259                $rebased = $rebase($url);
2260
2261                return $rebased === $url
2262                    ? $m[0]
2263                    : $m[1] . substr($quote($rebased), 4, -1);
2264            },
2265            $out,
2266        ) ?? $out;
2267    }
2268
2269    /**
2270     * The directory part of a stylesheet href, as a prefix to prepend to
2271     * that sheet's relative references. Empty when the sheet sits in the
2272     * document's own directory (nothing to rebase) or when the href is
2273     * itself absolute in a way the prefix can't express.
2274     */
2275    private function stylesheetBasePrefix(string $href): string
2276    {
2277        if ($href === '' || ($this->isAbsoluteResourceUrl($href) && !str_starts_with($href, '/'))) {
2278            return '';
2279        }
2280        // Strip any query / fragment before taking the directory.
2281        $path = preg_replace('~[?#].*$~', '', $href) ?? $href;
2282        $slash = strrpos($path, '/');
2283        if ($slash === false) {
2284            return '';
2285        }
2286
2287        return substr($path, 0, $slash + 1);
2288    }
2289
2290    /**
2291     * Whether a URL already carries its own base — a scheme, a root-
2292     * relative path, a `data:` payload or a bare fragment — and so must
2293     * not be rebased onto a stylesheet's directory.
2294     */
2295    private function isAbsoluteResourceUrl(string $url): bool
2296    {
2297        return str_starts_with($url, '/')
2298            || str_starts_with($url, '#')
2299            || preg_match('~^[a-zA-Z][a-zA-Z0-9+.-]*:~', $url) === 1;
2300    }
2301
2302    /**
2303     * Phase-1 media-type matcher mirrored from `Cascade::mediaPreludeMatches`.
2304     * The cascade can't be re-used because its method is private; we
2305     * duplicate the small predicate here. Both should stay in sync —
2306     * any change to the cascade matcher should reflect here too.
2307     */
2308    private function mediaPreludeMatches(string $prelude): bool
2309    {
2310        $lower = strtolower(trim($prelude));
2311        if ($lower === '' || $lower === 'all') {
2312            return true;
2313        }
2314        foreach (explode(',', $lower) as $part) {
2315            $tokens = preg_split('/\s+/', trim($part)) ?: [];
2316            foreach ($tokens as $tok) {
2317                if ($tok === 'print' || $tok === 'all') {
2318                    return true;
2319                }
2320            }
2321        }
2322        return false;
2323    }
2324
2325    /**
2326     * Resolve a single `src` candidate to raw font bytes via the unified
2327     * `ResourceLoader`. Returns null when the URL can't be unwrapped
2328     * (not a `url(...)` or `StringValue`) or when the loader can't
2329     * fetch it under the current security gates. Per-source diagnostic
2330     * Warnings only emit for the local-file branch when the resolved
2331     * path read fails — data: URLs that the loader can't decode fall
2332     * through silently so the caller's downstream parse-attempt can
2333     * surface the error.
2334     *
2335     * @param list<Warning> $warnings
2336     */
2337    private function fetchFontSource(\Phpdftk\Css\Value\Value $candidate, array &$warnings): ?string
2338    {
2339        $url = null;
2340        if ($candidate instanceof \Phpdftk\Css\Value\Url) {
2341            $url = $candidate->url;
2342        } elseif ($candidate instanceof \Phpdftk\Css\Value\CssFunction
2343            && strtolower($candidate->name) === 'url'
2344            && isset($candidate->arguments[0])
2345        ) {
2346            $first = $candidate->arguments[0];
2347            if ($first instanceof \Phpdftk\Css\Value\Url) {
2348                $url = $first->url;
2349            } elseif ($first instanceof \Phpdftk\Css\Value\StringValue) {
2350                $url = $first->value;
2351            }
2352        }
2353        if ($url === null || $url === '') {
2354            return null;
2355        }
2356        // Fonts are binary; `data:` URLs must be base64 for binary to
2357        // round-trip. The ResourceLoader accepts urlencoded payloads
2358        // too but they're unsafe for fonts — reject explicitly.
2359        if (str_starts_with($url, 'data:') && stripos($url, ';base64,') === false) {
2360            return null;
2361        }
2362        // http(s):// — route through the optional
2363        // phpdftk/resource-loader. When no loader configured the
2364        // url is rejected with a MissingResource warning (same as
2365        // local-file unfound) instead of silently dropping.
2366        if (str_starts_with($url, 'http://') || str_starts_with($url, 'https://')) {
2367            $bytes = $this->fetchHttpResource($url, $warnings, '@font-face src');
2368            return $bytes;
2369        }
2370        $bytes = $this->resourceLoader()->load($url);
2371        if ($bytes === null && !str_starts_with($url, 'data:')) {
2372            // Local-file branch: emit a per-source warning so authors
2373            // see what went wrong with a missing fixture. data: URL
2374            // failures fall through silently — the downstream parse
2375            // attempt surfaces them.
2376            if ($this->options->baseDir !== null) {
2377                $warnings[] = new Warning(
2378                    WarningCode::MissingResource,
2379                    sprintf('@font-face src `%s` could not be read.', $url),
2380                    WarningSeverity::Warning,
2381                );
2382            }
2383        }
2384        return $bytes;
2385    }
2386
2387    /**
2388     * Resolve an `http(s)://` URL through the optional
2389     * `phpdftk/resource-loader` attached via
2390     * `RendererOptions::withResourceLoader`. Without a loader the
2391     * URL is rejected and a per-source warning emitted so authors
2392     * see the missing configuration instead of silently dropping
2393     * their `@font-face` / `@import` / `<img>` / etc.
2394     *
2395     * @param list<Warning> $warnings
2396     */
2397    private function fetchHttpResource(string $url, array &$warnings, string $contextLabel): ?string
2398    {
2399        $loader = $this->options->resourceLoader;
2400        if ($loader === null) {
2401            $warnings[] = new Warning(
2402                WarningCode::MissingResource,
2403                sprintf(
2404                    '%s `%s` requires a ResourceLoader (via RendererOptions::withResourceLoader) — http(s) hrefs drop otherwise.',
2405                    $contextLabel,
2406                    $url,
2407                ),
2408                WarningSeverity::Warning,
2409            );
2410            return null;
2411        }
2412        try {
2413            $result = $loader->fetch($url);
2414            return $result->bytes;
2415        } catch (\Phpdftk\ResourceLoader\Exception\SsrfBlockedException $e) {
2416            $warnings[] = new Warning(
2417                WarningCode::MissingResource,
2418                sprintf('%s `%s` blocked by SSRF policy: %s', $contextLabel, $url, $e->getMessage()),
2419                WarningSeverity::Warning,
2420            );
2421            return null;
2422        } catch (\Phpdftk\ResourceLoader\Exception\FetchFailedException $e) {
2423            $warnings[] = new Warning(
2424                WarningCode::MissingResource,
2425                sprintf('%s `%s` fetch failed: %s', $contextLabel, $url, $e->getMessage()),
2426                WarningSeverity::Warning,
2427            );
2428            return null;
2429        }
2430    }
2431
2432    /**
2433     * Walk every supplied stylesheet looking for `@page` at-rules; for
2434     * each, extract its nested margin-box at-rules (e.g. `@top-center`)
2435     * and pull the `content` declaration out alongside any styling
2436     * declarations (`font-size`, `color`, `text-align`). The `content`
2437     * value is parsed into a sequence of parts (literal strings +
2438     * `counter(page)` / `counter(pages)` directives) so the per-page
2439     * paint pass can substitute the right page number at emission time.
2440     *
2441     * CSS Paged Media 3 §3.3 page selectors are partially supported at
2442     * Phase 1: `:first` (matches page index 0), `:left` (even-numbered
2443     * 0-indexed pages — index 1, 3, 5...), `:right` (odd-numbered
2444     * 0-indexed pages — index 0, 2, 4...). Other selectors (`:blank`,
2445     * `:nth(...)`, named pages) ignored. Multiple `@page` rules with
2446     * different selectors stack — `resolvePageMarginBoxes` overlays
2447     * them per-page at paint time.
2448     *
2449     * @param list<\Phpdftk\Css\Sheet\Stylesheet> $sheets
2450     * @return array<string, array<string, array{
2451     *     parts: list<array{kind: string, value: string}>,
2452     *     fontSize: float,
2453     *     color: \Phpdftk\Css\Value\Color,
2454     *     textAlign: ?string,
2455     *     fontFamily: ?\Phpdftk\Css\Value\Value,
2456     *     fontWeight: int,
2457     *     fontStyle: string,
2458     * }>> selector → position → spec
2459     */
2460    private function collectPageMarginBoxes(array $sheets): array
2461    {
2462        $supported = [
2463            'top-left-corner', 'top-left', 'top-center', 'top-right', 'top-right-corner',
2464            'bottom-left-corner', 'bottom-left', 'bottom-center', 'bottom-right', 'bottom-right-corner',
2465        ];
2466        $out = [];
2467        foreach ($sheets as $sheet) {
2468            foreach ($sheet->rules as $rule) {
2469                if (!$rule instanceof \Phpdftk\Css\Sheet\AtRule
2470                    || strtolower($rule->name) !== 'page'
2471                    || $rule->block === null
2472                ) {
2473                    continue;
2474                }
2475                $selector = $this->normalisePageSelector($rule->prelude);
2476                if ($selector === null) {
2477                    continue;
2478                }
2479                if (!isset($out[$selector])) {
2480                    $out[$selector] = [];
2481                }
2482                // CSS Paged Media 3 §3 + Generated Content 3 §2.1: the
2483                // `@page` rule's own typography declarations cascade
2484                // INTO its nested margin boxes. Read them first so each
2485                // box's spec starts at those defaults instead of the
2486                // hard-coded 10pt black; the margin box's own
2487                // declarations still win per source-order.
2488                $pageDefaults = [
2489                    'fontSize' => 10.0,
2490                    'color' => new \Phpdftk\Css\Value\Color(0.0, 0.0, 0.0, 1.0),
2491                    'textAlign' => null,
2492                    'fontFamily' => null,
2493                    'fontWeight' => 400,
2494                    'fontStyle' => 'normal',
2495                ];
2496                foreach ($rule->block->contents as $pageDecl) {
2497                    if (!$pageDecl instanceof \Phpdftk\Css\Sheet\Declaration) {
2498                        continue;
2499                    }
2500                    switch ($pageDecl->property) {
2501                        case 'font-size':
2502                            if ($pageDecl->value instanceof \Phpdftk\Css\Value\Length) {
2503                                $pageDefaults['fontSize'] = max(1.0, $pageDecl->value->value);
2504                            }
2505                            break;
2506                        case 'color':
2507                            if ($pageDecl->value instanceof \Phpdftk\Css\Value\Color) {
2508                                $pageDefaults['color'] = $pageDecl->value;
2509                            }
2510                            break;
2511                        case 'font-family':
2512                            $pageDefaults['fontFamily'] = $pageDecl->value;
2513                            break;
2514                        case 'font-weight':
2515                            $pageDefaults['fontWeight'] = $this->parseFontWeight($pageDecl->value);
2516                            break;
2517                        case 'font-style':
2518                            $pageDefaults['fontStyle'] = $this->parseFontStyle($pageDecl->value);
2519                            break;
2520                    }
2521                }
2522                foreach ($rule->block->contents as $item) {
2523                    if (!$item instanceof \Phpdftk\Css\Sheet\AtRule
2524                        || $item->block === null
2525                    ) {
2526                        continue;
2527                    }
2528                    $pos = strtolower($item->name);
2529                    if (!in_array($pos, $supported, true)) {
2530                        continue;
2531                    }
2532                    $parts = null;
2533                    $fontSize = $pageDefaults['fontSize'];
2534                    $color = $pageDefaults['color'];
2535                    $textAlign = $pageDefaults['textAlign'];
2536                    $fontFamily = $pageDefaults['fontFamily'];
2537                    $fontWeight = $pageDefaults['fontWeight'];
2538                    $fontStyle = $pageDefaults['fontStyle'];
2539                    foreach ($item->block->contents as $decl) {
2540                        if (!$decl instanceof \Phpdftk\Css\Sheet\Declaration) {
2541                            continue;
2542                        }
2543                        switch ($decl->property) {
2544                            case 'content':
2545                                $parts = $this->parseContentValue($decl->value);
2546                                break;
2547                            case 'font-size':
2548                                if ($decl->value instanceof \Phpdftk\Css\Value\Length) {
2549                                    $fontSize = max(1.0, $decl->value->value);
2550                                }
2551                                break;
2552                            case 'color':
2553                                if ($decl->value instanceof \Phpdftk\Css\Value\Color) {
2554                                    $color = $decl->value;
2555                                }
2556                                break;
2557                            case 'text-align':
2558                                if ($decl->value instanceof \Phpdftk\Css\Value\Keyword) {
2559                                    $kw = strtolower($decl->value->name);
2560                                    if (in_array($kw, ['left', 'right', 'center', 'start', 'end'], true)) {
2561                                        $textAlign = $kw === 'start' ? 'left'
2562                                            : ($kw === 'end' ? 'right' : $kw);
2563                                    }
2564                                }
2565                                break;
2566                            case 'font-family':
2567                                $fontFamily = $decl->value;
2568                                break;
2569                            case 'font-weight':
2570                                $fontWeight = $this->parseFontWeight($decl->value);
2571                                break;
2572                            case 'font-style':
2573                                $fontStyle = $this->parseFontStyle($decl->value);
2574                                break;
2575                        }
2576                    }
2577                    if ($parts !== null && $parts !== []) {
2578                        $out[$selector][$pos] = [
2579                            'parts' => $parts,
2580                            'fontSize' => $fontSize,
2581                            'color' => $color,
2582                            'textAlign' => $textAlign,
2583                            'fontFamily' => $fontFamily,
2584                            'fontWeight' => $fontWeight,
2585                            'fontStyle' => $fontStyle,
2586                        ];
2587                    }
2588                }
2589            }
2590        }
2591        return $out;
2592    }
2593
2594    /**
2595     * Reduce a `@page <prelude>` selector text to one of the supported
2596     * keys: `''` (unscoped / default), `:first`, `:left`, `:right`.
2597     * Returns null for unsupported selectors (`:blank`, named pages, etc.)
2598     * so the caller drops the rule entirely rather than mis-applying it.
2599     */
2600    /**
2601     * Resolve the effective page width / height from CSS Paged Media 3
2602     * §6.1 `@page { size: ... }` declarations. Falls back to the
2603     * `RendererOptions` defaults when no `size` is declared or the
2604     * declared value isn't recognised. Multiple `@page` rules merge —
2605     * the last `size` declaration wins per source-order.
2606     *
2607     * Supported forms:
2608     *   - `auto` — use defaults
2609     *   - `<length>{1,2}` — width [height]; one length sets a square
2610     *   - `<page-size>` — A3/A4/A5/B4/B5/JIS-B4/JIS-B5/letter/legal/ledger
2611     *   - `<page-size> <orientation>` or `<orientation> <page-size>`
2612     *   - `<orientation>` alone (rotates the default size)
2613     *
2614     * @param list<\Phpdftk\Css\Sheet\Stylesheet> $sheets
2615     * @return array{width: float, height: float}
2616     */
2617    private function resolvePageSize(array $sheets): array
2618    {
2619        $width = $this->options->pageWidth;
2620        $height = $this->options->pageHeight;
2621        foreach ($sheets as $sheet) {
2622            foreach ($sheet->rules as $rule) {
2623                if (!$rule instanceof \Phpdftk\Css\Sheet\AtRule
2624                    || strtolower($rule->name) !== 'page'
2625                    || $rule->block === null
2626                ) {
2627                    continue;
2628                }
2629                foreach ($rule->block->contents as $decl) {
2630                    if (!$decl instanceof \Phpdftk\Css\Sheet\Declaration
2631                        || $decl->property !== 'size'
2632                    ) {
2633                        continue;
2634                    }
2635                    $resolved = $this->parsePageSize($decl->value);
2636                    if ($resolved !== null) {
2637                        [$width, $height] = $resolved;
2638                    }
2639                }
2640            }
2641        }
2642        return ['width' => $width, 'height' => $height];
2643    }
2644
2645    /**
2646     * Resolve the effective page background color from every `@page`
2647     * rule's `background-color` (and the `background` shorthand's color
2648     * component) declarations. Returns null when no @page rule sets a
2649     * page-level background.
2650     *
2651     * Phase-1 simplification: colour only. `background-image`,
2652     * `background-repeat`, etc. lands later alongside the body-level
2653     * background-image painter once a shared image-paint path is in
2654     * place.
2655     *
2656     * `$pageName` (optional) selects an `@page <name>` overlay on top
2657     * of the default unnamed rule. CSS Paged Media 3 §3.4: when the
2658     * page being painted is tagged with a name, the named rule wins
2659     * for any property it sets.
2660     *
2661     * @param list<\Phpdftk\Css\Sheet\Stylesheet> $sheets
2662     */
2663    private function resolvePageBackground(array $sheets, ?string $pageName = null): ?\Phpdftk\Css\Value\Color
2664    {
2665        $expander = new \Phpdftk\Css\Cascade\ShorthandExpander();
2666        $color = null;
2667        foreach ($sheets as $sheet) {
2668            foreach ($sheet->rules as $rule) {
2669                if (!$rule instanceof \Phpdftk\Css\Sheet\AtRule
2670                    || strtolower($rule->name) !== 'page'
2671                    || $rule->block === null
2672                ) {
2673                    continue;
2674                }
2675                $sel = $this->normalisePageSelector($rule->prelude);
2676                if (!$this->pageSelectorAppliesTo($sel, $pageName)) {
2677                    continue;
2678                }
2679                foreach ($rule->block->contents as $decl) {
2680                    if (!$decl instanceof \Phpdftk\Css\Sheet\Declaration) {
2681                        continue;
2682                    }
2683                    if ($decl->property === 'background-color'
2684                        && $decl->value instanceof \Phpdftk\Css\Value\Color
2685                    ) {
2686                        $color = $decl->value;
2687                    } elseif ($decl->property === 'background') {
2688                        $expanded = $expander->expand('background', $decl->value);
2689                        $bg = $expanded['background-color'] ?? null;
2690                        if ($bg instanceof \Phpdftk\Css\Value\Color) {
2691                            $color = $bg;
2692                        }
2693                    }
2694                }
2695            }
2696        }
2697        return $color;
2698    }
2699
2700    /**
2701     * `true` when an `@page` rule with the given normalised selector
2702     * applies to a page tagged `$pageName`. Default (no selector)
2703     * always applies; named selectors apply only when their name
2704     * matches the page tag.
2705     */
2706    private function pageSelectorAppliesTo(?string $selector, ?string $pageName): bool
2707    {
2708        if ($selector === null) {
2709            return false;
2710        }
2711        if ($selector === '' || $selector === ':first' || $selector === ':left' || $selector === ':right') {
2712            // Phase-1: ignore parity / first overlays here. The
2713            // resolvePageMarginBoxes pipeline still honours them
2714            // separately via its own selector overlay.
2715            return $selector === '';
2716        }
2717        if (str_starts_with($selector, 'name:')) {
2718            return $pageName !== null && substr($selector, 5) === $pageName;
2719        }
2720        return false;
2721    }
2722
2723    /**
2724     * Walk the laid-out box tree once, building a per-page-index map of
2725     * the named page type that applies. A block with `page: foo`
2726     * tags the page containing its top edge as "foo" (CSS Paged Media
2727     * 3 §3.4 — the first fragment determines the page type).
2728     *
2729     * @return array<int, string>
2730     */
2731    private function resolvePageNames(\Phpdftk\HtmlToPdf\Box\Box $root, float $pageHeight, int $pageCount): array
2732    {
2733        $map = [];
2734        if ($pageHeight <= 0.0) {
2735            return $map;
2736        }
2737        $stack = [$root];
2738        while ($stack !== []) {
2739            $node = array_pop($stack);
2740            $value = $node->style->get('page');
2741            if ($value instanceof \Phpdftk\Css\Value\Keyword
2742                && strtolower($value->name) !== 'auto'
2743            ) {
2744                $pageIndex = (int) floor($node->geometry->y / $pageHeight);
2745                if ($pageIndex >= 0 && $pageIndex < $pageCount && !isset($map[$pageIndex])) {
2746                    $map[$pageIndex] = strtolower($value->name);
2747                }
2748            }
2749            // Push children in reverse order so document-order walk
2750            // processes the first child first.
2751            for ($i = count($node->children) - 1; $i >= 0; $i--) {
2752                $stack[] = $node->children[$i];
2753            }
2754        }
2755        return $map;
2756    }
2757
2758    /**
2759     * Resolve effective page margins (in PDF points) from every `@page`
2760     * rule's margin declarations. Honours the `margin` shorthand (1-4
2761     * components per CSS Box 3) and the per-side longhands
2762     * (`margin-top` / -right / -bottom / -left); later declarations win
2763     * per source order. Defaults to 36pt all sides — the same fixed
2764     * margin the painter used before CSS-driven control landed.
2765     *
2766     * @param list<\Phpdftk\Css\Sheet\Stylesheet> $sheets
2767     * @return array{top: float, right: float, bottom: float, left: float}
2768     */
2769    private function resolvePageMargins(array $sheets): array
2770    {
2771        $expander = new \Phpdftk\Css\Cascade\ShorthandExpander();
2772        $margins = ['top' => 36.0, 'right' => 36.0, 'bottom' => 36.0, 'left' => 36.0];
2773        foreach ($sheets as $sheet) {
2774            foreach ($sheet->rules as $rule) {
2775                if (!$rule instanceof \Phpdftk\Css\Sheet\AtRule
2776                    || strtolower($rule->name) !== 'page'
2777                    || $rule->block === null
2778                ) {
2779                    continue;
2780                }
2781                foreach ($rule->block->contents as $decl) {
2782                    if (!$decl instanceof \Phpdftk\Css\Sheet\Declaration) {
2783                        continue;
2784                    }
2785                    $prop = $decl->property;
2786                    if ($prop === 'margin') {
2787                        $expanded = $expander->expand('margin', $decl->value);
2788                        foreach (['top', 'right', 'bottom', 'left'] as $side) {
2789                            $sideValue = $expanded['margin-' . $side] ?? null;
2790                            if ($sideValue instanceof \Phpdftk\Css\Value\Length) {
2791                                $margins[$side] = $sideValue->value;
2792                            }
2793                        }
2794                    } elseif (in_array($prop, ['margin-top', 'margin-right', 'margin-bottom', 'margin-left'], true)) {
2795                        if ($decl->value instanceof \Phpdftk\Css\Value\Length) {
2796                            $margins[substr($prop, 7)] = $decl->value->value;
2797                        }
2798                    }
2799                }
2800            }
2801        }
2802        return $margins;
2803    }
2804
2805    /**
2806     * Parse a single `@page { size }` value into `[width, height]` in
2807     * PDF points, or null when the value can't be resolved.
2808     *
2809     * @return array{0: float, 1: float}|null
2810     */
2811    private function parsePageSize(\Phpdftk\Css\Value\Value $value): ?array
2812    {
2813        // Standard ISO + US page sizes in PDF points (1 inch = 72 pt).
2814        // Matches the CSS Paged Media 3 §6.1 named-size table.
2815        $named = [
2816            'a3' => [842.0, 1191.0],
2817            'a4' => [595.0, 842.0],
2818            'a5' => [420.0, 595.0],
2819            'b4' => [729.0, 1032.0],
2820            'b5' => [516.0, 729.0],
2821            'jis-b4' => [729.0, 1032.0],
2822            'jis-b5' => [516.0, 729.0],
2823            'letter' => [612.0, 792.0],
2824            'legal' => [612.0, 1008.0],
2825            'ledger' => [792.0, 1224.0],
2826        ];
2827        $items = $value instanceof \Phpdftk\Css\Value\ValueList
2828            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Space
2829                ? $value->values
2830                : [$value];
2831        // Single `auto` keyword → use defaults.
2832        if (count($items) === 1
2833            && $items[0] instanceof \Phpdftk\Css\Value\Keyword
2834            && strtolower($items[0]->name) === 'auto'
2835        ) {
2836            return null;
2837        }
2838        // Single length → square. Two lengths → width + height.
2839        $lengths = array_values(array_filter(
2840            $items,
2841            static fn($v) => $v instanceof \Phpdftk\Css\Value\Length,
2842        ));
2843        if (count($lengths) === 1) {
2844            return [$lengths[0]->value, $lengths[0]->value];
2845        }
2846        if (count($lengths) === 2) {
2847            return [$lengths[0]->value, $lengths[1]->value];
2848        }
2849        // Otherwise scan keywords: `<page-size>` + optional orientation.
2850        $size = null;
2851        $orientation = null;
2852        foreach ($items as $item) {
2853            if (!$item instanceof \Phpdftk\Css\Value\Keyword) {
2854                continue;
2855            }
2856            $kw = strtolower($item->name);
2857            if (isset($named[$kw])) {
2858                $size = $named[$kw];
2859            } elseif ($kw === 'landscape' || $kw === 'portrait') {
2860                $orientation = $kw;
2861            }
2862        }
2863        if ($size === null) {
2864            // Orientation alone: rotate the default size.
2865            if ($orientation !== null) {
2866                $defaultPortrait = [$this->options->pageWidth, $this->options->pageHeight];
2867                if ($defaultPortrait[0] > $defaultPortrait[1]) {
2868                    [$defaultPortrait[0], $defaultPortrait[1]] = [$defaultPortrait[1], $defaultPortrait[0]];
2869                }
2870                return $orientation === 'landscape'
2871                    ? [$defaultPortrait[1], $defaultPortrait[0]]
2872                    : $defaultPortrait;
2873            }
2874            return null;
2875        }
2876        if ($orientation === 'landscape' && $size[0] < $size[1]) {
2877            return [$size[1], $size[0]];
2878        }
2879        if ($orientation === 'portrait' && $size[0] > $size[1]) {
2880            return [$size[1], $size[0]];
2881        }
2882        return $size;
2883    }
2884
2885    /**
2886     * Parse a CSS `font-weight` value to the CSS Fonts 4 1–1000 range.
2887     * Keywords map per spec: `normal` → 400, `bold` / `bolder` → 700,
2888     * `lighter` → 100. Anything unrecognised falls back to 400.
2889     */
2890    private function parseFontWeight(\Phpdftk\Css\Value\Value $value): int
2891    {
2892        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
2893            return match (strtolower($value->name)) {
2894                'bold', 'bolder' => 700,
2895                'lighter' => 100,
2896                default => 400,
2897            };
2898        }
2899        if ($value instanceof \Phpdftk\Css\Value\Integer
2900            || $value instanceof \Phpdftk\Css\Value\Number
2901        ) {
2902            return max(1, min(1000, (int) $value->value));
2903        }
2904        return 400;
2905    }
2906
2907    /**
2908     * Parse a CSS `font-style` value to one of `normal`, `italic`, or
2909     * `oblique`. Unknown values fall back to `normal`.
2910     */
2911    private function parseFontStyle(\Phpdftk\Css\Value\Value $value): string
2912    {
2913        if ($value instanceof \Phpdftk\Css\Value\Keyword) {
2914            $lc = strtolower($value->name);
2915            if (in_array($lc, ['italic', 'oblique'], true)) {
2916                return $lc;
2917            }
2918        }
2919        return 'normal';
2920    }
2921
2922    private function normalisePageSelector(string $prelude): ?string
2923    {
2924        $lc = strtolower(trim($prelude));
2925        if ($lc === '') {
2926            return '';
2927        }
2928        if (in_array($lc, [':first', ':left', ':right'], true)) {
2929            return $lc;
2930        }
2931        // CSS Paged Media 3 §3.4: `@page <ident>` names a page type.
2932        // The prelude is an identifier (possibly followed by a
2933        // pseudo-class — Phase 1 ignores combined `<ident>:first`
2934        // forms and just keys on the bare name).
2935        if (preg_match('/^([a-z_][a-z0-9_-]*)$/', $lc, $m) === 1) {
2936            return 'name:' . $m[1];
2937        }
2938        return null;
2939    }
2940
2941    /**
2942     * Build the per-position margin-box map for a specific page index by
2943     * overlaying selector-scoped rules in CSS Paged Media 3 §3.3
2944     * specificity order: default (no selector) is the base, then
2945     * `:left` / `:right` (one applies per page), then `:first` (only
2946     * page 0). Position-keyed overlay so a `:first { @top-center }`
2947     * override preserves the default `@bottom-center` rule.
2948     *
2949     * @param array<string, array<string, array{
2950     *     parts: list<array{kind: string, value: string}>,
2951     *     fontSize: float,
2952     *     color: \Phpdftk\Css\Value\Color,
2953     *     textAlign: ?string,
2954     *     fontFamily: ?\Phpdftk\Css\Value\Value,
2955     *     fontWeight: int,
2956     *     fontStyle: string,
2957     * }>> $marginBoxes
2958     * @return array<string, array{
2959     *     parts: list<array{kind: string, value: string}>,
2960     *     fontSize: float,
2961     *     color: \Phpdftk\Css\Value\Color,
2962     *     textAlign: ?string,
2963     *     fontFamily: ?\Phpdftk\Css\Value\Value,
2964     *     fontWeight: int,
2965     *     fontStyle: string,
2966     * }>
2967     */
2968    private function resolvePageMarginBoxes(array $marginBoxes, int $pageIndex, ?string $pageName = null): array
2969    {
2970        $resolved = $marginBoxes[''] ?? [];
2971        // Even-numbered (0-indexed) pages are right-facing per the CSS
2972        // Paged Media 3 default ("the first page of a document begins on
2973        // a right page"). Odd-indexed are left-facing.
2974        $sideSelector = $pageIndex % 2 === 0 ? ':right' : ':left';
2975        if (isset($marginBoxes[$sideSelector])) {
2976            $resolved = array_merge($resolved, $marginBoxes[$sideSelector]);
2977        }
2978        if ($pageIndex === 0 && isset($marginBoxes[':first'])) {
2979            $resolved = array_merge($resolved, $marginBoxes[':first']);
2980        }
2981        // CSS Paged Media 3 §3.4: named selectors overlay on top of
2982        // the parity/first selectors when the page is tagged with a
2983        // matching name.
2984        if ($pageName !== null && isset($marginBoxes['name:' . $pageName])) {
2985            $resolved = array_merge($resolved, $marginBoxes['name:' . $pageName]);
2986        }
2987        return $resolved;
2988    }
2989
2990    /**
2991     * Parse a CSS `content` value (StringValue, counter() CssFunction,
2992     * or a space-separated ValueList mixing both) into a list of parts
2993     * the paint pass can resolve per page. Returns an empty list when
2994     * the value contains nothing renderable.
2995     *
2996     * `counter(name [, style])` arguments come back parsed by the CSS
2997     * value parser as a comma-separated `ValueList` inside the function
2998     * call. We honour the second positional `<counter-style>` keyword
2999     * argument (`decimal`, `lower-roman`, `upper-alpha`, etc. per CSS
3000     * Counter Styles 3 §6) by stashing the style with the counter part
3001     * so the paint pass can format the numeric value through it.
3002     *
3003     * @return list<array{kind: string, value: string, style?: string}>
3004     */
3005    private function parseContentValue(\Phpdftk\Css\Value\Value $value): array
3006    {
3007        $parts = [];
3008        $items = $value instanceof \Phpdftk\Css\Value\ValueList
3009            && $value->separator === \Phpdftk\Css\Value\ListSeparator::Space
3010                ? $value->values
3011                : [$value];
3012        foreach ($items as $item) {
3013            if ($item instanceof \Phpdftk\Css\Value\StringValue) {
3014                $parts[] = ['kind' => 'literal', 'value' => $item->value];
3015            } elseif ($item instanceof \Phpdftk\Css\Value\StringFunction) {
3016                // GCPM 3 §5.2 — resolved at paint time against the
3017                // box generator's named-string store.
3018                $parts[] = [
3019                    'kind' => 'namedstring',
3020                    'value' => '',
3021                    'name' => $item->name,
3022                    'target' => $item->target,
3023                ];
3024            } elseif ($item instanceof \Phpdftk\Css\Value\ElementFunction) {
3025                // GCPM 3 §4.2 — resolved at paint time against the
3026                // box generator's running-element store. The store
3027                // currently captures element textContent; full
3028                // fragment rendering is a future deliverable.
3029                $parts[] = [
3030                    'kind' => 'runningelement',
3031                    'value' => '',
3032                    'name' => $item->name,
3033                    'target' => $item->target,
3034                ];
3035            } elseif ($item instanceof \Phpdftk\Css\Value\CssFunction
3036                && strtolower($item->name) === 'counter'
3037                && $item->arguments !== []
3038            ) {
3039                $args = $this->splitCounterArgs($item->arguments);
3040                $first = $args[0] ?? null;
3041                $name = $first instanceof \Phpdftk\Css\Value\Keyword
3042                    ? strtolower($first->name)
3043                    : null;
3044                if ($name !== 'page' && $name !== 'pages') {
3045                    continue;
3046                }
3047                $style = 'decimal';
3048                $second = $args[1] ?? null;
3049                if ($second instanceof \Phpdftk\Css\Value\Keyword) {
3050                    $style = strtolower($second->name);
3051                }
3052                $parts[] = [
3053                    'kind' => $name === 'pages' ? 'totalpages' : 'pagenumber',
3054                    'value' => '',
3055                    'style' => $style,
3056                ];
3057            }
3058        }
3059        return $parts;
3060    }
3061
3062    /**
3063     * `counter(page, lower-roman)` parses into a CssFunction whose single
3064     * `arguments[0]` is a comma-separated `ValueList` of the actual
3065     * positional arguments. Split it back into a flat list so the caller
3066     * can index by position. Tolerant of the single-arg case (no comma).
3067     *
3068     * @param list<\Phpdftk\Css\Value\Value> $arguments
3069     * @return list<\Phpdftk\Css\Value\Value>
3070     */
3071    private function splitCounterArgs(array $arguments): array
3072    {
3073        if (count($arguments) !== 1) {
3074            return $arguments;
3075        }
3076        $head = $arguments[0];
3077        if ($head instanceof \Phpdftk\Css\Value\ValueList
3078            && $head->separator === \Phpdftk\Css\Value\ListSeparator::Comma
3079        ) {
3080            return $head->values;
3081        }
3082        return $arguments;
3083    }
3084
3085    /**
3086     * Paint the collected `@page` margin boxes on the current page.
3087     * Phase-1 positioning: a fixed 36pt (0.5") page margin band; text
3088     * baseline sits halfway through the margin. Each position picks an
3089     * anchor point and a horizontal alignment:
3090     *   - top-left / bottom-left → left-aligned at the margin
3091     *   - top-center / bottom-center → centred on the page width
3092     *   - top-right / bottom-right → right-aligned at the margin
3093     * Uses the document's default font at 10pt. Author-driven sizing /
3094     * styling lands when we cascade margin-box rules into a proper
3095     * mini-layout (follow-up).
3096     *
3097     * @param array<string, array{
3098     *     parts: list<array{kind: string, value: string, style?: string, name?: string, target?: string}>,
3099     *     fontSize: float,
3100     *     color: \Phpdftk\Css\Value\Color,
3101     *     textAlign: ?string,
3102     *     fontFamily: ?\Phpdftk\Css\Value\Value,
3103     *     fontWeight: int,
3104     *     fontStyle: string,
3105     * }> $boxes
3106     * @param array<string, \Phpdftk\Pdf\Core\Font\RegisteredFont> $registeredMap
3107     * @param array<string, string> $namedStrings  GCPM 3 §5 named-string
3108     *     store accumulated during box generation; resolves
3109     *     `content: string(name)` parts in page margin boxes.
3110     * @param array<string, string> $runningElements  GCPM 3 §4 running-
3111     *     element store accumulated during box generation;
3112     *     resolves `content: element(name)` parts.
3113     */
3114    private function paintPageMarginBoxes(
3115        \Phpdftk\Pdf\Core\Content\ContentStream $stream,
3116        array $boxes,
3117        float $pageWidth,
3118        float $pageHeight,
3119        \Phpdftk\FontParser\FontFaceData $font,
3120        \Phpdftk\Pdf\Core\Font\RegisteredFont $registered,
3121        int $pageIndex,
3122        int $pageCount,
3123        ?\Phpdftk\HtmlToPdf\Layout\FontResolver $fontResolver = null,
3124        array $registeredMap = [],
3125        float $marginTop = 36.0,
3126        float $marginRight = 36.0,
3127        float $marginBottom = 36.0,
3128        float $marginLeft = 36.0,
3129        array $namedStrings = [],
3130        array $runningElements = [],
3131    ): void {
3132        $shaper = new \Phpdftk\Text\Shaper();
3133        foreach ($boxes as $position => $spec) {
3134            // Resolve the per-page-variable parts. `counter(page)` becomes
3135            // the 1-based page number, `counter(pages)` the total, both
3136            // formatted through the optional `<counter-style>` argument
3137            // (`decimal` / `lower-roman` / `upper-alpha` / ...).
3138            $text = '';
3139            foreach ($spec['parts'] as $part) {
3140                if ($part['kind'] === 'pagenumber') {
3141                    $text .= \Phpdftk\HtmlToPdf\Layout\CounterFormat::format(
3142                        $pageIndex + 1,
3143                        $part['style'] ?? 'decimal',
3144                    );
3145                } elseif ($part['kind'] === 'totalpages') {
3146                    $text .= \Phpdftk\HtmlToPdf\Layout\CounterFormat::format(
3147                        $pageCount,
3148                        $part['style'] ?? 'decimal',
3149                    );
3150                } elseif ($part['kind'] === 'namedstring') {
3151                    $text .= $namedStrings[$part['name']] ?? '';
3152                } elseif ($part['kind'] === 'runningelement') {
3153                    $text .= $runningElements[$part['name']] ?? '';
3154                } else {
3155                    $text .= $part['value'];
3156                }
3157            }
3158            if ($text === '') {
3159                continue;
3160            }
3161            // Resolve a per-position `font-family` (+ weight + style)
3162            // through the same FontResolver the body uses. When a real
3163            // bold/italic face matches, the painter skips the synthetic
3164            // fake-bold / fake-italic fallbacks; otherwise the
3165            // FontMatch's match flags drive whether those fire.
3166            $faceFont = $font;
3167            $faceRegistered = $registered;
3168            $needsFakeBold = $spec['fontWeight'] >= 600;
3169            $needsFakeItalic = $spec['fontStyle'] !== 'normal';
3170            if ($spec['fontFamily'] !== null && $fontResolver !== null) {
3171                $match = $fontResolver->resolveMatch(
3172                    $spec['fontFamily'],
3173                    $spec['fontWeight'],
3174                    $spec['fontStyle'],
3175                );
3176                if ($match !== null
3177                    && isset($registeredMap[$match->face->data->postScriptName])
3178                ) {
3179                    $faceFont = $match->face->data;
3180                    $faceRegistered = $registeredMap[$match->face->data->postScriptName];
3181                    if ($match->matchesWeight) {
3182                        $needsFakeBold = false;
3183                    }
3184                    if ($match->matchesStyle) {
3185                        $needsFakeItalic = false;
3186                    }
3187                }
3188            }
3189            $shapingCtx = new \Phpdftk\Text\ShapingContext($faceFont, $spec['fontSize']);
3190            $shaped = $shaper->shapeRun($text, $shapingCtx);
3191            if ($shaped->glyphs === []) {
3192                continue;
3193            }
3194            $width = $shaped->totalAdvance;
3195            // Y bands: top boxes sit centred in the top margin
3196            // (pageHeight - marginTop / 2); bottom boxes centred in the
3197            // bottom margin (marginBottom / 2).
3198            $yPdf = match (true) {
3199                str_starts_with($position, 'top-') => $pageHeight - $marginTop / 2,
3200                default => $marginBottom / 2,
3201            };
3202            // Corner boxes sit in their respective margin corner area;
3203            // default alignment centres the text inside that area.
3204            // Author `text-align` still overrides.
3205            $isCorner = str_ends_with($position, '-corner');
3206            $alignment = $spec['textAlign'] ?? match (true) {
3207                $isCorner => 'center',
3208                str_ends_with($position, '-left') => 'left',
3209                str_ends_with($position, '-right') => 'right',
3210                default => 'center',
3211            };
3212            $xPdf = match (true) {
3213                $isCorner && str_contains($position, '-left-') => max(0.0, ($marginLeft - $width) / 2),
3214                $isCorner && str_contains($position, '-right-')
3215                    => $pageWidth - $marginRight + max(0.0, ($marginRight - $width) / 2),
3216                $alignment === 'left' => $marginLeft,
3217                $alignment === 'right' => $pageWidth - $marginRight - $width,
3218                default => ($pageWidth - $width) / 2,
3219            };
3220            $stream->saveGraphicsState();
3221            $stream->setFillColorRGB($spec['color']->r, $spec['color']->g, $spec['color']->b);
3222            $stream->setFont($faceRegistered, $spec['fontSize']);
3223            $stream->beginText();
3224            // Fake-italic via a 12° skew in the Tm `c` slot when no real
3225            // italic face matched — same trick used in the body painter.
3226            $skew = $needsFakeItalic ? 0.213 : 0.0;
3227            $stream->setTextMatrix(1, 0, $skew, 1, $xPdf, $yPdf);
3228            if ($needsFakeBold) {
3229                $stream->setStrokeColorRGB(
3230                    $spec['color']->r,
3231                    $spec['color']->g,
3232                    $spec['color']->b,
3233                );
3234                $stream->setLineWidth($spec['fontSize'] * 0.04);
3235                $stream->setTextRenderingMode(2);
3236            } else {
3237                $stream->setTextRenderingMode(0);
3238            }
3239            $gidMap = $faceRegistered instanceof \Phpdftk\Pdf\Writer\Font
3240                ? $faceRegistered->getOldToNewGidMap()
3241                : [];
3242            $hexParts = [];
3243            foreach ($shaped->glyphs as $glyph) {
3244                $newGid = $gidMap[$glyph->glyphId] ?? $glyph->glyphId;
3245                $hexParts[] = sprintf('%04X', $newGid);
3246            }
3247            $stream->showTextHex(implode('', $hexParts));
3248            $stream->endText();
3249            $stream->restoreGraphicsState();
3250        }
3251    }
3252
3253    /** @param list<Warning> $warnings */
3254    private function maybeThrow(array $warnings): void
3255    {
3256        if (!$this->options->strict) {
3257            return;
3258        }
3259        foreach ($warnings as $w) {
3260            if ($w->severity === WarningSeverity::Error) {
3261                throw new StrictModeException($w);
3262            }
3263        }
3264    }
3265}