Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.09% covered (success)
96.09%
123 / 128
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
PathData
96.09% covered (success)
96.09%
123 / 128
66.67% covered (warning)
66.67%
4 / 6
42
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parse
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
14
 isCommandLetter
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 readCommand
100.00% covered (success)
100.00%
71 / 71
100.00% covered (success)
100.00%
1 / 1
11
 readNumber
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 readFlag
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
8.81
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Svg\Path;
6
7/**
8 * Parsed SVG path-data — the typed AST for a `<path d="…">` attribute.
9 *
10 * `PathData::parse()` is **lenient by design**: per SVG 2 §9.3.9 a malformed
11 * `d` value is rendered up to (but not including) the segment containing the
12 * error. We mirror that behaviour by accumulating commands until the
13 * tokenizer can't make progress and then stopping silently. Callers that
14 * want strict validation should walk the input themselves first.
15 */
16final class PathData
17{
18    /** @param list<PathCommand> $commands */
19    public function __construct(public readonly array $commands) {}
20
21    public static function parse(string $raw): self
22    {
23        $cmds = [];
24        $offset = 0;
25        $len = strlen($raw);
26        $lastLetter = null;
27
28        try {
29            while ($offset < $len) {
30                while ($offset < $len && (ctype_space($raw[$offset]) || $raw[$offset] === ',')) {
31                    $offset++;
32                }
33                if ($offset >= $len) {
34                    break;
35                }
36                $ch = $raw[$offset];
37
38                if (self::isCommandLetter($ch)) {
39                    $letter = $ch;
40                    $offset++;
41                    self::readCommand($raw, $offset, $letter, $cmds);
42                    // Per SVG 2 §9.3.4: M/m's implicit repeat is L/l;
43                    // Z/z has no parameters and no implicit repeat
44                    // (the next character must be either a new
45                    // command letter or end-of-input). We mark
46                    // lastLetter null after a close so trailing
47                    // garbage hits the "malformed; stop" branch
48                    // below instead of looping on a zero-byte
49                    // command emission.
50                    $lastLetter = match ($letter) {
51                        'M' => 'L',
52                        'm' => 'l',
53                        'Z', 'z' => null,
54                        default => $letter,
55                    };
56                    continue;
57                }
58
59                if ($lastLetter === null) {
60                    // Number-before-any-command — malformed; stop here.
61                    break;
62                }
63
64                // Implicit-repeat of the last command (with M→L / m→l
65                // already applied above). Defensive backstop: if the
66                // repeat doesn't consume any bytes, break to avoid
67                // any future zero-byte command type triggering an
68                // unbounded loop the way Z/z did before the
69                // `lastLetter = null` rule above was added.
70                $offsetBefore = $offset;
71                self::readCommand($raw, $offset, $lastLetter, $cmds);
72                if ($offset === $offsetBefore) {
73                    break;
74                }
75            }
76        } catch (\InvalidArgumentException) {
77            // Per spec: keep the commands accumulated before the error.
78        }
79
80        return new self($cmds);
81    }
82
83    private static function isCommandLetter(string $ch): bool
84    {
85        return match ($ch) {
86            'M', 'm', 'L', 'l', 'H', 'h', 'V', 'v',
87            'C', 'c', 'S', 's', 'Q', 'q', 'T', 't',
88            'A', 'a', 'Z', 'z' => true,
89            default => false,
90        };
91    }
92
93    /**
94     * @param list<PathCommand> $cmds
95     */
96    private static function readCommand(string $raw, int &$offset, string $letter, array &$cmds): void
97    {
98        $absolute = ctype_upper($letter);
99        switch (strtolower($letter)) {
100            case 'z':
101                $cmds[] = new ClosePath($absolute);
102                return;
103            case 'm':
104                $cmds[] = new MoveTo(
105                    $absolute,
106                    self::readNumber($raw, $offset),
107                    self::readNumber($raw, $offset),
108                );
109                return;
110            case 'l':
111                $cmds[] = new LineTo(
112                    $absolute,
113                    self::readNumber($raw, $offset),
114                    self::readNumber($raw, $offset),
115                );
116                return;
117            case 'h':
118                $cmds[] = new HorizontalLineTo($absolute, self::readNumber($raw, $offset));
119                return;
120            case 'v':
121                $cmds[] = new VerticalLineTo($absolute, self::readNumber($raw, $offset));
122                return;
123            case 'c':
124                $cmds[] = new CurveTo(
125                    $absolute,
126                    self::readNumber($raw, $offset),
127                    self::readNumber($raw, $offset),
128                    self::readNumber($raw, $offset),
129                    self::readNumber($raw, $offset),
130                    self::readNumber($raw, $offset),
131                    self::readNumber($raw, $offset),
132                );
133                return;
134            case 's':
135                $cmds[] = new SmoothCurveTo(
136                    $absolute,
137                    self::readNumber($raw, $offset),
138                    self::readNumber($raw, $offset),
139                    self::readNumber($raw, $offset),
140                    self::readNumber($raw, $offset),
141                );
142                return;
143            case 'q':
144                $cmds[] = new QuadraticCurveTo(
145                    $absolute,
146                    self::readNumber($raw, $offset),
147                    self::readNumber($raw, $offset),
148                    self::readNumber($raw, $offset),
149                    self::readNumber($raw, $offset),
150                );
151                return;
152            case 't':
153                $cmds[] = new SmoothQuadraticCurveTo(
154                    $absolute,
155                    self::readNumber($raw, $offset),
156                    self::readNumber($raw, $offset),
157                );
158                return;
159            case 'a':
160                $rx = self::readNumber($raw, $offset);
161                $ry = self::readNumber($raw, $offset);
162                $rot = self::readNumber($raw, $offset);
163                $largeArc = self::readFlag($raw, $offset);
164                $sweep = self::readFlag($raw, $offset);
165                $x = self::readNumber($raw, $offset);
166                $y = self::readNumber($raw, $offset);
167                $cmds[] = new ArcTo($absolute, $rx, $ry, $rot, $largeArc, $sweep, $x, $y);
168                return;
169        }
170    }
171
172    private static function readNumber(string $raw, int &$offset): float
173    {
174        $len = strlen($raw);
175        while ($offset < $len && (ctype_space($raw[$offset]) || $raw[$offset] === ',')) {
176            $offset++;
177        }
178        if ($offset >= $len) {
179            throw new \InvalidArgumentException('Unexpected end of path data; expected a number.');
180        }
181        if (preg_match('/\G[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/', $raw, $m, 0, $offset) !== 1) {
182            throw new \InvalidArgumentException(
183                sprintf('Expected number at offset %d in path data.', $offset),
184            );
185        }
186        $offset += strlen($m[0]);
187        return (float) $m[0];
188    }
189
190    /**
191     * Arc flag (SVG 2 §9.5.4) — a single binary digit, no separator required
192     * from the previous or following token. `1` → true, `0` → false.
193     */
194    private static function readFlag(string $raw, int &$offset): bool
195    {
196        $len = strlen($raw);
197        while ($offset < $len && (ctype_space($raw[$offset]) || $raw[$offset] === ',')) {
198            $offset++;
199        }
200        if ($offset >= $len) {
201            throw new \InvalidArgumentException('Unexpected end of path data; expected an arc flag.');
202        }
203        $ch = $raw[$offset];
204        if ($ch !== '0' && $ch !== '1') {
205            throw new \InvalidArgumentException(
206                sprintf('Arc flag at offset %d must be 0 or 1.', $offset),
207            );
208        }
209        $offset++;
210        return $ch === '1';
211    }
212}