Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
89.93% |
500 / 556 |
|
43.24% |
16 / 37 |
CRAP | |
0.00% |
0 / 1 |
| InlineLayout | |
89.93% |
500 / 556 |
|
43.24% |
16 / 37 |
225.50 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| layout | |
98.08% |
102 / 104 |
|
0.00% |
0 / 1 |
20 | |||
| resolveTabSize | |
93.75% |
15 / 16 |
|
0.00% |
0 / 1 |
4.00 | |||
| lineBounds | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
2.00 | |||
| applyTextOverflow | |
72.41% |
21 / 29 |
|
0.00% |
0 / 1 |
12.10 | |||
| applyTextAlign | |
92.59% |
25 / 27 |
|
0.00% |
0 / 1 |
16.10 | |||
| isTextJustifyNone | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| textAlignLastKeyword | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
4.05 | |||
| textAlignKeyword | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| applyTextTransform | |
62.50% |
5 / 8 |
|
0.00% |
0 / 1 |
7.90 | |||
| capitalizeWords | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
30 | |||
| 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 | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
3.02 | |||
| collectTokens | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
2 | |||
| walkInline | |
100.00% |
104 / 104 |
|
100.00% |
1 / 1 |
20 | |||
| lineHeightFor | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| 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 | |||
| resolveBoxFont | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
5 | |||
| 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 | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
5.07 | |||
| resolveVerticalAlign | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
5.07 | |||
| tokeniseText | |
94.59% |
35 / 37 |
|
0.00% |
0 / 1 |
15.04 | |||
| 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 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\HtmlToPdf\Layout; |
| 6 | |
| 7 | use Phpdftk\Css\Value\Length; |
| 8 | use Phpdftk\HtmlToPdf\Box\AtomicInlineBox; |
| 9 | use Phpdftk\HtmlToPdf\Box\Box; |
| 10 | use Phpdftk\HtmlToPdf\Box\InlineBox; |
| 11 | use Phpdftk\HtmlToPdf\Box\LineBreakBox; |
| 12 | use Phpdftk\HtmlToPdf\Box\TextBox; |
| 13 | use Phpdftk\Text\LineBreaker; |
| 14 | use Phpdftk\Text\LineBreakKind; |
| 15 | use Phpdftk\Text\Shaper; |
| 16 | use Phpdftk\Text\ShapedGlyph; |
| 17 | use Phpdftk\Text\ShapedRun; |
| 18 | use Phpdftk\Text\ShapingContext; |
| 19 | |
| 20 | /** |
| 21 | * Inline formatting context layout — Phase 1F.2 (text shaping + greedy |
| 22 | * line wrapping). |
| 23 | * |
| 24 | * Walks a parent block's inline children (InlineBox / AtomicInlineBox / |
| 25 | * TextBox subtrees), shapes their text via `phpdftk/text`'s Shaper, finds |
| 26 | * line-break opportunities via UAX #14, and greedily fits the resulting |
| 27 | * fragments into line boxes that respect the parent's content width. |
| 28 | * |
| 29 | * Phase-1 simplifications: |
| 30 | * - Single font per inline run (the layout context's `defaultFont`). |
| 31 | * Font runs / fallback live alongside paragraph shaping in Phase 2. |
| 32 | * - Bidi reorder is the bidi engine's job; this layout reads logical |
| 33 | * order and lays runs left-to-right. |
| 34 | * - Atomic inline boxes (replaced elements, inline-block) are treated as |
| 35 | * fixed-size boxes; sizing comes from the box style (width / height). |
| 36 | * - Line height defaults to `1.2 × font-size` per CSS Inline §3. |
| 37 | * |
| 38 | * When no font is available in the layout context, this layout falls back |
| 39 | * to producing zero-height (no-op) lines so block layout can still |
| 40 | * complete end-to-end on the test surface. |
| 41 | */ |
| 42 | final class InlineLayout |
| 43 | { |
| 44 | /** |
| 45 | * Captured from the LayoutContext at the start of each `layout()` |
| 46 | * call so `walkInline` can re-resolve fonts for nested `<code>` etc. |
| 47 | * without threading the resolver through every recursive parameter. |
| 48 | */ |
| 49 | private ?FontResolver $currentFontResolver = null; |
| 50 | |
| 51 | public function __construct( |
| 52 | private readonly Shaper $shaper = new Shaper(), |
| 53 | private readonly LineBreaker $lineBreaker = new LineBreaker(), |
| 54 | ) {} |
| 55 | |
| 56 | /** |
| 57 | * Lay out the inline children of `$parent` within `$availableWidth`, |
| 58 | * starting at Y = 0 in the parent's coordinate space. Returns the |
| 59 | * generated line boxes (positions relative to the parent's content |
| 60 | * area) and the total height consumed. |
| 61 | * |
| 62 | * @return array{list<LineBox>, float} (lines, totalHeight) |
| 63 | */ |
| 64 | public function layout(Box $parent, float $availableWidth, LayoutContext $context): array |
| 65 | { |
| 66 | $this->currentFontResolver = $context->fontResolver; |
| 67 | // Resolve the shaping font + post-match synthetic-effect flags via |
| 68 | // CSS Fonts 4 §6 weight/style matching. When a real face matches |
| 69 | // the cascaded weight/style, `isBold`/`isItalic` come back false so |
| 70 | // the painter doesn't double up with fake-bold / fake-italic. |
| 71 | $parentMatch = $this->resolveBoxFont($parent, $context->defaultFont); |
| 72 | $font = $parentMatch['font']; |
| 73 | $fontSize = $this->dominantFontSize($parent, $context); |
| 74 | if ($font === null || $availableWidth <= 0.0) { |
| 75 | return [[], 0.0]; |
| 76 | } |
| 77 | $shapingCtx = new ShapingContext($font, $fontSize); |
| 78 | $lineHeight = $this->resolveLineHeight($parent, $fontSize); |
| 79 | $whiteSpace = $this->whiteSpaceKeyword($parent); |
| 80 | $allowSoftWrap = $whiteSpace !== 'nowrap' && $whiteSpace !== 'pre'; |
| 81 | // `pre` and `pre-wrap` preserve whitespace; only `normal` / `nowrap` / |
| 82 | // `pre-line` collapse leading whitespace at line starts (CSS Text 3 §4). |
| 83 | $collapseLeadingWhitespace = $whiteSpace !== 'pre' && $whiteSpace !== 'pre-wrap'; |
| 84 | $collapseInternalWhitespace = $whiteSpace === 'normal' || $whiteSpace === 'nowrap'; |
| 85 | |
| 86 | $letterSpacing = $this->resolveLetterSpacing($parent); |
| 87 | $wordSpacing = $this->resolveWordSpacing($parent); |
| 88 | $tokens = $this->collectTokens( |
| 89 | $parent, |
| 90 | $shapingCtx, |
| 91 | $collapseInternalWhitespace, |
| 92 | $letterSpacing, |
| 93 | $wordSpacing, |
| 94 | baselineShift: 0.0, |
| 95 | href: null, |
| 96 | isBold: $parentMatch['isBold'], |
| 97 | isItalic: $parentMatch['isItalic'], |
| 98 | decorationLines: $this->decorationLines($parent), |
| 99 | textColor: $this->resolveColor($parent), |
| 100 | backgroundColor: null, |
| 101 | linkTitle: null, |
| 102 | decorationColor: $this->resolveDecorationColor($parent), |
| 103 | ); |
| 104 | if ($tokens === []) { |
| 105 | return [[], 0.0]; |
| 106 | } |
| 107 | |
| 108 | // CSS Text 3 §3.1: `text-indent` shifts the first inline box of the |
| 109 | // first formatted line. Length resolves directly; Percentage resolves |
| 110 | // against the block's content width (our `$availableWidth`). |
| 111 | $textIndent = $this->resolveTextIndent($parent, $availableWidth); |
| 112 | |
| 113 | // CSS 2.1 §9.5.3 — line boxes shorten on the side(s) where a |
| 114 | // float is currently active. Compute the per-line (left, right) |
| 115 | // bounds against the float context each time we start a new line. |
| 116 | $bounds = $this->lineBounds($parent, $availableWidth, $context, 0.0); |
| 117 | $lines = []; |
| 118 | $currentFragments = []; |
| 119 | $currentX = $bounds['left'] + $textIndent; |
| 120 | $lineMaxRight = $bounds['right']; |
| 121 | $atLineStart = true; |
| 122 | $y = 0.0; |
| 123 | foreach ($tokens as $token) { |
| 124 | $width = $token['shapedRun']->totalAdvance; |
| 125 | $isMandatory = $token['kind'] === LineBreakKind::Mandatory; |
| 126 | |
| 127 | if ($collapseLeadingWhitespace && $token['isWhitespace'] && $atLineStart) { |
| 128 | // Leading whitespace at a line start is collapsed. |
| 129 | continue; |
| 130 | } |
| 131 | if ($allowSoftWrap |
| 132 | && $currentX + $width > $lineMaxRight |
| 133 | && $currentFragments !== [] |
| 134 | ) { |
| 135 | // Wrap before placing this token. |
| 136 | $effective = $this->lineHeightFor($currentFragments, $lineHeight); |
| 137 | $lines[] = new LineBox($y, $effective, $currentFragments); |
| 138 | $y += $effective; |
| 139 | $currentFragments = []; |
| 140 | $bounds = $this->lineBounds($parent, $availableWidth, $context, $y); |
| 141 | $currentX = $bounds['left']; |
| 142 | $lineMaxRight = $bounds['right']; |
| 143 | $atLineStart = true; |
| 144 | if ($collapseLeadingWhitespace && $token['isWhitespace']) { |
| 145 | // Drop whitespace at start of next line. |
| 146 | continue; |
| 147 | } |
| 148 | } |
| 149 | $currentFragments[] = new InlineFragment( |
| 150 | $currentX, |
| 151 | $width, |
| 152 | $token['shapedRun'], |
| 153 | $token['baselineShift'] ?? 0.0, |
| 154 | $token['href'] ?? null, |
| 155 | $token['isBold'] ?? false, |
| 156 | $token['isItalic'] ?? false, |
| 157 | $token['decorationLines'] ?? [], |
| 158 | $token['textColor'] ?? null, |
| 159 | $token['backgroundColor'] ?? null, |
| 160 | $token['linkTitle'] ?? null, |
| 161 | $token['decorationColor'] ?? null, |
| 162 | ); |
| 163 | // Side-channel: AtomicInlineBox positions get committed back to |
| 164 | // the box's geometry so the painter can draw images / replaced |
| 165 | // content at the right spot. CSS Inline 3 §4.5: for the default |
| 166 | // `vertical-align: baseline`, the inline-block's baseline aligns |
| 167 | // with the parent line's baseline; for replaced elements like |
| 168 | // `<img>` the baseline is the bottom of the box. So position |
| 169 | // the box so its *bottom* sits at the line's baseline (line.y + |
| 170 | // ascent of the shaping font) — same convention the painter |
| 171 | // uses for text baselines. |
| 172 | $atomic = $token['atomicBox'] ?? null; |
| 173 | if ($atomic !== null) { |
| 174 | $heightValue = $atomic->style->get('height'); |
| 175 | $atomicHeight = $heightValue instanceof Length |
| 176 | ? $heightValue->value |
| 177 | : $width; |
| 178 | $shapedRun = $token['shapedRun']; |
| 179 | $atomicFont = $shapedRun->font; |
| 180 | $atomicUpem = max(1, $atomicFont->unitsPerEm); |
| 181 | $atomicAscent = ($atomicFont->ascent / $atomicUpem) * $shapedRun->fontSizePt; |
| 182 | $atomic->geometry->x = $parent->geometry->x + $currentX; |
| 183 | $atomic->geometry->y = $parent->geometry->y + $y + $atomicAscent - $atomicHeight; |
| 184 | $atomic->geometry->width = $width; |
| 185 | $atomic->geometry->height = $atomicHeight; |
| 186 | } |
| 187 | $currentX += $width; |
| 188 | $atLineStart = false; |
| 189 | if ($isMandatory) { |
| 190 | $effective = $this->lineHeightFor($currentFragments, $lineHeight); |
| 191 | $lines[] = new LineBox($y, $effective, $currentFragments); |
| 192 | $y += $effective; |
| 193 | $currentFragments = []; |
| 194 | $bounds = $this->lineBounds($parent, $availableWidth, $context, $y); |
| 195 | $currentX = $bounds['left']; |
| 196 | $lineMaxRight = $bounds['right']; |
| 197 | $atLineStart = true; |
| 198 | } |
| 199 | } |
| 200 | if ($currentFragments !== []) { |
| 201 | $effective = $this->lineHeightFor($currentFragments, $lineHeight); |
| 202 | $lines[] = new LineBox($y, $effective, $currentFragments); |
| 203 | $y += $effective; |
| 204 | } |
| 205 | |
| 206 | // CSS UI 3 §6.2: `text-overflow: ellipsis` truncates each line's |
| 207 | // tail when its content exceeds the available width. Runs before |
| 208 | // text-align so the alignment math operates on the truncated rect. |
| 209 | $lines = $this->applyTextOverflow($lines, $availableWidth, $parent, $shapingCtx, $letterSpacing); |
| 210 | |
| 211 | $lines = $this->applyTextAlign($lines, $availableWidth, $parent); |
| 212 | return [$lines, $y]; |
| 213 | } |
| 214 | |
| 215 | /** |
| 216 | * Resolve CSS Text 3 §11.2 `tab-size` to an integer space count. |
| 217 | * |
| 218 | * - `<integer>` / `<number>`: direct space count. |
| 219 | * - `<length>`: divide by a glyph-space advance estimate |
| 220 | * (0.25 × font-size — a sane default for sans-serif) and round |
| 221 | * to the nearest integer ≥ 0. This is an approximation since |
| 222 | * tab-stop alignment isn't implemented, but converts a |
| 223 | * length-based author intent to the closest N-space expansion. |
| 224 | * - Anything else (`auto`, unknown keywords): the spec default 8. |
| 225 | */ |
| 226 | private function resolveTabSize(Box $box): int |
| 227 | { |
| 228 | $value = $box->style->get('tab-size'); |
| 229 | if ($value instanceof \Phpdftk\Css\Value\Integer) { |
| 230 | return max(0, $value->value); |
| 231 | } |
| 232 | if ($value instanceof \Phpdftk\Css\Value\Number) { |
| 233 | return max(0, (int) round($value->value)); |
| 234 | } |
| 235 | if ($value instanceof \Phpdftk\Css\Value\Length) { |
| 236 | $fontSize = $this->dominantFontSize($box, new LayoutContext( |
| 237 | 0.0, |
| 238 | 0.0, |
| 239 | 0.0, |
| 240 | 0.0, |
| 241 | new \Phpdftk\Css\Cascade\LengthContext(), |
| 242 | )); |
| 243 | $spaceAdvance = max(0.1, $fontSize * 0.25); |
| 244 | return max(0, (int) round($value->value / $spaceAdvance)); |
| 245 | } |
| 246 | return 8; |
| 247 | } |
| 248 | |
| 249 | /** |
| 250 | * Resolve the left and right inset of a line at relative-Y `$y` |
| 251 | * against the active {@see FloatContext}. Returns offsets relative |
| 252 | * to the parent's content-edge X — so `left` is the line's start X |
| 253 | * within the parent's box, and `right` is the line's max-end X. |
| 254 | * |
| 255 | * Without floats this is just `[0, $availableWidth]`. With a left |
| 256 | * float overlapping the line, `left` rises; with a right float, |
| 257 | * `right` falls. |
| 258 | * |
| 259 | * Phase-1 simplification: samples at the line's top edge only. |
| 260 | * Browsers conceptually sample across the full line range and take |
| 261 | * the most-constrained bounds. |
| 262 | * |
| 263 | * @return array{left: float, right: float} |
| 264 | */ |
| 265 | private function lineBounds(Box $parent, float $availableWidth, LayoutContext $context, float $relY): array |
| 266 | { |
| 267 | $floatCtx = $context->floatContext; |
| 268 | if ($floatCtx === null) { |
| 269 | return ['left' => 0.0, 'right' => $availableWidth]; |
| 270 | } |
| 271 | $parentX = $parent->geometry->x; |
| 272 | $parentY = $parent->geometry->y; |
| 273 | $absY = $parentY + $relY; |
| 274 | $absLeft = $floatCtx->leftEdgeAt($absY, $parentX); |
| 275 | $absRight = $floatCtx->rightEdgeAt($absY, $parentX + $availableWidth); |
| 276 | return [ |
| 277 | 'left' => max(0.0, $absLeft - $parentX), |
| 278 | 'right' => max(0.0, $absRight - $parentX), |
| 279 | ]; |
| 280 | } |
| 281 | |
| 282 | /** |
| 283 | * Drop fragments from each overflowing line until an ellipsis glyph |
| 284 | * fits at the end. Only applies when the parent's `text-overflow` is |
| 285 | * `ellipsis`; the default `clip` keyword silently lets the content |
| 286 | * overflow (matching the no-op CSS spec behaviour). |
| 287 | * |
| 288 | * @param list<LineBox> $lines |
| 289 | * @return list<LineBox> |
| 290 | */ |
| 291 | private function applyTextOverflow( |
| 292 | array $lines, |
| 293 | float $availableWidth, |
| 294 | Box $parent, |
| 295 | ShapingContext $shapingCtx, |
| 296 | float $letterSpacing, |
| 297 | ): array { |
| 298 | $value = $parent->style->get('text-overflow'); |
| 299 | if (!($value instanceof \Phpdftk\Css\Value\Keyword) |
| 300 | || strtolower($value->name) !== 'ellipsis' |
| 301 | ) { |
| 302 | return $lines; |
| 303 | } |
| 304 | $ellipsis = $this->shaper->shapeRun("\u{2026}", $shapingCtx); |
| 305 | if ($ellipsis->glyphs === []) { |
| 306 | return $lines; |
| 307 | } |
| 308 | if ($letterSpacing !== 0.0) { |
| 309 | $ellipsis = $this->applyLetterSpacing($ellipsis, $letterSpacing); |
| 310 | } |
| 311 | $ellipsisWidth = $ellipsis->totalAdvance; |
| 312 | |
| 313 | $out = []; |
| 314 | foreach ($lines as $line) { |
| 315 | if ($line->totalWidth() <= $availableWidth) { |
| 316 | $out[] = $line; |
| 317 | continue; |
| 318 | } |
| 319 | // Drop fragments from the end until the remaining content + |
| 320 | // ellipsis fits. Phase-1 truncates at fragment boundaries — |
| 321 | // mid-fragment truncation lands with a per-glyph cut later. |
| 322 | $fragments = $line->fragments; |
| 323 | $cutoff = $availableWidth - $ellipsisWidth; |
| 324 | while ($fragments !== []) { |
| 325 | $last = $fragments[array_key_last($fragments)]; |
| 326 | if ($last->x + $last->width <= $cutoff) { |
| 327 | break; |
| 328 | } |
| 329 | array_pop($fragments); |
| 330 | } |
| 331 | if ($fragments === []) { |
| 332 | // Nothing fits before the ellipsis; emit just the ellipsis |
| 333 | // at x = 0 so the user sees something. |
| 334 | $fragments[] = new InlineFragment(0.0, $ellipsisWidth, $ellipsis); |
| 335 | } else { |
| 336 | $last = $fragments[array_key_last($fragments)]; |
| 337 | $tail = $last->x + $last->width; |
| 338 | $fragments[] = new InlineFragment($tail, $ellipsisWidth, $ellipsis); |
| 339 | } |
| 340 | $out[] = new LineBox($line->y, $line->height, $fragments); |
| 341 | } |
| 342 | return $out; |
| 343 | } |
| 344 | |
| 345 | /** |
| 346 | * Apply the parent's `text-align` to each line: `start` / `left` (default, |
| 347 | * no-op), `center`, `end` / `right`, or `justify`. Justify is approximated |
| 348 | * for the last line as left-aligned per CSS Text 3 §7.3 ("the last line |
| 349 | * of a block, and any line ending with a forced line break, is start- |
| 350 | * aligned"); inter-fragment justification on non-final lines distributes |
| 351 | * extra space evenly across the gaps between fragments. |
| 352 | * |
| 353 | * @param list<LineBox> $lines |
| 354 | * @return list<LineBox> |
| 355 | */ |
| 356 | private function applyTextAlign(array $lines, float $availableWidth, Box $parent): array |
| 357 | { |
| 358 | $align = $this->textAlignKeyword($parent); |
| 359 | $alignLast = $this->textAlignLastKeyword($parent, $align); |
| 360 | // CSS Text 3 §7.5: `text-justify: none` disables justification. |
| 361 | // A `justify` text-align falls through to start-alignment. |
| 362 | if ($this->isTextJustifyNone($parent)) { |
| 363 | if ($align === 'justify') { |
| 364 | $align = 'start'; |
| 365 | } |
| 366 | if ($alignLast === 'justify') { |
| 367 | $alignLast = 'start'; |
| 368 | } |
| 369 | } |
| 370 | if ($align === 'left' || $align === 'start') { |
| 371 | if ($alignLast === 'left' || $alignLast === 'start' || $alignLast === 'auto') { |
| 372 | return $lines; |
| 373 | } |
| 374 | } |
| 375 | $count = count($lines); |
| 376 | $out = []; |
| 377 | foreach ($lines as $i => $line) { |
| 378 | $used = $line->totalWidth(); |
| 379 | $slack = $availableWidth - $used; |
| 380 | if ($slack <= 0.0) { |
| 381 | $out[] = $line; |
| 382 | continue; |
| 383 | } |
| 384 | $isLast = $i === $count - 1; |
| 385 | $effective = $isLast ? $alignLast : $align; |
| 386 | $newFragments = match ($effective) { |
| 387 | 'center' => $this->shiftFragments($line->fragments, $slack / 2.0), |
| 388 | 'right', 'end' => $this->shiftFragments($line->fragments, $slack), |
| 389 | 'justify' => $this->justifyFragments($line->fragments, $slack), |
| 390 | default => $line->fragments, |
| 391 | }; |
| 392 | $out[] = new LineBox($line->y, $line->height, $newFragments); |
| 393 | } |
| 394 | return $out; |
| 395 | } |
| 396 | |
| 397 | /** |
| 398 | * CSS Text 3 §7.5 — `true` when the parent declares |
| 399 | * `text-justify: none`, in which case the justify branches of |
| 400 | * `text-align` and `text-align-last` collapse to start-alignment. |
| 401 | */ |
| 402 | private function isTextJustifyNone(Box $parent): bool |
| 403 | { |
| 404 | $value = $parent->style->get('text-justify'); |
| 405 | if (!($value instanceof \Phpdftk\Css\Value\Keyword)) { |
| 406 | return false; |
| 407 | } |
| 408 | return strtolower($value->name) === 'none'; |
| 409 | } |
| 410 | |
| 411 | /** |
| 412 | * Resolve CSS Text 3 §7.4 `text-align-last`. `auto` (initial) |
| 413 | * inherits the block-aligned behaviour: when text-align is |
| 414 | * `justify` the last line is start-aligned, otherwise it matches |
| 415 | * text-align. Explicit values override. |
| 416 | */ |
| 417 | private function textAlignLastKeyword(Box $parent, string $align): string |
| 418 | { |
| 419 | $value = $parent->style->get('text-align-last'); |
| 420 | if (!($value instanceof \Phpdftk\Css\Value\Keyword)) { |
| 421 | return 'auto'; |
| 422 | } |
| 423 | $lower = strtolower($value->name); |
| 424 | if ($lower === 'auto') { |
| 425 | // text-align: justify → last line is start-aligned per spec. |
| 426 | return $align === 'justify' ? 'start' : $align; |
| 427 | } |
| 428 | return $lower; |
| 429 | } |
| 430 | |
| 431 | private function textAlignKeyword(Box $parent): string |
| 432 | { |
| 433 | $value = $parent->style->get('text-align'); |
| 434 | if ($value instanceof \Phpdftk\Css\Value\Keyword) { |
| 435 | return strtolower($value->name); |
| 436 | } |
| 437 | return 'start'; |
| 438 | } |
| 439 | |
| 440 | /** |
| 441 | * Apply CSS Text 3 §2 `text-transform` to a text run before shaping. |
| 442 | * `uppercase` / `lowercase` are full case mappings via `mb_strtoupper` / |
| 443 | * `mb_strtolower`; `capitalize` upper-cases the first grapheme of each |
| 444 | * whitespace-separated word; `full-width` / `full-size-kana` and other |
| 445 | * Phase-2 transforms fall through unchanged. |
| 446 | */ |
| 447 | private function applyTextTransform(string $text, Box $box): string |
| 448 | { |
| 449 | $value = $box->style->get('text-transform'); |
| 450 | if (!($value instanceof \Phpdftk\Css\Value\Keyword)) { |
| 451 | return $text; |
| 452 | } |
| 453 | return match (strtolower($value->name)) { |
| 454 | 'uppercase' => mb_strtoupper($text, 'UTF-8'), |
| 455 | 'lowercase' => mb_strtolower($text, 'UTF-8'), |
| 456 | 'capitalize' => $this->capitalizeWords($text), |
| 457 | default => $text, |
| 458 | }; |
| 459 | } |
| 460 | |
| 461 | private function capitalizeWords(string $text): string |
| 462 | { |
| 463 | // Split on whitespace runs, capitalize the first codepoint of each |
| 464 | // non-empty word, and rejoin with the original separators. |
| 465 | $parts = preg_split('/(\s+)/u', $text, -1, PREG_SPLIT_DELIM_CAPTURE); |
| 466 | if ($parts === false) { |
| 467 | return $text; |
| 468 | } |
| 469 | $out = ''; |
| 470 | foreach ($parts as $part) { |
| 471 | if ($part === '' || preg_match('/^\s+$/u', $part) === 1) { |
| 472 | $out .= $part; |
| 473 | continue; |
| 474 | } |
| 475 | $first = mb_substr($part, 0, 1, 'UTF-8'); |
| 476 | $rest = mb_substr($part, 1, null, 'UTF-8'); |
| 477 | $out .= mb_strtoupper($first, 'UTF-8') . $rest; |
| 478 | } |
| 479 | return $out; |
| 480 | } |
| 481 | |
| 482 | /** |
| 483 | * Resolve CSS Inline 3 §3 `line-height`: |
| 484 | * - `normal` → font-dependent multiplier (1.2 for Latin until proper |
| 485 | * OS/2 typo-metrics-driven line-height ships). |
| 486 | * - `<number>` → multiplier of font-size; value inherits as the number |
| 487 | * so children re-resolve against their own size. |
| 488 | * - `<length>` → absolute, already in px after `Cascade::resolveLengths`. |
| 489 | * - `<percentage>` → percentage of the element's own font-size. |
| 490 | */ |
| 491 | private function resolveLineHeight(Box $parent, float $fontSize): float |
| 492 | { |
| 493 | $value = $parent->style->get('line-height'); |
| 494 | if ($value instanceof \Phpdftk\Css\Value\Keyword |
| 495 | && strtolower($value->name) === 'normal' |
| 496 | ) { |
| 497 | return $fontSize * 1.2; |
| 498 | } |
| 499 | if ($value instanceof \Phpdftk\Css\Value\Number |
| 500 | || $value instanceof \Phpdftk\Css\Value\Integer |
| 501 | ) { |
| 502 | return $fontSize * $value->value; |
| 503 | } |
| 504 | if ($value instanceof \Phpdftk\Css\Value\Percentage) { |
| 505 | return $fontSize * ($value->value / 100.0); |
| 506 | } |
| 507 | if ($value instanceof Length) { |
| 508 | return $value->value; |
| 509 | } |
| 510 | return $fontSize * 1.2; |
| 511 | } |
| 512 | |
| 513 | /** |
| 514 | * Resolve the parent's `text-indent` CSS value against the available |
| 515 | * width. Length resolves directly; Percentage resolves against the |
| 516 | * block's content width per CSS Text 3 §3.1; everything else falls to 0. |
| 517 | */ |
| 518 | private function resolveTextIndent(Box $parent, float $availableWidth): float |
| 519 | { |
| 520 | $value = $parent->style->get('text-indent'); |
| 521 | if ($value instanceof Length) { |
| 522 | return $value->value; |
| 523 | } |
| 524 | if ($value instanceof \Phpdftk\Css\Value\Percentage) { |
| 525 | return $availableWidth * ($value->value / 100.0); |
| 526 | } |
| 527 | return 0.0; |
| 528 | } |
| 529 | |
| 530 | private function whiteSpaceKeyword(Box $parent): string |
| 531 | { |
| 532 | $value = $parent->style->get('white-space'); |
| 533 | if ($value instanceof \Phpdftk\Css\Value\Keyword) { |
| 534 | return strtolower($value->name); |
| 535 | } |
| 536 | return 'normal'; |
| 537 | } |
| 538 | |
| 539 | /** |
| 540 | * @param list<InlineFragment> $fragments |
| 541 | * @return list<InlineFragment> |
| 542 | */ |
| 543 | private function shiftFragments(array $fragments, float $dx): array |
| 544 | { |
| 545 | $out = []; |
| 546 | foreach ($fragments as $f) { |
| 547 | $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); |
| 548 | } |
| 549 | return $out; |
| 550 | } |
| 551 | |
| 552 | /** |
| 553 | * Spread the slack across the gaps between fragments (CSS Text 3 §7.3 |
| 554 | * `justify-content` approximation for inter-word distribution). |
| 555 | * |
| 556 | * @param list<InlineFragment> $fragments |
| 557 | * @return list<InlineFragment> |
| 558 | */ |
| 559 | private function justifyFragments(array $fragments, float $slack): array |
| 560 | { |
| 561 | $gaps = max(0, count($fragments) - 1); |
| 562 | if ($gaps === 0) { |
| 563 | return $fragments; |
| 564 | } |
| 565 | $delta = $slack / $gaps; |
| 566 | $out = []; |
| 567 | foreach ($fragments as $i => $f) { |
| 568 | $out[] = new InlineFragment($f->x + $i * $delta, $f->width, $f->shapedRun, $f->baselineShift, $f->href, $f->isBold, $f->isItalic, $f->decorationLines, $f->textColor, $f->backgroundColor, $f->linkTitle, $f->decorationColor); |
| 569 | } |
| 570 | return $out; |
| 571 | } |
| 572 | |
| 573 | /** |
| 574 | * Tokenise the inline subtree at line-break opportunities and shape |
| 575 | * each token. Each token records its width, its source kind |
| 576 | * (whitespace / non-whitespace), and whether it closes a mandatory |
| 577 | * break. |
| 578 | * |
| 579 | * @param list<string> $decorationLines |
| 580 | * @return list<array{shapedRun: ShapedRun, isWhitespace: bool, kind: LineBreakKind}> |
| 581 | */ |
| 582 | private function collectTokens( |
| 583 | Box $parent, |
| 584 | ShapingContext $shapingCtx, |
| 585 | bool $collapseInternal, |
| 586 | float $letterSpacing, |
| 587 | float $wordSpacing, |
| 588 | float $baselineShift, |
| 589 | ?string $href, |
| 590 | bool $isBold, |
| 591 | bool $isItalic, |
| 592 | array $decorationLines, |
| 593 | ?\Phpdftk\Css\Value\Color $textColor, |
| 594 | ?\Phpdftk\Css\Value\Color $backgroundColor, |
| 595 | ?string $linkTitle, |
| 596 | ?\Phpdftk\Css\Value\Color $decorationColor, |
| 597 | ): array { |
| 598 | $out = []; |
| 599 | foreach ($parent->children as $child) { |
| 600 | $this->walkInline( |
| 601 | $child, |
| 602 | $shapingCtx, |
| 603 | $out, |
| 604 | $collapseInternal, |
| 605 | $letterSpacing, |
| 606 | $wordSpacing, |
| 607 | $baselineShift, |
| 608 | $href, |
| 609 | $isBold, |
| 610 | $isItalic, |
| 611 | $decorationLines, |
| 612 | $textColor, |
| 613 | $backgroundColor, |
| 614 | $linkTitle, |
| 615 | $decorationColor, |
| 616 | ); |
| 617 | } |
| 618 | return $out; |
| 619 | } |
| 620 | |
| 621 | /** |
| 622 | * @param list<array{shapedRun: ShapedRun, isWhitespace: bool, kind: LineBreakKind}> $tokens |
| 623 | * @param list<string> $decorationLines |
| 624 | */ |
| 625 | private function walkInline( |
| 626 | Box $box, |
| 627 | ShapingContext $shapingCtx, |
| 628 | array &$tokens, |
| 629 | bool $collapseInternal, |
| 630 | float $letterSpacing, |
| 631 | float $wordSpacing, |
| 632 | float $baselineShift, |
| 633 | ?string $href, |
| 634 | bool $isBold, |
| 635 | bool $isItalic, |
| 636 | array $decorationLines, |
| 637 | ?\Phpdftk\Css\Value\Color $textColor, |
| 638 | ?\Phpdftk\Css\Value\Color $backgroundColor, |
| 639 | ?string $linkTitle, |
| 640 | ?\Phpdftk\Css\Value\Color $decorationColor, |
| 641 | ): void { |
| 642 | if ($box instanceof TextBox) { |
| 643 | $text = $box->text; |
| 644 | if ($collapseInternal) { |
| 645 | // CSS Text 3 §4.1.1: in `normal` / `nowrap`, runs of |
| 646 | // whitespace collapse to a single space. Newlines collapse |
| 647 | // alongside spaces / tabs / form feeds. |
| 648 | $text = preg_replace('/[ \t\n\r\f]+/', ' ', $text) ?? $text; |
| 649 | } else { |
| 650 | // CSS Text 3 §11.2 — in white-space modes that preserve |
| 651 | // tabs (`pre`, `pre-wrap`), each U+0009 expands to N |
| 652 | // spaces. Phase-1 simplification: fixed expansion |
| 653 | // instead of tab-stop alignment (which would require |
| 654 | // tracking column position across mid-text breaks). |
| 655 | $tabSize = $this->resolveTabSize($box); |
| 656 | if ($tabSize > 0) { |
| 657 | $text = str_replace("\t", str_repeat(' ', $tabSize), $text); |
| 658 | } else { |
| 659 | $text = str_replace("\t", '', $text); |
| 660 | } |
| 661 | } |
| 662 | // CSS Text 3 §2: `text-transform` runs before shaping so the |
| 663 | // shaper sees the transformed codepoints. |
| 664 | $text = $this->applyTextTransform($text, $box); |
| 665 | $breakAll = $this->isBreakAll($box); |
| 666 | foreach ($this->tokeniseText($text, $shapingCtx, $letterSpacing, $wordSpacing, $breakAll) as $token) { |
| 667 | $token['baselineShift'] = $baselineShift; |
| 668 | $token['href'] = $href; |
| 669 | $token['isBold'] = $isBold; |
| 670 | $token['isItalic'] = $isItalic; |
| 671 | $token['decorationLines'] = $decorationLines; |
| 672 | $token['textColor'] = $textColor; |
| 673 | $token['backgroundColor'] = $backgroundColor; |
| 674 | $token['linkTitle'] = $linkTitle; |
| 675 | $token['decorationColor'] = $decorationColor; |
| 676 | $tokens[] = $token; |
| 677 | } |
| 678 | return; |
| 679 | } |
| 680 | if ($box instanceof LineBreakBox) { |
| 681 | // `<br>` — hard break that survives `white-space: normal`'s |
| 682 | // collapsing. Emit a zero-width mandatory-break token so the |
| 683 | // line-fitter closes the current line and starts a new one. |
| 684 | $tokens[] = [ |
| 685 | 'shapedRun' => new ShapedRun( |
| 686 | $shapingCtx->font, |
| 687 | $shapingCtx->fontSizePt, |
| 688 | $shapingCtx->direction, |
| 689 | [], |
| 690 | 0.0, |
| 691 | ), |
| 692 | 'isWhitespace' => false, |
| 693 | 'kind' => LineBreakKind::Mandatory, |
| 694 | ]; |
| 695 | return; |
| 696 | } |
| 697 | if ($box instanceof AtomicInlineBox) { |
| 698 | // Treat as a single non-wrapping token with its computed width. |
| 699 | $widthValue = $box->style->get('width'); |
| 700 | $width = $widthValue instanceof Length ? $widthValue->value : 0.0; |
| 701 | $tokens[] = [ |
| 702 | 'shapedRun' => new ShapedRun( |
| 703 | $shapingCtx->font, |
| 704 | $shapingCtx->fontSizePt, |
| 705 | $shapingCtx->direction, |
| 706 | [], |
| 707 | $width, |
| 708 | ), |
| 709 | 'isWhitespace' => false, |
| 710 | 'kind' => LineBreakKind::Allowed, |
| 711 | 'baselineShift' => $baselineShift, |
| 712 | 'href' => $href, |
| 713 | 'isBold' => $isBold, |
| 714 | 'isItalic' => $isItalic, |
| 715 | 'decorationLines' => $decorationLines, |
| 716 | 'textColor' => $textColor, |
| 717 | 'backgroundColor' => $backgroundColor, |
| 718 | 'linkTitle' => $linkTitle, |
| 719 | 'decorationColor' => $decorationColor, |
| 720 | 'atomicBox' => $box, |
| 721 | ]; |
| 722 | return; |
| 723 | } |
| 724 | if ($box instanceof InlineBox) { |
| 725 | // CSS Inline 3 §4.5 `vertical-align: sub` / `super` shifts the |
| 726 | // child fragments' baselines. Composes with any outer shift so |
| 727 | // nested `<sup><sub>x</sub></sup>` still has a sensible effect. |
| 728 | $boxShift = $this->resolveVerticalAlign($box, $shapingCtx->fontSizePt); |
| 729 | // HTML 4 / 5 `<a href="...">` — descendants inherit the href so |
| 730 | // the painter can emit a `/Link` annotation per fragment. The |
| 731 | // companion `<a title="...">` lands on the annotation's |
| 732 | // `/Contents` for hover tooltips. |
| 733 | $childHref = $href; |
| 734 | $childTitle = $linkTitle; |
| 735 | if ($box->element !== null |
| 736 | && strtolower($box->element->localName) === 'a' |
| 737 | ) { |
| 738 | $linkUrl = $box->element->getAttribute('href'); |
| 739 | if ($linkUrl !== null && $linkUrl !== '') { |
| 740 | $childHref = $linkUrl; |
| 741 | $title = $box->element->getAttribute('title'); |
| 742 | $childTitle = $title === null || $title === '' ? null : $title; |
| 743 | } |
| 744 | } |
| 745 | // Inline-level emphasis: resolve this box's own weight/style |
| 746 | // request against the FontResolver. A real face match flips |
| 747 | // the per-fragment fake-bold / fake-italic flags off; an |
| 748 | // unmatched request OR no faceMap entry leaves the cascade's |
| 749 | // synthetic-effect flags on so the painter draws the fallback. |
| 750 | // OR with the inherited flags so `<strong><em>X</em></strong>` |
| 751 | // keeps both effects even when only one resolves to a real face. |
| 752 | $boxMatch = $this->resolveBoxFont($box, $shapingCtx->font); |
| 753 | $childBold = $boxMatch['isBold'] || $isBold; |
| 754 | $childItalic = $boxMatch['isItalic'] || $isItalic; |
| 755 | // CSS Text Decoration 4 §2 says decorations set on an inline |
| 756 | // apply to all in-flow descendant text. Union the box's |
| 757 | // decoration lines with whatever the enclosing context set. |
| 758 | $childDeco = $this->mergeDecorationLines($decorationLines, $this->decorationLines($box)); |
| 759 | // §3: `text-decoration-color` doesn't inherit, but when an |
| 760 | // inline element sets a colour explicitly that colour applies |
| 761 | // to its descendant fragments' decorations. A child only |
| 762 | // overrides if it sets its own value — otherwise it keeps the |
| 763 | // closest ancestor's choice. |
| 764 | $childDecoColor = $this->resolveDecorationColor($box) ?? $decorationColor; |
| 765 | // The inline box's own cascaded `color` overrides the inherited |
| 766 | // one — `<a>` gets blue from the UA stylesheet even when its |
| 767 | // parent is black. |
| 768 | $childColor = $this->resolveColor($box) ?? $textColor; |
| 769 | // `background-color` is not inherited but propagates downward |
| 770 | // for inline rendering — every descendant fragment of a |
| 771 | // `<mark>` should carry the yellow rect. |
| 772 | $boxBg = $this->resolveBackground($box); |
| 773 | $childBg = $boxBg ?? $backgroundColor; |
| 774 | // Mixed-size inline runs: if this inline carries a different |
| 775 | // computed `font-size` than the active shaping context, build |
| 776 | // a per-subtree context so descendants shape at the right size. |
| 777 | // Same for `font-family` — when an inline names a font that's |
| 778 | // registered in the FontResolver, switch the shaping font. |
| 779 | $childCtx = $shapingCtx; |
| 780 | $boxFontSize = $this->boxFontSize($box) ?? $shapingCtx->fontSizePt; |
| 781 | $boxFont = $boxMatch['font'] ?? $shapingCtx->font; |
| 782 | $fontSizeChanged = abs($boxFontSize - $shapingCtx->fontSizePt) > 0.001; |
| 783 | $fontChanged = $boxFont !== $shapingCtx->font; |
| 784 | if ($fontSizeChanged || $fontChanged) { |
| 785 | $childCtx = new ShapingContext($boxFont, $boxFontSize); |
| 786 | } |
| 787 | foreach ($box->children as $c) { |
| 788 | $this->walkInline( |
| 789 | $c, |
| 790 | $childCtx, |
| 791 | $tokens, |
| 792 | $collapseInternal, |
| 793 | $letterSpacing, |
| 794 | $wordSpacing, |
| 795 | $baselineShift + $boxShift, |
| 796 | $childHref, |
| 797 | $childBold, |
| 798 | $childItalic, |
| 799 | $childDeco, |
| 800 | $childColor, |
| 801 | $childBg, |
| 802 | $childTitle, |
| 803 | $childDecoColor, |
| 804 | ); |
| 805 | } |
| 806 | } |
| 807 | } |
| 808 | |
| 809 | /** |
| 810 | * Per CSS Inline 3 §3, the line box's used height is the maximum of the |
| 811 | * inline heights it contains. Use the parent's resolved line-height as |
| 812 | * the baseline, then grow if a fragment carries a larger font. |
| 813 | * |
| 814 | * @param list<InlineFragment> $fragments |
| 815 | */ |
| 816 | private function lineHeightFor(array $fragments, float $parentLineHeight): float |
| 817 | { |
| 818 | $maxFontSize = 0.0; |
| 819 | foreach ($fragments as $f) { |
| 820 | if ($f->shapedRun->fontSizePt > $maxFontSize) { |
| 821 | $maxFontSize = $f->shapedRun->fontSizePt; |
| 822 | } |
| 823 | } |
| 824 | return max($parentLineHeight, $maxFontSize * 1.2); |
| 825 | } |
| 826 | |
| 827 | /** |
| 828 | * The box's cascaded `font-size` in user-space units, or null when the |
| 829 | * cascade didn't produce a `Length` (the cascade should always produce |
| 830 | * one after `resolveLengths`; null is just a safety fallback). |
| 831 | */ |
| 832 | private function boxFontSize(Box $box): ?float |
| 833 | { |
| 834 | $value = $box->style->get('font-size'); |
| 835 | return $value instanceof Length ? $value->value : null; |
| 836 | } |
| 837 | |
| 838 | /** |
| 839 | * Resolve the cascaded `font-weight` to a numeric value in the CSS |
| 840 | * Fonts 4 1–1000 range. Keywords map per spec: `normal` → 400, |
| 841 | * `bold` / `bolder` → 700, `lighter` → 100. |
| 842 | */ |
| 843 | private function resolveWeight(Box $box): int |
| 844 | { |
| 845 | $value = $box->style->get('font-weight'); |
| 846 | if ($value instanceof \Phpdftk\Css\Value\Keyword) { |
| 847 | return match (strtolower($value->name)) { |
| 848 | 'bold', 'bolder' => 700, |
| 849 | 'lighter' => 100, |
| 850 | default => 400, |
| 851 | }; |
| 852 | } |
| 853 | if ($value instanceof \Phpdftk\Css\Value\Integer |
| 854 | || $value instanceof \Phpdftk\Css\Value\Number |
| 855 | ) { |
| 856 | return max(1, min(1000, (int) $value->value)); |
| 857 | } |
| 858 | return 400; |
| 859 | } |
| 860 | |
| 861 | /** |
| 862 | * Resolve the cascaded `font-style` to a lower-case keyword in the |
| 863 | * `normal` | `italic` | `oblique` set. Unrecognised values fall back |
| 864 | * to `normal`. |
| 865 | */ |
| 866 | private function resolveStyle(Box $box): string |
| 867 | { |
| 868 | $value = $box->style->get('font-style'); |
| 869 | if ($value instanceof \Phpdftk\Css\Value\Keyword) { |
| 870 | $lc = strtolower($value->name); |
| 871 | if (in_array($lc, ['italic', 'oblique'], true)) { |
| 872 | return $lc; |
| 873 | } |
| 874 | } |
| 875 | return 'normal'; |
| 876 | } |
| 877 | |
| 878 | /** |
| 879 | * Combined font/weight/style resolution for the box. Picks the |
| 880 | * concrete `OpenTypeData` via {@see FontResolver::resolveMatch()} and |
| 881 | * derives the post-match "still needs synthetic effect" flags by |
| 882 | * comparing the matched face's axes against the requested cascade. |
| 883 | * |
| 884 | * @return array{font: ?\Phpdftk\FontParser\OpenTypeData, isBold: bool, isItalic: bool} |
| 885 | */ |
| 886 | private function resolveBoxFont(Box $box, ?\Phpdftk\FontParser\OpenTypeData $fallback): array |
| 887 | { |
| 888 | $weight = $this->resolveWeight($box); |
| 889 | $style = $this->resolveStyle($box); |
| 890 | $requestBold = $weight >= 600; |
| 891 | $requestItalic = $style !== 'normal'; |
| 892 | $resolver = $this->currentFontResolver; |
| 893 | $match = $resolver?->resolveMatch( |
| 894 | $box->style->get('font-family'), |
| 895 | $weight, |
| 896 | $style, |
| 897 | ); |
| 898 | $font = $match?->face->data ?? $fallback; |
| 899 | $isBold = $requestBold && ($match === null || !$match->matchesWeight); |
| 900 | $isItalic = $requestItalic && ($match === null || !$match->matchesStyle); |
| 901 | return ['font' => $font, 'isBold' => $isBold, 'isItalic' => $isItalic]; |
| 902 | } |
| 903 | |
| 904 | /** |
| 905 | * Read the box's cascaded `text-decoration-line` and return the set of |
| 906 | * line keywords it carries — empty list for `none` / unset. |
| 907 | * |
| 908 | * @return list<string> |
| 909 | */ |
| 910 | private function decorationLines(Box $box): array |
| 911 | { |
| 912 | $value = $box->style->get('text-decoration-line'); |
| 913 | if ($value === null |
| 914 | || ($value instanceof \Phpdftk\Css\Value\Keyword |
| 915 | && strtolower($value->name) === 'none') |
| 916 | ) { |
| 917 | return []; |
| 918 | } |
| 919 | $names = []; |
| 920 | if ($value instanceof \Phpdftk\Css\Value\Keyword) { |
| 921 | $names[] = strtolower($value->name); |
| 922 | } elseif ($value instanceof \Phpdftk\Css\Value\ValueList) { |
| 923 | foreach ($value->values as $v) { |
| 924 | if ($v instanceof \Phpdftk\Css\Value\Keyword) { |
| 925 | $kw = strtolower($v->name); |
| 926 | if ($kw !== 'none') { |
| 927 | $names[] = $kw; |
| 928 | } |
| 929 | } |
| 930 | } |
| 931 | } |
| 932 | return array_values(array_unique(array_filter( |
| 933 | $names, |
| 934 | static fn(string $n): bool => in_array($n, ['underline', 'overline', 'line-through'], true), |
| 935 | ))); |
| 936 | } |
| 937 | |
| 938 | /** |
| 939 | * Combine outer + inner text-decoration line lists into a deduped list. |
| 940 | * |
| 941 | * @param list<string> $outer |
| 942 | * @param list<string> $inner |
| 943 | * @return list<string> |
| 944 | */ |
| 945 | private function mergeDecorationLines(array $outer, array $inner): array |
| 946 | { |
| 947 | return array_values(array_unique(array_merge($outer, $inner))); |
| 948 | } |
| 949 | |
| 950 | /** |
| 951 | * Read the box's cascaded `color`, or null when the cascade didn't |
| 952 | * produce a `Color` value (e.g. unresolved keyword fallback). |
| 953 | */ |
| 954 | private function resolveColor(Box $box): ?\Phpdftk\Css\Value\Color |
| 955 | { |
| 956 | $value = $box->style->get('color'); |
| 957 | return $value instanceof \Phpdftk\Css\Value\Color ? $value : null; |
| 958 | } |
| 959 | |
| 960 | private function resolveBackground(Box $box): ?\Phpdftk\Css\Value\Color |
| 961 | { |
| 962 | $value = $box->style->get('background-color'); |
| 963 | return $value instanceof \Phpdftk\Css\Value\Color ? $value : null; |
| 964 | } |
| 965 | |
| 966 | /** |
| 967 | * Read the box's cascaded `text-decoration-color`, or null when the |
| 968 | * property is unset / inherits to the default keyword. CSS Text |
| 969 | * Decoration 4 §3: the property does *not* inherit through inlines, |
| 970 | * so callers explicitly pick whichever closer ancestor set it. |
| 971 | */ |
| 972 | private function resolveDecorationColor(Box $box): ?\Phpdftk\Css\Value\Color |
| 973 | { |
| 974 | $value = $box->style->get('text-decoration-color'); |
| 975 | return $value instanceof \Phpdftk\Css\Value\Color ? $value : null; |
| 976 | } |
| 977 | |
| 978 | /** |
| 979 | * CSS Text 3 §5: `word-break: break-all` (and `overflow-wrap: |
| 980 | * anywhere`) allow line breaks between every two codepoints. |
| 981 | */ |
| 982 | private function isBreakAll(Box $box): bool |
| 983 | { |
| 984 | $wb = $box->style->get('word-break'); |
| 985 | if ($wb instanceof \Phpdftk\Css\Value\Keyword && strtolower($wb->name) === 'break-all') { |
| 986 | return true; |
| 987 | } |
| 988 | $ow = $box->style->get('overflow-wrap'); |
| 989 | if ($ow instanceof \Phpdftk\Css\Value\Keyword && strtolower($ow->name) === 'anywhere') { |
| 990 | return true; |
| 991 | } |
| 992 | return false; |
| 993 | } |
| 994 | |
| 995 | /** |
| 996 | * CSS Inline 3 §4.5 `vertical-align`: Phase-1 honours the `sub` and |
| 997 | * `super` keywords, lifting / lowering the fragment's baseline by a |
| 998 | * font-size-relative amount. Browser defaults: `super` ≈ +0.5em lift, |
| 999 | * `sub` ≈ +0.2em drop. Returns the offset in layout-Y space (negative |
| 1000 | * lifts, positive drops). All other values (baseline / Length / |
| 1001 | * Percentage / top / middle / bottom / text-top / text-bottom) fall |
| 1002 | * through to 0 for now — full vertical-align lands with mixed-size |
| 1003 | * inline runs. |
| 1004 | */ |
| 1005 | private function resolveVerticalAlign(Box $box, float $fontSize): float |
| 1006 | { |
| 1007 | $value = $box->style->get('vertical-align'); |
| 1008 | if (!($value instanceof \Phpdftk\Css\Value\Keyword)) { |
| 1009 | return 0.0; |
| 1010 | } |
| 1011 | return match (strtolower($value->name)) { |
| 1012 | 'super' => -$fontSize * 0.5, |
| 1013 | 'sub' => $fontSize * 0.2, |
| 1014 | default => 0.0, |
| 1015 | }; |
| 1016 | } |
| 1017 | |
| 1018 | /** |
| 1019 | * Tokenise plain text at UAX #14 break opportunities, shaping each |
| 1020 | * resulting segment. Each segment is one token. Whitespace segments |
| 1021 | * are tagged so the line-fitter can collapse them at line edges. When |
| 1022 | * `$letterSpacing` is non-zero, every shaped glyph's advance is bumped |
| 1023 | * by that amount per CSS Text 3 §10 — the painter picks the difference |
| 1024 | * up automatically via its TJ-kerning path. |
| 1025 | * |
| 1026 | * @return list<array{shapedRun: ShapedRun, isWhitespace: bool, kind: LineBreakKind}> |
| 1027 | */ |
| 1028 | private function tokeniseText( |
| 1029 | string $text, |
| 1030 | ShapingContext $shapingCtx, |
| 1031 | float $letterSpacing, |
| 1032 | float $wordSpacing, |
| 1033 | bool $breakAll = false, |
| 1034 | ): array { |
| 1035 | if ($text === '') { |
| 1036 | return []; |
| 1037 | } |
| 1038 | if ($breakAll) { |
| 1039 | // CSS Text 3 §5 `word-break: break-all` — every codepoint is a |
| 1040 | // valid break point. Walk UTF-8 codepoints and emit one |
| 1041 | // segment per character. Whitespace runs still collapse into |
| 1042 | // their own segment so word/letter-spacing logic stays sane. |
| 1043 | $segments = []; |
| 1044 | $bytes = strlen($text); |
| 1045 | $i = 0; |
| 1046 | while ($i < $bytes) { |
| 1047 | $b = ord($text[$i]); |
| 1048 | $cpLen = $b < 0x80 ? 1 : ($b < 0xE0 ? 2 : ($b < 0xF0 ? 3 : 4)); |
| 1049 | $segments[] = [ |
| 1050 | 'text' => substr($text, $i, $cpLen), |
| 1051 | 'kind' => LineBreakKind::Allowed, |
| 1052 | ]; |
| 1053 | $i += $cpLen; |
| 1054 | } |
| 1055 | } else { |
| 1056 | $breaks = iterator_to_array($this->lineBreaker->breakOpportunities($text), false); |
| 1057 | $segments = []; |
| 1058 | $start = 0; |
| 1059 | foreach ($breaks as $opp) { |
| 1060 | if ($opp->offset > $start) { |
| 1061 | $segments[] = ['text' => substr($text, $start, $opp->offset - $start), 'kind' => $opp->kind]; |
| 1062 | $start = $opp->offset; |
| 1063 | } |
| 1064 | } |
| 1065 | if ($start < strlen($text)) { |
| 1066 | $segments[] = ['text' => substr($text, $start), 'kind' => LineBreakKind::Allowed]; |
| 1067 | } |
| 1068 | } |
| 1069 | |
| 1070 | $out = []; |
| 1071 | foreach ($segments as $seg) { |
| 1072 | $isWs = preg_match('/^[ \t\n\r\f]+$/', $seg['text']) === 1; |
| 1073 | $shaped = $this->shaper->shapeRun($seg['text'], $shapingCtx); |
| 1074 | if ($letterSpacing !== 0.0 && $shaped->glyphs !== []) { |
| 1075 | $shaped = $this->applyLetterSpacing($shaped, $letterSpacing); |
| 1076 | } |
| 1077 | if ($wordSpacing !== 0.0 && $shaped->glyphs !== []) { |
| 1078 | // CSS Text 3 §9: `word-spacing` adds advance only at word- |
| 1079 | // separator glyphs (U+0020 / U+00A0 at MVP). |
| 1080 | $shaped = $this->applyWordSpacing($shaped, $seg['text'], $wordSpacing); |
| 1081 | } |
| 1082 | $out[] = [ |
| 1083 | 'shapedRun' => $shaped, |
| 1084 | 'isWhitespace' => $isWs, |
| 1085 | 'kind' => $seg['kind'], |
| 1086 | ]; |
| 1087 | } |
| 1088 | return $out; |
| 1089 | } |
| 1090 | |
| 1091 | /** |
| 1092 | * Return a new `ShapedRun` with every glyph's `advanceX` bumped by |
| 1093 | * `$letterSpacing` and the `totalAdvance` summed accordingly. |
| 1094 | */ |
| 1095 | private function applyLetterSpacing(ShapedRun $run, float $letterSpacing): ShapedRun |
| 1096 | { |
| 1097 | $glyphs = []; |
| 1098 | $total = 0.0; |
| 1099 | foreach ($run->glyphs as $g) { |
| 1100 | $newAdvance = $g->advanceX + $letterSpacing; |
| 1101 | $glyphs[] = new ShapedGlyph( |
| 1102 | $g->glyphId, |
| 1103 | $g->sourceOffset, |
| 1104 | $g->sourceLength, |
| 1105 | $newAdvance, |
| 1106 | $g->advanceY, |
| 1107 | $g->offsetX, |
| 1108 | $g->offsetY, |
| 1109 | ); |
| 1110 | $total += $newAdvance; |
| 1111 | } |
| 1112 | return new ShapedRun( |
| 1113 | $run->font, |
| 1114 | $run->fontSizePt, |
| 1115 | $run->direction, |
| 1116 | $glyphs, |
| 1117 | $total, |
| 1118 | ); |
| 1119 | } |
| 1120 | |
| 1121 | /** |
| 1122 | * CSS Text 3 §10: `letter-spacing` keyword `normal` resolves to 0; |
| 1123 | * any `Length` (already in px after `Cascade::resolveLengths`) is the |
| 1124 | * extra advance applied to every glyph. |
| 1125 | */ |
| 1126 | private function resolveLetterSpacing(Box $parent): float |
| 1127 | { |
| 1128 | $value = $parent->style->get('letter-spacing'); |
| 1129 | if ($value instanceof Length) { |
| 1130 | return $value->value; |
| 1131 | } |
| 1132 | return 0.0; |
| 1133 | } |
| 1134 | |
| 1135 | /** |
| 1136 | * CSS Text 3 §9: `word-spacing` adds advance only at word-separator |
| 1137 | * glyphs. `normal` → 0; any `Length` is the extra advance per separator. |
| 1138 | */ |
| 1139 | private function resolveWordSpacing(Box $parent): float |
| 1140 | { |
| 1141 | $value = $parent->style->get('word-spacing'); |
| 1142 | if ($value instanceof Length) { |
| 1143 | return $value->value; |
| 1144 | } |
| 1145 | return 0.0; |
| 1146 | } |
| 1147 | |
| 1148 | /** |
| 1149 | * Bump the advance of every glyph whose source codepoint is a CSS |
| 1150 | * word separator (U+0020 SPACE or U+00A0 NO-BREAK SPACE). Builds and |
| 1151 | * returns a new `ShapedRun`. |
| 1152 | */ |
| 1153 | private function applyWordSpacing(ShapedRun $run, string $sourceText, float $wordSpacing): ShapedRun |
| 1154 | { |
| 1155 | $glyphs = []; |
| 1156 | $total = 0.0; |
| 1157 | foreach ($run->glyphs as $g) { |
| 1158 | $bump = $this->isWordSeparatorAt($sourceText, $g->sourceOffset) ? $wordSpacing : 0.0; |
| 1159 | $newAdvance = $g->advanceX + $bump; |
| 1160 | $glyphs[] = new ShapedGlyph( |
| 1161 | $g->glyphId, |
| 1162 | $g->sourceOffset, |
| 1163 | $g->sourceLength, |
| 1164 | $newAdvance, |
| 1165 | $g->advanceY, |
| 1166 | $g->offsetX, |
| 1167 | $g->offsetY, |
| 1168 | ); |
| 1169 | $total += $newAdvance; |
| 1170 | } |
| 1171 | return new ShapedRun( |
| 1172 | $run->font, |
| 1173 | $run->fontSizePt, |
| 1174 | $run->direction, |
| 1175 | $glyphs, |
| 1176 | $total, |
| 1177 | ); |
| 1178 | } |
| 1179 | |
| 1180 | private function isWordSeparatorAt(string $text, int $offset): bool |
| 1181 | { |
| 1182 | if ($offset < 0 || $offset >= strlen($text)) { |
| 1183 | return false; |
| 1184 | } |
| 1185 | $b = ord($text[$offset]); |
| 1186 | if ($b === 0x20) { |
| 1187 | return true; |
| 1188 | } |
| 1189 | // U+00A0 NO-BREAK SPACE → UTF-8 bytes 0xC2 0xA0. |
| 1190 | return $b === 0xC2 && ($offset + 1) < strlen($text) && ord($text[$offset + 1]) === 0xA0; |
| 1191 | } |
| 1192 | |
| 1193 | /** |
| 1194 | * The dominant font-size for the inline run. Phase 1F.2 reads it from |
| 1195 | * the parent's cascaded `font-size`; mixed-size content is a Phase 2 |
| 1196 | * follow-up alongside multi-font runs. |
| 1197 | */ |
| 1198 | private function dominantFontSize(Box $parent, LayoutContext $context): float |
| 1199 | { |
| 1200 | $value = $parent->style->get('font-size'); |
| 1201 | if ($value instanceof Length) { |
| 1202 | return $value->value; |
| 1203 | } |
| 1204 | return $context->lengthContext->currentFontSize; |
| 1205 | } |
| 1206 | } |