Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.65% covered (success)
95.65%
88 / 92
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
ArcToCubic
95.65% covered (success)
95.65%
88 / 92
60.00% covered (warning)
60.00%
3 / 5
19
0.00% covered (danger)
0.00%
0 / 1
 convert
95.38% covered (success)
95.38%
62 / 65
0.00% covered (danger)
0.00%
0 / 1
13
 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        if (abs($x1 - $x2) < self::ZERO_LENGTH_EPSILON
52            && abs($y1 - $y2) < self::ZERO_LENGTH_EPSILON
53        ) {
54            return [];
55        }
56        if ($rx === 0.0 || $ry === 0.0) {
57            return [];
58        }
59
60        $rx = abs($rx);
61        $ry = abs($ry);
62        $phi = deg2rad(fmod($xAxisRotationDegrees, 360.0));
63        $cosPhi = cos($phi);
64        $sinPhi = sin($phi);
65
66        // Step 1: F.6.5.1 — compute (x1', y1').
67        $dx = ($x1 - $x2) / 2.0;
68        $dy = ($y1 - $y2) / 2.0;
69        $x1p = $cosPhi * $dx + $sinPhi * $dy;
70        $y1p = -$sinPhi * $dx + $cosPhi * $dy;
71
72        // Step 2: F.6.6 — radius correction.
73        $lambda = ($x1p * $x1p) / ($rx * $rx) + ($y1p * $y1p) / ($ry * $ry);
74        if ($lambda > 1.0) {
75            $scale = sqrt($lambda);
76            $rx *= $scale;
77            $ry *= $scale;
78        }
79
80        // Step 3: F.6.5.2 — compute (cx', cy').
81        $rxSq = $rx * $rx;
82        $rySq = $ry * $ry;
83        $x1pSq = $x1p * $x1p;
84        $y1pSq = $y1p * $y1p;
85        $factor = max(
86            0.0,
87            ($rxSq * $rySq - $rxSq * $y1pSq - $rySq * $x1pSq)
88                / ($rxSq * $y1pSq + $rySq * $x1pSq),
89        );
90        $coef = ($largeArc === $sweep ? -1.0 : 1.0) * sqrt($factor);
91        $cxp = $coef * $rx * $y1p / $ry;
92        $cyp = $coef * -$ry * $x1p / $rx;
93
94        // Step 4: F.6.5.3 — back-transform centre into the original
95        // coordinate system.
96        $cx = $cosPhi * $cxp - $sinPhi * $cyp + ($x1 + $x2) / 2.0;
97        $cy = $sinPhi * $cxp + $cosPhi * $cyp + ($y1 + $y2) / 2.0;
98
99        // Step 5: F.6.5.4 — compute θ₁ and Δθ.
100        $ux = ($x1p - $cxp) / $rx;
101        $uy = ($y1p - $cyp) / $ry;
102        $vx = (-$x1p - $cxp) / $rx;
103        $vy = (-$y1p - $cyp) / $ry;
104        $theta1 = self::angleBetween(1.0, 0.0, $ux, $uy);
105        $deltaTheta = self::angleBetween($ux, $uy, $vx, $vy);
106        if (!$sweep && $deltaTheta > 0.0) {
107            $deltaTheta -= 2.0 * M_PI;
108        }
109        if ($sweep && $deltaTheta < 0.0) {
110            $deltaTheta += 2.0 * M_PI;
111        }
112
113        // Step 6 — split into ≤ 90° segments and emit one cubic each.
114        $segmentCount = (int) ceil(abs($deltaTheta) / self::MAX_SEGMENT_ANGLE);
115        if ($segmentCount === 0) {
116            return [];
117        }
118        $segmentDelta = $deltaTheta / $segmentCount;
119        $alpha = (4.0 / 3.0) * tan($segmentDelta / 4.0);
120
121        $segments = [];
122        $theta = $theta1;
123        for ($i = 0; $i < $segmentCount; $i++) {
124            $thetaNext = $theta + $segmentDelta;
125            $segments[] = self::cubicSegment(
126                $cx,
127                $cy,
128                $rx,
129                $ry,
130                $cosPhi,
131                $sinPhi,
132                $theta,
133                $thetaNext,
134                $alpha,
135            );
136            $theta = $thetaNext;
137        }
138        return $segments;
139    }
140
141    /**
142     * One cubic Bézier approximating the arc from `$thetaStart` to
143     * `$thetaEnd` on the unit ellipse, then transformed by
144     * `(rx, ry, φ, cx, cy)`.
145     *
146     * @return array{x1: float, y1: float, x2: float, y2: float, x: float, y: float}
147     */
148    private static function cubicSegment(
149        float $cx,
150        float $cy,
151        float $rx,
152        float $ry,
153        float $cosPhi,
154        float $sinPhi,
155        float $thetaStart,
156        float $thetaEnd,
157        float $alpha,
158    ): array {
159        $cosA = cos($thetaStart);
160        $sinA = sin($thetaStart);
161        $cosB = cos($thetaEnd);
162        $sinB = sin($thetaEnd);
163
164        // Unit-ellipse control points.
165        $p1x = $cosA - $alpha * $sinA;
166        $p1y = $sinA + $alpha * $cosA;
167        $p2x = $cosB + $alpha * $sinB;
168        $p2y = $sinB - $alpha * $cosB;
169        $p3x = $cosB;
170        $p3y = $sinB;
171
172        return [
173            'x1' => self::transformX($p1x, $p1y, $rx, $ry, $cosPhi, $sinPhi, $cx),
174            'y1' => self::transformY($p1x, $p1y, $rx, $ry, $cosPhi, $sinPhi, $cy),
175            'x2' => self::transformX($p2x, $p2y, $rx, $ry, $cosPhi, $sinPhi, $cx),
176            'y2' => self::transformY($p2x, $p2y, $rx, $ry, $cosPhi, $sinPhi, $cy),
177            'x' => self::transformX($p3x, $p3y, $rx, $ry, $cosPhi, $sinPhi, $cx),
178            'y' => self::transformY($p3x, $p3y, $rx, $ry, $cosPhi, $sinPhi, $cy),
179        ];
180    }
181
182    private static function transformX(
183        float $x,
184        float $y,
185        float $rx,
186        float $ry,
187        float $cosPhi,
188        float $sinPhi,
189        float $cx,
190    ): float {
191        return $cosPhi * $x * $rx - $sinPhi * $y * $ry + $cx;
192    }
193
194    private static function transformY(
195        float $x,
196        float $y,
197        float $rx,
198        float $ry,
199        float $cosPhi,
200        float $sinPhi,
201        float $cy,
202    ): float {
203        return $sinPhi * $x * $rx + $cosPhi * $y * $ry + $cy;
204    }
205
206    /**
207     * Signed angle from `(ux, uy)` to `(vx, vy)` per SVG 2 §F.6.5.4.
208     * Result in `(-π, π]`.
209     */
210    private static function angleBetween(float $ux, float $uy, float $vx, float $vy): float
211    {
212        $dot = $ux * $vx + $uy * $vy;
213        $mag = sqrt(($ux * $ux + $uy * $uy) * ($vx * $vx + $vy * $vy));
214        if ($mag === 0.0) {
215            return 0.0;
216        }
217        $cos = max(-1.0, min(1.0, $dot / $mag));
218        $sign = ($ux * $vy - $uy * $vx) < 0.0 ? -1.0 : 1.0;
219        return $sign * acos($cos);
220    }
221}