Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.91% covered (warning)
89.91%
98 / 109
80.00% covered (warning)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
RendererOptions
89.91% covered (warning)
89.91%
98 / 109
80.00% covered (warning)
80.00%
8 / 10
17.30
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%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 withDefaultFont
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 withUserAgentStylesheet
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 withStrict
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 withBaseDir
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 withFonts
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 withFontFaces
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
5
 withGenericFamilies
100.00% covered (success)
100.00%
21 / 21
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\OpenTypeData;
8use Phpdftk\HtmlToPdf\Layout\FontFace;
9
10/**
11 * Configuration for the {@see Renderer}. Immutable; mutate via `with*()`.
12 *
13 * Phase-1 minimum surface: page size (width × height in PDF points),
14 * default font (optional `OpenTypeData` — when set, text emission works
15 * end-to-end; when null the painter emits no text, useful for headless
16 * tests), an override for the built-in UA stylesheet, and the strict
17 * mode toggle that promotes `Error`-severity warnings to thrown
18 * exceptions.
19 *
20 * Fields from `docs/plans/contracts.md` deferred to later phases:
21 * `baseUrl` / `securityPolicy` (Phase 1L — image / `@font-face` resolution),
22 * `conformance` (Phase 1N-bis — wire to existing PDF/A profiles),
23 * `cursorAfterAddHtml` (Phase 1N - `Pdf::addHtml` sugar).
24 */
25final readonly class RendererOptions
26{
27    public function __construct(
28        public float $pageWidth = 612.0,
29        public float $pageHeight = 792.0,
30        public ?OpenTypeData $defaultFont = null,
31        public ?string $userAgentStylesheet = null,
32        public bool $strict = false,
33        /**
34         * Base directory for resolving relative `<img src>` paths. Required
35         * for local-file image references — without it the painter only
36         * accepts `data:image/{png,jpeg}` URLs. The painter rejects
37         * resolved paths that escape this directory (no `..` walks) so
38         * authors can render templated HTML without arbitrary disk access.
39         */
40        public ?string $baseDir = null,
41        /**
42         * Additional fonts available for `font-family` selection, keyed
43         * by family name (case-insensitive). When a cascaded `font-family`
44         * names one of these, that font shapes the run; otherwise the
45         * Renderer falls back to `defaultFont`. The map is normalised to
46         * lower-case keys on construction.
47         *
48         * @var array<string, OpenTypeData>
49         */
50        public array $fontMap = [],
51        /**
52         * Multi-face per-family map for CSS Fonts 4 §6 weight/style
53         * matching. Each family name maps to a list of `FontFace`s tagged
54         * with their weight (1-1000) and style (normal|italic|oblique).
55         * When the resolver picks a face from this map, the painter
56         * suppresses the synthetic fake-bold / fake-italic fallbacks that
57         * would otherwise apply over a real face. Populated via
58         * {@see withFontFaces()}; the single-face `$fontMap` continues to
59         * cover the simple case.
60         *
61         * @var array<string, list<FontFace>>
62         */
63        public array $faceMap = [],
64    ) {}
65
66    public function withPageSize(float $width, float $height): self
67    {
68        return new self(
69            $width,
70            $height,
71            $this->defaultFont,
72            $this->userAgentStylesheet,
73            $this->strict,
74            $this->baseDir,
75            $this->fontMap,
76            $this->faceMap,
77        );
78    }
79
80    public function withDefaultFont(?OpenTypeData $font): self
81    {
82        return new self(
83            $this->pageWidth,
84            $this->pageHeight,
85            $font,
86            $this->userAgentStylesheet,
87            $this->strict,
88            $this->baseDir,
89            $this->fontMap,
90            $this->faceMap,
91        );
92    }
93
94    public function withUserAgentStylesheet(?string $css): self
95    {
96        return new self(
97            $this->pageWidth,
98            $this->pageHeight,
99            $this->defaultFont,
100            $css,
101            $this->strict,
102            $this->baseDir,
103            $this->fontMap,
104            $this->faceMap,
105        );
106    }
107
108    public function withStrict(bool $strict): self
109    {
110        return new self(
111            $this->pageWidth,
112            $this->pageHeight,
113            $this->defaultFont,
114            $this->userAgentStylesheet,
115            $strict,
116            $this->baseDir,
117            $this->fontMap,
118            $this->faceMap,
119        );
120    }
121
122    public function withBaseDir(?string $baseDir): self
123    {
124        return new self(
125            $this->pageWidth,
126            $this->pageHeight,
127            $this->defaultFont,
128            $this->userAgentStylesheet,
129            $this->strict,
130            $baseDir,
131            $this->fontMap,
132            $this->faceMap,
133        );
134    }
135
136    /**
137     * The set of CSS Fonts 4 §6.1 generic family keywords that callers
138     * may bind a concrete font to. Lower-case to match the resolver's
139     * lookup convention; any non-listed key passed to
140     * {@see withGenericFamilies()} raises an exception so typos surface
141     * at configuration time instead of silently going unmatched.
142     */
143    public const GENERIC_FAMILIES = [
144        'serif',
145        'sans-serif',
146        'monospace',
147        'cursive',
148        'fantasy',
149        'system-ui',
150        'ui-serif',
151        'ui-sans-serif',
152        'ui-monospace',
153        'ui-rounded',
154        'emoji',
155        'math',
156        'fangsong',
157    ];
158
159    /**
160     * Replace the font map with the given `family-name → OpenTypeData`
161     * mapping. Keys are normalised to lower-case so `font-family: Inter`
162     * and `font-family: inter` resolve the same way.
163     *
164     * Generic-family keywords (`serif`, `sans-serif`, `monospace`,
165     * `cursive`, `fantasy`, `system-ui`, …) are valid keys: a binding for
166     * `monospace` makes the UA stylesheet's `<code>` / `<pre>` rules pick
167     * up that font without the document having to opt in. See
168     * {@see withGenericFamilies()} for a stricter helper.
169     *
170     * @param array<string, OpenTypeData> $fonts
171     */
172    public function withFonts(array $fonts): self
173    {
174        $normalised = [];
175        foreach ($fonts as $name => $data) {
176            $normalised[strtolower($name)] = $data;
177        }
178        return new self(
179            $this->pageWidth,
180            $this->pageHeight,
181            $this->defaultFont,
182            $this->userAgentStylesheet,
183            $this->strict,
184            $this->baseDir,
185            $normalised,
186            $this->faceMap,
187        );
188    }
189
190    /**
191     * Bind one or more `FontFace` lists to family names for CSS Fonts 4
192     * §6 weight + style matching. Each family maps to either a single
193     * `FontFace` or a list of them; the value is normalised to a list so
194     * the resolver always iterates uniformly. Family-name keys are
195     * lower-cased on intake to match the resolver's case-insensitive
196     * lookup. Merges into the existing `faceMap` (so multiple calls add
197     * faces rather than replacing the whole family).
198     *
199     * Pairs with {@see withFonts()} / {@see withGenericFamilies()}: the
200     * resolver checks `faceMap` first (for proper weight/style matching);
201     * if no family there matches, it falls back to the single-face
202     * `fontMap` (treating that face as 400-normal).
203     *
204     * @param array<string, FontFace|list<FontFace>> $families
205     */
206    public function withFontFaces(array $families): self
207    {
208        $merged = $this->faceMap;
209        foreach ($families as $name => $entry) {
210            $key = strtolower($name);
211            $list = is_array($entry) ? array_values($entry) : [$entry];
212            foreach ($list as $face) {
213                if (!$face instanceof FontFace) {
214                    throw new \InvalidArgumentException(sprintf(
215                        'withFontFaces expects FontFace instances; got %s for family "%s"',
216                        get_debug_type($face),
217                        $name,
218                    ));
219                }
220            }
221            $merged[$key] = array_merge($merged[$key] ?? [], $list);
222        }
223        return new self(
224            $this->pageWidth,
225            $this->pageHeight,
226            $this->defaultFont,
227            $this->userAgentStylesheet,
228            $this->strict,
229            $this->baseDir,
230            $this->fontMap,
231            $merged,
232        );
233    }
234
235    /**
236     * Bind one or more CSS generic-family keywords (`serif`, `sans-serif`,
237     * `monospace`, …) to concrete fonts, *merging* into the existing font
238     * map (unlike {@see withFonts()} which replaces it). Rejects any key
239     * outside {@see GENERIC_FAMILIES} so typos surface immediately —
240     * `withGenericFamilies(['mono' => $f])` raises, forcing the caller to
241     * either fix the spelling or fall back to `withFonts()` for ad-hoc
242     * family names.
243     *
244     * The UA stylesheet maps `<code>` / `<pre>` / `<kbd>` / `<samp>` /
245     * `<tt>` to `font-family: monospace` out of the box, so binding
246     * `monospace` here is the lowest-effort way to switch code blocks to
247     * a fixed-width font without rewriting markup.
248     *
249     * @param array<string, OpenTypeData> $generics
250     */
251    public function withGenericFamilies(array $generics): self
252    {
253        $merged = $this->fontMap;
254        foreach ($generics as $name => $data) {
255            $key = strtolower($name);
256            if (!in_array($key, self::GENERIC_FAMILIES, true)) {
257                throw new \InvalidArgumentException(sprintf(
258                    'withGenericFamilies expects a CSS generic family keyword '
259                    . '(%s); got "%s". Use withFonts() for arbitrary family names.',
260                    implode(', ', self::GENERIC_FAMILIES),
261                    $name,
262                ));
263            }
264            $merged[$key] = $data;
265        }
266        return new self(
267            $this->pageWidth,
268            $this->pageHeight,
269            $this->defaultFont,
270            $this->userAgentStylesheet,
271            $this->strict,
272            $this->baseDir,
273            $merged,
274            $this->faceMap,
275        );
276    }
277
278    /**
279     * Pragmatic built-in UA stylesheet covering the elements the box
280     * generator dispatches on. Returns the override when one was set, or
281     * the built-in default. Phase 1N-bis will grow this to match
282     * browsers' html.css more closely.
283     */
284    public function effectiveUserAgentStylesheet(): string
285    {
286        return $this->userAgentStylesheet ?? <<<'CSS'
287            html, body, address, blockquote, dl, dd, div, fieldset, figcaption, figure,
288            footer, form, h1, h2, h3, h4, h5, h6, header, hr, main, nav, p, pre,
289            section {
290                display: block;
291            }
292            article, aside, hgroup, search { display: block; }
293            /* HTML 5 §4.11.6: `<dialog>` is hidden unless the `open`
294               attribute is set; opening is JS-driven so a static print
295               render shows nothing by default. */
296            dialog { display: none; }
297            dialog[open] { display: block; }
298            /* HTML 5 §3.2.6.1: the `hidden` attribute hides any element. */
299            [hidden] { display: none; }
300            /* HTML 5 §4.12.3: `<template>` content is inert and never
301               renders directly. */
302            template { display: none; }
303            menu { display: block; padding-left: 24pt; }
304            ul, ol { display: block; padding-left: 24pt; }
305            li { display: list-item; }
306            span, a, b, i, em, strong, code, small, big, sub, sup, label, mark,
307            del, ins, q, abbr, cite, var, kbd, samp, time, output {
308                display: inline;
309            }
310            img, button, input, select, textarea, svg { display: inline-block; }
311            input, select, textarea {
312                border: 1px solid #888;
313                padding: 2pt 4pt;
314                font-family: monospace;
315            }
316            /* HTML 5 §4.10.11: `<textarea>` preserves its whitespace
317               (including line breaks) and renders as a multi-line text
318               area. `pre-wrap` preserves runs of whitespace and wraps
319               long lines at the element's content edge. */
320            textarea { white-space: pre-wrap; }
321            /* HTML 5 §4.10.7: `<option>` content is rendered by the
322               `<select>` host (BoxGenerator picks the selected one);
323               options never paint on their own. */
324            option { display: none; }
325            button {
326                border: 1px solid #888;
327                background-color: #eee;
328                padding: 2pt 8pt;
329                border-radius: 3pt;
330            }
331            table { display: table; }
332            tr { display: table-row; }
333            td, th { display: table-cell; padding: 2pt; vertical-align: top; }
334            th { font-weight: bold; text-align: center; }
335            thead, tbody, tfoot, caption { display: block; }
336            colgroup, col { display: none; }
337            head, script, style, title, meta, link, base { display: none; }
338            /* HTML 5 §4.5.27 — `<wbr>` (Word Break Opportunity) is a
339               zero-width inline that just marks a permissible line
340               break. Rendering it as `inline` with zero content keeps
341               the inline flow intact; the U+200B zero-width-space
342               line-break opportunity emitted by the BoxGenerator does
343               the actual break. */
344            wbr { display: inline; }
345            /* HTML 5 §4.8.13 — `<map>` defines image-clickable regions
346               via nested `<area>` children. The map itself is an
347               inline container; the area elements never render in
348               print (interactive hotspots are display-time concerns). */
349            map { display: inline; }
350            area { display: none; }
351            /* HTML 5 §4.12.1 — `<noscript>` content is meant for UAs
352               with scripting disabled. Static print is effectively
353               script-less, so the children render as inline content. */
354            noscript { display: inline; }
355
356            /* Headings — sizes / margins per browsers' html.css. */
357            h1 { font-size: 32px; font-weight: bold; margin: 21px 0; }
358            h2 { font-size: 24px; font-weight: bold; margin: 19px 0; }
359            h3 { font-size: 19px; font-weight: bold; margin: 19px 0; }
360            h4 { font-size: 16px; font-weight: bold; margin: 21px 0; }
361            h5 { font-size: 13px; font-weight: bold; margin: 22px 0; }
362            h6 { font-size: 11px; font-weight: bold; margin: 25px 0; }
363
364            /* Paragraph and inline emphasis. */
365            p { margin: 16px 0; }
366            b, strong { font-weight: bold; }
367            i, em, cite, var, dfn { font-style: italic; }
368            small { font-size: 0.83em; }
369            big { font-size: 1.17em; }
370            sub { vertical-align: sub; font-size: 0.83em; }
371            sup { vertical-align: super; font-size: 0.83em; }
372
373            /* Code & preformatted. */
374            code, kbd, samp, tt { font-family: monospace; }
375            pre { font-family: monospace; margin: 16px 0; white-space: pre; }
376
377            /* Lists. */
378            ul, ol { margin: 16px 0; }
379            ol { list-style-type: decimal; }
380            ul ul, ol ul { list-style-type: circle; }
381            ul ul ul, ol ul ul { list-style-type: square; }
382
383            /* Block-level wrappers. */
384            blockquote { margin: 16px 40px; }
385            hr { display: block; border-top: 1px solid; margin: 8px 0; }
386
387            /* Anchors. */
388            a { color: #0033cc; text-decoration: underline; }
389
390            /* Other inline semantics. */
391            mark { background-color: #ffff00; color: #000; }
392            u, ins { text-decoration: underline; }
393            s, strike, del { text-decoration: line-through; }
394            abbr { text-decoration: underline; text-decoration-style: dotted; }
395
396            /* HTML 5 §4.5.6 — `<address>` carries contact info; the
397               typographic convention is italic. */
398            address { font-style: italic; }
399
400            /* HTML 5 §15.3 — bidi element UA defaults. `<bdo>`
401               overrides the bidi algorithm for its descendants;
402               `<bdi>` isolates them so surrounding text's bidi
403               doesn't bleed in / out. */
404            bdo { unicode-bidi: bidi-override; }
405            bdi { unicode-bidi: isolate; }
406            /* HTML 5 §15.3 maps the `dir` attribute to CSS
407               direction + bidi isolation. `:where(...)` keeps the
408               attribute-selector specificity at 0 so the bdo rule
409               above still wins for `<bdo>`. */
410            [dir="ltr"] { direction: ltr; }
411            [dir="rtl"] { direction: rtl; }
412            :where([dir="ltr"], [dir="rtl"]) { unicode-bidi: isolate; }
413            :where([dir="auto"]) { unicode-bidi: plaintext; }
414
415            /* Definition lists. */
416            dl { margin: 16px 0; }
417            dt { font-weight: bold; }
418            dd { margin-left: 40px; }
419
420            /* Figure / figcaption. */
421            figure { margin: 16px 40px; }
422            figcaption { font-size: 0.9em; }
423
424            /* Details / summary (HTML 5 §4.11.1). Closed by default —
425               only the summary renders — until the [open] attribute
426               flips the visibility. Print authors who want a permanent
427               open disclosure either set [open] on the tag or override
428               with their own CSS. */
429            details, summary { display: block; }
430            summary { font-weight: bold; }
431            details > * { display: none; }
432            details > summary { display: block; }
433            details[open] > * { display: block; }
434
435            /* `<q>` inline quotes — wrap content in straight double quotes
436               per the open-quote / close-quote Phase-1 simplification. */
437            q::before { content: open-quote; }
438            q::after { content: close-quote; }
439
440            /* `<picture>` is a transparent wrapper around an `<img>`;
441               `<source>` carries media-query metadata we can't evaluate
442               without JS, so it's hidden. The contained `<img>` renders
443               normally. */
444            picture { display: inline; }
445            source, track, param { display: none; }
446
447            /* HTML 5 §4.10.10 — `<datalist>` is a typeahead helper
448               for `<input>` and never renders on its own. */
449            datalist { display: none; }
450
451            /* HTML 5 §4.10.13 + §4.10.14 — `<meter>` and `<progress>`
452               are inline-block widgets. We don't paint the actual
453               bar / gauge (Phase 2 with proper widget rendering),
454               but the inline-block treatment ensures any text
455               children (the fallback value) flow inline. */
456            meter, progress { display: inline-block; }
457
458            /* HTML 5 §4.10.15 — `<fieldset>` is a labelled form
459               group with a thin border + small inset padding.
460               Legend positioning over the top border is Phase 2;
461               Phase 1 renders legend as a regular block child. */
462            fieldset { border: 1px solid #888; padding: 6pt 9pt 8pt; margin: 0 2pt; }
463            legend { display: block; padding: 0 2pt; }
464
465            /* HTML 5 §4.12.5 — `<canvas>` is a script-driven raster
466               surface. With no scripting it renders its fallback
467               children inline-block. */
468            canvas { display: inline-block; }
469            /* HTML 5 §4.5.21 — `<rp>` is the ruby-parenthesis
470               fallback for browsers without ruby layout support; in
471               browsers that DO support ruby it's `display: none`.
472               Phase 1 doesn't paint ruby annotations yet, so we keep
473               `<rp>` hidden to match the spec convention rather than
474               showing it as visible parentheses. */
475            rp { display: none; }
476
477            /* CSS Fragmentation 4 §3.2 — `break-inside: avoid` on
478               atomic content so a single row / quote / heading / image
479               that fits on a single page never straddles a page
480               boundary. Authors override with `break-inside: auto` on
481               structurally tall content (e.g. a multi-page <pre> block). */
482            tr, figure, blockquote, pre, img,
483            h1, h2, h3, h4, h5, h6 { break-inside: avoid; }
484        CSS;
485    }
486}