Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.81% covered (success)
91.81%
157 / 171
84.62% covered (warning)
84.62%
11 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
RendererOptions
91.81% covered (success)
91.81%
157 / 171
84.62% covered (warning)
84.62%
11 / 13
20.22
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 withPageSize
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 withDefaultFont
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 withUserAgentStylesheet
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 withStrict
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 withBaseDir
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 withSandboxRoot
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 withMatchingMediaTypes
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 withResourceLoader
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 withFonts
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 withFontFaces
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
5
 withGenericFamilies
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
3
 effectiveUserAgentStylesheet
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
1.12
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\HtmlToPdf;
6
7use Phpdftk\FontParser\FontFaceData;
8use Phpdftk\HtmlToPdf\Layout\FontFace;
9use Phpdftk\ResourceLoader\ResourceLoader as HttpResourceLoader;
10
11/**
12 * Configuration for the {@see Renderer}. Immutable; mutate via `with*()`.
13 *
14 * Phase-1 minimum surface: page size (width × height in PDF points),
15 * default font (optional `FontFaceData` — when set, text emission works
16 * end-to-end; when null the painter emits no text, useful for headless
17 * tests), an override for the built-in UA stylesheet, and the strict
18 * mode toggle that promotes `Error`-severity warnings to thrown
19 * exceptions.
20 *
21 * Fields from `docs/plans/contracts.md` deferred to later phases:
22 * `baseUrl` / `securityPolicy` (Phase 1L — image / `@font-face` resolution),
23 * `conformance` (Phase 1N-bis — wire to existing PDF/A profiles),
24 * `cursorAfterAddHtml` (Phase 1N - `Pdf::addHtml` sugar).
25 */
26final readonly class RendererOptions
27{
28    public function __construct(
29        public float $pageWidth = 612.0,
30        public float $pageHeight = 792.0,
31        public ?FontFaceData $defaultFont = null,
32        public ?string $userAgentStylesheet = null,
33        public bool $strict = false,
34        /**
35         * Base directory for resolving relative `<img src>` paths. Required
36         * for local-file image references — without it the painter only
37         * accepts `data:image/{png,jpeg}` URLs. The painter rejects
38         * resolved paths that escape this directory (no `..` walks) so
39         * authors can render templated HTML without arbitrary disk access.
40         */
41        public ?string $baseDir = null,
42        /**
43         * Broader filesystem sandbox the resolved path must remain
44         * under. Defaults to `$baseDir`. Setting it wider (e.g. an
45         * entire test-corpus root while `$baseDir` is the individual
46         * test's directory) lets `../sibling-dir/x.png` resolve
47         * correctly — the WPT pattern where a ref under `reference/`
48         * loads `../support/img.png`. Authors using the public
49         * Renderer API should leave this as null; the harness sets
50         * it explicitly.
51         */
52        public ?string $sandboxRoot = null,
53        /**
54         * Additional fonts available for `font-family` selection, keyed
55         * by family name (case-insensitive). When a cascaded `font-family`
56         * names one of these, that font shapes the run; otherwise the
57         * Renderer falls back to `defaultFont`. The map is normalised to
58         * lower-case keys on construction.
59         *
60         * @var array<string, FontFaceData>
61         */
62        public array $fontMap = [],
63        /**
64         * Multi-face per-family map for CSS Fonts 4 §6 weight/style
65         * matching. Each family name maps to a list of `FontFace`s tagged
66         * with their weight (1-1000) and style (normal|italic|oblique).
67         * When the resolver picks a face from this map, the painter
68         * suppresses the synthetic fake-bold / fake-italic fallbacks that
69         * would otherwise apply over a real face. Populated via
70         * {@see withFontFaces()}; the single-face `$fontMap` continues to
71         * cover the simple case.
72         *
73         * @var array<string, list<FontFace>>
74         */
75        public array $faceMap = [],
76        /**
77         * Optional `phpdftk/resource-loader` for network resource
78         * resolution. When supplied, `<img src="http(s)://...">`,
79         * `<picture><source>`, and (4F.5.1+) `@font-face url()`,
80         * `@import url()`, `background-image: url()`, and
81         * `<iframe>` / `<object>` will resolve through this loader
82         * — which runs the SSRF guard, follows redirects up to
83         * `maxRedirects`, enforces the body cap, strips
84         * Authorization across cross-host hops per RFC 9110 §15.4,
85         * and MIME-sniffs the response. When null (the default),
86         * network hrefs drop silently per the same no-image
87         * outcome that was the pre-4F posture — preserves
88         * existing call-site behaviour byte-for-byte.
89         */
90        public ?HttpResourceLoader $resourceLoader = null,
91        /**
92         * Media types this rendering context matches in `@media`
93         * queries. Defaults to `['print']` (PDF-output target). The
94         * WPT harness sets this to `['print', 'screen']` so author
95         * CSS gated on `@media screen` — by far the most common
96         * shape in browser-targeted test corpora — actually applies
97         * during reftest scoring. The universal `all` always
98         * matches; the type list governs which named buckets do.
99         *
100         * @var list<string>
101         */
102        public array $matchingMediaTypes = ['print'],
103    ) {}
104
105    public function withPageSize(float $width, float $height): self
106    {
107        return new self(
108            $width,
109            $height,
110            $this->defaultFont,
111            $this->userAgentStylesheet,
112            $this->strict,
113            $this->baseDir,
114            $this->sandboxRoot,
115            $this->fontMap,
116            $this->faceMap,
117            $this->resourceLoader,
118            $this->matchingMediaTypes,
119        );
120    }
121
122    public function withDefaultFont(?FontFaceData $font): self
123    {
124        return new self(
125            $this->pageWidth,
126            $this->pageHeight,
127            $font,
128            $this->userAgentStylesheet,
129            $this->strict,
130            $this->baseDir,
131            $this->sandboxRoot,
132            $this->fontMap,
133            $this->faceMap,
134            $this->resourceLoader,
135            $this->matchingMediaTypes,
136        );
137    }
138
139    public function withUserAgentStylesheet(?string $css): self
140    {
141        return new self(
142            $this->pageWidth,
143            $this->pageHeight,
144            $this->defaultFont,
145            $css,
146            $this->strict,
147            $this->baseDir,
148            $this->sandboxRoot,
149            $this->fontMap,
150            $this->faceMap,
151            $this->resourceLoader,
152            $this->matchingMediaTypes,
153        );
154    }
155
156    public function withStrict(bool $strict): self
157    {
158        return new self(
159            $this->pageWidth,
160            $this->pageHeight,
161            $this->defaultFont,
162            $this->userAgentStylesheet,
163            $strict,
164            $this->baseDir,
165            $this->sandboxRoot,
166            $this->fontMap,
167            $this->faceMap,
168            $this->resourceLoader,
169            $this->matchingMediaTypes,
170        );
171    }
172
173    public function withBaseDir(?string $baseDir): self
174    {
175        return new self(
176            $this->pageWidth,
177            $this->pageHeight,
178            $this->defaultFont,
179            $this->userAgentStylesheet,
180            $this->strict,
181            $baseDir,
182            $this->sandboxRoot,
183            $this->fontMap,
184            $this->faceMap,
185            $this->resourceLoader,
186            $this->matchingMediaTypes,
187        );
188    }
189
190    public function withSandboxRoot(?string $sandboxRoot): self
191    {
192        return new self(
193            $this->pageWidth,
194            $this->pageHeight,
195            $this->defaultFont,
196            $this->userAgentStylesheet,
197            $this->strict,
198            $this->baseDir,
199            $sandboxRoot,
200            $this->fontMap,
201            $this->faceMap,
202            $this->resourceLoader,
203            $this->matchingMediaTypes,
204        );
205    }
206
207    /**
208     * Configure which `@media` media types this rendering context
209     * matches. Defaults to `['print']`. Pass `['print', 'screen']`
210     * for a browser-like context (e.g. the WPT harness) where author
211     * CSS gated on `@media screen` should apply. The universal
212     * `all` always matches; this list governs the named ones.
213     *
214     * @param list<string> $types
215     */
216    public function withMatchingMediaTypes(array $types): self
217    {
218        return new self(
219            $this->pageWidth,
220            $this->pageHeight,
221            $this->defaultFont,
222            $this->userAgentStylesheet,
223            $this->strict,
224            $this->baseDir,
225            $this->sandboxRoot,
226            $this->fontMap,
227            $this->faceMap,
228            $this->resourceLoader,
229            $types,
230        );
231    }
232
233    /**
234     * Attach a {@see HttpResourceLoader} for network resource resolution.
235     * Without one, `<img src="https://...">` and other URL-form
236     * references drop silently. When supplied, the loader runs the
237     * SSRF guard, follows redirects, enforces the body cap, and
238     * MIME-sniffs the response — call sites in the painter integrate
239     * via `Painter::resolveImageSrc`.
240     */
241    public function withResourceLoader(?HttpResourceLoader $loader): self
242    {
243        return new self(
244            $this->pageWidth,
245            $this->pageHeight,
246            $this->defaultFont,
247            $this->userAgentStylesheet,
248            $this->strict,
249            $this->baseDir,
250            $this->sandboxRoot,
251            $this->fontMap,
252            $this->faceMap,
253            $loader,
254        );
255    }
256
257    /**
258     * The set of CSS Fonts 4 §6.1 generic family keywords that callers
259     * may bind a concrete font to. Lower-case to match the resolver's
260     * lookup convention; any non-listed key passed to
261     * {@see withGenericFamilies()} raises an exception so typos surface
262     * at configuration time instead of silently going unmatched.
263     */
264    public const GENERIC_FAMILIES = [
265        'serif',
266        'sans-serif',
267        'monospace',
268        'cursive',
269        'fantasy',
270        'system-ui',
271        'ui-serif',
272        'ui-sans-serif',
273        'ui-monospace',
274        'ui-rounded',
275        'emoji',
276        'math',
277        'fangsong',
278    ];
279
280    /**
281     * Replace the font map with the given `family-name → FontFaceData`
282     * mapping. Keys are normalised to lower-case so `font-family: Inter`
283     * and `font-family: inter` resolve the same way.
284     *
285     * Generic-family keywords (`serif`, `sans-serif`, `monospace`,
286     * `cursive`, `fantasy`, `system-ui`, …) are valid keys: a binding for
287     * `monospace` makes the UA stylesheet's `<code>` / `<pre>` rules pick
288     * up that font without the document having to opt in. See
289     * {@see withGenericFamilies()} for a stricter helper.
290     *
291     * @param array<string, FontFaceData> $fonts
292     */
293    public function withFonts(array $fonts): self
294    {
295        $normalised = [];
296        foreach ($fonts as $name => $data) {
297            $normalised[strtolower($name)] = $data;
298        }
299        return new self(
300            $this->pageWidth,
301            $this->pageHeight,
302            $this->defaultFont,
303            $this->userAgentStylesheet,
304            $this->strict,
305            $this->baseDir,
306            $this->sandboxRoot,
307            $normalised,
308            $this->faceMap,
309            $this->resourceLoader,
310            $this->matchingMediaTypes,
311        );
312    }
313
314    /**
315     * Bind one or more `FontFace` lists to family names for CSS Fonts 4
316     * §6 weight + style matching. Each family maps to either a single
317     * `FontFace` or a list of them; the value is normalised to a list so
318     * the resolver always iterates uniformly. Family-name keys are
319     * lower-cased on intake to match the resolver's case-insensitive
320     * lookup. Merges into the existing `faceMap` (so multiple calls add
321     * faces rather than replacing the whole family).
322     *
323     * Pairs with {@see withFonts()} / {@see withGenericFamilies()}: the
324     * resolver checks `faceMap` first (for proper weight/style matching);
325     * if no family there matches, it falls back to the single-face
326     * `fontMap` (treating that face as 400-normal).
327     *
328     * @param array<string, FontFace|list<FontFace>> $families
329     */
330    public function withFontFaces(array $families): self
331    {
332        $merged = $this->faceMap;
333        foreach ($families as $name => $entry) {
334            $key = strtolower($name);
335            $list = is_array($entry) ? array_values($entry) : [$entry];
336            foreach ($list as $face) {
337                if (!$face instanceof FontFace) {
338                    throw new \InvalidArgumentException(sprintf(
339                        'withFontFaces expects FontFace instances; got %s for family "%s"',
340                        get_debug_type($face),
341                        $name,
342                    ));
343                }
344            }
345            $merged[$key] = array_merge($merged[$key] ?? [], $list);
346        }
347        return new self(
348            $this->pageWidth,
349            $this->pageHeight,
350            $this->defaultFont,
351            $this->userAgentStylesheet,
352            $this->strict,
353            $this->baseDir,
354            $this->sandboxRoot,
355            $this->fontMap,
356            $merged,
357            $this->resourceLoader,
358            $this->matchingMediaTypes,
359        );
360    }
361
362    /**
363     * Bind one or more CSS generic-family keywords (`serif`, `sans-serif`,
364     * `monospace`, …) to concrete fonts, *merging* into the existing font
365     * map (unlike {@see withFonts()} which replaces it). Rejects any key
366     * outside {@see GENERIC_FAMILIES} so typos surface immediately —
367     * `withGenericFamilies(['mono' => $f])` raises, forcing the caller to
368     * either fix the spelling or fall back to `withFonts()` for ad-hoc
369     * family names.
370     *
371     * The UA stylesheet maps `<code>` / `<pre>` / `<kbd>` / `<samp>` /
372     * `<tt>` to `font-family: monospace` out of the box, so binding
373     * `monospace` here is the lowest-effort way to switch code blocks to
374     * a fixed-width font without rewriting markup.
375     *
376     * @param array<string, FontFaceData> $generics
377     */
378    public function withGenericFamilies(array $generics): self
379    {
380        $merged = $this->fontMap;
381        foreach ($generics as $name => $data) {
382            $key = strtolower($name);
383            if (!in_array($key, self::GENERIC_FAMILIES, true)) {
384                throw new \InvalidArgumentException(sprintf(
385                    'withGenericFamilies expects a CSS generic family keyword '
386                    . '(%s); got "%s". Use withFonts() for arbitrary family names.',
387                    implode(', ', self::GENERIC_FAMILIES),
388                    $name,
389                ));
390            }
391            $merged[$key] = $data;
392        }
393        return new self(
394            $this->pageWidth,
395            $this->pageHeight,
396            $this->defaultFont,
397            $this->userAgentStylesheet,
398            $this->strict,
399            $this->baseDir,
400            $this->sandboxRoot,
401            $merged,
402            $this->faceMap,
403            $this->resourceLoader,
404            $this->matchingMediaTypes,
405        );
406    }
407
408    /**
409     * Pragmatic built-in UA stylesheet covering the elements the box
410     * generator dispatches on. Returns the override when one was set, or
411     * the built-in default. Phase 1N-bis will grow this to match
412     * browsers' html.css more closely.
413     */
414    public function effectiveUserAgentStylesheet(): string
415    {
416        return $this->userAgentStylesheet ?? <<<'CSS'
417            html, body, address, blockquote, dl, dd, div, fieldset, figcaption, figure,
418            footer, form, h1, h2, h3, h4, h5, h6, header, hr, main, nav, p, pre,
419            section {
420                display: block;
421            }
422            article, aside, hgroup, search { display: block; }
423            /* HTML 5 §4.11.6: `<dialog>` is hidden unless the `open`
424               attribute is set; opening is JS-driven so a static print
425               render shows nothing by default. */
426            dialog { display: none; }
427            dialog[open] { display: block; }
428            /* HTML 5 §3.2.6.1: the `hidden` attribute hides any element.
429               `hidden="until-found"` is the find-in-page reveal mode —
430               it stays hidden in static print just like the bare form. */
431            [hidden] { display: none; }
432            [hidden="until-found"] { display: none; }
433            /* HTML 5 §4.12.3: `<template>` content is inert and never
434               renders directly. */
435            template { display: none; }
436            menu { display: block; padding-left: 24pt; }
437            ul, ol { display: block; padding-left: 24pt; }
438            li { display: list-item; }
439            span, a, b, i, em, strong, code, small, big, sub, sup, label, mark,
440            del, ins, q, abbr, cite, var, kbd, samp, time, output {
441                display: inline;
442            }
443            img, button, input, select, textarea, svg, math { display: inline-block; }
444            input, select, textarea {
445                border: 1px solid #888;
446                padding: 2pt 4pt;
447                font-family: monospace;
448            }
449            /* HTML 5 §4.10.11: `<textarea>` preserves its whitespace
450               (including line breaks) and renders as a multi-line text
451               area. `pre-wrap` preserves runs of whitespace and wraps
452               long lines at the element's content edge. */
453            textarea { white-space: pre-wrap; }
454            /* HTML 5 §4.10.7: `<option>` content is rendered by the
455               `<select>` host (BoxGenerator picks the selected one);
456               options never paint on their own. */
457            option { display: none; }
458            button {
459                border: 1px solid #888;
460                background-color: #eee;
461                padding: 2pt 8pt;
462                border-radius: 3pt;
463            }
464            table { display: table; }
465            tr { display: table-row; }
466            td, th { display: table-cell; padding: 2pt; vertical-align: top; }
467            th { font-weight: bold; text-align: center; }
468            thead, tbody, tfoot, caption { display: block; }
469            /* HTML 5 §4.9.1-3 — `<col>` / `<colgroup>` are layout-only
470               carriers for per-column declarations (width, background,
471               border). They never paint their own content, but their
472               cascaded values feed the table column-width pass —
473               BlockLayout's `collectColumnWidths` reads them when
474               `table-layout: fixed`. Generating a `TableColumnBox`
475               keeps the cascade reachable; the box itself is a layout
476               no-op. */
477            colgroup { display: table-column-group; }
478            col { display: table-column; }
479            head, script, style, title, meta, link, base { display: none; }
480            /* HTML 5 §4.5.27 — `<wbr>` (Word Break Opportunity) is a
481               zero-width inline that just marks a permissible line
482               break. Rendering it as `inline` with zero content keeps
483               the inline flow intact; the U+200B zero-width-space
484               line-break opportunity emitted by the BoxGenerator does
485               the actual break. */
486            wbr { display: inline; }
487            /* HTML 5 §4.8.13 — `<map>` defines image-clickable regions
488               via nested `<area>` children. The map itself is an
489               inline container; the area elements never render in
490               print (interactive hotspots are display-time concerns). */
491            map { display: inline; }
492            area { display: none; }
493            /* HTML 5 §4.12.1 — `<noscript>` content is meant for UAs
494               with scripting disabled. Static print is effectively
495               script-less, so the children render as inline content. */
496            noscript { display: inline; }
497
498            /* Headings — sizes / margins per browsers' html.css. */
499            h1 { font-size: 32px; font-weight: bold; margin: 21px 0; }
500            h2 { font-size: 24px; font-weight: bold; margin: 19px 0; }
501            h3 { font-size: 19px; font-weight: bold; margin: 19px 0; }
502            h4 { font-size: 16px; font-weight: bold; margin: 21px 0; }
503            h5 { font-size: 13px; font-weight: bold; margin: 22px 0; }
504            h6 { font-size: 11px; font-weight: bold; margin: 25px 0; }
505
506            /* Paragraph and inline emphasis. */
507            p { margin: 16px 0; }
508            b, strong { font-weight: bold; }
509            i, em, cite, var, dfn { font-style: italic; }
510            small { font-size: 0.83em; }
511            big { font-size: 1.17em; }
512            sub { vertical-align: sub; font-size: 0.83em; }
513            sup { vertical-align: super; font-size: 0.83em; }
514
515            /* Code & preformatted. */
516            code, kbd, samp, tt { font-family: monospace; }
517            pre { font-family: monospace; margin: 16px 0; white-space: pre; }
518
519            /* Lists. */
520            ul, ol { margin: 16px 0; }
521            ol { list-style-type: decimal; }
522            ul ul, ol ul { list-style-type: circle; }
523            ul ul ul, ol ul ul { list-style-type: square; }
524
525            /* Block-level wrappers. */
526            blockquote { margin: 16px 40px; }
527            hr { display: block; border-top: 1px solid; margin: 8px 0; }
528
529            /* Anchors. */
530            a { color: #0033cc; text-decoration: underline; }
531
532            /* Other inline semantics. */
533            mark { background-color: #ffff00; color: #000; }
534            u, ins { text-decoration: underline; }
535            s, strike, del { text-decoration: line-through; }
536            abbr { text-decoration: underline; text-decoration-style: dotted; }
537
538            /* HTML 5 §4.5.6 — `<address>` carries contact info; the
539               typographic convention is italic. */
540            address { font-style: italic; }
541
542            /* HTML 5 §15.3 — bidi element UA defaults. `<bdo>`
543               overrides the bidi algorithm for its descendants;
544               `<bdi>` isolates them so surrounding text's bidi
545               doesn't bleed in / out. */
546            bdo { unicode-bidi: bidi-override; }
547            bdi { unicode-bidi: isolate; }
548            /* HTML 5 §15.3 maps the `dir` attribute to CSS
549               direction + bidi isolation. `:where(...)` keeps the
550               attribute-selector specificity at 0 so the bdo rule
551               above still wins for `<bdo>`. */
552            [dir="ltr"] { direction: ltr; }
553            [dir="rtl"] { direction: rtl; }
554            :where([dir="ltr"], [dir="rtl"]) { unicode-bidi: isolate; }
555            :where([dir="auto"]) { unicode-bidi: plaintext; }
556
557            /* Definition lists. */
558            dl { margin: 16px 0; }
559            dt { font-weight: bold; }
560            dd { margin-left: 40px; }
561
562            /* Figure / figcaption. */
563            figure { margin: 16px 40px; }
564            figcaption { font-size: 0.9em; }
565
566            /* Details / summary (HTML 5 §4.11.1). Closed by default —
567               only the summary renders — until the [open] attribute
568               flips the visibility. Print authors who want a permanent
569               open disclosure either set [open] on the tag or override
570               with their own CSS.
571
572               The `▶ ` / `▼ ` triangle markers come from the
573               `summary::before` pseudo-element. Browsers render this
574               as a real `::marker` box, but our pseudo-element pipeline
575               already handles `::before`, so the visual outcome is the
576               same: a triangle prefix on the summary text. Authors
577               can hide it via `summary::before { content: none; }`. */
578            details, summary { display: block; }
579            summary { font-weight: bold; }
580            summary::before { content: "\25B6  "; }
581            details[open] > summary::before { content: "\25BC  "; }
582            details > * { display: none; }
583            details > summary { display: block; }
584            details[open] > * { display: block; }
585
586            /* `<q>` inline quotes — wrap content in straight double quotes
587               per the open-quote / close-quote Phase-1 simplification. */
588            q::before { content: open-quote; }
589            q::after { content: close-quote; }
590
591            /* `<picture>` is a transparent wrapper around an `<img>`;
592               `<source>` carries media-query metadata we can't evaluate
593               without JS, so it's hidden. The contained `<img>` renders
594               normally. */
595            picture { display: inline; }
596            source, track, param { display: none; }
597
598            /* HTML 5 §4.10.10 — `<datalist>` is a typeahead helper
599               for `<input>` and never renders on its own. */
600            datalist { display: none; }
601
602            /* HTML 5 §4.10.13 + §4.10.14 — `<meter>` and `<progress>`
603               are inline-block widgets. We don't paint the actual
604               bar / gauge (Phase 2 with proper widget rendering),
605               but the inline-block treatment ensures any text
606               children (the fallback value) flow inline. */
607            meter, progress { display: inline-block; }
608
609            /* HTML 5 §4.10.15 — `<fieldset>` is a labelled form
610               group with a thin border + small inset padding.
611               Legend positioning over the top border is Phase 2;
612               Phase 1 renders legend as a regular block child. */
613            fieldset { border: 1px solid #888; padding: 6pt 9pt 8pt; margin: 0 2pt; }
614            legend { display: block; padding: 0 2pt; }
615
616            /* HTML 5 §4.12.5 — `<canvas>` is a script-driven raster
617               surface. With no scripting it renders its fallback
618               children inline-block. */
619            canvas { display: inline-block; }
620            /* HTML 5 §4.5.21 — `<rp>` is the ruby-parenthesis
621               fallback for browsers without ruby layout support; in
622               browsers that DO support ruby it's `display: none`.
623               Phase 1 doesn't paint ruby annotations yet, so we keep
624               `<rp>` hidden to match the spec convention rather than
625               showing it as visible parentheses. */
626            rp { display: none; }
627
628            /* CSS Fragmentation 4 §3.2 — `break-inside: avoid` on
629               atomic content so a single row / quote / heading / image
630               that fits on a single page never straddles a page
631               boundary. Authors override with `break-inside: auto` on
632               structurally tall content (e.g. a multi-page <pre> block). */
633            tr, figure, blockquote, pre, img,
634            h1, h2, h3, h4, h5, h6 { break-inside: avoid; }
635        CSS;
636    }
637}