Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.13% covered (success)
91.13%
185 / 203
77.78% covered (warning)
77.78%
14 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
BasicShapePath
91.13% covered (success)
91.13%
185 / 203
77.78% covered (warning)
77.78%
14 / 18
84.46
0.00% covered (danger)
0.00%
0 / 1
 build
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
9
 inset
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
6
 cornerRadii
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
12
 expandCorners
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
6.40
 roundedRectangle
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 circle
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 ellipse
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 polygon
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 rect
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 xywh
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 rectangle
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 ellipseOutline
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 circleRadius
81.25% covered (warning)
81.25%
13 / 16
0.00% covered (danger)
0.00%
0 / 1
9.53
 axisRadius
27.27% covered (danger)
27.27%
3 / 11
0.00% covered (danger)
0.00%
0 / 1
25.85
 position
50.00% covered (danger)
50.00%
5 / 10
0.00% covered (danger)
0.00%
0 / 1
19.12
 edge
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 lengthPercent
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 calc
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Css\Shape;
6
7use Phpdftk\Css\Cascade\CalcEvaluator;
8use Phpdftk\Css\Cascade\LengthContext;
9use Phpdftk\Css\Value\BasicShape;
10use Phpdftk\Css\Value\Calc;
11use Phpdftk\Css\Value\CircleShape;
12use Phpdftk\Css\Value\EllipseShape;
13use Phpdftk\Css\Value\InsetShape;
14use Phpdftk\Css\Value\Keyword;
15use Phpdftk\Css\Value\Length;
16use Phpdftk\Css\Value\Percentage;
17use Phpdftk\Css\Value\PolygonShape;
18use Phpdftk\Css\Value\RectShape;
19use Phpdftk\Css\Value\Value;
20use Phpdftk\Css\Value\XywhShape;
21
22/**
23 * CSS Shapes 1 §3 / CSS Masking 1 §6 — resolve a `<basic-shape>` against a
24 * reference box into a flat outline.
25 *
26 * The result is pure geometry: absolute coordinates in the reference box's
27 * own space, with x running right and **y running down** (the CSS
28 * convention). Renderers map that into their own space — the HTML painter
29 * flips y against the page height, the SVG translator hands it straight
30 * through because its base matrix already carries the flip.
31 *
32 * Curves are pre-flattened to cubic Béziers so a consumer only has to
33 * understand four commands:
34 *
35 *   - `['M', x, y]`                          move to
36 *   - `['L', x, y]`                          line to
37 *   - `['C', x1, y1, x2, y2, x, y]`          cubic curve to
38 *   - `['Z']`                                close the subpath
39 */
40final class BasicShapePath
41{
42    /** Bézier circle constant: 4/3 · (√2 − 1). */
43    private const KAPPA = 0.5522847498307933;
44
45    /**
46     * Build the outline of `$shape` inside the reference box
47     * `($x, $y, $w, $h)`. Returns null when the value is not a basic
48     * shape this resolver models, or the shape is degenerate (a polygon
49     * with fewer than three vertices) — callers treat that as "no shape"
50     * and skip clipping rather than clipping everything away.
51     *
52     * @return array{fillRule: 'nonzero'|'evenodd', commands: list<list<string|float>>}|null
53     */
54    public static function build(Value $shape, float $x, float $y, float $w, float $h): ?array
55    {
56        if (!$shape instanceof BasicShape) {
57            return null;
58        }
59        return match (true) {
60            $shape instanceof InsetShape => self::inset($shape, $x, $y, $w, $h),
61            $shape instanceof CircleShape => self::circle($shape, $x, $y, $w, $h),
62            $shape instanceof EllipseShape => self::ellipse($shape, $x, $y, $w, $h),
63            $shape instanceof PolygonShape => self::polygon($shape, $x, $y, $w, $h),
64            $shape instanceof RectShape => self::rect($shape, $x, $y, $w, $h),
65            $shape instanceof XywhShape => self::xywh($shape, $x, $y, $w, $h),
66            default => null,
67        };
68    }
69
70    /**
71     * CSS Shapes 1 §3.1 — `inset()` insets each edge of the reference box.
72     * The 1–4 value form expands like the `margin` shorthand.
73     *
74     * @return array{fillRule: 'nonzero'|'evenodd', commands: list<list<string|float>>}
75     */
76    private static function inset(InsetShape $shape, float $x, float $y, float $w, float $h): array
77    {
78        $ins = $shape->insets;
79        $n = count($ins);
80        $top = self::lengthPercent($ins[0], $h);
81        $right = self::lengthPercent($ins[$n >= 2 ? 1 : 0], $w);
82        $bottom = self::lengthPercent($ins[$n >= 3 ? 2 : 0], $h);
83        $left = self::lengthPercent($ins[$n >= 4 ? 3 : ($n >= 2 ? 1 : 0)], $w);
84        $insetX = $x + $left;
85        $insetY = $y + $top;
86        $insetW = max(0.0, $w - $left - $right);
87        $insetH = max(0.0, $h - $top - $bottom);
88        // `round <border-radius>` rounds the corners of the inset
89        // rectangle. Percentages in the radius resolve against the
90        // REFERENCE BOX, not the inset rectangle — WPT's
91        // css-masking/clip-path-shape-inset-001 pins that: on a 200x200
92        // box `inset(10% round 10%)` is a radius of 20, not of 16.
93        $radii = self::cornerRadii(
94            $shape->borderRadius,
95            $shape->borderRadiusVertical,
96            $w,
97            $h,
98            $insetW,
99            $insetH,
100        );
101        if ($radii === null) {
102            return self::rectangle($insetX, $insetY, $insetW, $insetH);
103        }
104        return self::roundedRectangle($insetX, $insetY, $insetW, $insetH, $radii);
105    }
106
107    /**
108     * The four corner radii of an `inset()`'s `round` clause, as
109     * `[[rx, ry], …]` in TL, TR, BR, BL order — or null when the shape
110     * has no `round` clause or every radius is zero, so the caller can
111     * keep emitting a plain rectangle.
112     *
113     * Values expand like the `border-radius` shorthand (1-4 values,
114     * TL / TR / BR / BL with the usual mirroring). `$vertical` is the
115     * second half of the `a / b` two-axis form; when it is null each
116     * horizontal value is used for both axes.
117     *
118     * CSS Backgrounds 3 §5.5 — when the radii of a side overrun it,
119     * ALL radii scale by the same factor so the corners just touch.
120     * Without that the corner curves cross over and the clip region
121     * turns inside out.
122     *
123     * @param ?list<Value> $borderRadius
124     * @param ?list<Value> $vertical
125     * @return list<array{float, float}>|null
126     */
127    private static function cornerRadii(
128        ?array $borderRadius,
129        ?array $vertical,
130        float $boxW,
131        float $boxH,
132        float $insetW,
133        float $insetH,
134    ): ?array {
135        if ($borderRadius === null || $borderRadius === []) {
136            return null;
137        }
138        $horizontal = self::expandCorners($borderRadius);
139        $verticalValues = $vertical === null || $vertical === []
140            ? $horizontal
141            : self::expandCorners($vertical);
142        $radii = [];
143        foreach ($horizontal as $corner => $value) {
144            $radii[] = [
145                max(0.0, self::lengthPercent($value, $boxW)),
146                max(0.0, self::lengthPercent($verticalValues[$corner], $boxH)),
147            ];
148        }
149        $maxRadius = 0.0;
150        foreach ($radii as [$rx, $ry]) {
151            $maxRadius = max($maxRadius, $rx, $ry);
152        }
153        if ($maxRadius <= 0.0) {
154            return null;
155        }
156        $scale = 1.0;
157        foreach (
158            [
159                [$insetW, $radii[0][0] + $radii[1][0]],
160                [$insetW, $radii[3][0] + $radii[2][0]],
161                [$insetH, $radii[0][1] + $radii[3][1]],
162                [$insetH, $radii[1][1] + $radii[2][1]],
163            ] as [$side, $sum]
164        ) {
165            if ($sum > 0.0) {
166                $scale = min($scale, $side / $sum);
167            }
168        }
169        if ($scale < 1.0) {
170            foreach ($radii as $i => [$rx, $ry]) {
171                $radii[$i] = [$rx * $scale, $ry * $scale];
172            }
173        }
174        return $radii;
175    }
176
177    /**
178     * Expand a 1-4 value `border-radius` group to the four corners in
179     * TL, TR, BR, BL order, with the shorthand's usual mirroring.
180     *
181     * @param list<Value> $values
182     * @return list<Value>
183     */
184    private static function expandCorners(array $values): array
185    {
186        $n = count($values);
187        $order = match (true) {
188            $n === 1 => [0, 0, 0, 0],
189            $n === 2 => [0, 1, 0, 1],
190            $n === 3 => [0, 1, 2, 1],
191            default => [0, 1, 2, 3],
192        };
193        $out = [];
194        foreach ($order as $index) {
195            $out[] = $values[$index] ?? $values[0];
196        }
197        return $out;
198    }
199
200    /**
201     * A rectangle with per-corner elliptical rounding, as a path.
202     * Corner arcs use the same `KAPPA` cubic approximation the
203     * ellipse outline uses.
204     *
205     * @param list<array{float, float}> $radii TL, TR, BR, BL
206     * @return array{fillRule: 'nonzero'|'evenodd', commands: list<list<string|float>>}
207     */
208    private static function roundedRectangle(
209        float $x,
210        float $y,
211        float $w,
212        float $h,
213        array $radii,
214    ): array {
215        [[$tlx, $tly], [$trx, $try], [$brx, $bry], [$blx, $bly]] = $radii;
216        $right = $x + $w;
217        $bottom = $y + $h;
218        $k = self::KAPPA;
219        return [
220            'fillRule' => 'nonzero',
221            'commands' => [
222                ['M', $x + $tlx, $y],
223                ['L', $right - $trx, $y],
224                ['C', $right - $trx + $trx * $k, $y, $right, $y + $try - $try * $k, $right, $y + $try],
225                ['L', $right, $bottom - $bry],
226                ['C', $right, $bottom - $bry + $bry * $k, $right - $brx + $brx * $k, $bottom, $right - $brx, $bottom],
227                ['L', $x + $blx, $bottom],
228                ['C', $x + $blx - $blx * $k, $bottom, $x, $bottom - $bly + $bly * $k, $x, $bottom - $bly],
229                ['L', $x, $y + $tly],
230                ['C', $x, $y + $tly - $tly * $k, $x + $tlx - $tlx * $k, $y, $x + $tlx, $y],
231                ['Z'],
232            ],
233        ];
234    }
235
236    /**
237     * CSS Shapes 1 §3.2 — `circle(<radius> at <position>)`.
238     *
239     * @return array{fillRule: 'nonzero'|'evenodd', commands: list<list<string|float>>}
240     */
241    private static function circle(CircleShape $shape, float $x, float $y, float $w, float $h): array
242    {
243        $cx = self::position($shape->centerX, $w);
244        $cy = self::position($shape->centerY, $h);
245        $r = self::circleRadius($shape->radius, $cx, $cy, $w, $h);
246        return self::ellipseOutline($x + $cx, $y + $cy, $r, $r);
247    }
248
249    /**
250     * CSS Shapes 1 §3.3 — `ellipse(<rx> <ry> at <position>)`.
251     *
252     * @return array{fillRule: 'nonzero'|'evenodd', commands: list<list<string|float>>}
253     */
254    private static function ellipse(EllipseShape $shape, float $x, float $y, float $w, float $h): array
255    {
256        $cx = self::position($shape->centerX, $w);
257        $cy = self::position($shape->centerY, $h);
258        return self::ellipseOutline(
259            $x + $cx,
260            $y + $cy,
261            self::axisRadius($shape->radiusX, $cx, $w),
262            self::axisRadius($shape->radiusY, $cy, $h),
263        );
264    }
265
266    /**
267     * CSS Shapes 1 §3.4 — `polygon(<fill-rule>?, <vertex>#)`. Fewer than
268     * three vertices cannot enclose an area, so the shape is dropped.
269     *
270     * @return array{fillRule: 'nonzero'|'evenodd', commands: list<list<string|float>>}|null
271     */
272    private static function polygon(PolygonShape $shape, float $x, float $y, float $w, float $h): ?array
273    {
274        if (count($shape->vertices) < 3) {
275            return null;
276        }
277        $commands = [];
278        foreach ($shape->vertices as $i => [$vx, $vy]) {
279            $commands[] = [
280                $i === 0 ? 'M' : 'L',
281                $x + self::lengthPercent($vx, $w),
282                $y + self::lengthPercent($vy, $h),
283            ];
284        }
285        $commands[] = ['Z'];
286        return [
287            'fillRule' => strtolower($shape->fillRule) === 'evenodd' ? 'evenodd' : 'nonzero',
288            'commands' => $commands,
289        ];
290    }
291
292    /**
293     * CSS Shapes 2 §4.5 — `rect(top right bottom left)`: each value is an
294     * edge POSITION measured from the reference box's origin (top / bottom
295     * against its height, left / right against its width). `auto` resolves
296     * to the matching box edge.
297     *
298     * @return array{fillRule: 'nonzero'|'evenodd', commands: list<list<string|float>>}
299     */
300    private static function rect(RectShape $shape, float $x, float $y, float $w, float $h): array
301    {
302        $e = $shape->edges;
303        $top = self::edge($e[0] ?? null, $h, 0.0);
304        $right = self::edge($e[1] ?? null, $w, $w);
305        $bottom = self::edge($e[2] ?? null, $h, $h);
306        $left = self::edge($e[3] ?? null, $w, 0.0);
307        return self::rectangle(
308            $x + $left,
309            $y + $top,
310            max(0.0, $right - $left),
311            max(0.0, $bottom - $top),
312        );
313    }
314
315    /**
316     * CSS Shapes 2 §4.6 — `xywh(x y width height)`: origin plus size, x and
317     * width against the reference box width, y and height against its height.
318     *
319     * @return array{fillRule: 'nonzero'|'evenodd', commands: list<list<string|float>>}
320     */
321    private static function xywh(XywhShape $shape, float $x, float $y, float $w, float $h): array
322    {
323        return self::rectangle(
324            $x + self::lengthPercent($shape->x, $w),
325            $y + self::lengthPercent($shape->y, $h),
326            self::lengthPercent($shape->width, $w),
327            self::lengthPercent($shape->height, $h),
328        );
329    }
330
331    /**
332     * @return array{fillRule: 'nonzero'|'evenodd', commands: list<list<string|float>>}
333     */
334    private static function rectangle(float $x, float $y, float $w, float $h): array
335    {
336        return [
337            'fillRule' => 'nonzero',
338            'commands' => [
339                ['M', $x, $y],
340                ['L', $x + $w, $y],
341                ['L', $x + $w, $y + $h],
342                ['L', $x, $y + $h],
343                ['Z'],
344            ],
345        ];
346    }
347
348    /**
349     * Four-Bézier approximation of the ellipse with radii (rx, ry) centred
350     * at (cx, cy). Wound clockwise in a y-down space.
351     *
352     * @return array{fillRule: 'nonzero'|'evenodd', commands: list<list<string|float>>}
353     */
354    private static function ellipseOutline(float $cx, float $cy, float $rx, float $ry): array
355    {
356        $kx = $rx * self::KAPPA;
357        $ky = $ry * self::KAPPA;
358        return [
359            'fillRule' => 'nonzero',
360            'commands' => [
361                ['M', $cx + $rx, $cy],
362                ['C', $cx + $rx, $cy + $ky, $cx + $kx, $cy + $ry, $cx, $cy + $ry],
363                ['C', $cx - $kx, $cy + $ry, $cx - $rx, $cy + $ky, $cx - $rx, $cy],
364                ['C', $cx - $rx, $cy - $ky, $cx - $kx, $cy - $ry, $cx, $cy - $ry],
365                ['C', $cx + $kx, $cy - $ry, $cx + $rx, $cy - $ky, $cx + $rx, $cy],
366                ['Z'],
367            ],
368        ];
369    }
370
371    /**
372     * CSS Shapes 1 §3.2 — a `circle()` radius. A percentage resolves
373     * against the reference box's diagonal / √2; the extent keywords
374     * measure from the shape's centre.
375     */
376    private static function circleRadius(?Value $value, float $cx, float $cy, float $w, float $h): float
377    {
378        if ($value instanceof Length) {
379            return $value->value;
380        }
381        if ($value instanceof Percentage) {
382            return $value->value / 100.0 * (sqrt($w * $w + $h * $h) / M_SQRT2);
383        }
384        if ($value instanceof Calc) {
385            return self::calc($value, sqrt($w * $w + $h * $h) / M_SQRT2);
386        }
387        $sides = [$cx, $w - $cx, $cy, $h - $cy];
388        $corners = [
389            hypot($cx, $cy), hypot($w - $cx, $cy),
390            hypot($cx, $h - $cy), hypot($w - $cx, $h - $cy),
391        ];
392        return match ($value instanceof Keyword ? strtolower($value->name) : 'closest-side') {
393            'farthest-side' => max($sides),
394            'closest-corner' => min($corners),
395            'farthest-corner' => max($corners),
396            default => min($sides),
397        };
398    }
399
400    /**
401     * One `ellipse()` radius. `$center` is the centre offset along this
402     * axis and `$extent` the reference box extent along it; percentages
403     * resolve against `$extent` (not the diagonal, unlike `circle()`).
404     */
405    private static function axisRadius(?Value $value, float $center, float $extent): float
406    {
407        if ($value instanceof Length) {
408            return $value->value;
409        }
410        if ($value instanceof Percentage) {
411            return $value->value / 100.0 * $extent;
412        }
413        if ($value instanceof Calc) {
414            return self::calc($value, $extent);
415        }
416        $near = min($center, $extent - $center);
417        $far = max($center, $extent - $center);
418        return match ($value instanceof Keyword ? strtolower($value->name) : 'closest-side') {
419            'farthest-side' => $far,
420            default => $near,
421        };
422    }
423
424    /**
425     * A `<position>` component. `null` centres the shape; the edge
426     * keywords map to 0 / 50% / 100% of `$basis`.
427     */
428    private static function position(?Value $value, float $basis): float
429    {
430        if ($value === null) {
431            return $basis * 0.5;
432        }
433        if ($value instanceof Keyword) {
434            return match (strtolower($value->name)) {
435                'left', 'top' => 0.0,
436                'right', 'bottom' => $basis,
437                default => $basis * 0.5,
438            };
439        }
440        if ($value instanceof Length || $value instanceof Percentage || $value instanceof Calc) {
441            return self::lengthPercent($value, $basis);
442        }
443        return $basis * 0.5;
444    }
445
446    /**
447     * One `rect()` edge as an absolute position. `auto` (or a value this
448     * resolver can't read) falls back to `$auto`, the matching box edge.
449     */
450    private static function edge(?Value $value, float $basis, float $auto): float
451    {
452        if ($value instanceof Length || $value instanceof Percentage || $value instanceof Calc) {
453            return self::lengthPercent($value, $basis);
454        }
455        return $auto;
456    }
457
458    /** `<length-percentage>` → px against `$basis`; anything else → 0. */
459    private static function lengthPercent(Value $value, float $basis): float
460    {
461        if ($value instanceof Length) {
462            return $value->value;
463        }
464        if ($value instanceof Percentage) {
465            return $value->value / 100.0 * $basis;
466        }
467        if ($value instanceof Calc) {
468            return self::calc($value, $basis);
469        }
470        return 0.0;
471    }
472
473    private static function calc(Calc $value, float $basis): float
474    {
475        $resolved = CalcEvaluator::evaluate($value, new LengthContext(percentageBasis: $basis));
476        return is_finite($resolved) ? $resolved : 0.0;
477    }
478}