Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.70% covered (success)
98.70%
76 / 77
87.50% covered (warning)
87.50%
7 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
PathBuilder
98.70% covered (success)
98.70%
76 / 77
87.50% covered (warning)
87.50%
7 / 8
21
0.00% covered (danger)
0.00%
0 / 1
 moveTo
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 lineTo
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 curveTo
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 quadCurveTo
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 arcTo
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 close
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 replayTo
97.14% covered (success)
97.14%
34 / 35
0.00% covered (danger)
0.00%
0 / 1
9
 emitArc
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Pdf\Writer;
6
7use Phpdftk\Pdf\Core\Content\ContentStream;
8
9/**
10 * Fluent builder for custom PDF paths.
11 *
12 * Used with Page::drawPath() to construct complex shapes without
13 * knowing content stream operators. Supports lines, cubic Bézier
14 * curves, quadratic curves (converted to cubic), arcs, and closure.
15 *
16 * Usage:
17 *   $page->drawPath(function(PathBuilder $p) {
18 *       $p->moveTo(100, 100)
19 *         ->lineTo(200, 150)
20 *         ->curveTo(250, 200, 300, 100, 350, 150)
21 *         ->close();
22 *   }, fill: RgbColor::fromHex('#FF0000'));
23 */
24final class PathBuilder
25{
26    /** @var list<array{op: string, args: float[]}> */
27    private array $operations = [];
28
29    public function moveTo(float $x, float $y): self
30    {
31        $this->operations[] = ['op' => 'moveTo', 'args' => [$x, $y]];
32        return $this;
33    }
34
35    public function lineTo(float $x, float $y): self
36    {
37        $this->operations[] = ['op' => 'lineTo', 'args' => [$x, $y]];
38        return $this;
39    }
40
41    /**
42     * Cubic Bézier curve to (x3, y3) with control points (x1, y1) and (x2, y2).
43     */
44    public function curveTo(
45        float $x1,
46        float $y1,
47        float $x2,
48        float $y2,
49        float $x3,
50        float $y3,
51    ): self {
52        $this->operations[] = ['op' => 'curveTo', 'args' => [$x1, $y1, $x2, $y2, $x3, $y3]];
53        return $this;
54    }
55
56    /**
57     * Quadratic Bézier curve — converted to cubic internally.
58     *
59     * A quadratic curve with control point (cpx, cpy) and end point (x, y)
60     * is converted to a cubic curve using the standard 2/3 approximation.
61     */
62    public function quadCurveTo(float $cpx, float $cpy, float $x, float $y): self
63    {
64        $this->operations[] = ['op' => 'quadCurveTo', 'args' => [$cpx, $cpy, $x, $y]];
65        return $this;
66    }
67
68    /**
69     * Circular arc from startAngle to endAngle (in degrees, counterclockwise).
70     *
71     * Approximated with Bézier curves (one per 90-degree segment).
72     */
73    public function arcTo(
74        float $cx,
75        float $cy,
76        float $r,
77        float $startAngle,
78        float $endAngle,
79    ): self {
80        $this->operations[] = ['op' => 'arcTo', 'args' => [$cx, $cy, $r, $startAngle, $endAngle]];
81        return $this;
82    }
83
84    public function close(): self
85    {
86        $this->operations[] = ['op' => 'close', 'args' => []];
87        return $this;
88    }
89
90    /**
91     * @internal Replay recorded operations onto a ContentStream.
92     */
93    public function replayTo(ContentStream $cs): void
94    {
95        $lastX = 0.0;
96        $lastY = 0.0;
97
98        foreach ($this->operations as $op) {
99            match ($op['op']) {
100                'moveTo' => (function () use ($cs, $op, &$lastX, &$lastY) {
101                    $cs->moveTo($op['args'][0], $op['args'][1]);
102                    $lastX = $op['args'][0];
103                    $lastY = $op['args'][1];
104                })(),
105                'lineTo' => (function () use ($cs, $op, &$lastX, &$lastY) {
106                    $cs->lineTo($op['args'][0], $op['args'][1]);
107                    $lastX = $op['args'][0];
108                    $lastY = $op['args'][1];
109                })(),
110                'curveTo' => (function () use ($cs, $op, &$lastX, &$lastY) {
111                    $cs->curveTo(...$op['args']);
112                    $lastX = $op['args'][4];
113                    $lastY = $op['args'][5];
114                })(),
115                'quadCurveTo' => (function () use ($cs, $op, &$lastX, &$lastY) {
116                    [$cpx, $cpy, $x, $y] = $op['args'];
117                    // Convert quadratic to cubic: CP1 = P0 + 2/3*(CP-P0), CP2 = P + 2/3*(CP-P)
118                    $cp1x = $lastX + 2.0 / 3.0 * ($cpx - $lastX);
119                    $cp1y = $lastY + 2.0 / 3.0 * ($cpy - $lastY);
120                    $cp2x = $x + 2.0 / 3.0 * ($cpx - $x);
121                    $cp2y = $y + 2.0 / 3.0 * ($cpy - $y);
122                    $cs->curveTo($cp1x, $cp1y, $cp2x, $cp2y, $x, $y);
123                    $lastX = $x;
124                    $lastY = $y;
125                })(),
126                'arcTo' => (function () use ($cs, $op, &$lastX, &$lastY) {
127                    [$cx, $cy, $r, $startDeg, $endDeg] = $op['args'];
128                    self::emitArc($cs, $cx, $cy, $r, $startDeg, $endDeg, $lastX, $lastY);
129                })(),
130                'close' => $cs->closePath(),
131                default => null,
132            };
133        }
134    }
135
136    /**
137     * Emit a circular arc as Bézier curves onto a ContentStream.
138     * Splits into segments of at most 90 degrees.
139     */
140    private static function emitArc(
141        ContentStream $cs,
142        float $cx,
143        float $cy,
144        float $r,
145        float $startDeg,
146        float $endDeg,
147        float &$lastX,
148        float &$lastY,
149    ): void {
150        $startRad = deg2rad($startDeg);
151        $endRad = deg2rad($endDeg);
152
153        // Ensure we go counterclockwise
154        if ($endRad < $startRad) {
155            $endRad += 2 * M_PI;
156        }
157
158        $totalAngle = $endRad - $startRad;
159        $segments = (int) ceil($totalAngle / (M_PI / 2));
160        if ($segments === 0) {
161            return;
162        }
163
164        $segmentAngle = $totalAngle / $segments;
165        $currentAngle = $startRad;
166
167        // Move to start of arc if not already there
168        $sx = $cx + $r * cos($currentAngle);
169        $sy = $cy + $r * sin($currentAngle);
170        if (abs($sx - $lastX) > 0.01 || abs($sy - $lastY) > 0.01) {
171            $cs->lineTo($sx, $sy);
172        }
173
174        for ($i = 0; $i < $segments; $i++) {
175            $a1 = $currentAngle;
176            $a2 = $currentAngle + $segmentAngle;
177
178            // Bézier approximation of an arc segment
179            $alpha = 4.0 / 3.0 * tan($segmentAngle / 4);
180
181            $x1 = $cx + $r * cos($a1);
182            $y1 = $cy + $r * sin($a1);
183            $x4 = $cx + $r * cos($a2);
184            $y4 = $cy + $r * sin($a2);
185
186            $cp1x = $x1 - $alpha * $r * sin($a1);
187            $cp1y = $y1 + $alpha * $r * cos($a1);
188            $cp2x = $x4 + $alpha * $r * sin($a2);
189            $cp2y = $y4 - $alpha * $r * cos($a2);
190
191            $cs->curveTo($cp1x, $cp1y, $cp2x, $cp2y, $x4, $y4);
192
193            $lastX = $x4;
194            $lastY = $y4;
195            $currentAngle = $a2;
196        }
197    }
198}