feat(svg): paint <image> via an injected resolver — completes the renderer (T-320)
paintSvg/SvgScenePainter/SvgView take an optional SvgImageResolver (href → decoded ui.Image); image nodes draw into their dest rect. The caller owns loading (file/asset/network), so the painter stays pure rendering. Threaded marker + resolver through a small paint context. Pixel-probe tested (image draws via the resolver; nothing without one). The clide-owned SVG renderer is now feature-complete for T-320. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 `<image>` 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 `<image>` 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
|
||||
/// `<image>` 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<String, SvgMarker> 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<String, SvgMarker> 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<String, SvgMarker> 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<String, SvgMarker> 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<String, SvgMarker> 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<String, SvgMarker> 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 `<image>` 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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<ui.Image> render(String svg, double w, double h) async {
|
||||
Future<ui.Image> 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<ui.Image> 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.Image>();
|
||||
ui.decodeImageFromPixels(px, w, h, ui.PixelFormat.rgba8888, c.complete);
|
||||
return c.future;
|
||||
}
|
||||
|
||||
Future<int> 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 <image> via the injected resolver, into its dest rect', () async {
|
||||
final pic = await solidImage(4, 4, 0xFFFF00FF);
|
||||
const svg = '<svg viewBox="0 0 10 10"><image x="0" y="0" width="10" height="10" href="pic"/></svg>';
|
||||
final img = await render(svg, 10, 10, images: (href) => href == 'pic' ? pic : null);
|
||||
expect(await argbAt(img, 5, 5), 0xFFFF00FF);
|
||||
});
|
||||
|
||||
test('an <image> with no resolver paints nothing', () async {
|
||||
const svg = '<svg viewBox="0 0 10 10"><image x="0" y="0" width="10" height="10" href="pic"/></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);
|
||||
|
||||
Reference in New Issue
Block a user