Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.65% covered (success)
95.65%
264 / 276
68.00% covered (warning)
68.00%
17 / 25
CRAP
0.00% covered (danger)
0.00%
0 / 1
BoundingBox
95.65% covered (success)
95.65%
264 / 276
68.00% covered (warning)
68.00%
17 / 25
93
0.00% covered (danger)
0.00%
0 / 1
 compute
98.48% covered (success)
98.48%
65 / 66
0.00% covered (danger)
0.00%
0 / 1
19
 length
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 diagonal
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 grownByStroke
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 pathBoundingBox
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
14.04
 update
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
5
 result
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 clearCurveState
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 resolveAbsolute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 visitMoveTo
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 visitLineTo
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 visitHorizontalLineTo
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 visitVerticalLineTo
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 visitCurveTo
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 visitSmoothCurveTo
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
3.02
 visitQuadraticCurveTo
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 visitSmoothQuadraticCurveTo
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
3.03
 visitArcTo
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
2
 visitClosePath
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 visitCubicSegment
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 visitQuadraticSegment
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
3.18
 cubicExtremaT
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
9.02
 quadraticExtremumT
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 evalCubic
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 evalQuadratic
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\SvgToPdf\Geometry;
6
7use Phpdftk\Svg\Element;
8use Phpdftk\Svg\Path;
9use Phpdftk\Svg\Path\ArcTo;
10use Phpdftk\Svg\Path\ClosePath;
11use Phpdftk\Svg\Path\CurveTo;
12use Phpdftk\Svg\Path\HorizontalLineTo;
13use Phpdftk\Svg\Path\LineTo;
14use Phpdftk\Svg\Path\MoveTo;
15use Phpdftk\Svg\Path\QuadraticCurveTo;
16use Phpdftk\Svg\Path\SmoothCurveTo;
17use Phpdftk\Svg\Path\SmoothQuadraticCurveTo;
18use Phpdftk\Svg\Path\VerticalLineTo;
19use Phpdftk\Svg\Shape\Circle;
20use Phpdftk\Svg\Shape\Ellipse;
21use Phpdftk\Svg\Shape\Line;
22use Phpdftk\Svg\Shape\Polygon;
23use Phpdftk\Svg\Shape\Polyline;
24use Phpdftk\Svg\Shape\Rect;
25use Phpdftk\Svg\Value\Paint\None_;
26use Phpdftk\SvgToPdf\Path\ArcToCubic;
27
28/**
29 * Axis-aligned bounding box used by `objectBoundingBox`-mode gradients
30 * (SVG 2 §13.6.5), clip paths (§14.4), and masks (§14.5). Returns null
31 * for elements whose bbox can't be computed — the caller (gradient /
32 * clip / mask painter) falls back to no paint per SVG 2's "invalid →
33 * no paint" rule.
34 *
35 * `<path>` bbox handling tracks every command's contribution: line
36 * endpoints, cubic / quadratic Bézier endpoints **and** interior extrema
37 * (found by solving the derivative-equals-zero equation per axis), and
38 * arc segments routed through the same arc→cubic conversion the painter
39 * uses so the bbox is consistent with what gets drawn.
40 */
41final class BoundingBox
42{
43    /** Treat any value below this magnitude as zero. */
44    private const float EPSILON = 1.0e-12;
45
46    /**
47     * `$includeStroke` widens the result to the element's STROKE box
48     * (SVG 2 §7.5 / CSS Masking 1 §6's `stroke-box`): the stroke is
49     * centred on the geometry, so it reaches half its width beyond the
50     * fill box on every side.
51     *
52     * `$viewport` is the `{w, h}` a PERCENTAGE geometry attribute
53     * resolves against (SVG 2 §7.10). Without it the shape accessors
54     * strip the `%` and read `width="100%"` as a hundred user units,
55     * which silently made every `objectBoundingBox` gradient, pattern,
56     * mask and clip on a percentage-sized shape resolve against a box
57     * of the wrong size. Null keeps the accessor's reading, for
58     * callers that genuinely have no viewport to hand.
59     *
60     * @param array{w: float, h: float}|null $viewport
61     * @return array{minX: float, minY: float, width: float, height: float}|null
62     */
63    public static function compute(
64        Element $element,
65        bool $includeStroke = false,
66        ?array $viewport = null,
67    ): ?array {
68        if ($includeStroke) {
69            $box = self::compute($element, viewport: $viewport);
70            return $box === null ? null : self::grownByStroke($element, $box);
71        }
72        if ($element instanceof Rect) {
73            $w = self::length($element, 'width', $viewport, $viewport['w'] ?? 0.0, $element->width());
74            $h = self::length($element, 'height', $viewport, $viewport['h'] ?? 0.0, $element->height());
75            return $w <= 0.0 || $h <= 0.0
76                ? null
77                : [
78                    'minX' => self::length($element, 'x', $viewport, $viewport['w'] ?? 0.0, $element->x()),
79                    'minY' => self::length($element, 'y', $viewport, $viewport['h'] ?? 0.0, $element->y()),
80                    'width' => $w,
81                    'height' => $h,
82                ];
83        }
84        if ($element instanceof Circle) {
85            $r = self::length($element, 'r', $viewport, self::diagonal($viewport), $element->r());
86            if ($r <= 0.0) {
87                return null;
88            }
89            $cx = self::length($element, 'cx', $viewport, $viewport['w'] ?? 0.0, $element->cx());
90            $cy = self::length($element, 'cy', $viewport, $viewport['h'] ?? 0.0, $element->cy());
91            return [
92                'minX' => $cx - $r,
93                'minY' => $cy - $r,
94                'width' => 2.0 * $r,
95                'height' => 2.0 * $r,
96            ];
97        }
98        if ($element instanceof Ellipse) {
99            $rx = $element->rx();
100            $ry = $element->ry();
101            if ($rx === null || $ry === null) {
102                return null;
103            }
104            $rx = self::length($element, 'rx', $viewport, $viewport['w'] ?? 0.0, $rx);
105            $ry = self::length($element, 'ry', $viewport, $viewport['h'] ?? 0.0, $ry);
106            if ($rx <= 0.0 || $ry <= 0.0) {
107                return null;
108            }
109            $cx = self::length($element, 'cx', $viewport, $viewport['w'] ?? 0.0, $element->cx());
110            $cy = self::length($element, 'cy', $viewport, $viewport['h'] ?? 0.0, $element->cy());
111            return [
112                'minX' => $cx - $rx,
113                'minY' => $cy - $ry,
114                'width' => 2.0 * $rx,
115                'height' => 2.0 * $ry,
116            ];
117        }
118        if ($element instanceof Line) {
119            $minX = min($element->x1(), $element->x2());
120            $minY = min($element->y1(), $element->y2());
121            $maxX = max($element->x1(), $element->x2());
122            $maxY = max($element->y1(), $element->y2());
123            return ['minX' => $minX, 'minY' => $minY, 'width' => $maxX - $minX, 'height' => $maxY - $minY];
124        }
125        if ($element instanceof Polyline || $element instanceof Polygon) {
126            $points = $element->points();
127            if ($points === []) {
128                return null;
129            }
130            $minX = $points[0][0];
131            $minY = $points[0][1];
132            $maxX = $minX;
133            $maxY = $minY;
134            foreach ($points as $p) {
135                $minX = min($minX, $p[0]);
136                $minY = min($minY, $p[1]);
137                $maxX = max($maxX, $p[0]);
138                $maxY = max($maxY, $p[1]);
139            }
140            return ['minX' => $minX, 'minY' => $minY, 'width' => $maxX - $minX, 'height' => $maxY - $minY];
141        }
142        if ($element instanceof Path) {
143            return self::pathBoundingBox($element);
144        }
145        return null;
146    }
147
148    /**
149     * One geometry length, resolving a PERCENTAGE against `$basis`
150     * (SVG 2 §7.10) and otherwise deferring to `$fallback` — the value
151     * the shape's own accessor already computed, which handles absolute
152     * units and plain numbers.
153     *
154     * @param array{w: float, h: float}|null $viewport
155     */
156    private static function length(
157        Element $element,
158        string $property,
159        ?array $viewport,
160        float $basis,
161        float $fallback,
162    ): float {
163        if ($viewport === null) {
164            return $fallback;
165        }
166        $raw = $element->geometryValue($property);
167        if ($raw === null
168            || preg_match(
169                '/^\s*([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*%\s*$/',
170                $raw,
171                $m,
172            ) !== 1
173        ) {
174            return $fallback;
175        }
176        return (float) $m[1] / 100.0 * $basis;
177    }
178
179    /**
180     * SVG 2 §7.10's "normalized diagonal" — `sqrt(w^2 + h^2)/sqrt(2)`,
181     * the 1% basis for `r`, which is tied to neither axis.
182     *
183     * @param array{w: float, h: float}|null $viewport
184     */
185    private static function diagonal(?array $viewport): float
186    {
187        return $viewport === null
188            ? 0.0
189            : sqrt($viewport['w'] ** 2 + $viewport['h'] ** 2) / M_SQRT2;
190    }
191
192    /**
193     * Expand `$box` by half the element's stroke width on each side. An
194     * element with no stroke (or `stroke="none"`) is unchanged — its
195     * stroke box equals its fill box.
196     *
197     * @param array{minX: float, minY: float, width: float, height: float} $box
198     * @return array{minX: float, minY: float, width: float, height: float}
199     */
200    private static function grownByStroke(Element $element, array $box): array
201    {
202        $stroke = $element->stroke();
203        if ($stroke === null || $stroke instanceof None_) {
204            return $box;
205        }
206        $half = ($element->strokeWidth() ?? 1.0) / 2.0;
207        return [
208            'minX' => $box['minX'] - $half,
209            'minY' => $box['minY'] - $half,
210            'width' => $box['width'] + 2.0 * $half,
211            'height' => $box['height'] + 2.0 * $half,
212        ];
213    }
214
215    /**
216     * @return array{minX: float, minY: float, width: float, height: float}|null
217     */
218    private static function pathBoundingBox(Path $path): ?array
219    {
220        $commands = $path->d()->commands;
221        if ($commands === []) {
222            return null;
223        }
224
225        $tracker = new self();
226        foreach ($commands as $command) {
227            match (true) {
228                $command instanceof MoveTo => $tracker->visitMoveTo($command),
229                $command instanceof LineTo => $tracker->visitLineTo($command),
230                $command instanceof HorizontalLineTo => $tracker->visitHorizontalLineTo($command),
231                $command instanceof VerticalLineTo => $tracker->visitVerticalLineTo($command),
232                $command instanceof CurveTo => $tracker->visitCurveTo($command),
233                $command instanceof SmoothCurveTo => $tracker->visitSmoothCurveTo($command),
234                $command instanceof QuadraticCurveTo => $tracker->visitQuadraticCurveTo($command),
235                $command instanceof SmoothQuadraticCurveTo => $tracker->visitSmoothQuadraticCurveTo($command),
236                $command instanceof ArcTo => $tracker->visitArcTo($command),
237                $command instanceof ClosePath => $tracker->visitClosePath(),
238                default => null,
239            };
240        }
241        return $tracker->result();
242    }
243
244    private ?float $minX = null;
245    private ?float $maxX = null;
246    private ?float $minY = null;
247    private ?float $maxY = null;
248    private float $currentX = 0.0;
249    private float $currentY = 0.0;
250    private float $subpathStartX = 0.0;
251    private float $subpathStartY = 0.0;
252    private ?float $lastCubicCtrlX = null;
253    private ?float $lastCubicCtrlY = null;
254    private ?float $lastQuadCtrlX = null;
255    private ?float $lastQuadCtrlY = null;
256
257    private function update(float $x, float $y): void
258    {
259        $this->minX = $this->minX === null ? $x : min($this->minX, $x);
260        $this->maxX = $this->maxX === null ? $x : max($this->maxX, $x);
261        $this->minY = $this->minY === null ? $y : min($this->minY, $y);
262        $this->maxY = $this->maxY === null ? $y : max($this->maxY, $y);
263    }
264
265    /**
266     * @return array{minX: float, minY: float, width: float, height: float}|null
267     */
268    private function result(): ?array
269    {
270        if ($this->minX === null || $this->maxX === null || $this->minY === null || $this->maxY === null) {
271            return null;
272        }
273        return [
274            'minX' => $this->minX,
275            'minY' => $this->minY,
276            'width' => $this->maxX - $this->minX,
277            'height' => $this->maxY - $this->minY,
278        ];
279    }
280
281    private function clearCurveState(): void
282    {
283        $this->lastCubicCtrlX = null;
284        $this->lastCubicCtrlY = null;
285        $this->lastQuadCtrlX = null;
286        $this->lastQuadCtrlY = null;
287    }
288
289    /**
290     * @return array{float, float}
291     */
292    private function resolveAbsolute(float $x, float $y, bool $absolute): array
293    {
294        return $absolute ? [$x, $y] : [$this->currentX + $x, $this->currentY + $y];
295    }
296
297    private function visitMoveTo(MoveTo $cmd): void
298    {
299        [$x, $y] = $this->resolveAbsolute($cmd->x, $cmd->y, $cmd->absolute);
300        $this->update($x, $y);
301        $this->currentX = $x;
302        $this->currentY = $y;
303        $this->subpathStartX = $x;
304        $this->subpathStartY = $y;
305        $this->clearCurveState();
306    }
307
308    private function visitLineTo(LineTo $cmd): void
309    {
310        [$x, $y] = $this->resolveAbsolute($cmd->x, $cmd->y, $cmd->absolute);
311        $this->update($x, $y);
312        $this->currentX = $x;
313        $this->currentY = $y;
314        $this->clearCurveState();
315    }
316
317    private function visitHorizontalLineTo(HorizontalLineTo $cmd): void
318    {
319        $x = $cmd->absolute ? $cmd->x : $this->currentX + $cmd->x;
320        $this->update($x, $this->currentY);
321        $this->currentX = $x;
322        $this->clearCurveState();
323    }
324
325    private function visitVerticalLineTo(VerticalLineTo $cmd): void
326    {
327        $y = $cmd->absolute ? $cmd->y : $this->currentY + $cmd->y;
328        $this->update($this->currentX, $y);
329        $this->currentY = $y;
330        $this->clearCurveState();
331    }
332
333    private function visitCurveTo(CurveTo $cmd): void
334    {
335        [$x1, $y1] = $this->resolveAbsolute($cmd->x1, $cmd->y1, $cmd->absolute);
336        [$x2, $y2] = $this->resolveAbsolute($cmd->x2, $cmd->y2, $cmd->absolute);
337        [$x, $y] = $this->resolveAbsolute($cmd->x, $cmd->y, $cmd->absolute);
338        $this->visitCubicSegment($this->currentX, $this->currentY, $x1, $y1, $x2, $y2, $x, $y);
339        $this->currentX = $x;
340        $this->currentY = $y;
341        $this->lastCubicCtrlX = $x2;
342        $this->lastCubicCtrlY = $y2;
343        $this->lastQuadCtrlX = null;
344        $this->lastQuadCtrlY = null;
345    }
346
347    private function visitSmoothCurveTo(SmoothCurveTo $cmd): void
348    {
349        // The first control point is the reflection of the previous
350        // cubic's last control about the current point; falls back to
351        // the current point itself when the previous command wasn't
352        // cubic (matches PathPainterState's behaviour).
353        $x1 = $this->lastCubicCtrlX === null
354            ? $this->currentX
355            : 2.0 * $this->currentX - $this->lastCubicCtrlX;
356        $y1 = $this->lastCubicCtrlY === null
357            ? $this->currentY
358            : 2.0 * $this->currentY - $this->lastCubicCtrlY;
359        [$x2, $y2] = $this->resolveAbsolute($cmd->x2, $cmd->y2, $cmd->absolute);
360        [$x, $y] = $this->resolveAbsolute($cmd->x, $cmd->y, $cmd->absolute);
361        $this->visitCubicSegment($this->currentX, $this->currentY, $x1, $y1, $x2, $y2, $x, $y);
362        $this->currentX = $x;
363        $this->currentY = $y;
364        $this->lastCubicCtrlX = $x2;
365        $this->lastCubicCtrlY = $y2;
366        $this->lastQuadCtrlX = null;
367        $this->lastQuadCtrlY = null;
368    }
369
370    private function visitQuadraticCurveTo(QuadraticCurveTo $cmd): void
371    {
372        [$qx, $qy] = $this->resolveAbsolute($cmd->x1, $cmd->y1, $cmd->absolute);
373        [$x, $y] = $this->resolveAbsolute($cmd->x, $cmd->y, $cmd->absolute);
374        $this->visitQuadraticSegment($this->currentX, $this->currentY, $qx, $qy, $x, $y);
375        $this->currentX = $x;
376        $this->currentY = $y;
377        $this->lastQuadCtrlX = $qx;
378        $this->lastQuadCtrlY = $qy;
379        $this->lastCubicCtrlX = null;
380        $this->lastCubicCtrlY = null;
381    }
382
383    private function visitSmoothQuadraticCurveTo(SmoothQuadraticCurveTo $cmd): void
384    {
385        $qx = $this->lastQuadCtrlX === null
386            ? $this->currentX
387            : 2.0 * $this->currentX - $this->lastQuadCtrlX;
388        $qy = $this->lastQuadCtrlY === null
389            ? $this->currentY
390            : 2.0 * $this->currentY - $this->lastQuadCtrlY;
391        [$x, $y] = $this->resolveAbsolute($cmd->x, $cmd->y, $cmd->absolute);
392        $this->visitQuadraticSegment($this->currentX, $this->currentY, $qx, $qy, $x, $y);
393        $this->currentX = $x;
394        $this->currentY = $y;
395        $this->lastQuadCtrlX = $qx;
396        $this->lastQuadCtrlY = $qy;
397        $this->lastCubicCtrlX = null;
398        $this->lastCubicCtrlY = null;
399    }
400
401    private function visitArcTo(ArcTo $cmd): void
402    {
403        [$endX, $endY] = $this->resolveAbsolute($cmd->x, $cmd->y, $cmd->absolute);
404        // Update the endpoint regardless so a zero-length arc still
405        // affects the bbox via its endpoints.
406        $this->update($endX, $endY);
407        $segments = ArcToCubic::convert(
408            $this->currentX,
409            $this->currentY,
410            $cmd->rx,
411            $cmd->ry,
412            $cmd->xAxisRotation,
413            $cmd->largeArc,
414            $cmd->sweep,
415            $endX,
416            $endY,
417        );
418        $segStartX = $this->currentX;
419        $segStartY = $this->currentY;
420        foreach ($segments as $seg) {
421            $this->visitCubicSegment(
422                $segStartX,
423                $segStartY,
424                $seg['x1'],
425                $seg['y1'],
426                $seg['x2'],
427                $seg['y2'],
428                $seg['x'],
429                $seg['y'],
430            );
431            $segStartX = $seg['x'];
432            $segStartY = $seg['y'];
433        }
434        $this->currentX = $endX;
435        $this->currentY = $endY;
436        $this->clearCurveState();
437    }
438
439    private function visitClosePath(): void
440    {
441        $this->update($this->subpathStartX, $this->subpathStartY);
442        $this->currentX = $this->subpathStartX;
443        $this->currentY = $this->subpathStartY;
444        $this->clearCurveState();
445    }
446
447    private function visitCubicSegment(
448        float $p0x,
449        float $p0y,
450        float $p1x,
451        float $p1y,
452        float $p2x,
453        float $p2y,
454        float $p3x,
455        float $p3y,
456    ): void {
457        // Endpoint of the segment (the start is the previous current
458        // point, already covered by an earlier update).
459        $this->update($p3x, $p3y);
460        foreach (self::cubicExtremaT($p0x, $p1x, $p2x, $p3x) as $t) {
461            $x = self::evalCubic($p0x, $p1x, $p2x, $p3x, $t);
462            $y = self::evalCubic($p0y, $p1y, $p2y, $p3y, $t);
463            $this->update($x, $y);
464        }
465        foreach (self::cubicExtremaT($p0y, $p1y, $p2y, $p3y) as $t) {
466            $x = self::evalCubic($p0x, $p1x, $p2x, $p3x, $t);
467            $y = self::evalCubic($p0y, $p1y, $p2y, $p3y, $t);
468            $this->update($x, $y);
469        }
470    }
471
472    private function visitQuadraticSegment(
473        float $p0x,
474        float $p0y,
475        float $p1x,
476        float $p1y,
477        float $p2x,
478        float $p2y,
479    ): void {
480        $this->update($p2x, $p2y);
481        $tx = self::quadraticExtremumT($p0x, $p1x, $p2x);
482        if ($tx !== null) {
483            $x = self::evalQuadratic($p0x, $p1x, $p2x, $tx);
484            $y = self::evalQuadratic($p0y, $p1y, $p2y, $tx);
485            $this->update($x, $y);
486        }
487        $ty = self::quadraticExtremumT($p0y, $p1y, $p2y);
488        if ($ty !== null) {
489            $x = self::evalQuadratic($p0x, $p1x, $p2x, $ty);
490            $y = self::evalQuadratic($p0y, $p1y, $p2y, $ty);
491            $this->update($x, $y);
492        }
493    }
494
495    /**
496     * Roots of `B'(t) = 0` for a cubic Bézier component, clipped to
497     * the open interval `(0, 1)` — endpoints are already covered by
498     * the segment's own endpoint contributions.
499     *
500     * @return list<float>
501     */
502    private static function cubicExtremaT(float $p0, float $p1, float $p2, float $p3): array
503    {
504        // B(t) = (1-t)³ p0 + 3(1-t)²t p1 + 3(1-t)t² p2 + t³ p3
505        // B'(t) = 3 [ (p1-p0) + 2t (p2 - 2p1 + p0) + t² (p3 - 3p2 + 3p1 - p0) ]
506        $a = $p3 - 3.0 * $p2 + 3.0 * $p1 - $p0;
507        $b = 2.0 * ($p2 - 2.0 * $p1 + $p0);
508        $c = $p1 - $p0;
509
510        if (abs($a) < self::EPSILON) {
511            // Reduced to linear: b·t + c = 0 → t = -c / b.
512            if (abs($b) < self::EPSILON) {
513                return [];
514            }
515            $t = -$c / $b;
516            return $t > 0.0 && $t < 1.0 ? [$t] : [];
517        }
518
519        $discriminant = $b * $b - 4.0 * $a * $c;
520        if ($discriminant < 0.0) {
521            return [];
522        }
523        $sqrtD = sqrt($discriminant);
524        $out = [];
525        foreach ([(-$b + $sqrtD) / (2.0 * $a), (-$b - $sqrtD) / (2.0 * $a)] as $t) {
526            if ($t > 0.0 && $t < 1.0) {
527                $out[] = $t;
528            }
529        }
530        return $out;
531    }
532
533    /**
534     * Root of `Q'(t) = 0` for a quadratic Bézier component, clipped to
535     * the open interval `(0, 1)`. Returns null when the quadratic
536     * collapses to a line (no interior extremum).
537     */
538    private static function quadraticExtremumT(float $p0, float $p1, float $p2): ?float
539    {
540        $denom = $p0 - 2.0 * $p1 + $p2;
541        if (abs($denom) < self::EPSILON) {
542            return null;
543        }
544        $t = ($p0 - $p1) / $denom;
545        return $t > 0.0 && $t < 1.0 ? $t : null;
546    }
547
548    private static function evalCubic(float $p0, float $p1, float $p2, float $p3, float $t): float
549    {
550        $s = 1.0 - $t;
551        return $s * $s * $s * $p0
552            + 3.0 * $s * $s * $t * $p1
553            + 3.0 * $s * $t * $t * $p2
554            + $t * $t * $t * $p3;
555    }
556
557    private static function evalQuadratic(float $p0, float $p1, float $p2, float $t): float
558    {
559        $s = 1.0 - $t;
560        return $s * $s * $p0 + 2.0 * $s * $t * $p1 + $t * $t * $p2;
561    }
562}