Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
98.70% |
76 / 77 |
|
87.50% |
7 / 8 |
CRAP | |
0.00% |
0 / 1 |
| PathBuilder | |
98.70% |
76 / 77 |
|
87.50% |
7 / 8 |
21 | |
0.00% |
0 / 1 |
| moveTo | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| lineTo | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| curveTo | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| quadCurveTo | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| arcTo | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| close | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| replayTo | |
97.14% |
34 / 35 |
|
0.00% |
0 / 1 |
9 | |||
| emitArc | |
100.00% |
30 / 30 |
|
100.00% |
1 / 1 |
6 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\Pdf\Writer; |
| 6 | |
| 7 | use 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 | */ |
| 24 | final 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 | } |