feat(svg): marker/arrowhead rendering for the drawing-card renderer (T-320)

Collect <marker> defs (refX/refY, orient=auto, markerUnits) and the path
marker-start/mid/end refs in the builder, then paint them at the path
endpoints rotated to the path tangent (orient=auto). The viewBox→viewport
scale is approximated 1:1 (holds for d2's markers). d2 edges now render
with arrowheads. Covered by dart test (builder) + a flutter-test pixel
probe (the green arrowhead draws). image painting remains the last T-320
follow-on.

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 91607d54d1
commit 718d2b39c2
7 changed files with 248 additions and 14 deletions
+43 -1
View File
@@ -25,15 +25,50 @@ SvgDocument buildSvgDocument(String src) {
if (root == null || root.name != 'svg') return SvgDocument.empty;
inlineStyles(root);
final markers = <String, SvgMarker>{};
_collectMarkers(root, markers);
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)),
markers: markers,
);
}
void _collectMarkers(XmlElement el, Map<String, SvgMarker> into) {
if (el.name == 'marker') {
final id = el.attrs['id'];
if (id != null && id.isNotEmpty) into[id] = _marker(el);
}
for (final c in el.children) {
if (c is XmlElement) _collectMarkers(c, into);
}
}
SvgMarker _marker(XmlElement el) {
final orient = el.attrs['orient'];
final auto = orient == 'auto' || orient == 'auto-start-reverse';
return SvgMarker(
refX: _num(el.attrs['refX']),
refY: _num(el.attrs['refY']),
orientAuto: auto,
orientAngle: (orient != null && !auto) ? (_stripNum(orient) ?? 0) : 0,
strokeScaled: el.attrs['markerUnits'] != 'userSpaceOnUse', // default = strokeWidth
viewBox: _viewBox(el.attrs['viewBox']),
children: _children(el, SvgStyle.initial),
);
}
/// Extract the id from a `marker-*` value like `url(#id)`.
String? _markerRef(String? v) {
if (v == null) return null;
final m = RegExp(r'url\(\s*#([^)\s]+)\s*\)').firstMatch(v);
return m?.group(1) ?? (v.startsWith('#') ? v.substring(1) : null);
}
List<SvgNode> _children(XmlElement el, SvgStyle inherited) {
final out = <SvgNode>[];
for (final c in el.children) {
@@ -69,7 +104,14 @@ SvgNode? _node(XmlElement el, SvgStyle inherited) {
case 'polygon':
return SvgPolyline(style, tf, _points(a['points']), true);
case 'path':
return SvgPath(style, tf, parseSvgPath(a['d'] ?? ''));
return SvgPath(
style,
tf,
parseSvgPath(a['d'] ?? ''),
markerStart: _markerRef(a['marker-start']),
markerMid: _markerRef(a['marker-mid']),
markerEnd: _markerRef(a['marker-end']),
);
case 'text':
return SvgText(style, tf, _num(a['x']), _num(a['y']), _textOf(el));
case 'image':
+28 -2
View File
@@ -96,8 +96,31 @@ class SvgPolyline extends SvgNode {
}
class SvgPath extends SvgNode {
const SvgPath(super.style, super.transform, this.segments);
const SvgPath(super.style, super.transform, this.segments, {this.markerStart, this.markerMid, this.markerEnd});
final List<SvgPathSeg> segments;
/// Ids (sans `url(#…)`) of `marker-start`/`mid`/`end` definitions, if any.
final String? markerStart, markerMid, markerEnd;
}
/// A `<marker>` definition (e.g. an arrowhead), referenced by `marker-*`.
class SvgMarker {
const SvgMarker({
required this.refX,
required this.refY,
required this.orientAuto,
required this.orientAngle,
required this.strokeScaled,
required this.children,
this.viewBox,
});
final double refX, refY;
final bool orientAuto; // orient="auto"
final double orientAngle; // fixed angle (degrees) when not auto
final bool strokeScaled; // markerUnits=strokeWidth (default) vs userSpaceOnUse
final SvgViewBox? viewBox;
final List<SvgNode> children;
}
class SvgText extends SvgNode {
@@ -121,11 +144,14 @@ class SvgViewBox {
/// 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});
const SvgDocument({this.width, this.height, this.viewBox, required this.root, this.markers = const {}});
static const empty = SvgDocument(root: SvgGroup(SvgStyle.initial, null, []));
final double? width, height;
final SvgViewBox? viewBox;
final SvgGroup root;
/// `<marker>` definitions by id, referenced by path `marker-*`.
final Map<String, SvgMarker> markers;
}
+89 -8
View File
@@ -6,9 +6,9 @@
/// scale, centred — `xMidYMid meet`) and drawing shapes/text with per-node
/// transforms and opacity.
///
/// v1 scope (matches the builder): groups, rect/ellipse/line/poly/path, text.
/// `image` href resolution is async and deferred (not painted yet); markers
/// (arrowheads) are a follow-on. Default paints follow SVG: fill black, stroke
/// v1 scope: groups, rect/ellipse/line/poly/path, text, and `marker-*`
/// arrowheads (rotated to the path direction). `image` href resolution is async
/// and deferred (not painted yet). Default paints follow SVG: fill black, stroke
/// none, stroke-width 1.
library;
@@ -25,7 +25,7 @@ import 'package:flutter/widgets.dart';
void paintSvg(ui.Canvas canvas, Size size, SvgDocument doc) {
canvas.save();
_applyViewport(canvas, size, doc);
_paintNode(canvas, doc.root);
_paintNode(canvas, doc.root, doc.markers);
canvas.restore();
}
@@ -40,7 +40,7 @@ void _applyViewport(ui.Canvas canvas, Size size, SvgDocument doc) {
if (vb != null) canvas.translate(-vb.minX, -vb.minY);
}
void _paintNode(ui.Canvas canvas, SvgNode node) {
void _paintNode(ui.Canvas canvas, SvgNode node, Map<String, SvgMarker> markers) {
canvas.save();
if (node.transform != null) canvas.transform(_matrix4(node.transform!));
final layered = node.style.opacity < 1.0;
@@ -51,21 +51,21 @@ void _paintNode(ui.Canvas canvas, SvgNode node) {
switch (node) {
case SvgGroup g:
for (final c in g.children) {
_paintNode(canvas, c);
_paintNode(canvas, c, markers);
}
case SvgText t:
_paintText(canvas, t);
case SvgImage _:
break; // async href resolution deferred (v1)
default:
_paintShape(canvas, node);
_paintShape(canvas, node, markers);
}
if (layered) canvas.restore();
canvas.restore();
}
void _paintShape(ui.Canvas canvas, SvgNode node) {
void _paintShape(ui.Canvas canvas, SvgNode node, Map<String, SvgMarker> markers) {
final path = _shapePath(node);
if (path == null) return;
final s = node.style;
@@ -90,6 +90,87 @@ void _paintShape(ui.Canvas canvas, SvgNode node) {
..strokeJoin = _join(s.lineJoin),
);
}
// Markers (arrowheads) at the path ends, rotated to the path direction.
if (node is SvgPath && markers.isNotEmpty) {
final ends = _pathEnds(node.segments);
if (ends != null) {
final (sx, sy, sAngle, ex, ey, eAngle) = ends;
final sw = s.strokeWidth ?? 1.0;
final end = node.markerEnd == null ? null : markers[node.markerEnd];
if (end != null) _paintMarker(canvas, end, ex, ey, eAngle, sw, markers);
final start = node.markerStart == null ? null : markers[node.markerStart];
if (start != null) _paintMarker(canvas, start, sx, sy, sAngle, sw, markers);
}
}
}
void _paintMarker(ui.Canvas canvas, SvgMarker m, double x, double y, double angle, double strokeWidth, Map<String, SvgMarker> markers) {
canvas.save();
canvas.translate(x, y);
canvas.rotate(m.orientAuto ? angle : m.orientAngle * math.pi / 180);
if (m.strokeScaled) canvas.scale(strokeWidth);
// viewBox→viewport scaling is approximated 1:1 (holds for d2's markers).
canvas.translate(-m.refX, -m.refY);
for (final c in m.children) {
_paintNode(canvas, c, markers);
}
canvas.restore();
}
/// Start/end points and tangent angles of a path: `(sx, sy, startAngle, ex, ey,
/// endAngle)`, or `null` if the path has no drawing segments.
(double, double, double, double, double, double)? _pathEnds(List<SvgPathSeg> segs) {
double cx = 0, cy = 0, sx = 0, sy = 0;
double startAngle = 0, fx = 0, fy = 0;
var seenDraw = false, seenEnd = false;
for (final s in segs) {
final a = s.args;
switch (s.op) {
case SvgPathOp.moveTo:
cx = a[0];
cy = a[1];
sx = cx;
sy = cy;
case SvgPathOp.lineTo:
if (!seenDraw) startAngle = math.atan2(a[1] - cy, a[0] - cx);
fx = cx;
fy = cy;
cx = a[0];
cy = a[1];
seenDraw = seenEnd = true;
case SvgPathOp.cubicTo:
if (!seenDraw) startAngle = math.atan2(a[1] - cy, a[0] - cx);
fx = a[2];
fy = a[3];
cx = a[4];
cy = a[5];
seenDraw = seenEnd = true;
case SvgPathOp.quadTo:
if (!seenDraw) startAngle = math.atan2(a[1] - cy, a[0] - cx);
fx = a[0];
fy = a[1];
cx = a[2];
cy = a[3];
seenDraw = seenEnd = true;
case SvgPathOp.arcTo:
if (!seenDraw) startAngle = math.atan2(a[6] - cy, a[5] - cx);
fx = cx;
fy = cy;
cx = a[5];
cy = a[6];
seenDraw = seenEnd = true;
case SvgPathOp.close:
if (!seenDraw) startAngle = math.atan2(sy - cy, sx - cx);
fx = cx;
fy = cy;
cx = sx;
cy = sy;
seenDraw = seenEnd = true;
}
}
if (!seenEnd) return null;
return (sx, sy, startAngle, cx, cy, math.atan2(cy - fy, cx - fx));
}
ui.Path? _shapePath(SvgNode node) {