From 1a3486708c717a34ce9c4f1ed19dd7ca85babe82 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 28 Jun 2026 20:53:11 +0200 Subject: [PATCH] feat(svg): typed scene model + document builder for the drawing card (T-320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The builder ties the five parsers together: parseXml → inlineStyles → a typed SvgNode tree (group/rect/ellipse/line/poly/path/text/image) with viewBox, per-node Affine transforms, and inheritance-flattened SvgStyle (fill/stroke/font resolved to ARGB; opacity per-node). Tolerant — a non-svg root yields an empty doc; defs/marker deferred. An end-to-end test runs the whole pipeline against a real d2-rendered SVG fixture. Flutter- free, covered by dart test (17 cases). No user-visible behaviour yet. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/svg/svg_document.dart | 182 ++++++++++++++++++++++++++++++ lib/src/svg/svg_node.dart | 131 +++++++++++++++++++++ test/svg/fixtures/d2_pipeline.svg | 95 ++++++++++++++++ test/svg/svg_d2_fixture_test.dart | 39 +++++++ test/svg/svg_document_test.dart | 112 ++++++++++++++++++ 5 files changed, 559 insertions(+) create mode 100644 lib/src/svg/svg_document.dart create mode 100644 lib/src/svg/svg_node.dart create mode 100644 test/svg/fixtures/d2_pipeline.svg create mode 100644 test/svg/svg_d2_fixture_test.dart create mode 100644 test/svg/svg_document_test.dart diff --git a/lib/src/svg/svg_document.dart b/lib/src/svg/svg_document.dart new file mode 100644 index 00000000..2e24bfa8 --- /dev/null +++ b/lib/src/svg/svg_document.dart @@ -0,0 +1,182 @@ +/// Builds the typed [SvgDocument] scene from raw SVG text (T-320 / D-103). +/// +/// Pipeline: [parseXml] → [inlineStyles] (flatten classes to inline attrs) → +/// walk the tree into typed [SvgNode]s, resolving each element's geometry, +/// `transform` ([Affine]), and presentation [SvgStyle] with inheritance applied +/// down the tree. Colours resolve to packed ARGB ([parseSvgColor]); paths to +/// [SvgPathSeg]s ([parseSvgPath]). +/// +/// Tolerant: a non-`` root yields [SvgDocument.empty]; unknown elements +/// (and `defs`/`marker`, deferred this slice) are skipped. Never throws. +/// +/// Flutter-free: pure Dart, runs under `dart test`. +library; + +import 'svg_color.dart'; +import 'svg_node.dart'; +import 'svg_path.dart'; +import 'svg_style.dart'; +import 'svg_transform.dart'; +import 'svg_xml.dart'; + +/// Parse raw SVG into the typed scene model. +SvgDocument buildSvgDocument(String src) { + final root = parseXml(src); + if (root == null || root.name != 'svg') return SvgDocument.empty; + inlineStyles(root); + + final style = _resolveStyle(root.attrs, SvgStyle.initial); + return SvgDocument( + width: _lenN(root.attrs['width']), + height: _lenN(root.attrs['height']), + viewBox: _viewBox(root.attrs['viewBox']), + root: SvgGroup(style, _transform(root.attrs['transform']), _children(root, style)), + ); +} + +List _children(XmlElement el, SvgStyle inherited) { + final out = []; + for (final c in el.children) { + if (c is XmlElement) { + final n = _node(c, inherited); + if (n != null) out.add(n); + } + } + return out; +} + +SvgNode? _node(XmlElement el, SvgStyle inherited) { + final style = _resolveStyle(el.attrs, inherited); + final tf = _transform(el.attrs['transform']); + final a = el.attrs; + switch (el.name) { + case 'g': + case 'a': + case 'svg': + return SvgGroup(style, tf, _children(el, style)); + case 'rect': + final rx = _numN(a['rx']), ry = _numN(a['ry']); + return SvgRect(style, tf, _num(a['x']), _num(a['y']), _num(a['width']), _num(a['height']), rx ?? ry ?? 0, ry ?? rx ?? 0); + case 'circle': + final r = _num(a['r']); + return SvgEllipse(style, tf, _num(a['cx']), _num(a['cy']), r, r); + case 'ellipse': + return SvgEllipse(style, tf, _num(a['cx']), _num(a['cy']), _num(a['rx']), _num(a['ry'])); + case 'line': + return SvgLine(style, tf, _num(a['x1']), _num(a['y1']), _num(a['x2']), _num(a['y2'])); + case 'polyline': + return SvgPolyline(style, tf, _points(a['points']), false); + case 'polygon': + return SvgPolyline(style, tf, _points(a['points']), true); + case 'path': + return SvgPath(style, tf, parseSvgPath(a['d'] ?? '')); + case 'text': + return SvgText(style, tf, _num(a['x']), _num(a['y']), _textOf(el)); + case 'image': + return SvgImage(style, tf, _num(a['x']), _num(a['y']), _num(a['width']), _num(a['height']), a['href'] ?? a['xlink:href'] ?? ''); + default: + return null; // defs, marker, title, desc, unknown — skipped + } +} + +SvgStyle _resolveStyle(Map a, SvgStyle inh) { + int? color(String k, int? fb) { + final v = a[k]; + return v == null ? fb : (parseSvgColor(v) ?? fb); + } + + double? dbl(String k, double? fb) { + final v = a[k]; + return v == null ? fb : (_stripNum(v) ?? fb); + } + + return SvgStyle( + fill: color('fill', inh.fill), + stroke: color('stroke', inh.stroke), + strokeWidth: dbl('stroke-width', inh.strokeWidth), + opacity: _stripNum(a['opacity'] ?? '') ?? 1.0, // not inherited + fillOpacity: dbl('fill-opacity', inh.fillOpacity), + strokeOpacity: dbl('stroke-opacity', inh.strokeOpacity), + dashArray: a.containsKey('stroke-dasharray') ? _dash(a['stroke-dasharray']!) : inh.dashArray, + lineCap: a.containsKey('stroke-linecap') ? _cap(a['stroke-linecap']!) : inh.lineCap, + lineJoin: a.containsKey('stroke-linejoin') ? _join(a['stroke-linejoin']!) : inh.lineJoin, + fontFamily: a['font-family'] ?? inh.fontFamily, + fontSize: dbl('font-size', inh.fontSize), + fontWeight: a.containsKey('font-weight') ? _weight(a['font-weight']!) : inh.fontWeight, + textAnchor: a.containsKey('text-anchor') ? _anchor(a['text-anchor']!) : inh.textAnchor, + baseline: a.containsKey('dominant-baseline') ? _baseline(a['dominant-baseline']!) : inh.baseline, + ); +} + +SvgLineCap _cap(String v) => switch (v.trim()) { + 'round' => SvgLineCap.round, + 'square' => SvgLineCap.square, + _ => SvgLineCap.butt, +}; + +SvgLineJoin _join(String v) => switch (v.trim()) { + 'round' => SvgLineJoin.round, + 'bevel' => SvgLineJoin.bevel, + _ => SvgLineJoin.miter, +}; + +SvgTextAnchor _anchor(String v) => switch (v.trim()) { + 'middle' => SvgTextAnchor.middle, + 'end' => SvgTextAnchor.end, + _ => SvgTextAnchor.start, +}; + +SvgBaseline _baseline(String v) => switch (v.trim()) { + 'middle' || 'central' => SvgBaseline.middle, + 'hanging' || 'text-before-edge' => SvgBaseline.hanging, + _ => SvgBaseline.auto, +}; + +int _weight(String v) => switch (v.trim()) { + 'bold' => 700, + 'normal' => 400, + _ => int.tryParse(v.trim()) ?? 400, +}; + +List _dash(String v) { + if (v.trim() == 'none') return const []; + return v.split(RegExp(r'[\s,]+')).map(_stripNum).whereType().toList(); +} + +List _points(String? v) { + if (v == null) return const []; + return v.split(RegExp(r'[\s,]+')).where((p) => p.isNotEmpty).map(double.tryParse).whereType().toList(); +} + +SvgViewBox? _viewBox(String? v) { + if (v == null) return null; + final n = v.split(RegExp(r'[\s,]+')).where((p) => p.isNotEmpty).map(double.tryParse).toList(); + if (n.length < 4 || n.any((x) => x == null)) return null; + return SvgViewBox(n[0]!, n[1]!, n[2]!, n[3]!); +} + +Affine? _transform(String? v) { + if (v == null || v.isEmpty) return null; + final t = parseTransform(v); + return t.isIdentity ? null : t; +} + +String _textOf(XmlElement el) { + final buf = StringBuffer(); + for (final n in el.descendants()) { + if (n is XmlText) buf.write(n.text); + } + return buf.toString().replaceAll(RegExp(r'\s+'), ' ').trim(); +} + +/// Parse a length, ignoring a trailing unit (`12px` → 12); `null` if no number. +double? _stripNum(String v) { + final m = RegExp(r'[-+]?(?:[0-9]*\.[0-9]+|[0-9]+)(?:[eE][-+]?[0-9]+)?').firstMatch(v.trim()); + return m == null ? null : double.tryParse(m.group(0)!); +} + +double _num(String? v) => v == null ? 0 : (_stripNum(v) ?? 0); + +double? _numN(String? v) => v == null ? null : _stripNum(v); + +double? _lenN(String? v) => _numN(v); diff --git a/lib/src/svg/svg_node.dart b/lib/src/svg/svg_node.dart new file mode 100644 index 00000000..d9643765 --- /dev/null +++ b/lib/src/svg/svg_node.dart @@ -0,0 +1,131 @@ +/// The typed SVG scene model the painter draws (T-320 / D-103). +/// +/// The document builder ([buildSvgDocument]) lowers a normalized XML tree into +/// this model: every node carries a resolved [SvgStyle] (inheritance already +/// flattened), an optional [Affine] transform, and typed geometry. Colours are +/// packed ARGB ints, not `dart:ui` Colors, so the model stays Flutter-free and +/// `dart test`-able; the painter converts. +/// +/// `null` style fields mean "unspecified" — the painter applies the SVG default +/// (fill black, stroke none, stroke-width 1). +library; + +import 'svg_path.dart'; +import 'svg_transform.dart'; + +enum SvgLineCap { butt, round, square } + +enum SvgLineJoin { miter, round, bevel } + +enum SvgTextAnchor { start, middle, end } + +enum SvgBaseline { auto, middle, hanging } + +/// Resolved presentation style for a node, with inheritance already applied. +class SvgStyle { + const SvgStyle({ + this.fill, + this.stroke, + this.strokeWidth, + this.opacity = 1.0, + this.fillOpacity, + this.strokeOpacity, + this.dashArray, + this.lineCap, + this.lineJoin, + this.fontFamily, + this.fontSize, + this.fontWeight, + this.textAnchor, + this.baseline, + }); + + /// The root inheritance context: nothing specified, full opacity. + static const initial = SvgStyle(); + + final int? fill; // ARGB; 0x00000000 = explicit none + final int? stroke; // ARGB + final double? strokeWidth; + final double opacity; // element/group opacity — NOT inherited + final double? fillOpacity; + final double? strokeOpacity; + final List? dashArray; + final SvgLineCap? lineCap; + final SvgLineJoin? lineJoin; + final String? fontFamily; + final double? fontSize; + final int? fontWeight; // 400, 700, … + final SvgTextAnchor? textAnchor; + final SvgBaseline? baseline; +} + +/// A node in the typed scene — group or leaf. [style] is fully resolved; +/// [transform] is `null` when identity. +sealed class SvgNode { + const SvgNode(this.style, this.transform); + final SvgStyle style; + final Affine? transform; +} + +class SvgGroup extends SvgNode { + const SvgGroup(super.style, super.transform, this.children); + final List children; +} + +class SvgRect extends SvgNode { + const SvgRect(super.style, super.transform, this.x, this.y, this.width, this.height, this.rx, this.ry); + final double x, y, width, height, rx, ry; +} + +/// Circle is an ellipse with `rx == ry`. +class SvgEllipse extends SvgNode { + const SvgEllipse(super.style, super.transform, this.cx, this.cy, this.rx, this.ry); + final double cx, cy, rx, ry; +} + +class SvgLine extends SvgNode { + const SvgLine(super.style, super.transform, this.x1, this.y1, this.x2, this.y2); + final double x1, y1, x2, y2; +} + +/// Polyline (open) or polygon (`closed == true`). [points] is `[x0,y0,x1,y1,…]`. +class SvgPolyline extends SvgNode { + const SvgPolyline(super.style, super.transform, this.points, this.closed); + final List points; + final bool closed; +} + +class SvgPath extends SvgNode { + const SvgPath(super.style, super.transform, this.segments); + final List segments; +} + +class SvgText extends SvgNode { + const SvgText(super.style, super.transform, this.x, this.y, this.text); + final double x, y; + final String text; +} + +class SvgImage extends SvgNode { + const SvgImage(super.style, super.transform, this.x, this.y, this.width, this.height, this.href); + final double x, y, width, height; + final String href; +} + +/// `viewBox="minX minY width height"`. +class SvgViewBox { + const SvgViewBox(this.minX, this.minY, this.width, this.height); + final double minX, minY, width, height; +} + +/// A parsed SVG document: optional intrinsic [width]/[height] (px), optional +/// [viewBox], and the [root] group. +class SvgDocument { + const SvgDocument({this.width, this.height, this.viewBox, required this.root}); + + static const empty = SvgDocument(root: SvgGroup(SvgStyle.initial, null, [])); + + final double? width, height; + final SvgViewBox? viewBox; + final SvgGroup root; +} diff --git a/test/svg/fixtures/d2_pipeline.svg b/test/svg/fixtures/d2_pipeline.svg new file mode 100644 index 00000000..c88db8aa --- /dev/null +++ b/test/svg/fixtures/d2_pipeline.svg @@ -0,0 +1,95 @@ +fetchbuildtestdeploy + + + diff --git a/test/svg/svg_d2_fixture_test.dart b/test/svg/svg_d2_fixture_test.dart new file mode 100644 index 00000000..dccac236 --- /dev/null +++ b/test/svg/svg_d2_fixture_test.dart @@ -0,0 +1,39 @@ +import 'dart:io'; + +import 'package:clide/src/svg/svg_document.dart'; +import 'package:clide/src/svg/svg_node.dart'; +import 'package:test/test.dart'; + +/// End-to-end: the full raw-SVG → typed-scene pipeline against a real diagram +/// rendered by the `d2` binary (the output we sampled while scoping T-320). +void main() { + Iterable flatten(SvgNode n) sync* { + yield n; + if (n is SvgGroup) { + for (final c in n.children) { + yield* flatten(c); + } + } + } + + test('builds a real d2-rendered SVG into a sane scene', () { + final svg = File('test/svg/fixtures/d2_pipeline.svg').readAsStringSync(); + final doc = buildSvgDocument(svg); + final nodes = flatten(doc.root).toList(); + + expect(doc.viewBox, isNotNull, reason: 'd2 sets a viewBox'); + + // The four pipeline labels render as . + final labels = nodes.whereType().map((t) => t.text).toSet(); + expect(labels, containsAll(['fetch', 'build', 'test', 'deploy'])); + + // The node boxes render as . + expect(nodes.whereType(), isNotEmpty); + + // The edges render as , and their class-driven fill resolved to ARGB + // (proves the
').root.children, isEmpty); + expect(buildSvgDocument('garbage').root.children, isEmpty); + }); + + test('width, height and viewBox', () { + final d = buildSvgDocument(''); + expect(d.width, 480); + expect(d.height, 360); + expect(d.viewBox!.width, 48); + expect(d.viewBox!.height, 36); + }); + + test('rect geometry with rx inheriting to ry', () { + final r = kids('').single as SvgRect; + expect([r.x, r.y, r.width, r.height], [1, 2, 3, 4]); + expect(r.rx, 5); + expect(r.ry, 5); + }); + + test('circle becomes an ellipse with rx == ry', () { + final e = kids('').single as SvgEllipse; + expect([e.cx, e.cy, e.rx, e.ry], [5, 6, 7, 7]); + }); + + test('polygon is closed, polyline is not', () { + final poly = kids('').single as SvgPolyline; + expect(poly.points, [0, 0, 10, 0, 10, 10]); + expect(poly.closed, isTrue); + final line = kids('').single as SvgPolyline; + expect(line.closed, isFalse); + }); + + test('path data is parsed into segments', () { + final p = kids('').single as SvgPath; + expect(p.segments, [ + const SvgPathSeg(SvgPathOp.moveTo, [0, 0]), + const SvgPathSeg(SvgPathOp.lineTo, [10, 10]), + ]); + }); + + test('fill and stroke resolve to packed ARGB', () { + final r = kids('').single as SvgRect; + expect(r.style.fill, 0xFF0D32B2); + expect(r.style.stroke, 0xFFFF0000); + }); + + test('inheritable style flows from the parent group', () { + final g = kids('').single as SvgGroup; + expect((g.children.single as SvgRect).style.fill, 0xFFFF0000); + }); + + test('a child overrides an inherited value', () { + final g = kids('').single as SvgGroup; + expect((g.children.single as SvgRect).style.fill, 0xFF00FF00); + }); + + test('opacity is not inherited', () { + final g = kids('').single as SvgGroup; + expect(g.style.opacity, 0.5); + expect((g.children.single as SvgRect).style.opacity, 1.0); + }); + + test('transform parses; identity collapses to null', () { + final r = kids('').single as SvgRect; + expect(r.transform, const Affine(1, 0, 0, 1, 5, 10)); + expect((kids('').single as SvgRect).transform, isNull); + }); + + test('text content is collected and whitespace-collapsed', () { + final t = kids(' build ').single as SvgText; + expect(t.text, 'build'); + expect([t.x, t.y], [1, 2]); + }); + + test('image reads the xlink:href fallback', () { + final i = kids('').single as SvgImage; + expect(i.href, 'a.png'); + }); + + test('lengths tolerate units', () { + final t = kids('x').single as SvgText; + expect(t.style.fontSize, 12); + }); + + test('end-to-end: a d2-style class resolves through to an ARGB fill', () { + final p = + kids( + '' + '', + ).single + as SvgPath; + expect(p.style.fill, 0xFF0D32B2); + expect(p.segments.first.op, SvgPathOp.moveTo); + }); + + test('defs and marker are skipped this slice', () { + final k = kids(''); + expect(k.map((n) => n.runtimeType.toString()), ['SvgRect']); + }); + }); +}