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 @@
+fetch build test deploy
+
+
+
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 '
+ ' ',
+ ).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']);
+ });
+ });
+}