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 §15.3.1 — `<embed hidden>` keeps an INLINE box of
434               zero size instead of `display: none`. The distinction
435               matters because an author `embed { display: block }`
436               overrides a UA `display` (author origin beats UA) but
437               leaves the zero width/height in place, so the plugin
438               area still collapses to nothing. */
439            embed[hidden] { display: inline; height: 0; width: 0; }
440            /* HTML §15.3.1 — an `<audio>` with no `controls` has no
441               rendering at all (there is nothing to show without the
442               transport bar). With `controls` it is a replaced inline
443               box like `<video>`. */
444            audio:not([controls]) { display: none; }
445            /* HTML 5 §4.12.3: `<template>` content is inert and never
446               renders directly. */
447            template { display: none; }
448            /* HTML §15.3.7 — list indentation is 40px, not 24pt (32px). */
449            menu { display: block; padding-left: 40px; }
450            ul, ol { display: block; padding-left: 40px; }
451            li { display: list-item; }
452            span, a, b, i, em, strong, code, small, big, sub, sup, label, mark,
453            del, ins, q, abbr, cite, var, kbd, samp, time, output {
454                display: inline;
455            }
456            img, button, input, select, textarea, svg, math { display: inline-block; }
457            /* HTML §15.3.3 (Replaced elements) — an `<iframe>` hosts a
458               NESTED BROWSING CONTEXT: it is a replaced element whose
459               "image" is another document, laid out in the iframe's own
460               content box and clipped to it. The UA border is the
461               familiar 2px inset frame, and `overflow: clip` is what
462               makes the embedded document stop at the frame edge instead
463               of spilling into the host page. */
464            iframe { display: inline-block; border: 2px inset; overflow: clip; }
465            /* SVG 2 §8.2 / CSS Overflow 3 — the SVG viewport establishes a
466               clipping region: content outside the `width`×`height` box is
467               clipped by default. Modelled as `overflow: clip` so authored
468               per-axis `overflow-x/y: visible` and `overflow-clip-margin`
469               compose through the standard overflow machinery. */
470            svg { overflow: clip; }
471            input, select, textarea {
472                border: 1px solid #888;
473                padding: 2pt 4pt;
474                font-family: monospace;
475            }
476            /* HTML 5 §4.10.11: `<textarea>` preserves its whitespace
477               (including line breaks) and renders as a multi-line text
478               area. `pre-wrap` preserves runs of whitespace and wraps
479               long lines at the element's content edge. */
480            textarea { white-space: pre-wrap; }
481            /* HTML 5 §4.10.7: `<option>` content is rendered by the
482               `<select>` host (BoxGenerator picks the selected one);
483               options never paint on their own. */
484            option { display: none; }
485            button {
486                border: 1px solid #888;
487                background-color: #eee;
488                padding: 2pt 8pt;
489                border-radius: 3pt;
490            }
491            /* HTML §15.3.8 (Tables) — the UA defaults are
492               `border-spacing: 2px` on the table and `padding: 1px` on
493               cells. The `cellspacing` / `cellpadding` presentational
494               attributes override them (mapped in BoxGenerator). A
495               larger cell padding here silently offset every `<td>` by
496               ~3px against an equivalent `display: table-cell` box,
497               which is exactly what the CSS2 anonymous-table reftests
498               overlay to detect. */
499            table { display: table; border-spacing: 2px; }
500            tr { display: table-row; }
501            td, th { display: table-cell; padding: 1px; vertical-align: top; }
502            th { font-weight: bold; text-align: center; }
503            thead, tbody, tfoot, caption { display: block; }
504            /* HTML 5 §4.9.1-3 — `<col>` / `<colgroup>` are layout-only
505               carriers for per-column declarations (width, background,
506               border). They never paint their own content, but their
507               cascaded values feed the table column-width pass —
508               BlockLayout's `collectColumnWidths` reads them when
509               `table-layout: fixed`. Generating a `TableColumnBox`
510               keeps the cascade reachable; the box itself is a layout
511               no-op. */
512            colgroup { display: table-column-group; }
513            col { display: table-column; }
514            head, script, style, title, meta, link, base { display: none; }
515            /* HTML 5 §4.5.27 — `<wbr>` (Word Break Opportunity) is a
516               zero-width inline that just marks a permissible line
517               break. Rendering it as `inline` with zero content keeps
518               the inline flow intact; the U+200B zero-width-space
519               line-break opportunity emitted by the BoxGenerator does
520               the actual break. */
521            wbr { display: inline; }
522            /* HTML 5 §4.8.13 — `<map>` defines image-clickable regions
523               via nested `<area>` children. The map itself is an
524               inline container; the area elements never render in
525               print (interactive hotspots are display-time concerns). */
526            map { display: inline; }
527            area { display: none; }
528            /* HTML 5 §4.12.1 — `<noscript>` content is meant for UAs
529               with scripting disabled. Static print is effectively
530               script-less, so the children render as inline content. */
531            noscript { display: inline; }
532
533            /* Headings — HTML §15.3.6. The sizes and margins are `em`,
534               not px: a heading's font-size is relative to its parent's
535               and its margins to its OWN font-size, so both scale with
536               the document. Hard-coded px matched a 16px base and
537               silently stopped scaling for anything else. */
538            /* HTML §15.3.3 — the block-direction UA margins are LOGICAL
539               (`margin-block-*`), so they run along the writing mode's
540               block axis instead of always being vertical. */
541            h1 { font-size: 2em; font-weight: bold; margin: 0; margin-block-start: 0.67em; margin-block-end: 0.67em; }
542            h2 { font-size: 1.5em; font-weight: bold; margin: 0; margin-block-start: 0.83em; margin-block-end: 0.83em; }
543            h3 { font-size: 1.17em; font-weight: bold; margin: 0; margin-block-start: 1em; margin-block-end: 1em; }
544            h4 { font-weight: bold; margin: 0; margin-block-start: 1.33em; margin-block-end: 1.33em; }
545            h5 { font-size: 0.83em; font-weight: bold; margin: 0; margin-block-start: 1.67em; margin-block-end: 1.67em; }
546            h6 { font-size: 0.67em; font-weight: bold; margin: 0; margin-block-start: 2.33em; margin-block-end: 2.33em; }
547
548            /* Paragraph and inline emphasis. */
549            p { margin: 0; margin-block-start: 1em; margin-block-end: 1em; }
550            b, strong { font-weight: bold; }
551            i, em, cite, var, dfn { font-style: italic; }
552            small { font-size: 0.83em; }
553            big { font-size: 1.17em; }
554            sub { vertical-align: sub; font-size: 0.83em; }
555            sup { vertical-align: super; font-size: 0.83em; }
556
557            /* Code & preformatted. */
558            code, kbd, samp, tt { font-family: monospace; }
559            pre { font-family: monospace; margin: 0; margin-block-start: 1em; margin-block-end: 1em; white-space: pre; }
560
561            /* Lists. */
562            ul, ol { margin: 0; margin-block-start: 1em; margin-block-end: 1em; }
563            ol { list-style-type: decimal; }
564            ul ul, ol ul { list-style-type: circle; }
565            ul ul ul, ol ul ul { list-style-type: square; }
566            /* HTML §15.3.9 — the legacy `type` presentational hints, as
567               UA-origin rules so author CSS still wins outright and an
568               `<li type>` still beats what the `li` would INHERIT from a
569               styled list.
570
571               The flags are load-bearing, not decoration. `type` is on
572               HTML's ASCII-case-insensitive attribute list (§15.2), so the
573               ORDERED keywords need an explicit `s` or `type=a` and
574               `type=A` would collapse onto one counter style. The BULLET
575               keywords want the opposite and carry `i`.
576
577               Each keyword is bound to the element that owns it: there is
578               deliberately no `ol[type=circle]` or `ul[type="1"]`, so a
579               keyword written on the wrong list element matches nothing
580               and the list keeps its default marker. */
581            ol[type="1"], li[type="1"] { list-style-type: decimal; }
582            ol[type="a" s], li[type="a" s] { list-style-type: lower-alpha; }
583            ol[type="A" s], li[type="A" s] { list-style-type: upper-alpha; }
584            ol[type="i" s], li[type="i" s] { list-style-type: lower-roman; }
585            ol[type="I" s], li[type="I" s] { list-style-type: upper-roman; }
586            ul[type="none" i], li[type="none" i] { list-style-type: none; }
587            ul[type="disc" i], li[type="disc" i] { list-style-type: disc; }
588            ul[type="circle" i], li[type="circle" i] { list-style-type: circle; }
589            ul[type="square" i], li[type="square" i] { list-style-type: square; }
590
591            /* Block-level wrappers. */
592            blockquote { margin: 0; margin-block-start: 1em; margin-block-end: 1em;
593                         margin-inline-start: 40px; margin-inline-end: 40px; }
594            /* HTML §15.3.5 — an `<hr>` is a zero-height box with a 1px
595               INSET border on all four sides (which is what gives the
596               familiar two-tone rule), not a single top border. */
597            hr { display: block; color: gray; border: 1px inset; margin: 0.5em auto; overflow: hidden; }
598
599            /* Anchors. */
600            a { color: #0033cc; text-decoration: underline; }
601
602            /* Other inline semantics. */
603            mark { background-color: #ffff00; color: #000; }
604            u, ins { text-decoration: underline; }
605            s, strike, del { text-decoration: line-through; }
606            abbr { text-decoration: underline; text-decoration-style: dotted; }
607
608            /* HTML 5 §4.5.6 — `<address>` carries contact info; the
609               typographic convention is italic. */
610            address { font-style: italic; }
611
612            /* HTML 5 §15.3 — bidi element UA defaults. `<bdo>`
613               overrides the bidi algorithm for its descendants;
614               `<bdi>` isolates them so surrounding text's bidi
615               doesn't bleed in / out. */
616            bdo { unicode-bidi: bidi-override; }
617            bdi { unicode-bidi: isolate; }
618            /* HTML 5 §15.3 maps the `dir` attribute to CSS
619               direction + bidi isolation. `:where(...)` keeps the
620               attribute-selector specificity at 0 so the bdo rule
621               above still wins for `<bdo>`. */
622            [dir="ltr"] { direction: ltr; }
623            [dir="rtl"] { direction: rtl; }
624            :where([dir="ltr"], [dir="rtl"]) { unicode-bidi: isolate; }
625            :where([dir="auto"]) { unicode-bidi: plaintext; }
626
627            /* Definition lists. */
628            dl { margin: 1em 0; }
629            dt { font-weight: bold; }
630            dd { margin-left: 40px; }
631
632            /* Figure / figcaption. */
633            figure { margin: 1em 40px; }
634            /* No UA font-size on figcaption — HTML §15.3.9 gives it
635               `display: block` only, so it inherits its parent's size. */
636            figcaption { display: block; }
637
638            /* Details / summary — HTML §15.3.11 verbatim. Closed by
639               default (only the summary renders) until the [open]
640               attribute flips the visibility. Print authors who want a
641               permanent open disclosure either set [open] on the tag or
642               override with their own CSS.
643
644               The `▶` / `▼` disclosure triangle is the summary's real
645               `::marker`, produced by `display: list-item` plus the
646               `disclosure-closed` / `disclosure-open` counter styles —
647               NOT a `::before`. That distinction is load-bearing: it is
648               what lets an author hide the triangle the way the spec
649               says to, with `summary { list-style-type: none }`, and it
650               scopes the marker to the FIRST summary of a details (a
651               bare `<summary>` outside a details has no marker at all).
652
653               Everything in the details except that first summary is
654               slotted into `::details-content`, whose
655               `content-visibility` is what hides the closed content —
656               so an author can force it visible on a closed details,
657               and `display: contents` on it lets summary and content
658               share a line. */
659            details, summary { display: block; }
660            details > summary:first-of-type {
661                display: list-item;
662                counter-increment: list-item 0;
663                list-style: disclosure-closed inside;
664            }
665            details[open] > summary:first-of-type { list-style-type: disclosure-open; }
666            details::details-content { display: block; content-visibility: hidden; }
667            details[open]::details-content { content-visibility: visible; }
668
669            /* `<q>` inline quotes — wrap content in straight double quotes
670               per the open-quote / close-quote Phase-1 simplification. */
671            q::before { content: open-quote; }
672            q::after { content: close-quote; }
673
674            /* `<picture>` is a transparent wrapper around an `<img>`;
675               `<source>` carries media-query metadata we can't evaluate
676               without JS, so it's hidden. The contained `<img>` renders
677               normally. */
678            picture { display: inline; }
679            source, track, param { display: none; }
680
681            /* HTML 5 §4.10.10 — `<datalist>` is a typeahead helper
682               for `<input>` and never renders on its own. */
683            datalist { display: none; }
684
685            /* HTML 5 §4.10.13 + §4.10.14 — `<meter>` and `<progress>`
686               are inline-block widgets. We don't paint the actual
687               bar / gauge (Phase 2 with proper widget rendering),
688               but the inline-block treatment ensures any text
689               children (the fallback value) flow inline. */
690            meter, progress { display: inline-block; }
691
692            /* HTML 5 §4.10.15 — `<fieldset>` is a labelled form
693               group with a thin border + small inset padding.
694               Legend positioning over the top border is Phase 2;
695               Phase 1 renders legend as a regular block child. */
696            /* HTML §15.3.8 — fieldset padding is em-relative and its
697               inline margin 2px; legend takes 2px of inline padding. */
698            fieldset { border: 1px solid #888; padding: 0.35em 0.75em 0.625em; margin: 0 2px; }
699            legend { display: block; padding: 0 2px; }
700
701            /* HTML 5 §4.12.5 — `<canvas>` is a script-driven raster
702               surface. With no scripting it renders its fallback
703               children inline-block. */
704            canvas { display: inline-block; }
705            /* HTML 5 §4.5.21 — `<rp>` is the ruby-parenthesis
706               fallback for browsers without ruby layout support; in
707               browsers that DO support ruby it's `display: none`.
708               Phase 1 doesn't paint ruby annotations yet, so we keep
709               `<rp>` hidden to match the spec convention rather than
710               showing it as visible parentheses. */
711            rp { display: none; }
712
713            /* CSS Fragmentation 4 §3.2 — `break-inside: avoid` on
714               atomic content so a single row / quote / heading / image
715               that fits on a single page never straddles a page
716               boundary. Authors override with `break-inside: auto` on
717               structurally tall content (e.g. a multi-page <pre> block). */
718            tr, figure, blockquote, pre, img,
719            h1, h2, h3, h4, h5, h6 { break-inside: avoid; }
720        CSS;
721    }
722}