diff --git a/lib/widgets/src/svg/svg_painter.dart b/lib/widgets/src/svg/svg_painter.dart index db3d6e90..4723f01c 100644 --- a/lib/widgets/src/svg/svg_painter.dart +++ b/lib/widgets/src/svg/svg_painter.dart @@ -6,9 +6,9 @@ /// scale, centred — `xMidYMid meet`) and drawing shapes/text with per-node /// transforms and opacity. /// -/// 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 +/// v1 scope: groups, rect/ellipse/line/poly/path, text, `marker-*` arrowheads +/// (rotated to the path direction), and `` via an injected resolver (the +/// caller owns href loading). Default paints follow SVG: fill black, stroke /// none, stroke-width 1. library; @@ -21,14 +21,27 @@ import 'package:clide/src/svg/svg_path.dart'; import 'package:clide/src/svg/svg_transform.dart'; import 'package:flutter/widgets.dart'; -/// Paint [doc] onto [canvas], fitting its viewBox into [size]. -void paintSvg(ui.Canvas canvas, Size size, SvgDocument doc) { +/// Resolves an `` href to an already-decoded image, or `null` if it +/// isn't available yet. The caller owns loading (file/asset/network) and policy; +/// the painter stays pure rendering. Returning `null` simply paints nothing. +typedef SvgImageResolver = ui.Image? Function(String href); + +/// Paint [doc] onto [canvas], fitting its viewBox into [size]. [images] resolves +/// `` hrefs to decoded images. +void paintSvg(ui.Canvas canvas, Size size, SvgDocument doc, {SvgImageResolver? images}) { canvas.save(); _applyViewport(canvas, size, doc); - _paintNode(canvas, doc.root, doc.markers); + _paintNode(canvas, doc.root, _Ctx(doc.markers, images)); canvas.restore(); } +/// Per-paint context threaded through the walk. +class _Ctx { + const _Ctx(this.markers, this.images); + final Map markers; + final SvgImageResolver? images; +} + void _applyViewport(ui.Canvas canvas, Size size, SvgDocument doc) { final vb = doc.viewBox; final srcW = vb?.width ?? doc.width ?? size.width; @@ -40,7 +53,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, Map markers) { +void _paintNode(ui.Canvas canvas, SvgNode node, _Ctx ctx) { canvas.save(); if (node.transform != null) canvas.transform(_matrix4(node.transform!)); final layered = node.style.opacity < 1.0; @@ -51,21 +64,27 @@ void _paintNode(ui.Canvas canvas, SvgNode node, Map markers) switch (node) { case SvgGroup g: for (final c in g.children) { - _paintNode(canvas, c, markers); + _paintNode(canvas, c, ctx); } case SvgText t: _paintText(canvas, t); - case SvgImage _: - break; // async href resolution deferred (v1) + case SvgImage im: + _paintImage(canvas, im, ctx); default: - _paintShape(canvas, node, markers); + _paintShape(canvas, node, ctx); } if (layered) canvas.restore(); canvas.restore(); } -void _paintShape(ui.Canvas canvas, SvgNode node, Map markers) { +void _paintImage(ui.Canvas canvas, SvgImage im, _Ctx ctx) { + final img = ctx.images?.call(im.href); + if (img == null) return; // not loaded / no resolver — paint nothing + canvas.drawImageRect(img, Rect.fromLTWH(0, 0, img.width.toDouble(), img.height.toDouble()), Rect.fromLTWH(im.x, im.y, im.width, im.height), ui.Paint()); +} + +void _paintShape(ui.Canvas canvas, SvgNode node, _Ctx ctx) { final path = _shapePath(node); if (path == null) return; final s = node.style; @@ -92,20 +111,20 @@ void _paintShape(ui.Canvas canvas, SvgNode node, Map markers) } // Markers (arrowheads) at the path ends, rotated to the path direction. - if (node is SvgPath && markers.isNotEmpty) { + if (node is SvgPath && ctx.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); + final end = node.markerEnd == null ? null : ctx.markers[node.markerEnd]; + if (end != null) _paintMarker(canvas, end, ex, ey, eAngle, sw, ctx); + final start = node.markerStart == null ? null : ctx.markers[node.markerStart]; + if (start != null) _paintMarker(canvas, start, sx, sy, sAngle, sw, ctx); } } } -void _paintMarker(ui.Canvas canvas, SvgMarker m, double x, double y, double angle, double strokeWidth, Map markers) { +void _paintMarker(ui.Canvas canvas, SvgMarker m, double x, double y, double angle, double strokeWidth, _Ctx ctx) { canvas.save(); canvas.translate(x, y); canvas.rotate(m.orientAuto ? angle : m.orientAngle * math.pi / 180); @@ -113,7 +132,7 @@ void _paintMarker(ui.Canvas canvas, SvgMarker m, double x, double y, double angl // 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); + _paintNode(canvas, c, ctx); } canvas.restore(); } @@ -277,24 +296,30 @@ Float64List _matrix4(Affine m) => Float64List.fromList([ m.e, m.f, 0, 1, // ]); -/// A `CustomPainter` that draws an [SvgDocument]. Repaints only when the -/// document instance changes. +/// A `CustomPainter` that draws an [SvgDocument]. Repaints when the document +/// instance or the [images] resolver changes. class SvgScenePainter extends CustomPainter { - const SvgScenePainter(this.document); + const SvgScenePainter(this.document, {this.images}); final SvgDocument document; + final SvgImageResolver? images; @override - void paint(ui.Canvas canvas, Size size) => paintSvg(canvas, size, document); + void paint(ui.Canvas canvas, Size size) => paintSvg(canvas, size, document, images: images); @override - bool shouldRepaint(SvgScenePainter old) => !identical(old.document, document); + bool shouldRepaint(SvgScenePainter old) => !identical(old.document, document) || old.images != images; } -/// A widget that renders an [SvgDocument], filling its constraints. +/// A widget that renders an [SvgDocument], filling its constraints. [images] +/// resolves `` hrefs to decoded images (loading is the caller's job). class SvgView extends StatelessWidget { - const SvgView({super.key, required this.document}); + const SvgView({super.key, required this.document, this.images}); final SvgDocument document; + final SvgImageResolver? images; @override - Widget build(BuildContext context) => CustomPaint(painter: SvgScenePainter(document), child: const SizedBox.expand()); + Widget build(BuildContext context) => CustomPaint( + painter: SvgScenePainter(document, images: images), + child: const SizedBox.expand(), + ); } diff --git a/test/widgets/svg/svg_painter_test.dart b/test/widgets/svg/svg_painter_test.dart index 63b62f6c..41b96ba7 100644 --- a/test/widgets/svg/svg_painter_test.dart +++ b/test/widgets/svg/svg_painter_test.dart @@ -1,4 +1,6 @@ +import 'dart:async'; import 'dart:io'; +import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:clide/src/svg/svg_document.dart'; @@ -11,13 +13,26 @@ import 'package:flutter_test/flutter_test.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - Future render(String svg, double w, double h) async { + Future render(String svg, double w, double h, {SvgImageResolver? images}) async { final recorder = ui.PictureRecorder(); final canvas = ui.Canvas(recorder, Rect.fromLTWH(0, 0, w, h)); - paintSvg(canvas, Size(w, h), buildSvgDocument(svg)); + paintSvg(canvas, Size(w, h), buildSvgDocument(svg), images: images); return recorder.endRecording().toImage(w.round(), h.round()); } + Future solidImage(int w, int h, int argb) { + final px = Uint8List(w * h * 4); + for (var i = 0; i < w * h; i++) { + px[i * 4] = (argb >> 16) & 0xFF; + px[i * 4 + 1] = (argb >> 8) & 0xFF; + px[i * 4 + 2] = argb & 0xFF; + px[i * 4 + 3] = (argb >> 24) & 0xFF; + } + final c = Completer(); + ui.decodeImageFromPixels(px, w, h, ui.PixelFormat.rgba8888, c.complete); + return c.future; + } + Future argbAt(ui.Image img, int x, int y) async { final data = (await img.toByteData())!; final i = (y * img.width + x) * 4; @@ -82,6 +97,19 @@ void main() { expect(greenDrawn, isTrue, reason: 'the green arrowhead should have been painted'); }); + test('paints an via the injected resolver, into its dest rect', () async { + final pic = await solidImage(4, 4, 0xFFFF00FF); + const svg = ''; + final img = await render(svg, 10, 10, images: (href) => href == 'pic' ? pic : null); + expect(await argbAt(img, 5, 5), 0xFFFF00FF); + }); + + test('an with no resolver paints nothing', () async { + const svg = ''; + final img = await render(svg, 10, 10); + expect(alpha(await argbAt(img, 5, 5)), 0); + }); + 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);