Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
87.49% |
1385 / 1583 |
|
47.37% |
36 / 76 |
CRAP | |
0.00% |
0 / 1 |
| InlineLayout | |
87.49% |
1385 / 1583 |
|
47.37% |
36 / 76 |
995.13 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| layout | |
96.86% |
216 / 223 |
|
0.00% |
0 / 1 |
42 | |||
| applyVerticalLineShift | |
95.65% |
44 / 46 |
|
0.00% |
0 / 1 |
9 | |||
| commitVerticalAtomicGeometry | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
4 | |||
| layoutAtomicOnly | |
100.00% |
85 / 85 |
|
100.00% |
1 / 1 |
27 | |||
| resolveTabSize | |
93.75% |
15 / 16 |
|
0.00% |
0 / 1 |
4.00 | |||
| verticalInlineMeasure | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
6 | |||
| lineBounds | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
3.00 | |||
| applyTextOverflow | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
| appendEllipsisToLine | |
84.00% |
21 / 25 |
|
0.00% |
0 / 1 |
9.33 | |||
| clampHostLines | |
80.77% |
21 / 26 |
|
0.00% |
0 / 1 |
4.11 | |||
| lineExtent | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
| lineClampCount | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
6.05 | |||
| legacyWebkitClampApplies | |
55.56% |
5 / 9 |
|
0.00% |
0 / 1 |
7.19 | |||
| applyLineClamp | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
| trimFragmentToFit | |
97.30% |
36 / 37 |
|
0.00% |
0 / 1 |
5 | |||
| applyTextAlign | |
87.18% |
34 / 39 |
|
0.00% |
0 / 1 |
18.68 | |||
| lineUsedWidth | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
5 | |||
| isRtlDirection | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| resolveLogicalTextAlign | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
6 | |||
| isTextJustifyNone | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| textAlignLastKeyword | |
77.78% |
7 / 9 |
|
0.00% |
0 / 1 |
5.27 | |||
| textAlignKeyword | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| applyTextTransform | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| resolveLineHeight | |
83.33% |
10 / 12 |
|
0.00% |
0 / 1 |
7.23 | |||
| resolveTextIndent | |
50.00% |
3 / 6 |
|
0.00% |
0 / 1 |
4.12 | |||
| whiteSpaceKeyword | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| shiftFragments | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| justifyFragments | |
83.33% |
10 / 12 |
|
0.00% |
0 / 1 |
6.17 | |||
| collectTokens | |
100.00% |
32 / 32 |
|
100.00% |
1 / 1 |
7 | |||
| walkInline | |
87.56% |
190 / 217 |
|
0.00% |
0 / 1 |
35.10 | |||
| inlineInlineAxisInset | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| inlineSpacerToken | |
0.00% |
0 / 21 |
|
0.00% |
0 / 1 |
2 | |||
| fragmentAscent | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| fragmentDescent | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| finalizeLine | |
92.45% |
49 / 53 |
|
0.00% |
0 / 1 |
20.17 | |||
| resolveAtomicHeights | |
82.61% |
38 / 46 |
|
0.00% |
0 / 1 |
10.53 | |||
| commitAtomicFragmentY | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
4 | |||
| commitAtomicFragmentX | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| atomicBaselineFromMarginTop | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| fragmentExtents | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
5 | |||
| withBaselineShift | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
1 | |||
| boxFontSize | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| resolveWeight | |
50.00% |
5 / 10 |
|
0.00% |
0 / 1 |
13.12 | |||
| resolveStyle | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| resolveOpenTypeFeatures | |
72.86% |
51 / 70 |
|
0.00% |
0 / 1 |
26.22 | |||
| iterateKeywords | |
42.86% |
3 / 7 |
|
0.00% |
0 / 1 |
9.66 | |||
| resolveStretch | |
33.33% |
5 / 15 |
|
0.00% |
0 / 1 |
54.67 | |||
| resolveBoxFont | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
7 | |||
| allowsSynthesis | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| decorationLines | |
66.67% |
12 / 18 |
|
0.00% |
0 / 1 |
12.00 | |||
| mergeDecorationLines | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| resolveColor | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| resolveBackground | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| resolveDecorationColor | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| isBreakAll | |
81.25% |
13 / 16 |
|
0.00% |
0 / 1 |
10.66 | |||
| resolveVerticalAlign | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
5.07 | |||
| resolveVerticalAlignKeyword | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
4.05 | |||
| tokeniseText | |
94.12% |
64 / 68 |
|
0.00% |
0 / 1 |
23.11 | |||
| trimTrailingSpace | |
96.43% |
27 / 28 |
|
0.00% |
0 / 1 |
4 | |||
| trailingCollapsibleAdvance | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
6 | |||
| splitBidiRuns | |
88.00% |
44 / 50 |
|
0.00% |
0 / 1 |
17.50 | |||
| applyLetterSpacing | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
2 | |||
| resolveLetterSpacing | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| resolveWordSpacing | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| applyWordSpacing | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
3 | |||
| isWordSeparatorAt | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
6.17 | |||
| dominantFontSize | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| atomicLength | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| atomicBorderWidth | |
50.00% |
7 / 14 |
|
0.00% |
0 / 1 |
19.12 | |||
| atomicIsBorderBoxSizing | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| atomicDefiniteContentHeight | |
70.59% |
12 / 17 |
|
0.00% |
0 / 1 |
11.06 | |||
| atomicAspectRatio | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
9 | |||
| atomicNumeric | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| resolveAtomicMinMax | |
58.82% |
10 / 17 |
|
0.00% |
0 / 1 |
19.45 | |||
| clampAtomicReplaced | |
64.00% |
16 / 25 |
|
0.00% |
0 / 1 |
23.14 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\HtmlToPdf\Layout; |
| 6 | |
| 7 | use Phpdftk\Css\Cascade\WritingMode; |
| 8 | use Phpdftk\Css\Value\Length; |
| 9 | use Phpdftk\HtmlToPdf\Box\AtomicInlineBox; |
| 10 | use Phpdftk\HtmlToPdf\Box\Box; |
| 11 | use Phpdftk\HtmlToPdf\Box\InlineBox; |
| 12 | use Phpdftk\HtmlToPdf\Box\LineBreakBox; |
| 13 | use Phpdftk\HtmlToPdf\Box\TextBox; |
| 14 | use Phpdftk\Text\LineBreaker; |
| 15 | use Phpdftk\Text\LineBreakKind; |
| 16 | use Phpdftk\Text\Shaper; |
| 17 | use Phpdftk\Text\ShapedGlyph; |
| 18 | use Phpdftk\Text\ShapedRun; |
| 19 | use Phpdftk\Text\ShapingContext; |
| 20 | |
| 21 | /** |
| 22 | * Inline formatting context layout — Phase 1F.2 (text shaping + greedy |
| 23 | * line wrapping). |
| 24 | * |
| 25 | * Walks a parent block's inline children (InlineBox / AtomicInlineBox / |
| 26 | * TextBox subtrees), shapes their text via `phpdftk/text`'s Shaper, finds |
| 27 | * line-break opportunities via UAX #14, and greedily fits the resulting |
| 28 | * fragments into line boxes that respect the parent's content width. |
| 29 | * |
| 30 | * Phase-1 simplifications: |
| 31 | * - Single font per inline run (the layout context's `defaultFont`). |
| 32 | * Font runs / fallback live alongside paragraph shaping in Phase 2. |
| 33 | * - Bidi reorder is the bidi engine's job; this layout reads logical |
| 34 | * order and lays runs left-to-right. |
| 35 | * - Atomic inline boxes (replaced elements, inline-block) are treated as |
| 36 | * fixed-size boxes; sizing comes from the box style (width / height). |
| 37 | * - Line height defaults to `1.2 × font-size` per CSS Inline §3. |
| 38 | * |
| 39 | * When no font is available in the layout context, this layout falls back |
| 40 | * to producing zero-height (no-op) lines so block layout can still |
| 41 | * complete end-to-end on the test surface. |
| 42 | */ |
| 43 | final class InlineLayout |
| 44 | { |
| 45 | /** |
| 46 | * Captured from the LayoutContext at the start of each `layout()` |
| 47 | * call so `walkInline` can re-resolve fonts for nested `<code>` etc. |
| 48 | * without threading the resolver through every recursive parameter. |
| 49 | */ |
| 50 | private ?FontResolver $currentFontResolver = null; |
| 51 | |
| 52 | /** |
| 53 | * Captured at the top of each `layout()` call so atomic-inline |
| 54 | * width-percentage resolution (CSS Sizing 3 §6.2: % on an inline |
| 55 | * box resolves against its containing block) can see the basis |
| 56 | * without threading the value through `collectTokens` / |
| 57 | * `walkInline`. |
| 58 | */ |
| 59 | private float $currentAvailableWidth = 0.0; |
| 60 | |
| 61 | /** |
| 62 | * CSS Writing Modes 4 §3 — the measure lines WRAP against, when that |
| 63 | * differs from the physical width. Null in a horizontal writing mode |
| 64 | * (and in a vertical one whose block size is indefinite), which keeps |
| 65 | * every horizontal path byte-identical. |
| 66 | */ |
| 67 | private ?float $currentWrapMeasure = null; |
| 68 | |
| 69 | /** |
| 70 | * CSS Sizing 4 §6.4 — true when the container's BLOCK size (its |
| 71 | * physical width, in a vertical writing mode) is `auto` and will be |
| 72 | * set from this pass's column total. The `vrl` / `sideways-rl` |
| 73 | * transpose then anchors the columns against that total rather than |
| 74 | * against the pre-shrink physical width it was handed. |
| 75 | */ |
| 76 | private bool $currentBlockSizeAuto = false; |
| 77 | |
| 78 | /** |
| 79 | * True while laying out an inline formatting context whose block |
| 80 | * container is in a vertical writing mode (CSS Writing Modes 4 §3). |
| 81 | * The pass runs in a horizontal coordinate space and |
| 82 | * {@see applyVerticalLineShift} transposes it at the end, so this is |
| 83 | * what tells the atomic-inline sizing which physical dimension is the |
| 84 | * INLINE one: an `<img>` keeps its physical `width`/`height`, so in a |
| 85 | * vertical mode its inline extent is its HEIGHT and its contribution |
| 86 | * to the line's cross-size is its WIDTH — the opposite of horizontal-tb. |
| 87 | */ |
| 88 | private bool $currentIsVertical = false; |
| 89 | |
| 90 | /** |
| 91 | * The containing block's content height and whether it is definite, |
| 92 | * threaded from the block layout so an atomic replaced element can |
| 93 | * resolve a percentage `height` / `max-height` / `min-height` and, |
| 94 | * via its intrinsic ratio, derive an auto inline size (CSS 2.1 |
| 95 | * §10.3.2). `0` / `false` when the CB height is indefinite. |
| 96 | */ |
| 97 | private float $currentCbHeight = 0.0; |
| 98 | |
| 99 | private bool $currentCbHeightDefinite = false; |
| 100 | |
| 101 | public function __construct( |
| 102 | private readonly Shaper $shaper = new Shaper(), |
| 103 | private readonly LineBreaker $lineBreaker = new LineBreaker(), |
| 104 | ) {} |
| 105 | |
| 106 | /** |
| 107 | * Lay out the inline children of `$parent` within `$availableWidth`, |
| 108 | * starting at Y = 0 in the parent's coordinate space. Returns the |
| 109 | * generated line boxes (positions relative to the parent's content |
| 110 | * area) and the total height consumed. |
| 111 | * |
| 112 | * @return array{list<LineBox>, float} (lines, totalHeight) |
| 113 | */ |
| 114 | public function layout( |
| 115 | Box $parent, |
| 116 | float $availableWidth, |
| 117 | LayoutContext $context, |
| 118 | ?float $inlineExtent = null, |
| 119 | bool $blockSizeAuto = false, |
| 120 | ): array { |
| 121 | $this->currentFontResolver = $context->fontResolver; |
| 122 | $this->currentAvailableWidth = $availableWidth; |
| 123 | $this->currentCbHeight = $context->containingBlockHeight; |
| 124 | $this->currentCbHeightDefinite = $context->inFlowHeightDefinite; |
| 125 | $this->currentWrapMeasure = $this->verticalInlineMeasure($parent, $inlineExtent); |
| 126 | $this->currentBlockSizeAuto = $blockSizeAuto; |
| 127 | $this->currentIsVertical = WritingMode::fromStyle($parent->style)->isVertical(); |
| 128 | if ($availableWidth <= 0.0) { |
| 129 | return [[], 0.0]; |
| 130 | } |
| 131 | // Resolve the shaping font + post-match synthetic-effect flags via |
| 132 | // CSS Fonts 4 §6 weight/style matching. When a real face matches |
| 133 | // the cascaded weight/style, `isBold`/`isItalic` come back false so |
| 134 | // the painter doesn't double up with fake-bold / fake-italic. |
| 135 | $parentMatch = $this->resolveBoxFont($parent, $context->defaultFont); |
| 136 | $font = $parentMatch['font']; |
| 137 | $fontSize = $this->dominantFontSize($parent, $context); |
| 138 | if ($font === null) { |
| 139 | // No font means text shaping is impossible — but inline |
| 140 | // replaced content (img, svg, math, inline-block divs) |
| 141 | // doesn't need a font for layout. Fall back to a minimal |
| 142 | // atomic-only pass that pulls width / height off the |
| 143 | // cascade so paintImage and the foreign-content painters |
| 144 | // (paintInlineSvg, paintInlineMath) see real geometry |
| 145 | // instead of the (0, 0, 0, 0) box the full early return |
| 146 | // used to produce. Closes #39 for the atomic-content |
| 147 | // case; text-bearing documents still need an explicit |
| 148 | // default font. |
| 149 | return $this->layoutAtomicOnly($parent); |
| 150 | } |
| 151 | $shapingCtx = new ShapingContext($font, $fontSize, features: $this->resolveOpenTypeFeatures($parent)); |
| 152 | $lineHeight = $this->resolveLineHeight($parent, $fontSize); |
| 153 | // CSS2 §10.8 strut: the block container's own font establishes a |
| 154 | // zero-width inline box that every line box contains, so an empty |
| 155 | // line or one of only-smaller text still reserves the block's line |
| 156 | // height. `lineMetrics` folds this in alongside the real fragments. |
| 157 | $strutAscent = ($font->ascent / max(1, $font->unitsPerEm)) * $fontSize; |
| 158 | $strutDescent = (abs($font->descent) / max(1, $font->unitsPerEm)) * $fontSize; |
| 159 | // x-height drives `vertical-align: middle` (box centre aligns with the |
| 160 | // baseline minus half the parent's x-height). Fall back to 0.5em when |
| 161 | // the font carries no x-height metric. |
| 162 | $strutXHeight = $font->xHeight > 0 |
| 163 | ? ($font->xHeight / max(1, $font->unitsPerEm)) * $fontSize |
| 164 | : $fontSize * 0.5; |
| 165 | $whiteSpace = $this->whiteSpaceKeyword($parent); |
| 166 | // CSS Text 3 §4 — wrap permission table: |
| 167 | // normal / pre-wrap / pre-line / break-spaces → allow soft wrap |
| 168 | // nowrap / pre → no soft wrap |
| 169 | $allowSoftWrap = $whiteSpace !== 'nowrap' && $whiteSpace !== 'pre'; |
| 170 | // CSS Text 3 §4 — leading-whitespace collapse table: |
| 171 | // normal / nowrap / pre-line → collapse leading whitespace |
| 172 | // pre / pre-wrap / break-spaces → preserve leading whitespace |
| 173 | // (`break-spaces` is `pre-wrap` plus the additional rule that |
| 174 | // every preserved space is a wrap opportunity. Leading-edge |
| 175 | // semantics match `pre-wrap` — both keep the leading run.) |
| 176 | $collapseLeadingWhitespace = $whiteSpace !== 'pre' |
| 177 | && $whiteSpace !== 'pre-wrap' |
| 178 | && $whiteSpace !== 'break-spaces'; |
| 179 | $collapseInternalWhitespace = $whiteSpace === 'normal' || $whiteSpace === 'nowrap'; |
| 180 | |
| 181 | $letterSpacing = $this->resolveLetterSpacing($parent); |
| 182 | $wordSpacing = $this->resolveWordSpacing($parent); |
| 183 | // CSS Text 3 §5.5 — when `pre-wrap` / `break-spaces` is in |
| 184 | // effect, trailing whitespace at the end of a line hangs. For |
| 185 | // that to work, the tokeniser must emit a separate token for |
| 186 | // each whitespace run; otherwise UAX-14's `XX<ws>` bundle |
| 187 | // hides the trailing ws from the line fitter. |
| 188 | $hangsTrailingWhitespacePreCompute = $whiteSpace === 'pre-wrap' || $whiteSpace === 'break-spaces'; |
| 189 | $tokens = $this->collectTokens( |
| 190 | $parent, |
| 191 | $shapingCtx, |
| 192 | $collapseInternalWhitespace, |
| 193 | $letterSpacing, |
| 194 | $wordSpacing, |
| 195 | baselineShift: 0.0, |
| 196 | lineHeight: $lineHeight, |
| 197 | verticalAlign: 'baseline', |
| 198 | href: null, |
| 199 | isBold: $parentMatch['isBold'], |
| 200 | isItalic: $parentMatch['isItalic'], |
| 201 | decorationLines: $this->decorationLines($parent), |
| 202 | textColor: $this->resolveColor($parent), |
| 203 | backgroundColor: null, |
| 204 | linkTitle: null, |
| 205 | decorationColor: $this->resolveDecorationColor($parent), |
| 206 | splitWsBoundaries: $hangsTrailingWhitespacePreCompute, |
| 207 | ); |
| 208 | if ($tokens === []) { |
| 209 | return [[], 0.0]; |
| 210 | } |
| 211 | |
| 212 | // CSS Text 3 §3.1: `text-indent` shifts the first inline box of the |
| 213 | // first formatted line. Length resolves directly; Percentage resolves |
| 214 | // against the block's content width (our `$availableWidth`). |
| 215 | $textIndent = $this->resolveTextIndent($parent, $availableWidth); |
| 216 | |
| 217 | // CSS 2.1 §9.5.3 — line boxes shorten on the side(s) where a |
| 218 | // float is currently active. Compute the per-line (left, right) |
| 219 | // bounds against the float context each time we start a new line. |
| 220 | // The strut is only a LOWER bound on the line's block extent; each |
| 221 | // token that joins the line may grow `$lineBand` (see below). |
| 222 | $lineBand = $lineHeight; |
| 223 | $bounds = $this->lineBounds($parent, $availableWidth, $context, 0.0, $lineBand); |
| 224 | $lines = []; |
| 225 | $currentFragments = []; |
| 226 | // CSS Text 3 §5.5 — when in `pre-wrap` / `break-spaces`, trailing |
| 227 | // whitespace at the end of a line hangs (renders past the line edge |
| 228 | // with zero contribution to line measurement). We mirror that by |
| 229 | // tracking which fragments in `$currentFragments` were whitespace |
| 230 | // so we can drop them from the line when wrapping. |
| 231 | $hangsTrailingWhitespace = $whiteSpace === 'pre-wrap' || $whiteSpace === 'break-spaces'; |
| 232 | /** @var list<bool> $currentFragmentIsWs */ |
| 233 | $currentFragmentIsWs = []; |
| 234 | // Advance of the collapsible whitespace the line currently ends with. |
| 235 | // CSS Text 3 §4.1.3 removes it, so it must come off the line's WIDTH |
| 236 | // as well as off the fit test — otherwise a line packed to its full |
| 237 | // measure reports as over-wide and `text-align` sees negative slack. |
| 238 | $currentTrailingSpace = 0.0; |
| 239 | $currentX = $bounds['left'] + $textIndent; |
| 240 | $lineMaxRight = $bounds['right']; |
| 241 | $atLineStart = true; |
| 242 | $y = 0.0; |
| 243 | foreach ($tokens as $token) { |
| 244 | $width = $token['shapedRun']->totalAdvance; |
| 245 | // CSS Text 3 §4.1.3 — a sequence of COLLAPSIBLE spaces at the end |
| 246 | // of a line is removed, so it must not decide whether the line |
| 247 | // fits. A break opportunity sits after the space (UAX #14), which |
| 248 | // makes "word + its trailing space" a single token; measuring the |
| 249 | // space here broke every line one space early, and a line crafted |
| 250 | // to fill its measure exactly lost its last word. |
| 251 | // |
| 252 | // `pre-wrap` / `break-spaces` keep their spaces (they hang |
| 253 | // instead), and that path already drops the fragments after the |
| 254 | // fact, so it is left alone. |
| 255 | $fitWidth = $hangsTrailingWhitespace |
| 256 | ? $width |
| 257 | : $width - $token['trailingSpace']; |
| 258 | $isMandatory = $token['kind'] === LineBreakKind::Mandatory; |
| 259 | |
| 260 | if ($collapseLeadingWhitespace && $token['isWhitespace'] && $atLineStart) { |
| 261 | // Leading whitespace at a line start is collapsed. |
| 262 | continue; |
| 263 | } |
| 264 | // CSS Shapes 1 §1.2 — an atomic inline's own block extent sets |
| 265 | // the band the float contour is resolved over, so it has to be |
| 266 | // known BEFORE this line's bounds are (re-)queried below. Only |
| 267 | // the height resolution moves up here; committing the box's |
| 268 | // geometry still waits until the inline cursor is final. |
| 269 | $atomic = $token['atomicBox'] ?? null; |
| 270 | $atomicContentHeight = 0.0; |
| 271 | $atomicOuterHeight = 0.0; |
| 272 | $atomicPadTop = 0.0; |
| 273 | $atomicPadBottom = 0.0; |
| 274 | $atomicBorderTop = 0.0; |
| 275 | $atomicBorderBottom = 0.0; |
| 276 | if ($atomic !== null) { |
| 277 | // The token captured the box-sizing-resolved content + |
| 278 | // outer widths; resolve the heights with the same |
| 279 | // semantics here. Falling back to width when height is |
| 280 | // unset keeps the square-replaced-element default |
| 281 | // (img with intrinsic ratio) the existing tests rely on. |
| 282 | // The token resolved the box-sizing-aware block size at |
| 283 | // creation time (the inline advance depends on it in a |
| 284 | // vertical writing mode, so it cannot wait until here). |
| 285 | [ |
| 286 | $atomicContentHeight, |
| 287 | $atomicOuterHeight, |
| 288 | $atomicPadTop, |
| 289 | $atomicPadBottom, |
| 290 | $atomicBorderTop, |
| 291 | $atomicBorderBottom, |
| 292 | ] = $token['atomicHeights'] ?? $this->resolveAtomicHeights( |
| 293 | $atomic, |
| 294 | (bool) ($token['atomicBorderBox'] ?? false), |
| 295 | $width, |
| 296 | ); |
| 297 | } |
| 298 | // A line may not break inside an inline box's own padding / |
| 299 | // border, so its spacer never opens a break opportunity: wrapping |
| 300 | // before it would strand the inset on the next line, away from |
| 301 | // the text it belongs to. |
| 302 | // A ZERO-WIDTH token always fits, so it must never trigger a |
| 303 | // wrap. Without this an already-overflowing line followed by a |
| 304 | // `<br>` breaks BEFORE the break itself: the `<br>` lands alone |
| 305 | // on a line of its own and everything after is pushed a whole |
| 306 | // line down. `border-padding-bleed-001` overflows its 596px |
| 307 | // measure with 640px of Ahem text, so its `<br>` produced a third |
| 308 | // line box where two are correct. |
| 309 | if ($allowSoftWrap |
| 310 | && $fitWidth > 0.0 |
| 311 | && ($token['noWrapBefore'] ?? false) === false |
| 312 | && $currentX + $fitWidth > $lineMaxRight |
| 313 | && $currentFragments !== [] |
| 314 | ) { |
| 315 | // Wrap before placing this token. |
| 316 | // For `pre-wrap` / `break-spaces`: trailing whitespace at the |
| 317 | // end of the current line "hangs" — drop those fragments so |
| 318 | // they don't push the line width and don't get re-emitted on |
| 319 | // the next line. The overflowing whitespace token that |
| 320 | // triggered this wrap also hangs (we drop it below). |
| 321 | if ($hangsTrailingWhitespace) { |
| 322 | while ($currentFragmentIsWs !== [] && end($currentFragmentIsWs) === true) { |
| 323 | array_pop($currentFragments); |
| 324 | array_pop($currentFragmentIsWs); |
| 325 | } |
| 326 | // Re-derive currentX from the surviving fragments so |
| 327 | // line-width-based math (e.g. alignment) sees the |
| 328 | // post-hang width. |
| 329 | $currentX = $currentFragments === [] |
| 330 | ? $bounds['left'] + $textIndent |
| 331 | : end($currentFragments)->x + end($currentFragments)->width; |
| 332 | } |
| 333 | $currentFragments = $this->trimTrailingSpace($currentFragments, $currentTrailingSpace); |
| 334 | $currentTrailingSpace = 0.0; |
| 335 | [$effective, $lineBase, $currentFragments] = $this->finalizeLine($currentFragments, $strutAscent, $strutDescent, $lineHeight, $strutXHeight); |
| 336 | $this->commitAtomicFragmentY($parent, $y, $lineBase, $currentFragments); |
| 337 | $lines[] = new LineBox($y, $effective, $currentFragments, $lineBase, $lineMaxRight); |
| 338 | $y += $effective; |
| 339 | $currentFragments = []; |
| 340 | $currentFragmentIsWs = []; |
| 341 | $lineBand = $lineHeight; |
| 342 | $bounds = $this->lineBounds($parent, $availableWidth, $context, $y, $lineBand); |
| 343 | $currentX = $bounds['left']; |
| 344 | $lineMaxRight = $bounds['right']; |
| 345 | $atLineStart = true; |
| 346 | if ($collapseLeadingWhitespace && $token['isWhitespace']) { |
| 347 | // Drop whitespace at start of next line. |
| 348 | continue; |
| 349 | } |
| 350 | if ($hangsTrailingWhitespace && $token['isWhitespace']) { |
| 351 | // The overflowing whitespace hangs on the prior line — |
| 352 | // don't carry it to the new line. |
| 353 | continue; |
| 354 | } |
| 355 | } |
| 356 | // CSS Shapes 1 §1.2 — the float area is resolved per LINE BOX, |
| 357 | // over that line's own block extent. The strut is only a lower |
| 358 | // bound on it: a `line-height: 0` block whose lines are built |
| 359 | // from 36px-tall inline-blocks has a 36px band, and a curved or |
| 360 | // sloped contour intrudes far further across that band than it |
| 361 | // does at the line's top edge. So grow the band as each token |
| 362 | // joins the line and re-query the contour. Fragments already on |
| 363 | // the line shift by the difference, which is what re-running the |
| 364 | // line against the tighter bounds would have produced; the |
| 365 | // dominant case — an atomic that is the line's first token — |
| 366 | // has nothing to shift at all. |
| 367 | $tokenBand = $atomic !== null |
| 368 | ? $atomicOuterHeight |
| 369 | + (float) ($token['atomicMarginTop'] ?? 0.0) |
| 370 | + (float) ($token['atomicMarginBottom'] ?? 0.0) |
| 371 | : max(0.0, (float) ($token['lineHeight'] ?? -1.0)); |
| 372 | if ($tokenBand > $lineBand + 0.001) { |
| 373 | $lineBand = $tokenBand; |
| 374 | $rebanded = $this->lineBounds($parent, $availableWidth, $context, $y, $lineBand); |
| 375 | $dx = $rebanded['left'] - $bounds['left']; |
| 376 | if ($dx !== 0.0 && $currentFragments !== []) { |
| 377 | $currentFragments = $this->shiftFragments($currentFragments, $dx); |
| 378 | $this->commitAtomicFragmentX($parent, $currentFragments); |
| 379 | } |
| 380 | $currentX += $dx; |
| 381 | $bounds = $rebanded; |
| 382 | $lineMaxRight = $rebanded['right']; |
| 383 | } |
| 384 | $currentFragments[] = new InlineFragment( |
| 385 | $currentX, |
| 386 | $width, |
| 387 | $token['shapedRun'], |
| 388 | $token['baselineShift'] ?? 0.0, |
| 389 | $token['href'] ?? null, |
| 390 | $token['isBold'] ?? false, |
| 391 | $token['isItalic'] ?? false, |
| 392 | $token['decorationLines'] ?? [], |
| 393 | $token['textColor'] ?? null, |
| 394 | $token['backgroundColor'] ?? null, |
| 395 | $token['linkTitle'] ?? null, |
| 396 | $token['decorationColor'] ?? null, |
| 397 | (bool) $token['isWhitespace'], |
| 398 | $token['lineHeight'] ?? -1.0, |
| 399 | $token['verticalAlign'] ?? 'baseline', |
| 400 | atomicBox: $token['atomicBox'] ?? null, |
| 401 | bgExtendAbove: $token['bgExtendAbove'] ?? 0.0, |
| 402 | bgExtendBelow: $token['bgExtendBelow'] ?? 0.0, |
| 403 | ); |
| 404 | $currentFragmentIsWs[] = (bool) $token['isWhitespace']; |
| 405 | $currentTrailingSpace = $hangsTrailingWhitespace ? 0.0 : $token['trailingSpace']; |
| 406 | // Side-channel: AtomicInlineBox positions get committed back to |
| 407 | // the box's geometry so the painter can draw images / replaced |
| 408 | // content at the right spot. CSS Inline 3 §4.5: for the default |
| 409 | // `vertical-align: baseline`, the inline-block's baseline aligns |
| 410 | // with the parent line's baseline; for replaced elements like |
| 411 | // `<img>` the baseline is the bottom of the box. So position |
| 412 | // the box so its *bottom* sits at the line's baseline (line.y + |
| 413 | // ascent of the shaping font) — same convention the painter |
| 414 | // uses for text baselines. |
| 415 | if ($atomic !== null) { |
| 416 | $shapedRun = $token['shapedRun']; |
| 417 | $atomicFont = $shapedRun->font; |
| 418 | $atomicUpem = max(1, $atomicFont->unitsPerEm); |
| 419 | $atomicAscent = ($atomicFont->ascent / $atomicUpem) * $shapedRun->fontSizePt; |
| 420 | // Outer box top-left is `(currentX, y + ascent - outerHeight)`; |
| 421 | // the content box sits inside the padding + border edges. |
| 422 | $atomicContentWidth = $token['atomicContentWidth'] ?? $width; |
| 423 | $atomicPadLeft = $token['atomicPadLeft'] ?? 0.0; |
| 424 | $atomicBorderLeft = $token['atomicBorderLeft'] ?? 0.0; |
| 425 | $atomicPadRight = $token['atomicPadRight'] ?? 0.0; |
| 426 | $atomicBorderRight = $token['atomicBorderRight'] ?? 0.0; |
| 427 | $atomicMarginLeft = $token['atomicMarginLeft'] ?? 0.0; |
| 428 | $atomicMarginRight = $token['atomicMarginRight'] ?? 0.0; |
| 429 | $atomicMarginTop = $token['atomicMarginTop'] ?? 0.0; |
| 430 | $atomicMarginBottom = $token['atomicMarginBottom'] ?? 0.0; |
| 431 | // `$currentX` sits at the item's margin-box start; step past |
| 432 | // the left margin (plus border + padding) to the content box. |
| 433 | $atomic->geometry->x = $parent->geometry->x + $currentX |
| 434 | + $atomicMarginLeft + $atomicBorderLeft + $atomicPadLeft; |
| 435 | // CSS 2.2 §10.8 — the inline-block's baseline (no in-flow line |
| 436 | // boxes here) is its bottom margin edge, so the margin box |
| 437 | // bottom aligns with the line baseline; the border box bottom |
| 438 | // sits a bottom-margin above it. |
| 439 | $atomic->geometry->y = $parent->geometry->y + $y |
| 440 | + $atomicAscent - $atomicOuterHeight - $atomicMarginBottom |
| 441 | + $atomicBorderTop + $atomicPadTop; |
| 442 | $atomic->geometry->width = $atomicContentWidth; |
| 443 | $atomic->geometry->height = $atomicContentHeight; |
| 444 | $atomic->geometry->paddingLeft = $atomicPadLeft; |
| 445 | $atomic->geometry->paddingRight = $atomicPadRight; |
| 446 | $atomic->geometry->paddingTop = $atomicPadTop; |
| 447 | $atomic->geometry->paddingBottom = $atomicPadBottom; |
| 448 | $atomic->geometry->borderLeft = $atomicBorderLeft; |
| 449 | $atomic->geometry->borderRight = $atomicBorderRight; |
| 450 | $atomic->geometry->borderTop = $atomicBorderTop; |
| 451 | $atomic->geometry->borderBottom = $atomicBorderBottom; |
| 452 | $atomic->geometry->marginLeft = $atomicMarginLeft; |
| 453 | $atomic->geometry->marginRight = $atomicMarginRight; |
| 454 | $atomic->geometry->marginTop = $atomicMarginTop; |
| 455 | $atomic->geometry->marginBottom = $atomicMarginBottom; |
| 456 | } |
| 457 | $currentX += $width; |
| 458 | $atLineStart = false; |
| 459 | if ($isMandatory) { |
| 460 | $currentFragments = $this->trimTrailingSpace($currentFragments, $currentTrailingSpace); |
| 461 | $currentTrailingSpace = 0.0; |
| 462 | [$effective, $lineBase, $currentFragments] = $this->finalizeLine($currentFragments, $strutAscent, $strutDescent, $lineHeight, $strutXHeight); |
| 463 | $this->commitAtomicFragmentY($parent, $y, $lineBase, $currentFragments); |
| 464 | $lines[] = new LineBox($y, $effective, $currentFragments, $lineBase, $lineMaxRight); |
| 465 | $y += $effective; |
| 466 | $currentFragments = []; |
| 467 | $currentFragmentIsWs = []; |
| 468 | $lineBand = $lineHeight; |
| 469 | $bounds = $this->lineBounds($parent, $availableWidth, $context, $y, $lineBand); |
| 470 | $currentX = $bounds['left']; |
| 471 | $lineMaxRight = $bounds['right']; |
| 472 | $atLineStart = true; |
| 473 | } |
| 474 | } |
| 475 | if ($currentFragments !== []) { |
| 476 | $currentFragments = $this->trimTrailingSpace($currentFragments, $currentTrailingSpace); |
| 477 | $currentTrailingSpace = 0.0; |
| 478 | [$effective, $lineBase, $currentFragments] = $this->finalizeLine($currentFragments, $strutAscent, $strutDescent, $lineHeight, $strutXHeight); |
| 479 | $this->commitAtomicFragmentY($parent, $y, $lineBase, $currentFragments); |
| 480 | $lines[] = new LineBox($y, $effective, $currentFragments, $lineBase, $lineMaxRight); |
| 481 | $y += $effective; |
| 482 | } |
| 483 | |
| 484 | // CSS Overflow 4 line-clamp: keep only the first N line boxes, mark |
| 485 | // the last retained one with a block ellipsis, and shrink the block's |
| 486 | // height to those lines. Runs before text-overflow so a clamped line |
| 487 | // that also overflows horizontally isn't given two ellipses, and |
| 488 | // before text-align so alignment sees the retained rect. |
| 489 | $clamp = $this->lineClampCount($parent); |
| 490 | if ($clamp !== null && count($lines) > $clamp) { |
| 491 | [$lines, $y] = $this->applyLineClamp($lines, $clamp, $availableWidth, $shapingCtx, $letterSpacing); |
| 492 | } |
| 493 | |
| 494 | // CSS UI 3 §6.2: `text-overflow: ellipsis` truncates each line's |
| 495 | // tail when its content exceeds the available width. Runs before |
| 496 | // text-align so the alignment math operates on the truncated rect. |
| 497 | $lines = $this->applyTextOverflow($lines, $availableWidth, $parent, $shapingCtx, $letterSpacing); |
| 498 | |
| 499 | $lines = $this->applyTextAlign($lines, $availableWidth, $parent); |
| 500 | // text-align shifts each line's fragments; re-commit any atomic / |
| 501 | // replaced box's inline-axis geometry so an <img> / inline-block on a |
| 502 | // centred / right-aligned / justified line paints at the shifted |
| 503 | // position rather than the line's pre-alignment start. Horizontal |
| 504 | // writing modes only — in a vertical mode the inline axis is Y and the |
| 505 | // atomic's geometry is (re)placed by the transpose in |
| 506 | // applyVerticalLineShift below, so an X commit here would fight it. |
| 507 | if (!WritingMode::fromStyle($parent->style)->isVertical()) { |
| 508 | foreach ($lines as $line) { |
| 509 | $this->commitAtomicFragmentX($parent, $line->fragments); |
| 510 | } |
| 511 | } |
| 512 | // CSS Writing Modes 4 §3 — for a vertical-mode block container |
| 513 | // hosting an inline formatting context, lines should sit at the |
| 514 | // block-start edge of the container's content area: |
| 515 | // - `vrl` / `sideways-rl`: block-start = right edge → shift the |
| 516 | // line's fragments rightward by (availableWidth - lineWidth) |
| 517 | // so the visible content lands at the right. |
| 518 | // - `vlr` / `sideways-lr`: block-start = left edge → no shift. |
| 519 | // |
| 520 | // Phase-4 scaffold: text glyphs still lay out horizontally |
| 521 | // (per the current Shaper-driven flow) — full vertical text |
| 522 | // shaping with rotated glyphs comes in a later commit. The |
| 523 | // immediate win is that single-line atomic inline content |
| 524 | // (`<img>`, inline-block divs) lands at the visually-correct |
| 525 | // block-start edge in `vrl` containers. |
| 526 | $lines = $this->applyVerticalLineShift($lines, $availableWidth, $parent); |
| 527 | return [$lines, $y]; |
| 528 | } |
| 529 | |
| 530 | /** |
| 531 | * @param list<LineBox> $lines |
| 532 | * @return list<LineBox> |
| 533 | */ |
| 534 | private function applyVerticalLineShift(array $lines, float $availableWidth, Box $parent): array |
| 535 | { |
| 536 | $wm = WritingMode::fromStyle($parent->style); |
| 537 | if (!$wm->isVertical()) { |
| 538 | return $lines; |
| 539 | } |
| 540 | // CSS WM 4 §3 — transpose the horizontal IFC to the vertical block flow. |
| 541 | // Each line becomes a vertical column: the line's INLINE offset (the |
| 542 | // fragment's x — where `text-indent` / `text-align` placed it) moves to |
| 543 | // the vertical axis (`line.y`, which the painter reads as the column's |
| 544 | // vertical start), and columns stack along the BLOCK axis (physical x), |
| 545 | // each occupying the line's cross-size (`height`). `vlr` / `sideways-lr` |
| 546 | // grow rightward from the left content edge; `vrl` / `sideways-rl` grow |
| 547 | // leftward from the right edge (block-start = right). The painter's |
| 548 | // vertical branch (paintFragment) rotates glyphs 90° CW, reads |
| 549 | // `fragment.x` as the column's left edge and `line.y` as its top. |
| 550 | // |
| 551 | // Increment 2: EVERY line (single- or multi-fragment) transposes |
| 552 | // into one vertical column. All fragments of a source line share the |
| 553 | // column's block-axis position (`fragment.x = $blockLeft`); each |
| 554 | // keeps its inline advance as a `blockOffset` DOWN the column so the |
| 555 | // painter stacks them. `LineBox.y` becomes 0 (the column top; the |
| 556 | // per-fragment offset now carries the inline position). The former |
| 557 | // single-fragment special case is the degenerate blockOffset=x case |
| 558 | // of this loop. |
| 559 | $rtl = $wm->blockDirection() === -1; |
| 560 | // CSS Sizing 4 §6.4 — the `vrl` / `sideways-rl` columns are |
| 561 | // anchored to the container's block-START edge, i.e. the RIGHT |
| 562 | // edge of its content box. With an `auto` block size that edge is |
| 563 | // not `$availableWidth` (the pre-shrink stretch value handed to |
| 564 | // this pass) but the column total the caller is about to adopt as |
| 565 | // the used width — anchor against that instead, or the columns |
| 566 | // land off the shrunk box. |
| 567 | $anchor = $availableWidth; |
| 568 | if ($this->currentBlockSizeAuto) { |
| 569 | $anchor = 0.0; |
| 570 | foreach ($lines as $line) { |
| 571 | if ($line->height > 0.0) { |
| 572 | $anchor += $line->height; |
| 573 | } |
| 574 | } |
| 575 | } |
| 576 | $out = []; |
| 577 | $cumBlock = 0.0; |
| 578 | foreach ($lines as $line) { |
| 579 | // Transpose needs a real cross-size to place the column along the |
| 580 | // block axis; a degenerate line box (`line-height: 0`) has none, |
| 581 | // so pass it through rather than seed a zero-width column. |
| 582 | if ($line->height <= 0.0) { |
| 583 | $out[] = $line; |
| 584 | continue; |
| 585 | } |
| 586 | // `vlr` / `sideways-lr`: columns grow rightward from the left |
| 587 | // content edge. `vrl` / `sideways-rl`: block-start = right, so |
| 588 | // grow leftward (place the column `$cumBlock` in from the right). |
| 589 | $blockLeft = $rtl |
| 590 | ? max(0.0, $anchor - $cumBlock - $line->height) |
| 591 | : $cumBlock; |
| 592 | $newFrags = []; |
| 593 | foreach ($line->fragments as $f) { |
| 594 | $newFrags[] = new InlineFragment( |
| 595 | $blockLeft, |
| 596 | $f->width, |
| 597 | $f->shapedRun, |
| 598 | $f->baselineShift, |
| 599 | $f->href, |
| 600 | $f->isBold, |
| 601 | $f->isItalic, |
| 602 | $f->decorationLines, |
| 603 | $f->textColor, |
| 604 | $f->backgroundColor, |
| 605 | $f->linkTitle, |
| 606 | $f->decorationColor, |
| 607 | $f->isWhitespace, |
| 608 | $f->lineHeight, |
| 609 | $f->verticalAlign, |
| 610 | blockOffset: $f->x, |
| 611 | // The transposed fragment has to keep its atomic / |
| 612 | // replaced box: the painter reads it to place inline |
| 613 | // images, and commitVerticalAtomicGeometry below needs |
| 614 | // it to re-seat their geometry in physical coordinates. |
| 615 | atomicBox: $f->atomicBox, |
| 616 | bgExtendAbove: $f->bgExtendAbove, |
| 617 | bgExtendBelow: $f->bgExtendBelow, |
| 618 | ); |
| 619 | } |
| 620 | $this->commitVerticalAtomicGeometry($parent, $newFrags, $blockLeft, $line->height, $rtl); |
| 621 | $out[] = new LineBox(0.0, $line->height, $newFrags, $line->baseline, $line->availableRight); |
| 622 | $cumBlock += $line->height; |
| 623 | } |
| 624 | return $out; |
| 625 | } |
| 626 | |
| 627 | /** |
| 628 | * Place each atomic / replaced box of a TRANSPOSED line in physical |
| 629 | * coordinates (CSS Writing Modes 4 §3 / §7.1). |
| 630 | * |
| 631 | * `commitAtomicFragmentX` / `commitAtomicFragmentY` placed the box in |
| 632 | * the horizontal coordinate space the inline pass works in; after the |
| 633 | * transpose a fragment's inline position lives in `blockOffset` |
| 634 | * (physical +Y down the column) and the column's block-axis position |
| 635 | * in `$blockLeft` (physical X). A replaced box keeps its physical |
| 636 | * `width`/`height`, so only its ORIGIN moves. |
| 637 | * |
| 638 | * The box sits at the column's block-START edge, which is the column's |
| 639 | * left edge for `vertical-lr` / `sideways-lr` and its right edge for |
| 640 | * `vertical-rl` / `sideways-rl`. |
| 641 | * |
| 642 | * @param list<InlineFragment> $fragments |
| 643 | */ |
| 644 | private function commitVerticalAtomicGeometry( |
| 645 | Box $parent, |
| 646 | array $fragments, |
| 647 | float $blockLeft, |
| 648 | float $columnExtent, |
| 649 | bool $blockRtl, |
| 650 | ): void { |
| 651 | foreach ($fragments as $fragment) { |
| 652 | $atomic = $fragment->atomicBox; |
| 653 | if ($atomic === null) { |
| 654 | continue; |
| 655 | } |
| 656 | $g = $atomic->geometry; |
| 657 | $outerWidth = $g->marginLeft + $g->borderLeft + $g->paddingLeft + $g->width |
| 658 | + $g->paddingRight + $g->borderRight + $g->marginRight; |
| 659 | $blockStartInset = $blockRtl ? max(0.0, $columnExtent - $outerWidth) : 0.0; |
| 660 | $g->x = $parent->geometry->x + $blockLeft + $blockStartInset |
| 661 | + $g->marginLeft + $g->borderLeft + $g->paddingLeft; |
| 662 | $g->y = $parent->geometry->y + $fragment->blockOffset |
| 663 | + $g->marginTop + $g->borderTop + $g->paddingTop; |
| 664 | } |
| 665 | } |
| 666 | |
| 667 | /** |
| 668 | * Fallback layout for blocks whose inline-formatting context has |
| 669 | * no shaping font available. Closes the geometry gap from #39 for |
| 670 | * documents that contain only inline replaced content (img, svg, |
| 671 | * math, inline-block divs) and never registered a default font. |
| 672 | * |
| 673 | * Walks the parent's direct children, reads cascaded width / |
| 674 | * height off each AtomicInlineBox, sets its geometry, and |
| 675 | * advances a cursor. No line wrapping — atoms that overflow the |
| 676 | * available width stack anyway (the painter clips per-page). |
| 677 | * Non-atomic children (TextBox, InlineBox, LineBreakBox) are |
| 678 | * skipped because they need a font to lay out. |
| 679 | * |
| 680 | * Returns no LineBox: the painter doesn't iterate lines to find |
| 681 | * an AtomicInlineBox, it walks the box tree top-down, so setting |
| 682 | * geometry directly is sufficient for paintImage's namespace |
| 683 | * dispatch (paintInlineSvg / paintInlineMath) to render. |
| 684 | * |
| 685 | * @return array{list<LineBox>, float} |
| 686 | */ |
| 687 | private function layoutAtomicOnly(Box $parent): array |
| 688 | { |
| 689 | $currentX = 0.0; |
| 690 | $maxHeight = 0.0; |
| 691 | // Line-box tracking: atomics flow left-to-right and wrap to a new |
| 692 | // line when the next one won't fit in the IFC available width |
| 693 | // (CSS 2.1 §9.4.2). `$lineTop` is the current line's top offset |
| 694 | // from the parent's content top; `$lineHeight` its tallest box. |
| 695 | $lineTop = 0.0; |
| 696 | $lineHeight = 0.0; |
| 697 | foreach ($parent->children as $child) { |
| 698 | if (!($child instanceof AtomicInlineBox)) { |
| 699 | continue; |
| 700 | } |
| 701 | // Border + padding insets, resolved BEFORE the inline size so a |
| 702 | // definite height can derive an auto width via the intrinsic |
| 703 | // ratio (CSS 2.1 §10.3.2). Folding both axes' insets in keeps a |
| 704 | // uniformly-bordered inline-block's border box intact (the CSS2 |
| 705 | // border-width tests). |
| 706 | $padTop = self::atomicLength($child->style->get('padding-top')); |
| 707 | $padBottom = self::atomicLength($child->style->get('padding-bottom')); |
| 708 | $padLeft = self::atomicLength($child->style->get('padding-left')); |
| 709 | $padRight = self::atomicLength($child->style->get('padding-right')); |
| 710 | $borderTop = self::atomicBorderWidth($child->style, 'top'); |
| 711 | $borderBottom = self::atomicBorderWidth($child->style, 'bottom'); |
| 712 | $borderLeft = self::atomicBorderWidth($child->style, 'left'); |
| 713 | $borderRight = self::atomicBorderWidth($child->style, 'right'); |
| 714 | $verticalInset = $padTop + $padBottom + $borderTop + $borderBottom; |
| 715 | $horizontalInset = $padLeft + $padRight + $borderLeft + $borderRight; |
| 716 | // CSS 2.2 §10.8 — an inline-block's margins take part in layout: |
| 717 | // horizontal margins add to its inline advance, its margin box |
| 718 | // contributes to line height. |
| 719 | $marginTop = self::atomicLength($child->style->get('margin-top')); |
| 720 | $marginBottom = self::atomicLength($child->style->get('margin-bottom')); |
| 721 | $marginLeft = self::atomicLength($child->style->get('margin-left')); |
| 722 | $marginRight = self::atomicLength($child->style->get('margin-right')); |
| 723 | // CSS Sizing 3 §6.2 — with `box-sizing: border-box` the declared |
| 724 | // width/height already includes the inset. |
| 725 | $borderBox = self::atomicIsBorderBoxSizing($child->style); |
| 726 | // Resolve a definite CONTENT height first: an explicit length / |
| 727 | // `0`, or a percentage against a definite containing-block |
| 728 | // height. A definite height lets an auto inline size come from |
| 729 | // the intrinsic ratio below. |
| 730 | $heightValue = $child->style->get('height'); |
| 731 | $declaredHeight = match (true) { |
| 732 | $heightValue instanceof Length => $heightValue->value, |
| 733 | $heightValue instanceof \Phpdftk\Css\Value\Integer => (float) $heightValue->value, |
| 734 | $heightValue instanceof \Phpdftk\Css\Value\Percentage |
| 735 | && $this->currentCbHeightDefinite && $this->currentCbHeight > 0.0 |
| 736 | => $this->currentCbHeight * ($heightValue->value / 100.0), |
| 737 | default => null, |
| 738 | }; |
| 739 | $definiteContentHeight = null; |
| 740 | if ($declaredHeight !== null) { |
| 741 | $declaredHeight = max(0.0, $declaredHeight); |
| 742 | $definiteContentHeight = $borderBox |
| 743 | ? max(0.0, $declaredHeight - $verticalInset) |
| 744 | : $declaredHeight; |
| 745 | } |
| 746 | // Inline (content-box) width: an explicit length, a percentage |
| 747 | // against the IFC available width (the basis the shaped path |
| 748 | // uses), else — when auto with a definite height and intrinsic |
| 749 | // ratio — height × ratio (§10.3.2 replaced sizing). |
| 750 | $ratio = self::atomicAspectRatio($child->style); |
| 751 | $widthValue = $child->style->get('width'); |
| 752 | $width = match (true) { |
| 753 | $widthValue instanceof Length && $widthValue->value > 0.0 |
| 754 | => $widthValue->value, |
| 755 | $widthValue instanceof \Phpdftk\Css\Value\Percentage && $widthValue->value > 0.0 |
| 756 | => $this->currentAvailableWidth * ($widthValue->value / 100.0), |
| 757 | default => 0.0, |
| 758 | }; |
| 759 | $contentWidth = $borderBox ? max(0.0, $width - $horizontalInset) : $width; |
| 760 | if ($width <= 0.0 |
| 761 | && $definiteContentHeight !== null && $definiteContentHeight > 0.0 |
| 762 | && $ratio !== null && $ratio > 0.0 |
| 763 | ) { |
| 764 | $contentWidth = $definiteContentHeight * $ratio; |
| 765 | } |
| 766 | if ($contentWidth <= 0.0) { |
| 767 | // Atomic-content painters have their own intrinsic-size |
| 768 | // fallbacks (svg attrs / viewBox; math defaults). Leave |
| 769 | // geometry at 0 so the painter's fallback chain still runs. |
| 770 | continue; |
| 771 | } |
| 772 | $outerWidth = $contentWidth + $horizontalInset; |
| 773 | // Content height: the definite value resolved above, else square |
| 774 | // the outer box to its width (the historical no-font contract |
| 775 | // the shaped path also follows), floored at the inset. |
| 776 | $contentHeight = $definiteContentHeight |
| 777 | ?? max(0.0, max($outerWidth, $verticalInset) - $verticalInset); |
| 778 | // CSS Sizing 3 §5.2 — replaced min/max-width / -height clamps |
| 779 | // (incl. min/max-content transferred through the intrinsic |
| 780 | // ratio). Without this `max-width: min-content` on a sized |
| 781 | // <canvas>/<img> is ignored. |
| 782 | [$contentWidth, $contentHeight] = $this->clampAtomicReplaced($child->style, $contentWidth, $contentHeight); |
| 783 | $outerWidth = $contentWidth + $horizontalInset; |
| 784 | $outerHeight = $contentHeight + $verticalInset; |
| 785 | // The item's inline footprint is its margin box; the block-axis |
| 786 | // footprint (line-height contribution) is likewise the margin box. |
| 787 | $outerAdvance = $marginLeft + $outerWidth + $marginRight; |
| 788 | $marginBoxHeight = $marginTop + $outerHeight + $marginBottom; |
| 789 | // CSS 2.1 §9.4.2 — wrap to a new line when the current line |
| 790 | // already holds content and this box would overflow the IFC |
| 791 | // available width. (A single box wider than the line still |
| 792 | // gets its own line rather than an infinite loop.) |
| 793 | if ($currentX > 0.0 |
| 794 | && $currentX + $outerAdvance |
| 795 | > ($this->currentWrapMeasure ?? $this->currentAvailableWidth) + 0.01 |
| 796 | ) { |
| 797 | $lineTop += $lineHeight; |
| 798 | $currentX = 0.0; |
| 799 | $lineHeight = 0.0; |
| 800 | } |
| 801 | // Offset the content box by the left/top margin + inset so the |
| 802 | // border box's top-left edge sits inside the margin box. |
| 803 | $child->geometry->x = $parent->geometry->x + $currentX + $marginLeft + $borderLeft + $padLeft; |
| 804 | $child->geometry->y = $parent->geometry->y + $lineTop + $marginTop + $borderTop + $padTop; |
| 805 | $child->geometry->width = $contentWidth; |
| 806 | $child->geometry->height = $contentHeight; |
| 807 | $child->geometry->paddingTop = $padTop; |
| 808 | $child->geometry->paddingBottom = $padBottom; |
| 809 | $child->geometry->paddingLeft = $padLeft; |
| 810 | $child->geometry->paddingRight = $padRight; |
| 811 | $child->geometry->borderTop = $borderTop; |
| 812 | $child->geometry->borderBottom = $borderBottom; |
| 813 | $child->geometry->borderLeft = $borderLeft; |
| 814 | $child->geometry->borderRight = $borderRight; |
| 815 | $child->geometry->marginTop = $marginTop; |
| 816 | $child->geometry->marginBottom = $marginBottom; |
| 817 | $child->geometry->marginLeft = $marginLeft; |
| 818 | $child->geometry->marginRight = $marginRight; |
| 819 | $currentX += $outerAdvance; |
| 820 | if ($marginBoxHeight > $lineHeight) { |
| 821 | $lineHeight = $marginBoxHeight; |
| 822 | } |
| 823 | if ($lineTop + $lineHeight > $maxHeight) { |
| 824 | $maxHeight = $lineTop + $lineHeight; |
| 825 | } |
| 826 | } |
| 827 | return [[], $maxHeight]; |
| 828 | } |
| 829 | |
| 830 | /** |
| 831 | * Resolve CSS Text 3 §11.2 `tab-size` to an integer space count. |
| 832 | * |
| 833 | * - `<integer>` / `<number>`: direct space count. |
| 834 | * - `<length>`: divide by a glyph-space advance estimate |
| 835 | * (0.25 × font-size — a sane default for sans-serif) and round |
| 836 | * to the nearest integer ≥ 0. This is an approximation since |
| 837 | * tab-stop alignment isn't implemented, but converts a |
| 838 | * length-based author intent to the closest N-space expansion. |
| 839 | * - Anything else (`auto`, unknown keywords): the spec default 8. |
| 840 | */ |
| 841 | private function resolveTabSize(Box $box): int |
| 842 | { |
| 843 | $value = $box->style->get('tab-size'); |
| 844 | if ($value instanceof \Phpdftk\Css\Value\Integer) { |
| 845 | return max(0, $value->value); |
| 846 | } |
| 847 | if ($value instanceof \Phpdftk\Css\Value\Number) { |
| 848 | return max(0, (int) round($value->value)); |
| 849 | } |
| 850 | if ($value instanceof \Phpdftk\Css\Value\Length) { |
| 851 | $fontSize = $this->dominantFontSize($box, new LayoutContext( |
| 852 | 0.0, |
| 853 | 0.0, |
| 854 | 0.0, |
| 855 | 0.0, |
| 856 | new \Phpdftk\Css\Cascade\LengthContext(), |
| 857 | )); |
| 858 | $spaceAdvance = max(0.1, $fontSize * 0.25); |
| 859 | return max(0, (int) round($value->value / $spaceAdvance)); |
| 860 | } |
| 861 | return 8; |
| 862 | } |
| 863 | |
| 864 | /** |
| 865 | * CSS Writing Modes 4 §3 — the container's INLINE size, when it is not |
| 866 | * the physical width. In a vertical writing mode the inline axis runs |
| 867 | * vertically, so lines must wrap against the container's block size |
| 868 | * (its physical height) and only then be transposed onto the vertical |
| 869 | * axis by {@see applyVerticalLineShift}. Wrapping against the physical |
| 870 | * width and transposing afterwards breaks lines on the wrong axis. |
| 871 | * |
| 872 | * The geometry height is not committed yet while the inline pass runs, |
| 873 | * so this reads the cascaded `height`, which the cascade has already |
| 874 | * resolved to px. Returns null for a horizontal container, and for a |
| 875 | * vertical one whose block size is indefinite: the used height of |
| 876 | * those is only settled AFTER inline layout, so wrapping against it |
| 877 | * would be a sizing cycle. Those keep the physical-width measure. |
| 878 | */ |
| 879 | private function verticalInlineMeasure(Box $parent, ?float $inlineExtent = null): ?float |
| 880 | { |
| 881 | if (!WritingMode::fromStyle($parent->style)->isVertical()) { |
| 882 | return null; |
| 883 | } |
| 884 | $height = $parent->style->get('height'); |
| 885 | if ($height instanceof Length && $height->value > 0.0) { |
| 886 | return $height->value; |
| 887 | } |
| 888 | // CSS Sizing 4 §6.4 — a block-level box in a vertical containing |
| 889 | // block STRETCH-fits its inline size (physical height) to that |
| 890 | // containing block, so an `auto` height is still a definite |
| 891 | // measure to wrap against. BlockLayout resolves the stretch |
| 892 | // before this pass runs and threads the used value in here. |
| 893 | if ($inlineExtent !== null && $inlineExtent > 0.0) { |
| 894 | return $inlineExtent; |
| 895 | } |
| 896 | // A §7.3 orthogonal-flow fallback (take the inline size from the |
| 897 | // nearest definite ancestor block size) was measured here and |
| 898 | // scored EXACTLY the same as this narrower gate, so it is left out: |
| 899 | // it widens the blast radius for no gain. |
| 900 | return null; |
| 901 | } |
| 902 | |
| 903 | /** |
| 904 | * Resolve the left and right inset of a line at relative-Y `$y` |
| 905 | * against the active {@see FloatContext}. Returns offsets relative |
| 906 | * to the parent's content-edge X — so `left` is the line's start X |
| 907 | * within the parent's box, and `right` is the line's max-end X. |
| 908 | * |
| 909 | * Without floats this is just `[0, $availableWidth]`. With a left |
| 910 | * float overlapping the line, `left` rises; with a right float, |
| 911 | * `right` falls. |
| 912 | * |
| 913 | * CSS 2.1 §9.5 / CSS Shapes 1 §1.2 — a line box next to a float is |
| 914 | * shortened by the MOST-constrained intrusion over the line's full block |
| 915 | * extent, not just at its top edge. `$bandHeight` defines that extent and |
| 916 | * {@see FloatContext::leftEdgeInBand} takes the true extremum over it — |
| 917 | * analytic for `inset()` and `polygon()`, and for `circle()` / `ellipse()` |
| 918 | * the value at whichever band endpoint (or the vertical centre, when the |
| 919 | * band straddles it) intrudes furthest. A `$bandHeight` of 0 (the default) |
| 920 | * degrades to a single-point query at the top edge. |
| 921 | * |
| 922 | * @return array{left: float, right: float} |
| 923 | */ |
| 924 | private function lineBounds(Box $parent, float $availableWidth, LayoutContext $context, float $relY, float $bandHeight = 0.0): array |
| 925 | { |
| 926 | // A vertical IFC wraps against the block size, and FloatContext is |
| 927 | // physically horizontal — it samples a Y band and returns left/right |
| 928 | // X, so feeding it this measure would compare different axes. Until |
| 929 | // exclusions are logical, a vertical IFC simply takes the full |
| 930 | // measure with no float narrowing. |
| 931 | if ($this->currentWrapMeasure !== null) { |
| 932 | return ['left' => 0.0, 'right' => $this->currentWrapMeasure]; |
| 933 | } |
| 934 | $floatCtx = $context->floatContext; |
| 935 | if ($floatCtx === null) { |
| 936 | return ['left' => 0.0, 'right' => $availableWidth]; |
| 937 | } |
| 938 | $parentX = $parent->geometry->x; |
| 939 | $absTop = $parent->geometry->y + $relY; |
| 940 | $absBottom = $absTop + max(0.0, $bandHeight); |
| 941 | $maxLeft = $floatCtx->leftEdgeInBand($absTop, $absBottom, $parentX); |
| 942 | $minRight = $floatCtx->rightEdgeInBand($absTop, $absBottom, $parentX + $availableWidth); |
| 943 | return [ |
| 944 | 'left' => max(0.0, $maxLeft - $parentX), |
| 945 | 'right' => max(0.0, $minRight - $parentX), |
| 946 | ]; |
| 947 | } |
| 948 | |
| 949 | /** |
| 950 | * Drop fragments from each overflowing line until an ellipsis glyph |
| 951 | * fits at the end. Only applies when the parent's `text-overflow` is |
| 952 | * `ellipsis`; the default `clip` keyword silently lets the content |
| 953 | * overflow (matching the no-op CSS spec behaviour). |
| 954 | * |
| 955 | * @param list<LineBox> $lines |
| 956 | * @return list<LineBox> |
| 957 | */ |
| 958 | private function applyTextOverflow( |
| 959 | array $lines, |
| 960 | float $availableWidth, |
| 961 | Box $parent, |
| 962 | ShapingContext $shapingCtx, |
| 963 | float $letterSpacing, |
| 964 | ): array { |
| 965 | $value = $parent->style->get('text-overflow'); |
| 966 | if (!($value instanceof \Phpdftk\Css\Value\Keyword) |
| 967 | || strtolower($value->name) !== 'ellipsis' |
| 968 | ) { |
| 969 | return $lines; |
| 970 | } |
| 971 | $out = []; |
| 972 | foreach ($lines as $line) { |
| 973 | // `force: false` — only lines whose content actually overflows the |
| 974 | // available width get an ellipsis (CSS UI 3 §6.2). |
| 975 | $out[] = $this->appendEllipsisToLine($line, $availableWidth, $shapingCtx, $letterSpacing, false); |
| 976 | } |
| 977 | return $out; |
| 978 | } |
| 979 | |
| 980 | /** |
| 981 | * Append a U+2026 ellipsis to the end of a single line, trimming the |
| 982 | * trailing content so the ellipsis fits within `$availableWidth`. When |
| 983 | * `$force` is false the line is returned untouched unless its content |
| 984 | * exceeds the width (the `text-overflow: ellipsis` contract); when true |
| 985 | * the ellipsis is added regardless of overflow (the line-clamp |
| 986 | * block-ellipsis, required on the last retained line even if it fits). |
| 987 | */ |
| 988 | private function appendEllipsisToLine( |
| 989 | LineBox $line, |
| 990 | float $availableWidth, |
| 991 | ShapingContext $shapingCtx, |
| 992 | float $letterSpacing, |
| 993 | bool $force, |
| 994 | ): LineBox { |
| 995 | if (!$force && $line->totalWidth() <= $availableWidth) { |
| 996 | return $line; |
| 997 | } |
| 998 | $ellipsis = $this->shaper->shapeRun("\u{2026}", $shapingCtx); |
| 999 | if ($ellipsis->glyphs === []) { |
| 1000 | return $line; |
| 1001 | } |
| 1002 | if ($letterSpacing !== 0.0) { |
| 1003 | $ellipsis = $this->applyLetterSpacing($ellipsis, $letterSpacing); |
| 1004 | } |
| 1005 | $ellipsisWidth = $ellipsis->totalAdvance; |
| 1006 | |
| 1007 | // Drop fragments from the end until the remaining content + ellipsis |
| 1008 | // fits, then trim glyphs off the trailing fragment if it still |
| 1009 | // overflows. Per CSS UI 3 §6.2 the ellipsis sits immediately adjacent |
| 1010 | // to the last visible glyph. |
| 1011 | $fragments = $line->fragments; |
| 1012 | $cutoff = $availableWidth - $ellipsisWidth; |
| 1013 | while ($fragments !== []) { |
| 1014 | $last = $fragments[array_key_last($fragments)]; |
| 1015 | if ($last->x + $last->width <= $cutoff) { |
| 1016 | break; |
| 1017 | } |
| 1018 | // Try a per-glyph trim of the trailing fragment before discarding |
| 1019 | // it whole — `ppppp` overflowing `400px / 100px-per-glyph` keeps |
| 1020 | // `ppp` + ellipsis, not just an orphan ellipsis at x = 0. |
| 1021 | $trimmed = $this->trimFragmentToFit($last, $cutoff); |
| 1022 | if ($trimmed !== null) { |
| 1023 | $fragments[array_key_last($fragments)] = $trimmed; |
| 1024 | break; |
| 1025 | } |
| 1026 | array_pop($fragments); |
| 1027 | } |
| 1028 | if ($fragments === []) { |
| 1029 | // Nothing fits before the ellipsis; emit just the ellipsis at |
| 1030 | // x = 0 so the user sees something. |
| 1031 | $fragments[] = new InlineFragment(0.0, $ellipsisWidth, $ellipsis); |
| 1032 | } else { |
| 1033 | $last = $fragments[array_key_last($fragments)]; |
| 1034 | $tail = $last->x + $last->width; |
| 1035 | $fragments[] = new InlineFragment($tail, $ellipsisWidth, $ellipsis); |
| 1036 | } |
| 1037 | |
| 1038 | return new LineBox($line->y, $line->height, $fragments, $line->baseline, $line->availableRight); |
| 1039 | } |
| 1040 | |
| 1041 | /** |
| 1042 | * CSS Overflow 4 §6 — truncate an already-laid-out line host to its |
| 1043 | * first `$keep` line boxes and force the block ellipsis onto the last |
| 1044 | * retained one. |
| 1045 | * |
| 1046 | * The single-block clamp in {@see layout()} only sees the lines one box |
| 1047 | * produced for itself. `line-clamp` counts the lines of the clamp |
| 1048 | * container's whole formatting context, so the block-level pass in |
| 1049 | * {@see BlockLayout} walks the subtree and calls back in here for the |
| 1050 | * host that straddles the clamp point. The ellipsis is forced even when |
| 1051 | * `count($lines) === $keep`: the caller only reaches this host because |
| 1052 | * more lines follow it, which is exactly the condition the block |
| 1053 | * ellipsis marks. |
| 1054 | * |
| 1055 | * Returns the block-axis extent of the retained lines in the host's own |
| 1056 | * coordinate space (0 when every line was dropped). |
| 1057 | */ |
| 1058 | public function clampHostLines(Box $host, int $keep, LayoutContext $context): float |
| 1059 | { |
| 1060 | if ($keep <= 0) { |
| 1061 | $host->lineBoxes = []; |
| 1062 | |
| 1063 | return 0.0; |
| 1064 | } |
| 1065 | $lines = $host->lineBoxes; |
| 1066 | if ($lines === []) { |
| 1067 | return 0.0; |
| 1068 | } |
| 1069 | $this->currentFontResolver = $context->fontResolver; |
| 1070 | $match = $this->resolveBoxFont($host, $context->defaultFont); |
| 1071 | $font = $match['font']; |
| 1072 | if ($font === null) { |
| 1073 | // No shapeable font — drop the surplus lines without an |
| 1074 | // ellipsis rather than leaving the overflow visible. |
| 1075 | $host->lineBoxes = array_slice($lines, 0, $keep); |
| 1076 | |
| 1077 | return $this->lineExtent($host->lineBoxes); |
| 1078 | } |
| 1079 | $shapingCtx = new ShapingContext( |
| 1080 | $font, |
| 1081 | $this->dominantFontSize($host, $context), |
| 1082 | features: $this->resolveOpenTypeFeatures($host), |
| 1083 | ); |
| 1084 | [$kept, $height] = $this->applyLineClamp( |
| 1085 | $lines, |
| 1086 | $keep, |
| 1087 | $host->geometry->width, |
| 1088 | $shapingCtx, |
| 1089 | $this->resolveLetterSpacing($host), |
| 1090 | ); |
| 1091 | $host->lineBoxes = $kept; |
| 1092 | |
| 1093 | return $height; |
| 1094 | } |
| 1095 | |
| 1096 | /** |
| 1097 | * Block-axis extent of a line list (the bottom edge of its last line). |
| 1098 | * |
| 1099 | * @param list<LineBox> $lines |
| 1100 | */ |
| 1101 | private function lineExtent(array $lines): float |
| 1102 | { |
| 1103 | $height = 0.0; |
| 1104 | foreach ($lines as $line) { |
| 1105 | $height = max($height, $line->y + $line->height); |
| 1106 | } |
| 1107 | |
| 1108 | return $height; |
| 1109 | } |
| 1110 | |
| 1111 | /** |
| 1112 | * The effective `line-clamp` count for a block, or null when the block is |
| 1113 | * not clamped. The standard `line-clamp: <integer>` applies to any block; |
| 1114 | * the legacy `-webkit-line-clamp: <integer>` only takes effect under the |
| 1115 | * legacy `display: -webkit-box` / `-webkit-box-orient: vertical` pairing |
| 1116 | * (CSS Overflow 4 Appendix; WPT asserts it is otherwise inert). |
| 1117 | */ |
| 1118 | private function lineClampCount(Box $box): ?int |
| 1119 | { |
| 1120 | $value = $box->style->get('line-clamp'); |
| 1121 | if ($value instanceof \Phpdftk\Css\Value\Integer && $value->value > 0) { |
| 1122 | return $value->value; |
| 1123 | } |
| 1124 | $legacy = $box->style->get('-webkit-line-clamp'); |
| 1125 | if ($legacy instanceof \Phpdftk\Css\Value\Integer |
| 1126 | && $legacy->value > 0 |
| 1127 | && $this->legacyWebkitClampApplies($box) |
| 1128 | ) { |
| 1129 | return $legacy->value; |
| 1130 | } |
| 1131 | |
| 1132 | return null; |
| 1133 | } |
| 1134 | |
| 1135 | /** |
| 1136 | * Whether the legacy `-webkit-line-clamp` gate is satisfied: the box must |
| 1137 | * be a legacy `-webkit-box` / `-webkit-inline-box` with a vertical |
| 1138 | * `-webkit-box-orient`. |
| 1139 | */ |
| 1140 | private function legacyWebkitClampApplies(Box $box): bool |
| 1141 | { |
| 1142 | $display = $box->style->get('display'); |
| 1143 | if (!($display instanceof \Phpdftk\Css\Value\Keyword)) { |
| 1144 | return false; |
| 1145 | } |
| 1146 | $d = strtolower($display->name); |
| 1147 | if ($d !== '-webkit-box' && $d !== '-webkit-inline-box') { |
| 1148 | return false; |
| 1149 | } |
| 1150 | $orient = $box->style->get('-webkit-box-orient'); |
| 1151 | |
| 1152 | return $orient instanceof \Phpdftk\Css\Value\Keyword |
| 1153 | && in_array(strtolower($orient->name), ['vertical', 'block-axis'], true); |
| 1154 | } |
| 1155 | |
| 1156 | /** |
| 1157 | * Clamp the line list to `$maxLines`: keep the first N lines, mark the |
| 1158 | * last retained line with a forced block ellipsis, and return the reduced |
| 1159 | * line list together with the new block-axis extent (so the block shrinks |
| 1160 | * to exactly the retained lines). Caller guarantees `count($lines) > |
| 1161 | * $maxLines >= 1`. |
| 1162 | * |
| 1163 | * @param list<LineBox> $lines |
| 1164 | * @return array{list<LineBox>, float} |
| 1165 | */ |
| 1166 | private function applyLineClamp( |
| 1167 | array $lines, |
| 1168 | int $maxLines, |
| 1169 | float $availableWidth, |
| 1170 | ShapingContext $shapingCtx, |
| 1171 | float $letterSpacing, |
| 1172 | ): array { |
| 1173 | $kept = array_slice($lines, 0, $maxLines); |
| 1174 | $lastIdx = array_key_last($kept); |
| 1175 | $kept[$lastIdx] = $this->appendEllipsisToLine($kept[$lastIdx], $availableWidth, $shapingCtx, $letterSpacing, true); |
| 1176 | $height = 0.0; |
| 1177 | foreach ($kept as $line) { |
| 1178 | $height = max($height, $line->y + $line->height); |
| 1179 | } |
| 1180 | |
| 1181 | return [$kept, $height]; |
| 1182 | } |
| 1183 | |
| 1184 | /** |
| 1185 | * Trim glyphs off the tail of `$fragment` until its right edge sits |
| 1186 | * at or before `$cutoff`. Returns a new `InlineFragment` with the |
| 1187 | * shorter `ShapedRun`, or `null` when not a single glyph fits |
| 1188 | * (caller should drop the whole fragment instead). |
| 1189 | */ |
| 1190 | private function trimFragmentToFit(InlineFragment $fragment, float $cutoff): ?InlineFragment |
| 1191 | { |
| 1192 | $shaped = $fragment->shapedRun; |
| 1193 | if ($shaped->glyphs === []) { |
| 1194 | return null; |
| 1195 | } |
| 1196 | $maxWidth = max(0.0, $cutoff - $fragment->x); |
| 1197 | $keptGlyphs = []; |
| 1198 | $total = 0.0; |
| 1199 | foreach ($shaped->glyphs as $g) { |
| 1200 | if ($total + $g->advanceX > $maxWidth + 0.001) { |
| 1201 | break; |
| 1202 | } |
| 1203 | $keptGlyphs[] = $g; |
| 1204 | $total += $g->advanceX; |
| 1205 | } |
| 1206 | if ($keptGlyphs === []) { |
| 1207 | return null; |
| 1208 | } |
| 1209 | $newShaped = new ShapedRun( |
| 1210 | $shaped->font, |
| 1211 | $shaped->fontSizePt, |
| 1212 | $shaped->direction, |
| 1213 | $keptGlyphs, |
| 1214 | $total, |
| 1215 | ); |
| 1216 | return new InlineFragment( |
| 1217 | $fragment->x, |
| 1218 | $total, |
| 1219 | $newShaped, |
| 1220 | $fragment->baselineShift, |
| 1221 | $fragment->href, |
| 1222 | $fragment->isBold, |
| 1223 | $fragment->isItalic, |
| 1224 | $fragment->decorationLines, |
| 1225 | $fragment->textColor, |
| 1226 | $fragment->backgroundColor, |
| 1227 | $fragment->linkTitle, |
| 1228 | $fragment->decorationColor, |
| 1229 | $fragment->isWhitespace, |
| 1230 | $fragment->lineHeight, |
| 1231 | $fragment->verticalAlign, |
| 1232 | ); |
| 1233 | } |
| 1234 | |
| 1235 | /** |
| 1236 | * Apply the parent's `text-align` to each line: `start` / `left` (default, |
| 1237 | * no-op), `center`, `end` / `right`, or `justify`. Justify is approximated |
| 1238 | * for the last line as left-aligned per CSS Text 3 §7.3 ("the last line |
| 1239 | * of a block, and any line ending with a forced line break, is start- |
| 1240 | * aligned"); inter-fragment justification on non-final lines distributes |
| 1241 | * extra space evenly across the gaps between fragments. |
| 1242 | * |
| 1243 | * @param list<LineBox> $lines |
| 1244 | * @return list<LineBox> |
| 1245 | */ |
| 1246 | private function applyTextAlign(array $lines, float $availableWidth, Box $parent): array |
| 1247 | { |
| 1248 | $align = $this->textAlignKeyword($parent); |
| 1249 | $alignLast = $this->textAlignLastKeyword($parent, $align); |
| 1250 | // CSS Writing Modes 4 §3 — `text-align` aligns along the INLINE axis. |
| 1251 | // In a vertical writing mode the inline axis is vertical, so alignment |
| 1252 | // slack is measured against the container's inline size (its physical |
| 1253 | // height), not the block-axis `availableWidth` the horizontal model |
| 1254 | // was handed. (Increment 1 transposes the resulting inline offset onto |
| 1255 | // the vertical axis downstream in `applyVerticalLineShift`.) |
| 1256 | $wm = WritingMode::fromStyle($parent->style); |
| 1257 | // Same measure the fitter wrapped against, so alignment slack is |
| 1258 | // computed on the inline axis in both writing modes. |
| 1259 | $inlineExtent = $this->currentWrapMeasure ?? $availableWidth; |
| 1260 | // CSS Text 3 §7.2: `justify-all` is `justify` for every line |
| 1261 | // including the trailing one. Normalise to `justify` for the |
| 1262 | // body lines and force the last-line alignment to `justify` |
| 1263 | // too (the `textAlignLastKeyword` fallback would otherwise |
| 1264 | // start-align the last line for plain `justify`). |
| 1265 | if ($align === 'justify-all') { |
| 1266 | $align = 'justify'; |
| 1267 | $alignLast = 'justify'; |
| 1268 | } |
| 1269 | // CSS Text 3 §7.5: `text-justify: none` disables justification. |
| 1270 | // A `justify` text-align falls through to start-alignment. |
| 1271 | if ($this->isTextJustifyNone($parent)) { |
| 1272 | if ($align === 'justify') { |
| 1273 | $align = 'start'; |
| 1274 | } |
| 1275 | if ($alignLast === 'justify') { |
| 1276 | $alignLast = 'start'; |
| 1277 | } |
| 1278 | } |
| 1279 | // CSS Text 3 §7.1 — resolve direction-relative `start` / `end` |
| 1280 | // against the parent's writing direction. `start` is the |
| 1281 | // inline-start edge (left in LTR, right in RTL); `end` is the |
| 1282 | // inline-end edge (right in LTR, left in RTL). The physical |
| 1283 | // `left` / `right` / `center` values pass through unchanged. |
| 1284 | $isRtl = $this->isRtlDirection($parent); |
| 1285 | $align = $this->resolveLogicalTextAlign($align, $isRtl); |
| 1286 | $alignLast = $this->resolveLogicalTextAlign($alignLast, $isRtl); |
| 1287 | if ($align === 'left') { |
| 1288 | if ($alignLast === 'left' || $alignLast === 'auto') { |
| 1289 | return $lines; |
| 1290 | } |
| 1291 | } |
| 1292 | $count = count($lines); |
| 1293 | $out = []; |
| 1294 | foreach ($lines as $i => $line) { |
| 1295 | // CSS Text 3 §5.5 — trailing whitespace at the end of a |
| 1296 | // line hangs (zero-width for line measurement). For |
| 1297 | // alignment slack this means we measure the line's |
| 1298 | // visible content edge, NOT the full fragment tail. |
| 1299 | $used = $this->lineUsedWidth($line); |
| 1300 | // CSS 2.1 §9.5 — a line beside a float is SHORTER than the |
| 1301 | // container. Measuring slack against the container's inline |
| 1302 | // size instead sends right-aligned and centred text sliding |
| 1303 | // under the float. |
| 1304 | // |
| 1305 | // The fitter's bound is only comparable when it was measured in |
| 1306 | // the same axis alignment uses. That now holds for a vertical |
| 1307 | // IFC too whenever the fitter had a definite block size to wrap |
| 1308 | // against (`currentWrapMeasure`); a vertical container with an |
| 1309 | // indefinite block size still wraps against the physical width, |
| 1310 | // so its bound belongs to the other axis and is ignored. |
| 1311 | $axesAgree = !$wm->isVertical() || $this->currentWrapMeasure !== null; |
| 1312 | $lineExtent = $axesAgree && $line->availableRight !== null |
| 1313 | ? $line->availableRight |
| 1314 | : $inlineExtent; |
| 1315 | $slack = $lineExtent - $used; |
| 1316 | if ($slack <= 0.0) { |
| 1317 | $out[] = $line; |
| 1318 | continue; |
| 1319 | } |
| 1320 | $isLast = $i === $count - 1; |
| 1321 | $effective = $isLast ? $alignLast : $align; |
| 1322 | $newFragments = match ($effective) { |
| 1323 | 'center' => $this->shiftFragments($line->fragments, $slack / 2.0), |
| 1324 | 'right' => $this->shiftFragments($line->fragments, $slack), |
| 1325 | 'justify' => $this->justifyFragments($line->fragments, $slack), |
| 1326 | default => $line->fragments, |
| 1327 | }; |
| 1328 | $out[] = new LineBox($line->y, $line->height, $newFragments, $line->baseline, $line->availableRight); |
| 1329 | } |
| 1330 | return $out; |
| 1331 | } |
| 1332 | |
| 1333 | /** |
| 1334 | * Compute the line's content edge for alignment, excluding any |
| 1335 | * trailing whitespace fragments (CSS Text 3 §5.5). Used by |
| 1336 | * `applyTextAlign` so the centre / right shift respects the |
| 1337 | * visible content's right edge, not the hung-whitespace tail. |
| 1338 | */ |
| 1339 | private function lineUsedWidth(LineBox $line): float |
| 1340 | { |
| 1341 | $right = 0.0; |
| 1342 | $fragments = $line->fragments; |
| 1343 | // Walk backwards skipping trailing whitespace fragments. |
| 1344 | $lastVisible = count($fragments) - 1; |
| 1345 | while ($lastVisible >= 0 && $fragments[$lastVisible]->isWhitespace) { |
| 1346 | $lastVisible--; |
| 1347 | } |
| 1348 | for ($i = 0; $i <= $lastVisible; $i++) { |
| 1349 | $edge = $fragments[$i]->x + $fragments[$i]->width; |
| 1350 | if ($edge > $right) { |
| 1351 | $right = $edge; |
| 1352 | } |
| 1353 | } |
| 1354 | return $right; |
| 1355 | } |
| 1356 | |
| 1357 | /** |
| 1358 | * Read the parent's cascaded `direction` and report whether it |
| 1359 | * resolves to `rtl`. Defaults to LTR when the property is |
| 1360 | * missing or the value isn't a Keyword the spec recognises. |
| 1361 | */ |
| 1362 | private function isRtlDirection(Box $parent): bool |
| 1363 | { |
| 1364 | $value = $parent->style->get('direction'); |
| 1365 | return $value instanceof \Phpdftk\Css\Value\Keyword |
| 1366 | && strtolower($value->name) === 'rtl'; |
| 1367 | } |
| 1368 | |
| 1369 | /** |
| 1370 | * Map a CSS `text-align` keyword to its physical equivalent. |
| 1371 | * `start` → `left` in LTR, `right` in RTL; `end` → `right` in |
| 1372 | * LTR, `left` in RTL. The physical keywords pass through. |
| 1373 | */ |
| 1374 | private function resolveLogicalTextAlign(string $align, bool $isRtl): string |
| 1375 | { |
| 1376 | return match ($align) { |
| 1377 | 'start' => $isRtl ? 'right' : 'left', |
| 1378 | 'end' => $isRtl ? 'left' : 'right', |
| 1379 | default => $align, |
| 1380 | }; |
| 1381 | } |
| 1382 | |
| 1383 | /** |
| 1384 | * CSS Text 3 §7.5 — `true` when the parent declares |
| 1385 | * `text-justify: none`, in which case the justify branches of |
| 1386 | * `text-align` and `text-align-last` collapse to start-alignment. |
| 1387 | */ |
| 1388 | private function isTextJustifyNone(Box $parent): bool |
| 1389 | { |
| 1390 | $value = $parent->style->get('text-justify'); |
| 1391 | if (!($value instanceof \Phpdftk\Css\Value\Keyword)) { |
| 1392 | return false; |
| 1393 | } |
| 1394 | return strtolower($value->name) === 'none'; |
| 1395 | } |
| 1396 | |
| 1397 | /** |
| 1398 | * Resolve CSS Text 3 §7.4 `text-align-last`. `auto` (initial) |
| 1399 | * inherits the block-aligned behaviour: when text-align is |
| 1400 | * `justify` the last line is start-aligned, otherwise it matches |
| 1401 | * text-align. Explicit values override. |
| 1402 | */ |
| 1403 | private function textAlignLastKeyword(Box $parent, string $align): string |
| 1404 | { |
| 1405 | $value = $parent->style->get('text-align-last'); |
| 1406 | if (!($value instanceof \Phpdftk\Css\Value\Keyword)) { |
| 1407 | return 'auto'; |
| 1408 | } |
| 1409 | $lower = strtolower($value->name); |
| 1410 | if ($lower === 'auto') { |
| 1411 | // text-align: justify → last line is start-aligned per spec |
| 1412 | // (CSS Text 3 §7.4); `justify-all` is handled by the |
| 1413 | // caller before this resolution runs. |
| 1414 | return $align === 'justify' ? 'start' : $align; |
| 1415 | } |
| 1416 | // `text-align-last: justify-all` doesn't appear in any spec |
| 1417 | // grammar — only `text-align: justify-all` exists. Treat any |
| 1418 | // stray value as plain `justify` so the trailing line still |
| 1419 | // gets the fully-justified shifting. |
| 1420 | if ($lower === 'justify-all') { |
| 1421 | return 'justify'; |
| 1422 | } |
| 1423 | return $lower; |
| 1424 | } |
| 1425 | |
| 1426 | private function textAlignKeyword(Box $parent): string |
| 1427 | { |
| 1428 | $value = $parent->style->get('text-align'); |
| 1429 | if ($value instanceof \Phpdftk\Css\Value\Keyword) { |
| 1430 | return strtolower($value->name); |
| 1431 | } |
| 1432 | return 'start'; |
| 1433 | } |
| 1434 | |
| 1435 | /** |
| 1436 | * Apply CSS Text 3 §2 `text-transform` to a text run before shaping. |
| 1437 | * `uppercase` / `lowercase` are full case mappings via `mb_strtoupper` / |
| 1438 | * `mb_strtolower`; `capitalize` upper-cases the first grapheme of each |
| 1439 | * whitespace-separated word; `full-width` / `full-size-kana` and other |
| 1440 | * Phase-2 transforms fall through unchanged. |
| 1441 | */ |
| 1442 | private function applyTextTransform(string $text, Box $box): string |
| 1443 | { |
| 1444 | $value = $box->style->get('text-transform'); |
| 1445 | if (!($value instanceof \Phpdftk\Css\Value\Keyword)) { |
| 1446 | return $text; |
| 1447 | } |
| 1448 | return \Phpdftk\Text\TextTransform::apply($text, $value->name); |
| 1449 | } |
| 1450 | |
| 1451 | /** |
| 1452 | * Resolve CSS Inline 3 §3 `line-height`: |
| 1453 | * - `normal` → font-dependent multiplier (1.2 for Latin until proper |
| 1454 | * OS/2 typo-metrics-driven line-height ships). |
| 1455 | * - `<number>` → multiplier of font-size; value inherits as the number |
| 1456 | * so children re-resolve against their own size. |
| 1457 | * - `<length>` → absolute, already in px after `Cascade::resolveLengths`. |
| 1458 | * - `<percentage>` → percentage of the element's own font-size. |
| 1459 | */ |
| 1460 | private function resolveLineHeight(Box $parent, float $fontSize): float |
| 1461 | { |
| 1462 | $value = $parent->style->get('line-height'); |
| 1463 | if ($value instanceof \Phpdftk\Css\Value\Keyword |
| 1464 | && strtolower($value->name) === 'normal' |
| 1465 | ) { |
| 1466 | return $fontSize * 1.2; |
| 1467 | } |
| 1468 | if ($value instanceof \Phpdftk\Css\Value\Number |
| 1469 | || $value instanceof \Phpdftk\Css\Value\Integer |
| 1470 | ) { |
| 1471 | return $fontSize * $value->value; |
| 1472 | } |
| 1473 | if ($value instanceof \Phpdftk\Css\Value\Percentage) { |
| 1474 | return $fontSize * ($value->value / 100.0); |
| 1475 | } |
| 1476 | if ($value instanceof Length) { |
| 1477 | return $value->value; |
| 1478 | } |
| 1479 | return $fontSize * 1.2; |
| 1480 | } |
| 1481 | |
| 1482 | /** |
| 1483 | * Resolve the parent's `text-indent` CSS value against the available |
| 1484 | * width. Length resolves directly; Percentage resolves against the |
| 1485 | * block's content width per CSS Text 3 §3.1; everything else falls to 0. |
| 1486 | */ |
| 1487 | private function resolveTextIndent(Box $parent, float $availableWidth): float |
| 1488 | { |
| 1489 | $value = $parent->style->get('text-indent'); |
| 1490 | if ($value instanceof Length) { |
| 1491 | return $value->value; |
| 1492 | } |
| 1493 | if ($value instanceof \Phpdftk\Css\Value\Percentage) { |
| 1494 | return $availableWidth * ($value->value / 100.0); |
| 1495 | } |
| 1496 | return 0.0; |
| 1497 | } |
| 1498 | |
| 1499 | private function whiteSpaceKeyword(Box $parent): string |
| 1500 | { |
| 1501 | $value = $parent->style->get('white-space'); |
| 1502 | if ($value instanceof \Phpdftk\Css\Value\Keyword) { |
| 1503 | return strtolower($value->name); |
| 1504 | } |
| 1505 | return 'normal'; |
| 1506 | } |
| 1507 | |
| 1508 | /** |
| 1509 | * Translate a line's fragments along the inline axis. `InlineFragment` |
| 1510 | * is readonly, so each one is rebuilt at the new `x`. |
| 1511 | * |
| 1512 | * Public because `BlockLayout` reuses it when it moves whole lines — |
| 1513 | * e.g. distributing an inline formatting context's lines into |
| 1514 | * multi-column columns. |
| 1515 | * |
| 1516 | * @param list<InlineFragment> $fragments |
| 1517 | * @return list<InlineFragment> |
| 1518 | */ |
| 1519 | public function shiftFragments(array $fragments, float $dx): array |
| 1520 | { |
| 1521 | $out = []; |
| 1522 | foreach ($fragments as $f) { |
| 1523 | $out[] = new InlineFragment($f->x + $dx, $f->width, $f->shapedRun, $f->baselineShift, $f->href, $f->isBold, $f->isItalic, $f->decorationLines, $f->textColor, $f->backgroundColor, $f->linkTitle, $f->decorationColor, $f->isWhitespace, $f->lineHeight, $f->verticalAlign, $f->blockOffset, $f->atomicBox, $f->bgExtendAbove, $f->bgExtendBelow); |
| 1524 | } |
| 1525 | return $out; |
| 1526 | } |
| 1527 | |
| 1528 | /** |
| 1529 | * Spread the slack across the gaps between fragments (CSS Text 3 §7.3 |
| 1530 | * `justify-content` approximation for inter-word distribution). |
| 1531 | * |
| 1532 | * @param list<InlineFragment> $fragments |
| 1533 | * @return list<InlineFragment> |
| 1534 | */ |
| 1535 | private function justifyFragments(array $fragments, float $slack): array |
| 1536 | { |
| 1537 | // CSS Text 3 §7.5 — trailing whitespace at the end of a line |
| 1538 | // is excluded from justification (it hangs). Skip trailing |
| 1539 | // whitespace fragments when counting gaps and when shifting. |
| 1540 | $lastVisible = count($fragments) - 1; |
| 1541 | while ($lastVisible >= 0 && $fragments[$lastVisible]->isWhitespace) { |
| 1542 | $lastVisible--; |
| 1543 | } |
| 1544 | if ($lastVisible < 1) { |
| 1545 | return $fragments; |
| 1546 | } |
| 1547 | $gaps = $lastVisible; |
| 1548 | $delta = $slack / $gaps; |
| 1549 | $out = []; |
| 1550 | foreach ($fragments as $i => $f) { |
| 1551 | // Trailing-ws fragments pinned to whatever the last |
| 1552 | // visible fragment's right edge becomes — they aren't |
| 1553 | // shifted (they hang past the line edge). |
| 1554 | $shift = $i <= $lastVisible ? $i * $delta : $lastVisible * $delta; |
| 1555 | $out[] = new InlineFragment($f->x + $shift, $f->width, $f->shapedRun, $f->baselineShift, $f->href, $f->isBold, $f->isItalic, $f->decorationLines, $f->textColor, $f->backgroundColor, $f->linkTitle, $f->decorationColor, $f->isWhitespace, $f->lineHeight, $f->verticalAlign, $f->blockOffset, $f->atomicBox, $f->bgExtendAbove, $f->bgExtendBelow); |
| 1556 | } |
| 1557 | return $out; |
| 1558 | } |
| 1559 | |
| 1560 | /** |
| 1561 | * Tokenise the inline subtree at line-break opportunities and shape |
| 1562 | * each token. Each token records its width, its source kind |
| 1563 | * (whitespace / non-whitespace), and whether it closes a mandatory |
| 1564 | * break. |
| 1565 | * |
| 1566 | * @param list<string> $decorationLines |
| 1567 | * @return list<array{ |
| 1568 | * shapedRun: ShapedRun, |
| 1569 | * isWhitespace: bool, |
| 1570 | * kind: LineBreakKind, |
| 1571 | * trailingSpace: float, |
| 1572 | * noWrapBefore?: bool, |
| 1573 | * baselineShift?: float, |
| 1574 | * lineHeight?: float, |
| 1575 | * verticalAlign?: string, |
| 1576 | * href?: string|null, |
| 1577 | * isBold?: bool, |
| 1578 | * isItalic?: bool, |
| 1579 | * decorationLines?: list<string>, |
| 1580 | * textColor?: \Phpdftk\Css\Value\Color|null, |
| 1581 | * backgroundColor?: \Phpdftk\Css\Value\Color|null, |
| 1582 | * linkTitle?: string|null, |
| 1583 | * decorationColor?: \Phpdftk\Css\Value\Color|null, |
| 1584 | * bgExtendAbove?: float, |
| 1585 | * bgExtendBelow?: float, |
| 1586 | * atomicBox?: AtomicInlineBox, |
| 1587 | * atomicContentWidth?: float, |
| 1588 | * atomicOuterWidth?: float, |
| 1589 | * atomicPadLeft?: float, |
| 1590 | * atomicPadRight?: float, |
| 1591 | * atomicBorderLeft?: float, |
| 1592 | * atomicBorderRight?: float, |
| 1593 | * atomicMarginLeft?: float, |
| 1594 | * atomicMarginRight?: float, |
| 1595 | * atomicMarginTop?: float, |
| 1596 | * atomicMarginBottom?: float, |
| 1597 | * atomicBorderBox?: bool, |
| 1598 | * }> |
| 1599 | */ |
| 1600 | private function collectTokens( |
| 1601 | Box $parent, |
| 1602 | ShapingContext $shapingCtx, |
| 1603 | bool $collapseInternal, |
| 1604 | float $letterSpacing, |
| 1605 | float $wordSpacing, |
| 1606 | float $baselineShift, |
| 1607 | float $lineHeight, |
| 1608 | string $verticalAlign, |
| 1609 | ?string $href, |
| 1610 | bool $isBold, |
| 1611 | bool $isItalic, |
| 1612 | array $decorationLines, |
| 1613 | ?\Phpdftk\Css\Value\Color $textColor, |
| 1614 | ?\Phpdftk\Css\Value\Color $backgroundColor, |
| 1615 | ?string $linkTitle, |
| 1616 | ?\Phpdftk\Css\Value\Color $decorationColor, |
| 1617 | bool $splitWsBoundaries = false, |
| 1618 | ): array { |
| 1619 | $out = []; |
| 1620 | foreach ($parent->children as $child) { |
| 1621 | $this->walkInline( |
| 1622 | $child, |
| 1623 | $shapingCtx, |
| 1624 | $out, |
| 1625 | $collapseInternal, |
| 1626 | $letterSpacing, |
| 1627 | $wordSpacing, |
| 1628 | $baselineShift, |
| 1629 | $lineHeight, |
| 1630 | $verticalAlign, |
| 1631 | $href, |
| 1632 | $isBold, |
| 1633 | $isItalic, |
| 1634 | $decorationLines, |
| 1635 | $textColor, |
| 1636 | $backgroundColor, |
| 1637 | $linkTitle, |
| 1638 | $decorationColor, |
| 1639 | $splitWsBoundaries, |
| 1640 | ); |
| 1641 | } |
| 1642 | // CSS 2.1 §16.6.1 — collapse runs of whitespace across the |
| 1643 | // *entire* inline tree, not per text node. The per-TextBox |
| 1644 | // collapse in `walkInline` only sees one node at a time, so |
| 1645 | // `<span>a </span><span> b</span>` arrives here as two |
| 1646 | // adjacent whitespace tokens. Drop any whitespace token whose |
| 1647 | // predecessor already ENDS in collapsible whitespace (only when |
| 1648 | // collapsing is on). |
| 1649 | // |
| 1650 | // "Ends in whitespace" is not the same as "is whitespace": the |
| 1651 | // tokeniser bundles a word with its trailing space (UAX #14 puts |
| 1652 | // the break opportunity AFTER the space), so `X <span> <i>s` |
| 1653 | // arrives as [`X `, ` `, `s`] — a run this pass used to keep |
| 1654 | // BOTH spaces of, because the first token isn't whitespace-only. |
| 1655 | // That put a spurious second space in front of every inline box |
| 1656 | // whose own content starts with collapsible whitespace. |
| 1657 | if ($collapseInternal) { |
| 1658 | $deduped = []; |
| 1659 | $prevEndsInWs = false; |
| 1660 | foreach ($out as $token) { |
| 1661 | if ($token['isWhitespace'] && $prevEndsInWs) { |
| 1662 | continue; |
| 1663 | } |
| 1664 | $deduped[] = $token; |
| 1665 | $prevEndsInWs = $token['isWhitespace'] || $token['trailingSpace'] > 0.0; |
| 1666 | } |
| 1667 | $out = $deduped; |
| 1668 | } |
| 1669 | return $out; |
| 1670 | } |
| 1671 | |
| 1672 | /** |
| 1673 | * @param list<array{ |
| 1674 | * shapedRun: ShapedRun, |
| 1675 | * isWhitespace: bool, |
| 1676 | * kind: LineBreakKind, |
| 1677 | * trailingSpace: float, |
| 1678 | * noWrapBefore?: bool, |
| 1679 | * baselineShift?: float, |
| 1680 | * lineHeight?: float, |
| 1681 | * verticalAlign?: string, |
| 1682 | * href?: string|null, |
| 1683 | * isBold?: bool, |
| 1684 | * isItalic?: bool, |
| 1685 | * decorationLines?: list<string>, |
| 1686 | * textColor?: \Phpdftk\Css\Value\Color|null, |
| 1687 | * backgroundColor?: \Phpdftk\Css\Value\Color|null, |
| 1688 | * linkTitle?: string|null, |
| 1689 | * decorationColor?: \Phpdftk\Css\Value\Color|null, |
| 1690 | * bgExtendAbove?: float, |
| 1691 | * bgExtendBelow?: float, |
| 1692 | * atomicBox?: AtomicInlineBox, |
| 1693 | * atomicContentWidth?: float, |
| 1694 | * atomicOuterWidth?: float, |
| 1695 | * atomicPadLeft?: float, |
| 1696 | * atomicPadRight?: float, |
| 1697 | * atomicBorderLeft?: float, |
| 1698 | * atomicBorderRight?: float, |
| 1699 | * atomicMarginLeft?: float, |
| 1700 | * atomicMarginRight?: float, |
| 1701 | * atomicMarginTop?: float, |
| 1702 | * atomicMarginBottom?: float, |
| 1703 | * atomicBorderBox?: bool, |
| 1704 | * }> $tokens |
| 1705 | * @param list<string> $decorationLines |
| 1706 | */ |
| 1707 | private function walkInline( |
| 1708 | Box $box, |
| 1709 | ShapingContext $shapingCtx, |
| 1710 | array &$tokens, |
| 1711 | bool $collapseInternal, |
| 1712 | float $letterSpacing, |
| 1713 | float $wordSpacing, |
| 1714 | float $baselineShift, |
| 1715 | float $lineHeight, |
| 1716 | string $verticalAlign, |
| 1717 | ?string $href, |
| 1718 | bool $isBold, |
| 1719 | bool $isItalic, |
| 1720 | array $decorationLines, |
| 1721 | ?\Phpdftk\Css\Value\Color $textColor, |
| 1722 | ?\Phpdftk\Css\Value\Color $backgroundColor, |
| 1723 | ?string $linkTitle, |
| 1724 | ?\Phpdftk\Css\Value\Color $decorationColor, |
| 1725 | bool $splitWsBoundaries = false, |
| 1726 | float $bgExtendAbove = 0.0, |
| 1727 | float $bgExtendBelow = 0.0, |
| 1728 | ): void { |
| 1729 | if ($box instanceof TextBox) { |
| 1730 | $text = $box->text; |
| 1731 | if ($collapseInternal) { |
| 1732 | // CSS Text 3 §4.1.1: in `normal` / `nowrap`, runs of |
| 1733 | // whitespace collapse to a single space. Newlines collapse |
| 1734 | // alongside spaces / tabs / form feeds. |
| 1735 | $text = preg_replace('/[ \t\n\r\f]+/', ' ', $text) ?? $text; |
| 1736 | } else { |
| 1737 | // CSS Text 3 §11.2 — in white-space modes that preserve |
| 1738 | // tabs (`pre`, `pre-wrap`), each U+0009 expands to N |
| 1739 | // spaces. Phase-1 simplification: fixed expansion |
| 1740 | // instead of tab-stop alignment (which would require |
| 1741 | // tracking column position across mid-text breaks). |
| 1742 | $tabSize = $this->resolveTabSize($box); |
| 1743 | if ($tabSize > 0) { |
| 1744 | $text = str_replace("\t", str_repeat(' ', $tabSize), $text); |
| 1745 | } else { |
| 1746 | $text = str_replace("\t", '', $text); |
| 1747 | } |
| 1748 | } |
| 1749 | // CSS Text 3 §2: `text-transform` runs before shaping so the |
| 1750 | // shaper sees the transformed codepoints. |
| 1751 | $text = $this->applyTextTransform($text, $box); |
| 1752 | $breakAll = $this->isBreakAll($box); |
| 1753 | foreach ($this->tokeniseText($text, $shapingCtx, $letterSpacing, $wordSpacing, $breakAll, $splitWsBoundaries) as $token) { |
| 1754 | $token['baselineShift'] = $baselineShift; |
| 1755 | $token['lineHeight'] = $lineHeight; |
| 1756 | $token['verticalAlign'] = $verticalAlign; |
| 1757 | $token['href'] = $href; |
| 1758 | $token['isBold'] = $isBold; |
| 1759 | $token['isItalic'] = $isItalic; |
| 1760 | $token['decorationLines'] = $decorationLines; |
| 1761 | $token['textColor'] = $textColor; |
| 1762 | $token['backgroundColor'] = $backgroundColor; |
| 1763 | $token['bgExtendAbove'] = $bgExtendAbove; |
| 1764 | $token['bgExtendBelow'] = $bgExtendBelow; |
| 1765 | $token['linkTitle'] = $linkTitle; |
| 1766 | $token['decorationColor'] = $decorationColor; |
| 1767 | $tokens[] = $token; |
| 1768 | } |
| 1769 | return; |
| 1770 | } |
| 1771 | if ($box instanceof LineBreakBox) { |
| 1772 | // `<br>` — hard break that survives `white-space: normal`'s |
| 1773 | // collapsing. Emit a zero-width mandatory-break token so the |
| 1774 | // line-fitter closes the current line and starts a new one. |
| 1775 | $tokens[] = [ |
| 1776 | 'shapedRun' => new ShapedRun( |
| 1777 | $shapingCtx->font, |
| 1778 | $shapingCtx->fontSizePt, |
| 1779 | $shapingCtx->direction, |
| 1780 | [], |
| 1781 | 0.0, |
| 1782 | ), |
| 1783 | 'isWhitespace' => false, |
| 1784 | 'trailingSpace' => 0.0, |
| 1785 | 'kind' => LineBreakKind::Mandatory, |
| 1786 | 'lineHeight' => $lineHeight, |
| 1787 | 'verticalAlign' => $verticalAlign, |
| 1788 | ]; |
| 1789 | return; |
| 1790 | } |
| 1791 | if ($box instanceof AtomicInlineBox) { |
| 1792 | // Resolve the box's intrinsic *outer* horizontal advance — |
| 1793 | // what the line breaker needs — and the *content-box* |
| 1794 | // width that the painter draws into. The two differ |
| 1795 | // whenever the atomic carries padding / border, and they |
| 1796 | // diverge further under `box-sizing: border-box` (CSS |
| 1797 | // Sizing 3 §6.2): under border-box, the declared `width` |
| 1798 | // already includes padding + border, so the content |
| 1799 | // shrinks by that inset instead of the outer growing. |
| 1800 | $widthValue = $box->style->get('width'); |
| 1801 | // CSS Sizing 3 §6.2 — percentages on atomic-inline |
| 1802 | // `width` resolve against the inline-formatting-context's |
| 1803 | // containing block (= the `availableWidth` parameter). |
| 1804 | // Without this, `<canvas style="width: 100%">` collapses |
| 1805 | // to 0 instead of stretching to fill the line. |
| 1806 | $declaredWidth = match (true) { |
| 1807 | $widthValue instanceof Length => $widthValue->value, |
| 1808 | $widthValue instanceof \Phpdftk\Css\Value\Percentage |
| 1809 | => $this->currentAvailableWidth * ($widthValue->value / 100.0), |
| 1810 | default => 0.0, |
| 1811 | }; |
| 1812 | $atomicPadLeft = self::atomicLength($box->style->get('padding-left')); |
| 1813 | $atomicPadRight = self::atomicLength($box->style->get('padding-right')); |
| 1814 | $atomicBorderLeft = self::atomicBorderWidth($box->style, 'left'); |
| 1815 | $atomicBorderRight = self::atomicBorderWidth($box->style, 'right'); |
| 1816 | $horizontalInset = $atomicPadLeft + $atomicPadRight + $atomicBorderLeft + $atomicBorderRight; |
| 1817 | // CSS 2.2 §10.8 — an inline-block's margins participate in layout: |
| 1818 | // horizontal margins add to the inline advance it occupies on the |
| 1819 | // line; vertical margins are part of its margin box (which |
| 1820 | // determines its line-height contribution). Previously dropped, so |
| 1821 | // adjacent inline-blocks touched instead of showing their gaps. |
| 1822 | $atomicMarginLeft = self::atomicLength($box->style->get('margin-left')); |
| 1823 | $atomicMarginRight = self::atomicLength($box->style->get('margin-right')); |
| 1824 | $atomicMarginTop = self::atomicLength($box->style->get('margin-top')); |
| 1825 | $atomicMarginBottom = self::atomicLength($box->style->get('margin-bottom')); |
| 1826 | $atomicBorderBox = self::atomicIsBorderBoxSizing($box->style); |
| 1827 | // CSS 2.1 §9.4.2 — when the box has an inner formatting |
| 1828 | // context, `BlockLayout::layoutInlineAtomicContents()` has |
| 1829 | // already laid it out and resolved its used content size |
| 1830 | // (shrink-to-fit for `width: auto`, §10.3.9). Take that size |
| 1831 | // and the box-model edges it resolved verbatim: recomputing |
| 1832 | // them here from the cascade would desynchronise the advance |
| 1833 | // the line breaker allocates from the geometry the children |
| 1834 | // were actually laid out in. |
| 1835 | if ($box->laidOutContentWidth !== null) { |
| 1836 | $g = $box->geometry; |
| 1837 | $atomicPadLeft = $g->paddingLeft; |
| 1838 | $atomicPadRight = $g->paddingRight; |
| 1839 | $atomicBorderLeft = $g->borderLeft; |
| 1840 | $atomicBorderRight = $g->borderRight; |
| 1841 | $atomicMarginLeft = $g->marginLeft; |
| 1842 | $atomicMarginRight = $g->marginRight; |
| 1843 | $atomicMarginTop = $g->marginTop; |
| 1844 | $atomicMarginBottom = $g->marginBottom; |
| 1845 | $horizontalInset = $atomicPadLeft + $atomicPadRight |
| 1846 | + $atomicBorderLeft + $atomicBorderRight; |
| 1847 | $atomicContentWidth = $box->laidOutContentWidth; |
| 1848 | $atomicOuterWidth = $atomicContentWidth + $horizontalInset; |
| 1849 | } elseif ($declaredWidth > 0.0) { |
| 1850 | if ($atomicBorderBox) { |
| 1851 | // Declared width is the border-box; content shrinks |
| 1852 | // by the inset, outer stays at the declared value. |
| 1853 | $atomicContentWidth = max(0.0, $declaredWidth - $horizontalInset); |
| 1854 | $atomicOuterWidth = $declaredWidth; |
| 1855 | } else { |
| 1856 | // Declared width is the content-box; outer grows by |
| 1857 | // the inset. |
| 1858 | $atomicContentWidth = $declaredWidth; |
| 1859 | $atomicOuterWidth = $declaredWidth + $horizontalInset; |
| 1860 | } |
| 1861 | } else { |
| 1862 | // `width: auto`: CSS 2.1 §10.3.2 — a replaced element with a |
| 1863 | // definite height and an intrinsic aspect ratio derives its |
| 1864 | // width from height × ratio. Resolve the definite content |
| 1865 | // height (an explicit length, or a percentage against a |
| 1866 | // definite CB height) and transfer it through the ratio, so |
| 1867 | // the shaped path sizes an auto-width `<img height="100%">` |
| 1868 | // the same way layoutAtomicOnly() does. Without a definite |
| 1869 | // height or a ratio, defer to the painter's intrinsic |
| 1870 | // fallback (content = 0, no allocated advance). |
| 1871 | $definiteHeight = $this->atomicDefiniteContentHeight($box); |
| 1872 | $ratio = self::atomicAspectRatio($box->style); |
| 1873 | if ($definiteHeight !== null && $definiteHeight > 0.0 |
| 1874 | && $ratio !== null && $ratio > 0.0 |
| 1875 | ) { |
| 1876 | $atomicContentWidth = $definiteHeight * $ratio; |
| 1877 | $atomicOuterWidth = $atomicContentWidth + $horizontalInset; |
| 1878 | } else { |
| 1879 | $atomicContentWidth = 0.0; |
| 1880 | $atomicOuterWidth = $horizontalInset; |
| 1881 | } |
| 1882 | } |
| 1883 | // CSS Writing Modes 4 §7.1 — `width` / `height` on a replaced |
| 1884 | // or inline-block box stay PHYSICAL, so which of them is the |
| 1885 | // inline size depends on the writing mode. Resolve the block |
| 1886 | // extents here (not in the fitter loop) because the advance |
| 1887 | // below needs them in a vertical mode. |
| 1888 | $atomicHeights = $this->resolveAtomicHeights( |
| 1889 | $box, |
| 1890 | $atomicBorderBox, |
| 1891 | $atomicMarginLeft + $atomicOuterWidth + $atomicMarginRight, |
| 1892 | ); |
| 1893 | // Advance = margin-box INLINE size so the line-breaker and fitter |
| 1894 | // allocate the item's full footprint along the inline axis: its |
| 1895 | // horizontal extent in horizontal-tb, its VERTICAL extent in a |
| 1896 | // vertical writing mode. |
| 1897 | $atomicAdvance = $this->currentIsVertical |
| 1898 | ? $atomicMarginTop + $atomicHeights[1] + $atomicMarginBottom |
| 1899 | : $atomicMarginLeft + $atomicOuterWidth + $atomicMarginRight; |
| 1900 | $tokens[] = [ |
| 1901 | 'shapedRun' => new ShapedRun( |
| 1902 | $shapingCtx->font, |
| 1903 | $shapingCtx->fontSizePt, |
| 1904 | $shapingCtx->direction, |
| 1905 | [], |
| 1906 | $atomicAdvance, |
| 1907 | ), |
| 1908 | 'isWhitespace' => false, |
| 1909 | 'trailingSpace' => 0.0, |
| 1910 | 'kind' => LineBreakKind::Allowed, |
| 1911 | 'baselineShift' => $baselineShift, |
| 1912 | 'lineHeight' => $lineHeight, |
| 1913 | 'verticalAlign' => $this->resolveVerticalAlignKeyword($box), |
| 1914 | 'href' => $href, |
| 1915 | 'isBold' => $isBold, |
| 1916 | 'isItalic' => $isItalic, |
| 1917 | 'decorationLines' => $decorationLines, |
| 1918 | 'textColor' => $textColor, |
| 1919 | 'backgroundColor' => $backgroundColor, |
| 1920 | 'linkTitle' => $linkTitle, |
| 1921 | 'decorationColor' => $decorationColor, |
| 1922 | 'atomicBox' => $box, |
| 1923 | 'atomicHeights' => $atomicHeights, |
| 1924 | 'atomicContentWidth' => $atomicContentWidth, |
| 1925 | 'atomicOuterWidth' => $atomicOuterWidth, |
| 1926 | 'atomicPadLeft' => $atomicPadLeft, |
| 1927 | 'atomicPadRight' => $atomicPadRight, |
| 1928 | 'atomicBorderLeft' => $atomicBorderLeft, |
| 1929 | 'atomicBorderRight' => $atomicBorderRight, |
| 1930 | 'atomicMarginLeft' => $atomicMarginLeft, |
| 1931 | 'atomicMarginRight' => $atomicMarginRight, |
| 1932 | 'atomicMarginTop' => $atomicMarginTop, |
| 1933 | 'atomicMarginBottom' => $atomicMarginBottom, |
| 1934 | 'atomicBorderBox' => $atomicBorderBox, |
| 1935 | ]; |
| 1936 | return; |
| 1937 | } |
| 1938 | if ($box instanceof InlineBox) { |
| 1939 | // CSS Inline 3 §4.5 `vertical-align: sub` / `super` shifts the |
| 1940 | // child fragments' baselines. Composes with any outer shift so |
| 1941 | // nested `<sup><sub>x</sub></sup>` still has a sensible effect. |
| 1942 | $boxShift = $this->resolveVerticalAlign($box, $shapingCtx->fontSizePt); |
| 1943 | // HTML 4 / 5 `<a href="...">` — descendants inherit the href so |
| 1944 | // the painter can emit a `/Link` annotation per fragment. The |
| 1945 | // companion `<a title="...">` lands on the annotation's |
| 1946 | // `/Contents` for hover tooltips. |
| 1947 | $childHref = $href; |
| 1948 | $childTitle = $linkTitle; |
| 1949 | if ($box->element !== null |
| 1950 | && strtolower($box->element->localName) === 'a' |
| 1951 | ) { |
| 1952 | $linkUrl = $box->element->getAttribute('href'); |
| 1953 | if ($linkUrl !== null && $linkUrl !== '') { |
| 1954 | $childHref = $linkUrl; |
| 1955 | $title = $box->element->getAttribute('title'); |
| 1956 | $childTitle = $title === null || $title === '' ? null : $title; |
| 1957 | } |
| 1958 | } |
| 1959 | // Inline-level emphasis: resolve this box's own weight/style |
| 1960 | // request against the FontResolver. A real face match flips |
| 1961 | // the per-fragment fake-bold / fake-italic flags off; an |
| 1962 | // unmatched request OR no faceMap entry leaves the cascade's |
| 1963 | // synthetic-effect flags on so the painter draws the fallback. |
| 1964 | // OR with the inherited flags so `<strong><em>X</em></strong>` |
| 1965 | // keeps both effects even when only one resolves to a real face. |
| 1966 | $boxMatch = $this->resolveBoxFont($box, $shapingCtx->font); |
| 1967 | $childBold = $boxMatch['isBold'] || $isBold; |
| 1968 | $childItalic = $boxMatch['isItalic'] || $isItalic; |
| 1969 | // CSS Text Decoration 4 §2 says decorations set on an inline |
| 1970 | // apply to all in-flow descendant text. Union the box's |
| 1971 | // decoration lines with whatever the enclosing context set. |
| 1972 | $childDeco = $this->mergeDecorationLines($decorationLines, $this->decorationLines($box)); |
| 1973 | // §3: `text-decoration-color` doesn't inherit, but when an |
| 1974 | // inline element sets a colour explicitly that colour applies |
| 1975 | // to its descendant fragments' decorations. A child only |
| 1976 | // overrides if it sets its own value — otherwise it keeps the |
| 1977 | // closest ancestor's choice. |
| 1978 | $childDecoColor = $this->resolveDecorationColor($box) ?? $decorationColor; |
| 1979 | // The inline box's own cascaded `color` overrides the inherited |
| 1980 | // one — `<a>` gets blue from the UA stylesheet even when its |
| 1981 | // parent is black. |
| 1982 | $childColor = $this->resolveColor($box) ?? $textColor; |
| 1983 | // `background-color` is not inherited but propagates downward |
| 1984 | // for inline rendering — every descendant fragment of a |
| 1985 | // `<mark>` should carry the yellow rect. |
| 1986 | $boxBg = $this->resolveBackground($box); |
| 1987 | $childBg = $boxBg ?? $backgroundColor; |
| 1988 | // CSS 2.1 §10.6.1 — vertical padding / border on an inline box |
| 1989 | // leave line height alone but are still PAINTED, so the box's |
| 1990 | // background bleeds over the lines above and below. The insets |
| 1991 | // travel with the background they belong to: a box painting its |
| 1992 | // own background carries its own insets, otherwise the nearest |
| 1993 | // ancestor's pair passes through unchanged. |
| 1994 | $childExtendAbove = $bgExtendAbove; |
| 1995 | $childExtendBelow = $bgExtendBelow; |
| 1996 | if ($boxBg !== null) { |
| 1997 | $childExtendAbove = self::atomicBorderWidth($box->style, 'top') |
| 1998 | + self::atomicLength($box->style->get('padding-top')); |
| 1999 | $childExtendBelow = self::atomicBorderWidth($box->style, 'bottom') |
| 2000 | + self::atomicLength($box->style->get('padding-bottom')); |
| 2001 | } |
| 2002 | // Mixed-size inline runs: if this inline carries a different |
| 2003 | // computed `font-size` than the active shaping context, build |
| 2004 | // a per-subtree context so descendants shape at the right size. |
| 2005 | // Same for `font-family` — when an inline names a font that's |
| 2006 | // registered in the FontResolver, switch the shaping font. |
| 2007 | $childCtx = $shapingCtx; |
| 2008 | $boxFontSize = $this->boxFontSize($box) ?? $shapingCtx->fontSizePt; |
| 2009 | // CSS Inline 3 §3 — `line-height` inherits (as a number it |
| 2010 | // re-resolves against each box's own font-size), so resolve this |
| 2011 | // inline box's used line-height here and carry it down. Fragments |
| 2012 | // stamp it so `lineMetrics` can apply per-run half-leading |
| 2013 | // instead of the block's uniform value. |
| 2014 | $childLineHeight = $this->resolveLineHeight($box, $boxFontSize); |
| 2015 | // vertical-align does NOT inherit: each inline box gets its own |
| 2016 | // keyword (default `baseline`), applied to its direct content. |
| 2017 | $childVAlign = $this->resolveVerticalAlignKeyword($box); |
| 2018 | $boxFont = $boxMatch['font'] ?? $shapingCtx->font; |
| 2019 | $fontSizeChanged = abs($boxFontSize - $shapingCtx->fontSizePt) > 0.001; |
| 2020 | $fontChanged = $boxFont !== $shapingCtx->font; |
| 2021 | if ($fontSizeChanged || $fontChanged) { |
| 2022 | $childCtx = new ShapingContext($boxFont, $boxFontSize); |
| 2023 | } |
| 2024 | // CSS 2.1 §8.4 — horizontal padding, border and margin on an |
| 2025 | // INLINE box are laid out: they add to its advance, pushing the |
| 2026 | // following content along. (The vertical ones deliberately do |
| 2027 | // NOT affect line height per §10.6.1.) They were being dropped |
| 2028 | // entirely, so a padded `<span>` occupied exactly its text. |
| 2029 | // |
| 2030 | // The inset rides in as a zero-glyph spacer token carrying the |
| 2031 | // inline's own background, so the padding area paints with the |
| 2032 | // box rather than showing the parent through. It is emitted with |
| 2033 | // a non-breaking kind: a line may not break inside an inline's |
| 2034 | // own padding. |
| 2035 | $leadInset = $this->inlineInlineAxisInset($box, 'left'); |
| 2036 | if ($leadInset > 0.0) { |
| 2037 | $tokens[] = $this->inlineSpacerToken( |
| 2038 | $leadInset, |
| 2039 | $childCtx, |
| 2040 | $baselineShift + $boxShift, |
| 2041 | $childLineHeight, |
| 2042 | $childVAlign, |
| 2043 | $childHref, |
| 2044 | $childBg, |
| 2045 | $childTitle, |
| 2046 | $childExtendAbove, |
| 2047 | $childExtendBelow, |
| 2048 | ); |
| 2049 | } |
| 2050 | foreach ($box->children as $c) { |
| 2051 | $this->walkInline( |
| 2052 | $c, |
| 2053 | $childCtx, |
| 2054 | $tokens, |
| 2055 | $collapseInternal, |
| 2056 | $letterSpacing, |
| 2057 | $wordSpacing, |
| 2058 | $baselineShift + $boxShift, |
| 2059 | $childLineHeight, |
| 2060 | $childVAlign, |
| 2061 | $childHref, |
| 2062 | $childBold, |
| 2063 | $childItalic, |
| 2064 | $childDeco, |
| 2065 | $childColor, |
| 2066 | $childBg, |
| 2067 | $childTitle, |
| 2068 | $childDecoColor, |
| 2069 | $splitWsBoundaries, |
| 2070 | $childExtendAbove, |
| 2071 | $childExtendBelow, |
| 2072 | ); |
| 2073 | } |
| 2074 | $trailInset = $this->inlineInlineAxisInset($box, 'right'); |
| 2075 | if ($trailInset > 0.0) { |
| 2076 | $tokens[] = $this->inlineSpacerToken( |
| 2077 | $trailInset, |
| 2078 | $childCtx, |
| 2079 | $baselineShift + $boxShift, |
| 2080 | $childLineHeight, |
| 2081 | $childVAlign, |
| 2082 | $childHref, |
| 2083 | $childBg, |
| 2084 | $childTitle, |
| 2085 | $childExtendAbove, |
| 2086 | $childExtendBelow, |
| 2087 | ); |
| 2088 | } |
| 2089 | } |
| 2090 | } |
| 2091 | |
| 2092 | /** |
| 2093 | * The inline-axis inset an inline box contributes on one side: its |
| 2094 | * margin + border + padding (CSS 2.1 §8.4). |
| 2095 | */ |
| 2096 | private function inlineInlineAxisInset(InlineBox $box, string $side): float |
| 2097 | { |
| 2098 | return self::atomicLength($box->style->get("margin-$side")) |
| 2099 | + self::atomicBorderWidth($box->style, $side) |
| 2100 | + self::atomicLength($box->style->get("padding-$side")); |
| 2101 | } |
| 2102 | |
| 2103 | /** |
| 2104 | * A zero-glyph token that occupies `$width` of inline advance — used for |
| 2105 | * an inline box's own horizontal padding / border / margin. |
| 2106 | * |
| 2107 | * It carries the inline's background so the inset paints as part of the |
| 2108 | * box, and is marked `Mandatory`-free: `kind` stays `Allowed` only at the |
| 2109 | * OUTER edges of the run, never between the inset and its own text, which |
| 2110 | * is why the spacer itself reports no break opportunity of its own. |
| 2111 | * |
| 2112 | * @return array{shapedRun: ShapedRun, isWhitespace: bool, kind: LineBreakKind, trailingSpace: float, baselineShift: float, lineHeight: float, verticalAlign: string, href: ?string, backgroundColor: ?\Phpdftk\Css\Value\Color, linkTitle: ?string} |
| 2113 | */ |
| 2114 | private function inlineSpacerToken( |
| 2115 | float $width, |
| 2116 | ShapingContext $ctx, |
| 2117 | float $baselineShift, |
| 2118 | float $lineHeight, |
| 2119 | string $verticalAlign, |
| 2120 | ?string $href, |
| 2121 | ?\Phpdftk\Css\Value\Color $background, |
| 2122 | ?string $linkTitle, |
| 2123 | float $bgExtendAbove = 0.0, |
| 2124 | float $bgExtendBelow = 0.0, |
| 2125 | ): array { |
| 2126 | return [ |
| 2127 | 'shapedRun' => new ShapedRun( |
| 2128 | $ctx->font, |
| 2129 | $ctx->fontSizePt, |
| 2130 | \Phpdftk\Text\ShapingDirection::Ltr, |
| 2131 | [], |
| 2132 | $width, |
| 2133 | ), |
| 2134 | 'isWhitespace' => false, |
| 2135 | 'kind' => LineBreakKind::Allowed, |
| 2136 | 'trailingSpace' => 0.0, |
| 2137 | 'noWrapBefore' => true, |
| 2138 | 'baselineShift' => $baselineShift, |
| 2139 | 'lineHeight' => $lineHeight, |
| 2140 | 'verticalAlign' => $verticalAlign, |
| 2141 | 'href' => $href, |
| 2142 | 'backgroundColor' => $background, |
| 2143 | 'bgExtendAbove' => $bgExtendAbove, |
| 2144 | 'bgExtendBelow' => $bgExtendBelow, |
| 2145 | 'linkTitle' => $linkTitle, |
| 2146 | ]; |
| 2147 | } |
| 2148 | |
| 2149 | /** |
| 2150 | * The alphabetic ascent of a fragment's shaping font in layout units: |
| 2151 | * `(font.ascent / unitsPerEm) × fontSizePt`. Mirrors the painter's own |
| 2152 | * ascent computation so the layout baseline and the painted baseline |
| 2153 | * agree. |
| 2154 | */ |
| 2155 | private function fragmentAscent(InlineFragment $f): float |
| 2156 | { |
| 2157 | $font = $f->shapedRun->font; |
| 2158 | return ($font->ascent / max(1, $font->unitsPerEm)) * $f->shapedRun->fontSizePt; |
| 2159 | } |
| 2160 | |
| 2161 | /** |
| 2162 | * The alphabetic descent of a fragment's shaping font in layout units: |
| 2163 | * `(|font.descent| / unitsPerEm) × fontSizePt`. Positive (distance below |
| 2164 | * the baseline), mirroring the painter's descent computation. |
| 2165 | */ |
| 2166 | private function fragmentDescent(InlineFragment $f): float |
| 2167 | { |
| 2168 | $font = $f->shapedRun->font; |
| 2169 | return (abs($font->descent) / max(1, $font->unitsPerEm)) * $f->shapedRun->fontSizePt; |
| 2170 | } |
| 2171 | |
| 2172 | /** |
| 2173 | * CSS2 §10.8 line-box sizing + `vertical-align` placement, in two passes. |
| 2174 | * |
| 2175 | * Each inline box (and the block's own strut) contributes a content box of |
| 2176 | * `ascent + descent` grown by its half-leading `(lineHeight − ascent − |
| 2177 | * descent) / 2`. A fragment's offset `o` from the line baseline (positive = |
| 2178 | * lower) drives both its placement and its extent (`above = expandedAscent |
| 2179 | * − o`, `below = expandedDescent + o`). |
| 2180 | * |
| 2181 | * Pass A — baseline (with composed `sub`/`super`/`<length>` already in |
| 2182 | * `baselineShift`) plus the strut-relative keywords `text-top` |
| 2183 | * (content-top → line text-top), `text-bottom` (content-bottom → line |
| 2184 | * text-bottom) and `middle` (box centre → baseline − x-height/2). The line |
| 2185 | * baseline sits at the greatest `above`; the height runs to the greatest |
| 2186 | * `below`. The strut always participates, so an empty line or a line of |
| 2187 | * only-smaller text still reserves the block's line height. |
| 2188 | * |
| 2189 | * Pass B — the line-box-relative keywords `top` (box top → line top, may |
| 2190 | * grow the line downward) and `bottom` (box bottom → line bottom), placed |
| 2191 | * against the Pass-A extent. |
| 2192 | * |
| 2193 | * Fragments whose computed offset differs from their stored `baselineShift` |
| 2194 | * are reconstructed so the painter (which draws at `line.baseline + |
| 2195 | * baselineShift`) places them correctly. A fragment whose `lineHeight` is |
| 2196 | * the `-1.0` sentinel falls back to no leading; an explicit `0.0` is |
| 2197 | * honoured as full negative leading. |
| 2198 | * |
| 2199 | * @param list<InlineFragment> $fragments |
| 2200 | * @return array{float, float, list<InlineFragment>} [height, baseline, fragments] |
| 2201 | */ |
| 2202 | private function finalizeLine( |
| 2203 | array $fragments, |
| 2204 | float $strutAscent, |
| 2205 | float $strutDescent, |
| 2206 | float $strutLineHeight, |
| 2207 | float $strutXHeight, |
| 2208 | ): array { |
| 2209 | $strutLead = ($strutLineHeight - ($strutAscent + $strutDescent)) / 2.0; |
| 2210 | // CSS Writing Modes 4 §4.1 — a vertical writing mode's dominant |
| 2211 | // baseline is CENTRAL, not alphabetic: every item's extent is |
| 2212 | // centred on it rather than split into an ascent above and a |
| 2213 | // descent below. Modelling that as a symmetric half-extent makes |
| 2214 | // the line's cross size come out as the widest item's extent, |
| 2215 | // instead of that item's full extent PLUS the strut's descent. |
| 2216 | $vertical = $this->currentIsVertical; |
| 2217 | $above = $vertical |
| 2218 | ? ($strutAscent + $strutDescent) / 2.0 + $strutLead |
| 2219 | : $strutAscent + $strutLead; |
| 2220 | $below = $vertical |
| 2221 | ? ($strutAscent + $strutDescent) / 2.0 + $strutLead |
| 2222 | : $strutDescent + $strutLead; |
| 2223 | // Per-fragment offset below the line baseline; null defers a |
| 2224 | // top/bottom fragment to pass B. |
| 2225 | /** @var array<int, float|null> $offsets */ |
| 2226 | $offsets = []; |
| 2227 | foreach ($fragments as $i => $f) { |
| 2228 | [$a, $d, $ea, $ed] = $this->fragmentExtents($f); |
| 2229 | if ($f->verticalAlign === 'top' || $f->verticalAlign === 'bottom') { |
| 2230 | $offsets[$i] = null; |
| 2231 | continue; |
| 2232 | } |
| 2233 | $o = match ($f->verticalAlign) { |
| 2234 | 'text-top' => $a - $strutAscent, |
| 2235 | 'text-bottom' => $strutDescent - $d, |
| 2236 | 'middle' => ($a - $d) / 2.0 - $strutXHeight / 2.0, |
| 2237 | default => $f->baselineShift, |
| 2238 | }; |
| 2239 | $offsets[$i] = $o; |
| 2240 | if ($vertical) { |
| 2241 | $half = ($ea + $ed) / 2.0; |
| 2242 | $above = max($above, $half - $o); |
| 2243 | $below = max($below, $half + $o); |
| 2244 | continue; |
| 2245 | } |
| 2246 | $above = max($above, $ea - $o); |
| 2247 | $below = max($below, $ed + $o); |
| 2248 | } |
| 2249 | $baseline = $above; |
| 2250 | $height = $above + $below; |
| 2251 | // Pass B — `top` / `bottom` align against the LINE BOX rather than the |
| 2252 | // baseline, so they can only be placed once the baseline-aligned |
| 2253 | // content has sized it. CSS 2.1 §10.8.1: the line box height spans the |
| 2254 | // uppermost box top to the lowermost box bottom, and top/bottom-aligned |
| 2255 | // boxes are included in that span — a box taller than the line grows |
| 2256 | // it. Which EDGE is pinned decides which way it grows: `top` holds the |
| 2257 | // box top against the line top so the line grows downward and the |
| 2258 | // baseline stays put, while `bottom` holds the box bottom against the |
| 2259 | // line bottom so the line grows upward and the baseline travels down |
| 2260 | // with it. |
| 2261 | // |
| 2262 | // Growth therefore has to finish before any offset is assigned; |
| 2263 | // computing them in one pass left every `bottom` box pinned to a line |
| 2264 | // that had not yet grown, so a 50px inline-block sat in a 19px line |
| 2265 | // and consecutive ones overlapped. |
| 2266 | $deferred = []; |
| 2267 | foreach ($fragments as $i => $f) { |
| 2268 | if ($offsets[$i] !== null) { |
| 2269 | continue; |
| 2270 | } |
| 2271 | [, , $ea, $ed] = $this->fragmentExtents($f); |
| 2272 | $deferred[$i] = [$f->verticalAlign, $ea, $ed]; |
| 2273 | } |
| 2274 | foreach ($deferred as [$verticalAlign, $ea, $ed]) { |
| 2275 | $extent = $ea + $ed; |
| 2276 | if ($extent <= $height) { |
| 2277 | continue; |
| 2278 | } |
| 2279 | if ($verticalAlign !== 'top') { |
| 2280 | $baseline += $extent - $height; |
| 2281 | } |
| 2282 | $height = $extent; |
| 2283 | } |
| 2284 | foreach ($deferred as $i => [$verticalAlign, $ea, $ed]) { |
| 2285 | $offsets[$i] = $verticalAlign === 'top' |
| 2286 | ? $ea - $baseline |
| 2287 | : $height - $ed - $baseline; |
| 2288 | } |
| 2289 | $out = []; |
| 2290 | foreach ($fragments as $i => $f) { |
| 2291 | $o = $offsets[$i] ?? $f->baselineShift; |
| 2292 | $out[] = abs($o - $f->baselineShift) < 0.0001 |
| 2293 | ? $f |
| 2294 | : $this->withBaselineShift($f, $o); |
| 2295 | } |
| 2296 | return [$height, $baseline, $out]; |
| 2297 | } |
| 2298 | |
| 2299 | /** |
| 2300 | * Resolve an atomic inline box's used BLOCK-axis extents, with the |
| 2301 | * same box-sizing semantics the inline-axis pass applies to its |
| 2302 | * widths. |
| 2303 | * |
| 2304 | * Returns `[contentHeight, outerHeight, padTop, padBottom, |
| 2305 | * borderTop, borderBottom]`, where `outerHeight` is the border box |
| 2306 | * (padding + border included, margins not). |
| 2307 | * |
| 2308 | * `$fallbackOuter` is the value an `auto` height with no intrinsic |
| 2309 | * source squares to — the box's own outer WIDTH, preserving the |
| 2310 | * historical "no height = square box" contract. |
| 2311 | * |
| 2312 | * @return array{float, float, float, float, float, float} |
| 2313 | */ |
| 2314 | private function resolveAtomicHeights( |
| 2315 | AtomicInlineBox $box, |
| 2316 | bool $borderBox, |
| 2317 | float $fallbackOuter, |
| 2318 | ): array { |
| 2319 | $heightValue = $box->style->get('height'); |
| 2320 | // CSS 2.1 §10.5 — a percentage block size resolves only against a |
| 2321 | // definite containing-block height; otherwise it stays auto (0 |
| 2322 | // here) and the box squares to its width. An explicit length / `0` |
| 2323 | // is always definite. |
| 2324 | $declaredHeight = match (true) { |
| 2325 | $heightValue instanceof Length => $heightValue->value, |
| 2326 | $heightValue instanceof \Phpdftk\Css\Value\Integer => (float) $heightValue->value, |
| 2327 | $heightValue instanceof \Phpdftk\Css\Value\Percentage |
| 2328 | && $this->currentCbHeightDefinite && $this->currentCbHeight > 0.0 |
| 2329 | => $this->currentCbHeight * ($heightValue->value / 100.0), |
| 2330 | default => 0.0, |
| 2331 | }; |
| 2332 | $padTop = self::atomicLength($box->style->get('padding-top')); |
| 2333 | $padBottom = self::atomicLength($box->style->get('padding-bottom')); |
| 2334 | $borderTop = self::atomicBorderWidth($box->style, 'top'); |
| 2335 | $borderBottom = self::atomicBorderWidth($box->style, 'bottom'); |
| 2336 | $inset = $padTop + $padBottom + $borderTop + $borderBottom; |
| 2337 | // The pre-layout pass resolved the used insets and the used content |
| 2338 | // block size (CSS 2.1 §10.6.3 — `height: auto` on a block container |
| 2339 | // is the distance to its last line box's bottom). Prefer both over |
| 2340 | // the cascade re-read, which has no way to know how tall the |
| 2341 | // contents came out. |
| 2342 | if ($box->laidOutContentHeight !== null) { |
| 2343 | $padTop = $box->geometry->paddingTop; |
| 2344 | $padBottom = $box->geometry->paddingBottom; |
| 2345 | $borderTop = $box->geometry->borderTop; |
| 2346 | $borderBottom = $box->geometry->borderBottom; |
| 2347 | $inset = $padTop + $padBottom + $borderTop + $borderBottom; |
| 2348 | $content = $box->laidOutContentHeight; |
| 2349 | |
| 2350 | return [$content, $content + $inset, $padTop, $padBottom, $borderTop, $borderBottom]; |
| 2351 | } |
| 2352 | if ($declaredHeight > 0.0) { |
| 2353 | if ($borderBox) { |
| 2354 | return [ |
| 2355 | max(0.0, $declaredHeight - $inset), |
| 2356 | $declaredHeight, |
| 2357 | $padTop, |
| 2358 | $padBottom, |
| 2359 | $borderTop, |
| 2360 | $borderBottom, |
| 2361 | ]; |
| 2362 | } |
| 2363 | |
| 2364 | return [ |
| 2365 | $declaredHeight, |
| 2366 | $declaredHeight + $inset, |
| 2367 | $padTop, |
| 2368 | $padBottom, |
| 2369 | $borderTop, |
| 2370 | $borderBottom, |
| 2371 | ]; |
| 2372 | } |
| 2373 | |
| 2374 | return [ |
| 2375 | max(0.0, $fallbackOuter - $inset), |
| 2376 | $fallbackOuter, |
| 2377 | $padTop, |
| 2378 | $padBottom, |
| 2379 | $borderTop, |
| 2380 | $borderBottom, |
| 2381 | ]; |
| 2382 | } |
| 2383 | |
| 2384 | /** |
| 2385 | * Re-commit each inline atomic / replaced fragment's block-axis position |
| 2386 | * against the FINALIZED line baseline. The atomic side-channel in the |
| 2387 | * token loop seeds `geometry->y` from the raw shaping-font ascent, but the |
| 2388 | * true baseline is only known once {@see finalizeLine} has sized the line |
| 2389 | * — a tall atomic grows the line and moves the baseline downward. CSS 2.1 |
| 2390 | * §10.8.1: a replaced / inline-block box aligns its bottom margin edge with |
| 2391 | * the line baseline, as shifted by `vertical-align` (folded into the |
| 2392 | * fragment's resolved `baselineShift` by finalizeLine). This overwrites the |
| 2393 | * seed with the correct value; nothing reads the seed in between. Pure-text |
| 2394 | * lines carry no atomic fragments, so this is a no-op for them. |
| 2395 | * |
| 2396 | * @param list<InlineFragment> $fragments |
| 2397 | */ |
| 2398 | private function commitAtomicFragmentY(Box $parent, float $lineTop, float $lineBaseline, array $fragments): void |
| 2399 | { |
| 2400 | foreach ($fragments as $f) { |
| 2401 | $atomic = $f->atomicBox; |
| 2402 | if ($atomic === null) { |
| 2403 | continue; |
| 2404 | } |
| 2405 | $g = $atomic->geometry; |
| 2406 | $baselineY = $parent->geometry->y + $lineTop + $lineBaseline + $f->baselineShift; |
| 2407 | // §10.8.1 — an `inline-block` with in-flow line boxes puts its |
| 2408 | // OWN last baseline on the line's baseline, so the content-box |
| 2409 | // top sits exactly that offset above it. |
| 2410 | if ($atomic->laidOutBaseline !== null) { |
| 2411 | $g->y = $baselineY - $atomic->laidOutBaseline; |
| 2412 | continue; |
| 2413 | } |
| 2414 | // Border-box height from the committed geometry (content + |
| 2415 | // padding + border); the margin box adds the vertical margins. |
| 2416 | $outerHeight = $g->borderTop + $g->paddingTop + $g->height + $g->paddingBottom + $g->borderBottom; |
| 2417 | // Margin-box bottom on the baseline (+ vertical-align shift); step |
| 2418 | // up past bottom margin and the border box, then back down into the |
| 2419 | // content box's top-left — mirrors the seed formula in the token |
| 2420 | // loop but with the finalized baseline instead of the font ascent. |
| 2421 | $g->y = $baselineY |
| 2422 | - $g->marginBottom - $outerHeight + $g->borderTop + $g->paddingTop; |
| 2423 | } |
| 2424 | } |
| 2425 | |
| 2426 | /** |
| 2427 | * Re-commit each inline atomic / replaced fragment's inline-axis position |
| 2428 | * after `text-align` (and justify) have shifted the line's fragments. The |
| 2429 | * token loop seeded the atomic box's `geometry->x` from the pre-alignment |
| 2430 | * cursor, so a centred / right-aligned / justified line left the box |
| 2431 | * behind at the line's start while its text shifted. This mirrors the |
| 2432 | * seed formula in the token loop (content-box left = margin-box start + |
| 2433 | * left margin + border + padding) but with the fragment's now-shifted |
| 2434 | * `x`. Pure-text lines carry no atomic fragments, so this is a no-op. |
| 2435 | * |
| 2436 | * @param list<InlineFragment> $fragments |
| 2437 | */ |
| 2438 | private function commitAtomicFragmentX(Box $parent, array $fragments): void |
| 2439 | { |
| 2440 | foreach ($fragments as $f) { |
| 2441 | $atomic = $f->atomicBox; |
| 2442 | if ($atomic === null) { |
| 2443 | continue; |
| 2444 | } |
| 2445 | $g = $atomic->geometry; |
| 2446 | $g->x = $parent->geometry->x + $f->x + $g->marginLeft + $g->borderLeft + $g->paddingLeft; |
| 2447 | } |
| 2448 | } |
| 2449 | |
| 2450 | /** |
| 2451 | * CSS 2.1 §10.8.1 — the distance from an atomic inline box's MARGIN-box |
| 2452 | * top edge down to the baseline it aligns on the line with. |
| 2453 | * |
| 2454 | * `BlockLayout` records the offset from the content-box top while laying |
| 2455 | * the box's own formatting context out; this re-bases it onto the margin |
| 2456 | * box, which is the extent the line box actually reserves. `null` when |
| 2457 | * the box takes the §10.8.1 fallback (no in-flow line boxes, or |
| 2458 | * `overflow` other than `visible`) and so aligns its bottom margin edge. |
| 2459 | */ |
| 2460 | private static function atomicBaselineFromMarginTop(AtomicInlineBox $box): ?float |
| 2461 | { |
| 2462 | if ($box->laidOutBaseline === null) { |
| 2463 | return null; |
| 2464 | } |
| 2465 | $g = $box->geometry; |
| 2466 | |
| 2467 | return $g->marginTop + $g->borderTop + $g->paddingTop + $box->laidOutBaseline; |
| 2468 | } |
| 2469 | |
| 2470 | /** |
| 2471 | * A fragment's ascent, descent, and their half-leading-expanded forms: |
| 2472 | * `[ascent, descent, expandedAscent, expandedDescent]`. |
| 2473 | * |
| 2474 | * @return array{float, float, float, float} |
| 2475 | */ |
| 2476 | private function fragmentExtents(InlineFragment $f): array |
| 2477 | { |
| 2478 | // CSS 2.1 §10.8 — an inline atomic / replaced box contributes its |
| 2479 | // *margin-box height* to the line box, not the surrounding font's |
| 2480 | // ascent/descent (its glyph run is empty). The box's baseline is its |
| 2481 | // bottom margin edge (§10.8.1), so the whole margin box sits above the |
| 2482 | // baseline: ascent = margin-box height, descent = 0. No half-leading — |
| 2483 | // replaced boxes don't carry line-height leading. The geometry was |
| 2484 | // already resolved by the atomic side-channel in the token loop before |
| 2485 | // this line was finalized. |
| 2486 | if ($f->atomicBox !== null) { |
| 2487 | $g = $f->atomicBox->geometry; |
| 2488 | if ($this->currentIsVertical) { |
| 2489 | // CSS Writing Modes 4 §7.1 — the box keeps its physical |
| 2490 | // `width`/`height`, so in a vertical writing mode the extent |
| 2491 | // it contributes to the line's CROSS size is its margin-box |
| 2492 | // WIDTH (its height is the inline advance instead). The |
| 2493 | // §10.8.1 baseline split doesn't apply across the cross axis |
| 2494 | // here, so the whole margin box counts as ascent. |
| 2495 | $crossBox = $g->marginLeft + $g->borderLeft + $g->paddingLeft + $g->width |
| 2496 | + $g->paddingRight + $g->borderRight + $g->marginRight; |
| 2497 | |
| 2498 | return [$crossBox, 0.0, $crossBox, 0.0]; |
| 2499 | } |
| 2500 | $marginBox = $g->marginTop + $g->borderTop + $g->paddingTop + $g->height |
| 2501 | + $g->paddingBottom + $g->borderBottom + $g->marginBottom; |
| 2502 | // §10.8.1 again: an `inline-block` WITH in-flow line boxes |
| 2503 | // aligns its own last baseline with the line's, so only the |
| 2504 | // part of its margin box above that baseline counts as |
| 2505 | // ascent — the rest hangs below like a descender. |
| 2506 | $ascent = self::atomicBaselineFromMarginTop($f->atomicBox); |
| 2507 | if ($ascent !== null) { |
| 2508 | $descent = max(0.0, $marginBox - $ascent); |
| 2509 | |
| 2510 | return [$ascent, $descent, $ascent, $descent]; |
| 2511 | } |
| 2512 | |
| 2513 | return [$marginBox, 0.0, $marginBox, 0.0]; |
| 2514 | } |
| 2515 | $a = $this->fragmentAscent($f); |
| 2516 | $d = $this->fragmentDescent($f); |
| 2517 | $lh = $f->lineHeight >= 0.0 ? $f->lineHeight : ($a + $d); |
| 2518 | $lead = ($lh - ($a + $d)) / 2.0; |
| 2519 | return [$a, $d, $a + $lead, $d + $lead]; |
| 2520 | } |
| 2521 | |
| 2522 | /** |
| 2523 | * Clone a fragment with a new `baselineShift` (its offset below the line |
| 2524 | * baseline). Used by {@see finalizeLine} to write back the resolved |
| 2525 | * `vertical-align` offset while preserving every other field. |
| 2526 | */ |
| 2527 | private function withBaselineShift(InlineFragment $f, float $shift): InlineFragment |
| 2528 | { |
| 2529 | return new InlineFragment( |
| 2530 | $f->x, |
| 2531 | $f->width, |
| 2532 | $f->shapedRun, |
| 2533 | $shift, |
| 2534 | $f->href, |
| 2535 | $f->isBold, |
| 2536 | $f->isItalic, |
| 2537 | $f->decorationLines, |
| 2538 | $f->textColor, |
| 2539 | $f->backgroundColor, |
| 2540 | $f->linkTitle, |
| 2541 | $f->decorationColor, |
| 2542 | $f->isWhitespace, |
| 2543 | $f->lineHeight, |
| 2544 | $f->verticalAlign, |
| 2545 | $f->blockOffset, |
| 2546 | $f->atomicBox, |
| 2547 | $f->bgExtendAbove, |
| 2548 | $f->bgExtendBelow, |
| 2549 | ); |
| 2550 | } |
| 2551 | |
| 2552 | /** |
| 2553 | * The box's cascaded `font-size` in user-space units, or null when the |
| 2554 | * cascade didn't produce a `Length` (the cascade should always produce |
| 2555 | * one after `resolveLengths`; null is just a safety fallback). |
| 2556 | */ |
| 2557 | private function boxFontSize(Box $box): ?float |
| 2558 | { |
| 2559 | $value = $box->style->get('font-size'); |
| 2560 | return $value instanceof Length ? $value->value : null; |
| 2561 | } |
| 2562 | |
| 2563 | /** |
| 2564 | * Resolve the cascaded `font-weight` to a numeric value in the CSS |
| 2565 | * Fonts 4 1–1000 range. Keywords map per spec: `normal` → 400, |
| 2566 | * `bold` / `bolder` → 700, `lighter` → 100. |
| 2567 | */ |
| 2568 | private function resolveWeight(Box $box): int |
| 2569 | { |
| 2570 | $value = $box->style->get('font-weight'); |
| 2571 | if ($value instanceof \Phpdftk\Css\Value\Keyword) { |
| 2572 | return match (strtolower($value->name)) { |
| 2573 | 'bold', 'bolder' => 700, |
| 2574 | 'lighter' => 100, |
| 2575 | default => 400, |
| 2576 | }; |
| 2577 | } |
| 2578 | if ($value instanceof \Phpdftk\Css\Value\Integer |
| 2579 | || $value instanceof \Phpdftk\Css\Value\Number |
| 2580 | ) { |
| 2581 | return max(1, min(1000, (int) $value->value)); |
| 2582 | } |
| 2583 | return 400; |
| 2584 | } |
| 2585 | |
| 2586 | /** |
| 2587 | * Resolve the cascaded `font-style` to a lower-case keyword in the |
| 2588 | * `normal` | `italic` | `oblique` set. Unrecognised values fall back |
| 2589 | * to `normal`. |
| 2590 | */ |
| 2591 | private function resolveStyle(Box $box): string |
| 2592 | { |
| 2593 | $value = $box->style->get('font-style'); |
| 2594 | if ($value instanceof \Phpdftk\Css\Value\Keyword) { |
| 2595 | $lc = strtolower($value->name); |
| 2596 | if (in_array($lc, ['italic', 'oblique'], true)) { |
| 2597 | return $lc; |
| 2598 | } |
| 2599 | } |
| 2600 | return 'normal'; |
| 2601 | } |
| 2602 | |
| 2603 | /** |
| 2604 | * Resolve the cascaded `font-variant-*` family + `font-feature- |
| 2605 | * settings` into the OpenType feature-tag list the shaper |
| 2606 | * consumes. Implements the mappings in CSS Fonts 4 §6 from |
| 2607 | * each high-level value keyword to the underlying OpenType |
| 2608 | * GSUB / GPOS feature tags. |
| 2609 | * |
| 2610 | * Tags from font-variant-* are emitted as bare tag strings |
| 2611 | * (= "enable"); font-feature-settings entries with a non-1 |
| 2612 | * integer are emitted as `tag=N` so the shaper can encode |
| 2613 | * the variant index. The default `kern liga` baseline is |
| 2614 | * always present. |
| 2615 | * |
| 2616 | * @return list<string> |
| 2617 | */ |
| 2618 | private function resolveOpenTypeFeatures(Box $box): array |
| 2619 | { |
| 2620 | $tags = ['kern', 'liga']; |
| 2621 | $add = function (string $tag) use (&$tags): void { |
| 2622 | if (!in_array($tag, $tags, true)) { |
| 2623 | $tags[] = $tag; |
| 2624 | } |
| 2625 | }; |
| 2626 | $disable = function (string $tag) use (&$tags): void { |
| 2627 | $tags = array_values(array_filter($tags, fn(string $t) => $t !== $tag)); |
| 2628 | $tags[] = $tag . '=0'; |
| 2629 | }; |
| 2630 | $variantMap = [ |
| 2631 | 'font-variant-caps' => [ |
| 2632 | 'small-caps' => ['smcp'], |
| 2633 | 'all-small-caps' => ['smcp', 'c2sc'], |
| 2634 | 'petite-caps' => ['pcap'], |
| 2635 | 'all-petite-caps' => ['pcap', 'c2pc'], |
| 2636 | 'unicase' => ['unic'], |
| 2637 | 'titling-caps' => ['titl'], |
| 2638 | ], |
| 2639 | 'font-variant-numeric' => [ |
| 2640 | 'lining-nums' => ['lnum'], |
| 2641 | 'oldstyle-nums' => ['onum'], |
| 2642 | 'proportional-nums' => ['pnum'], |
| 2643 | 'tabular-nums' => ['tnum'], |
| 2644 | 'diagonal-fractions' => ['frac'], |
| 2645 | 'stacked-fractions' => ['afrc'], |
| 2646 | 'ordinal' => ['ordn'], |
| 2647 | 'slashed-zero' => ['zero'], |
| 2648 | ], |
| 2649 | 'font-variant-position' => [ |
| 2650 | 'sub' => ['subs'], |
| 2651 | 'super' => ['sups'], |
| 2652 | ], |
| 2653 | 'font-variant-east-asian' => [ |
| 2654 | 'jis78' => ['jp78'], |
| 2655 | 'jis83' => ['jp83'], |
| 2656 | 'jis90' => ['jp90'], |
| 2657 | 'jis04' => ['jp04'], |
| 2658 | 'simplified' => ['smpl'], |
| 2659 | 'traditional' => ['trad'], |
| 2660 | 'full-width' => ['fwid'], |
| 2661 | 'proportional-width' => ['pwid'], |
| 2662 | 'ruby' => ['ruby'], |
| 2663 | ], |
| 2664 | ]; |
| 2665 | foreach ($variantMap as $prop => $kwMap) { |
| 2666 | $value = $box->style->get($prop); |
| 2667 | foreach ($this->iterateKeywords($value) as $kw) { |
| 2668 | foreach ($kwMap[$kw] ?? [] as $tag) { |
| 2669 | $add($tag); |
| 2670 | } |
| 2671 | } |
| 2672 | } |
| 2673 | // font-variant-ligatures has both enable / disable forms. |
| 2674 | $ligValue = $box->style->get('font-variant-ligatures'); |
| 2675 | foreach ($this->iterateKeywords($ligValue) as $kw) { |
| 2676 | match ($kw) { |
| 2677 | 'common-ligatures' => $add('liga'), |
| 2678 | 'no-common-ligatures' => $disable('liga'), |
| 2679 | 'discretionary-ligatures' => $add('dlig'), |
| 2680 | 'no-discretionary-ligatures' => $disable('dlig'), |
| 2681 | 'historical-ligatures' => $add('hlig'), |
| 2682 | 'no-historical-ligatures' => $disable('hlig'), |
| 2683 | 'contextual' => $add('calt'), |
| 2684 | 'no-contextual' => $disable('calt'), |
| 2685 | default => null, |
| 2686 | }; |
| 2687 | } |
| 2688 | // font-feature-settings — typed values land as |
| 2689 | // FontFeatureSettings; pass each entry through. |
| 2690 | $fss = $box->style->get('font-feature-settings'); |
| 2691 | if ($fss instanceof \Phpdftk\Css\Value\FontFeatureSettings) { |
| 2692 | foreach ($fss->features as $entry) { |
| 2693 | if ($entry->value === 1) { |
| 2694 | $add($entry->tag); |
| 2695 | } elseif ($entry->value === 0) { |
| 2696 | $disable($entry->tag); |
| 2697 | } else { |
| 2698 | // Variant index — encode as tag=N. |
| 2699 | $add($entry->tag . '=' . $entry->value); |
| 2700 | } |
| 2701 | } |
| 2702 | } |
| 2703 | return $tags; |
| 2704 | } |
| 2705 | |
| 2706 | /** |
| 2707 | * Walk a cascaded value yielding each keyword name it |
| 2708 | * carries. Handles bare Keyword and Space-separated ValueList |
| 2709 | * forms (the two shapes font-variant-* values arrive in). |
| 2710 | * |
| 2711 | * @return iterable<string> |
| 2712 | */ |
| 2713 | private function iterateKeywords(?\Phpdftk\Css\Value\Value $value): iterable |
| 2714 | { |
| 2715 | if ($value instanceof \Phpdftk\Css\Value\Keyword) { |
| 2716 | yield strtolower($value->name); |
| 2717 | return; |
| 2718 | } |
| 2719 | if ($value instanceof \Phpdftk\Css\Value\ValueList) { |
| 2720 | foreach ($value->values as $v) { |
| 2721 | if ($v instanceof \Phpdftk\Css\Value\Keyword) { |
| 2722 | yield strtolower($v->name); |
| 2723 | } |
| 2724 | } |
| 2725 | } |
| 2726 | } |
| 2727 | |
| 2728 | /** |
| 2729 | * Resolve the cascaded `font-stretch` to its percentage value on |
| 2730 | * the CSS Fonts 4 §3.4 axis (50..200). Accepts the named keyword |
| 2731 | * forms (`condensed`, `expanded`, ...) and bare percentages. |
| 2732 | */ |
| 2733 | private function resolveStretch(Box $box): float |
| 2734 | { |
| 2735 | $value = $box->style->get('font-stretch'); |
| 2736 | if ($value instanceof \Phpdftk\Css\Value\Keyword) { |
| 2737 | return match (strtolower($value->name)) { |
| 2738 | 'ultra-condensed' => 50.0, |
| 2739 | 'extra-condensed' => 62.5, |
| 2740 | 'condensed' => 75.0, |
| 2741 | 'semi-condensed' => 87.5, |
| 2742 | 'semi-expanded' => 112.5, |
| 2743 | 'expanded' => 125.0, |
| 2744 | 'extra-expanded' => 150.0, |
| 2745 | 'ultra-expanded' => 200.0, |
| 2746 | default => 100.0, |
| 2747 | }; |
| 2748 | } |
| 2749 | if ($value instanceof \Phpdftk\Css\Value\Percentage) { |
| 2750 | return max(50.0, min(200.0, (float) $value->value)); |
| 2751 | } |
| 2752 | return 100.0; |
| 2753 | } |
| 2754 | |
| 2755 | /** |
| 2756 | * Combined font/weight/style resolution for the box. Picks the |
| 2757 | * concrete `OpenTypeData` via {@see FontResolver::resolveMatch()} and |
| 2758 | * derives the post-match "still needs synthetic effect" flags by |
| 2759 | * comparing the matched face's axes against the requested cascade. |
| 2760 | * |
| 2761 | * @return array{font: ?\Phpdftk\FontParser\FontFaceData, isBold: bool, isItalic: bool} |
| 2762 | */ |
| 2763 | private function resolveBoxFont(Box $box, ?\Phpdftk\FontParser\FontFaceData $fallback): array |
| 2764 | { |
| 2765 | $weight = $this->resolveWeight($box); |
| 2766 | $style = $this->resolveStyle($box); |
| 2767 | $stretch = $this->resolveStretch($box); |
| 2768 | $requestBold = $weight >= 600; |
| 2769 | $requestItalic = $style !== 'normal'; |
| 2770 | $resolver = $this->currentFontResolver; |
| 2771 | $match = $resolver?->resolveMatch( |
| 2772 | $box->style->get('font-family'), |
| 2773 | $weight, |
| 2774 | $style, |
| 2775 | $stretch, |
| 2776 | ); |
| 2777 | $font = $match?->face->data ?? $fallback; |
| 2778 | // CSS Fonts 4 §6.7 — `font-synthesis-weight` / `font-synthesis-style` |
| 2779 | // veto the fake-bold stroke / fake-italic skew the painter would |
| 2780 | // otherwise apply when the matched face has no real bold or italic. |
| 2781 | $isBold = $requestBold |
| 2782 | && ($match === null || !$match->matchesWeight) |
| 2783 | && self::allowsSynthesis($box, 'font-synthesis-weight'); |
| 2784 | $isItalic = $requestItalic |
| 2785 | && ($match === null || !$match->matchesStyle) |
| 2786 | && self::allowsSynthesis($box, 'font-synthesis-style'); |
| 2787 | return ['font' => $font, 'isBold' => $isBold, 'isItalic' => $isItalic]; |
| 2788 | } |
| 2789 | |
| 2790 | /** |
| 2791 | * Is the named `font-synthesis-*` longhand anything other than `none`? |
| 2792 | * |
| 2793 | * Both longhands are `auto | none` with an `auto` initial, so only an |
| 2794 | * explicit `none` suppresses the synthetic face. |
| 2795 | */ |
| 2796 | private static function allowsSynthesis(Box $box, string $property): bool |
| 2797 | { |
| 2798 | $value = $box->style->get($property); |
| 2799 | return !($value instanceof \Phpdftk\Css\Value\Keyword) |
| 2800 | || strtolower($value->name) !== 'none'; |
| 2801 | } |
| 2802 | |
| 2803 | /** |
| 2804 | * Read the box's cascaded `text-decoration-line` and return the set of |
| 2805 | * line keywords it carries — empty list for `none` / unset. |
| 2806 | * |
| 2807 | * @return list<string> |
| 2808 | */ |
| 2809 | private function decorationLines(Box $box): array |
| 2810 | { |
| 2811 | $value = $box->style->get('text-decoration-line'); |
| 2812 | if ($value === null |
| 2813 | || ($value instanceof \Phpdftk\Css\Value\Keyword |
| 2814 | && strtolower($value->name) === 'none') |
| 2815 | ) { |
| 2816 | return []; |
| 2817 | } |
| 2818 | $names = []; |
| 2819 | if ($value instanceof \Phpdftk\Css\Value\Keyword) { |
| 2820 | $names[] = strtolower($value->name); |
| 2821 | } elseif ($value instanceof \Phpdftk\Css\Value\ValueList) { |
| 2822 | foreach ($value->values as $v) { |
| 2823 | if ($v instanceof \Phpdftk\Css\Value\Keyword) { |
| 2824 | $kw = strtolower($v->name); |
| 2825 | if ($kw !== 'none') { |
| 2826 | $names[] = $kw; |
| 2827 | } |
| 2828 | } |
| 2829 | } |
| 2830 | } |
| 2831 | return array_values(array_unique(array_filter( |
| 2832 | $names, |
| 2833 | static fn(string $n): bool => in_array($n, ['underline', 'overline', 'line-through'], true), |
| 2834 | ))); |
| 2835 | } |
| 2836 | |
| 2837 | /** |
| 2838 | * Combine outer + inner text-decoration line lists into a deduped list. |
| 2839 | * |
| 2840 | * @param list<string> $outer |
| 2841 | * @param list<string> $inner |
| 2842 | * @return list<string> |
| 2843 | */ |
| 2844 | private function mergeDecorationLines(array $outer, array $inner): array |
| 2845 | { |
| 2846 | return array_values(array_unique(array_merge($outer, $inner))); |
| 2847 | } |
| 2848 | |
| 2849 | /** |
| 2850 | * Read the box's cascaded `color`, or null when the cascade didn't |
| 2851 | * produce a `Color` value (e.g. unresolved keyword fallback). |
| 2852 | */ |
| 2853 | private function resolveColor(Box $box): ?\Phpdftk\Css\Value\Color |
| 2854 | { |
| 2855 | $value = $box->style->get('color'); |
| 2856 | return $value instanceof \Phpdftk\Css\Value\Color ? $value : null; |
| 2857 | } |
| 2858 | |
| 2859 | private function resolveBackground(Box $box): ?\Phpdftk\Css\Value\Color |
| 2860 | { |
| 2861 | $value = $box->style->get('background-color'); |
| 2862 | return $value instanceof \Phpdftk\Css\Value\Color ? $value : null; |
| 2863 | } |
| 2864 | |
| 2865 | /** |
| 2866 | * Read the box's cascaded `text-decoration-color`, or null when the |
| 2867 | * property is unset / inherits to the default keyword. CSS Text |
| 2868 | * Decoration 4 §3: the property does *not* inherit through inlines, |
| 2869 | * so callers explicitly pick whichever closer ancestor set it. |
| 2870 | */ |
| 2871 | private function resolveDecorationColor(Box $box): ?\Phpdftk\Css\Value\Color |
| 2872 | { |
| 2873 | $value = $box->style->get('text-decoration-color'); |
| 2874 | return $value instanceof \Phpdftk\Css\Value\Color ? $value : null; |
| 2875 | } |
| 2876 | |
| 2877 | /** |
| 2878 | * CSS Text 3 §5 / §6 — soft-wrap opportunities exist between |
| 2879 | * every two codepoints under: `word-break: break-all`, |
| 2880 | * `overflow-wrap: anywhere`, and `line-break: anywhere`. All |
| 2881 | * three are checked here so the line-fitter splits at any |
| 2882 | * character regardless of which property the author used. |
| 2883 | */ |
| 2884 | private function isBreakAll(Box $box): bool |
| 2885 | { |
| 2886 | $wb = $box->style->get('word-break'); |
| 2887 | if ($wb instanceof \Phpdftk\Css\Value\Keyword && strtolower($wb->name) === 'break-all') { |
| 2888 | return true; |
| 2889 | } |
| 2890 | $ow = $box->style->get('overflow-wrap'); |
| 2891 | if ($ow instanceof \Phpdftk\Css\Value\Keyword) { |
| 2892 | $name = strtolower($ow->name); |
| 2893 | // `anywhere` adds break opportunities AND contributes to |
| 2894 | // min-content intrinsic sizing. `break-word` (and its |
| 2895 | // legacy `word-wrap: break-word` alias, which the cascade |
| 2896 | // normalises into `overflow-wrap`) adds the same |
| 2897 | // codepoint-level opportunities for line-fitting but does |
| 2898 | // NOT change intrinsic sizing — see CSS Text 3 §5.5. For |
| 2899 | // the line-fitter both fall into the same "every code- |
| 2900 | // point is a break opportunity" path; the min/max-content |
| 2901 | // measurer gates on `anywhere` only via |
| 2902 | // `intrinsicBreaksAnywhere`. |
| 2903 | if ($name === 'anywhere' || $name === 'break-word') { |
| 2904 | return true; |
| 2905 | } |
| 2906 | } |
| 2907 | // Legacy `word-wrap: break-word` is the same property under a |
| 2908 | // different name. The shorthand expander normalises it into |
| 2909 | // `overflow-wrap`, but author CSS that sets `word-wrap` |
| 2910 | // directly still lands here — read both. |
| 2911 | $ww = $box->style->get('word-wrap'); |
| 2912 | if ($ww instanceof \Phpdftk\Css\Value\Keyword |
| 2913 | && strtolower($ww->name) === 'break-word' |
| 2914 | ) { |
| 2915 | return true; |
| 2916 | } |
| 2917 | $lb = $box->style->get('line-break'); |
| 2918 | if ($lb instanceof \Phpdftk\Css\Value\Keyword && strtolower($lb->name) === 'anywhere') { |
| 2919 | return true; |
| 2920 | } |
| 2921 | return false; |
| 2922 | } |
| 2923 | |
| 2924 | /** |
| 2925 | * CSS Inline 3 §4.5 `vertical-align`: the `sub` and `super` keywords lift |
| 2926 | * / lower the fragment's baseline by a font-size-relative amount and |
| 2927 | * COMPOSE through nesting, so they're folded into the running |
| 2928 | * `baselineShift` during the tree walk. Browser defaults: `super` ≈ |
| 2929 | * +0.5em lift, `sub` ≈ +0.2em drop. Returns the offset in layout-Y space |
| 2930 | * (negative lifts, positive drops). The line-relative keywords (top / |
| 2931 | * bottom / middle / text-top / text-bottom) do NOT compose and are |
| 2932 | * resolved later against the line box in `finalizeLine`, so they return 0 |
| 2933 | * here — see {@see resolveVerticalAlignKeyword}. |
| 2934 | */ |
| 2935 | private function resolveVerticalAlign(Box $box, float $fontSize): float |
| 2936 | { |
| 2937 | $value = $box->style->get('vertical-align'); |
| 2938 | if (!($value instanceof \Phpdftk\Css\Value\Keyword)) { |
| 2939 | return 0.0; |
| 2940 | } |
| 2941 | return match (strtolower($value->name)) { |
| 2942 | 'super' => -$fontSize * 0.5, |
| 2943 | 'sub' => $fontSize * 0.2, |
| 2944 | default => 0.0, |
| 2945 | }; |
| 2946 | } |
| 2947 | |
| 2948 | /** |
| 2949 | * The line-relative `vertical-align` keyword for a box — one of `top`, |
| 2950 | * `bottom`, `middle`, `text-top`, `text-bottom` — or `baseline` for |
| 2951 | * everything else (including `sub`/`super`/`<length>`, which compose via |
| 2952 | * {@see resolveVerticalAlign} into `baselineShift`). These keywords are |
| 2953 | * resolved against the line box / strut in `finalizeLine`. |
| 2954 | */ |
| 2955 | private function resolveVerticalAlignKeyword(Box $box): string |
| 2956 | { |
| 2957 | $value = $box->style->get('vertical-align'); |
| 2958 | if (!($value instanceof \Phpdftk\Css\Value\Keyword)) { |
| 2959 | return 'baseline'; |
| 2960 | } |
| 2961 | $name = strtolower($value->name); |
| 2962 | return match ($name) { |
| 2963 | 'top', 'bottom', 'middle', 'text-top', 'text-bottom' => $name, |
| 2964 | default => 'baseline', |
| 2965 | }; |
| 2966 | } |
| 2967 | |
| 2968 | /** |
| 2969 | * Tokenise plain text at UAX #14 break opportunities, shaping each |
| 2970 | * resulting segment. Each segment is one token. Whitespace segments |
| 2971 | * are tagged so the line-fitter can collapse them at line edges. When |
| 2972 | * `$letterSpacing` is non-zero, every shaped glyph's advance is bumped |
| 2973 | * by that amount per CSS Text 3 §10 — the painter picks the difference |
| 2974 | * up automatically via its TJ-kerning path. |
| 2975 | * |
| 2976 | * @return list<array{ |
| 2977 | * shapedRun: ShapedRun, |
| 2978 | * isWhitespace: bool, |
| 2979 | * kind: LineBreakKind, |
| 2980 | * trailingSpace: float, |
| 2981 | * noWrapBefore?: bool, |
| 2982 | * baselineShift?: float, |
| 2983 | * lineHeight?: float, |
| 2984 | * verticalAlign?: string, |
| 2985 | * href?: string|null, |
| 2986 | * isBold?: bool, |
| 2987 | * isItalic?: bool, |
| 2988 | * decorationLines?: list<string>, |
| 2989 | * textColor?: \Phpdftk\Css\Value\Color|null, |
| 2990 | * backgroundColor?: \Phpdftk\Css\Value\Color|null, |
| 2991 | * linkTitle?: string|null, |
| 2992 | * decorationColor?: \Phpdftk\Css\Value\Color|null, |
| 2993 | * bgExtendAbove?: float, |
| 2994 | * bgExtendBelow?: float, |
| 2995 | * atomicBox?: AtomicInlineBox, |
| 2996 | * atomicContentWidth?: float, |
| 2997 | * atomicOuterWidth?: float, |
| 2998 | * atomicPadLeft?: float, |
| 2999 | * atomicPadRight?: float, |
| 3000 | * atomicBorderLeft?: float, |
| 3001 | * atomicBorderRight?: float, |
| 3002 | * atomicMarginLeft?: float, |
| 3003 | * atomicMarginRight?: float, |
| 3004 | * atomicMarginTop?: float, |
| 3005 | * atomicMarginBottom?: float, |
| 3006 | * atomicBorderBox?: bool, |
| 3007 | * }> |
| 3008 | */ |
| 3009 | private function tokeniseText( |
| 3010 | string $text, |
| 3011 | ShapingContext $shapingCtx, |
| 3012 | float $letterSpacing, |
| 3013 | float $wordSpacing, |
| 3014 | bool $breakAll = false, |
| 3015 | bool $splitWsBoundaries = false, |
| 3016 | ): array { |
| 3017 | if ($text === '') { |
| 3018 | return []; |
| 3019 | } |
| 3020 | if ($breakAll) { |
| 3021 | // CSS Text 3 §5 `word-break: break-all` — every codepoint is a |
| 3022 | // valid break point. Walk UTF-8 codepoints and emit one |
| 3023 | // segment per character. Whitespace runs still collapse into |
| 3024 | // their own segment so word/letter-spacing logic stays sane. |
| 3025 | $segments = []; |
| 3026 | $bytes = strlen($text); |
| 3027 | $i = 0; |
| 3028 | while ($i < $bytes) { |
| 3029 | $b = ord($text[$i]); |
| 3030 | $cpLen = $b < 0x80 ? 1 : ($b < 0xE0 ? 2 : ($b < 0xF0 ? 3 : 4)); |
| 3031 | $segments[] = [ |
| 3032 | 'text' => substr($text, $i, $cpLen), |
| 3033 | 'kind' => LineBreakKind::Allowed, |
| 3034 | ]; |
| 3035 | $i += $cpLen; |
| 3036 | } |
| 3037 | } else { |
| 3038 | $breaks = iterator_to_array($this->lineBreaker->breakOpportunities($text), false); |
| 3039 | $segments = []; |
| 3040 | $start = 0; |
| 3041 | foreach ($breaks as $opp) { |
| 3042 | if ($opp->offset > $start) { |
| 3043 | $segments[] = ['text' => substr($text, $start, $opp->offset - $start), 'kind' => $opp->kind]; |
| 3044 | $start = $opp->offset; |
| 3045 | } |
| 3046 | } |
| 3047 | if ($start < strlen($text)) { |
| 3048 | $segments[] = ['text' => substr($text, $start), 'kind' => LineBreakKind::Allowed]; |
| 3049 | } |
| 3050 | // CSS Text 3 §5.5 — for the hanging-trailing-whitespace |
| 3051 | // behaviour to take effect under `pre-wrap` / `break-spaces`, |
| 3052 | // each contiguous whitespace run must be its own token. UAX-14 |
| 3053 | // bundles `XX<ws>` into one segment (the break opportunity |
| 3054 | // sits at the end of the whitespace), which prevents the |
| 3055 | // line-fitter from telling the trailing ws apart from the |
| 3056 | // leading word at wrap time. Refine bundled segments here: |
| 3057 | // split any segment that mixes ws and non-ws into alternating |
| 3058 | // runs. Only applied when the caller opts in — under `normal` |
| 3059 | // / `nowrap` the bundled segments are correct (whitespace |
| 3060 | // collapses to single spaces that contribute to line width). |
| 3061 | if ($splitWsBoundaries) { |
| 3062 | $refined = []; |
| 3063 | foreach ($segments as $seg) { |
| 3064 | if (preg_match('/[ \t\n\r\f]/', $seg['text']) !== 1 |
| 3065 | || preg_match('/[^ \t\n\r\f]/', $seg['text']) !== 1 |
| 3066 | ) { |
| 3067 | $refined[] = $seg; |
| 3068 | continue; |
| 3069 | } |
| 3070 | if (preg_match_all('/[ \t\n\r\f]+|[^ \t\n\r\f]+/u', $seg['text'], $m) === false) { |
| 3071 | $refined[] = $seg; |
| 3072 | continue; |
| 3073 | } |
| 3074 | $lastIdx = count($m[0]) - 1; |
| 3075 | foreach ($m[0] as $i => $part) { |
| 3076 | $refined[] = [ |
| 3077 | 'text' => $part, |
| 3078 | // The original break opportunity sits at |
| 3079 | // the end of the bundled segment — keep |
| 3080 | // that on the last sub-segment; intermediate |
| 3081 | // boundaries get a plain `Allowed` |
| 3082 | // opportunity so the breaker can wrap there |
| 3083 | // too. |
| 3084 | 'kind' => $i === $lastIdx ? $seg['kind'] : LineBreakKind::Allowed, |
| 3085 | ]; |
| 3086 | } |
| 3087 | } |
| 3088 | $segments = $refined; |
| 3089 | } |
| 3090 | } |
| 3091 | |
| 3092 | // CSS Writing Modes 3 §2 / Unicode UAX #9 — split each segment |
| 3093 | // at bidi-direction boundaries so a single text node like |
| 3094 | // "First שלום world" produces three separate shaping runs |
| 3095 | // (LTR-RTL-LTR) the layout can place individually. Without |
| 3096 | // this split, the run containing both LTR and RTL characters |
| 3097 | // shapes as a single block at one bidi level, miscoordinating |
| 3098 | // with browser-emitted swatch reference geometries. |
| 3099 | $segments = $this->splitBidiRuns($segments); |
| 3100 | |
| 3101 | $out = []; |
| 3102 | foreach ($segments as $seg) { |
| 3103 | $isWs = preg_match('/^[ \t\n\r\f]+$/', $seg['text']) === 1; |
| 3104 | $runDirection = $seg['direction'] ?? null; |
| 3105 | $runCtx = $shapingCtx; |
| 3106 | if ($runDirection === 'rtl') { |
| 3107 | $runCtx = new ShapingContext( |
| 3108 | $shapingCtx->font, |
| 3109 | $shapingCtx->fontSizePt, |
| 3110 | $shapingCtx->script, |
| 3111 | $shapingCtx->language, |
| 3112 | \Phpdftk\Text\ShapingDirection::Rtl, |
| 3113 | $shapingCtx->features, |
| 3114 | ); |
| 3115 | } |
| 3116 | // CSS Text 3 §4.1.1 — a *segment break* (U+000A, and the |
| 3117 | // U+000D that may precede it) is a control character: in the |
| 3118 | // preserving white-space modes it forces a line break, and it |
| 3119 | // is NEVER rendered. UAX-14 already handed us the `Mandatory` |
| 3120 | // break opportunity, so all that is left is to keep the |
| 3121 | // codepoint away from the shaper — otherwise the font has no |
| 3122 | // glyph for it and every preserved line ended in a `.notdef` |
| 3123 | // box. The break kind and the whitespace flag still come from |
| 3124 | // the original segment text. |
| 3125 | $shapeText = strtr($seg['text'], ["\r\n" => '', "\n" => '', "\r" => '']); |
| 3126 | $shaped = $this->shaper->shapeRun($shapeText, $runCtx); |
| 3127 | if ($letterSpacing !== 0.0 && $shaped->glyphs !== []) { |
| 3128 | $shaped = $this->applyLetterSpacing($shaped, $letterSpacing); |
| 3129 | } |
| 3130 | if ($wordSpacing !== 0.0 && $shaped->glyphs !== []) { |
| 3131 | // CSS Text 3 §9: `word-spacing` adds advance only at word- |
| 3132 | // separator glyphs (U+0020 / U+00A0 at MVP). |
| 3133 | $shaped = $this->applyWordSpacing($shaped, $shapeText, $wordSpacing); |
| 3134 | } |
| 3135 | $out[] = [ |
| 3136 | 'shapedRun' => $shaped, |
| 3137 | 'isWhitespace' => $isWs, |
| 3138 | 'kind' => $seg['kind'], |
| 3139 | 'trailingSpace' => $this->trailingCollapsibleAdvance($shaped, $shapeText, $isWs), |
| 3140 | ]; |
| 3141 | } |
| 3142 | return $out; |
| 3143 | } |
| 3144 | |
| 3145 | /** |
| 3146 | * Shrink the last fragment by the collapsible whitespace it ends with, |
| 3147 | * so a line's reported width is its width AFTER CSS Text 3 §4.1.3 has |
| 3148 | * removed that space. The glyph stays in the run — a space paints |
| 3149 | * nothing — but it stops counting toward alignment and decoration. |
| 3150 | * |
| 3151 | * @param list<InlineFragment> $fragments |
| 3152 | * @return list<InlineFragment> |
| 3153 | */ |
| 3154 | private function trimTrailingSpace(array $fragments, float $trailing): array |
| 3155 | { |
| 3156 | if ($trailing <= 0.0 || $fragments === []) { |
| 3157 | return $fragments; |
| 3158 | } |
| 3159 | $last = $fragments[count($fragments) - 1]; |
| 3160 | $width = max(0.0, $last->width - $trailing); |
| 3161 | if ($width === $last->width) { |
| 3162 | return $fragments; |
| 3163 | } |
| 3164 | $fragments[count($fragments) - 1] = new InlineFragment( |
| 3165 | $last->x, |
| 3166 | $width, |
| 3167 | $last->shapedRun, |
| 3168 | $last->baselineShift, |
| 3169 | $last->href, |
| 3170 | $last->isBold, |
| 3171 | $last->isItalic, |
| 3172 | $last->decorationLines, |
| 3173 | $last->textColor, |
| 3174 | $last->backgroundColor, |
| 3175 | $last->linkTitle, |
| 3176 | $last->decorationColor, |
| 3177 | $last->isWhitespace, |
| 3178 | $last->lineHeight, |
| 3179 | $last->verticalAlign, |
| 3180 | blockOffset: $last->blockOffset, |
| 3181 | atomicBox: $last->atomicBox, |
| 3182 | bgExtendAbove: $last->bgExtendAbove, |
| 3183 | bgExtendBelow: $last->bgExtendBelow, |
| 3184 | ); |
| 3185 | return $fragments; |
| 3186 | } |
| 3187 | |
| 3188 | /** |
| 3189 | * The advance of the COLLAPSIBLE whitespace a token ends with — the run |
| 3190 | * that CSS Text 3 §4.1.3 removes when the token lands at a line end. |
| 3191 | * |
| 3192 | * U+00A0 and friends are deliberately excluded: a no-break space is not |
| 3193 | * collapsible, so it keeps its width and its power to push a line over. |
| 3194 | */ |
| 3195 | private function trailingCollapsibleAdvance(ShapedRun $shaped, string $text, bool $isWhitespace): float |
| 3196 | { |
| 3197 | if ($shaped->glyphs === []) { |
| 3198 | return 0.0; |
| 3199 | } |
| 3200 | if ($isWhitespace) { |
| 3201 | return $shaped->totalAdvance; |
| 3202 | } |
| 3203 | if (preg_match('/[ \t\n\r\f]+$/', $text, $matches, PREG_OFFSET_CAPTURE) !== 1) { |
| 3204 | return 0.0; |
| 3205 | } |
| 3206 | $start = $matches[0][1]; |
| 3207 | $advance = 0.0; |
| 3208 | foreach ($shaped->glyphs as $glyph) { |
| 3209 | if ($glyph->sourceOffset >= $start) { |
| 3210 | $advance += $glyph->advanceX; |
| 3211 | } |
| 3212 | } |
| 3213 | return $advance; |
| 3214 | } |
| 3215 | |
| 3216 | /** |
| 3217 | * Unicode UAX #9 (Bidi) — minimal per-codepoint split. Walks each |
| 3218 | * segment, classifies codepoints into LTR / RTL / neutral via |
| 3219 | * `IntlChar::charDirection`, groups consecutive same-direction |
| 3220 | * characters into runs, and emits one segment per run. Neutrals |
| 3221 | * adopt the direction of their surrounding run (left-context |
| 3222 | * fallback). When `intl` is unavailable or the segment is pure |
| 3223 | * one-direction, segments pass through untouched. |
| 3224 | * |
| 3225 | * Each returned segment carries a `direction` field (`'ltr'` / |
| 3226 | * `'rtl'`) so the shaping pass can switch ShapingDirection per |
| 3227 | * run without re-classifying codepoints. |
| 3228 | * |
| 3229 | * @param list<array{text: string, kind: LineBreakKind}> $segments |
| 3230 | * @return list<array{text: string, kind: LineBreakKind, direction?: string}> |
| 3231 | */ |
| 3232 | private function splitBidiRuns(array $segments): array |
| 3233 | { |
| 3234 | if (!class_exists(\IntlChar::class)) { |
| 3235 | return $segments; |
| 3236 | } |
| 3237 | $out = []; |
| 3238 | foreach ($segments as $seg) { |
| 3239 | $text = $seg['text']; |
| 3240 | // Quick exit: if no codepoint has explicit RTL direction, |
| 3241 | // the whole segment is LTR. Avoids the per-codepoint walk |
| 3242 | // for the common Latin case. |
| 3243 | if (preg_match('/[\x{0590}-\x{08FF}\x{FB1D}-\x{FDFF}\x{FE70}-\x{FEFF}]/u', $text) !== 1) { |
| 3244 | $seg['direction'] = 'ltr'; |
| 3245 | $out[] = $seg; |
| 3246 | continue; |
| 3247 | } |
| 3248 | $bytes = strlen($text); |
| 3249 | $i = 0; |
| 3250 | $currentRun = ''; |
| 3251 | $currentDir = null; |
| 3252 | while ($i < $bytes) { |
| 3253 | $b = ord($text[$i]); |
| 3254 | $cpLen = $b < 0x80 ? 1 : ($b < 0xE0 ? 2 : ($b < 0xF0 ? 3 : 4)); |
| 3255 | $chunk = substr($text, $i, $cpLen); |
| 3256 | $cp = mb_ord($chunk, 'UTF-8'); |
| 3257 | $i += $cpLen; |
| 3258 | if ($cp === false) { |
| 3259 | $currentRun .= $chunk; |
| 3260 | continue; |
| 3261 | } |
| 3262 | $bidiClass = \IntlChar::charDirection($cp); |
| 3263 | // L (LEFT_TO_RIGHT) → LTR; R / AL (RIGHT_TO_LEFT, |
| 3264 | // RIGHT_TO_LEFT_ARABIC) → RTL; everything else is a |
| 3265 | // neutral and inherits the surrounding run. |
| 3266 | $dirHere = match ($bidiClass) { |
| 3267 | \IntlChar::CHAR_DIRECTION_LEFT_TO_RIGHT => 'ltr', |
| 3268 | \IntlChar::CHAR_DIRECTION_RIGHT_TO_LEFT, |
| 3269 | \IntlChar::CHAR_DIRECTION_RIGHT_TO_LEFT_ARABIC => 'rtl', |
| 3270 | default => null, |
| 3271 | }; |
| 3272 | if ($dirHere === null) { |
| 3273 | $currentRun .= $chunk; |
| 3274 | continue; |
| 3275 | } |
| 3276 | if ($currentDir === null) { |
| 3277 | $currentDir = $dirHere; |
| 3278 | } |
| 3279 | if ($dirHere !== $currentDir) { |
| 3280 | // Direction boundary — flush the current run. |
| 3281 | if ($currentRun !== '') { |
| 3282 | $out[] = [ |
| 3283 | 'text' => $currentRun, |
| 3284 | 'kind' => LineBreakKind::Allowed, |
| 3285 | 'direction' => $currentDir, |
| 3286 | ]; |
| 3287 | } |
| 3288 | $currentRun = $chunk; |
| 3289 | $currentDir = $dirHere; |
| 3290 | } else { |
| 3291 | $currentRun .= $chunk; |
| 3292 | } |
| 3293 | } |
| 3294 | if ($currentRun !== '') { |
| 3295 | $out[] = [ |
| 3296 | 'text' => $currentRun, |
| 3297 | // The last sub-run keeps the parent segment's |
| 3298 | // original break-kind (so a UAX-14 mandatory |
| 3299 | // break at the source's end still fires). |
| 3300 | 'kind' => $seg['kind'], |
| 3301 | 'direction' => $currentDir ?? 'ltr', |
| 3302 | ]; |
| 3303 | } |
| 3304 | } |
| 3305 | return $out; |
| 3306 | } |
| 3307 | |
| 3308 | /** |
| 3309 | * Return a new `ShapedRun` with every glyph's `advanceX` bumped by |
| 3310 | * `$letterSpacing` and the `totalAdvance` summed accordingly. |
| 3311 | */ |
| 3312 | private function applyLetterSpacing(ShapedRun $run, float $letterSpacing): ShapedRun |
| 3313 | { |
| 3314 | $glyphs = []; |
| 3315 | $total = 0.0; |
| 3316 | foreach ($run->glyphs as $g) { |
| 3317 | $newAdvance = $g->advanceX + $letterSpacing; |
| 3318 | $glyphs[] = new ShapedGlyph( |
| 3319 | $g->glyphId, |
| 3320 | $g->sourceOffset, |
| 3321 | $g->sourceLength, |
| 3322 | $newAdvance, |
| 3323 | $g->advanceY, |
| 3324 | $g->offsetX, |
| 3325 | $g->offsetY, |
| 3326 | ); |
| 3327 | $total += $newAdvance; |
| 3328 | } |
| 3329 | return new ShapedRun( |
| 3330 | $run->font, |
| 3331 | $run->fontSizePt, |
| 3332 | $run->direction, |
| 3333 | $glyphs, |
| 3334 | $total, |
| 3335 | ); |
| 3336 | } |
| 3337 | |
| 3338 | /** |
| 3339 | * CSS Text 3 §10: `letter-spacing` keyword `normal` resolves to 0; |
| 3340 | * any `Length` (already in px after `Cascade::resolveLengths`) is the |
| 3341 | * extra advance applied to every glyph. |
| 3342 | */ |
| 3343 | private function resolveLetterSpacing(Box $parent): float |
| 3344 | { |
| 3345 | $value = $parent->style->get('letter-spacing'); |
| 3346 | if ($value instanceof Length) { |
| 3347 | return $value->value; |
| 3348 | } |
| 3349 | return 0.0; |
| 3350 | } |
| 3351 | |
| 3352 | /** |
| 3353 | * CSS Text 3 §9: `word-spacing` adds advance only at word-separator |
| 3354 | * glyphs. `normal` → 0; any `Length` is the extra advance per separator. |
| 3355 | */ |
| 3356 | private function resolveWordSpacing(Box $parent): float |
| 3357 | { |
| 3358 | $value = $parent->style->get('word-spacing'); |
| 3359 | if ($value instanceof Length) { |
| 3360 | return $value->value; |
| 3361 | } |
| 3362 | return 0.0; |
| 3363 | } |
| 3364 | |
| 3365 | /** |
| 3366 | * Bump the advance of every glyph whose source codepoint is a CSS |
| 3367 | * word separator (U+0020 SPACE or U+00A0 NO-BREAK SPACE). Builds and |
| 3368 | * returns a new `ShapedRun`. |
| 3369 | */ |
| 3370 | private function applyWordSpacing(ShapedRun $run, string $sourceText, float $wordSpacing): ShapedRun |
| 3371 | { |
| 3372 | $glyphs = []; |
| 3373 | $total = 0.0; |
| 3374 | foreach ($run->glyphs as $g) { |
| 3375 | $bump = $this->isWordSeparatorAt($sourceText, $g->sourceOffset) ? $wordSpacing : 0.0; |
| 3376 | $newAdvance = $g->advanceX + $bump; |
| 3377 | $glyphs[] = new ShapedGlyph( |
| 3378 | $g->glyphId, |
| 3379 | $g->sourceOffset, |
| 3380 | $g->sourceLength, |
| 3381 | $newAdvance, |
| 3382 | $g->advanceY, |
| 3383 | $g->offsetX, |
| 3384 | $g->offsetY, |
| 3385 | ); |
| 3386 | $total += $newAdvance; |
| 3387 | } |
| 3388 | return new ShapedRun( |
| 3389 | $run->font, |
| 3390 | $run->fontSizePt, |
| 3391 | $run->direction, |
| 3392 | $glyphs, |
| 3393 | $total, |
| 3394 | ); |
| 3395 | } |
| 3396 | |
| 3397 | private function isWordSeparatorAt(string $text, int $offset): bool |
| 3398 | { |
| 3399 | if ($offset < 0 || $offset >= strlen($text)) { |
| 3400 | return false; |
| 3401 | } |
| 3402 | $b = ord($text[$offset]); |
| 3403 | if ($b === 0x20) { |
| 3404 | return true; |
| 3405 | } |
| 3406 | // U+00A0 NO-BREAK SPACE → UTF-8 bytes 0xC2 0xA0. |
| 3407 | return $b === 0xC2 && ($offset + 1) < strlen($text) && ord($text[$offset + 1]) === 0xA0; |
| 3408 | } |
| 3409 | |
| 3410 | /** |
| 3411 | * The dominant font-size for the inline run. Phase 1F.2 reads it from |
| 3412 | * the parent's cascaded `font-size`; mixed-size content is a Phase 2 |
| 3413 | * follow-up alongside multi-font runs. |
| 3414 | */ |
| 3415 | private function dominantFontSize(Box $parent, LayoutContext $context): float |
| 3416 | { |
| 3417 | $value = $parent->style->get('font-size'); |
| 3418 | if ($value instanceof Length) { |
| 3419 | return $value->value; |
| 3420 | } |
| 3421 | return $context->lengthContext->currentFontSize; |
| 3422 | } |
| 3423 | |
| 3424 | /** |
| 3425 | * Pull a Length value off a cascaded property in pixels. Atomic |
| 3426 | * inline boxes don't have an in-progress containing-block width |
| 3427 | * to resolve percentages against at token-collection time (the |
| 3428 | * containing block isn't passed to {@see collectTokens}), so |
| 3429 | * Percentages resolve to 0 here. That matches the older atomic |
| 3430 | * behaviour and is acceptable for the in-scope test surface |
| 3431 | * (no `<img padding-left="50%">` fixtures); percentage-padding |
| 3432 | * on replaced inlines is a future enhancement once the inline |
| 3433 | * layout owns its parent's content width directly. |
| 3434 | */ |
| 3435 | private static function atomicLength(?\Phpdftk\Css\Value\Value $value): float |
| 3436 | { |
| 3437 | return $value instanceof Length |
| 3438 | ? \Phpdftk\Css\Cascade\LengthResolver::clampPx($value->value) |
| 3439 | : 0.0; |
| 3440 | } |
| 3441 | |
| 3442 | /** |
| 3443 | * Side-specific border width for atomic inline boxes, accounting |
| 3444 | * for `border-<side>-style: none` (which collapses the width to |
| 3445 | * zero per CSS Backgrounds 3 §4.4) and the `thin`/`medium`/`thick` |
| 3446 | * keyword widths. |
| 3447 | */ |
| 3448 | private static function atomicBorderWidth(\Phpdftk\Css\Cascade\CascadedValues $style, string $side): float |
| 3449 | { |
| 3450 | $styleValue = $style->get("border-$side-style"); |
| 3451 | if ($styleValue instanceof \Phpdftk\Css\Value\Keyword |
| 3452 | && strtolower($styleValue->name) === 'none' |
| 3453 | ) { |
| 3454 | return 0.0; |
| 3455 | } |
| 3456 | $width = $style->get("border-$side-width"); |
| 3457 | if ($width instanceof Length) { |
| 3458 | return \Phpdftk\Css\Cascade\LengthResolver::clampPx($width->value); |
| 3459 | } |
| 3460 | if ($width instanceof \Phpdftk\Css\Value\Keyword) { |
| 3461 | return match (strtolower($width->name)) { |
| 3462 | 'thin' => 1.0, |
| 3463 | 'medium' => 3.0, |
| 3464 | 'thick' => 5.0, |
| 3465 | default => 0.0, |
| 3466 | }; |
| 3467 | } |
| 3468 | return 0.0; |
| 3469 | } |
| 3470 | |
| 3471 | /** |
| 3472 | * CSS Sizing 3 §6.2 — `true` when the cascaded `box-sizing` is |
| 3473 | * `border-box`, meaning declared width/height include the |
| 3474 | * padding + border edges. |
| 3475 | */ |
| 3476 | private static function atomicIsBorderBoxSizing(\Phpdftk\Css\Cascade\CascadedValues $style): bool |
| 3477 | { |
| 3478 | $value = $style->get('box-sizing'); |
| 3479 | return $value instanceof \Phpdftk\Css\Value\Keyword |
| 3480 | && strtolower($value->name) === 'border-box'; |
| 3481 | } |
| 3482 | |
| 3483 | /** |
| 3484 | * Resolve an atomic replaced box's DEFINITE content-box height, or |
| 3485 | * null when the height is auto / indefinite. An explicit length (or |
| 3486 | * `0`) is definite; a percentage is definite only against a definite |
| 3487 | * containing-block height (CSS 2.1 §10.5). Under `box-sizing: |
| 3488 | * border-box` the declared value includes the padding + border, so |
| 3489 | * the content height is the declared value minus the vertical inset. |
| 3490 | * Mirrors the height resolution in layoutAtomicOnly() so the shaped |
| 3491 | * path can transfer a definite height through the intrinsic ratio to |
| 3492 | * derive an auto width (§10.3.2). |
| 3493 | */ |
| 3494 | private function atomicDefiniteContentHeight(AtomicInlineBox $box): ?float |
| 3495 | { |
| 3496 | $heightValue = $box->style->get('height'); |
| 3497 | $declared = match (true) { |
| 3498 | $heightValue instanceof Length => $heightValue->value, |
| 3499 | $heightValue instanceof \Phpdftk\Css\Value\Integer => (float) $heightValue->value, |
| 3500 | $heightValue instanceof \Phpdftk\Css\Value\Percentage |
| 3501 | && $this->currentCbHeightDefinite && $this->currentCbHeight > 0.0 |
| 3502 | => $this->currentCbHeight * ($heightValue->value / 100.0), |
| 3503 | default => null, |
| 3504 | }; |
| 3505 | if ($declared === null) { |
| 3506 | return null; |
| 3507 | } |
| 3508 | $declared = max(0.0, $declared); |
| 3509 | if (!self::atomicIsBorderBoxSizing($box->style)) { |
| 3510 | return $declared; |
| 3511 | } |
| 3512 | $inset = self::atomicLength($box->style->get('padding-top')) |
| 3513 | + self::atomicLength($box->style->get('padding-bottom')) |
| 3514 | + self::atomicBorderWidth($box->style, 'top') |
| 3515 | + self::atomicBorderWidth($box->style, 'bottom'); |
| 3516 | return max(0.0, $declared - $inset); |
| 3517 | } |
| 3518 | |
| 3519 | /** |
| 3520 | * Intrinsic aspect ratio (width / height) of a replaced atomic box, |
| 3521 | * read from the `aspect-ratio` cascade value the box generator |
| 3522 | * exposes for `<img>` / `<canvas>`. Null when absent or malformed. |
| 3523 | */ |
| 3524 | private static function atomicAspectRatio(\Phpdftk\Css\Cascade\CascadedValues $style): ?float |
| 3525 | { |
| 3526 | $value = $style->get('aspect-ratio'); |
| 3527 | if ($value instanceof \Phpdftk\Css\Value\ValueList |
| 3528 | && $value->separator === \Phpdftk\Css\Value\ListSeparator::Slash |
| 3529 | && count($value->values) >= 2 |
| 3530 | ) { |
| 3531 | $w = self::atomicNumeric($value->values[0]); |
| 3532 | $h = self::atomicNumeric($value->values[1]); |
| 3533 | if ($w !== null && $h !== null && $h > 0.0) { |
| 3534 | return $w / $h; |
| 3535 | } |
| 3536 | } |
| 3537 | $direct = self::atomicNumeric($value); |
| 3538 | return ($direct !== null && $direct > 0.0) ? $direct : null; |
| 3539 | } |
| 3540 | |
| 3541 | private static function atomicNumeric(?\Phpdftk\Css\Value\Value $v): ?float |
| 3542 | { |
| 3543 | if ($v instanceof \Phpdftk\Css\Value\Integer) { |
| 3544 | return (float) $v->value; |
| 3545 | } |
| 3546 | if ($v instanceof \Phpdftk\Css\Value\Number) { |
| 3547 | return $v->value; |
| 3548 | } |
| 3549 | return null; |
| 3550 | } |
| 3551 | |
| 3552 | /** |
| 3553 | * CSS Sizing 3 §5.2 + csswg issue 3973 — resolve a min/max-width or |
| 3554 | * min/max-height value for a replaced atomic box to pixels. Lengths |
| 3555 | * pass through; `min-content` / `max-content` / `fit-content` |
| 3556 | * transfer the box's definite cross size through the intrinsic |
| 3557 | * aspect ratio (a replaced element's content-based size in one axis |
| 3558 | * is the other axis times the ratio). Returns null when the |
| 3559 | * constraint does not apply (`none` / `auto` / `stretch`, a block- |
| 3560 | * axis percentage, or a keyword with no ratio to transfer through). |
| 3561 | */ |
| 3562 | private function resolveAtomicMinMax( |
| 3563 | \Phpdftk\Css\Cascade\CascadedValues $style, |
| 3564 | string $property, |
| 3565 | float $crossContentSize, |
| 3566 | ?float $ratio, |
| 3567 | bool $isWidth, |
| 3568 | ): ?float { |
| 3569 | $v = $style->get($property); |
| 3570 | if ($v instanceof \Phpdftk\Css\Value\Keyword) { |
| 3571 | $name = strtolower($v->name); |
| 3572 | if (in_array($name, ['min-content', 'max-content', 'fit-content'], true)) { |
| 3573 | if ($ratio === null || $ratio <= 0.0) { |
| 3574 | return null; |
| 3575 | } |
| 3576 | return $isWidth ? $crossContentSize * $ratio : $crossContentSize / $ratio; |
| 3577 | } |
| 3578 | return null; |
| 3579 | } |
| 3580 | if ($v instanceof Length) { |
| 3581 | return \Phpdftk\Css\Cascade\LengthResolver::clampPx($v->value); |
| 3582 | } |
| 3583 | if ($v instanceof \Phpdftk\Css\Value\Percentage) { |
| 3584 | if ($isWidth) { |
| 3585 | return $this->currentAvailableWidth * ($v->value / 100.0); |
| 3586 | } |
| 3587 | // A percentage block-axis min/max resolves only against a |
| 3588 | // definite containing-block height (CSS 2.1 §10.5). |
| 3589 | if ($this->currentCbHeightDefinite && $this->currentCbHeight > 0.0) { |
| 3590 | return $this->currentCbHeight * ($v->value / 100.0); |
| 3591 | } |
| 3592 | return null; |
| 3593 | } |
| 3594 | return null; |
| 3595 | } |
| 3596 | |
| 3597 | /** |
| 3598 | * Apply the replaced-element min/max-width / -height clamps to a |
| 3599 | * resolved (content-box) width / height pair. The width clamps |
| 3600 | * transfer the original height through the ratio (and vice versa) |
| 3601 | * so each keyword resolves against the definite cross size, not a |
| 3602 | * value another clamp on the same call already changed. |
| 3603 | * |
| 3604 | * @return array{0: float, 1: float} clamped [width, height] |
| 3605 | */ |
| 3606 | private function clampAtomicReplaced( |
| 3607 | \Phpdftk\Css\Cascade\CascadedValues $style, |
| 3608 | float $width, |
| 3609 | float $height, |
| 3610 | ): array { |
| 3611 | $ratio = self::atomicAspectRatio($style); |
| 3612 | $origWidth = $width; |
| 3613 | $origHeight = $height; |
| 3614 | $hasRatio = $ratio !== null && $ratio > 0.0; |
| 3615 | $maxW = $this->resolveAtomicMinMax($style, 'max-width', $origHeight, $ratio, true); |
| 3616 | if ($maxW !== null && $width > $maxW) { |
| 3617 | $width = $maxW; |
| 3618 | // CSS 2.1 §10.4 — clamping one axis of a replaced element with |
| 3619 | // an intrinsic ratio transfers to the other axis to preserve |
| 3620 | // the ratio. |
| 3621 | if ($hasRatio) { |
| 3622 | $height = $width / $ratio; |
| 3623 | } |
| 3624 | } |
| 3625 | $minW = $this->resolveAtomicMinMax($style, 'min-width', $origHeight, $ratio, true); |
| 3626 | if ($minW !== null && $width < $minW) { |
| 3627 | $width = $minW; |
| 3628 | if ($hasRatio) { |
| 3629 | $height = $width / $ratio; |
| 3630 | } |
| 3631 | } |
| 3632 | $maxH = $this->resolveAtomicMinMax($style, 'max-height', $origWidth, $ratio, false); |
| 3633 | if ($maxH !== null && $height > $maxH) { |
| 3634 | $height = $maxH; |
| 3635 | if ($hasRatio) { |
| 3636 | $width = $height * $ratio; |
| 3637 | } |
| 3638 | } |
| 3639 | $minH = $this->resolveAtomicMinMax($style, 'min-height', $origWidth, $ratio, false); |
| 3640 | if ($minH !== null && $height < $minH) { |
| 3641 | $height = $minH; |
| 3642 | if ($hasRatio) { |
| 3643 | $width = $height * $ratio; |
| 3644 | } |
| 3645 | } |
| 3646 | return [$width, $height]; |
| 3647 | } |
| 3648 | } |