Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
91.81% |
157 / 171 |
|
84.62% |
11 / 13 |
CRAP | |
0.00% |
0 / 1 |
| RendererOptions | |
91.81% |
157 / 171 |
|
84.62% |
11 / 13 |
20.22 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| withPageSize | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
1 | |||
| withDefaultFont | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
1 | |||
| withUserAgentStylesheet | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
2 | |||
| withStrict | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
1 | |||
| withBaseDir | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
1 | |||
| withSandboxRoot | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
1 | |||
| withMatchingMediaTypes | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
1 | |||
| withResourceLoader | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
1 | |||
| withFonts | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
2 | |||
| withFontFaces | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
5 | |||
| withGenericFamilies | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
3 | |||
| effectiveUserAgentStylesheet | |
50.00% |
1 / 2 |
|
0.00% |
0 / 1 |
1.12 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\HtmlToPdf; |
| 6 | |
| 7 | use Phpdftk\FontParser\FontFaceData; |
| 8 | use Phpdftk\HtmlToPdf\Layout\FontFace; |
| 9 | use 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 | */ |
| 26 | final 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 | } |