Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.54% covered (success)
97.54%
119 / 122
50.00% covered (danger)
50.00%
3 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
ArcToCubic
97.54% covered (success)
97.54%
119 / 122
50.00% covered (danger)
50.00%
3 / 6
21
0.00% covered (danger)
0.00%
0 / 1
 convert
97.67% covered (success)
97.67%
42 / 43
0.00% covered (danger)
0.00%
0 / 1
4
 centreParameters
98.08% covered (success)
98.08%
51 / 52
0.00% covered (danger)
0.00%
0 / 1
11
 cubicSegment
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 transformX
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 transformY
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 angleBetween
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\SvgToPdf\Path;
6
7/**
8 * SVG elliptical arc → list of cubic Bézier segments.
9 *
10 * PDF has no native elliptical-arc operator, so an SVG `A` / `a` command
11 * gets baked into one or more cubic Béziers. The algorithm follows the
12 * SVG 1.1 implementation note Appendix B / SVG 2 §9.5.2:
13 *
14 *  1. End-point parameterisation (rx, ry, φ, large-arc, sweep, x2, y2)
15 *     converts to centre parameterisation `(cx, cy, θ₁, Δθ)`.
16 *  2. `Δθ` is split into segments of at most `π/2` (90°). Approximating
17 *     a quarter-arc with one cubic has a worst-case radial error
18 *     ≈ 1.2·10⁻⁴ of the radius — invisible at print resolution.
19 *  3. Each segment emits one cubic Bézier using the standard
20 *     `α = (4/3) · tan(Δ/4)` control-point distance.
21 *
22 * Degenerate inputs (`rx == 0`, `ry == 0`, or start == end) return the
23 * empty list — the caller falls back to a straight line or omits the
24 * arc entirely.
25 */
26final class ArcToCubic
27{
28    /** Quarter-arc cap on the per-segment angular span. */
29    private const float MAX_SEGMENT_ANGLE = M_PI / 2.0;
30
31    /** Tolerance for "start == end" — well below print resolution. */
32    private const float ZERO_LENGTH_EPSILON = 1.0e-12;
33
34    /**
35     * @return list<array{x1: float, y1: float, x2: float, y2: float, x: float, y: float}>
36     *         A list of cubic Béziers, each {control1, control2, endpoint}.
37     *         The first segment's start point is `($x1, $y1)`; each
38     *         subsequent segment starts where the previous ended.
39     */
40    public static function convert(
41        float $x1,
42        float $y1,
43        float $rx,
44        float $ry,
45        float $xAxisRotationDegrees,
46        bool $largeArc,
47        bool $sweep,
48        float $x2,
49        float $y2,
50    ): array {
51        $arc = self::centreParameters(
52            $x1,
53            $y1,
54            $rx,
55            $ry,
56            $xAxisRotationDegrees,
57            $largeArc,
58            $sweep,
59            $x2,
60            $y2,
61        );
62        if ($arc === null) {
63            return [];
64        }
65        $cx = $arc['cx'];
66        $cy = $arc['cy'];
67        $rx = $arc['rx'];
68        $ry = $arc['ry'];
69        $cosPhi = cos($arc['phi']);
70        $sinPhi = sin($arc['phi']);
71        $theta1 = $arc['theta1'];
72        $deltaTheta = $arc['delta'];
73
74        // Step 6 — split into <= 90 degree segments and emit one cubic each.
75        $segmentCount = (int) ceil(abs($deltaTheta) / self::MAX_SEGMENT_ANGLE);
76        if ($segmentCount === 0) {
77            return [];
78        }
79        $segmentDelta = $deltaTheta / $segmentCount;
80        $alpha = (4.0 / 3.0) * tan($segmentDelta / 4.0);
81
82        $segments = [];
83        $theta = $theta1;
84        for ($i = 0; $i < $segmentCount; $i++) {
85            $thetaNext = $theta + $segmentDelta;
86            $segments[] = self::cubicSegment(
87                $cx,
88                $cy,
89                $rx,
90                $ry,
91                $cosPhi,
92                $sinPhi,
93                $theta,
94                $thetaNext,
95                $alpha,
96            );
97            $theta = $thetaNext;
98        }
99        return $segments;
100    }
101
102    /**
103     * SVG 1.1 Appendix F.6.5 — end-point parameterisation to centre
104     * parameterisation.
105     *
106     * Split out of {@see convert()} so callers that need the TRUE
107     * ellipse rather than its Bezier approximation (arc-length
108     * measurement for SVG 2 section 9.6 `pathLength`, for one) work from
109     * the same radius correction and the same sweep normalisation the
110     * painter uses.
111     *
112     * Returns null for the degenerate inputs `convert()` also rejects:
113     * a zero-length chord or a zero radius.
114     *
115     * @return array{cx: float, cy: float, rx: float, ry: float, phi: float, theta1: float, delta: float}|null
116     */
117    public static function centreParameters(
118        float $x1,
119        float $y1,
120        float $rx,
121        float $ry,
122        float $xAxisRotationDegrees,
123        bool $largeArc,
124        bool $sweep,
125        float $x2,
126        float $y2,
127    ): ?array {
128        if (abs($x1 - $x2) < self::ZERO_LENGTH_EPSILON
129            && abs($y1 - $y2) < self::ZERO_LENGTH_EPSILON
130        ) {
131            return null;
132        }
133        if ($rx === 0.0 || $ry === 0.0) {
134            return null;
135        }
136
137        $rx = abs($rx);
138        $ry = abs($ry);
139        $phi = deg2rad(fmod($xAxisRotationDegrees, 360.0));
140        $cosPhi = cos($phi);
141        $sinPhi = sin($phi);
142
143        // Step 1: F.6.5.1 — compute (x1', y1').
144        $dx = ($x1 - $x2) / 2.0;
145        $dy = ($y1 - $y2) / 2.0;
146        $x1p = $cosPhi * $dx + $sinPhi * $dy;
147        $y1p = -$sinPhi * $dx + $cosPhi * $dy;
148
149        // Step 2: F.6.6 — radius correction.
150        $lambda = ($x1p * $x1p) / ($rx * $rx) + ($y1p * $y1p) / ($ry * $ry);
151        if ($lambda > 1.0) {
152            $scale = sqrt($lambda);
153            $rx *= $scale;
154            $ry *= $scale;
155        }
156
157        // Step 3: F.6.5.2 — compute (cx', cy').
158        $rxSq = $rx * $rx;
159        $rySq = $ry * $ry;
160        $x1pSq = $x1p * $x1p;
161        $y1pSq = $y1p * $y1p;
162        $factor = max(
163            0.0,
164            ($rxSq * $rySq - $rxSq * $y1pSq - $rySq * $x1pSq)
165                / ($rxSq * $y1pSq + $rySq * $x1pSq),
166        );
167        $coef = ($largeArc === $sweep ? -1.0 : 1.0) * sqrt($factor);
168        $cxp = $coef * $rx * $y1p / $ry;
169        $cyp = $coef * -$ry * $x1p / $rx;
170
171        // Step 4: F.6.5.3 — back-transform centre into the original
172        // coordinate system.
173        $cx = $cosPhi * $cxp - $sinPhi * $cyp + ($x1 + $x2) / 2.0;
174        $cy = $sinPhi * $cxp + $cosPhi * $cyp + ($y1 + $y2) / 2.0;
175
176        // Step 5: F.6.5.4 — compute θ₁ and Δθ.
177        $ux = ($x1p - $cxp) / $rx;
178        $uy = ($y1p - $cyp) / $ry;
179        $vx = (-$x1p - $cxp) / $rx;
180        $vy = (-$y1p - $cyp) / $ry;
181        $theta1 = self::angleBetween(1.0, 0.0, $ux, $uy);
182        $deltaTheta = self::angleBetween($ux, $uy, $vx, $vy);
183        if (!$sweep && $deltaTheta > 0.0) {
184            $deltaTheta -= 2.0 * M_PI;
185        }
186        if ($sweep && $deltaTheta < 0.0) {
187            $deltaTheta += 2.0 * M_PI;
188        }
189
190        return [
191            'cx' => $cx,
192            'cy' => $cy,
193            'rx' => $rx,
194            'ry' => $ry,
195            'phi' => $phi,
196            'theta1' => $theta1,
197            'delta' => $deltaTheta,
198        ];
199    }
200
201    /**
202     * One cubic Bézier approximating the arc from `$thetaStart` to
203     * `$thetaEnd` on the unit ellipse, then transformed by
204     * `(rx, ry, φ, cx, cy)`.
205     *
206     * @return array{x1: float, y1: float, x2: float, y2: float, x: float, y: float}
207     */
208    private static function cubicSegment(
209        float $cx,
210        float $cy,
211        float $rx,
212        float $ry,
213        float $cosPhi,
214        float $sinPhi,
215        float $thetaStart,
216        float $thetaEnd,
217        float $alpha,
218    ): array {
219        $cosA = cos($thetaStart);
220        $sinA = sin($thetaStart);
221        $cosB = cos($thetaEnd);
222        $sinB = sin($thetaEnd);
223
224        // Unit-ellipse control points.
225        $p1x = $cosA - $alpha * $sinA;
226        $p1y = $sinA + $alpha * $cosA;
227        $p2x = $cosB + $alpha * $sinB;
228        $p2y = $sinB - $alpha * $cosB;
229        $p3x = $cosB;
230        $p3y = $sinB;
231
232        return [
233            'x1' => self::transformX($p1x, $p1y, $rx, $ry, $cosPhi, $sinPhi, $cx),
234            'y1' => self::transformY($p1x, $p1y, $rx, $ry, $cosPhi, $sinPhi, $cy),
235            'x2' => self::transformX($p2x, $p2y, $rx, $ry, $cosPhi, $sinPhi, $cx),
236            'y2' => self::transformY($p2x, $p2y, $rx, $ry, $cosPhi, $sinPhi, $cy),
237            'x' => self::transformX($p3x, $p3y, $rx, $ry, $cosPhi, $sinPhi, $cx),
238            'y' => self::transformY($p3x, $p3y, $rx, $ry, $cosPhi, $sinPhi, $cy),
239        ];
240    }
241
242    private static function transformX(
243        float $x,
244        float $y,
245        float $rx,
246        float $ry,
247        float $cosPhi,
248        float $sinPhi,
249        float $cx,
250    ): float {
251        return $cosPhi * $x * $rx - $sinPhi * $y * $ry + $cx;
252    }
253
254    private static function transformY(
255        float $x,
256        float $y,
257        float $rx,
258        float $ry,
259        float $cosPhi,
260        float $sinPhi,
261        float $cy,
262    ): float {
263        return $sinPhi * $x * $rx + $cosPhi * $y * $ry + $cy;
264    }
265
266    /**
267     * Signed angle from `(ux, uy)` to `(vx, vy)` per SVG 2 §F.6.5.4.
268     * Result in `(-π, π]`.
269     */
270    private static function angleBetween(float $ux, float $uy, float $vx, float $vy): float
271    {
272        $dot = $ux * $vx + $uy * $vy;
273        $mag = sqrt(($ux * $ux + $uy * $uy) * ($vx * $vx + $vy * $vy));
274        if ($mag === 0.0) {
275            return 0.0;
276        }
277        $cos = max(-1.0, min(1.0, $dot / $mag));
278        $sign = ($ux * $vy - $uy * $vx) < 0.0 ? -1.0 : 1.0;
279        return $sign * acos($cos);
280    }
281}