Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
99.56% |
225 / 226 |
|
83.33% |
5 / 6 |
CRAP | |
0.00% |
0 / 1 |
| MarkerVertices | |
99.56% |
225 / 226 |
|
83.33% |
5 / 6 |
55 | |
0.00% |
0 / 1 |
| ofPathData | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| ofPoints | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
| flatten | |
99.08% |
108 / 109 |
|
0.00% |
0 / 1 |
22 | |||
| ofSegments | |
100.00% |
98 / 98 |
|
100.00% |
1 / 1 |
25 | |||
| withInSlope | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
| resolve | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\SvgToPdf\Path; |
| 6 | |
| 7 | use Phpdftk\Svg\Path\ArcTo; |
| 8 | use Phpdftk\Svg\Path\ClosePath; |
| 9 | use Phpdftk\Svg\Path\CurveTo; |
| 10 | use Phpdftk\Svg\Path\HorizontalLineTo; |
| 11 | use Phpdftk\Svg\Path\LineTo; |
| 12 | use Phpdftk\Svg\Path\MoveTo; |
| 13 | use Phpdftk\Svg\Path\PathData; |
| 14 | use Phpdftk\Svg\Path\QuadraticCurveTo; |
| 15 | use Phpdftk\Svg\Path\SmoothCurveTo; |
| 16 | use Phpdftk\Svg\Path\SmoothQuadraticCurveTo; |
| 17 | use Phpdftk\Svg\Path\VerticalLineTo; |
| 18 | |
| 19 | /** |
| 20 | * SVG 2 §11.6.2 — the vertices at which a shape's `marker-start`, |
| 21 | * `marker-mid` and `marker-end` markers are placed, and the angle each |
| 22 | * one is drawn at. |
| 23 | * |
| 24 | * A vertex is every point at which one path segment ends and the next |
| 25 | * begins, INCLUDING the points either side of a `moveto` that opens a |
| 26 | * new subpath and the point a `closepath` returns to. The first vertex |
| 27 | * of the whole shape takes `marker-start`, the last takes `marker-end`, |
| 28 | * and every other vertex takes `marker-mid` — the split is per SHAPE, |
| 29 | * not per subpath, which is why `m … m …` puts mid markers on both |
| 30 | * sides of the gap rather than a second start/end pair. |
| 31 | * |
| 32 | * The angle at a vertex is derived from the two tangent directions that |
| 33 | * meet there: |
| 34 | * |
| 35 | * - `marker-start` uses the OUTGOING direction only. |
| 36 | * - `marker-end` uses the INCOMING direction only. |
| 37 | * - `marker-mid` uses the bisector of the two. The bisector is |
| 38 | * computed on the two angles, not on the summed vectors, and the |
| 39 | * incoming angle is lifted by a full turn when the pair straddles |
| 40 | * the ±180° cut so a shallow join never flips the marker by 180° |
| 41 | * (the classic WebKit 193015 discontinuity). |
| 42 | * |
| 43 | * The tangent of a curve segment is taken from its control polygon: the |
| 44 | * first control point gives the outgoing direction at its start, the |
| 45 | * last control point the incoming direction at its end. Elliptical arcs |
| 46 | * are lowered to cubics first, exactly as the painter lowers them, so |
| 47 | * the tangents agree with the geometry that actually gets drawn. |
| 48 | */ |
| 49 | final class MarkerVertices |
| 50 | { |
| 51 | /** Below this, a segment is a point rather than a direction. */ |
| 52 | private const float EPSILON = 1.0e-9; |
| 53 | |
| 54 | /** |
| 55 | * A shape's marker vertices, in path order. |
| 56 | * |
| 57 | * @return list<MarkerVertex> |
| 58 | */ |
| 59 | public static function ofPathData(PathData $data): array |
| 60 | { |
| 61 | return self::ofSegments(self::flatten($data)); |
| 62 | } |
| 63 | |
| 64 | /** |
| 65 | * The marker vertices of a point-list shape — `<line>`, |
| 66 | * `<polyline>` and `<polygon>`. A `<polygon>` is a `<polyline>` |
| 67 | * plus the implicit `closepath` SVG 2 §10.7 gives it, which adds |
| 68 | * the vertex at the closing corner. |
| 69 | * |
| 70 | * @param list<array{float, float}> $points |
| 71 | * @return list<MarkerVertex> |
| 72 | */ |
| 73 | public static function ofPoints(array $points, bool $closed): array |
| 74 | { |
| 75 | if ($points === []) { |
| 76 | return []; |
| 77 | } |
| 78 | $segments = [['move', [$points[0]]]]; |
| 79 | $count = count($points); |
| 80 | for ($i = 1; $i < $count; $i++) { |
| 81 | $segments[] = ['line', [$points[$i]]]; |
| 82 | } |
| 83 | if ($closed) { |
| 84 | $segments[] = ['close', []]; |
| 85 | } |
| 86 | return self::ofSegments($segments); |
| 87 | } |
| 88 | |
| 89 | /** |
| 90 | * Lower a parsed `d` attribute to the absolute `move` / `line` / |
| 91 | * `quad` / `cubic` / `close` segment list the vertex walk consumes. |
| 92 | * |
| 93 | * The relative-to-absolute and smooth-reflection rules here mirror |
| 94 | * {@see PathLengthMeasure} and the painter's own emitters: a |
| 95 | * reflected control point is only seeded by a curve of the SAME |
| 96 | * degree (SVG 2 §9.3.6 / §9.3.7), so `L 10 10 S …` reflects about |
| 97 | * the current point rather than a stale cubic control. |
| 98 | * |
| 99 | * @return list<array{string, list<array{float, float}>}> |
| 100 | */ |
| 101 | private static function flatten(PathData $data): array |
| 102 | { |
| 103 | /** @var list<array{string, list<array{float, float}>}> $segments */ |
| 104 | $segments = []; |
| 105 | $cx = $cy = 0.0; |
| 106 | $startX = $startY = 0.0; |
| 107 | $lastCubicControlX = $lastCubicControlY = null; |
| 108 | $lastQuadControlX = $lastQuadControlY = null; |
| 109 | |
| 110 | foreach ($data->commands as $command) { |
| 111 | $isCubic = false; |
| 112 | $isQuad = false; |
| 113 | |
| 114 | switch (true) { |
| 115 | case $command instanceof MoveTo: |
| 116 | [$x, $y] = self::resolve($command->absolute, $command->x, $command->y, $cx, $cy); |
| 117 | $segments[] = ['move', [[$x, $y]]]; |
| 118 | $cx = $startX = $x; |
| 119 | $cy = $startY = $y; |
| 120 | break; |
| 121 | |
| 122 | case $command instanceof LineTo: |
| 123 | [$x, $y] = self::resolve($command->absolute, $command->x, $command->y, $cx, $cy); |
| 124 | $segments[] = ['line', [[$x, $y]]]; |
| 125 | $cx = $x; |
| 126 | $cy = $y; |
| 127 | break; |
| 128 | |
| 129 | case $command instanceof HorizontalLineTo: |
| 130 | $x = $command->absolute ? $command->x : $cx + $command->x; |
| 131 | $segments[] = ['line', [[$x, $cy]]]; |
| 132 | $cx = $x; |
| 133 | break; |
| 134 | |
| 135 | case $command instanceof VerticalLineTo: |
| 136 | $y = $command->absolute ? $command->y : $cy + $command->y; |
| 137 | $segments[] = ['line', [[$cx, $y]]]; |
| 138 | $cy = $y; |
| 139 | break; |
| 140 | |
| 141 | case $command instanceof CurveTo: |
| 142 | [$x1, $y1] = self::resolve($command->absolute, $command->x1, $command->y1, $cx, $cy); |
| 143 | [$x2, $y2] = self::resolve($command->absolute, $command->x2, $command->y2, $cx, $cy); |
| 144 | [$x, $y] = self::resolve($command->absolute, $command->x, $command->y, $cx, $cy); |
| 145 | $segments[] = ['cubic', [[$x1, $y1], [$x2, $y2], [$x, $y]]]; |
| 146 | $lastCubicControlX = $x2; |
| 147 | $lastCubicControlY = $y2; |
| 148 | $cx = $x; |
| 149 | $cy = $y; |
| 150 | $isCubic = true; |
| 151 | break; |
| 152 | |
| 153 | case $command instanceof SmoothCurveTo: |
| 154 | $x1 = $lastCubicControlX === null ? $cx : 2.0 * $cx - $lastCubicControlX; |
| 155 | $y1 = $lastCubicControlY === null ? $cy : 2.0 * $cy - $lastCubicControlY; |
| 156 | [$x2, $y2] = self::resolve($command->absolute, $command->x2, $command->y2, $cx, $cy); |
| 157 | [$x, $y] = self::resolve($command->absolute, $command->x, $command->y, $cx, $cy); |
| 158 | $segments[] = ['cubic', [[$x1, $y1], [$x2, $y2], [$x, $y]]]; |
| 159 | $lastCubicControlX = $x2; |
| 160 | $lastCubicControlY = $y2; |
| 161 | $cx = $x; |
| 162 | $cy = $y; |
| 163 | $isCubic = true; |
| 164 | break; |
| 165 | |
| 166 | case $command instanceof QuadraticCurveTo: |
| 167 | [$x1, $y1] = self::resolve($command->absolute, $command->x1, $command->y1, $cx, $cy); |
| 168 | [$x, $y] = self::resolve($command->absolute, $command->x, $command->y, $cx, $cy); |
| 169 | $segments[] = ['quad', [[$x1, $y1], [$x, $y]]]; |
| 170 | $lastQuadControlX = $x1; |
| 171 | $lastQuadControlY = $y1; |
| 172 | $cx = $x; |
| 173 | $cy = $y; |
| 174 | $isQuad = true; |
| 175 | break; |
| 176 | |
| 177 | case $command instanceof SmoothQuadraticCurveTo: |
| 178 | $x1 = $lastQuadControlX === null ? $cx : 2.0 * $cx - $lastQuadControlX; |
| 179 | $y1 = $lastQuadControlY === null ? $cy : 2.0 * $cy - $lastQuadControlY; |
| 180 | [$x, $y] = self::resolve($command->absolute, $command->x, $command->y, $cx, $cy); |
| 181 | $segments[] = ['quad', [[$x1, $y1], [$x, $y]]]; |
| 182 | $lastQuadControlX = $x1; |
| 183 | $lastQuadControlY = $y1; |
| 184 | $cx = $x; |
| 185 | $cy = $y; |
| 186 | $isQuad = true; |
| 187 | break; |
| 188 | |
| 189 | case $command instanceof ArcTo: |
| 190 | [$x, $y] = self::resolve($command->absolute, $command->x, $command->y, $cx, $cy); |
| 191 | $cubics = ArcToCubic::convert( |
| 192 | $cx, |
| 193 | $cy, |
| 194 | $command->rx, |
| 195 | $command->ry, |
| 196 | $command->xAxisRotation, |
| 197 | $command->largeArc, |
| 198 | $command->sweep, |
| 199 | $x, |
| 200 | $y, |
| 201 | ); |
| 202 | if ($cubics === []) { |
| 203 | // A degenerate arc (zero radius, or start == |
| 204 | // end) is a straight line per SVG 2 §9.5.4 — |
| 205 | // the painter draws one, so it contributes the |
| 206 | // same single vertex here. |
| 207 | $segments[] = ['line', [[$x, $y]]]; |
| 208 | } else { |
| 209 | // An `A` is ONE path segment (SVG 2 §11.6.2), |
| 210 | // so it contributes ONE vertex — at its end. |
| 211 | // The painter lowers it to up to four cubics |
| 212 | // for PDF, but emitting those as separate |
| 213 | // segments here planted a spurious mid marker |
| 214 | // at every quarter-arc boundary. Only the |
| 215 | // tangents at the two ENDS matter, so keep the |
| 216 | // first control point and the last one and |
| 217 | // drop the subdivision. |
| 218 | $last = $cubics[count($cubics) - 1]; |
| 219 | $segments[] = ['cubic', [ |
| 220 | [$cubics[0]['x1'], $cubics[0]['y1']], |
| 221 | [$last['x2'], $last['y2']], |
| 222 | [$last['x'], $last['y']], |
| 223 | ]]; |
| 224 | } |
| 225 | $cx = $x; |
| 226 | $cy = $y; |
| 227 | break; |
| 228 | |
| 229 | case $command instanceof ClosePath: |
| 230 | $segments[] = ['close', [[$startX, $startY]]]; |
| 231 | $cx = $startX; |
| 232 | $cy = $startY; |
| 233 | break; |
| 234 | |
| 235 | default: |
| 236 | // Third-party `PathCommand` implementations are not |
| 237 | // part of the spec; the painter no-ops on them, so |
| 238 | // they place no markers either. |
| 239 | break; |
| 240 | } |
| 241 | |
| 242 | if (!$isCubic) { |
| 243 | $lastCubicControlX = $lastCubicControlY = null; |
| 244 | } |
| 245 | if (!$isQuad) { |
| 246 | $lastQuadControlX = $lastQuadControlY = null; |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | return $segments; |
| 251 | } |
| 252 | |
| 253 | /** |
| 254 | * The vertex walk itself. |
| 255 | * |
| 256 | * Each segment first CLOSES the vertex the previous segment landed |
| 257 | * on — its own first point supplies that vertex's OUTGOING tangent — |
| 258 | * and then opens the next one. The last vertex is emitted after the |
| 259 | * loop. |
| 260 | * |
| 261 | * Two SVG 2 §11.6.2 rules shape the ends of a subpath: |
| 262 | * |
| 263 | * - A `moveto` is not a segment and carries no direction. "For a |
| 264 | * vertex that starts a subpath the 'in' direction is the same as |
| 265 | * the 'out' direction, and for a vertex that ends a subpath the |
| 266 | * 'out' direction is the same as the 'in'." So the jump between |
| 267 | * subpaths never bends a marker. Treating the `moveto` as an |
| 268 | * ordinary segment instead bisected the real tangent against the |
| 269 | * jump vector and rotated the markers either side off the path. |
| 270 | * |
| 271 | * - A CLOSED subpath has no ends: its first and last vertex are |
| 272 | * the same point and it is a genuine corner, so the first |
| 273 | * vertex's incoming direction is the closing segment and the |
| 274 | * last vertex's outgoing direction is the first segment. The |
| 275 | * closing segment is only known once `Z` is reached, which is |
| 276 | * why the subpath's opening vertex is patched afterwards rather |
| 277 | * than written correctly first time. |
| 278 | * |
| 279 | * @param list<array{string, list<array{float, float}>}> $segments |
| 280 | * @return list<MarkerVertex> |
| 281 | */ |
| 282 | private static function ofSegments(array $segments): array |
| 283 | { |
| 284 | if ($segments === []) { |
| 285 | return []; |
| 286 | } |
| 287 | /** @var list<MarkerVertex> $vertices */ |
| 288 | $vertices = []; |
| 289 | $originX = $originY = 0.0; |
| 290 | $subpathStartX = $subpathStartY = 0.0; |
| 291 | $inSlopeX = $inSlopeY = 0.0; |
| 292 | $startsSubpath = true; |
| 293 | // Bookkeeping for the closed-subpath corner: which vertex opened |
| 294 | // the current subpath, the direction it left on, and whether a |
| 295 | // `Z` has brought us back to it. |
| 296 | $openingVertex = null; |
| 297 | $openingOutX = $openingOutY = 0.0; |
| 298 | $justClosed = false; |
| 299 | $index = 0; |
| 300 | |
| 301 | foreach ($segments as [$type, $points]) { |
| 302 | if ($points === []) { |
| 303 | $points = [[$subpathStartX, $subpathStartY]]; |
| 304 | } |
| 305 | // A `Z` onto the point the subpath already ends on draws |
| 306 | // no segment, so it adds no vertex either. Emitting one |
| 307 | // put a SECOND marker on the closing corner — a mid on top |
| 308 | // of the end marker — whenever the last curve already |
| 309 | // landed back on the start point. |
| 310 | $degenerateClose = $type === 'close' |
| 311 | && abs($subpathStartX - $originX) <= self::EPSILON |
| 312 | && abs($subpathStartY - $originY) <= self::EPSILON; |
| 313 | if ($index > 0 && !$degenerateClose) { |
| 314 | // A `moveto` ends the subpath the previous segment was |
| 315 | // building, so that vertex has no outgoing tangent of |
| 316 | // its own and reuses its incoming one. |
| 317 | $endsSubpath = $type === 'move'; |
| 318 | $outSlopeX = $endsSubpath ? $inSlopeX : $points[0][0] - $originX; |
| 319 | $outSlopeY = $endsSubpath ? $inSlopeY : $points[0][1] - $originY; |
| 320 | $thisInX = $startsSubpath ? $outSlopeX : $inSlopeX; |
| 321 | $thisInY = $startsSubpath ? $outSlopeY : $inSlopeY; |
| 322 | if ($justClosed && $endsSubpath && $openingVertex !== null) { |
| 323 | $outSlopeX = $openingOutX; |
| 324 | $outSlopeY = $openingOutY; |
| 325 | $vertices[$openingVertex] = self::withInSlope( |
| 326 | $vertices[$openingVertex], |
| 327 | $thisInX, |
| 328 | $thisInY, |
| 329 | ); |
| 330 | } |
| 331 | if ($startsSubpath) { |
| 332 | $openingVertex = count($vertices); |
| 333 | $openingOutX = $outSlopeX; |
| 334 | $openingOutY = $outSlopeY; |
| 335 | } |
| 336 | $vertices[] = new MarkerVertex( |
| 337 | $originX, |
| 338 | $originY, |
| 339 | $thisInX, |
| 340 | $thisInY, |
| 341 | $outSlopeX, |
| 342 | $outSlopeY, |
| 343 | ); |
| 344 | $startsSubpath = $endsSubpath; |
| 345 | $justClosed = false; |
| 346 | } |
| 347 | switch ($type) { |
| 348 | case 'move': |
| 349 | $subpathStartX = $points[0][0]; |
| 350 | $subpathStartY = $points[0][1]; |
| 351 | $originX = $points[0][0]; |
| 352 | $originY = $points[0][1]; |
| 353 | break; |
| 354 | case 'line': |
| 355 | $inSlopeX = $points[0][0] - $originX; |
| 356 | $inSlopeY = $points[0][1] - $originY; |
| 357 | $originX = $points[0][0]; |
| 358 | $originY = $points[0][1]; |
| 359 | break; |
| 360 | case 'quad': |
| 361 | $inSlopeX = $points[1][0] - $points[0][0]; |
| 362 | $inSlopeY = $points[1][1] - $points[0][1]; |
| 363 | $originX = $points[1][0]; |
| 364 | $originY = $points[1][1]; |
| 365 | break; |
| 366 | case 'cubic': |
| 367 | $inSlopeX = $points[2][0] - $points[1][0]; |
| 368 | $inSlopeY = $points[2][1] - $points[1][1]; |
| 369 | $originX = $points[2][0]; |
| 370 | $originY = $points[2][1]; |
| 371 | break; |
| 372 | case 'close': |
| 373 | // A `Z` whose subpath already ends on its start |
| 374 | // point draws no segment, so it supplies no |
| 375 | // direction either and the tangent the last real |
| 376 | // segment arrived on stands. Taking the zero vector |
| 377 | // as the incoming direction instead read as 0 deg |
| 378 | // and spun every marker on a closed Bezier or arc |
| 379 | // loop a quarter turn off the outline. |
| 380 | if (abs($subpathStartX - $originX) > self::EPSILON |
| 381 | || abs($subpathStartY - $originY) > self::EPSILON |
| 382 | ) { |
| 383 | $inSlopeX = $subpathStartX - $originX; |
| 384 | $inSlopeY = $subpathStartY - $originY; |
| 385 | } |
| 386 | $originX = $subpathStartX; |
| 387 | $originY = $subpathStartY; |
| 388 | $justClosed = true; |
| 389 | break; |
| 390 | } |
| 391 | $index++; |
| 392 | } |
| 393 | |
| 394 | // The final vertex ends its subpath, so its outgoing direction |
| 395 | // is its incoming one — unless a `Z` closed that subpath, in |
| 396 | // which case it is the corner the subpath opened on. A path |
| 397 | // that is nothing but a `moveto` has neither direction and gets |
| 398 | // the zero vector, which `atan2` reads as 0 degrees: the same |
| 399 | // unrotated marker a browser draws. |
| 400 | $lastOutX = $inSlopeX; |
| 401 | $lastOutY = $inSlopeY; |
| 402 | if ($justClosed && $openingVertex !== null) { |
| 403 | $lastOutX = $openingOutX; |
| 404 | $lastOutY = $openingOutY; |
| 405 | $vertices[$openingVertex] = self::withInSlope( |
| 406 | $vertices[$openingVertex], |
| 407 | $inSlopeX, |
| 408 | $inSlopeY, |
| 409 | ); |
| 410 | } |
| 411 | $vertices[] = new MarkerVertex( |
| 412 | $originX, |
| 413 | $originY, |
| 414 | $inSlopeX, |
| 415 | $inSlopeY, |
| 416 | $lastOutX, |
| 417 | $lastOutY, |
| 418 | ); |
| 419 | |
| 420 | return $vertices; |
| 421 | } |
| 422 | |
| 423 | /** The same vertex with a different incoming tangent. */ |
| 424 | private static function withInSlope(MarkerVertex $vertex, float $x, float $y): MarkerVertex |
| 425 | { |
| 426 | return new MarkerVertex( |
| 427 | $vertex->x, |
| 428 | $vertex->y, |
| 429 | $x, |
| 430 | $y, |
| 431 | $vertex->outSlopeX, |
| 432 | $vertex->outSlopeY, |
| 433 | ); |
| 434 | } |
| 435 | |
| 436 | /** @return array{float, float} */ |
| 437 | private static function resolve( |
| 438 | bool $absolute, |
| 439 | float $x, |
| 440 | float $y, |
| 441 | float $cx, |
| 442 | float $cy, |
| 443 | ): array { |
| 444 | return $absolute ? [$x, $y] : [$cx + $x, $cy + $y]; |
| 445 | } |
| 446 | } |