Skip to content

Commit 6c55388

Browse files
committed
refactor: extract svg arc math collaborator and public tests
Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
1 parent 8fc4e25 commit 6c55388

4 files changed

Lines changed: 617 additions & 461 deletions

File tree

src/Pdf/Svg/SvgArcConverter.php

Lines changed: 10 additions & 182 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,13 @@
1717
* The algorithm implements the SVG 2 specification's arc-to-Bézier conversion,
1818
* decomposing the arc into multiple segments for accurate curve approximation.
1919
*/
20-
final class SvgArcConverter
20+
final readonly class SvgArcConverter
2121
{
22+
public function __construct(
23+
private \LibreSign\XObjectTemplate\Pdf\Svg\SvgArcMath $math = new \LibreSign\XObjectTemplate\Pdf\Svg\SvgArcMath(),
24+
) {
25+
}
26+
2227
/**
2328
* Convert SVG arc command parameters to cubic Bézier curves.
2429
*
@@ -70,195 +75,18 @@ public function arcToBezierCurves(
7075
$sweep,
7176
);
7277

73-
// Step 1: Normalize radii
74-
$params = $this->normalizeArcRadii($params);
78+
$params = $this->math->normalizeArcRadii($params);
7579

76-
// Step 2: Calculate center point
77-
[$centerX, $centerY] = $this->calculateArcCenter($params);
80+
[$centerX, $centerY] = $this->math->calculateArcCenter($params);
7881

79-
// Step 3: Calculate angles and deltas
80-
[$startAngle, $deltaAngle] = $this->calculateArcAngles($params, $centerX, $centerY);
82+
[$startAngle, $deltaAngle] = $this->math->calculateArcAngles($params);
8183

82-
// Step 4: Generate cubic Bézier curves
83-
return $this->generateArcCurves(
84+
return $this->math->generateArcCurves(
8485
$params,
8586
$centerX,
8687
$centerY,
8788
$startAngle,
8889
$deltaAngle,
8990
);
9091
}
91-
92-
/**
93-
* Normalize arc radii to satisfy SVG constraints.
94-
*
95-
* If the given radii are too small, they are scaled up to ensure the arc
96-
* can connect the start and end points.
97-
*/
98-
private function normalizeArcRadii(ArcParams $params): ArcParams
99-
{
100-
$deltaX2 = ($params->fromX - $params->toX) / 2.0;
101-
$deltaY2 = ($params->fromY - $params->toY) / 2.0;
102-
$primeX = $params->cosTh * $deltaX2 + $params->sinTh * $deltaY2;
103-
$primeY = -$params->sinTh * $deltaX2 + $params->cosTh * $deltaY2;
104-
105-
$radiusX2 = $params->radiusX * $params->radiusX;
106-
$radiusY2 = $params->radiusY * $params->radiusY;
107-
$primeX2 = $primeX * $primeX;
108-
$primeY2 = $primeY * $primeY;
109-
$scale = $primeX2 / $radiusX2 + $primeY2 / $radiusY2;
110-
if ($scale <= 1.0) {
111-
return $params;
112-
}
113-
114-
$scaleFactor = sqrt($scale);
115-
116-
return $params->withRadii($params->radiusX * $scaleFactor, $params->radiusY * $scaleFactor);
117-
}
118-
119-
/**
120-
* Calculate the center point of the arc.
121-
*
122-
* @return array{0:float,1:float} [$cx, $cy]
123-
*/
124-
private function calculateArcCenter(ArcParams $params): array
125-
{
126-
[$primeX, $primeY] = $this->calculatePrimeCoordinates($params);
127-
128-
$radiusX2 = $params->radiusX * $params->radiusX;
129-
$radiusY2 = $params->radiusY * $params->radiusY;
130-
$primeX2 = $primeX * $primeX;
131-
$primeY2 = $primeY * $primeY;
132-
$numerator = max(0.0, $radiusX2 * $radiusY2 - $radiusX2 * $primeY2 - $radiusY2 * $primeX2);
133-
$denominator = $radiusX2 * $primeY2 + $radiusY2 * $primeX2;
134-
$denominatorBucket = floor($denominator * 1e10);
135-
$squareRoot = $denominatorBucket > 0 ? sqrt($numerator / $denominator) : 0.0;
136-
if ($params->largeArc === $params->sweep) {
137-
$squareRoot = -$squareRoot;
138-
}
139-
140-
$centerX1 = $squareRoot * $params->radiusX * $primeY / $params->radiusY;
141-
$centerY1 = -$squareRoot * $params->radiusY * $primeX / $params->radiusX;
142-
143-
$midX = ($params->fromX + $params->toX) / 2.0;
144-
$midY = ($params->fromY + $params->toY) / 2.0;
145-
$centerX = $params->cosTh * $centerX1 - $params->sinTh * $centerY1 + $midX;
146-
$centerY = $params->sinTh * $centerX1 + $params->cosTh * $centerY1 + $midY;
147-
148-
return [$centerX, $centerY];
149-
}
150-
151-
/**
152-
* Calculate start angle and total angle delta for the arc.
153-
*
154-
* @return array{0:float,1:float} [$startAngle, $dAngle]
155-
*/
156-
private function calculateArcAngles(ArcParams $params, float $centerX, float $centerY): array
157-
{
158-
[$primeX, $primeY] = $this->calculatePrimeCoordinates($params);
159-
160-
$vectorUX = $primeX / $params->radiusX;
161-
$vectorUY = $primeY / $params->radiusY;
162-
163-
$startAngle = atan2($vectorUY, $vectorUX);
164-
165-
// In this formulation, V is always the opposite of U, so acos(cosDA)
166-
// collapses to π for stable magnitudes and π/2 for near-zero magnitudes.
167-
$normSquared = $vectorUX * $vectorUX + $vectorUY * $vectorUY;
168-
$magnitudeBucket = floor($normSquared * 1e10);
169-
$deltaAngle = $magnitudeBucket > 0 ? M_PI : M_PI / 2.0;
170-
171-
if ($params->sweep === 0) {
172-
$deltaAngle -= 2.0 * M_PI;
173-
}
174-
175-
return [$startAngle, $deltaAngle];
176-
}
177-
178-
/**
179-
* Calculate transformed midpoint delta coordinates in the rotated arc space.
180-
*
181-
* @return array{0:float,1:float} [$px, $py]
182-
*/
183-
private function calculatePrimeCoordinates(ArcParams $params): array
184-
{
185-
$deltaX2 = ($params->fromX - $params->toX) / 2.0;
186-
$deltaY2 = ($params->fromY - $params->toY) / 2.0;
187-
$primeX = $params->cosTh * $deltaX2 + $params->sinTh * $deltaY2;
188-
$primeY = -$params->sinTh * $deltaX2 + $params->cosTh * $deltaY2;
189-
190-
return [$primeX, $primeY];
191-
}
192-
193-
/**
194-
* Generate cubic Bézier curve segments for the arc.
195-
*
196-
* Splits the arc into multiple segments and computes the Bézier control
197-
* points for each segment to approximate the circular/elliptical arc.
198-
*
199-
* @param ArcParams $params Arc geometry and endpoint parameters
200-
* @param float $centerX Center X coordinate
201-
* @param float $centerY Center Y coordinate
202-
* @param float $startAngle Starting angle in radians
203-
* @param float $deltaAngle Total angle delta in radians
204-
* @return array<int, array<int, float>> Array of Bézier curve control points
205-
*/
206-
private function generateArcCurves(
207-
ArcParams $params,
208-
float $centerX,
209-
float $centerY,
210-
float $startAngle,
211-
float $deltaAngle,
212-
): array {
213-
$segments = max(1, intval(ceil(abs($deltaAngle) / (M_PI / 2.0))));
214-
$angleStep = $deltaAngle / $segments;
215-
$tanHalfAngleStep = tan($angleStep / 2.0);
216-
$alpha = abs($angleStep) > 1e-10
217-
? sin($angleStep) * (sqrt(4.0 + 3.0 * $tanHalfAngleStep * $tanHalfAngleStep) - 1.0) / 3.0
218-
: 0.0;
219-
220-
$curves = [];
221-
$angle1 = $startAngle;
222-
$cos1 = cos($angle1);
223-
$sin1 = sin($angle1);
224-
$endX1 = $centerX + $params->cosTh * $params->radiusX * $cos1 - $params->sinTh * $params->radiusY * $sin1;
225-
$endY1 = $centerY + $params->sinTh * $params->radiusX * $cos1 + $params->cosTh * $params->radiusY * $sin1;
226-
227-
foreach (range(0, $segments - 1) as $i) {
228-
$angle2 = $angle1 + $angleStep;
229-
$cos2 = cos($angle2);
230-
$sin2 = sin($angle2);
231-
232-
$endX2 = $centerX + $params->cosTh * $params->radiusX * $cos2 - $params->sinTh * $params->radiusY * $sin2;
233-
$endY2 = $centerY + $params->sinTh * $params->radiusX * $cos2 + $params->cosTh * $params->radiusY * $sin2;
234-
235-
// For the last segment, ensure the endpoint is exactly the target point
236-
if ($i === $segments - 1) {
237-
$endX2 = $params->toX;
238-
$endY2 = $params->toY;
239-
}
240-
241-
$tangentXD1 = -$params->cosTh * $params->radiusX * $sin1 - $params->sinTh * $params->radiusY * $cos1;
242-
$tangentYD1 = -$params->sinTh * $params->radiusX * $sin1 + $params->cosTh * $params->radiusY * $cos1;
243-
$tangentXD2 = -$params->cosTh * $params->radiusX * $sin2 - $params->sinTh * $params->radiusY * $cos2;
244-
$tangentYD2 = -$params->sinTh * $params->radiusX * $sin2 + $params->cosTh * $params->radiusY * $cos2;
245-
246-
$curves[] = [
247-
$endX1 + $alpha * $tangentXD1,
248-
$endY1 + $alpha * $tangentYD1,
249-
$endX2 - $alpha * $tangentXD2,
250-
$endY2 - $alpha * $tangentYD2,
251-
$endX2,
252-
$endY2,
253-
];
254-
255-
$angle1 = $angle2;
256-
$cos1 = $cos2;
257-
$sin1 = $sin2;
258-
$endX1 = $endX2;
259-
$endY1 = $endY2;
260-
}
261-
262-
return $curves;
263-
}
26492
}

src/Pdf/Svg/SvgArcMath.php

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
// SPDX-FileCopyrightText: 2026 LibreSign
6+
// SPDX-License-Identifier: AGPL-3.0-or-later
7+
8+
namespace LibreSign\XObjectTemplate\Pdf\Svg;
9+
10+
/**
11+
* Internal arc geometry math collaborator.
12+
*
13+
* @internal
14+
*/
15+
final class SvgArcMath
16+
{
17+
/**
18+
* Normalize arc radii to satisfy SVG constraints.
19+
*/
20+
public function normalizeArcRadii(ArcParams $params): ArcParams
21+
{
22+
$deltaX2 = ($params->fromX - $params->toX) / 2.0;
23+
$deltaY2 = ($params->fromY - $params->toY) / 2.0;
24+
$primeX = $params->cosTh * $deltaX2 + $params->sinTh * $deltaY2;
25+
$primeY = -$params->sinTh * $deltaX2 + $params->cosTh * $deltaY2;
26+
27+
$radiusX2 = $params->radiusX * $params->radiusX;
28+
$radiusY2 = $params->radiusY * $params->radiusY;
29+
$primeX2 = $primeX * $primeX;
30+
$primeY2 = $primeY * $primeY;
31+
$scale = $primeX2 / $radiusX2 + $primeY2 / $radiusY2;
32+
if ($scale <= 1.0) {
33+
return $params;
34+
}
35+
36+
$scaleFactor = sqrt($scale);
37+
38+
return $params->withRadii($params->radiusX * $scaleFactor, $params->radiusY * $scaleFactor);
39+
}
40+
41+
/**
42+
* @return array{0:float,1:float} [$cx, $cy]
43+
*/
44+
public function calculateArcCenter(ArcParams $params): array
45+
{
46+
[$primeX, $primeY] = $this->calculatePrimeCoordinates($params);
47+
48+
$radiusX2 = $params->radiusX * $params->radiusX;
49+
$radiusY2 = $params->radiusY * $params->radiusY;
50+
$primeX2 = $primeX * $primeX;
51+
$primeY2 = $primeY * $primeY;
52+
$numerator = max(0.0, $radiusX2 * $radiusY2 - $radiusX2 * $primeY2 - $radiusY2 * $primeX2);
53+
$denominator = $radiusX2 * $primeY2 + $radiusY2 * $primeX2;
54+
$denominatorBucket = floor($denominator * 1e10);
55+
$squareRoot = $denominatorBucket > 0 ? sqrt($numerator / $denominator) : 0.0;
56+
if ($params->largeArc === $params->sweep) {
57+
$squareRoot = -$squareRoot;
58+
}
59+
60+
$centerX1 = $squareRoot * $params->radiusX * $primeY / $params->radiusY;
61+
$centerY1 = -$squareRoot * $params->radiusY * $primeX / $params->radiusX;
62+
63+
$midX = ($params->fromX + $params->toX) / 2.0;
64+
$midY = ($params->fromY + $params->toY) / 2.0;
65+
$centerX = $params->cosTh * $centerX1 - $params->sinTh * $centerY1 + $midX;
66+
$centerY = $params->sinTh * $centerX1 + $params->cosTh * $centerY1 + $midY;
67+
68+
return [$centerX, $centerY];
69+
}
70+
71+
/**
72+
* @return array{0:float,1:float} [$startAngle, $dAngle]
73+
*/
74+
public function calculateArcAngles(ArcParams $params): array
75+
{
76+
[$primeX, $primeY] = $this->calculatePrimeCoordinates($params);
77+
78+
$vectorUX = $primeX / $params->radiusX;
79+
$vectorUY = $primeY / $params->radiusY;
80+
81+
$startAngle = atan2($vectorUY, $vectorUX);
82+
83+
$normSquared = $vectorUX * $vectorUX + $vectorUY * $vectorUY;
84+
$magnitudeBucket = floor($normSquared * 1e10);
85+
$deltaAngle = $magnitudeBucket > 0 ? M_PI : M_PI / 2.0;
86+
87+
if ($params->sweep === 0) {
88+
$deltaAngle -= 2.0 * M_PI;
89+
}
90+
91+
return [$startAngle, $deltaAngle];
92+
}
93+
94+
/**
95+
* @return array<int, array<int, float>>
96+
*/
97+
public function generateArcCurves(
98+
ArcParams $params,
99+
float $centerX,
100+
float $centerY,
101+
float $startAngle,
102+
float $deltaAngle,
103+
): array {
104+
$segments = max(1, intval(ceil(abs($deltaAngle) / (M_PI / 2.0))));
105+
$angleStep = $deltaAngle / $segments;
106+
$tanHalfAngleStep = tan($angleStep / 2.0);
107+
$alpha = abs($angleStep) > 1e-10
108+
? sin($angleStep) * (sqrt(4.0 + 3.0 * $tanHalfAngleStep * $tanHalfAngleStep) - 1.0) / 3.0
109+
: 0.0;
110+
111+
$curves = [];
112+
$angle1 = $startAngle;
113+
$cos1 = cos($angle1);
114+
$sin1 = sin($angle1);
115+
$endX1 = $centerX + $params->cosTh * $params->radiusX * $cos1 - $params->sinTh * $params->radiusY * $sin1;
116+
$endY1 = $centerY + $params->sinTh * $params->radiusX * $cos1 + $params->cosTh * $params->radiusY * $sin1;
117+
118+
foreach (range(0, $segments - 1) as $i) {
119+
$angle2 = $angle1 + $angleStep;
120+
$cos2 = cos($angle2);
121+
$sin2 = sin($angle2);
122+
123+
$endX2 = $centerX + $params->cosTh * $params->radiusX * $cos2 - $params->sinTh * $params->radiusY * $sin2;
124+
$endY2 = $centerY + $params->sinTh * $params->radiusX * $cos2 + $params->cosTh * $params->radiusY * $sin2;
125+
126+
if ($i === $segments - 1) {
127+
$endX2 = $params->toX;
128+
$endY2 = $params->toY;
129+
}
130+
131+
$tangentXD1 = -$params->cosTh * $params->radiusX * $sin1 - $params->sinTh * $params->radiusY * $cos1;
132+
$tangentYD1 = -$params->sinTh * $params->radiusX * $sin1 + $params->cosTh * $params->radiusY * $cos1;
133+
$tangentXD2 = -$params->cosTh * $params->radiusX * $sin2 - $params->sinTh * $params->radiusY * $cos2;
134+
$tangentYD2 = -$params->sinTh * $params->radiusX * $sin2 + $params->cosTh * $params->radiusY * $cos2;
135+
136+
$curves[] = [
137+
$endX1 + $alpha * $tangentXD1,
138+
$endY1 + $alpha * $tangentYD1,
139+
$endX2 - $alpha * $tangentXD2,
140+
$endY2 - $alpha * $tangentYD2,
141+
$endX2,
142+
$endY2,
143+
];
144+
145+
$angle1 = $angle2;
146+
$cos1 = $cos2;
147+
$sin1 = $sin2;
148+
$endX1 = $endX2;
149+
$endY1 = $endY2;
150+
}
151+
152+
return $curves;
153+
}
154+
155+
/**
156+
* @return array{0:float,1:float} [$px, $py]
157+
*/
158+
private function calculatePrimeCoordinates(ArcParams $params): array
159+
{
160+
$deltaX2 = ($params->fromX - $params->toX) / 2.0;
161+
$deltaY2 = ($params->fromY - $params->toY) / 2.0;
162+
$primeX = $params->cosTh * $deltaX2 + $params->sinTh * $deltaY2;
163+
$primeY = -$params->sinTh * $deltaX2 + $params->cosTh * $deltaY2;
164+
165+
return [$primeX, $primeY];
166+
}
167+
}

0 commit comments

Comments
 (0)