feat(svg): SVG path-data parser for the drawing-card renderer (T-320)

Parse the `d` attribute into an absolute-coordinate SvgPathSeg list,
lowering SVG shorthands to a small command set the painter can replay:
relative→absolute, H/V→lineTo, S→cubic and T→quad with control
reflection, arcs kept as arcTo. Tolerant — malformed tail returns the
understood prefix and never throws, so a bad diagram can't crash the
conversation. Flutter-free, covered by dart test. First slice of the SVG
engine (D-103); no user-visible behaviour yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 22:18:53 +02:00
co-authored by Claude Opus 4.8
parent 87634898c0
commit 9cc3d68862
4 changed files with 390 additions and 0 deletions
@@ -8107,3 +8107,4 @@ CSS DECISION — inline-normalize (option A): d2 emits a <style> block of FLAT s
BUILD SPLIT: (1) inline-style normalizer; (2) path-data parser; (3) the CustomPaint painter (shapes/text/image/marker/transform); (4) injected image-href resolver (like image.show). Fixtures: real d2 + graphviz output.', NULL, '2026-06-28 17:54:38', '2026-06-28 17:54:38.871', '2026-06-28 17:54:38.871', NULL, 'afedea62e1486510e317fc4afb0df528', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGYS06SDJGZ4W3Z9V9HDEATW', 'description', NULL, 'The pre-commit hook re-exports + stages .pql/changelog on commit but only catches the tickets + ticket_history tables — it leaves ticket_deps (blocker edges) and ticket_idmap (new T-NNN <-> record_id mappings) UNSTAGED. Seen repeatedly this session (D-103 re-sequencing blockers; T-495 id mapping), each needing a manual follow-up commit; otherwise the planning state silently doesn''t persist and a later branch switch drops it — exactly the failure the hook exists to prevent. Fix: stage ALL of .pql/changelog/ (git add .pql/changelog/), not just the tables the export rewrote. Repro: pql ticket block X --by Y + create a ticket, commit unrelated files, observe ticket_deps/ticket_idmap still modified afterward.', NULL, '2026-06-28 18:00:41', '2026-06-28 18:00:41.453', '2026-06-28 18:00:41.453', NULL, '81bf67c8003a8e48eb20c5c0b16fc90c', 2) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ETJQP0CT6X7W3CWZ6NS9G', 'status', 'backlog', 'in_progress', NULL, '2026-06-28 18:04:13', '2026-06-28 18:04:13.608', '2026-06-28 18:04:13.608', NULL, 'd1705e1dbede4e9b9cbe8d1fa6e4352e', 2) ON CONFLICT(hash) DO NOTHING;
+17
View File
@@ -9963,3 +9963,20 @@ CSS DECISION — inline-normalize (option A): d2 emits a <style> block of FLAT s
BUILD SPLIT: (1) inline-style normalizer; (2) path-data parser; (3) the CustomPaint painter (shapes/text/image/marker/transform); (4) injected image-href resolver (like image.show). Fixtures: real d2 + graphviz output.', 'backlog', 'low', NULL, NULL, NULL, '2026-06-10 11:11:55', '2026-06-28 17:54:38.871', NULL, 'd4994ff93518b92deef01b3e12a8077e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGYS06SDJGZ4W3Z9V9HDEATW', 'bug', NULL, 'pre-commit changelog hook leaves ticket_deps/ticket_idmap unstaged', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-06-28 18:00:41.419', '2026-06-28 18:00:41.419', NULL, 'dda7cc3fb4d964d6d80a0c726f4372a8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGYS06SDJGZ4W3Z9V9HDEATW', 'bug', NULL, 'pre-commit changelog hook leaves ticket_deps/ticket_idmap unstaged', 'The pre-commit hook re-exports + stages .pql/changelog on commit but only catches the tickets + ticket_history tables — it leaves ticket_deps (blocker edges) and ticket_idmap (new T-NNN <-> record_id mappings) UNSTAGED. Seen repeatedly this session (D-103 re-sequencing blockers; T-495 id mapping), each needing a manual follow-up commit; otherwise the planning state silently doesn''t persist and a later branch switch drops it — exactly the failure the hook exists to prevent. Fix: stage ALL of .pql/changelog/ (git add .pql/changelog/), not just the tables the export rewrote. Repro: pql ticket block X --by Y + create a ticket, commit unrelated files, observe ticket_deps/ticket_idmap still modified afterward.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-28 18:00:41.419', '2026-06-28 18:00:41.452', NULL, '6fed07a2fa2199d1b287829a4d7e47dd', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ETJQP0CT6X7W3CWZ6NS9G', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Drawing card template: SVG render', 'Template for the unified drawing card (T-317, D-91): render an SVG file referenced in the JSON. clide-owned rendering per the own-the-rendering-stack guardrail — no opinionated SVG package without justification; evaluate what is feasible via CustomPaint / a minimal path parser, and scope accordingly (SVG is a broad spec; define the supported subset). Optional label + description beneath (T-318). Display-only (D-78). Depends on the core engine (T-318).
PROMOTED TO ENGINE (D-103, 2026-06-28): this is now the drawing-card''s rendering FOUNDATION, not one template among many. Build the clide-owned CustomPaint SVG renderer over a BOUNDED subset (rect/line/polyline/polygon/circle/ellipse/path, text incl. the Phosphor font, image, g + transform, basic fill/stroke/opacity/rx). T-318 (envelope + dispatch + Flutter overlay) and every template lower onto this; d2/graphviz/mermaid output renders for free. No longer blocked by T-318 (dependency inverted). Open: pin the subset to what our own templates + d2/graphviz emit. Schema: docs/design/drawing-card-schema.md.
SVG SUBSET FINALIZED (2026-06-28, grounded in a real d2 sample + our own templates):
STRUCTURE: <svg> (viewBox/width/height, incl. nested svg), <g> (transform, opacity, class), <defs>, <marker> (+ marker-start/mid/end; orient=auto, refX/refY, viewBox) edge arrowheads.
SHAPES: rect (rx/ry), circle, ellipse, line, polyline, polygon, path (full data: M L H V C S Q T A Z + relatives).
TEXT: text + tspan (x/y/dx/dy, font-family incl. Phosphor, font-size/weight, text-anchor, dominant-baseline).
RASTER: image (href/xlink:href, x/y/w/h, preserveAspectRatio) our image/compare templates.
STYLING: presentation attrs (fill, fill-opacity, stroke, stroke-width, stroke-linecap/linejoin, stroke-dasharray, opacity, color), transform (translate/scale/rotate/matrix), class= resolved via the normalizer below.
DEFERRED v1: <mask> (d2 masks connections for clean edge/node joins) ignore and rely on node-over-edge paint order; add basic mask only if d2 output looks wrong.
OUT: foreignObject, filters, <animate>/SMIL, scripting, <use>/<symbol>, gradients, patterns, clipPath. (So mermaid, which leans on foreignObject, is not a launch target.)
CSS DECISION inline-normalize (option A): d2 emits a <style> block of FLAT single-class selectors (.fill-B1, .color-N1, .shape, .connection, .text-bold presentation props). A preprocessing normalizer parses <style> into class->props, merges each element''s class props into inline presentation attributes (inline wins), and drops <style>; the painter then only ever sees inline attrs a pure, testable presentation-attribute renderer. Chosen over a render-time CSS resolver to keep the painter free of any CSS concept.
BUILD SPLIT: (1) inline-style normalizer; (2) path-data parser; (3) the CustomPaint painter (shapes/text/image/marker/transform); (4) injected image-href resolver (like image.show). Fixtures: real d2 + graphviz output.', 'in_progress', 'low', NULL, NULL, NULL, '2026-06-10 11:11:55', '2026-06-28 18:04:13.608', NULL, 'ea0df82b234d48fcb6008bb09c927578', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+267
View File
@@ -0,0 +1,267 @@
/// SVG `path` `d`-attribute parser (T-320 / D-103).
///
/// Parses SVG path data into a flat list of [SvgPathSeg]s in ABSOLUTE
/// coordinates, lowering the SVG shorthands to a small command set the painter
/// can replay straight onto a `dart:ui` Path:
/// - relative (lower-case) commands → absolute
/// - `H`/`V` → [SvgPathOp.lineTo]
/// - `S` → [SvgPathOp.cubicTo] (first control reflected from the previous cubic)
/// - `T` → [SvgPathOp.quadTo] (control reflected from the previous quadratic)
/// - arcs (`A`) kept as [SvgPathOp.arcTo] for the painter to realise
///
/// Tolerant by construction: malformed tail input stops the parse and returns
/// what was understood so far — a broken `d` never throws, so a bad diagram
/// can't crash the conversation. No dependency, per prefer-zero-deps.
///
/// Flutter-free: pure Dart, runs under `dart test`. The painter (Flutter)
/// consumes [SvgPathSeg] and builds the actual Path.
library;
/// The painter-facing path command set the parser lowers SVG path data to.
enum SvgPathOp { moveTo, lineTo, cubicTo, quadTo, arcTo, close }
/// One absolute-coordinate path segment.
///
/// [args] layout by [op]:
/// - moveTo / lineTo : `[x, y]`
/// - cubicTo : `[x1, y1, x2, y2, x, y]`
/// - quadTo : `[x1, y1, x, y]`
/// - arcTo : `[rx, ry, xAxisRotationDeg, largeArc, sweep, x, y]`
/// (largeArc / sweep are `0.0` or `1.0`)
/// - close : `[]`
class SvgPathSeg {
final SvgPathOp op;
final List<double> args;
const SvgPathSeg(this.op, this.args);
@override
bool operator ==(Object other) {
if (other is! SvgPathSeg || other.op != op || other.args.length != args.length) {
return false;
}
for (var i = 0; i < args.length; i++) {
if (other.args[i] != args[i]) return false;
}
return true;
}
@override
int get hashCode => Object.hash(op, Object.hashAll(args));
@override
String toString() => '${op.name}(${args.join(', ')})';
}
/// Parse an SVG `path` `d` attribute into absolute segments. Returns whatever
/// was understood up to the first unrecoverable token; never throws.
List<SvgPathSeg> parseSvgPath(String d) {
final out = <SvgPathSeg>[];
final sc = _Scanner(d);
double cx = 0, cy = 0; // current point
double sx = 0, sy = 0; // subpath start (target of Z)
double? cubX, cubY; // previous cubic's 2nd control (for S reflection)
double? quadX, quadY; // previous quadratic's control (for T reflection)
String prev = '';
sc.skipSep();
while (!sc.atEnd) {
final loopStart = sc.i;
final ch = sc.s[sc.i];
final explicit = _isCmd(ch);
String cmd;
if (explicit) {
cmd = ch;
sc.i++;
} else if (prev.isNotEmpty) {
// Implicit repeat of the previous command; an initial M/m repeats as L/l.
cmd = prev == 'M' ? 'L' : (prev == 'm' ? 'l' : prev);
} else {
break; // data must open with a command
}
// Z carries no parameters, so it never repeats implicitly.
if (!explicit && (cmd == 'Z' || cmd == 'z')) break;
final rel = cmd == cmd.toLowerCase();
switch (cmd.toUpperCase()) {
case 'M':
final x = sc.num_(), y = sc.num_();
if (x == null || y == null) return out;
cx = rel ? cx + x : x;
cy = rel ? cy + y : y;
sx = cx;
sy = cy;
out.add(SvgPathSeg(SvgPathOp.moveTo, [cx, cy]));
cubX = cubY = quadX = quadY = null;
case 'L':
final x = sc.num_(), y = sc.num_();
if (x == null || y == null) return out;
cx = rel ? cx + x : x;
cy = rel ? cy + y : y;
out.add(SvgPathSeg(SvgPathOp.lineTo, [cx, cy]));
cubX = cubY = quadX = quadY = null;
case 'H':
final x = sc.num_();
if (x == null) return out;
cx = rel ? cx + x : x;
out.add(SvgPathSeg(SvgPathOp.lineTo, [cx, cy]));
cubX = cubY = quadX = quadY = null;
case 'V':
final y = sc.num_();
if (y == null) return out;
cy = rel ? cy + y : y;
out.add(SvgPathSeg(SvgPathOp.lineTo, [cx, cy]));
cubX = cubY = quadX = quadY = null;
case 'C':
final x1 = sc.num_(), y1 = sc.num_();
final x2 = sc.num_(), y2 = sc.num_();
final x = sc.num_(), y = sc.num_();
if (x1 == null || y1 == null || x2 == null || y2 == null || x == null || y == null) {
return out;
}
final ax1 = rel ? cx + x1 : x1, ay1 = rel ? cy + y1 : y1;
final ax2 = rel ? cx + x2 : x2, ay2 = rel ? cy + y2 : y2;
final ax = rel ? cx + x : x, ay = rel ? cy + y : y;
out.add(SvgPathSeg(SvgPathOp.cubicTo, [ax1, ay1, ax2, ay2, ax, ay]));
cubX = ax2;
cubY = ay2;
quadX = quadY = null;
cx = ax;
cy = ay;
case 'S':
final x2 = sc.num_(), y2 = sc.num_();
final x = sc.num_(), y = sc.num_();
if (x2 == null || y2 == null || x == null || y == null) return out;
final rx = cubX != null ? 2 * cx - cubX : cx;
final ry = cubY != null ? 2 * cy - cubY : cy;
final ax2 = rel ? cx + x2 : x2, ay2 = rel ? cy + y2 : y2;
final ax = rel ? cx + x : x, ay = rel ? cy + y : y;
out.add(SvgPathSeg(SvgPathOp.cubicTo, [rx, ry, ax2, ay2, ax, ay]));
cubX = ax2;
cubY = ay2;
quadX = quadY = null;
cx = ax;
cy = ay;
case 'Q':
final x1 = sc.num_(), y1 = sc.num_();
final x = sc.num_(), y = sc.num_();
if (x1 == null || y1 == null || x == null || y == null) return out;
final ax1 = rel ? cx + x1 : x1, ay1 = rel ? cy + y1 : y1;
final ax = rel ? cx + x : x, ay = rel ? cy + y : y;
out.add(SvgPathSeg(SvgPathOp.quadTo, [ax1, ay1, ax, ay]));
quadX = ax1;
quadY = ay1;
cubX = cubY = null;
cx = ax;
cy = ay;
case 'T':
final x = sc.num_(), y = sc.num_();
if (x == null || y == null) return out;
final rx = quadX != null ? 2 * cx - quadX : cx;
final ry = quadY != null ? 2 * cy - quadY : cy;
final ax = rel ? cx + x : x, ay = rel ? cy + y : y;
out.add(SvgPathSeg(SvgPathOp.quadTo, [rx, ry, ax, ay]));
quadX = rx;
quadY = ry;
cubX = cubY = null;
cx = ax;
cy = ay;
case 'A':
final rx = sc.num_(), ry = sc.num_(), rot = sc.num_();
final laf = sc.flag(), swf = sc.flag();
final x = sc.num_(), y = sc.num_();
if (rx == null || ry == null || rot == null || laf == null || swf == null || x == null || y == null) {
return out;
}
final ax = rel ? cx + x : x, ay = rel ? cy + y : y;
out.add(SvgPathSeg(SvgPathOp.arcTo, [rx.abs(), ry.abs(), rot, laf.toDouble(), swf.toDouble(), ax, ay]));
cubX = cubY = quadX = quadY = null;
cx = ax;
cy = ay;
case 'Z':
out.add(const SvgPathSeg(SvgPathOp.close, []));
cx = sx;
cy = sy;
cubX = cubY = quadX = quadY = null;
default:
return out; // unknown command letter
}
prev = cmd;
sc.skipSep();
if (sc.i == loopStart) break; // no progress (e.g. implicit repeat of Z) — bail
}
return out;
}
bool _isCmd(String c) => 'MmLlHhVvCcSsQqTtAaZz'.contains(c);
bool _isDigit(int c) => c >= 0x30 && c <= 0x39;
/// A forgiving cursor over path data, handling SVG's wsp/comma separators and
/// its delimiter-free number packing (`.5.5`, `1-2`, `1e2`).
class _Scanner {
_Scanner(this.s);
final String s;
int i = 0;
bool get atEnd => i >= s.length;
void skipSep() {
while (i < s.length) {
final c = s.codeUnitAt(i);
// wsp (space, tab, LF, CR, FF) or comma
if (c == 0x20 || c == 0x09 || c == 0x0A || c == 0x0D || c == 0x0C || c == 0x2C) {
i++;
} else {
break;
}
}
}
/// Read a number; `null` (cursor restored) if none is present.
double? num_() {
skipSep();
final start = i;
if (i < s.length && (s[i] == '+' || s[i] == '-')) i++;
var digits = false;
while (i < s.length && _isDigit(s.codeUnitAt(i))) {
i++;
digits = true;
}
if (i < s.length && s[i] == '.') {
i++;
while (i < s.length && _isDigit(s.codeUnitAt(i))) {
i++;
digits = true;
}
}
if (!digits) {
i = start;
return null;
}
if (i < s.length && (s[i] == 'e' || s[i] == 'E')) {
final save = i;
i++;
if (i < s.length && (s[i] == '+' || s[i] == '-')) i++;
var expDigits = false;
while (i < s.length && _isDigit(s.codeUnitAt(i))) {
i++;
expDigits = true;
}
if (!expDigits) i = save; // bare 'e' — not part of the number
}
return double.tryParse(s.substring(start, i));
}
/// Arc flags are a single `0` or `1`, possibly packed against neighbours.
int? flag() {
skipSep();
if (i < s.length && (s[i] == '0' || s[i] == '1')) {
final v = s[i] == '1' ? 1 : 0;
i++;
return v;
}
return null;
}
}
+105
View File
@@ -0,0 +1,105 @@
import 'package:clide/src/svg/svg_path.dart';
import 'package:test/test.dart';
void main() {
SvgPathSeg m(double x, double y) => SvgPathSeg(SvgPathOp.moveTo, [x, y]);
SvgPathSeg l(double x, double y) => SvgPathSeg(SvgPathOp.lineTo, [x, y]);
const close = SvgPathSeg(SvgPathOp.close, []);
group('parseSvgPath', () {
test('absolute moveto + lineto', () {
expect(parseSvgPath('M10 10 L20 20'), [m(10, 10), l(20, 20)]);
});
test('relative commands accumulate from the current point', () {
expect(parseSvgPath('m10 10 l5 5'), [m(10, 10), l(15, 15)]);
});
test('extra moveto coordinates become implicit linetos', () {
expect(parseSvgPath('M0 0 10 10 20 20'), [m(0, 0), l(10, 10), l(20, 20)]);
});
test('extra relative-moveto coordinates become relative linetos', () {
expect(parseSvgPath('m0 0 10 10'), [m(0, 0), l(10, 10)]);
});
test('H and V lower to lineto, absolute and relative', () {
expect(parseSvgPath('M0 0 H10 V10'), [m(0, 0), l(10, 0), l(10, 10)]);
expect(parseSvgPath('M5 5 h10 v-5'), [m(5, 5), l(15, 5), l(15, 0)]);
});
test('cubic bezier', () {
expect(parseSvgPath('M0 0 C1 2 3 4 5 6'), [
m(0, 0),
const SvgPathSeg(SvgPathOp.cubicTo, [1, 2, 3, 4, 5, 6]),
]);
});
test('smooth cubic reflects the previous cubic control', () {
// C ends at (5,6) with 2nd control (3,4); S reflects it about (5,6) → (7,8).
final segs = parseSvgPath('M0 0 C1 2 3 4 5 6 S9 9 10 10');
expect(segs[2], const SvgPathSeg(SvgPathOp.cubicTo, [7, 8, 9, 9, 10, 10]));
});
test('smooth cubic with no preceding cubic uses the current point', () {
final segs = parseSvgPath('M0 0 S2 2 4 4');
expect(segs[1], const SvgPathSeg(SvgPathOp.cubicTo, [0, 0, 2, 2, 4, 4]));
});
test('quadratic + smooth-quadratic reflection', () {
// Q control (2,0) end (4,0); T reflects control about (4,0) → (6,0).
final segs = parseSvgPath('M0 0 Q2 0 4 0 T8 0');
expect(segs[1], const SvgPathSeg(SvgPathOp.quadTo, [2, 0, 4, 0]));
expect(segs[2], const SvgPathSeg(SvgPathOp.quadTo, [6, 0, 8, 0]));
});
test('arc keeps flags and normalises radii to positive', () {
final segs = parseSvgPath('M0 0 A5 5 0 0 1 10 10');
expect(segs[1], const SvgPathSeg(SvgPathOp.arcTo, [5, 5, 0, 0, 1, 10, 10]));
});
test('arc flags packed without separators', () {
// "...0 0110 10" → rot 0, largeArc 0, sweep 1, then x=10 y=10.
final segs = parseSvgPath('M0 0 A5 5 0 0110 10');
expect(segs[1], const SvgPathSeg(SvgPathOp.arcTo, [5, 5, 0, 0, 1, 10, 10]));
});
test('close returns the current point to the subpath start', () {
// After Z the point is back at (10,10); the relative l5 5 → (15,15).
expect(parseSvgPath('M10 10 L20 20 Z l5 5'), [m(10, 10), l(20, 20), close, l(15, 15)]);
});
test('packed decimals split correctly', () {
expect(parseSvgPath('M.5.5'), [m(0.5, 0.5)]);
expect(parseSvgPath('M1.5.3'), [m(1.5, 0.3)]);
});
test('negative numbers act as their own delimiter', () {
expect(parseSvgPath('M0 0L-5-5'), [m(0, 0), l(-5, -5)]);
});
test('scientific notation', () {
expect(parseSvgPath('M1e2 0'), [m(100, 0)]);
});
test('commas and stray whitespace are tolerated', () {
expect(parseSvgPath(' M0,0 L 10 , 10 '), [m(0, 0), l(10, 10)]);
});
test('malformed tail yields the understood prefix and never throws', () {
expect(parseSvgPath('M0 0 L10 10 L20'), [m(0, 0), l(10, 10)]);
});
test('empty or garbage input is empty and never throws', () {
expect(parseSvgPath(''), isEmpty);
expect(parseSvgPath(' '), isEmpty);
expect(parseSvgPath('garbage'), isEmpty);
});
test('explicit repeated Z closes twice; trailing junk after Z bails cleanly', () {
expect(parseSvgPath('M0 0 Z Z'), [m(0, 0), close, close]);
// A number after Z is invalid: no implicit repeat, no spurious close, no hang.
expect(parseSvgPath('M0 0 Z 5 5'), [m(0, 0), close]);
});
});
}