Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
99.56% covered (success)
99.56%
225 / 226
83.33% covered (warning)
83.33%
5 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
MarkerVertices
99.56% covered (success)
99.56%
225 / 226
83.33% covered (warning)
83.33%
5 / 6
55
0.00% covered (danger)
0.00%
0 / 1
 ofPathData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ofPoints
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 flatten
99.08% covered (success)
99.08%
108 / 109
0.00% covered (danger)
0.00%
0 / 1
22
 ofSegments
100.00% covered (success)
100.00%
98 / 98
100.00% covered (success)
100.00%
1 / 1
25
 withInSlope
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 resolve
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\SvgToPdf\Path;
6
7use Phpdftk\Svg\Path\ArcTo;
8use Phpdftk\Svg\Path\ClosePath;
9use Phpdftk\Svg\Path\CurveTo;
10use Phpdftk\Svg\Path\HorizontalLineTo;
11use Phpdftk\Svg\Path\LineTo;
12use Phpdftk\Svg\Path\MoveTo;
13use Phpdftk\Svg\Path\PathData;
14use Phpdftk\Svg\Path\QuadraticCurveTo;
15use Phpdftk\Svg\Path\SmoothCurveTo;
16use Phpdftk\Svg\Path\SmoothQuadraticCurveTo;
17use 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 */
49final 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}