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
+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;
}