Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
87.21% |
382 / 438 |
|
51.85% |
14 / 27 |
CRAP | |
0.00% |
0 / 1 |
| ShorthandExpander | |
87.21% |
382 / 438 |
|
51.85% |
14 / 27 |
317.32 | |
0.00% |
0 / 1 |
| expand | |
92.86% |
26 / 28 |
|
0.00% |
0 / 1 |
22.18 | |||
| expandFourSided | |
93.33% |
14 / 15 |
|
0.00% |
0 / 1 |
6.01 | |||
| expandBorderSide | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| expandBorder | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| expandOutline | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
11 | |||
| classifyBorderComponents | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
12 | |||
| toComponents | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| stripSlashTail | |
33.33% |
2 / 6 |
|
0.00% |
0 / 1 |
5.67 | |||
| looksLikeBorderWidth | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| looksLikeBorderStyle | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| looksLikeColor | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| expandFont | |
96.67% |
58 / 60 |
|
0.00% |
0 / 1 |
29 | |||
| looksLikeFontStyle | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| looksLikeFontVariant | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| looksLikeFontWeight | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
7 | |||
| looksLikeFontStretch | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| expandBackground | |
95.45% |
42 / 44 |
|
0.00% |
0 / 1 |
18 | |||
| expandListStyle | |
91.89% |
34 / 37 |
|
0.00% |
0 / 1 |
13.09 | |||
| expandTextDecoration | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
10 | |||
| expandFlex | |
48.84% |
21 / 43 |
|
0.00% |
0 / 1 |
80.06 | |||
| expandFlexFlow | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
56 | |||
| expandOverflow | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
3.01 | |||
| expandInset | |
86.67% |
13 / 15 |
|
0.00% |
0 / 1 |
6.09 | |||
| expandGap | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
4.02 | |||
| expandColumns | |
90.91% |
20 / 22 |
|
0.00% |
0 / 1 |
15.17 | |||
| expandColumnRule | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
11 | |||
| looksLikeFontSize | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
4.03 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\Css\Cascade; |
| 6 | |
| 7 | use Phpdftk\Css\Value\Integer; |
| 8 | use Phpdftk\Css\Value\Keyword; |
| 9 | use Phpdftk\Css\Value\Length; |
| 10 | use Phpdftk\Css\Value\ListSeparator; |
| 11 | use Phpdftk\Css\Value\Number; |
| 12 | use Phpdftk\Css\Value\Percentage; |
| 13 | use Phpdftk\Css\Value\StringValue; |
| 14 | use Phpdftk\Css\Value\Value; |
| 15 | use Phpdftk\Css\Value\ValueList; |
| 16 | |
| 17 | /** |
| 18 | * Per CSS Cascade 5 §3.2, shorthand declarations expand into their longhand |
| 19 | * components before the cascade picks winners. This class implements the |
| 20 | * structural box-edge shorthands needed by Phase 1F layout: |
| 21 | * |
| 22 | * - `margin` / `padding` / `border-width` / `border-style` / |
| 23 | * `border-color` — four-sided variants. |
| 24 | * - `border-top` / `border-right` / `border-bottom` / `border-left` — |
| 25 | * composite per-side shorthand (width / style / color in any order). |
| 26 | * - `border` — combines `border-{width,style,color}` for all four sides. |
| 27 | * |
| 28 | * Shorthand value-list rules (CSS Backgrounds 3 §3.1 / CSS Box 3): |
| 29 | * - 1 value: applied to all four sides. |
| 30 | * - 2 values: top/bottom, left/right. |
| 31 | * - 3 values: top, left/right, bottom. |
| 32 | * - 4 values: top, right, bottom, left (clockwise from top). |
| 33 | * |
| 34 | * Unknown shorthands fall through unchanged so the cascade can still match |
| 35 | * declarations the registry doesn't know about — they just don't decompose. |
| 36 | */ |
| 37 | final class ShorthandExpander |
| 38 | { |
| 39 | /** |
| 40 | * Expand `$property = $value`. Returns the resulting longhand map; the |
| 41 | * shorthand name is intentionally omitted so the cascade doesn't keep |
| 42 | * tracking it alongside its longhands. |
| 43 | * |
| 44 | * @return array<string, Value> |
| 45 | */ |
| 46 | public function expand(string $property, Value $value): array |
| 47 | { |
| 48 | $name = strtolower($property); |
| 49 | return match ($name) { |
| 50 | 'margin' => $this->expandFourSided('margin', $value, ['top', 'right', 'bottom', 'left']), |
| 51 | 'padding' => $this->expandFourSided('padding', $value, ['top', 'right', 'bottom', 'left']), |
| 52 | 'border-width' => $this->expandFourSided('border', $value, ['top-width', 'right-width', 'bottom-width', 'left-width']), |
| 53 | 'border-style' => $this->expandFourSided('border', $value, ['top-style', 'right-style', 'bottom-style', 'left-style']), |
| 54 | 'border-color' => $this->expandFourSided('border', $value, ['top-color', 'right-color', 'bottom-color', 'left-color']), |
| 55 | // CSS Backgrounds 3 §6: `border-radius` expands like `margin` |
| 56 | // but the corner suffix order is TL TR BR BL (clockwise from |
| 57 | // top-left), and the horizontal/vertical pair `/` form is |
| 58 | // ignored — Phase 1 only honours the symmetrical value. |
| 59 | 'border-radius' => $this->expandFourSided( |
| 60 | 'border', |
| 61 | $this->stripSlashTail($value), |
| 62 | ['top-left-radius', 'top-right-radius', 'bottom-right-radius', 'bottom-left-radius'], |
| 63 | ), |
| 64 | 'border-top', 'border-right', 'border-bottom', 'border-left' |
| 65 | => $this->expandBorderSide($name, $value), |
| 66 | 'border' => $this->expandBorder($value), |
| 67 | 'outline' => $this->expandOutline($value), |
| 68 | 'font' => $this->expandFont($value), |
| 69 | 'text-decoration' => $this->expandTextDecoration($value), |
| 70 | 'background' => $this->expandBackground($value), |
| 71 | 'list-style' => $this->expandListStyle($value), |
| 72 | 'columns' => $this->expandColumns($value), |
| 73 | 'column-rule' => $this->expandColumnRule($value), |
| 74 | 'gap' => $this->expandGap($value), |
| 75 | 'inset' => $this->expandInset($value), |
| 76 | 'overflow' => $this->expandOverflow($value), |
| 77 | 'flex' => $this->expandFlex($value), |
| 78 | 'flex-flow' => $this->expandFlexFlow($value), |
| 79 | default => [$property => $value], |
| 80 | }; |
| 81 | } |
| 82 | |
| 83 | /** |
| 84 | * `margin: 10px;` → margin-top/right/bottom/left = 10px |
| 85 | * `margin: 10px 5px;` → top/bottom = 10px, left/right = 5px |
| 86 | * `margin: 10px 5px 20px;` → top = 10, right/left = 5, bottom = 20 |
| 87 | * `margin: 10px 5px 20px 0;` → t r b l |
| 88 | * |
| 89 | * @param array{0:string, 1:string, 2:string, 3:string} $suffixes |
| 90 | * @return array<string, Value> |
| 91 | */ |
| 92 | private function expandFourSided(string $prefix, Value $value, array $suffixes): array |
| 93 | { |
| 94 | $components = $this->toComponents($value); |
| 95 | $count = count($components); |
| 96 | if ($count === 0) { |
| 97 | return []; |
| 98 | } |
| 99 | [$top, $right, $bottom, $left] = match ($count) { |
| 100 | 1 => [$components[0], $components[0], $components[0], $components[0]], |
| 101 | 2 => [$components[0], $components[1], $components[0], $components[1]], |
| 102 | 3 => [$components[0], $components[1], $components[2], $components[1]], |
| 103 | default => [$components[0], $components[1], $components[2], $components[3]], |
| 104 | }; |
| 105 | return [ |
| 106 | $prefix . '-' . $suffixes[0] => $top, |
| 107 | $prefix . '-' . $suffixes[1] => $right, |
| 108 | $prefix . '-' . $suffixes[2] => $bottom, |
| 109 | $prefix . '-' . $suffixes[3] => $left, |
| 110 | ]; |
| 111 | } |
| 112 | |
| 113 | /** |
| 114 | * `border-top: 1px solid red` → expands to border-top-width, -style, -color. |
| 115 | * Components can appear in any order; missing fields default to the |
| 116 | * spec's initial value (`medium` for width, `none` for style, `currentcolor` |
| 117 | * for color) but we leave the omitted longhands out so the registry's |
| 118 | * initial values apply. |
| 119 | * |
| 120 | * @return array<string, Value> |
| 121 | */ |
| 122 | private function expandBorderSide(string $shorthand, Value $value): array |
| 123 | { |
| 124 | // shorthand is e.g. "border-top". |
| 125 | $side = substr($shorthand, strlen('border-')); |
| 126 | $components = $this->toComponents($value); |
| 127 | return $this->classifyBorderComponents($components, [$side]); |
| 128 | } |
| 129 | |
| 130 | /** @return array<string, Value> */ |
| 131 | private function expandBorder(Value $value): array |
| 132 | { |
| 133 | $components = $this->toComponents($value); |
| 134 | return $this->classifyBorderComponents($components, ['top', 'right', 'bottom', 'left']); |
| 135 | } |
| 136 | |
| 137 | /** |
| 138 | * `outline: 2px solid red` → outline-width, outline-style, outline-color. |
| 139 | * Free-order components like border-side. |
| 140 | * |
| 141 | * @return array<string, Value> |
| 142 | */ |
| 143 | private function expandOutline(Value $value): array |
| 144 | { |
| 145 | $components = $this->toComponents($value); |
| 146 | $width = null; |
| 147 | $style = null; |
| 148 | $color = null; |
| 149 | foreach ($components as $c) { |
| 150 | if ($style === null && $this->looksLikeBorderStyle($c)) { |
| 151 | $style = $c; |
| 152 | continue; |
| 153 | } |
| 154 | if ($width === null && $this->looksLikeBorderWidth($c)) { |
| 155 | $width = $c; |
| 156 | continue; |
| 157 | } |
| 158 | if ($color === null && $c instanceof \Phpdftk\Css\Value\Color) { |
| 159 | $color = $c; |
| 160 | } |
| 161 | } |
| 162 | $out = []; |
| 163 | if ($width !== null) { |
| 164 | $out['outline-width'] = $width; |
| 165 | } |
| 166 | if ($style !== null) { |
| 167 | $out['outline-style'] = $style; |
| 168 | } |
| 169 | if ($color !== null) { |
| 170 | $out['outline-color'] = $color; |
| 171 | } |
| 172 | return $out; |
| 173 | } |
| 174 | |
| 175 | /** |
| 176 | * Map free-order `<width> <style> <color>` components onto every side |
| 177 | * listed in `$sides`. Order-agnostic per CSS Backgrounds 3 §3.1. |
| 178 | * |
| 179 | * @param list<Value> $components |
| 180 | * @param list<string> $sides |
| 181 | * @return array<string, Value> |
| 182 | */ |
| 183 | private function classifyBorderComponents(array $components, array $sides): array |
| 184 | { |
| 185 | $width = null; |
| 186 | $style = null; |
| 187 | $color = null; |
| 188 | foreach ($components as $c) { |
| 189 | if ($style === null && $this->looksLikeBorderStyle($c)) { |
| 190 | $style = $c; |
| 191 | continue; |
| 192 | } |
| 193 | if ($width === null && $this->looksLikeBorderWidth($c)) { |
| 194 | $width = $c; |
| 195 | continue; |
| 196 | } |
| 197 | if ($color === null && $this->looksLikeColor($c)) { |
| 198 | $color = $c; |
| 199 | continue; |
| 200 | } |
| 201 | } |
| 202 | $out = []; |
| 203 | foreach ($sides as $side) { |
| 204 | if ($width !== null) { |
| 205 | $out["border-$side-width"] = $width; |
| 206 | } |
| 207 | if ($style !== null) { |
| 208 | $out["border-$side-style"] = $style; |
| 209 | } |
| 210 | if ($color !== null) { |
| 211 | $out["border-$side-color"] = $color; |
| 212 | } |
| 213 | } |
| 214 | return $out; |
| 215 | } |
| 216 | |
| 217 | /** @return list<Value> */ |
| 218 | private function toComponents(Value $value): array |
| 219 | { |
| 220 | if ($value instanceof ValueList) { |
| 221 | return $value->values; |
| 222 | } |
| 223 | return [$value]; |
| 224 | } |
| 225 | |
| 226 | /** |
| 227 | * `border-radius: 5px 10px / 8px 12px` — Phase 1 ignores the second |
| 228 | * (vertical) radius set after `/`; drop everything past the slash so |
| 229 | * the horizontal radii feed `expandFourSided`. |
| 230 | */ |
| 231 | private function stripSlashTail(Value $value): Value |
| 232 | { |
| 233 | if (!($value instanceof ValueList)) { |
| 234 | return $value; |
| 235 | } |
| 236 | if ($value->separator !== \Phpdftk\Css\Value\ListSeparator::Slash) { |
| 237 | return $value; |
| 238 | } |
| 239 | // The slash-separated outer list has the horizontal radii as its |
| 240 | // first item; keep only that. The horizontal-radii item may itself |
| 241 | // be a Space ValueList. |
| 242 | $first = $value->values[0] ?? $value; |
| 243 | return $first; |
| 244 | } |
| 245 | |
| 246 | private function looksLikeBorderWidth(Value $v): bool |
| 247 | { |
| 248 | if ($v instanceof \Phpdftk\Css\Value\Length) { |
| 249 | return true; |
| 250 | } |
| 251 | if ($v instanceof \Phpdftk\Css\Value\Keyword) { |
| 252 | return in_array(strtolower($v->name), ['thin', 'medium', 'thick'], true); |
| 253 | } |
| 254 | return false; |
| 255 | } |
| 256 | |
| 257 | private function looksLikeBorderStyle(Value $v): bool |
| 258 | { |
| 259 | if (!$v instanceof \Phpdftk\Css\Value\Keyword) { |
| 260 | return false; |
| 261 | } |
| 262 | return in_array(strtolower($v->name), [ |
| 263 | 'none', 'hidden', 'dotted', 'dashed', 'solid', |
| 264 | 'double', 'groove', 'ridge', 'inset', 'outset', |
| 265 | ], true); |
| 266 | } |
| 267 | |
| 268 | private function looksLikeColor(Value $v): bool |
| 269 | { |
| 270 | return $v instanceof \Phpdftk\Css\Value\Color; |
| 271 | } |
| 272 | |
| 273 | /** |
| 274 | * CSS Fonts 4 §6.7: `font: [<style> || <variant> || <weight> || <stretch>]? |
| 275 | * <size> [/ <line-height>]? <family>`. |
| 276 | * |
| 277 | * The input value can carry three nested structures depending on whether |
| 278 | * comma-separated families and a slash-separated line-height are present: |
| 279 | * |
| 280 | * - bare Space list: `bold 16px Arial` |
| 281 | * - Slash wrapping Space lists: `bold 16px/1.5 Arial` |
| 282 | * - Comma wrapping the above + extra family items: `bold 16px Arial, sans-serif` |
| 283 | * - Comma + Slash combo: `bold 16px/1.5 Arial, sans-serif` |
| 284 | * |
| 285 | * @return array<string, Value> |
| 286 | */ |
| 287 | private function expandFont(Value $value): array |
| 288 | { |
| 289 | $head = $value; |
| 290 | $extraFamilies = []; |
| 291 | if ($value instanceof ValueList && $value->separator === ListSeparator::Comma) { |
| 292 | $head = $value->values[0] ?? $value; |
| 293 | $extraFamilies = array_slice($value->values, 1); |
| 294 | } |
| 295 | |
| 296 | $lineHeight = null; |
| 297 | $tail = []; |
| 298 | if ($head instanceof ValueList && $head->separator === ListSeparator::Slash) { |
| 299 | $sizeSegment = $head->values[0] ?? $head; |
| 300 | $afterSlash = $head->values[1] ?? null; |
| 301 | if ($afterSlash !== null) { |
| 302 | $afterItems = $afterSlash instanceof ValueList && $afterSlash->separator === ListSeparator::Space |
| 303 | ? $afterSlash->values |
| 304 | : [$afterSlash]; |
| 305 | $lineHeight = $afterItems[0] ?? null; |
| 306 | $tail = array_slice($afterItems, 1); |
| 307 | } |
| 308 | $head = $sizeSegment; |
| 309 | } |
| 310 | |
| 311 | $items = $head instanceof ValueList && $head->separator === ListSeparator::Space |
| 312 | ? $head->values |
| 313 | : [$head]; |
| 314 | |
| 315 | $style = $variant = $weight = $stretch = $size = null; |
| 316 | $familyHead = []; |
| 317 | foreach ($items as $item) { |
| 318 | if ($size === null) { |
| 319 | if ($style === null && $this->looksLikeFontStyle($item)) { |
| 320 | $style = $item; |
| 321 | continue; |
| 322 | } |
| 323 | if ($variant === null && $this->looksLikeFontVariant($item)) { |
| 324 | $variant = $item; |
| 325 | continue; |
| 326 | } |
| 327 | if ($weight === null && $this->looksLikeFontWeight($item)) { |
| 328 | $weight = $item; |
| 329 | continue; |
| 330 | } |
| 331 | if ($stretch === null && $this->looksLikeFontStretch($item)) { |
| 332 | $stretch = $item; |
| 333 | continue; |
| 334 | } |
| 335 | if ($this->looksLikeFontSize($item)) { |
| 336 | $size = $item; |
| 337 | continue; |
| 338 | } |
| 339 | continue; |
| 340 | } |
| 341 | $familyHead[] = $item; |
| 342 | } |
| 343 | |
| 344 | $allFamilies = array_merge($familyHead, $tail, $extraFamilies); |
| 345 | |
| 346 | $out = []; |
| 347 | if ($size !== null) { |
| 348 | $out['font-size'] = $size; |
| 349 | } |
| 350 | if ($style !== null) { |
| 351 | $out['font-style'] = $style; |
| 352 | } |
| 353 | if ($variant !== null) { |
| 354 | $out['font-variant'] = $variant; |
| 355 | } |
| 356 | if ($weight !== null) { |
| 357 | $out['font-weight'] = $weight; |
| 358 | } |
| 359 | if ($stretch !== null) { |
| 360 | $out['font-stretch'] = $stretch; |
| 361 | } |
| 362 | if ($lineHeight !== null) { |
| 363 | $out['line-height'] = $lineHeight; |
| 364 | } |
| 365 | if ($allFamilies !== []) { |
| 366 | $out['font-family'] = count($allFamilies) === 1 |
| 367 | ? $allFamilies[0] |
| 368 | : new ValueList(array_values($allFamilies), ListSeparator::Comma); |
| 369 | } |
| 370 | return $out; |
| 371 | } |
| 372 | |
| 373 | private function looksLikeFontStyle(Value $v): bool |
| 374 | { |
| 375 | if (!$v instanceof Keyword) { |
| 376 | return false; |
| 377 | } |
| 378 | return in_array(strtolower($v->name), ['italic', 'oblique'], true); |
| 379 | } |
| 380 | |
| 381 | private function looksLikeFontVariant(Value $v): bool |
| 382 | { |
| 383 | return $v instanceof Keyword && strtolower($v->name) === 'small-caps'; |
| 384 | } |
| 385 | |
| 386 | private function looksLikeFontWeight(Value $v): bool |
| 387 | { |
| 388 | if ($v instanceof Keyword |
| 389 | && in_array(strtolower($v->name), ['bold', 'bolder', 'lighter'], true) |
| 390 | ) { |
| 391 | return true; |
| 392 | } |
| 393 | if ($v instanceof Integer || $v instanceof Number) { |
| 394 | $n = $v instanceof Integer ? $v->value : (int) $v->value; |
| 395 | return $n >= 1 && $n <= 1000; |
| 396 | } |
| 397 | return false; |
| 398 | } |
| 399 | |
| 400 | private function looksLikeFontStretch(Value $v): bool |
| 401 | { |
| 402 | if (!$v instanceof Keyword) { |
| 403 | return false; |
| 404 | } |
| 405 | return in_array(strtolower($v->name), [ |
| 406 | 'ultra-condensed', 'extra-condensed', 'condensed', 'semi-condensed', |
| 407 | 'semi-expanded', 'expanded', 'extra-expanded', 'ultra-expanded', |
| 408 | ], true); |
| 409 | } |
| 410 | |
| 411 | /** |
| 412 | * `background: <bg-image> || <position> [ / <bg-size> ]? || <repeat> || |
| 413 | * <attachment> || <box> || <box> || <color>` (CSS Backgrounds 3 §3.10), |
| 414 | * with `<box>` reused for both `background-origin` and `background-clip`. |
| 415 | * |
| 416 | * Phase-1 implementation classifies components by their parsed type and |
| 417 | * keyword vocabulary rather than tracking the per-component grammar |
| 418 | * precisely — sufficient for the common author-CSS patterns |
| 419 | * (`background: red`, `background: url(x.png)`, `background: #fff |
| 420 | * no-repeat`, `background: url(bg.jpg) center / cover`). Components the |
| 421 | * classifier doesn't recognise are dropped, which is forgiving but |
| 422 | * sometimes lossy — matches browser behaviour for malformed inputs. |
| 423 | * |
| 424 | * @return array<string, Value> |
| 425 | */ |
| 426 | private function expandBackground(Value $value): array |
| 427 | { |
| 428 | // Slash separates position from size: `background: <pos> / <size>`. |
| 429 | $position = null; |
| 430 | $size = null; |
| 431 | $body = $value; |
| 432 | if ($value instanceof ValueList && $value->separator === ListSeparator::Slash) { |
| 433 | $body = $value->values[0] ?? $value; |
| 434 | $size = $value->values[1] ?? null; |
| 435 | } |
| 436 | |
| 437 | $components = $this->toComponents($body); |
| 438 | $color = null; |
| 439 | $image = null; |
| 440 | $repeat = null; |
| 441 | $positionParts = []; |
| 442 | foreach ($components as $c) { |
| 443 | if ($this->looksLikeColor($c)) { |
| 444 | $color = $c; |
| 445 | continue; |
| 446 | } |
| 447 | if ($c instanceof \Phpdftk\Css\Value\Url) { |
| 448 | $image = $c; |
| 449 | continue; |
| 450 | } |
| 451 | if ($c instanceof Keyword) { |
| 452 | $lower = strtolower($c->name); |
| 453 | if (in_array($lower, ['repeat', 'no-repeat', 'repeat-x', 'repeat-y', 'round', 'space'], true)) { |
| 454 | $repeat = $c; |
| 455 | continue; |
| 456 | } |
| 457 | if (in_array($lower, ['top', 'bottom', 'left', 'right', 'center'], true)) { |
| 458 | $positionParts[] = $c; |
| 459 | continue; |
| 460 | } |
| 461 | } |
| 462 | if ($c instanceof Length || $c instanceof Percentage) { |
| 463 | $positionParts[] = $c; |
| 464 | } |
| 465 | } |
| 466 | if ($positionParts !== []) { |
| 467 | $position = count($positionParts) === 1 |
| 468 | ? $positionParts[0] |
| 469 | : new ValueList($positionParts, ListSeparator::Space); |
| 470 | } |
| 471 | |
| 472 | $out = []; |
| 473 | if ($color !== null) { |
| 474 | $out['background-color'] = $color; |
| 475 | } |
| 476 | if ($image !== null) { |
| 477 | $out['background-image'] = $image; |
| 478 | } |
| 479 | if ($repeat !== null) { |
| 480 | $out['background-repeat'] = $repeat; |
| 481 | } |
| 482 | if ($position !== null) { |
| 483 | $out['background-position'] = $position; |
| 484 | } |
| 485 | if ($size !== null) { |
| 486 | $out['background-size'] = $size; |
| 487 | } |
| 488 | return $out; |
| 489 | } |
| 490 | |
| 491 | /** |
| 492 | * `list-style: <list-style-type> || <list-style-position> || |
| 493 | * <list-style-image>` (CSS Lists 3 §1.4). Free order; the `none` |
| 494 | * keyword is genuinely ambiguous between type and image — per spec we |
| 495 | * apply it to whichever side hasn't been set yet, defaulting to type |
| 496 | * when both are still free. |
| 497 | * |
| 498 | * @return array<string, Value> |
| 499 | */ |
| 500 | private function expandListStyle(Value $value): array |
| 501 | { |
| 502 | $type = null; |
| 503 | $position = null; |
| 504 | $image = null; |
| 505 | $components = $this->toComponents($value); |
| 506 | |
| 507 | $typeKeywords = [ |
| 508 | 'disc', 'circle', 'square', 'decimal', 'decimal-leading-zero', |
| 509 | 'lower-alpha', 'upper-alpha', 'lower-roman', 'upper-roman', |
| 510 | 'lower-greek', 'lower-latin', 'upper-latin', 'armenian', 'georgian', |
| 511 | 'hebrew', 'cjk-decimal', 'simp-chinese-formal', 'simp-chinese-informal', |
| 512 | 'trad-chinese-formal', 'trad-chinese-informal', |
| 513 | ]; |
| 514 | |
| 515 | foreach ($components as $c) { |
| 516 | if ($c instanceof \Phpdftk\Css\Value\Url) { |
| 517 | $image = $c; |
| 518 | continue; |
| 519 | } |
| 520 | if (!$c instanceof Keyword) { |
| 521 | continue; |
| 522 | } |
| 523 | $lower = strtolower($c->name); |
| 524 | if ($lower === 'inside' || $lower === 'outside') { |
| 525 | $position = $c; |
| 526 | continue; |
| 527 | } |
| 528 | if ($lower === 'none') { |
| 529 | if ($type === null) { |
| 530 | $type = $c; |
| 531 | } elseif ($image === null) { |
| 532 | $image = $c; |
| 533 | } |
| 534 | continue; |
| 535 | } |
| 536 | if (in_array($lower, $typeKeywords, true)) { |
| 537 | $type = $c; |
| 538 | } |
| 539 | } |
| 540 | |
| 541 | $out = []; |
| 542 | if ($type !== null) { |
| 543 | $out['list-style-type'] = $type; |
| 544 | } |
| 545 | if ($position !== null) { |
| 546 | $out['list-style-position'] = $position; |
| 547 | } |
| 548 | if ($image !== null) { |
| 549 | $out['list-style-image'] = $image; |
| 550 | } |
| 551 | return $out; |
| 552 | } |
| 553 | |
| 554 | /** |
| 555 | * `text-decoration: <line> || <style> || <color>` (CSS Text Decoration 3 |
| 556 | * §2). `<line>` itself can be a space-list of `underline` / `overline` / |
| 557 | * `line-through` / `blink`, or `none`. Order is free. |
| 558 | * |
| 559 | * @return array<string, Value> |
| 560 | */ |
| 561 | private function expandTextDecoration(Value $value): array |
| 562 | { |
| 563 | $components = $this->toComponents($value); |
| 564 | $lineParts = []; |
| 565 | $style = null; |
| 566 | $color = null; |
| 567 | foreach ($components as $c) { |
| 568 | if ($c instanceof Keyword) { |
| 569 | $lower = strtolower($c->name); |
| 570 | if (in_array($lower, ['underline', 'overline', 'line-through', 'blink', 'none'], true)) { |
| 571 | $lineParts[] = $c; |
| 572 | continue; |
| 573 | } |
| 574 | if (in_array($lower, ['solid', 'double', 'dotted', 'dashed', 'wavy'], true)) { |
| 575 | $style = $c; |
| 576 | continue; |
| 577 | } |
| 578 | } |
| 579 | if ($this->looksLikeColor($c)) { |
| 580 | $color = $c; |
| 581 | } |
| 582 | } |
| 583 | $out = []; |
| 584 | if ($lineParts !== []) { |
| 585 | $out['text-decoration-line'] = count($lineParts) === 1 |
| 586 | ? $lineParts[0] |
| 587 | : new ValueList($lineParts, ListSeparator::Space); |
| 588 | } |
| 589 | if ($style !== null) { |
| 590 | $out['text-decoration-style'] = $style; |
| 591 | } |
| 592 | if ($color !== null) { |
| 593 | $out['text-decoration-color'] = $color; |
| 594 | } |
| 595 | return $out; |
| 596 | } |
| 597 | |
| 598 | /** |
| 599 | * `flex: <flex-grow> <flex-shrink>? <flex-basis>?` (CSS Flex 1 |
| 600 | * §7.2). Common forms: |
| 601 | * - `flex: <number>` → grow with shrink=1, basis=0%. |
| 602 | * - `flex: <number> <number>` → grow + shrink, basis=0%. |
| 603 | * - `flex: <number> <number> <length>` → all three explicit. |
| 604 | * - `flex: none` → 0 0 auto. |
| 605 | * - `flex: auto` → 1 1 auto. |
| 606 | * - `flex: initial` → 0 1 auto (the spec initial). |
| 607 | * |
| 608 | * @return array<string, Value> |
| 609 | */ |
| 610 | private function expandFlex(Value $value): array |
| 611 | { |
| 612 | if ($value instanceof Keyword) { |
| 613 | return match (strtolower($value->name)) { |
| 614 | 'none' => [ |
| 615 | 'flex-grow' => new Number(0), |
| 616 | 'flex-shrink' => new Number(0), |
| 617 | 'flex-basis' => new Keyword('auto'), |
| 618 | ], |
| 619 | 'auto' => [ |
| 620 | 'flex-grow' => new Number(1), |
| 621 | 'flex-shrink' => new Number(1), |
| 622 | 'flex-basis' => new Keyword('auto'), |
| 623 | ], |
| 624 | 'initial' => [ |
| 625 | 'flex-grow' => new Number(0), |
| 626 | 'flex-shrink' => new Number(1), |
| 627 | 'flex-basis' => new Keyword('auto'), |
| 628 | ], |
| 629 | default => [], |
| 630 | }; |
| 631 | } |
| 632 | $components = $this->toComponents($value); |
| 633 | $grow = null; |
| 634 | $shrink = null; |
| 635 | $basis = null; |
| 636 | $numericCount = 0; |
| 637 | foreach ($components as $c) { |
| 638 | if (($c instanceof Number || $c instanceof Integer) && $numericCount < 2) { |
| 639 | if ($numericCount === 0) { |
| 640 | $grow = new Number($c instanceof Number ? $c->value : (float) $c->value); |
| 641 | } else { |
| 642 | $shrink = new Number($c instanceof Number ? $c->value : (float) $c->value); |
| 643 | } |
| 644 | $numericCount++; |
| 645 | } elseif ($basis === null) { |
| 646 | $basis = $c; |
| 647 | } |
| 648 | } |
| 649 | $out = []; |
| 650 | if ($grow !== null) { |
| 651 | $out['flex-grow'] = $grow; |
| 652 | } |
| 653 | if ($shrink !== null) { |
| 654 | $out['flex-shrink'] = $shrink; |
| 655 | } |
| 656 | if ($basis !== null) { |
| 657 | $out['flex-basis'] = $basis; |
| 658 | } |
| 659 | // Per spec, omitted basis defaults to 0% when grow is set |
| 660 | // (so `flex: 2` → grow:2, shrink:1, basis:0%). Use Length 0 |
| 661 | // as the closest approximation. |
| 662 | if ($grow !== null && $basis === null) { |
| 663 | $out['flex-basis'] = new \Phpdftk\Css\Value\Length(0.0, \Phpdftk\Css\Value\LengthUnit::Px); |
| 664 | } |
| 665 | // Omitted shrink defaults to 1. |
| 666 | if ($grow !== null && $shrink === null) { |
| 667 | $out['flex-shrink'] = new Number(1.0); |
| 668 | } |
| 669 | return $out; |
| 670 | } |
| 671 | |
| 672 | /** |
| 673 | * `flex-flow: <flex-direction> || <flex-wrap>` (CSS Flex 1 §6.2). |
| 674 | * |
| 675 | * @return array<string, Value> |
| 676 | */ |
| 677 | private function expandFlexFlow(Value $value): array |
| 678 | { |
| 679 | $components = $this->toComponents($value); |
| 680 | $directions = ['row', 'row-reverse', 'column', 'column-reverse']; |
| 681 | $wraps = ['nowrap', 'wrap', 'wrap-reverse']; |
| 682 | $out = []; |
| 683 | foreach ($components as $c) { |
| 684 | if (!($c instanceof Keyword)) { |
| 685 | continue; |
| 686 | } |
| 687 | $name = strtolower($c->name); |
| 688 | if (in_array($name, $directions, true) && !isset($out['flex-direction'])) { |
| 689 | $out['flex-direction'] = $c; |
| 690 | } elseif (in_array($name, $wraps, true) && !isset($out['flex-wrap'])) { |
| 691 | $out['flex-wrap'] = $c; |
| 692 | } |
| 693 | } |
| 694 | return $out; |
| 695 | } |
| 696 | |
| 697 | /** |
| 698 | * `overflow: <visible|hidden|clip|scroll|auto>{1,2}` (CSS |
| 699 | * Overflow 3 §3.2). Single value applies to both axes; |
| 700 | * two values are `overflow-x overflow-y`. Also keeps the legacy |
| 701 | * `overflow` longhand so existing painter code keeps reading |
| 702 | * the un-prefixed value. |
| 703 | * |
| 704 | * @return array<string, Value> |
| 705 | */ |
| 706 | private function expandOverflow(Value $value): array |
| 707 | { |
| 708 | $components = $this->toComponents($value); |
| 709 | if ($components === []) { |
| 710 | return []; |
| 711 | } |
| 712 | [$x, $y] = count($components) === 1 |
| 713 | ? [$components[0], $components[0]] |
| 714 | : [$components[0], $components[1]]; |
| 715 | return [ |
| 716 | 'overflow-x' => $x, |
| 717 | 'overflow-y' => $y, |
| 718 | // Keep the legacy direct property in sync so any reader |
| 719 | // that still reaches for `overflow` sees a sensible value |
| 720 | // (the X axis for asymmetric splits). |
| 721 | 'overflow' => $x, |
| 722 | ]; |
| 723 | } |
| 724 | |
| 725 | /** |
| 726 | * `inset: <length> [<length>{1,3}]?` (CSS Position 3 §3.3). |
| 727 | * Shorthand for `top` / `right` / `bottom` / `left` using the |
| 728 | * standard 1-to-4-value clockwise pattern (TRBL). |
| 729 | * |
| 730 | * @return array<string, Value> |
| 731 | */ |
| 732 | private function expandInset(Value $value): array |
| 733 | { |
| 734 | $components = $this->toComponents($value); |
| 735 | $count = count($components); |
| 736 | if ($count === 0) { |
| 737 | return []; |
| 738 | } |
| 739 | [$top, $right, $bottom, $left] = match ($count) { |
| 740 | 1 => [$components[0], $components[0], $components[0], $components[0]], |
| 741 | 2 => [$components[0], $components[1], $components[0], $components[1]], |
| 742 | 3 => [$components[0], $components[1], $components[2], $components[1]], |
| 743 | default => [$components[0], $components[1], $components[2], $components[3]], |
| 744 | }; |
| 745 | return [ |
| 746 | 'top' => $top, |
| 747 | 'right' => $right, |
| 748 | 'bottom' => $bottom, |
| 749 | 'left' => $left, |
| 750 | ]; |
| 751 | } |
| 752 | |
| 753 | /** |
| 754 | * `gap: <row-gap> [<column-gap>]?` (CSS Box Alignment 3 §8.3). |
| 755 | * One value: applies to both axes; two values: row first, column |
| 756 | * second. |
| 757 | * |
| 758 | * @return array<string, Value> |
| 759 | */ |
| 760 | private function expandGap(Value $value): array |
| 761 | { |
| 762 | $components = $this->toComponents($value); |
| 763 | if ($components === []) { |
| 764 | return []; |
| 765 | } |
| 766 | [$row, $col] = match (count($components)) { |
| 767 | 1 => [$components[0], $components[0]], |
| 768 | default => [$components[0], $components[1]], |
| 769 | }; |
| 770 | return [ |
| 771 | 'row-gap' => $row, |
| 772 | 'column-gap' => $col, |
| 773 | ]; |
| 774 | } |
| 775 | |
| 776 | /** |
| 777 | * `columns: <'column-width'> || <'column-count'>` (CSS Multi-column 1 |
| 778 | * §10.1). Either side may be omitted; `auto` may appear as either side |
| 779 | * and is assigned to whichever slot is still free (width first). |
| 780 | * |
| 781 | * @return array<string, Value> |
| 782 | */ |
| 783 | private function expandColumns(Value $value): array |
| 784 | { |
| 785 | $components = $this->toComponents($value); |
| 786 | $width = null; |
| 787 | $count = null; |
| 788 | foreach ($components as $c) { |
| 789 | if ($count === null && ($c instanceof Integer |
| 790 | || ($c instanceof Number && floor($c->value) === $c->value)) |
| 791 | ) { |
| 792 | $count = $c; |
| 793 | continue; |
| 794 | } |
| 795 | if ($width === null && ($c instanceof Length || $c instanceof Percentage)) { |
| 796 | $width = $c; |
| 797 | continue; |
| 798 | } |
| 799 | if ($c instanceof Keyword && strtolower($c->name) === 'auto') { |
| 800 | if ($width === null) { |
| 801 | $width = $c; |
| 802 | } elseif ($count === null) { |
| 803 | $count = $c; |
| 804 | } |
| 805 | } |
| 806 | } |
| 807 | $out = []; |
| 808 | if ($width !== null) { |
| 809 | $out['column-width'] = $width; |
| 810 | } |
| 811 | if ($count !== null) { |
| 812 | $out['column-count'] = $count; |
| 813 | } |
| 814 | return $out; |
| 815 | } |
| 816 | |
| 817 | /** |
| 818 | * `column-rule: <'column-rule-width'> || <'column-rule-style'> || |
| 819 | * <'column-rule-color'>` (CSS Multi-column 1 §3.2). Free order; reuses |
| 820 | * the border-width / border-style / color classifiers because the value |
| 821 | * grammars match. |
| 822 | * |
| 823 | * @return array<string, Value> |
| 824 | */ |
| 825 | private function expandColumnRule(Value $value): array |
| 826 | { |
| 827 | $components = $this->toComponents($value); |
| 828 | $width = null; |
| 829 | $style = null; |
| 830 | $color = null; |
| 831 | foreach ($components as $c) { |
| 832 | if ($style === null && $this->looksLikeBorderStyle($c)) { |
| 833 | $style = $c; |
| 834 | continue; |
| 835 | } |
| 836 | if ($width === null && $this->looksLikeBorderWidth($c)) { |
| 837 | $width = $c; |
| 838 | continue; |
| 839 | } |
| 840 | if ($color === null && $this->looksLikeColor($c)) { |
| 841 | $color = $c; |
| 842 | } |
| 843 | } |
| 844 | $out = []; |
| 845 | if ($width !== null) { |
| 846 | $out['column-rule-width'] = $width; |
| 847 | } |
| 848 | if ($style !== null) { |
| 849 | $out['column-rule-style'] = $style; |
| 850 | } |
| 851 | if ($color !== null) { |
| 852 | $out['column-rule-color'] = $color; |
| 853 | } |
| 854 | return $out; |
| 855 | } |
| 856 | |
| 857 | private function looksLikeFontSize(Value $v): bool |
| 858 | { |
| 859 | if ($v instanceof Length || $v instanceof Percentage) { |
| 860 | return true; |
| 861 | } |
| 862 | if ($v instanceof Keyword) { |
| 863 | return in_array(strtolower($v->name), [ |
| 864 | 'xx-small', 'x-small', 'small', 'medium', 'large', 'x-large', 'xx-large', |
| 865 | 'larger', 'smaller', |
| 866 | ], true); |
| 867 | } |
| 868 | return false; |
| 869 | } |
| 870 | } |