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:
@@ -8108,3 +8108,38 @@ CSS DECISION — inline-normalize (option A): d2 emits a <style> block of FLAT s
|
||||
BUILD SPLIT: (1) inline-style normalizer; (2) path-data parser; (3) the CustomPaint painter (shapes/text/image/marker/transform); (4) injected image-href resolver (like image.show). Fixtures: real d2 + graphviz output.', NULL, '2026-06-28 17:54:38', '2026-06-28 17:54:38.871', '2026-06-28 17:54:38.871', NULL, 'afedea62e1486510e317fc4afb0df528', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGYS06SDJGZ4W3Z9V9HDEATW', 'description', NULL, 'The pre-commit hook re-exports + stages .pql/changelog on commit but only catches the tickets + ticket_history tables — it leaves ticket_deps (blocker edges) and ticket_idmap (new T-NNN <-> record_id mappings) UNSTAGED. Seen repeatedly this session (D-103 re-sequencing blockers; T-495 id mapping), each needing a manual follow-up commit; otherwise the planning state silently doesn''t persist and a later branch switch drops it — exactly the failure the hook exists to prevent. Fix: stage ALL of .pql/changelog/ (git add .pql/changelog/), not just the tables the export rewrote. Repro: pql ticket block X --by Y + create a ticket, commit unrelated files, observe ticket_deps/ticket_idmap still modified afterward.', NULL, '2026-06-28 18:00:41', '2026-06-28 18:00:41.453', '2026-06-28 18:00:41.453', NULL, '81bf67c8003a8e48eb20c5c0b16fc90c', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ETJQP0CT6X7W3CWZ6NS9G', 'status', 'backlog', 'in_progress', NULL, '2026-06-28 18:04:13', '2026-06-28 18:04:13.608', '2026-06-28 18:04:13.608', NULL, 'd1705e1dbede4e9b9cbe8d1fa6e4352e', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ETJQP0CT6X7W3CWZ6NS9G', 'description', 'Template for the unified drawing card (T-317, D-91): render an SVG file referenced in the JSON. clide-owned rendering per the own-the-rendering-stack guardrail — no opinionated SVG package without justification; evaluate what is feasible via CustomPaint / a minimal path parser, and scope accordingly (SVG is a broad spec; define the supported subset). Optional label + description beneath (T-318). Display-only (D-78). Depends on the core engine (T-318).
|
||||
|
||||
PROMOTED TO ENGINE (D-103, 2026-06-28): this is now the drawing-card''s rendering FOUNDATION, not one template among many. Build the clide-owned CustomPaint SVG renderer over a BOUNDED subset (rect/line/polyline/polygon/circle/ellipse/path, text incl. the Phosphor font, image, g + transform, basic fill/stroke/opacity/rx). T-318 (envelope + dispatch + Flutter overlay) and every template lower onto this; d2/graphviz/mermaid output renders for free. No longer blocked by T-318 (dependency inverted). Open: pin the subset to what our own templates + d2/graphviz emit. Schema: docs/design/drawing-card-schema.md.
|
||||
|
||||
SVG SUBSET FINALIZED (2026-06-28, grounded in a real d2 sample + our own templates):
|
||||
|
||||
STRUCTURE: <svg> (viewBox/width/height, incl. nested svg), <g> (transform, opacity, class), <defs>, <marker> (+ marker-start/mid/end; orient=auto, refX/refY, viewBox) — edge arrowheads.
|
||||
SHAPES: rect (rx/ry), circle, ellipse, line, polyline, polygon, path (full data: M L H V C S Q T A Z + relatives).
|
||||
TEXT: text + tspan (x/y/dx/dy, font-family incl. Phosphor, font-size/weight, text-anchor, dominant-baseline).
|
||||
RASTER: image (href/xlink:href, x/y/w/h, preserveAspectRatio) — our image/compare templates.
|
||||
STYLING: presentation attrs (fill, fill-opacity, stroke, stroke-width, stroke-linecap/linejoin, stroke-dasharray, opacity, color), transform (translate/scale/rotate/matrix), class= resolved via the normalizer below.
|
||||
DEFERRED v1: <mask> (d2 masks connections for clean edge/node joins) — ignore and rely on node-over-edge paint order; add basic mask only if d2 output looks wrong.
|
||||
OUT: foreignObject, filters, <animate>/SMIL, scripting, <use>/<symbol>, gradients, patterns, clipPath. (So mermaid, which leans on foreignObject, is not a launch target.)
|
||||
|
||||
CSS DECISION — inline-normalize (option A): d2 emits a <style> block of FLAT single-class selectors (.fill-B1, .color-N1, .shape, .connection, .text-bold → presentation props). A preprocessing normalizer parses <style> into class->props, merges each element''s class props into inline presentation attributes (inline wins), and drops <style>; the painter then only ever sees inline attrs — a pure, testable presentation-attribute renderer. Chosen over a render-time CSS resolver to keep the painter free of any CSS concept.
|
||||
|
||||
BUILD SPLIT: (1) inline-style normalizer; (2) path-data parser; (3) the CustomPaint painter (shapes/text/image/marker/transform); (4) injected image-href resolver (like image.show). Fixtures: real d2 + graphviz output.', 'Template for the unified drawing card (T-317, D-91): render an SVG file referenced in the JSON. clide-owned rendering per the own-the-rendering-stack guardrail — no opinionated SVG package without justification; evaluate what is feasible via CustomPaint / a minimal path parser, and scope accordingly (SVG is a broad spec; define the supported subset). Optional label + description beneath (T-318). Display-only (D-78). Depends on the core engine (T-318).
|
||||
|
||||
PROMOTED TO ENGINE (D-103, 2026-06-28): this is now the drawing-card''s rendering FOUNDATION, not one template among many. Build the clide-owned CustomPaint SVG renderer over a BOUNDED subset (rect/line/polyline/polygon/circle/ellipse/path, text incl. the Phosphor font, image, g + transform, basic fill/stroke/opacity/rx). T-318 (envelope + dispatch + Flutter overlay) and every template lower onto this; d2/graphviz/mermaid output renders for free. No longer blocked by T-318 (dependency inverted). Open: pin the subset to what our own templates + d2/graphviz emit. Schema: docs/design/drawing-card-schema.md.
|
||||
|
||||
SVG SUBSET FINALIZED (2026-06-28, grounded in a real d2 sample + our own templates):
|
||||
|
||||
STRUCTURE: <svg> (viewBox/width/height, incl. nested svg), <g> (transform, opacity, class), <defs>, <marker> (+ marker-start/mid/end; orient=auto, refX/refY, viewBox) — edge arrowheads.
|
||||
SHAPES: rect (rx/ry), circle, ellipse, line, polyline, polygon, path (full data: M L H V C S Q T A Z + relatives).
|
||||
TEXT: text + tspan (x/y/dx/dy, font-family incl. Phosphor, font-size/weight, text-anchor, dominant-baseline).
|
||||
RASTER: image (href/xlink:href, x/y/w/h, preserveAspectRatio) — our image/compare templates.
|
||||
STYLING: presentation attrs (fill, fill-opacity, stroke, stroke-width, stroke-linecap/linejoin, stroke-dasharray, opacity, color), transform (translate/scale/rotate/matrix), class= resolved via the normalizer below.
|
||||
DEFERRED v1: <mask> (d2 masks connections for clean edge/node joins) — ignore and rely on node-over-edge paint order; add basic mask only if d2 output looks wrong.
|
||||
OUT: foreignObject, filters, <animate>/SMIL, scripting, <use>/<symbol>, gradients, patterns, clipPath. (So mermaid, which leans on foreignObject, is not a launch target.)
|
||||
|
||||
CSS DECISION — inline-normalize (option A): d2 emits a <style> block of FLAT single-class selectors (.fill-B1, .color-N1, .shape, .connection, .text-bold → presentation props). A preprocessing normalizer parses <style> into class->props, merges each element''s class props into inline presentation attributes (inline wins), and drops <style>; the painter then only ever sees inline attrs — a pure, testable presentation-attribute renderer. Chosen over a render-time CSS resolver to keep the painter free of any CSS concept.
|
||||
|
||||
BUILD SPLIT: (1) inline-style normalizer; (2) path-data parser; (3) the CustomPaint painter (shapes/text/image/marker/transform); (4) injected image-href resolver (like image.show). Fixtures: real d2 + graphviz output.
|
||||
|
||||
RENDERER CORE DONE (2026-06-28): the pure-Dart pipeline (5 parsers + typed model + builder; 82 dart-test cases incl. a real-d2 fixture) plus the CustomPaint painter (lib/widgets/src/svg/svg_painter.dart; 8 pixel-probe cases under flutter test) render real d2 output end to end — shapes/text/transforms/group-opacity/viewBox all draw. Exposes paintSvg + SvgScenePainter + SvgView. REMAINING for T-320: (1) markers/arrowheads — collect <marker> defs in the builder + paint marker-end at path endpoints (orient=auto); (2) image painting — async href → ui.Image via an injected resolver (like image.show), currently skipped. After those, T-318 (envelope/dispatch/overlay) consumes SvgView.', NULL, '2026-06-28 18:59:56', '2026-06-28 18:59:56.371', '2026-06-28 18:59:56.371', NULL, '46585e90df499aa7dce4886336e56752', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -9980,3 +9980,22 @@ OUT: foreignObject, filters, <animate>/SMIL, scripting, <use>/<symbol>, gradient
|
||||
CSS DECISION — inline-normalize (option A): d2 emits a <style> block of FLAT single-class selectors (.fill-B1, .color-N1, .shape, .connection, .text-bold → presentation props). A preprocessing normalizer parses <style> into class->props, merges each element''s class props into inline presentation attributes (inline wins), and drops <style>; the painter then only ever sees inline attrs — a pure, testable presentation-attribute renderer. Chosen over a render-time CSS resolver to keep the painter free of any CSS concept.
|
||||
|
||||
BUILD SPLIT: (1) inline-style normalizer; (2) path-data parser; (3) the CustomPaint painter (shapes/text/image/marker/transform); (4) injected image-href resolver (like image.show). Fixtures: real d2 + graphviz output.', 'in_progress', 'low', NULL, NULL, NULL, '2026-06-10 11:11:55', '2026-06-28 18:04:13.608', NULL, 'ea0df82b234d48fcb6008bb09c927578', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FB2ETJQP0CT6X7W3CWZ6NS9G', 'task', '06FB2EDCBYRBDSV9V1PJ1KE3CM', 'Drawing card template: SVG render', 'Template for the unified drawing card (T-317, D-91): render an SVG file referenced in the JSON. clide-owned rendering per the own-the-rendering-stack guardrail — no opinionated SVG package without justification; evaluate what is feasible via CustomPaint / a minimal path parser, and scope accordingly (SVG is a broad spec; define the supported subset). Optional label + description beneath (T-318). Display-only (D-78). Depends on the core engine (T-318).
|
||||
|
||||
PROMOTED TO ENGINE (D-103, 2026-06-28): this is now the drawing-card''s rendering FOUNDATION, not one template among many. Build the clide-owned CustomPaint SVG renderer over a BOUNDED subset (rect/line/polyline/polygon/circle/ellipse/path, text incl. the Phosphor font, image, g + transform, basic fill/stroke/opacity/rx). T-318 (envelope + dispatch + Flutter overlay) and every template lower onto this; d2/graphviz/mermaid output renders for free. No longer blocked by T-318 (dependency inverted). Open: pin the subset to what our own templates + d2/graphviz emit. Schema: docs/design/drawing-card-schema.md.
|
||||
|
||||
SVG SUBSET FINALIZED (2026-06-28, grounded in a real d2 sample + our own templates):
|
||||
|
||||
STRUCTURE: <svg> (viewBox/width/height, incl. nested svg), <g> (transform, opacity, class), <defs>, <marker> (+ marker-start/mid/end; orient=auto, refX/refY, viewBox) — edge arrowheads.
|
||||
SHAPES: rect (rx/ry), circle, ellipse, line, polyline, polygon, path (full data: M L H V C S Q T A Z + relatives).
|
||||
TEXT: text + tspan (x/y/dx/dy, font-family incl. Phosphor, font-size/weight, text-anchor, dominant-baseline).
|
||||
RASTER: image (href/xlink:href, x/y/w/h, preserveAspectRatio) — our image/compare templates.
|
||||
STYLING: presentation attrs (fill, fill-opacity, stroke, stroke-width, stroke-linecap/linejoin, stroke-dasharray, opacity, color), transform (translate/scale/rotate/matrix), class= resolved via the normalizer below.
|
||||
DEFERRED v1: <mask> (d2 masks connections for clean edge/node joins) — ignore and rely on node-over-edge paint order; add basic mask only if d2 output looks wrong.
|
||||
OUT: foreignObject, filters, <animate>/SMIL, scripting, <use>/<symbol>, gradients, patterns, clipPath. (So mermaid, which leans on foreignObject, is not a launch target.)
|
||||
|
||||
CSS DECISION — inline-normalize (option A): d2 emits a <style> block of FLAT single-class selectors (.fill-B1, .color-N1, .shape, .connection, .text-bold → presentation props). A preprocessing normalizer parses <style> into class->props, merges each element''s class props into inline presentation attributes (inline wins), and drops <style>; the painter then only ever sees inline attrs — a pure, testable presentation-attribute renderer. Chosen over a render-time CSS resolver to keep the painter free of any CSS concept.
|
||||
|
||||
BUILD SPLIT: (1) inline-style normalizer; (2) path-data parser; (3) the CustomPaint painter (shapes/text/image/marker/transform); (4) injected image-href resolver (like image.show). Fixtures: real d2 + graphviz output.
|
||||
|
||||
RENDERER CORE DONE (2026-06-28): the pure-Dart pipeline (5 parsers + typed model + builder; 82 dart-test cases incl. a real-d2 fixture) plus the CustomPaint painter (lib/widgets/src/svg/svg_painter.dart; 8 pixel-probe cases under flutter test) render real d2 output end to end — shapes/text/transforms/group-opacity/viewBox all draw. Exposes paintSvg + SvgScenePainter + SvgView. REMAINING for T-320: (1) markers/arrowheads — collect <marker> defs in the builder + paint marker-end at path endpoints (orient=auto); (2) image painting — async href → ui.Image via an injected resolver (like image.show), currently skipped. After those, T-318 (envelope/dispatch/overlay) consumes SvgView.', 'in_progress', 'low', NULL, NULL, NULL, '2026-06-10 11:11:55', '2026-06-28 18:59:56.371', NULL, '3748efc57b0f8cd46f1665d805f491c6', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -104,9 +104,21 @@ void main() {
|
||||
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']);
|
||||
test('marker defs are collected (not drawn inline) with the path marker-end ref', () {
|
||||
final d = buildSvgDocument(
|
||||
'<svg><defs><marker id="arrow" refX="7" refY="6" orient="auto" markerUnits="userSpaceOnUse">'
|
||||
'<polygon points="0,0 10,6 0,12" fill="#0D32B2"/></marker></defs>'
|
||||
'<path d="M0 0 L10 0" marker-end="url(#arrow)"/></svg>',
|
||||
);
|
||||
// defs/marker are not drawable children — only the path is.
|
||||
expect(d.root.children.map((n) => n.runtimeType.toString()), ['SvgPath']);
|
||||
// …but the marker is collected, with its content style resolved.
|
||||
final m = d.markers['arrow']!;
|
||||
expect(m.refX, 7);
|
||||
expect(m.orientAuto, isTrue);
|
||||
expect(m.strokeScaled, isFalse); // markerUnits=userSpaceOnUse
|
||||
expect((m.children.single as SvgPolyline).style.fill, 0xFF0D32B2);
|
||||
expect((d.root.children.single as SvgPath).markerEnd, 'arrow');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,6 +63,25 @@ void main() {
|
||||
expect(alpha(await argbAt(img, 5, 5)), closeTo(128, 4));
|
||||
});
|
||||
|
||||
test('draws a marker-end arrowhead, rotated to the path direction', () async {
|
||||
// A rightward path with a green right-pointing triangle marker at its end.
|
||||
const svg =
|
||||
'<svg viewBox="0 0 20 10">'
|
||||
'<defs><marker id="a" refX="0" refY="2" orient="auto" markerUnits="userSpaceOnUse">'
|
||||
'<polygon points="0,0 4,2 0,4" fill="#00FF00"/></marker></defs>'
|
||||
'<path d="M2 5 L12 5" stroke="#000000" marker-end="url(#a)"/></svg>';
|
||||
final data = (await (await render(svg, 40, 20)).toByteData())!;
|
||||
var greenDrawn = false;
|
||||
for (var i = 0; i < data.lengthInBytes; i += 4) {
|
||||
final r = data.getUint8(i), g = data.getUint8(i + 1), b = data.getUint8(i + 2), a = data.getUint8(i + 3);
|
||||
if (a > 200 && g > 200 && r < 80 && b < 80) {
|
||||
greenDrawn = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(greenDrawn, isTrue, reason: 'the green arrowhead should have been painted');
|
||||
});
|
||||
|
||||
test('renders the real d2 fixture without error and draws ink', () async {
|
||||
final svg = File('test/svg/fixtures/d2_pipeline.svg').readAsStringSync();
|
||||
final img = await render(svg, 200, 120);
|
||||
|
||||
Reference in New Issue
Block a user