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