Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
89.91% |
98 / 109 |
|
80.00% |
8 / 10 |
CRAP | |
0.00% |
0 / 1 |
| RendererOptions | |
89.91% |
98 / 109 |
|
80.00% |
8 / 10 |
17.30 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| withPageSize | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
| withDefaultFont | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
| withUserAgentStylesheet | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
| withStrict | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
| withBaseDir | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
| withFonts | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
2 | |||
| withFontFaces | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
5 | |||
| withGenericFamilies | |
100.00% |
21 / 21 |
|
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\OpenTypeData; |
| 8 | use 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 | */ |
| 25 | final 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 | } |