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