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:
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" data-d2-version="0.7.1" preserveAspectRatio="xMinYMin meet" viewBox="0 0 660 394"><svg class="d2-4268889648 d2-svg" width="660" height="394" viewBox="-101 -101 660 394"><rect x="-101.000000" y="-101.000000" width="660.000000" height="394.000000" rx="0.000000" fill="#FFFFFF" class=" fill-N7" stroke-width="0" /><style type="text/css"><![CDATA[
|
||||
.d2-4268889648 .text-bold {
|
||||
font-family: "d2-4268889648-font-bold";
|
||||
}
|
||||
@font-face {
|
||||
font-family: d2-4268889648-font-bold;
|
||||
src: url("data:application/font-woff;base64,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");
|
||||
}]]></style><style type="text/css"><![CDATA[.shape {
|
||||
shape-rendering: geometricPrecision;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.connection {
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.blend {
|
||||
mix-blend-mode: multiply;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.d2-4268889648 .fill-N1{fill:#0A0F25;}
|
||||
.d2-4268889648 .fill-N2{fill:#676C7E;}
|
||||
.d2-4268889648 .fill-N3{fill:#9499AB;}
|
||||
.d2-4268889648 .fill-N4{fill:#CFD2DD;}
|
||||
.d2-4268889648 .fill-N5{fill:#DEE1EB;}
|
||||
.d2-4268889648 .fill-N6{fill:#EEF1F8;}
|
||||
.d2-4268889648 .fill-N7{fill:#FFFFFF;}
|
||||
.d2-4268889648 .fill-B1{fill:#0D32B2;}
|
||||
.d2-4268889648 .fill-B2{fill:#0D32B2;}
|
||||
.d2-4268889648 .fill-B3{fill:#E3E9FD;}
|
||||
.d2-4268889648 .fill-B4{fill:#E3E9FD;}
|
||||
.d2-4268889648 .fill-B5{fill:#EDF0FD;}
|
||||
.d2-4268889648 .fill-B6{fill:#F7F8FE;}
|
||||
.d2-4268889648 .fill-AA2{fill:#4A6FF3;}
|
||||
.d2-4268889648 .fill-AA4{fill:#EDF0FD;}
|
||||
.d2-4268889648 .fill-AA5{fill:#F7F8FE;}
|
||||
.d2-4268889648 .fill-AB4{fill:#EDF0FD;}
|
||||
.d2-4268889648 .fill-AB5{fill:#F7F8FE;}
|
||||
.d2-4268889648 .stroke-N1{stroke:#0A0F25;}
|
||||
.d2-4268889648 .stroke-N2{stroke:#676C7E;}
|
||||
.d2-4268889648 .stroke-N3{stroke:#9499AB;}
|
||||
.d2-4268889648 .stroke-N4{stroke:#CFD2DD;}
|
||||
.d2-4268889648 .stroke-N5{stroke:#DEE1EB;}
|
||||
.d2-4268889648 .stroke-N6{stroke:#EEF1F8;}
|
||||
.d2-4268889648 .stroke-N7{stroke:#FFFFFF;}
|
||||
.d2-4268889648 .stroke-B1{stroke:#0D32B2;}
|
||||
.d2-4268889648 .stroke-B2{stroke:#0D32B2;}
|
||||
.d2-4268889648 .stroke-B3{stroke:#E3E9FD;}
|
||||
.d2-4268889648 .stroke-B4{stroke:#E3E9FD;}
|
||||
.d2-4268889648 .stroke-B5{stroke:#EDF0FD;}
|
||||
.d2-4268889648 .stroke-B6{stroke:#F7F8FE;}
|
||||
.d2-4268889648 .stroke-AA2{stroke:#4A6FF3;}
|
||||
.d2-4268889648 .stroke-AA4{stroke:#EDF0FD;}
|
||||
.d2-4268889648 .stroke-AA5{stroke:#F7F8FE;}
|
||||
.d2-4268889648 .stroke-AB4{stroke:#EDF0FD;}
|
||||
.d2-4268889648 .stroke-AB5{stroke:#F7F8FE;}
|
||||
.d2-4268889648 .background-color-N1{background-color:#0A0F25;}
|
||||
.d2-4268889648 .background-color-N2{background-color:#676C7E;}
|
||||
.d2-4268889648 .background-color-N3{background-color:#9499AB;}
|
||||
.d2-4268889648 .background-color-N4{background-color:#CFD2DD;}
|
||||
.d2-4268889648 .background-color-N5{background-color:#DEE1EB;}
|
||||
.d2-4268889648 .background-color-N6{background-color:#EEF1F8;}
|
||||
.d2-4268889648 .background-color-N7{background-color:#FFFFFF;}
|
||||
.d2-4268889648 .background-color-B1{background-color:#0D32B2;}
|
||||
.d2-4268889648 .background-color-B2{background-color:#0D32B2;}
|
||||
.d2-4268889648 .background-color-B3{background-color:#E3E9FD;}
|
||||
.d2-4268889648 .background-color-B4{background-color:#E3E9FD;}
|
||||
.d2-4268889648 .background-color-B5{background-color:#EDF0FD;}
|
||||
.d2-4268889648 .background-color-B6{background-color:#F7F8FE;}
|
||||
.d2-4268889648 .background-color-AA2{background-color:#4A6FF3;}
|
||||
.d2-4268889648 .background-color-AA4{background-color:#EDF0FD;}
|
||||
.d2-4268889648 .background-color-AA5{background-color:#F7F8FE;}
|
||||
.d2-4268889648 .background-color-AB4{background-color:#EDF0FD;}
|
||||
.d2-4268889648 .background-color-AB5{background-color:#F7F8FE;}
|
||||
.d2-4268889648 .color-N1{color:#0A0F25;}
|
||||
.d2-4268889648 .color-N2{color:#676C7E;}
|
||||
.d2-4268889648 .color-N3{color:#9499AB;}
|
||||
.d2-4268889648 .color-N4{color:#CFD2DD;}
|
||||
.d2-4268889648 .color-N5{color:#DEE1EB;}
|
||||
.d2-4268889648 .color-N6{color:#EEF1F8;}
|
||||
.d2-4268889648 .color-N7{color:#FFFFFF;}
|
||||
.d2-4268889648 .color-B1{color:#0D32B2;}
|
||||
.d2-4268889648 .color-B2{color:#0D32B2;}
|
||||
.d2-4268889648 .color-B3{color:#E3E9FD;}
|
||||
.d2-4268889648 .color-B4{color:#E3E9FD;}
|
||||
.d2-4268889648 .color-B5{color:#EDF0FD;}
|
||||
.d2-4268889648 .color-B6{color:#F7F8FE;}
|
||||
.d2-4268889648 .color-AA2{color:#4A6FF3;}
|
||||
.d2-4268889648 .color-AA4{color:#EDF0FD;}
|
||||
.d2-4268889648 .color-AA5{color:#F7F8FE;}
|
||||
.d2-4268889648 .color-AB4{color:#EDF0FD;}
|
||||
.d2-4268889648 .color-AB5{color:#F7F8FE;}.appendix text.text{fill:#0A0F25}.md{--color-fg-default:#0A0F25;--color-fg-muted:#676C7E;--color-fg-subtle:#9499AB;--color-canvas-default:#FFFFFF;--color-canvas-subtle:#EEF1F8;--color-border-default:#0D32B2;--color-border-muted:#0D32B2;--color-neutral-muted:#EEF1F8;--color-accent-fg:#0D32B2;--color-accent-emphasis:#0D32B2;--color-attention-subtle:#676C7E;--color-danger-fg:red;}.sketch-overlay-B1{fill:url(#streaks-darker-d2-4268889648);mix-blend-mode:lighten}.sketch-overlay-B2{fill:url(#streaks-darker-d2-4268889648);mix-blend-mode:lighten}.sketch-overlay-B3{fill:url(#streaks-bright-d2-4268889648);mix-blend-mode:darken}.sketch-overlay-B4{fill:url(#streaks-bright-d2-4268889648);mix-blend-mode:darken}.sketch-overlay-B5{fill:url(#streaks-bright-d2-4268889648);mix-blend-mode:darken}.sketch-overlay-B6{fill:url(#streaks-bright-d2-4268889648);mix-blend-mode:darken}.sketch-overlay-AA2{fill:url(#streaks-dark-d2-4268889648);mix-blend-mode:overlay}.sketch-overlay-AA4{fill:url(#streaks-bright-d2-4268889648);mix-blend-mode:darken}.sketch-overlay-AA5{fill:url(#streaks-bright-d2-4268889648);mix-blend-mode:darken}.sketch-overlay-AB4{fill:url(#streaks-bright-d2-4268889648);mix-blend-mode:darken}.sketch-overlay-AB5{fill:url(#streaks-bright-d2-4268889648);mix-blend-mode:darken}.sketch-overlay-N1{fill:url(#streaks-darker-d2-4268889648);mix-blend-mode:lighten}.sketch-overlay-N2{fill:url(#streaks-dark-d2-4268889648);mix-blend-mode:overlay}.sketch-overlay-N3{fill:url(#streaks-normal-d2-4268889648);mix-blend-mode:color-burn}.sketch-overlay-N4{fill:url(#streaks-normal-d2-4268889648);mix-blend-mode:color-burn}.sketch-overlay-N5{fill:url(#streaks-bright-d2-4268889648);mix-blend-mode:darken}.sketch-overlay-N6{fill:url(#streaks-bright-d2-4268889648);mix-blend-mode:darken}.sketch-overlay-N7{fill:url(#streaks-bright-d2-4268889648);mix-blend-mode:darken}.light-code{display: block}.dark-code{display: none}]]></style><g class="ZmV0Y2g="><g class="shape" ><rect x="0.000000" y="63.000000" width="82.000000" height="66.000000" stroke="#0D32B2" fill="#F7F8FE" class=" stroke-B1 fill-B6" style="stroke-width:2;" /></g><text x="41.000000" y="101.500000" fill="#0A0F25" class="text-bold fill-N1" style="text-anchor:middle;font-size:16px">fetch</text></g><g class="YnVpbGQ="><g class="shape" ><rect x="182.000000" y="63.000000" width="81.000000" height="66.000000" stroke="#0D32B2" fill="#F7F8FE" class=" stroke-B1 fill-B6" style="stroke-width:2;" /></g><text x="222.500000" y="101.500000" fill="#0A0F25" class="text-bold fill-N1" style="text-anchor:middle;font-size:16px">build</text></g><g class="dGVzdA=="><g class="shape" ><rect x="374.000000" y="0.000000" width="73.000000" height="66.000000" stroke="#0D32B2" fill="#F7F8FE" class=" stroke-B1 fill-B6" style="stroke-width:2;" /></g><text x="410.500000" y="38.500000" fill="#0A0F25" class="text-bold fill-N1" style="text-anchor:middle;font-size:16px">test</text></g><g class="ZGVwbG95"><g class="shape" ><rect x="363.000000" y="126.000000" width="95.000000" height="66.000000" stroke="#0D32B2" fill="#F7F8FE" class=" stroke-B1 fill-B6" style="stroke-width:2;" /></g><text x="410.500000" y="164.500000" fill="#0A0F25" class="text-bold fill-N1" style="text-anchor:middle;font-size:16px">deploy</text></g><g class="KGZldGNoIC0mZ3Q7IGJ1aWxkKVswXQ=="><marker id="mk-d2-4268889648-3488378134" markerWidth="10.000000" markerHeight="12.000000" refX="7.000000" refY="6.000000" viewBox="0.000000 0.000000 10.000000 12.000000" orient="auto" markerUnits="userSpaceOnUse"> <polygon points="0.000000,0.000000 10.000000,6.000000 0.000000,12.000000" fill="#0D32B2" class="connection fill-B1" stroke-width="2" /> </marker><path d="M 84.000000 96.000000 C 122.000000 96.000000 142.000000 96.000000 178.000000 96.000000" stroke="#0D32B2" fill="none" class="connection stroke-B1" style="stroke-width:2;" marker-end="url(#mk-d2-4268889648-3488378134)" mask="url(#d2-4268889648)" /></g><g class="KGJ1aWxkIC0mZ3Q7IHRlc3QpWzBd"><path d="M 264.638464 66.853075 C 303.000000 40.000000 325.200012 33.000000 370.000000 33.000000" stroke="#0D32B2" fill="none" class="connection stroke-B1" style="stroke-width:2;" marker-end="url(#mk-d2-4268889648-3488378134)" mask="url(#d2-4268889648)" /></g><g class="KGJ1aWxkIC0mZ3Q7IGRlcGxveSlbMF0="><path d="M 264.638464 125.146925 C 303.000000 152.000000 323.000000 159.000000 359.000000 159.000000" stroke="#0D32B2" fill="none" class="connection stroke-B1" style="stroke-width:2;" marker-end="url(#mk-d2-4268889648-3488378134)" mask="url(#d2-4268889648)" /></g><mask id="d2-4268889648" maskUnits="userSpaceOnUse" x="-101" y="-101" width="660" height="394">
|
||||
<rect x="-101" y="-101" width="660" height="394" fill="white"></rect>
|
||||
|
||||
</mask></svg></svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
@@ -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<SvgNode> 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 <text>.
|
||||
final labels = nodes.whereType<SvgText>().map((t) => t.text).toSet();
|
||||
expect(labels, containsAll(['fetch', 'build', 'test', 'deploy']));
|
||||
|
||||
// The node boxes render as <rect>.
|
||||
expect(nodes.whereType<SvgRect>(), isNotEmpty);
|
||||
|
||||
// The edges render as <path>, and their class-driven fill resolved to ARGB
|
||||
// (proves the <style> → inline normalize → colour pipeline end to end).
|
||||
final paths = nodes.whereType<SvgPath>().toList();
|
||||
expect(paths.length, greaterThanOrEqualTo(3));
|
||||
expect(paths.where((p) => p.style.fill != null), isNotEmpty);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import 'package:clide/src/svg/svg_document.dart';
|
||||
import 'package:clide/src/svg/svg_node.dart';
|
||||
import 'package:clide/src/svg/svg_path.dart';
|
||||
import 'package:clide/src/svg/svg_transform.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
List<SvgNode> kids(String s) => buildSvgDocument(s).root.children;
|
||||
|
||||
group('buildSvgDocument', () {
|
||||
test('a non-svg root yields the empty document', () {
|
||||
expect(buildSvgDocument('<div/>').root.children, isEmpty);
|
||||
expect(buildSvgDocument('garbage').root.children, isEmpty);
|
||||
});
|
||||
|
||||
test('width, height and viewBox', () {
|
||||
final d = buildSvgDocument('<svg width="480" height="360" viewBox="0 0 48 36"/>');
|
||||
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('<svg><rect x="1" y="2" width="3" height="4" rx="5"/></svg>').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('<svg><circle cx="5" cy="6" r="7"/></svg>').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('<svg><polygon points="0,0 10,0 10,10"/></svg>').single as SvgPolyline;
|
||||
expect(poly.points, [0, 0, 10, 0, 10, 10]);
|
||||
expect(poly.closed, isTrue);
|
||||
final line = kids('<svg><polyline points="0,0 5,5"/></svg>').single as SvgPolyline;
|
||||
expect(line.closed, isFalse);
|
||||
});
|
||||
|
||||
test('path data is parsed into segments', () {
|
||||
final p = kids('<svg><path d="M0 0 L10 10"/></svg>').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('<svg><rect fill="#0D32B2" stroke="red"/></svg>').single as SvgRect;
|
||||
expect(r.style.fill, 0xFF0D32B2);
|
||||
expect(r.style.stroke, 0xFFFF0000);
|
||||
});
|
||||
|
||||
test('inheritable style flows from the parent group', () {
|
||||
final g = kids('<svg><g fill="red"><rect/></g></svg>').single as SvgGroup;
|
||||
expect((g.children.single as SvgRect).style.fill, 0xFFFF0000);
|
||||
});
|
||||
|
||||
test('a child overrides an inherited value', () {
|
||||
final g = kids('<svg><g fill="red"><rect fill="#00FF00"/></g></svg>').single as SvgGroup;
|
||||
expect((g.children.single as SvgRect).style.fill, 0xFF00FF00);
|
||||
});
|
||||
|
||||
test('opacity is not inherited', () {
|
||||
final g = kids('<svg><g opacity="0.5"><rect/></g></svg>').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('<svg><rect transform="translate(5,10)"/></svg>').single as SvgRect;
|
||||
expect(r.transform, const Affine(1, 0, 0, 1, 5, 10));
|
||||
expect((kids('<svg><rect/></svg>').single as SvgRect).transform, isNull);
|
||||
});
|
||||
|
||||
test('text content is collected and whitespace-collapsed', () {
|
||||
final t = kids('<svg><text x="1" y="2"> build </text></svg>').single as SvgText;
|
||||
expect(t.text, 'build');
|
||||
expect([t.x, t.y], [1, 2]);
|
||||
});
|
||||
|
||||
test('image reads the xlink:href fallback', () {
|
||||
final i = kids('<svg><image x="0" y="0" width="8" height="8" xlink:href="a.png"/></svg>').single as SvgImage;
|
||||
expect(i.href, 'a.png');
|
||||
});
|
||||
|
||||
test('lengths tolerate units', () {
|
||||
final t = kids('<svg><text font-size="12px">x</text></svg>').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(
|
||||
'<svg viewBox="0 0 100 100"><style>.fill-B1{fill:#0D32B2}</style>'
|
||||
'<path class="connection fill-B1" d="M0 0 L10 10"/></svg>',
|
||||
).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('<svg><defs><marker id="m"><polygon points="0,0 1,1"/></marker></defs><rect/></svg>');
|
||||
expect(k.map((n) => n.runtimeType.toString()), ['SvgRect']);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user