Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
70.37% |
696 / 989 |
|
41.38% |
24 / 58 |
CRAP | |
0.00% |
0 / 1 |
| BoxGenerator | |
70.37% |
696 / 989 |
|
41.38% |
24 / 58 |
7054.72 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| generate | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
2.01 | |||
| getNamedStrings | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getRunningElements | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| buildElementBox | |
93.85% |
168 / 179 |
|
0.00% |
0 / 1 |
79.41 | |||
| containsBlockLevel | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| containsInFlowBlockLevel | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
4.25 | |||
| isAbsolutelyPositioned | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| imageIsLoadable | |
54.55% |
6 / 11 |
|
0.00% |
0 / 1 |
9.38 | |||
| applyPictureSourceOverride | |
91.67% |
22 / 24 |
|
0.00% |
0 / 1 |
17.17 | |||
| sourceTypeAcceptable | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| firstSrcsetUrl | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
4.02 | |||
| parseSrcsetCandidates | |
90.00% |
18 / 20 |
|
0.00% |
0 / 1 |
8.06 | |||
| makePseudoBox | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
5.01 | |||
| collectSelectOptions | |
89.66% |
26 / 29 |
|
0.00% |
0 / 1 |
13.19 | |||
| resolvePseudoContent | |
88.89% |
16 / 18 |
|
0.00% |
0 / 1 |
9.11 | |||
| resolveQuotePair | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
8 | |||
| currentQuoteDepth | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
| contentItemAsString | |
81.36% |
48 / 59 |
|
0.00% |
0 / 1 |
41.49 | |||
| applyCounterReset | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| extractRunningPositionName | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
5.03 | |||
| applyStringSet | |
87.50% |
14 / 16 |
|
0.00% |
0 / 1 |
8.12 | |||
| splitStringSetGroups | |
27.27% |
3 / 11 |
|
0.00% |
0 / 1 |
19.85 | |||
| resolveStringSetPart | |
58.06% |
18 / 31 |
|
0.00% |
0 / 1 |
41.89 | |||
| applyCounterSet | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| applyCounterIncrement | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| forEachCounterPair | |
80.00% |
16 / 20 |
|
0.00% |
0 / 1 |
10.80 | |||
| formatCounter | |
7.50% |
3 / 40 |
|
0.00% |
0 / 1 |
218.61 | |||
| lowerGreek | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
12 | |||
| hebrew | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
30 | |||
| armenian | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
72 | |||
| georgian | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
72 | |||
| kanaList | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
| bijectiveBase26 | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
20 | |||
| roman | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
5.01 | |||
| flushInlineGroup | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
| onlyCollapsibleWhitespace | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
9 | |||
| stripWhitespaceTextChildren | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
9.05 | |||
| makeBox | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
10 | |||
| isForeignContentRoot | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| foreignContentKind | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
8 | |||
| isOutOfFlow | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
7 | |||
| isFlexOrGridContainer | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| displayKeyword | |
17.65% |
3 / 17 |
|
0.00% |
0 / 1 |
65.85 | |||
| isInitialValue | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
30 | |||
| expandDisplayContents | |
64.00% |
16 / 25 |
|
0.00% |
0 / 1 |
14.67 | |||
| applyPresentationalAttributes | |
68.57% |
72 / 105 |
|
0.00% |
0 / 1 |
171.76 | |||
| presentationalInsetsAndBoxSizing | |
65.52% |
19 / 29 |
|
0.00% |
0 / 1 |
15.96 | |||
| isAutoLength | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| isReplacedSizeAuto | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| constrainReplacedNaturalSize | |
60.87% |
14 / 23 |
|
0.00% |
0 / 1 |
23.13 | |||
| hasSizeContainment | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
42 | |||
| naturalImageSize | |
77.78% |
21 / 27 |
|
0.00% |
0 / 1 |
17.47 | |||
| resolveLocalImagePath | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| parseHtmlPercentage | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| parseHtmlLength | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
3.04 | |||
| mixesBlockAndInline | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
5 | |||
| isInlineLevel | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\HtmlToPdf\Box; |
| 6 | |
| 7 | use Phpdftk\Css\Cascade\Cascade; |
| 8 | use Phpdftk\Css\Cascade\CascadedValues; |
| 9 | use Phpdftk\Css\Sheet\Stylesheet; |
| 10 | use Phpdftk\Css\Value\Keyword; |
| 11 | use Phpdftk\Html\Dom\Document; |
| 12 | use Phpdftk\Html\Dom\Element; |
| 13 | use Phpdftk\Html\Dom\Text; |
| 14 | |
| 15 | /** |
| 16 | * Walks a parsed HTML document, runs the CSS cascade against each element, |
| 17 | * and emits the box tree. |
| 18 | * |
| 19 | * Phase 1E.1 implements the common path of CSS Display 3 box generation: |
| 20 | * - `display: block` / `list-item` → {@see BlockBox} |
| 21 | * - `display: inline` → {@see InlineBox} |
| 22 | * - `display: inline-block` and replaced elements → {@see AtomicInlineBox} |
| 23 | * - `display: none` → element + subtree skipped |
| 24 | * - Text nodes inside any element → {@see TextBox} |
| 25 | * - Anonymous block wrapping per CSS Display 3 §3.4 when a block parent |
| 26 | * has mixed inline + block children |
| 27 | * |
| 28 | * Display values we don't yet generate for (table, flex, grid, ruby) fall |
| 29 | * through to BlockBox as a sensible default — layout will reject them in a |
| 30 | * dedicated message until those sub-phases ship. |
| 31 | * |
| 32 | * The flat-tree composition that Q11 calls for (slot distribution + |
| 33 | * shadow-tree traversal) lives in 1E.2; this version walks the light DOM. |
| 34 | */ |
| 35 | final class BoxGenerator |
| 36 | { |
| 37 | /** |
| 38 | * Live CSS counter state during a single `generate()` walk. Keyed by |
| 39 | * counter name, value is the current count. Reset per generate; not |
| 40 | * shared across documents. |
| 41 | * |
| 42 | * @var array<string, int> |
| 43 | */ |
| 44 | private array $counters = []; |
| 45 | |
| 46 | /** |
| 47 | * CSS Generated Content for Paged Media 3 §5 — named-string |
| 48 | * store populated as `string-set` declarations flow through |
| 49 | * the document. Keyed by the string name (the first arg of |
| 50 | * `string-set: <name> <value>`), value is the resolved string. |
| 51 | * Page-margin painting reads this via {@see getNamedStrings}. |
| 52 | * |
| 53 | * @var array<string, string> |
| 54 | */ |
| 55 | private array $namedStrings = []; |
| 56 | |
| 57 | /** |
| 58 | * CSS Generated Content for Paged Media 3 §4 — running-element |
| 59 | * store populated by `position: running(name)` declarations. |
| 60 | * Keyed by the running name, value is the element's text |
| 61 | * content captured at the point the element was visited. |
| 62 | * Page-margin painting reads this via {@see getRunningElements} |
| 63 | * to resolve `content: element(name)`. |
| 64 | * |
| 65 | * @var array<string, string> |
| 66 | */ |
| 67 | private array $runningElements = []; |
| 68 | |
| 69 | public function __construct( |
| 70 | private readonly Cascade $cascade = new Cascade(), |
| 71 | /** |
| 72 | * Base directory for resolving local-file `<img src>` paths when |
| 73 | * reading intrinsic image dimensions. Same posture as the |
| 74 | * painter's `baseDir`: `null` disables local-file lookups (only |
| 75 | * `data:` URLs supply natural sizes); a non-null value joins |
| 76 | * relative paths and rejects any escape via `realpath()`. |
| 77 | */ |
| 78 | private readonly ?string $baseDir = null, |
| 79 | /** |
| 80 | * Optional broader sandbox the resolved path must remain |
| 81 | * under (mirrors Painter / RendererOptions). Defaults to |
| 82 | * `baseDir`. |
| 83 | */ |
| 84 | private readonly ?string $sandboxRoot = null, |
| 85 | ) {} |
| 86 | |
| 87 | /** |
| 88 | * Generate a box tree from a parsed HTML document + a list of |
| 89 | * stylesheets in their cascade-origin order. |
| 90 | * |
| 91 | * @param list<Stylesheet> $sheets |
| 92 | */ |
| 93 | public function generate(Document $document, array $sheets): ?Box |
| 94 | { |
| 95 | $root = $document->documentElement; |
| 96 | if ($root === null) { |
| 97 | return null; |
| 98 | } |
| 99 | $this->counters = []; |
| 100 | $this->namedStrings = []; |
| 101 | $this->runningElements = []; |
| 102 | return $this->buildElementBox($root, $sheets, null); |
| 103 | } |
| 104 | |
| 105 | /** |
| 106 | * Snapshot of the named-string store accumulated during the |
| 107 | * last {@see generate} run. Used by the page-margin painter |
| 108 | * to resolve `content: string(name)` references in @page |
| 109 | * margin boxes. |
| 110 | * |
| 111 | * @return array<string, string> |
| 112 | */ |
| 113 | public function getNamedStrings(): array |
| 114 | { |
| 115 | return $this->namedStrings; |
| 116 | } |
| 117 | |
| 118 | /** |
| 119 | * Snapshot of the running-element store. Used by the page- |
| 120 | * margin painter to resolve `content: element(name)` against |
| 121 | * `position: running(name)` opt-outs in the document body. |
| 122 | * |
| 123 | * @return array<string, string> |
| 124 | */ |
| 125 | public function getRunningElements(): array |
| 126 | { |
| 127 | return $this->runningElements; |
| 128 | } |
| 129 | |
| 130 | /** @param list<Stylesheet> $sheets */ |
| 131 | private function buildElementBox( |
| 132 | Element $element, |
| 133 | array $sheets, |
| 134 | ?CascadedValues $parentValues, |
| 135 | ): ?Box { |
| 136 | $values = $this->cascade->computeFor($sheets, $element, $parentValues); |
| 137 | $this->applyPresentationalAttributes( |
| 138 | $element, |
| 139 | $values, |
| 140 | $parentValues !== null && $this->isFlexOrGridContainer($parentValues), |
| 141 | ); |
| 142 | $display = $this->displayKeyword($values); |
| 143 | if ($display === 'none') { |
| 144 | return null; |
| 145 | } |
| 146 | // CSS Grid 3 §11 — `display: grid-lanes` (masonry layout) |
| 147 | // currently aliases to `display: grid` with `grid-auto-flow` |
| 148 | // derived from which axis the author specified tracks for. |
| 149 | // For a row-axis grid-lanes (only `grid-template-rows` set), |
| 150 | // items flow column-major (auto-flow: column) and the |
| 151 | // column tracks grow implicitly. Symmetric for column-axis. |
| 152 | // The actual masonry algorithm (variable-height packing |
| 153 | // across tracks) is a future enhancement; this aliasing |
| 154 | // already lights up the empty-container / order / |
| 155 | // basic-placement subset of the corpus. |
| 156 | if ($display === 'grid-lanes' || $display === 'inline-grid-lanes') { |
| 157 | $hasRowTracks = !$this->isInitialValue($values->get('grid-template-rows')); |
| 158 | $hasColTracks = !$this->isInitialValue($values->get('grid-template-columns')); |
| 159 | if ($hasRowTracks && !$hasColTracks) { |
| 160 | $values->set('grid-auto-flow', new Keyword('column')); |
| 161 | } |
| 162 | $values->set('display', new Keyword($display === 'inline-grid-lanes' ? 'inline-grid' : 'grid')); |
| 163 | $display = $display === 'inline-grid-lanes' ? 'inline-grid' : 'grid'; |
| 164 | } |
| 165 | // CSS Writing Modes 3 §2 — `direction` doesn't apply to |
| 166 | // certain internal-table display types (table-row-group / |
| 167 | // -header-group / -footer-group / -row / -column / |
| 168 | // -column-group). Browsers reset the value to the inherited |
| 169 | // one from the parent rather than honouring an explicit |
| 170 | // declaration on the row-group itself. Force the cascade back |
| 171 | // to the parent's resolved direction (or `ltr` at the root) |
| 172 | // so descendants don't pick up an invalid declaration via |
| 173 | // inheritance. |
| 174 | if (in_array($display, ['table-row-group', 'table-header-group', 'table-footer-group', 'table-row', 'table-column', 'table-column-group'], true)) { |
| 175 | $parentDirection = $parentValues?->get('direction'); |
| 176 | if ($parentDirection instanceof Keyword) { |
| 177 | $values->set('direction', $parentDirection); |
| 178 | } else { |
| 179 | $values->set('direction', new Keyword('ltr')); |
| 180 | } |
| 181 | } |
| 182 | // CSS Display 3 §3.2.1 — `display: contents` on the root |
| 183 | // element is "blockified": the value is treated as `block` |
| 184 | // so the root still generates a box and its background / |
| 185 | // borders still propagate to the canvas. The caller (the |
| 186 | // top-level `generate()` entry) hits this for the document |
| 187 | // root only; in-tree `display: contents` is handled by the |
| 188 | // expansion in the child-collection loop below. |
| 189 | if ($display === 'contents' && $parentValues === null) { |
| 190 | $values->set('display', new Keyword('block')); |
| 191 | $display = 'block'; |
| 192 | } |
| 193 | // CSS 2.1 §9.7 + CSS Display §2.7 — out-of-flow elements |
| 194 | // (position: absolute / fixed, float: left / right) are |
| 195 | // "blockified": an inline-level computed `display` becomes |
| 196 | // `block` so the box participates in the abs-pos / float |
| 197 | // layout pipeline rather than the inline-flow line-box |
| 198 | // path. This is what `<img position: absolute; left: 7.5px>` |
| 199 | // needs to honour its corner anchors (#21, also unblocks |
| 200 | // the SVG-embed positioning fixture in #143). |
| 201 | // |
| 202 | // Foreign content (root <math> and <svg>) is intentionally |
| 203 | // excluded: those elements route through their own atomic- |
| 204 | // inline painters (`paintInlineMath` / `paintInlineSvg`) |
| 205 | // that already honour cascaded `position` / `left` / `top` |
| 206 | // via `resolveInlineAbsoluteOrigin`. Blockifying them |
| 207 | // would re-route through the generic block pipeline that |
| 208 | // doesn't know how to delegate to those painters — and |
| 209 | // doing so regresses `mathml/presentation-markup/spaces/ |
| 210 | // space-3` (which uses `<math style="position: absolute; |
| 211 | // top: 0; left: 0">`). |
| 212 | if (in_array($display, ['inline', 'inline-block', 'inline-flex', 'inline-grid', 'inline-table'], true) |
| 213 | && $this->isOutOfFlow($values) |
| 214 | && !$this->isForeignContentRoot($element) |
| 215 | ) { |
| 216 | $values->set('display', new Keyword('block')); |
| 217 | $display = 'block'; |
| 218 | } |
| 219 | // Foreign-content roots (`<svg>` / `<math>`) are replaced |
| 220 | // atomic-inline boxes routed to the dedicated foreign painters. |
| 221 | // The UA sheet's `svg, math { display: inline-block }` matches the |
| 222 | // unprefixed form by tag name; the prefixed XHTML form |
| 223 | // (`<svg:svg>`, localName `"svg:svg"`) misses that selector and |
| 224 | // would fall back to generic inline flow. Force any inline-level |
| 225 | // foreign root to `inline-block` so it generates an |
| 226 | // `AtomicInlineBox`. |
| 227 | if ($this->isForeignContentRoot($element) |
| 228 | && in_array($display, ['inline', 'inline-block', 'inline-flex', 'inline-grid', 'inline-table'], true) |
| 229 | ) { |
| 230 | $values->set('display', new Keyword('inline-block')); |
| 231 | $display = 'inline-block'; |
| 232 | } |
| 233 | // CSS Containment 2 §4 — `content-visibility: hidden` |
| 234 | // suppresses box generation just like `display: none` for |
| 235 | // static print. (`auto` is a runtime-visibility optimisation |
| 236 | // with no print equivalent and is treated as `visible`.) |
| 237 | $cv = $values->get('content-visibility'); |
| 238 | if ($cv instanceof Keyword && strtolower($cv->name) === 'hidden') { |
| 239 | return null; |
| 240 | } |
| 241 | // CSS GCPM 3 §4 — `position: running(<name>)` opts the |
| 242 | // element out of normal flow and into the running-element |
| 243 | // store. No box is generated; the element's text content |
| 244 | // becomes available to @page margin boxes via |
| 245 | // `content: element(<name>)`. |
| 246 | $runningName = $this->extractRunningPositionName($values); |
| 247 | if ($runningName !== null) { |
| 248 | $this->runningElements[$runningName] = $element->textContent(); |
| 249 | return null; |
| 250 | } |
| 251 | |
| 252 | // CSS Generated Content 3 §2: apply counter-reset (creates + |
| 253 | // sets), then counter-set (sets to a specific value WITHOUT |
| 254 | // creating a new scope), then counter-increment (bumps) so any |
| 255 | // `::before` content that reads `counter()` sees the post- |
| 256 | // increment value at this element's position in document order. |
| 257 | // counter-set's distinction from counter-reset is scope-related; |
| 258 | // since BoxGenerator carries a single flat counter table for |
| 259 | // print render rather than a per-scope stack, both reduce to the |
| 260 | // same write here but the property is still honoured rather than |
| 261 | // silently dropped. |
| 262 | $this->applyCounterReset($values); |
| 263 | $this->applyCounterSet($values); |
| 264 | $this->applyCounterIncrement($values); |
| 265 | $this->applyStringSet($element, $values); |
| 266 | |
| 267 | // HTML `<br>` produces a sentinel line-break box — a hard break |
| 268 | // inside the parent inline formatting context that survives |
| 269 | // whitespace collapsing under `white-space: normal`. |
| 270 | if (strtolower($element->localName) === 'br') { |
| 271 | return new LineBreakBox($element, $values); |
| 272 | } |
| 273 | // HTML 5 `<wbr>` — a soft-break opportunity. Lower to an |
| 274 | // `InlineBox` carrying a zero-width-space TextBox so UAX #14 has |
| 275 | // a wrap point even when surrounding text doesn't. |
| 276 | if (strtolower($element->localName) === 'wbr') { |
| 277 | $inline = new InlineBox($element, $values); |
| 278 | $inline->addChild(new TextBox($element, $values, "\u{200B}")); |
| 279 | return $inline; |
| 280 | } |
| 281 | |
| 282 | // HTML 5 §4.8.4.2 — when the `<img>` is wrapped in a |
| 283 | // `<picture>`, walk the preceding `<source>` siblings to |
| 284 | // pick the best one for the print medium. Phase-1 honours |
| 285 | // a `media` attribute containing `print` or `all` (or an |
| 286 | // absent media attribute, which means "all media"). |
| 287 | if (strtolower($element->localName) === 'img') { |
| 288 | $this->applyPictureSourceOverride($element); |
| 289 | // HTML 5 §4.8.3 — the alt-text fallback only kicks in when |
| 290 | // the image *can't* be painted: missing src, an unloadable |
| 291 | // source, or an unsupported format. When the painter can |
| 292 | // resolve the src, render the image; otherwise hand the |
| 293 | // alt text to inline layout as a synthetic TextBox so the |
| 294 | // surrounding flow doesn't collapse to nothing. |
| 295 | $alt = $element->getAttribute('alt'); |
| 296 | if ($alt !== null && $alt !== '' && !$this->imageIsLoadable($element)) { |
| 297 | $inline = new InlineBox($element, $values); |
| 298 | $inline->addChild(new TextBox($element, $values, $alt)); |
| 299 | return $inline; |
| 300 | } |
| 301 | } |
| 302 | |
| 303 | // HTML 5 §4.10.5.1: `<input type="text|email|search|...">` renders |
| 304 | // its `value` attribute as static text for print output. PDF |
| 305 | // AcroForm field generation is a Phase 2 task. Emit as an |
| 306 | // `InlineBox` so the text child flows through inline layout. |
| 307 | if (strtolower($element->localName) === 'input') { |
| 308 | $type = strtolower($element->getAttribute('type') ?? 'text'); |
| 309 | // HTML 5 §4.10.5.1.7 — `<input type=hidden>` is never |
| 310 | // rendered, regardless of the `hidden` attribute on |
| 311 | // its ancestors. |
| 312 | if ($type === 'hidden') { |
| 313 | return null; |
| 314 | } |
| 315 | $textTypes = ['text', 'email', 'search', 'tel', 'url', 'number', 'date', 'time', 'datetime-local']; |
| 316 | if (in_array($type, $textTypes, true)) { |
| 317 | $inline = new InlineBox($element, $values); |
| 318 | $value = $element->getAttribute('value') ?? ''; |
| 319 | if ($value !== '') { |
| 320 | $inline->addChild(new TextBox($element, $values, $value)); |
| 321 | } |
| 322 | return $inline; |
| 323 | } |
| 324 | // HTML 5 §4.10.5.1.15 — password fields render their |
| 325 | // value as a sequence of U+2022 bullets so the printed |
| 326 | // form keeps a sense of "this field is populated" |
| 327 | // without leaking the value. |
| 328 | if ($type === 'password') { |
| 329 | $inline = new InlineBox($element, $values); |
| 330 | $value = $element->getAttribute('value') ?? ''; |
| 331 | if ($value !== '') { |
| 332 | $masked = str_repeat("\u{2022}", mb_strlen($value, 'UTF-8')); |
| 333 | $inline->addChild(new TextBox($element, $values, $masked)); |
| 334 | } |
| 335 | return $inline; |
| 336 | } |
| 337 | // HTML 5 §4.10.5.1.21 — `<input type=file>` renders a |
| 338 | // placeholder label since the actual file picker is |
| 339 | // interactive. The chosen filename never reaches a |
| 340 | // server-side print render. |
| 341 | if ($type === 'file') { |
| 342 | $inline = new InlineBox($element, $values); |
| 343 | $inline->addChild(new TextBox($element, $values, 'No file chosen')); |
| 344 | return $inline; |
| 345 | } |
| 346 | // HTML 5 §4.10.5.1.18: button-type inputs render the |
| 347 | // `value` as the button label. Phase 2 will paint them as |
| 348 | // proper PDF widget annotations; for now we just emit the |
| 349 | // label text inline. |
| 350 | $buttonTypes = ['button', 'submit', 'reset']; |
| 351 | if (in_array($type, $buttonTypes, true)) { |
| 352 | $inline = new InlineBox($element, $values); |
| 353 | $label = $element->getAttribute('value'); |
| 354 | if ($label === null || $label === '') { |
| 355 | // HTML 5 default labels when `value` is missing. |
| 356 | $label = match ($type) { |
| 357 | 'submit' => 'Submit', |
| 358 | 'reset' => 'Reset', |
| 359 | default => '', |
| 360 | }; |
| 361 | } |
| 362 | if ($label !== '') { |
| 363 | $inline->addChild(new TextBox($element, $values, $label)); |
| 364 | } |
| 365 | return $inline; |
| 366 | } |
| 367 | // Checkbox / radio — render an ASCII visual indicator so |
| 368 | // form-print output stays informative without depending on |
| 369 | // ☐/☑ glyphs in the user's font. |
| 370 | if ($type === 'checkbox' || $type === 'radio') { |
| 371 | $checked = $element->getAttribute('checked') !== null; |
| 372 | $marker = $type === 'checkbox' |
| 373 | ? ($checked ? '[x] ' : '[ ] ') |
| 374 | : ($checked ? '(o) ' : '( ) '); |
| 375 | $inline = new InlineBox($element, $values); |
| 376 | $inline->addChild(new TextBox($element, $values, $marker)); |
| 377 | return $inline; |
| 378 | } |
| 379 | } |
| 380 | |
| 381 | // HTML 5 §4.5.27: `<wbr>` (Word Break Opportunity) is a void |
| 382 | // inline element that just marks a permissible line break. |
| 383 | // Emit a U+200B (zero-width space) text child — it has zero |
| 384 | // advance width but the line breaker recognises it as a break |
| 385 | // opportunity, so a long unbroken token wrapping a `<wbr>` |
| 386 | // can split at that point. |
| 387 | if (strtolower($element->localName) === 'wbr') { |
| 388 | $inline = new InlineBox($element, $values); |
| 389 | $inline->addChild(new TextBox($element, $values, "\u{200B}")); |
| 390 | return $inline; |
| 391 | } |
| 392 | |
| 393 | // HTML 5 §4.10.7: `<select>` renders only its currently-selected |
| 394 | // `<option>` in static print output (no dropdown widget). |
| 395 | // - Single-select (default): one option, the first one with |
| 396 | // `selected` (else the first option). |
| 397 | // - `<select multiple>`: every option with `selected` (else |
| 398 | // the empty selection), each on its own line. |
| 399 | // `<optgroup label="...">` labels the contained options with |
| 400 | // an inline-level "label: " prefix so the print form keeps |
| 401 | // the grouping visible. |
| 402 | if (strtolower($element->localName) === 'select') { |
| 403 | $isMultiple = $element->getAttribute('multiple') !== null; |
| 404 | /** @var list<array{label: ?string, option: Element}> $renderedOptions */ |
| 405 | $renderedOptions = $this->collectSelectOptions($element, $isMultiple); |
| 406 | $inline = new InlineBox($element, $values); |
| 407 | foreach ($renderedOptions as $i => $entry) { |
| 408 | if ($i > 0) { |
| 409 | // Separate multi-select entries with a newline so |
| 410 | // they stack across lines instead of running on. |
| 411 | $inline->addChild(new TextBox($element, $values, "\n")); |
| 412 | } |
| 413 | if ($entry['label'] !== null) { |
| 414 | $inline->addChild(new TextBox($element, $values, $entry['label'] . ': ')); |
| 415 | } |
| 416 | $text = $entry['option']->textContent(); |
| 417 | if ($text !== '') { |
| 418 | $inline->addChild(new TextBox($element, $values, $text)); |
| 419 | } |
| 420 | } |
| 421 | return $inline; |
| 422 | } |
| 423 | |
| 424 | $box = $this->makeBox($element, $values, $display); |
| 425 | |
| 426 | // Walk children, building child boxes. Text nodes become TextBoxes. |
| 427 | // `::before` is generated content prepended to the element's own |
| 428 | // children; `::after` is appended. Both are inline boxes carrying a |
| 429 | // synthetic TextBox of the `content` string. Phase-1 supports |
| 430 | // `content: <string>` only — `attr()`, `counter()`, `open-quote` / |
| 431 | // `close-quote`, etc. fall through to the `normal` initial. |
| 432 | $rawChildren = []; |
| 433 | $before = $this->makePseudoBox($element, $sheets, $values, 'before'); |
| 434 | if ($before !== null) { |
| 435 | $rawChildren[] = $before; |
| 436 | } |
| 437 | for ($n = $element->firstChild; $n !== null; $n = $n->nextSibling) { |
| 438 | if ($n instanceof Element) { |
| 439 | // CSS Display 3 §3.2 — `display: contents` makes the |
| 440 | // element generate no box of its own; its children |
| 441 | // render as if they were direct children of this |
| 442 | // element's parent (i.e. the box we're currently |
| 443 | // building). Recurse via the helper so nested |
| 444 | // `display: contents` chains flatten cleanly. |
| 445 | $childCascade = $this->cascade->computeFor($sheets, $n, $values); |
| 446 | $this->applyPresentationalAttributes($n, $childCascade, $this->isFlexOrGridContainer($values)); |
| 447 | if ($this->displayKeyword($childCascade) === 'contents') { |
| 448 | foreach ($this->expandDisplayContents($n, $sheets, $values) as $grandchild) { |
| 449 | $rawChildren[] = $grandchild; |
| 450 | } |
| 451 | continue; |
| 452 | } |
| 453 | $child = $this->buildElementBox($n, $sheets, $values); |
| 454 | if ($child !== null) { |
| 455 | $rawChildren[] = $child; |
| 456 | } |
| 457 | } elseif ($n instanceof Text) { |
| 458 | if ($n->data === '') { |
| 459 | continue; |
| 460 | } |
| 461 | $rawChildren[] = new TextBox($element, $values, $n->data); |
| 462 | } |
| 463 | // Comments and other node types are dropped. |
| 464 | } |
| 465 | $after = $this->makePseudoBox($element, $sheets, $values, 'after'); |
| 466 | if ($after !== null) { |
| 467 | $rawChildren[] = $after; |
| 468 | } |
| 469 | |
| 470 | // CSS Flexbox 1 §4 / CSS Grid Layout 2 §6: an anonymous flex / |
| 471 | // grid item that contains only whitespace is not rendered (as |
| 472 | // if its text nodes were `display: none`). Without this filter |
| 473 | // the trailing `\n` after `<div class="box"></div>` becomes a |
| 474 | // second flex item and consumes the slack `justify-content` |
| 475 | // would otherwise distribute. |
| 476 | if ($box instanceof FlexBox || $box instanceof GridBox) { |
| 477 | $rawChildren = $this->stripWhitespaceTextChildren($rawChildren, $values); |
| 478 | } |
| 479 | |
| 480 | // CSS 2.1 §9.2.1.1 — when an inline box has a block-level |
| 481 | // descendant, the inline box splits around the block. The |
| 482 | // block sits between two anonymous inline halves, all |
| 483 | // wrapped in an anonymous block. We implement the simpler |
| 484 | // single-level case: an InlineBox / AtomicInlineBox whose |
| 485 | // immediate `$rawChildren` contain at least one block-level |
| 486 | // child gets promoted to an AnonymousBlockBox whose children |
| 487 | // are alternating (anonymous inline halves around blocks). |
| 488 | // The original element's cascade rides on the |
| 489 | // AnonymousBlockBox so `position: relative` on the inline |
| 490 | // still affects the block half per spec. |
| 491 | $splitsAroundBlock = $box instanceof AtomicInlineBox |
| 492 | ? $this->containsInFlowBlockLevel($rawChildren) |
| 493 | : $this->containsBlockLevel($rawChildren); |
| 494 | if (($box instanceof InlineBox || $box instanceof AtomicInlineBox) |
| 495 | && $splitsAroundBlock |
| 496 | ) { |
| 497 | $promoted = new AnonymousBlockBox($element, $values); |
| 498 | $inlineGroup = []; |
| 499 | foreach ($rawChildren as $child) { |
| 500 | if ($this->isInlineLevel($child)) { |
| 501 | $inlineGroup[] = $child; |
| 502 | continue; |
| 503 | } |
| 504 | if ($inlineGroup !== []) { |
| 505 | $half = new InlineBox($element, $values); |
| 506 | foreach ($inlineGroup as $g) { |
| 507 | $half->addChild($g); |
| 508 | } |
| 509 | $promoted->addChild($half); |
| 510 | $inlineGroup = []; |
| 511 | } |
| 512 | $promoted->addChild($child); |
| 513 | } |
| 514 | if ($inlineGroup !== []) { |
| 515 | $half = new InlineBox($element, $values); |
| 516 | foreach ($inlineGroup as $g) { |
| 517 | $half->addChild($g); |
| 518 | } |
| 519 | $promoted->addChild($half); |
| 520 | } |
| 521 | return $promoted; |
| 522 | } |
| 523 | |
| 524 | // Anonymous-block wrapping per CSS Display 3 §3.4: only inside |
| 525 | // block-context parents whose children mix block + inline. |
| 526 | $needsAnonymous = $box instanceof BlockBox && $this->mixesBlockAndInline($rawChildren); |
| 527 | if (!$needsAnonymous) { |
| 528 | foreach ($rawChildren as $child) { |
| 529 | $box->addChild($child); |
| 530 | } |
| 531 | return $box; |
| 532 | } |
| 533 | |
| 534 | // Run through children; group contiguous inline-ish children under |
| 535 | // an AnonymousBlockBox sharing the parent's style. |
| 536 | $inlineGroup = []; |
| 537 | foreach ($rawChildren as $child) { |
| 538 | if ($this->isInlineLevel($child)) { |
| 539 | $inlineGroup[] = $child; |
| 540 | continue; |
| 541 | } |
| 542 | $this->flushInlineGroup($box, $values, $inlineGroup); |
| 543 | $inlineGroup = []; |
| 544 | $box->addChild($child); |
| 545 | } |
| 546 | $this->flushInlineGroup($box, $values, $inlineGroup); |
| 547 | return $box; |
| 548 | } |
| 549 | |
| 550 | /** |
| 551 | * `true` when any direct child of `$children` is block-level. |
| 552 | * Used by the block-in-inline split (CSS 2.1 §9.2.1.1) to detect |
| 553 | * when an inline box needs promotion to an anonymous block. |
| 554 | * |
| 555 | * @param list<Box> $children |
| 556 | */ |
| 557 | private function containsBlockLevel(array $children): bool |
| 558 | { |
| 559 | foreach ($children as $c) { |
| 560 | if (!$this->isInlineLevel($c)) { |
| 561 | return true; |
| 562 | } |
| 563 | } |
| 564 | return false; |
| 565 | } |
| 566 | |
| 567 | /** @param list<Box> $children */ |
| 568 | private function containsInFlowBlockLevel(array $children): bool |
| 569 | { |
| 570 | foreach ($children as $c) { |
| 571 | if (!$this->isInlineLevel($c) && !$this->isAbsolutelyPositioned($c->style)) { |
| 572 | return true; |
| 573 | } |
| 574 | } |
| 575 | return false; |
| 576 | } |
| 577 | |
| 578 | /** |
| 579 | * `position: absolute | fixed` only — NOT floats. A floated block child |
| 580 | * still interacts with the inline box's layout in ways our flex / |
| 581 | * multicol paths depend on, so it keeps triggering blockification; |
| 582 | * only genuinely out-of-flow (abs-pos) children are skipped. |
| 583 | */ |
| 584 | private function isAbsolutelyPositioned(CascadedValues $values): bool |
| 585 | { |
| 586 | $position = $values->get('position'); |
| 587 | return $position instanceof Keyword |
| 588 | && in_array(strtolower($position->name), ['absolute', 'fixed'], true); |
| 589 | } |
| 590 | |
| 591 | /** |
| 592 | * HTML 5 §4.8.4.2 — when an `<img>` is the fallback inside a |
| 593 | * `<picture>`, the browser walks the `<source>` siblings and |
| 594 | * picks the first one whose `media` attribute matches. For |
| 595 | * print rendering: pick the first `<source>` with |
| 596 | * `media="print"` (or `media="all"` or no media attribute) and |
| 597 | * use the first URL of its `srcset` as the effective `src`. |
| 598 | * |
| 599 | * Mutates the element's `src` attribute in place — feels |
| 600 | * intrusive but means the existing painter code that reads |
| 601 | * `$element->getAttribute('src')` Just Works without any |
| 602 | * extra plumbing through the box tree. |
| 603 | */ |
| 604 | /** |
| 605 | * `true` when the painter can resolve the `<img>`'s `src` to bytes |
| 606 | * the renderer can paint. Drives the alt-text fallback in |
| 607 | * `boxForElement`: a loadable image keeps its `AtomicInlineBox` |
| 608 | * status (painted as an Image XObject or routed through the SVG |
| 609 | * renderer for `image/svg+xml`); an unloadable one falls back to |
| 610 | * the alt text so the surrounding inline flow still has content. |
| 611 | * |
| 612 | * Looks for `src` first, then `srcset` (first candidate only — |
| 613 | * full responsive selection is a separate substrate gate). Data |
| 614 | * URLs are loadable when the MIME label is one the painter |
| 615 | * recognises; local paths are loadable when they parse as one |
| 616 | * of the supported formats via {@see ImageParser}. |
| 617 | */ |
| 618 | private function imageIsLoadable(Element $img): bool |
| 619 | { |
| 620 | $src = $img->getAttribute('src'); |
| 621 | if ($src === null || $src === '') { |
| 622 | return false; |
| 623 | } |
| 624 | if (str_starts_with($src, 'data:')) { |
| 625 | // The renderer's data: handlers accept image/png, image/jpeg |
| 626 | // (raster), image/svg+xml, plus a few siblings the painter |
| 627 | // routes the same way. Anything else falls back to alt. |
| 628 | return preg_match( |
| 629 | '~^data:image/(png|jpe?g|gif|bmp|webp|svg\+xml|tiff?|jpeg2000|jbig2)\b~i', |
| 630 | $src, |
| 631 | ) === 1; |
| 632 | } |
| 633 | if (str_starts_with($src, 'http://') || str_starts_with($src, 'https://')) { |
| 634 | // Network sources are loadable only when the renderer has |
| 635 | // a resource loader attached. The painter still handles |
| 636 | // the actual fetch (and any errors there fall back to |
| 637 | // the no-image path), but we want the box-generator |
| 638 | // decision to track loader configuration. |
| 639 | return false; |
| 640 | } |
| 641 | return $this->naturalImageSize($src) !== null; |
| 642 | } |
| 643 | |
| 644 | private function applyPictureSourceOverride(Element $img): void |
| 645 | { |
| 646 | $parent = $img->parentNode; |
| 647 | if (!($parent instanceof Element) |
| 648 | || strtolower($parent->localName) !== 'picture' |
| 649 | ) { |
| 650 | return; |
| 651 | } |
| 652 | foreach ($parent->children() as $sibling) { |
| 653 | if ($sibling === $img) { |
| 654 | continue; |
| 655 | } |
| 656 | if (strtolower($sibling->localName) !== 'source') { |
| 657 | continue; |
| 658 | } |
| 659 | $media = $sibling->getAttribute('media'); |
| 660 | if ($media !== null && $media !== '') { |
| 661 | $lower = strtolower(trim($media)); |
| 662 | if ($lower !== 'all' && !str_contains($lower, 'print')) { |
| 663 | continue; |
| 664 | } |
| 665 | } |
| 666 | // HTML 5 §4.8.4.2.4 — `<source type="image/...">` lets the |
| 667 | // author flag a format hint. Skip a source whose declared |
| 668 | // MIME isn't a format the painter can decode (currently |
| 669 | // PNG + JPEG via the image-metadata pipeline). |
| 670 | $type = $sibling->getAttribute('type'); |
| 671 | if ($type !== null && $type !== '' && !$this->sourceTypeAcceptable($type)) { |
| 672 | continue; |
| 673 | } |
| 674 | $srcset = $sibling->getAttribute('srcset'); |
| 675 | if ($srcset === null || trim($srcset) === '') { |
| 676 | continue; |
| 677 | } |
| 678 | $url = $this->firstSrcsetUrl($srcset); |
| 679 | if ($url !== null && $url !== '') { |
| 680 | $img->setAttribute('src', $url); |
| 681 | return; |
| 682 | } |
| 683 | } |
| 684 | } |
| 685 | |
| 686 | /** |
| 687 | * Return true when the `<source type="...">` MIME indicates a |
| 688 | * format the painter can render. Print PDF supports raster PNG |
| 689 | * and JPEG today; anything else (AVIF, WebP, HEIF, SVG-as-image) |
| 690 | * gets skipped so the next `<source>` or the `<img>` fallback |
| 691 | * wins. |
| 692 | */ |
| 693 | private function sourceTypeAcceptable(string $type): bool |
| 694 | { |
| 695 | $lower = strtolower(trim($type)); |
| 696 | $supported = ['image/png', 'image/jpeg', 'image/jpg']; |
| 697 | return in_array($lower, $supported, true); |
| 698 | } |
| 699 | |
| 700 | /** |
| 701 | * Pick the best `srcset` candidate for print. HTML 5 §4.8.4.2.4 |
| 702 | * syntax: comma-separated `url [descriptor]` pairs where the |
| 703 | * descriptor is `Nx` (density) or `Nw` (width). When no |
| 704 | * descriptor is given, defaults to `1x`. |
| 705 | * |
| 706 | * Print rendering targets high resolution (300+ DPI) so the |
| 707 | * algorithm picks the candidate with the highest density. Width |
| 708 | * descriptors are converted to "approximate density" using a |
| 709 | * reference width of 100 (so a 200w candidate counts as 2x, |
| 710 | * 400w as 4x). Bare candidates count as 1x. On ties the first |
| 711 | * declared candidate wins. |
| 712 | */ |
| 713 | private function firstSrcsetUrl(string $srcset): ?string |
| 714 | { |
| 715 | $candidates = $this->parseSrcsetCandidates($srcset); |
| 716 | if ($candidates === []) { |
| 717 | return null; |
| 718 | } |
| 719 | $best = null; |
| 720 | $bestDensity = -INF; |
| 721 | foreach ($candidates as $cand) { |
| 722 | if ($cand['density'] > $bestDensity) { |
| 723 | $best = $cand['url']; |
| 724 | $bestDensity = $cand['density']; |
| 725 | } |
| 726 | } |
| 727 | return $best; |
| 728 | } |
| 729 | |
| 730 | /** |
| 731 | * Parse a `srcset` value into a list of `{url, density}` candidates. |
| 732 | * Descriptor parsing: |
| 733 | * - `Nx` → density = N |
| 734 | * - `Nw` → density ≈ N / 100 (matches typical author intent) |
| 735 | * - missing → density = 1 |
| 736 | * - unrecognised descriptor → candidate is dropped |
| 737 | * |
| 738 | * @return list<array{url: string, density: float}> |
| 739 | */ |
| 740 | private function parseSrcsetCandidates(string $srcset): array |
| 741 | { |
| 742 | $out = []; |
| 743 | foreach (explode(',', $srcset) as $part) { |
| 744 | $part = trim($part); |
| 745 | if ($part === '') { |
| 746 | continue; |
| 747 | } |
| 748 | $tokens = preg_split('/\s+/', $part, 2) ?: []; |
| 749 | $url = $tokens[0] ?? ''; |
| 750 | if ($url === '') { |
| 751 | continue; |
| 752 | } |
| 753 | $descriptor = trim($tokens[1] ?? ''); |
| 754 | if ($descriptor === '') { |
| 755 | $out[] = ['url' => $url, 'density' => 1.0]; |
| 756 | continue; |
| 757 | } |
| 758 | if (preg_match('/^([0-9]*\.?[0-9]+)([xw])$/i', $descriptor, $m) !== 1) { |
| 759 | continue; |
| 760 | } |
| 761 | $value = (float) $m[1]; |
| 762 | $unit = strtolower($m[2]); |
| 763 | $density = $unit === 'x' ? $value : $value / 100.0; |
| 764 | $out[] = ['url' => $url, 'density' => $density]; |
| 765 | } |
| 766 | return $out; |
| 767 | } |
| 768 | |
| 769 | /** |
| 770 | * Build a pseudo-element box (`::before` / `::after`) for an element |
| 771 | * when the cascade produces a non-`none` / non-`normal` `content` |
| 772 | * value. Returns null when no rule targets the pseudo, or when the |
| 773 | * content keyword indicates no generated box. |
| 774 | * |
| 775 | * @param list<Stylesheet> $sheets |
| 776 | */ |
| 777 | private function makePseudoBox( |
| 778 | Element $element, |
| 779 | array $sheets, |
| 780 | CascadedValues $hostValues, |
| 781 | string $pseudoName, |
| 782 | ): ?Box { |
| 783 | $pseudoValues = $this->cascade->computeFor($sheets, $element, $hostValues, $pseudoName); |
| 784 | $content = $pseudoValues->get('content'); |
| 785 | $text = $this->resolvePseudoContent($content, $element, $pseudoValues); |
| 786 | if ($text === null) { |
| 787 | return null; |
| 788 | } |
| 789 | $display = $this->displayKeyword($pseudoValues); |
| 790 | // CSS Display 3 §3.2 — `display: contents` on a pseudo-element |
| 791 | // suppresses its box entirely; its generated `content` flows |
| 792 | // into the parent as if it were a plain text node carrying |
| 793 | // the pseudo's inherited text styles. Skip the box wrap and |
| 794 | // return a TextBox directly so the pseudo's border / background |
| 795 | // / etc. don't paint (the pseudo has no box). |
| 796 | if ($display === 'contents') { |
| 797 | return $text === '' ? null : new TextBox($element, $pseudoValues, $text); |
| 798 | } |
| 799 | // Pseudo-elements default to `inline` when no `display` rule fires. |
| 800 | $pseudo = $this->makeBox($element, $pseudoValues, $display); |
| 801 | if ($text !== '') { |
| 802 | $pseudo->addChild(new TextBox($element, $pseudoValues, $text)); |
| 803 | } |
| 804 | return $pseudo; |
| 805 | } |
| 806 | |
| 807 | /** |
| 808 | * Walk a `<select>`'s children (and one level of `<optgroup>`) |
| 809 | * collecting the `<option>` elements to render. For single-select, |
| 810 | * returns at most one entry — the first option with `selected` |
| 811 | * else the first option overall. For `<select multiple>`, returns |
| 812 | * every option carrying `selected`. Each entry pairs the option |
| 813 | * with the optgroup label that contains it (or null). |
| 814 | * |
| 815 | * @return list<array{label: ?string, option: Element}> |
| 816 | */ |
| 817 | private function collectSelectOptions(Element $select, bool $multiple): array |
| 818 | { |
| 819 | /** @var list<array{label: ?string, option: Element}> $available */ |
| 820 | $available = []; |
| 821 | /** @var list<array{label: ?string, option: Element}> $selectedEntries */ |
| 822 | $selectedEntries = []; |
| 823 | foreach ($select->children() as $child) { |
| 824 | if (!$child instanceof Element) { |
| 825 | continue; |
| 826 | } |
| 827 | $tag = strtolower($child->localName); |
| 828 | if ($tag === 'option') { |
| 829 | $entry = ['label' => null, 'option' => $child]; |
| 830 | $available[] = $entry; |
| 831 | if ($child->getAttribute('selected') !== null) { |
| 832 | $selectedEntries[] = $entry; |
| 833 | } |
| 834 | } elseif ($tag === 'optgroup') { |
| 835 | $label = $child->getAttribute('label'); |
| 836 | foreach ($child->children() as $grand) { |
| 837 | if (!$grand instanceof Element) { |
| 838 | continue; |
| 839 | } |
| 840 | if (strtolower($grand->localName) !== 'option') { |
| 841 | continue; |
| 842 | } |
| 843 | $entry = ['label' => $label, 'option' => $grand]; |
| 844 | $available[] = $entry; |
| 845 | if ($grand->getAttribute('selected') !== null) { |
| 846 | $selectedEntries[] = $entry; |
| 847 | } |
| 848 | } |
| 849 | } |
| 850 | } |
| 851 | if ($multiple) { |
| 852 | return $selectedEntries; |
| 853 | } |
| 854 | if ($selectedEntries !== []) { |
| 855 | return [$selectedEntries[0]]; |
| 856 | } |
| 857 | if ($available !== []) { |
| 858 | return [$available[0]]; |
| 859 | } |
| 860 | return []; |
| 861 | } |
| 862 | |
| 863 | /** |
| 864 | * Resolve the `content` value to a plain string. Returns null when the |
| 865 | * pseudo-element should produce no box (`none` / `normal` / unsupported |
| 866 | * generators like `counter()` / `<image>` — Phase 2). Returns the empty |
| 867 | * string when `content` is explicitly an empty string (the pseudo box |
| 868 | * still generates). |
| 869 | * |
| 870 | * Supports `<string>`, `attr(name)`, and any space-joined list of those. |
| 871 | */ |
| 872 | private function resolvePseudoContent(?\Phpdftk\Css\Value\Value $value, Element $host, CascadedValues $values): ?string |
| 873 | { |
| 874 | if ($value === null) { |
| 875 | return null; |
| 876 | } |
| 877 | if ($value instanceof Keyword) { |
| 878 | $name = strtolower($value->name); |
| 879 | if ($name === 'none' || $name === 'normal') { |
| 880 | return null; |
| 881 | } |
| 882 | // Other keywords (open-quote / close-quote / no-open-quote / |
| 883 | // no-close-quote / etc.) fall through to `contentItemAsString` |
| 884 | // which produces the right glyph. |
| 885 | } |
| 886 | $item = $this->contentItemAsString($value, $host, $values); |
| 887 | if ($item !== null) { |
| 888 | return $item; |
| 889 | } |
| 890 | if ($value instanceof \Phpdftk\Css\Value\ValueList) { |
| 891 | $out = ''; |
| 892 | foreach ($value->values as $v) { |
| 893 | $piece = $this->contentItemAsString($v, $host, $values); |
| 894 | if ($piece === null) { |
| 895 | // Unsupported component (counter/url/etc.) — bail. |
| 896 | return null; |
| 897 | } |
| 898 | $out .= $piece; |
| 899 | } |
| 900 | return $out; |
| 901 | } |
| 902 | return null; |
| 903 | } |
| 904 | |
| 905 | /** |
| 906 | * Translate a single content-list item into a plain string, returning |
| 907 | * null when the item isn't a Phase-1 supported producer. Handles |
| 908 | * `<string>`, `attr(name)`, and the `open-quote` / `close-quote` |
| 909 | * keywords. Phase-1 emits an ASCII double quote for both — full |
| 910 | * `quotes` property + nesting depth tracking lands in a follow-up. |
| 911 | */ |
| 912 | /** |
| 913 | * Resolve CSS Generated Content 3 §3.1 `quotes` to the |
| 914 | * `[openQuote, closeQuote]` pair for `open-quote` / `close-quote` |
| 915 | * content keywords. `auto` (initial value) defers to the |
| 916 | * typographic default U+201C / U+201D ("smart quotes"). `none` |
| 917 | * suppresses quote glyphs entirely — both `open-quote` and |
| 918 | * `close-quote` evaluate to the empty string in that case. |
| 919 | * Explicit string lists are paired open/close; nested-depth |
| 920 | * tracking through ancestor `<q>` chains picks the pair at the |
| 921 | * current depth (clamping to the last pair when nesting exceeds |
| 922 | * the list). |
| 923 | * |
| 924 | * @return array{0:string, 1:string}|null Null means `quotes: none`. |
| 925 | */ |
| 926 | private function resolveQuotePair(CascadedValues $values, int $depth): ?array |
| 927 | { |
| 928 | $value = $values->get('quotes'); |
| 929 | if ($value instanceof Keyword && strtolower($value->name) === 'none') { |
| 930 | return null; |
| 931 | } |
| 932 | if ($value instanceof \Phpdftk\Css\Value\ValueList) { |
| 933 | $pairs = []; |
| 934 | $strings = []; |
| 935 | foreach ($value->values as $v) { |
| 936 | if ($v instanceof \Phpdftk\Css\Value\StringValue) { |
| 937 | $strings[] = $v->value; |
| 938 | if (count($strings) === 2) { |
| 939 | $pairs[] = [$strings[0], $strings[1]]; |
| 940 | $strings = []; |
| 941 | } |
| 942 | } |
| 943 | } |
| 944 | if ($pairs !== []) { |
| 945 | $idx = max(0, min($depth, count($pairs) - 1)); |
| 946 | return $pairs[$idx]; |
| 947 | } |
| 948 | } |
| 949 | // U+201C LEFT DOUBLE QUOTATION MARK + U+201D RIGHT DOUBLE |
| 950 | // QUOTATION MARK — the typographic default for English. Other |
| 951 | // locales (German „..." / French «...») are Phase 2 once the |
| 952 | // cascade tracks `:lang()`-driven UA stylesheets. |
| 953 | return ["\u{201C}", "\u{201D}"]; |
| 954 | } |
| 955 | |
| 956 | /** |
| 957 | * Walk up the host element's `<q>` ancestor chain to compute the |
| 958 | * current quote nesting depth. Each enclosing `<q>` bumps the |
| 959 | * depth by one. The depth is what indexes into the `quotes` |
| 960 | * property's pair list. |
| 961 | */ |
| 962 | private function currentQuoteDepth(Element $host): int |
| 963 | { |
| 964 | $depth = 0; |
| 965 | $node = $host->parentNode; |
| 966 | while ($node !== null) { |
| 967 | if ($node instanceof Element && strtolower($node->localName) === 'q') { |
| 968 | $depth++; |
| 969 | } |
| 970 | $node = $node->parentNode; |
| 971 | } |
| 972 | return $depth; |
| 973 | } |
| 974 | |
| 975 | private function contentItemAsString(\Phpdftk\Css\Value\Value $value, Element $host, CascadedValues $values): ?string |
| 976 | { |
| 977 | if ($value instanceof \Phpdftk\Css\Value\StringValue) { |
| 978 | return $value->value; |
| 979 | } |
| 980 | if ($value instanceof Keyword) { |
| 981 | $kw = strtolower($value->name); |
| 982 | if ($kw === 'open-quote' || $kw === 'close-quote') { |
| 983 | $depth = $this->currentQuoteDepth($host); |
| 984 | $pair = $this->resolveQuotePair($values, $depth); |
| 985 | if ($pair === null) { |
| 986 | return ''; |
| 987 | } |
| 988 | return $kw === 'open-quote' ? $pair[0] : $pair[1]; |
| 989 | } |
| 990 | return match ($kw) { |
| 991 | 'no-open-quote', 'no-close-quote' => '', |
| 992 | default => null, |
| 993 | }; |
| 994 | } |
| 995 | // CSS Values 5 §11 typed AttrFunction (preferred path). |
| 996 | if ($value instanceof \Phpdftk\Css\Value\AttrFunction) { |
| 997 | $name = $value->attributeName; |
| 998 | if ($name !== '') { |
| 999 | $attrValue = $host->getAttribute($name); |
| 1000 | if ($attrValue !== null) { |
| 1001 | return $attrValue; |
| 1002 | } |
| 1003 | // Fallback expression on missing attribute — use |
| 1004 | // its serialized form for now (the typed fallback |
| 1005 | // value lands when AttrFunction is consumed by |
| 1006 | // computed-value time). |
| 1007 | if ($value->fallback !== null) { |
| 1008 | return $value->fallback->toCss(); |
| 1009 | } |
| 1010 | return ''; |
| 1011 | } |
| 1012 | } |
| 1013 | // Legacy generic CssFunction path for value-paths that |
| 1014 | // bypass Parser::makeDeclaration. |
| 1015 | if ($value instanceof \Phpdftk\Css\Value\CssFunction |
| 1016 | && strtolower($value->name) === 'attr' |
| 1017 | && $value->arguments !== [] |
| 1018 | ) { |
| 1019 | $arg = $value->arguments[0]; |
| 1020 | $name = null; |
| 1021 | if ($arg instanceof Keyword) { |
| 1022 | $name = $arg->name; |
| 1023 | } elseif ($arg instanceof \Phpdftk\Css\Value\StringValue) { |
| 1024 | $name = $arg->value; |
| 1025 | } |
| 1026 | if ($name !== null && $name !== '') { |
| 1027 | $attrValue = $host->getAttribute($name); |
| 1028 | return $attrValue ?? ''; |
| 1029 | } |
| 1030 | } |
| 1031 | if ($value instanceof \Phpdftk\Css\Value\CssFunction |
| 1032 | && strtolower($value->name) === 'counter' |
| 1033 | && $value->arguments !== [] |
| 1034 | ) { |
| 1035 | $nameArg = $value->arguments[0]; |
| 1036 | if ($nameArg instanceof Keyword) { |
| 1037 | $count = $this->counters[$nameArg->name] ?? 0; |
| 1038 | $style = isset($value->arguments[1]) && $value->arguments[1] instanceof Keyword |
| 1039 | ? strtolower($value->arguments[1]->name) |
| 1040 | : 'decimal'; |
| 1041 | return $this->formatCounter($count, $style); |
| 1042 | } |
| 1043 | } |
| 1044 | // CSS Generated Content 3 §2.3 — `counters(name, separator, style?)` |
| 1045 | // formats the nested chain of counters with `name` joined by |
| 1046 | // `separator`. The cascade doesn't track per-scope counter |
| 1047 | // stacks yet, so this falls back to formatting the single |
| 1048 | // current value (matching the common authored use case |
| 1049 | // `counters(foo, ".")` on a non-nested counter). |
| 1050 | if ($value instanceof \Phpdftk\Css\Value\CssFunction |
| 1051 | && strtolower($value->name) === 'counters' |
| 1052 | && count($value->arguments) >= 2 |
| 1053 | ) { |
| 1054 | $nameArg = $value->arguments[0]; |
| 1055 | $sepArg = $value->arguments[1]; |
| 1056 | if (!$nameArg instanceof Keyword || !$sepArg instanceof \Phpdftk\Css\Value\StringValue) { |
| 1057 | return null; |
| 1058 | } |
| 1059 | $count = $this->counters[$nameArg->name] ?? 0; |
| 1060 | $style = isset($value->arguments[2]) && $value->arguments[2] instanceof Keyword |
| 1061 | ? strtolower($value->arguments[2]->name) |
| 1062 | : 'decimal'; |
| 1063 | // Single-scope fallback: emit the current counter value |
| 1064 | // (no separator-joining since there's no nested chain). |
| 1065 | // The separator is retained for grammar compatibility. |
| 1066 | return $this->formatCounter($count, $style); |
| 1067 | } |
| 1068 | // `content: url(...)` is a replaced-element generator. For |
| 1069 | // Phase-2 we accept the syntax but emit no text — the pseudo |
| 1070 | // box still generates so author CSS targeting it (e.g. |
| 1071 | // `::before { content: url(badge.png); margin-right: 4px }`) |
| 1072 | // doesn't get silently dropped. Image insertion through |
| 1073 | // generated content is a follow-up requiring XObject hooks |
| 1074 | // to thread through pseudo-element generation. |
| 1075 | if ($value instanceof \Phpdftk\Css\Value\Url) { |
| 1076 | return ''; |
| 1077 | } |
| 1078 | return null; |
| 1079 | } |
| 1080 | |
| 1081 | /** |
| 1082 | * Apply `counter-reset: <name> [<int>]?` declarations to {@see counters}. |
| 1083 | * Multiple name/value pairs in a list are supported. |
| 1084 | */ |
| 1085 | private function applyCounterReset(CascadedValues $values): void |
| 1086 | { |
| 1087 | $value = $values->get('counter-reset'); |
| 1088 | $this->forEachCounterPair($value, function (string $name, int $defaultOrSpecified): void { |
| 1089 | $this->counters[$name] = $defaultOrSpecified; |
| 1090 | }, defaultValue: 0); |
| 1091 | } |
| 1092 | |
| 1093 | /** |
| 1094 | * Extract `<name>` from `position: running(<name>)` when the |
| 1095 | * cascaded `position` value is a generic CssFunction wrapping |
| 1096 | * a bare ident argument. Returns null for any other position |
| 1097 | * value, so normal positioning (static / relative / absolute |
| 1098 | * / fixed) keeps its existing layout path. |
| 1099 | */ |
| 1100 | private function extractRunningPositionName(CascadedValues $values): ?string |
| 1101 | { |
| 1102 | $pos = $values->get('position'); |
| 1103 | if (!($pos instanceof \Phpdftk\Css\Value\CssFunction) |
| 1104 | || strtolower($pos->name) !== 'running' |
| 1105 | || $pos->arguments === [] |
| 1106 | ) { |
| 1107 | return null; |
| 1108 | } |
| 1109 | $arg = $pos->arguments[0]; |
| 1110 | if (!($arg instanceof Keyword)) { |
| 1111 | return null; |
| 1112 | } |
| 1113 | return $arg->name; |
| 1114 | } |
| 1115 | |
| 1116 | /** |
| 1117 | * Apply `string-set: <name> <content-list>` declarations — set |
| 1118 | * the named string value to a resolved content list. Used by |
| 1119 | * GCPM 3 §5 for running headers / footers. |
| 1120 | * |
| 1121 | * Supported `<content-list>` items for the initial pass: |
| 1122 | * |
| 1123 | * - `<string>` literal → emit literally |
| 1124 | * - `content()` → emit the element's text content |
| 1125 | * - `attr(name)` → emit the named attribute value |
| 1126 | * |
| 1127 | * Multiple `string-set` pairs may appear in a comma-separated |
| 1128 | * list; each pair is processed independently. Unsupported |
| 1129 | * content-list items are silently skipped so an unrecognised |
| 1130 | * form doesn't corrupt the rest of the assignment. |
| 1131 | */ |
| 1132 | private function applyStringSet(Element $element, CascadedValues $values): void |
| 1133 | { |
| 1134 | $value = $values->get('string-set'); |
| 1135 | if ($value === null |
| 1136 | || ($value instanceof Keyword && strtolower($value->name) === 'none') |
| 1137 | ) { |
| 1138 | return; |
| 1139 | } |
| 1140 | $groups = $this->splitStringSetGroups($value); |
| 1141 | foreach ($groups as $group) { |
| 1142 | if (count($group) < 2) { |
| 1143 | continue; |
| 1144 | } |
| 1145 | $head = $group[0]; |
| 1146 | if (!($head instanceof Keyword)) { |
| 1147 | continue; |
| 1148 | } |
| 1149 | $name = $head->name; |
| 1150 | $resolved = ''; |
| 1151 | for ($i = 1; $i < count($group); $i++) { |
| 1152 | $resolved .= $this->resolveStringSetPart($group[$i], $element); |
| 1153 | } |
| 1154 | $this->namedStrings[$name] = $resolved; |
| 1155 | } |
| 1156 | } |
| 1157 | |
| 1158 | /** |
| 1159 | * Split the `string-set` value into the per-name groups — |
| 1160 | * `string-set: a "x", b "y"` becomes `[[Kw(a), "x"], [Kw(b), "y"]]`. |
| 1161 | * Each group is a name followed by a content list. Top-level |
| 1162 | * commas are separators between groups; everything else is |
| 1163 | * part of the current group. |
| 1164 | * |
| 1165 | * @return list<list<\Phpdftk\Css\Value\Value>> |
| 1166 | */ |
| 1167 | private function splitStringSetGroups(\Phpdftk\Css\Value\Value $value): array |
| 1168 | { |
| 1169 | if (!($value instanceof \Phpdftk\Css\Value\ValueList)) { |
| 1170 | return [[$value]]; |
| 1171 | } |
| 1172 | if ($value->separator === \Phpdftk\Css\Value\ListSeparator::Comma) { |
| 1173 | $out = []; |
| 1174 | foreach ($value->values as $item) { |
| 1175 | $out[] = $item instanceof \Phpdftk\Css\Value\ValueList |
| 1176 | && $item->separator === \Phpdftk\Css\Value\ListSeparator::Space |
| 1177 | ? $item->values |
| 1178 | : [$item]; |
| 1179 | } |
| 1180 | return $out; |
| 1181 | } |
| 1182 | return [$value->values]; |
| 1183 | } |
| 1184 | |
| 1185 | private function resolveStringSetPart(\Phpdftk\Css\Value\Value $value, Element $host): string |
| 1186 | { |
| 1187 | if ($value instanceof \Phpdftk\Css\Value\StringValue) { |
| 1188 | return $value->value; |
| 1189 | } |
| 1190 | if ($value instanceof \Phpdftk\Css\Value\CssFunction |
| 1191 | && strtolower($value->name) === 'content' |
| 1192 | ) { |
| 1193 | // CSS GCPM 3 §5.1 — `content()` reads the host element's |
| 1194 | // text content. Arguments select sub-text (text, before, |
| 1195 | // after, first-letter); only the default form is honoured |
| 1196 | // here for now. |
| 1197 | return $host->textContent(); |
| 1198 | } |
| 1199 | if ($value instanceof \Phpdftk\Css\Value\AttrFunction) { |
| 1200 | $name = $value->attributeName; |
| 1201 | if ($name === '') { |
| 1202 | return ''; |
| 1203 | } |
| 1204 | return $host->getAttribute($name) ?? ''; |
| 1205 | } |
| 1206 | if ($value instanceof \Phpdftk\Css\Value\CssFunction |
| 1207 | && strtolower($value->name) === 'attr' |
| 1208 | && $value->arguments !== [] |
| 1209 | ) { |
| 1210 | $arg = $value->arguments[0]; |
| 1211 | $name = $arg instanceof Keyword ? $arg->name : null; |
| 1212 | if ($name === null || $name === '') { |
| 1213 | return ''; |
| 1214 | } |
| 1215 | return $host->getAttribute($name) ?? ''; |
| 1216 | } |
| 1217 | if ($value instanceof \Phpdftk\Css\Value\CssFunction |
| 1218 | && strtolower($value->name) === 'counter' |
| 1219 | && $value->arguments !== [] |
| 1220 | ) { |
| 1221 | // CSS GCPM 3 §5.1 — `counter(<name> [, <style>]?)` inside |
| 1222 | // string-set emits the current counter value at this |
| 1223 | // element. Reuses the existing counter store + formatter. |
| 1224 | $args = $value->arguments; |
| 1225 | $head = $args[0]; |
| 1226 | if (!($head instanceof Keyword)) { |
| 1227 | return ''; |
| 1228 | } |
| 1229 | $count = $this->counters[$head->name] ?? 0; |
| 1230 | $style = 'decimal'; |
| 1231 | if (isset($args[1]) && $args[1] instanceof Keyword) { |
| 1232 | $style = strtolower($args[1]->name); |
| 1233 | } |
| 1234 | return $this->formatCounter($count, $style); |
| 1235 | } |
| 1236 | return ''; |
| 1237 | } |
| 1238 | |
| 1239 | /** |
| 1240 | * Apply `counter-set: <name> [<int>]?` declarations — sets the |
| 1241 | * named counter to the specified value (default 0), without the |
| 1242 | * scope-creating semantics of `counter-reset`. CSS Lists 3 §6. |
| 1243 | */ |
| 1244 | private function applyCounterSet(CascadedValues $values): void |
| 1245 | { |
| 1246 | $value = $values->get('counter-set'); |
| 1247 | $this->forEachCounterPair($value, function (string $name, int $defaultOrSpecified): void { |
| 1248 | $this->counters[$name] = $defaultOrSpecified; |
| 1249 | }, defaultValue: 0); |
| 1250 | } |
| 1251 | |
| 1252 | /** |
| 1253 | * Apply `counter-increment: <name> [<int>]?` declarations — bumps the |
| 1254 | * named counter by the specified delta (default +1). |
| 1255 | */ |
| 1256 | private function applyCounterIncrement(CascadedValues $values): void |
| 1257 | { |
| 1258 | $value = $values->get('counter-increment'); |
| 1259 | $this->forEachCounterPair($value, function (string $name, int $delta): void { |
| 1260 | $this->counters[$name] = ($this->counters[$name] ?? 0) + $delta; |
| 1261 | }, defaultValue: 1); |
| 1262 | } |
| 1263 | |
| 1264 | /** |
| 1265 | * Walk a `counter-reset` / `counter-increment` value and invoke the |
| 1266 | * callback for each `<name> [<int>]?` pair encountered. Handles single |
| 1267 | * Keyword, single Keyword + Integer, and Space-separated `ValueList` |
| 1268 | * shapes. Skips when the value is the `none` keyword. |
| 1269 | * |
| 1270 | * @param \Closure(string, int): void $cb |
| 1271 | */ |
| 1272 | private function forEachCounterPair(?\Phpdftk\Css\Value\Value $value, \Closure $cb, int $defaultValue): void |
| 1273 | { |
| 1274 | if ($value === null |
| 1275 | || ($value instanceof Keyword && strtolower($value->name) === 'none') |
| 1276 | ) { |
| 1277 | return; |
| 1278 | } |
| 1279 | if ($value instanceof Keyword) { |
| 1280 | $cb($value->name, $defaultValue); |
| 1281 | return; |
| 1282 | } |
| 1283 | if ($value instanceof \Phpdftk\Css\Value\ValueList) { |
| 1284 | $items = $value->values; |
| 1285 | $i = 0; |
| 1286 | $n = count($items); |
| 1287 | while ($i < $n) { |
| 1288 | if (!($items[$i] instanceof Keyword)) { |
| 1289 | $i++; |
| 1290 | continue; |
| 1291 | } |
| 1292 | $name = $items[$i]->name; |
| 1293 | if ($i + 1 < $n && $items[$i + 1] instanceof \Phpdftk\Css\Value\Integer) { |
| 1294 | $cb($name, $items[$i + 1]->value); |
| 1295 | $i += 2; |
| 1296 | } else { |
| 1297 | $cb($name, $defaultValue); |
| 1298 | $i++; |
| 1299 | } |
| 1300 | } |
| 1301 | } |
| 1302 | } |
| 1303 | |
| 1304 | /** |
| 1305 | * Format `$count` per `$style`. Supports the CSS Counter Styles 3 §6 |
| 1306 | * predefined styles: |
| 1307 | * |
| 1308 | * - decimal, decimal-leading-zero |
| 1309 | * - lower-alpha / upper-alpha (aliases lower-latin / upper-latin) |
| 1310 | * - lower-roman / upper-roman |
| 1311 | * - lower-greek (α β γ ...) |
| 1312 | * - cjk-decimal (Chinese decimal — uses the same arabic digits but |
| 1313 | * appended with U+3001 punctuation per browsers' implementation) |
| 1314 | * - hebrew (Hebrew letter numerals 1-999) |
| 1315 | * - armenian / lower-armenian / upper-armenian (1-9999) |
| 1316 | * - georgian (1-19999) |
| 1317 | * - hiragana / hiragana-iroha (Japanese kana ordering) |
| 1318 | * - katakana / katakana-iroha |
| 1319 | * |
| 1320 | * Unknown style names fall back to decimal. |
| 1321 | */ |
| 1322 | private function formatCounter(int $count, string $style): string |
| 1323 | { |
| 1324 | return match ($style) { |
| 1325 | 'decimal-leading-zero' => sprintf('%02d', $count), |
| 1326 | 'lower-alpha', 'lower-latin' => $this->bijectiveBase26($count, lower: true), |
| 1327 | 'upper-alpha', 'upper-latin' => $this->bijectiveBase26($count, lower: false), |
| 1328 | 'lower-roman' => strtolower($this->roman($count)), |
| 1329 | 'upper-roman' => $this->roman($count), |
| 1330 | 'lower-greek' => $this->lowerGreek($count), |
| 1331 | 'hebrew' => $this->hebrew($count), |
| 1332 | 'armenian', 'upper-armenian' => $this->armenian($count, lower: false), |
| 1333 | 'lower-armenian' => $this->armenian($count, lower: true), |
| 1334 | 'georgian' => $this->georgian($count), |
| 1335 | 'hiragana' => $this->kanaList($count, [ |
| 1336 | 'あ','い','う','え','お','か','き','く','け','こ', |
| 1337 | 'さ','し','す','せ','そ','た','ち','つ','て','と', |
| 1338 | 'な','に','ぬ','ね','の','は','ひ','ふ','へ','ほ', |
| 1339 | 'ま','み','む','め','も','や','ゆ','よ','ら','り', |
| 1340 | 'る','れ','ろ','わ','ゐ','ゑ','を','ん', |
| 1341 | ]), |
| 1342 | 'hiragana-iroha' => $this->kanaList($count, [ |
| 1343 | 'い','ろ','は','に','ほ','へ','と','ち','り','ぬ', |
| 1344 | 'る','を','わ','か','よ','た','れ','そ','つ','ね', |
| 1345 | 'な','ら','む','う','ゐ','の','お','く','や','ま', |
| 1346 | 'け','ふ','こ','え','て','あ','さ','き','ゆ','め', |
| 1347 | 'み','し','ゑ','ひ','も','せ','す', |
| 1348 | ]), |
| 1349 | 'katakana' => $this->kanaList($count, [ |
| 1350 | 'ア','イ','ウ','エ','オ','カ','キ','ク','ケ','コ', |
| 1351 | 'サ','シ','ス','セ','ソ','タ','チ','ツ','テ','ト', |
| 1352 | 'ナ','ニ','ヌ','ネ','ノ','ハ','ヒ','フ','ヘ','ホ', |
| 1353 | 'マ','ミ','ム','メ','モ','ヤ','ユ','ヨ','ラ','リ', |
| 1354 | 'ル','レ','ロ','ワ','ヰ','ヱ','ヲ','ン', |
| 1355 | ]), |
| 1356 | 'katakana-iroha' => $this->kanaList($count, [ |
| 1357 | 'イ','ロ','ハ','ニ','ホ','ヘ','ト','チ','リ','ヌ', |
| 1358 | 'ル','ヲ','ワ','カ','ヨ','タ','レ','ソ','ツ','ネ', |
| 1359 | 'ナ','ラ','ム','ウ','ヰ','ノ','オ','ク','ヤ','マ', |
| 1360 | 'ケ','フ','コ','エ','テ','ア','サ','キ','ユ','メ', |
| 1361 | 'ミ','シ','ヱ','ヒ','モ','セ','ス', |
| 1362 | ]), |
| 1363 | default => (string) $count, |
| 1364 | }; |
| 1365 | } |
| 1366 | |
| 1367 | /** |
| 1368 | * CSS Counter Styles 3 §6.4 — lower-greek. 1-24 maps to α-ω |
| 1369 | * (skipping final-σ and using ς in position 18 per the spec |
| 1370 | * actually uses non-final sigma). Values outside 1-24 wrap |
| 1371 | * via bijective base-24 over the alphabet. |
| 1372 | */ |
| 1373 | private function lowerGreek(int $n): string |
| 1374 | { |
| 1375 | $alphabet = [ |
| 1376 | 'α','β','γ','δ','ε','ζ','η','θ','ι','κ','λ','μ', |
| 1377 | 'ν','ξ','ο','π','ρ','σ','τ','υ','φ','χ','ψ','ω', |
| 1378 | ]; |
| 1379 | if ($n < 1) { |
| 1380 | return (string) $n; |
| 1381 | } |
| 1382 | $out = ''; |
| 1383 | while ($n > 0) { |
| 1384 | $n--; |
| 1385 | $out = $alphabet[$n % 24] . $out; |
| 1386 | $n = intdiv($n, 24); |
| 1387 | } |
| 1388 | return $out; |
| 1389 | } |
| 1390 | |
| 1391 | /** |
| 1392 | * CSS Counter Styles 3 §6.5 — hebrew numerals 1-999. |
| 1393 | * Out-of-range falls back to decimal. |
| 1394 | */ |
| 1395 | private function hebrew(int $n): string |
| 1396 | { |
| 1397 | if ($n < 1 || $n > 999) { |
| 1398 | return (string) $n; |
| 1399 | } |
| 1400 | $map = [ |
| 1401 | 400 => 'ת', 300 => 'ש', 200 => 'ר', 100 => 'ק', |
| 1402 | 90 => 'צ', 80 => 'פ', 70 => 'ע', 60 => 'ס', |
| 1403 | 50 => 'נ', 40 => 'מ', 30 => 'ל', 20 => 'כ', |
| 1404 | 19 => 'יט', 18 => 'יח', 17 => 'יז', 16 => 'טז', 15 => 'טו', |
| 1405 | 14 => 'יד', 13 => 'יג', 12 => 'יב', 11 => 'יא', |
| 1406 | 10 => 'י', |
| 1407 | 9 => 'ט', 8 => 'ח', 7 => 'ז', 6 => 'ו', 5 => 'ה', |
| 1408 | 4 => 'ד', 3 => 'ג', 2 => 'ב', 1 => 'א', |
| 1409 | ]; |
| 1410 | $out = ''; |
| 1411 | foreach ($map as $v => $s) { |
| 1412 | while ($n >= $v) { |
| 1413 | $out .= $s; |
| 1414 | $n -= $v; |
| 1415 | } |
| 1416 | } |
| 1417 | return $out; |
| 1418 | } |
| 1419 | |
| 1420 | /** |
| 1421 | * CSS Counter Styles 3 §6.7 — Armenian numerals. Range 1-9999. |
| 1422 | */ |
| 1423 | private function armenian(int $n, bool $lower): string |
| 1424 | { |
| 1425 | if ($n < 1 || $n > 9999) { |
| 1426 | return (string) $n; |
| 1427 | } |
| 1428 | $upperOnes = ['Ա','Բ','Գ','Դ','Ե','Զ','Է','Ը','Թ']; |
| 1429 | $upperTens = ['Ժ','Ի','Լ','Խ','Ծ','Կ','Հ','Ձ','Ղ']; |
| 1430 | $upperHundreds = ['Ճ','Մ','Յ','Ն','Շ','Ո','Չ','Պ','Ջ']; |
| 1431 | $upperThousands = ['Ռ','Ս','Վ','Տ','Ր','Ց','Ւ','Փ','Ք']; |
| 1432 | $thousands = intdiv($n, 1000); |
| 1433 | $hundreds = intdiv($n % 1000, 100); |
| 1434 | $tens = intdiv($n % 100, 10); |
| 1435 | $ones = $n % 10; |
| 1436 | $out = ''; |
| 1437 | if ($thousands > 0) { |
| 1438 | $out .= $upperThousands[$thousands - 1]; |
| 1439 | } |
| 1440 | if ($hundreds > 0) { |
| 1441 | $out .= $upperHundreds[$hundreds - 1]; |
| 1442 | } |
| 1443 | if ($tens > 0) { |
| 1444 | $out .= $upperTens[$tens - 1]; |
| 1445 | } |
| 1446 | if ($ones > 0) { |
| 1447 | $out .= $upperOnes[$ones - 1]; |
| 1448 | } |
| 1449 | return $lower ? mb_strtolower($out, 'UTF-8') : $out; |
| 1450 | } |
| 1451 | |
| 1452 | /** |
| 1453 | * CSS Counter Styles 3 §6.6 — Georgian numerals (Mkhedruli). |
| 1454 | * Range 1-19999. |
| 1455 | */ |
| 1456 | private function georgian(int $n): string |
| 1457 | { |
| 1458 | if ($n < 1 || $n > 19999) { |
| 1459 | return (string) $n; |
| 1460 | } |
| 1461 | $ones = ['ა','ბ','გ','დ','ე','ვ','ზ','ჱ','თ']; |
| 1462 | $tens = ['ი','კ','ლ','მ','ნ','ჲ','ო','პ','ჟ']; |
| 1463 | $hundreds = ['რ','ს','ტ','ჳ','ფ','ქ','ღ','ყ','შ']; |
| 1464 | $thousands = ['ჩ','ც','ძ','წ','ჭ','ხ','ჴ','ჯ','ჰ']; |
| 1465 | $tt = intdiv($n, 10000); |
| 1466 | $h = intdiv($n % 10000, 1000); |
| 1467 | $t = intdiv($n % 1000, 100); |
| 1468 | $te = intdiv($n % 100, 10); |
| 1469 | $o = $n % 10; |
| 1470 | $out = ''; |
| 1471 | if ($tt > 0) { |
| 1472 | $out .= 'ჵ'; |
| 1473 | } |
| 1474 | if ($h > 0) { |
| 1475 | $out .= $thousands[$h - 1]; |
| 1476 | } |
| 1477 | if ($t > 0) { |
| 1478 | $out .= $hundreds[$t - 1]; |
| 1479 | } |
| 1480 | if ($te > 0) { |
| 1481 | $out .= $tens[$te - 1]; |
| 1482 | } |
| 1483 | if ($o > 0) { |
| 1484 | $out .= $ones[$o - 1]; |
| 1485 | } |
| 1486 | return $out; |
| 1487 | } |
| 1488 | |
| 1489 | /** |
| 1490 | * Generic kana / alphabetic style — bijective expansion over |
| 1491 | * the supplied symbol list. Used by hiragana / katakana |
| 1492 | * (gojuon + iroha orderings). |
| 1493 | * |
| 1494 | * @param list<string> $symbols |
| 1495 | */ |
| 1496 | private function kanaList(int $n, array $symbols): string |
| 1497 | { |
| 1498 | if ($n < 1 || $symbols === []) { |
| 1499 | return (string) $n; |
| 1500 | } |
| 1501 | $base = count($symbols); |
| 1502 | $out = ''; |
| 1503 | while ($n > 0) { |
| 1504 | $n--; |
| 1505 | $out = $symbols[$n % $base] . $out; |
| 1506 | $n = intdiv($n, $base); |
| 1507 | } |
| 1508 | return $out; |
| 1509 | } |
| 1510 | |
| 1511 | private function bijectiveBase26(int $n, bool $lower): string |
| 1512 | { |
| 1513 | if ($n <= 0) { |
| 1514 | return (string) $n; |
| 1515 | } |
| 1516 | $out = ''; |
| 1517 | while ($n > 0) { |
| 1518 | $n--; |
| 1519 | $out = chr(($lower ? ord('a') : ord('A')) + ($n % 26)) . $out; |
| 1520 | $n = intdiv($n, 26); |
| 1521 | } |
| 1522 | return $out; |
| 1523 | } |
| 1524 | |
| 1525 | private function roman(int $n): string |
| 1526 | { |
| 1527 | if ($n < 1 || $n > 3999) { |
| 1528 | return (string) $n; |
| 1529 | } |
| 1530 | $map = [ |
| 1531 | 1000 => 'M', 900 => 'CM', 500 => 'D', 400 => 'CD', |
| 1532 | 100 => 'C', 90 => 'XC', 50 => 'L', 40 => 'XL', |
| 1533 | 10 => 'X', 9 => 'IX', 5 => 'V', 4 => 'IV', 1 => 'I', |
| 1534 | ]; |
| 1535 | $out = ''; |
| 1536 | foreach ($map as $v => $s) { |
| 1537 | while ($n >= $v) { |
| 1538 | $out .= $s; |
| 1539 | $n -= $v; |
| 1540 | } |
| 1541 | } |
| 1542 | return $out; |
| 1543 | } |
| 1544 | |
| 1545 | /** @param list<Box> $inlineGroup */ |
| 1546 | private function flushInlineGroup(Box $parent, CascadedValues $values, array $inlineGroup): void |
| 1547 | { |
| 1548 | if ($inlineGroup === []) { |
| 1549 | return; |
| 1550 | } |
| 1551 | // CSS 2.1 §9.2.2.1 / Display 3 §3.4 — anonymous block boxes that |
| 1552 | // contain only whitespace text are removed during box generation. |
| 1553 | // The whitespace was already going to collapse to nothing in |
| 1554 | // inline layout, but keeping the wrapper as an empty box still |
| 1555 | // breaks adjacent-sibling margin collapse on the parent (the |
| 1556 | // first sibling's `margin-bottom` no longer adjoins the next |
| 1557 | // sibling's `margin-top`). |
| 1558 | if ($this->onlyCollapsibleWhitespace($inlineGroup, $values)) { |
| 1559 | return; |
| 1560 | } |
| 1561 | // CSS Display 3 §3.4 — anonymous block boxes have no element |
| 1562 | // and inherit only the inheritable properties from their |
| 1563 | // parent. Crucially, non-inherited properties (background, |
| 1564 | // width, height, border, padding, margin, …) MUST stay at |
| 1565 | // their initial values; otherwise an anonymous wrapper around |
| 1566 | // a run of whitespace text inside a `width: 100px; height: |
| 1567 | // 100px; background: black` parent would paint a second |
| 1568 | // 100×100 black rect at the cursor. |
| 1569 | $anonValues = $this->cascade->anonymousFromParent($values); |
| 1570 | $anon = new AnonymousBlockBox(null, $anonValues); |
| 1571 | foreach ($inlineGroup as $c) { |
| 1572 | $anon->addChild($c); |
| 1573 | } |
| 1574 | $parent->addChild($anon); |
| 1575 | } |
| 1576 | |
| 1577 | /** |
| 1578 | * True when every entry in `$inlineGroup` is a TextBox carrying only |
| 1579 | * ASCII / Unicode collapsible whitespace — and the parent's |
| 1580 | * `white-space` value lets that whitespace collapse. Whitespace- |
| 1581 | * preserving values (`pre`, `pre-wrap`, `pre-line`, `break-spaces`) |
| 1582 | * keep the run, since the spec says we have to lay them out. |
| 1583 | * |
| 1584 | * @param list<Box> $inlineGroup |
| 1585 | */ |
| 1586 | private function onlyCollapsibleWhitespace(array $inlineGroup, CascadedValues $values): bool |
| 1587 | { |
| 1588 | $whiteSpace = $values->get('white-space'); |
| 1589 | if ($whiteSpace instanceof Keyword) { |
| 1590 | $kw = strtolower($whiteSpace->name); |
| 1591 | if ($kw === 'pre' || $kw === 'pre-wrap' || $kw === 'pre-line' || $kw === 'break-spaces') { |
| 1592 | return false; |
| 1593 | } |
| 1594 | } |
| 1595 | foreach ($inlineGroup as $box) { |
| 1596 | if (!$box instanceof TextBox) { |
| 1597 | return false; |
| 1598 | } |
| 1599 | if (preg_match('/^[\s\x{200B}]*$/u', $box->text) !== 1) { |
| 1600 | return false; |
| 1601 | } |
| 1602 | } |
| 1603 | return true; |
| 1604 | } |
| 1605 | |
| 1606 | /** |
| 1607 | * Drop direct TextBox children whose text is entirely collapsible |
| 1608 | * whitespace when the parent is a flex / grid container, matching |
| 1609 | * the "anonymous whitespace flex item is not rendered" rule in |
| 1610 | * CSS Flexbox 1 §4 (echoed by CSS Grid Layout 2 §6). |
| 1611 | * |
| 1612 | * @param list<Box> $rawChildren |
| 1613 | * @return list<Box> |
| 1614 | */ |
| 1615 | private function stripWhitespaceTextChildren(array $rawChildren, CascadedValues $values): array |
| 1616 | { |
| 1617 | $whiteSpace = $values->get('white-space'); |
| 1618 | if ($whiteSpace instanceof Keyword) { |
| 1619 | $kw = strtolower($whiteSpace->name); |
| 1620 | if ($kw === 'pre' || $kw === 'pre-wrap' || $kw === 'pre-line' || $kw === 'break-spaces') { |
| 1621 | return $rawChildren; |
| 1622 | } |
| 1623 | } |
| 1624 | $out = []; |
| 1625 | foreach ($rawChildren as $child) { |
| 1626 | if ($child instanceof TextBox |
| 1627 | && preg_match('/^[\s\x{200B}]*$/u', $child->text) === 1 |
| 1628 | ) { |
| 1629 | continue; |
| 1630 | } |
| 1631 | $out[] = $child; |
| 1632 | } |
| 1633 | return $out; |
| 1634 | } |
| 1635 | |
| 1636 | private function makeBox(Element $element, CascadedValues $values, string $display): Box |
| 1637 | { |
| 1638 | return match ($display) { |
| 1639 | 'inline' => new InlineBox($element, $values), |
| 1640 | 'inline-block', 'inline-table', 'inline-flex', 'inline-grid' |
| 1641 | => new AtomicInlineBox($element, $values), |
| 1642 | 'table' => new TableBox($element, $values), |
| 1643 | 'table-row' => new TableRowBox($element, $values), |
| 1644 | 'table-cell' => new TableCellBox($element, $values), |
| 1645 | 'table-column', 'table-column-group' => new TableColumnBox($element, $values), |
| 1646 | 'flex' => new FlexBox($element, $values), |
| 1647 | 'grid' => new GridBox($element, $values), |
| 1648 | default => new BlockBox($element, $values), |
| 1649 | }; |
| 1650 | } |
| 1651 | |
| 1652 | /** |
| 1653 | * Root foreign-content elements (`<math>` and `<svg>`) route |
| 1654 | * through dedicated atomic-inline painters that resolve their |
| 1655 | * own positioning via `resolveInlineAbsoluteOrigin`. They must |
| 1656 | * NOT be blockified by the CSS Display §2.7 out-of-flow rule |
| 1657 | * because the generic block pipeline doesn't know how to |
| 1658 | * delegate to those painters. See the `mathml/spaces/space-3` |
| 1659 | * regression note where blockification dropped the inline-math |
| 1660 | * paint entirely. |
| 1661 | */ |
| 1662 | private function isForeignContentRoot(Element $element): bool |
| 1663 | { |
| 1664 | return self::foreignContentKind($element) !== null; |
| 1665 | } |
| 1666 | |
| 1667 | /** |
| 1668 | * Classify an element as a foreign-content root: `'svg'`, `'math'`, |
| 1669 | * or `null`. Handles both the standard namespaced form (`<svg>` in |
| 1670 | * the SVG namespace) AND the prefixed XHTML form (`<svg:svg>` / |
| 1671 | * `<math:math>`), which the HTML parser leaves as a plain element |
| 1672 | * with `localName` like `"svg:svg"` and the HTML namespace — the |
| 1673 | * prefix then identifies the foreign content. |
| 1674 | */ |
| 1675 | public static function foreignContentKind(Element $element): ?string |
| 1676 | { |
| 1677 | $tag = strtolower($element->localName); |
| 1678 | $colon = strrpos($tag, ':'); |
| 1679 | $prefixed = $colon !== false; |
| 1680 | $local = $prefixed ? substr($tag, $colon + 1) : $tag; |
| 1681 | $ns = $element->namespaceUri(); |
| 1682 | if ($local === 'math' && ($prefixed || $ns === \Phpdftk\Mathml\Parser::MATHML_NS)) { |
| 1683 | return 'math'; |
| 1684 | } |
| 1685 | if ($local === 'svg' && ($prefixed || $ns === \Phpdftk\Svg\Parser::SVG_NS)) { |
| 1686 | return 'svg'; |
| 1687 | } |
| 1688 | return null; |
| 1689 | } |
| 1690 | |
| 1691 | /** |
| 1692 | * CSS 2.1 §9.7 — an element is "out-of-flow" when its `position` |
| 1693 | * is `absolute` / `fixed` or its `float` is `left` / `right`. |
| 1694 | * Out-of-flow elements are blockified: an inline-level computed |
| 1695 | * `display` becomes `block`. |
| 1696 | */ |
| 1697 | private function isOutOfFlow(CascadedValues $values): bool |
| 1698 | { |
| 1699 | $position = $values->get('position'); |
| 1700 | if ($position instanceof Keyword) { |
| 1701 | $name = strtolower($position->name); |
| 1702 | if ($name === 'absolute' || $name === 'fixed') { |
| 1703 | return true; |
| 1704 | } |
| 1705 | } |
| 1706 | $float = $values->get('float'); |
| 1707 | if ($float instanceof Keyword) { |
| 1708 | $name = strtolower($float->name); |
| 1709 | if ($name === 'left' || $name === 'right') { |
| 1710 | return true; |
| 1711 | } |
| 1712 | } |
| 1713 | return false; |
| 1714 | } |
| 1715 | |
| 1716 | /** |
| 1717 | * `true` when the cascaded display establishes a flex or grid |
| 1718 | * formatting context, so its children are flex / grid items whose |
| 1719 | * min/max sizing is owned by the flex / grid algorithm rather than |
| 1720 | * the replaced-element box-gen constraint pass. |
| 1721 | */ |
| 1722 | private function isFlexOrGridContainer(CascadedValues $values): bool |
| 1723 | { |
| 1724 | return in_array( |
| 1725 | $this->displayKeyword($values), |
| 1726 | ['flex', 'inline-flex', 'grid', 'inline-grid'], |
| 1727 | true, |
| 1728 | ); |
| 1729 | } |
| 1730 | |
| 1731 | private function displayKeyword(CascadedValues $values): string |
| 1732 | { |
| 1733 | $display = $values->get('display'); |
| 1734 | if ($display instanceof Keyword) { |
| 1735 | return strtolower($display->name); |
| 1736 | } |
| 1737 | // CSS Display 3 §2 — `display: <outside> <inside>` (the two- |
| 1738 | // keyword syntax, e.g. `display: inline grid` / |
| 1739 | // `display: inline grid-lanes`) parses as a ValueList of |
| 1740 | // Keywords. We compose them into the canonical single |
| 1741 | // keyword form (`inline-grid`, `inline-grid-lanes`) so the |
| 1742 | // rest of BoxGenerator's `match ($display)` paths keep |
| 1743 | // working as before. |
| 1744 | if ($display instanceof \Phpdftk\Css\Value\ValueList) { |
| 1745 | $names = []; |
| 1746 | foreach ($display->values as $v) { |
| 1747 | if ($v instanceof Keyword) { |
| 1748 | $names[] = strtolower($v->name); |
| 1749 | } |
| 1750 | } |
| 1751 | if ($names !== []) { |
| 1752 | $outside = $names[0]; |
| 1753 | $inside = $names[1] ?? null; |
| 1754 | if ($outside === 'inline' && $inside !== null) { |
| 1755 | return 'inline-' . $inside; |
| 1756 | } |
| 1757 | if ($outside === 'block' && $inside !== null) { |
| 1758 | return $inside; |
| 1759 | } |
| 1760 | return implode('-', $names); |
| 1761 | } |
| 1762 | } |
| 1763 | return 'inline'; |
| 1764 | } |
| 1765 | |
| 1766 | /** |
| 1767 | * Returns `true` when the cascaded value is the property's initial |
| 1768 | * (or a `none` / `auto` keyword that's effectively initial for the |
| 1769 | * track-list properties). Used by the `display: grid-lanes` |
| 1770 | * aliasing to decide which axis the author specified tracks for. |
| 1771 | */ |
| 1772 | private function isInitialValue(?\Phpdftk\Css\Value\Value $value): bool |
| 1773 | { |
| 1774 | if ($value === null) { |
| 1775 | return true; |
| 1776 | } |
| 1777 | if ($value instanceof Keyword) { |
| 1778 | $name = strtolower($value->name); |
| 1779 | return $name === 'none' || $name === 'auto' || $name === 'initial'; |
| 1780 | } |
| 1781 | return false; |
| 1782 | } |
| 1783 | |
| 1784 | /** |
| 1785 | * CSS Display 3 §3.2 — expand a `display: contents` element's |
| 1786 | * children as if they were direct children of the parent that |
| 1787 | * called us. Nested `display: contents` elements flatten |
| 1788 | * recursively. Text node children become {@see TextBox}es |
| 1789 | * attached to the parent's text cascade (the parent passed in |
| 1790 | * `$parentValues`). |
| 1791 | * |
| 1792 | * Pseudo-element generation (`::before` / `::after`) on a |
| 1793 | * `display: contents` element is honoured because the cascade |
| 1794 | * runs as normal — the pseudos generate boxes which get |
| 1795 | * collected here just like any other child. |
| 1796 | * |
| 1797 | * @param list<Stylesheet> $sheets |
| 1798 | * @return list<Box> |
| 1799 | */ |
| 1800 | private function expandDisplayContents( |
| 1801 | Element $element, |
| 1802 | array $sheets, |
| 1803 | CascadedValues $parentValues, |
| 1804 | ): array { |
| 1805 | $values = $this->cascade->computeFor($sheets, $element, $parentValues); |
| 1806 | $this->applyPresentationalAttributes($element, $values); |
| 1807 | $result = []; |
| 1808 | // `::before` generated content participates in the flattened |
| 1809 | // child stream — it's defined on the display:contents element |
| 1810 | // so it visually still attaches "at" that element's position. |
| 1811 | $before = $this->makePseudoBox($element, $sheets, $values, 'before'); |
| 1812 | if ($before !== null) { |
| 1813 | $result[] = $before; |
| 1814 | } |
| 1815 | for ($n = $element->firstChild; $n !== null; $n = $n->nextSibling) { |
| 1816 | if ($n instanceof Element) { |
| 1817 | $childCascade = $this->cascade->computeFor($sheets, $n, $values); |
| 1818 | $this->applyPresentationalAttributes($n, $childCascade, $this->isFlexOrGridContainer($values)); |
| 1819 | if ($this->displayKeyword($childCascade) === 'contents') { |
| 1820 | foreach ($this->expandDisplayContents($n, $sheets, $values) as $g) { |
| 1821 | $result[] = $g; |
| 1822 | } |
| 1823 | continue; |
| 1824 | } |
| 1825 | $box = $this->buildElementBox($n, $sheets, $values); |
| 1826 | if ($box !== null) { |
| 1827 | $result[] = $box; |
| 1828 | } |
| 1829 | } elseif ($n instanceof Text) { |
| 1830 | if ($n->data === '') { |
| 1831 | continue; |
| 1832 | } |
| 1833 | // Text node children of a display:contents element |
| 1834 | // attach as TextBoxes using the element's own cascade |
| 1835 | // (so the contents-styled element's inherited |
| 1836 | // text properties still apply, even though the |
| 1837 | // element itself produces no box). |
| 1838 | $result[] = new TextBox($element, $values, $n->data); |
| 1839 | } |
| 1840 | } |
| 1841 | $after = $this->makePseudoBox($element, $sheets, $values, 'after'); |
| 1842 | if ($after !== null) { |
| 1843 | $result[] = $after; |
| 1844 | } |
| 1845 | return $result; |
| 1846 | } |
| 1847 | |
| 1848 | /** |
| 1849 | * Pre-CSS HTML attributes that map to CSS properties — `<img width>`, |
| 1850 | * `<img height>`, `<font color>` etc. Per HTML 5 §15.3, these |
| 1851 | * "presentational attributes" map into the user-agent style sheet at |
| 1852 | * the lowest specificity. We apply them after the cascade so any |
| 1853 | * author CSS still wins, but they provide the size to layout for |
| 1854 | * elements that lack explicit `width` / `height` declarations. |
| 1855 | */ |
| 1856 | private function applyPresentationalAttributes(Element $element, CascadedValues $values, bool $parentIsFlexOrGrid = false): void |
| 1857 | { |
| 1858 | $tag = strtolower($element->localName); |
| 1859 | if ($tag === 'img' || $tag === 'embed' || $tag === 'iframe' || $tag === 'video') { |
| 1860 | foreach (['width', 'height'] as $attr) { |
| 1861 | if ($values->has($attr) && !$this->isAutoLength($values->get($attr))) { |
| 1862 | continue; // author CSS wins |
| 1863 | } |
| 1864 | $raw = $element->getAttribute($attr); |
| 1865 | if ($raw === null) { |
| 1866 | continue; |
| 1867 | } |
| 1868 | // HTML 5 §2.4.4.4 dimension values allow a trailing `%`; |
| 1869 | // browsers map `<img width="100%">` to a CSS percentage on |
| 1870 | // the replaced box (resolved against its containing block at |
| 1871 | // layout time), not the intrinsic size. |
| 1872 | $pct = $this->parseHtmlPercentage($raw); |
| 1873 | if ($pct !== null) { |
| 1874 | $values->set($attr, new \Phpdftk\Css\Value\Percentage($pct)); |
| 1875 | continue; |
| 1876 | } |
| 1877 | $px = $this->parseHtmlLength($raw); |
| 1878 | if ($px !== null) { |
| 1879 | $values->set($attr, new \Phpdftk\Css\Value\Length($px, \Phpdftk\Css\Value\LengthUnit::Px)); |
| 1880 | } |
| 1881 | } |
| 1882 | // Intrinsic-dimension fallback for `<img src="data:image/...">` |
| 1883 | // when neither CSS nor HTML attributes provide width/height. |
| 1884 | // Decode the data URL once via ImageParser::parseString so layout |
| 1885 | // gets the natural pixel dimensions, then derive missing sides |
| 1886 | // from the aspect ratio when exactly one dimension is given |
| 1887 | // (CSS Images 3 §3.3 "used image dimensions"). |
| 1888 | if ($tag === 'img') { |
| 1889 | $wValue = $values->get('width'); |
| 1890 | $hValue = $values->get('height'); |
| 1891 | // CSS Sizing 3 §5.2 — `min-content` / `max-content` / |
| 1892 | // `fit-content` on a replaced element resolve to its |
| 1893 | // intrinsic (natural) size, so for dimension derivation |
| 1894 | // they behave like `auto`. |
| 1895 | $wUnset = !$values->has('width') || $this->isReplacedSizeAuto($wValue); |
| 1896 | $hUnset = !$values->has('height') || $this->isReplacedSizeAuto($hValue); |
| 1897 | // Replaced elements have an intrinsic aspect ratio |
| 1898 | // per CSS Sizing 4 §5.1. Expose it as the |
| 1899 | // `aspect-ratio` cascade value (when the author |
| 1900 | // hasn't overridden it) so layout primitives like |
| 1901 | // `aspectRatioTransfer` and Flexbox §4.5 automatic |
| 1902 | // minimum sizing can read it without re-decoding |
| 1903 | // the image. |
| 1904 | $natural = $this->naturalImageSize($element->getAttribute('src')); |
| 1905 | if ($natural !== null) { |
| 1906 | [$nw, $nh] = $natural; |
| 1907 | if ($nw > 0 && $nh > 0 && !$values->has('aspect-ratio')) { |
| 1908 | $values->set( |
| 1909 | 'aspect-ratio', |
| 1910 | new \Phpdftk\Css\Value\ValueList( |
| 1911 | [ |
| 1912 | new \Phpdftk\Css\Value\Number((float) $nw), |
| 1913 | new \Phpdftk\Css\Value\Number((float) $nh), |
| 1914 | ], |
| 1915 | \Phpdftk\Css\Value\ListSeparator::Slash, |
| 1916 | ), |
| 1917 | ); |
| 1918 | } |
| 1919 | } |
| 1920 | if ($wUnset || $hUnset) { |
| 1921 | if ($natural !== null) { |
| 1922 | [$nw, $nh] = $natural; |
| 1923 | if ($wUnset && $hUnset) { |
| 1924 | // Both dimensions auto / intrinsic: start at the |
| 1925 | // natural size, then apply any length `min/max- |
| 1926 | // width|height` preserving the aspect ratio |
| 1927 | // (CSS 2.1 §10.4 constraint-violation table — |
| 1928 | // e.g. `max-width: 100px` on a 150x150 image |
| 1929 | // yields 100x100, not 100x150). Percentage / |
| 1930 | // keyword min-max are left to the layout-time |
| 1931 | // replaced clamp. A flex / grid *item*'s min/max |
| 1932 | // is resolved by the flex / grid algorithm (which |
| 1933 | // also transfers through the ratio), so leave the |
| 1934 | // natural size untouched there to avoid double- |
| 1935 | // applying the constraint. |
| 1936 | [$uw, $uh] = $parentIsFlexOrGrid |
| 1937 | ? [(float) $nw, (float) $nh] |
| 1938 | : $this->constrainReplacedNaturalSize((float) $nw, (float) $nh, $values); |
| 1939 | $values->set('width', new \Phpdftk\Css\Value\Length($uw, \Phpdftk\Css\Value\LengthUnit::Px)); |
| 1940 | $values->set('height', new \Phpdftk\Css\Value\Length($uh, \Phpdftk\Css\Value\LengthUnit::Px)); |
| 1941 | } elseif ($wUnset && $hValue instanceof \Phpdftk\Css\Value\Length && $nh > 0) { |
| 1942 | // Under `box-sizing: border-box`, the declared |
| 1943 | // height includes the padding/border vertical |
| 1944 | // inset, so derive the content height first, |
| 1945 | // then add the horizontal inset to land on the |
| 1946 | // declared width that produces the right |
| 1947 | // content-width-to-content-height ratio. |
| 1948 | [$hInset, $vInset, $borderBox] = $this->presentationalInsetsAndBoxSizing($values); |
| 1949 | $declaredH = $hValue->value; |
| 1950 | $contentH = $borderBox ? max(0.0, $declaredH - $vInset) : $declaredH; |
| 1951 | $contentW = $contentH * ($nw / $nh); |
| 1952 | $declaredW = $borderBox ? ($contentW + $hInset) : $contentW; |
| 1953 | $values->set( |
| 1954 | 'width', |
| 1955 | new \Phpdftk\Css\Value\Length( |
| 1956 | $declaredW, |
| 1957 | \Phpdftk\Css\Value\LengthUnit::Px, |
| 1958 | ), |
| 1959 | ); |
| 1960 | } elseif ($hUnset && $wValue instanceof \Phpdftk\Css\Value\Length && $nw > 0) { |
| 1961 | [$hInset, $vInset, $borderBox] = $this->presentationalInsetsAndBoxSizing($values); |
| 1962 | $declaredW = $wValue->value; |
| 1963 | $contentW = $borderBox ? max(0.0, $declaredW - $hInset) : $declaredW; |
| 1964 | $contentH = $contentW * ($nh / $nw); |
| 1965 | $declaredH = $borderBox ? ($contentH + $vInset) : $contentH; |
| 1966 | $values->set( |
| 1967 | 'height', |
| 1968 | new \Phpdftk\Css\Value\Length( |
| 1969 | $declaredH, |
| 1970 | \Phpdftk\Css\Value\LengthUnit::Px, |
| 1971 | ), |
| 1972 | ); |
| 1973 | } |
| 1974 | } |
| 1975 | } |
| 1976 | } |
| 1977 | } |
| 1978 | // HTML 5 §4.12.5 — a `<canvas>` is a replaced element whose |
| 1979 | // intrinsic dimensions are its `width` / `height` content |
| 1980 | // attributes (default 300 x 150). When BOTH are given |
| 1981 | // explicitly, apply them as presentational width/height (unless |
| 1982 | // the author set CSS dims, mirroring the `<img>` path) so block |
| 1983 | // layout sizes the canvas, and expose the intrinsic ratio as |
| 1984 | // `aspect-ratio` so the replaced-element keyword sizing can |
| 1985 | // transfer a definite cross size into a `min-content` / |
| 1986 | // `max-content` main size. An attribute-less canvas keeps the |
| 1987 | // default 300 x 150 handled downstream — forcing that default |
| 1988 | // here disturbs cases (object-view-box, vertical writing modes) |
| 1989 | // that already render correctly. Skip under `contain: size`, |
| 1990 | // where CSS Containment 3 §4.1 substitutes `contain-intrinsic- |
| 1991 | // size` for the intrinsic size. |
| 1992 | $canvasW = $tag === 'canvas' ? $this->parseHtmlLength($element->getAttribute('width') ?? '') : null; |
| 1993 | $canvasH = $tag === 'canvas' ? $this->parseHtmlLength($element->getAttribute('height') ?? '') : null; |
| 1994 | if ($canvasW !== null && $canvasW > 0.0 |
| 1995 | && $canvasH !== null && $canvasH > 0.0 |
| 1996 | && !$this->hasSizeContainment($values) |
| 1997 | ) { |
| 1998 | foreach (['width' => $canvasW, 'height' => $canvasH] as $attr => $val) { |
| 1999 | if (!$values->has($attr) || $this->isAutoLength($values->get($attr))) { |
| 2000 | $values->set($attr, new \Phpdftk\Css\Value\Length($val, \Phpdftk\Css\Value\LengthUnit::Px)); |
| 2001 | } |
| 2002 | } |
| 2003 | if (!$values->has('aspect-ratio')) { |
| 2004 | $values->set( |
| 2005 | 'aspect-ratio', |
| 2006 | new \Phpdftk\Css\Value\ValueList( |
| 2007 | [ |
| 2008 | new \Phpdftk\Css\Value\Number($canvasW), |
| 2009 | new \Phpdftk\Css\Value\Number($canvasH), |
| 2010 | ], |
| 2011 | \Phpdftk\Css\Value\ListSeparator::Slash, |
| 2012 | ), |
| 2013 | ); |
| 2014 | } |
| 2015 | } |
| 2016 | // HTML 5 §4.4.5.1: `<ol type="A">` / `"a"` / `"I"` / `"i"` / `"1"` |
| 2017 | // maps to a `list-style-type` keyword. `<ul type="..."` is the |
| 2018 | // older HTML 4 form; supported because real-world docs still use |
| 2019 | // it. |
| 2020 | if ($tag === 'ol' || $tag === 'ul') { |
| 2021 | $type = $element->getAttribute('type'); |
| 2022 | if ($type !== null && $type !== '') { |
| 2023 | $keyword = match ($type) { |
| 2024 | '1' => 'decimal', |
| 2025 | 'A' => 'upper-alpha', |
| 2026 | 'a' => 'lower-alpha', |
| 2027 | 'I' => 'upper-roman', |
| 2028 | 'i' => 'lower-roman', |
| 2029 | 'disc', 'circle', 'square' => $type, |
| 2030 | default => null, |
| 2031 | }; |
| 2032 | if ($keyword !== null) { |
| 2033 | // Author CSS still wins: only apply when the cascade |
| 2034 | // hasn't already set a non-default value. |
| 2035 | $current = $values->get('list-style-type'); |
| 2036 | $defaulted = $current instanceof Keyword |
| 2037 | && in_array(strtolower($current->name), ['disc', 'decimal'], true); |
| 2038 | if (!$values->has('list-style-type') || $defaulted) { |
| 2039 | $values->set('list-style-type', new Keyword($keyword)); |
| 2040 | } |
| 2041 | } |
| 2042 | } |
| 2043 | } |
| 2044 | } |
| 2045 | |
| 2046 | /** |
| 2047 | * Sum the horizontal and vertical padding + border lengths from a |
| 2048 | * cascaded-values bundle, plus whether `box-sizing` is `border-box`. |
| 2049 | * Used by the `<img>` intrinsic-ratio derivation to compute the |
| 2050 | * declared dimension that produces the right content dimension once |
| 2051 | * border-box subtracts the inset. |
| 2052 | * |
| 2053 | * @return array{0: float, 1: float, 2: bool} |
| 2054 | */ |
| 2055 | private function presentationalInsetsAndBoxSizing(\Phpdftk\Css\Cascade\CascadedValues $values): array |
| 2056 | { |
| 2057 | $sumLength = function (string $property) use ($values): float { |
| 2058 | $v = $values->get($property); |
| 2059 | return $v instanceof \Phpdftk\Css\Value\Length |
| 2060 | ? \Phpdftk\Css\Cascade\LengthResolver::clampPx($v->value) |
| 2061 | : 0.0; |
| 2062 | }; |
| 2063 | $borderSide = function (string $side) use ($values): float { |
| 2064 | $styleValue = $values->get("border-$side-style"); |
| 2065 | if ($styleValue instanceof Keyword && strtolower($styleValue->name) === 'none') { |
| 2066 | return 0.0; |
| 2067 | } |
| 2068 | $width = $values->get("border-$side-width"); |
| 2069 | if ($width instanceof \Phpdftk\Css\Value\Length) { |
| 2070 | return \Phpdftk\Css\Cascade\LengthResolver::clampPx($width->value); |
| 2071 | } |
| 2072 | if ($width instanceof Keyword) { |
| 2073 | return match (strtolower($width->name)) { |
| 2074 | 'thin' => 1.0, |
| 2075 | 'medium' => 3.0, |
| 2076 | 'thick' => 5.0, |
| 2077 | default => 0.0, |
| 2078 | }; |
| 2079 | } |
| 2080 | return 0.0; |
| 2081 | }; |
| 2082 | $hInset = $sumLength('padding-left') + $sumLength('padding-right') |
| 2083 | + $borderSide('left') + $borderSide('right'); |
| 2084 | $vInset = $sumLength('padding-top') + $sumLength('padding-bottom') |
| 2085 | + $borderSide('top') + $borderSide('bottom'); |
| 2086 | $boxSizing = $values->get('box-sizing'); |
| 2087 | $borderBox = $boxSizing instanceof Keyword |
| 2088 | && strtolower($boxSizing->name) === 'border-box'; |
| 2089 | return [$hInset, $vInset, $borderBox]; |
| 2090 | } |
| 2091 | |
| 2092 | private function isAutoLength(?\Phpdftk\Css\Value\Value $v): bool |
| 2093 | { |
| 2094 | return $v instanceof Keyword && strtolower($v->name) === 'auto'; |
| 2095 | } |
| 2096 | |
| 2097 | /** |
| 2098 | * `true` when a replaced element's width/height is `auto` or one of |
| 2099 | * the intrinsic sizing keywords (`min-content` / `max-content` / |
| 2100 | * `fit-content`), all of which resolve to the natural dimension for |
| 2101 | * dimension derivation. {@see isAutoLength} but also matching the |
| 2102 | * content keywords. |
| 2103 | */ |
| 2104 | private function isReplacedSizeAuto(?\Phpdftk\Css\Value\Value $v): bool |
| 2105 | { |
| 2106 | return $v instanceof Keyword |
| 2107 | && in_array(strtolower($v->name), ['auto', 'min-content', 'max-content', 'fit-content'], true); |
| 2108 | } |
| 2109 | |
| 2110 | /** |
| 2111 | * CSS 2.1 §10.4 — given a replaced element's natural size and a |
| 2112 | * cascaded-values bundle, apply any *length* `min/max-width|height` |
| 2113 | * while preserving the aspect ratio. Percentage and keyword |
| 2114 | * constraints are skipped here (no containing block at box-gen time; |
| 2115 | * the layout-time replaced clamp handles those). The clamp order |
| 2116 | * (max then min, width before height) approximates the §10.4 |
| 2117 | * constraint-violation table for the common single-constraint cases. |
| 2118 | * |
| 2119 | * @return array{0: float, 1: float} used [width, height] |
| 2120 | */ |
| 2121 | private function constrainReplacedNaturalSize(float $w, float $h, \Phpdftk\Css\Cascade\CascadedValues $values): array |
| 2122 | { |
| 2123 | if ($h <= 0.0 || $w <= 0.0) { |
| 2124 | return [$w, $h]; |
| 2125 | } |
| 2126 | $ratio = $w / $h; |
| 2127 | $len = static function (?\Phpdftk\Css\Value\Value $v): ?float { |
| 2128 | return $v instanceof \Phpdftk\Css\Value\Length && $v->value >= 0.0 ? $v->value : null; |
| 2129 | }; |
| 2130 | $maxW = $len($values->get('max-width')); |
| 2131 | $maxH = $len($values->get('max-height')); |
| 2132 | $minW = $len($values->get('min-width')); |
| 2133 | $minH = $len($values->get('min-height')); |
| 2134 | if ($maxW !== null && $w > $maxW) { |
| 2135 | $w = $maxW; |
| 2136 | $h = $w / $ratio; |
| 2137 | } |
| 2138 | if ($maxH !== null && $h > $maxH) { |
| 2139 | $h = $maxH; |
| 2140 | $w = $h * $ratio; |
| 2141 | } |
| 2142 | if ($minW !== null && $w < $minW) { |
| 2143 | $w = $minW; |
| 2144 | $h = $w / $ratio; |
| 2145 | } |
| 2146 | if ($minH !== null && $h < $minH) { |
| 2147 | $h = $minH; |
| 2148 | $w = $h * $ratio; |
| 2149 | } |
| 2150 | return [$w, $h]; |
| 2151 | } |
| 2152 | |
| 2153 | /** |
| 2154 | * CSS Containment 3 §2 — `true` when the cascaded `contain` enables |
| 2155 | * size containment: the `size` or `strict` keyword, or a list that |
| 2156 | * includes `size`. (`content` = layout|paint|style does NOT contain |
| 2157 | * size.) Under size containment a replaced element's intrinsic size |
| 2158 | * is taken from `contain-intrinsic-size`, so attribute / natural |
| 2159 | * dimensions must not be applied. |
| 2160 | */ |
| 2161 | private function hasSizeContainment(\Phpdftk\Css\Cascade\CascadedValues $values): bool |
| 2162 | { |
| 2163 | $contain = $values->get('contain'); |
| 2164 | if ($contain instanceof Keyword) { |
| 2165 | return in_array(strtolower($contain->name), ['size', 'strict'], true); |
| 2166 | } |
| 2167 | if ($contain instanceof \Phpdftk\Css\Value\ValueList) { |
| 2168 | foreach ($contain->values as $part) { |
| 2169 | if ($part instanceof Keyword && strtolower($part->name) === 'size') { |
| 2170 | return true; |
| 2171 | } |
| 2172 | } |
| 2173 | } |
| 2174 | return false; |
| 2175 | } |
| 2176 | |
| 2177 | /** |
| 2178 | * Read the natural pixel dimensions for an `<img src>` value. Returns |
| 2179 | * `[width, height]` or null when the URL isn't a recognised Phase-1 |
| 2180 | * variant, when local-file resolution fails security gates, or when |
| 2181 | * the underlying bytes don't parse as a supported image format. |
| 2182 | * |
| 2183 | * Supported sources: |
| 2184 | * - `data:image/{png,jpeg};base64,...` (and the rfc2397 non-base64 |
| 2185 | * form) — bytes go straight to `ImageParser::parseString`. |
| 2186 | * - relative or absolute filesystem paths — joined with `baseDir`, |
| 2187 | * must resolve under it via `realpath()`. Stream-wrapper URLs |
| 2188 | * (`http://`, `phar://`, etc.) are rejected. |
| 2189 | * |
| 2190 | * @return array{int, int}|null |
| 2191 | */ |
| 2192 | private function naturalImageSize(?string $src): ?array |
| 2193 | { |
| 2194 | if ($src === null || $src === '') { |
| 2195 | return null; |
| 2196 | } |
| 2197 | if (str_starts_with($src, 'data:')) { |
| 2198 | // Accept any `data:image/...` MIME, plus `data:image/svg+xml` |
| 2199 | // textual payloads. The sniffer in `ImageParser::parseString` |
| 2200 | // dispatches on the actual byte signature, so we don't gate |
| 2201 | // on the MIME label here — broader than the prior |
| 2202 | // `(png|jpeg|jpg)` allow-list and consistent with the |
| 2203 | // painter's permissive handling. |
| 2204 | if (preg_match('~^data:image/[^;,]+(?:;([^,]*))?,(.*)$~s', $src, $m) !== 1) { |
| 2205 | return null; |
| 2206 | } |
| 2207 | $parameters = strtolower($m[1]); |
| 2208 | $rawPayload = $m[2]; |
| 2209 | $payload = str_contains($parameters, 'base64') |
| 2210 | ? base64_decode($rawPayload, strict: true) |
| 2211 | : urldecode($rawPayload); |
| 2212 | if ($payload === false || $payload === '') { |
| 2213 | return null; |
| 2214 | } |
| 2215 | try { |
| 2216 | $info = \Phpdftk\ImageMetadata\ImageParser::parseString($payload); |
| 2217 | } catch (\Throwable) { |
| 2218 | return null; |
| 2219 | } |
| 2220 | return $info->width > 0 && $info->height > 0 |
| 2221 | ? [$info->width, $info->height] |
| 2222 | : null; |
| 2223 | } |
| 2224 | $resolved = $this->resolveLocalImagePath($src); |
| 2225 | if ($resolved === null) { |
| 2226 | return null; |
| 2227 | } |
| 2228 | try { |
| 2229 | $info = \Phpdftk\ImageMetadata\ImageParser::parse($resolved); |
| 2230 | } catch (\Throwable) { |
| 2231 | return null; |
| 2232 | } |
| 2233 | return $info->width > 0 && $info->height > 0 |
| 2234 | ? [$info->width, $info->height] |
| 2235 | : null; |
| 2236 | } |
| 2237 | |
| 2238 | /** |
| 2239 | * Resolve an `<img src>` value to a real local-file path, or null |
| 2240 | * when the path can't be confirmed safe. Delegates to the unified |
| 2241 | * `Phpdftk\Filesystem\ResourceLoader` so the BoxGenerator and the |
| 2242 | * painter share one resolver — they must agree on what's loadable |
| 2243 | * (the BoxGenerator decides layout, the painter fetches the bytes). |
| 2244 | */ |
| 2245 | private function resolveLocalImagePath(string $src): ?string |
| 2246 | { |
| 2247 | return (new \Phpdftk\Filesystem\ResourceLoader($this->baseDir, $this->sandboxRoot)) |
| 2248 | ->resolveLocalPath($src); |
| 2249 | } |
| 2250 | |
| 2251 | /** |
| 2252 | * HTML legacy `width` / `height` attributes: plain integer = pixels; |
| 2253 | * trailing `%` = percentage (not yet honoured here, returns null and |
| 2254 | * leaves the value at whatever the cascade said). Everything else is |
| 2255 | * rejected. |
| 2256 | */ |
| 2257 | /** |
| 2258 | * HTML 5 §2.4.4.4 — a dimension attribute value ending in `%` is a |
| 2259 | * percentage. Returns the numeric percentage (e.g. `100` for |
| 2260 | * `"100%"`), or null when the value isn't a percentage form. Callers |
| 2261 | * that only accept absolute pixel sizes (e.g. a `<canvas>` bitmap) |
| 2262 | * skip this and use {@see parseHtmlLength}. |
| 2263 | */ |
| 2264 | private function parseHtmlPercentage(string $raw): ?float |
| 2265 | { |
| 2266 | $raw = trim($raw); |
| 2267 | if (preg_match('/^(\d+(?:\.\d+)?)%$/', $raw, $m) === 1) { |
| 2268 | return (float) $m[1]; |
| 2269 | } |
| 2270 | return null; |
| 2271 | } |
| 2272 | |
| 2273 | private function parseHtmlLength(string $raw): ?float |
| 2274 | { |
| 2275 | $raw = trim($raw); |
| 2276 | if ($raw === '') { |
| 2277 | return null; |
| 2278 | } |
| 2279 | // HTML 5 §2.4.4.4 "rules for parsing dimension values" — skip |
| 2280 | // a leading number, then accept (and ignore) a trailing `px` |
| 2281 | // suffix per the relaxed dimension form. Browsers treat |
| 2282 | // `width="100"` and `width="100px"` identically on |
| 2283 | // `<img>` / `<embed>` / `<iframe>` / `<video>`; rejecting |
| 2284 | // the `px` form drops the value back to `width: auto` and |
| 2285 | // mis-sizes the replaced element. |
| 2286 | if (preg_match('/^(\d+(?:\.\d+)?)(?:px)?$/i', $raw, $m) === 1) { |
| 2287 | return (float) $m[1]; |
| 2288 | } |
| 2289 | return null; |
| 2290 | } |
| 2291 | |
| 2292 | /** @param list<Box> $boxes */ |
| 2293 | private function mixesBlockAndInline(array $boxes): bool |
| 2294 | { |
| 2295 | $hasBlock = false; |
| 2296 | $hasInline = false; |
| 2297 | foreach ($boxes as $b) { |
| 2298 | if ($this->isInlineLevel($b)) { |
| 2299 | $hasInline = true; |
| 2300 | } else { |
| 2301 | $hasBlock = true; |
| 2302 | } |
| 2303 | if ($hasBlock && $hasInline) { |
| 2304 | return true; |
| 2305 | } |
| 2306 | } |
| 2307 | return false; |
| 2308 | } |
| 2309 | |
| 2310 | private function isInlineLevel(Box $box): bool |
| 2311 | { |
| 2312 | return $box instanceof InlineBox |
| 2313 | || $box instanceof TextBox |
| 2314 | || $box instanceof AtomicInlineBox |
| 2315 | || $box instanceof LineBreakBox; |
| 2316 | } |
| 2317 | } |