feat(svg): typed scene model + document builder for the drawing card (T-320)

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 22:18:53 +02:00
co-authored by Claude Opus 4.8
parent ac387796c8
commit 1a3486708c
5 changed files with 559 additions and 0 deletions
+182
View File
@@ -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-`<svg>` 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<SvgNode> _children(XmlElement el, SvgStyle inherited) {
final out = <SvgNode>[];
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<String, String> 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<double> _dash(String v) {
if (v.trim() == 'none') return const [];
return v.split(RegExp(r'[\s,]+')).map(_stripNum).whereType<double>().toList();
}
List<double> _points(String? v) {
if (v == null) return const [];
return v.split(RegExp(r'[\s,]+')).where((p) => p.isNotEmpty).map(double.tryParse).whereType<double>().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);
+131
View File
@@ -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<double>? 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<SvgNode> 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<double> points;
final bool closed;
}
class SvgPath extends SvgNode {
const SvgPath(super.style, super.transform, this.segments);
final List<SvgPathSeg> 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;
}