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:
@@ -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':
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user