feat(svg): extract per-object data-* annotations for the drawing-card overlay (T-318)

The builder captures data-label / data-description / data-lightbox on SVG
elements into SvgDocument.annotations, each with the element's AABB in
viewBox coordinates (accumulated transform applied — rect/ellipse/image/
line/poly/path bounded; groups skipped, text degenerates to its anchor
point). Feeds the Flutter caption/lightbox overlay next. Flutter-free,
covered by dart test (6 new cases).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 22:31:04 +02:00
co-authored by Claude Opus 4.8
parent 75f378ff20
commit 78b0a7b8a4
5 changed files with 194 additions and 8 deletions
+99 -6
View File
@@ -28,13 +28,17 @@ SvgDocument buildSvgDocument(String src) {
final markers = <String, SvgMarker>{};
_collectMarkers(root, markers);
final annotations = <SvgAnnotation>[];
final style = _resolveStyle(root.attrs, SvgStyle.initial);
final rootTf = _transform(root.attrs['transform']);
final children = _children(root, style, rootTf ?? Affine.identity, annotations);
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)),
root: SvgGroup(style, rootTf, children),
markers: markers,
annotations: annotations,
);
}
@@ -58,7 +62,7 @@ SvgMarker _marker(XmlElement el) {
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),
children: _children(el, SvgStyle.initial, Affine.identity, []), // markers don't carry annotations
);
}
@@ -69,26 +73,33 @@ String? _markerRef(String? v) {
return m?.group(1) ?? (v.startsWith('#') ? v.substring(1) : null);
}
List<SvgNode> _children(XmlElement el, SvgStyle inherited) {
List<SvgNode> _children(XmlElement el, SvgStyle inherited, Affine accumulated, List<SvgAnnotation> annotations) {
final out = <SvgNode>[];
for (final c in el.children) {
if (c is XmlElement) {
final n = _node(c, inherited);
final n = _node(c, inherited, accumulated, annotations);
if (n != null) out.add(n);
}
}
return out;
}
SvgNode? _node(XmlElement el, SvgStyle inherited) {
SvgNode? _node(XmlElement el, SvgStyle inherited, Affine accumulated, List<SvgAnnotation> annotations) {
final style = _resolveStyle(el.attrs, inherited);
final tf = _transform(el.attrs['transform']);
final acc = tf == null ? accumulated : accumulated.multiply(tf);
final node = _build(el, style, tf, acc, annotations);
if (node != null) _maybeAnnotate(el, node, acc, annotations);
return node;
}
SvgNode? _build(XmlElement el, SvgStyle style, Affine? tf, Affine acc, List<SvgAnnotation> annotations) {
final a = el.attrs;
switch (el.name) {
case 'g':
case 'a':
case 'svg':
return SvgGroup(style, tf, _children(el, style));
return SvgGroup(style, tf, _children(el, style, acc, annotations));
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);
@@ -121,6 +132,88 @@ SvgNode? _node(XmlElement el, SvgStyle inherited) {
}
}
void _maybeAnnotate(XmlElement el, SvgNode node, Affine acc, List<SvgAnnotation> annotations) {
final label = el.attrs['data-label'];
final desc = el.attrs['data-description'];
final lightbox = el.attrs.containsKey('data-lightbox');
if (label == null && desc == null && !lightbox) return;
final box = _localBBox(node);
if (box == null) return;
final r = _transformedAABB(box, acc);
annotations.add(
SvgAnnotation(x: r[0], y: r[1], width: r[2], height: r[3], label: label, description: desc, lightbox: lightbox, href: node is SvgImage ? node.href : null),
);
}
/// Axis-aligned bounding box of [node] in its own local coordinates. Groups are
/// not bounded (anchor leaf shapes); text degenerates to its anchor point.
List<double>? _localBBox(SvgNode node) {
switch (node) {
case SvgRect r:
return [r.x, r.y, r.width, r.height];
case SvgImage i:
return [i.x, i.y, i.width, i.height];
case SvgEllipse e:
return [e.cx - e.rx, e.cy - e.ry, e.rx * 2, e.ry * 2];
case SvgLine l:
return _aabbOfPoints([l.x1, l.y1, l.x2, l.y2]);
case SvgPolyline p:
return _aabbOfPoints(p.points);
case SvgPath p:
return _aabbOfPoints(_pathPoints(p.segments));
case SvgText t:
return [t.x, t.y, 0, 0];
case SvgGroup _:
return null;
}
}
List<double> _pathPoints(List<SvgPathSeg> segs) {
final pts = <double>[];
for (final s in segs) {
switch (s.op) {
case SvgPathOp.moveTo:
case SvgPathOp.lineTo:
pts.addAll([s.args[0], s.args[1]]);
case SvgPathOp.cubicTo:
pts.addAll([s.args[0], s.args[1], s.args[2], s.args[3], s.args[4], s.args[5]]);
case SvgPathOp.quadTo:
pts.addAll([s.args[0], s.args[1], s.args[2], s.args[3]]);
case SvgPathOp.arcTo:
pts.addAll([s.args[5], s.args[6]]);
case SvgPathOp.close:
break;
}
}
return pts;
}
List<double>? _aabbOfPoints(List<double> pts) {
if (pts.length < 2) return null;
var minX = pts[0], minY = pts[1], maxX = pts[0], maxY = pts[1];
for (var i = 0; i + 1 < pts.length; i += 2) {
minX = pts[i] < minX ? pts[i] : minX;
maxX = pts[i] > maxX ? pts[i] : maxX;
minY = pts[i + 1] < minY ? pts[i + 1] : minY;
maxY = pts[i + 1] > maxY ? pts[i + 1] : maxY;
}
return [minX, minY, maxX - minX, maxY - minY];
}
/// Transform [box] = `[x, y, w, h]` by [m]; return the AABB `[x, y, w, h]`.
List<double> _transformedAABB(List<double> box, Affine m) {
final x = box[0], y = box[1], w = box[2], h = box[3];
final corners = [m.apply(x, y), m.apply(x + w, y), m.apply(x, y + h), m.apply(x + w, y + h)];
var minX = corners[0].$1, minY = corners[0].$2, maxX = corners[0].$1, maxY = corners[0].$2;
for (final c in corners) {
minX = c.$1 < minX ? c.$1 : minX;
maxX = c.$1 > maxX ? c.$1 : maxX;
minY = c.$2 < minY ? c.$2 : minY;
maxY = c.$2 > maxY ? c.$2 : maxY;
}
return [minX, minY, maxX - minX, maxY - minY];
}
SvgStyle _resolveStyle(Map<String, String> a, SvgStyle inh) {
int? color(String k, int? fb) {
final v = a[k];
+30 -2
View File
@@ -141,10 +141,35 @@ class SvgViewBox {
final double minX, minY, width, height;
}
/// A per-object overlay annotation (T-318, D-103): a caption and/or lightbox
/// affordance anchored to an SVG element carrying `data-label` /
/// `data-description` / `data-lightbox`. The rect is the element's axis-aligned
/// bounding box in viewBox (user-space) coordinates with its transform applied;
/// the Flutter overlay maps it through the same viewBox→size fit the painter
/// uses, so captions sit under the right spot.
class SvgAnnotation {
const SvgAnnotation({
required this.x,
required this.y,
required this.width,
required this.height,
this.label,
this.description,
this.lightbox = false,
this.href,
});
final double x, y, width, height;
final String? label, description;
final bool lightbox;
final String? href; // image href, for a lightbox target
}
/// A parsed SVG document: optional intrinsic [width]/[height] (px), optional
/// [viewBox], and the [root] group.
/// [viewBox], the [root] group, marker defs, and per-object overlay
/// [annotations].
class SvgDocument {
const SvgDocument({this.width, this.height, this.viewBox, required this.root, this.markers = const {}});
const SvgDocument({this.width, this.height, this.viewBox, required this.root, this.markers = const {}, this.annotations = const []});
static const empty = SvgDocument(root: SvgGroup(SvgStyle.initial, null, []));
@@ -154,4 +179,7 @@ class SvgDocument {
/// `<marker>` definitions by id, referenced by path `marker-*`.
final Map<String, SvgMarker> markers;
/// Overlay captions/lightbox anchors extracted from `data-*` attributes.
final List<SvgAnnotation> annotations;
}