From 91607d54d1b2e6fea844c529b471e2914a49e0d8 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sun, 28 Jun 2026 20:59:38 +0200 Subject: [PATCH] feat(svg): CustomPaint renderer for the drawing-card SVG engine (T-320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit paintSvg walks the typed SvgDocument and draws it: shapes → ui.Path (rect/rrect/ellipse/line/poly/path incl. arcs), text → TextPainter (anchor + baseline), per-node Affine transforms, group opacity via saveLayer, and viewBox fit (xMidYMid meet). Exposes SvgScenePainter and the SvgView widget. image-href (async) and markers/arrowheads are deferred follow-ons. Pixel-probe tested under flutter test (8 cases, incl. rendering the real d2 fixture). Completes the clide-owned SVG renderer core (D-103); not yet wired into a card. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/widgets/src/svg/svg_painter.dart | 219 +++++++++++++++++++++++++ test/widgets/svg/svg_painter_test.dart | 79 +++++++++ 2 files changed, 298 insertions(+) create mode 100644 lib/widgets/src/svg/svg_painter.dart create mode 100644 test/widgets/svg/svg_painter_test.dart diff --git a/lib/widgets/src/svg/svg_painter.dart b/lib/widgets/src/svg/svg_painter.dart new file mode 100644 index 00000000..f3bc9e09 --- /dev/null +++ b/lib/widgets/src/svg/svg_painter.dart @@ -0,0 +1,219 @@ +/// Renders a typed [SvgDocument] scene onto a Flutter canvas (T-320 / D-103). +/// +/// This is the clide-owned `CustomPaint` SVG renderer — the engine the whole +/// drawing card (D-103) lowers onto. It walks the typed model built by +/// `svg_document.dart`, fitting the viewBox into the available size (uniform +/// 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 +/// none, stroke-width 1. +library; + +import 'dart:math' as math; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:clide/src/svg/svg_node.dart'; +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) { + canvas.save(); + _applyViewport(canvas, size, doc); + _paintNode(canvas, doc.root); + canvas.restore(); +} + +void _applyViewport(ui.Canvas canvas, Size size, SvgDocument doc) { + final vb = doc.viewBox; + final srcW = vb?.width ?? doc.width ?? size.width; + final srcH = vb?.height ?? doc.height ?? size.height; + if (srcW <= 0 || srcH <= 0) return; + final scale = math.min(size.width / srcW, size.height / srcH); + canvas.translate((size.width - srcW * scale) / 2, (size.height - srcH * scale) / 2); + canvas.scale(scale); + if (vb != null) canvas.translate(-vb.minX, -vb.minY); +} + +void _paintNode(ui.Canvas canvas, SvgNode node) { + canvas.save(); + if (node.transform != null) canvas.transform(_matrix4(node.transform!)); + final layered = node.style.opacity < 1.0; + if (layered) { + canvas.saveLayer(null, ui.Paint()..color = ui.Color.fromRGBO(0, 0, 0, node.style.opacity.clamp(0, 1))); + } + + switch (node) { + case SvgGroup g: + for (final c in g.children) { + _paintNode(canvas, c); + } + case SvgText t: + _paintText(canvas, t); + case SvgImage _: + break; // async href resolution deferred (v1) + default: + _paintShape(canvas, node); + } + + if (layered) canvas.restore(); + canvas.restore(); +} + +void _paintShape(ui.Canvas canvas, SvgNode node) { + final path = _shapePath(node); + if (path == null) return; + final s = node.style; + + // Fill — SVG default is black; a line is never filled. + if (node is! SvgLine) { + final fill = s.fill ?? 0xFF000000; + if (_alpha(fill) != 0) { + canvas.drawPath(path, ui.Paint()..color = _color(fill, s.fillOpacity)); + } + } + // Stroke — only when specified and not none. + final stroke = s.stroke; + if (stroke != null && _alpha(stroke) != 0) { + canvas.drawPath( + path, + ui.Paint() + ..style = ui.PaintingStyle.stroke + ..color = _color(stroke, s.strokeOpacity) + ..strokeWidth = s.strokeWidth ?? 1.0 + ..strokeCap = _cap(s.lineCap) + ..strokeJoin = _join(s.lineJoin), + ); + } +} + +ui.Path? _shapePath(SvgNode node) { + switch (node) { + case SvgRect r: + final rect = Rect.fromLTWH(r.x, r.y, r.width, r.height); + return Path()..addRRect( + (r.rx > 0 || r.ry > 0) ? RRect.fromRectXY(rect, r.rx == 0 ? r.ry : r.rx, r.ry == 0 ? r.rx : r.ry) : RRect.fromRectAndRadius(rect, Radius.zero), + ); + case SvgEllipse e: + return Path()..addOval(Rect.fromCenter(center: Offset(e.cx, e.cy), width: e.rx * 2, height: e.ry * 2)); + case SvgLine l: + return Path() + ..moveTo(l.x1, l.y1) + ..lineTo(l.x2, l.y2); + case SvgPolyline p: + if (p.points.length < 4) return null; + final path = Path()..moveTo(p.points[0], p.points[1]); + for (var i = 2; i + 1 < p.points.length; i += 2) { + path.lineTo(p.points[i], p.points[i + 1]); + } + if (p.closed) path.close(); + return path; + case SvgPath p: + return _segPath(p.segments); + default: + return null; + } +} + +ui.Path _segPath(List segs) { + final path = Path(); + for (final s in segs) { + final a = s.args; + switch (s.op) { + case SvgPathOp.moveTo: + path.moveTo(a[0], a[1]); + case SvgPathOp.lineTo: + path.lineTo(a[0], a[1]); + case SvgPathOp.cubicTo: + path.cubicTo(a[0], a[1], a[2], a[3], a[4], a[5]); + case SvgPathOp.quadTo: + path.quadraticBezierTo(a[0], a[1], a[2], a[3]); + case SvgPathOp.arcTo: + path.arcToPoint(Offset(a[5], a[6]), radius: Radius.elliptical(a[0], a[1]), rotation: a[2], largeArc: a[3] != 0, clockwise: a[4] != 0); + case SvgPathOp.close: + path.close(); + } + } + return path; +} + +void _paintText(ui.Canvas canvas, SvgText t) { + if (t.text.isEmpty) return; + final s = t.style; + final fontSize = s.fontSize ?? 16.0; + final tp = TextPainter( + text: TextSpan( + text: t.text, + style: TextStyle(fontFamily: s.fontFamily, fontSize: fontSize, fontWeight: _weight(s.fontWeight), color: _color(s.fill ?? 0xFF000000, s.fillOpacity)), + ), + textDirection: TextDirection.ltr, + )..layout(); + + final dx = switch (s.textAnchor) { + SvgTextAnchor.middle => -tp.width / 2, + SvgTextAnchor.end => -tp.width, + _ => 0.0, + }; + final dy = switch (s.baseline) { + SvgBaseline.middle => -tp.height / 2, + SvgBaseline.hanging => 0.0, + _ => -tp.computeDistanceToActualBaseline(TextBaseline.alphabetic), + }; + tp.paint(canvas, Offset(t.x + dx, t.y + dy)); +} + +int _alpha(int argb) => (argb >> 24) & 0xFF; + +ui.Color _color(int argb, double? extraOpacity) { + if (extraOpacity == null || extraOpacity >= 1) return ui.Color(argb); + final a = (((argb >> 24) & 0xFF) * extraOpacity.clamp(0, 1)).round() & 0xFF; + return ui.Color((a << 24) | (argb & 0x00FFFFFF)); +} + +ui.StrokeCap _cap(SvgLineCap? c) => switch (c) { + SvgLineCap.round => ui.StrokeCap.round, + SvgLineCap.square => ui.StrokeCap.square, + _ => ui.StrokeCap.butt, +}; + +ui.StrokeJoin _join(SvgLineJoin? j) => switch (j) { + SvgLineJoin.round => ui.StrokeJoin.round, + SvgLineJoin.bevel => ui.StrokeJoin.bevel, + _ => ui.StrokeJoin.miter, +}; + +FontWeight _weight(int? w) => w == null ? FontWeight.w400 : FontWeight.values[((w / 100).round() - 1).clamp(0, 8)]; + +Float64List _matrix4(Affine m) => Float64List.fromList([ + m.a, m.b, 0, 0, // + m.c, m.d, 0, 0, // + 0, 0, 1, 0, // + m.e, m.f, 0, 1, // +]); + +/// A `CustomPainter` that draws an [SvgDocument]. Repaints only when the +/// document instance changes. +class SvgScenePainter extends CustomPainter { + const SvgScenePainter(this.document); + final SvgDocument document; + + @override + void paint(ui.Canvas canvas, Size size) => paintSvg(canvas, size, document); + + @override + bool shouldRepaint(SvgScenePainter old) => !identical(old.document, document); +} + +/// A widget that renders an [SvgDocument], filling its constraints. +class SvgView extends StatelessWidget { + const SvgView({super.key, required this.document}); + final SvgDocument document; + + @override + Widget build(BuildContext context) => CustomPaint(painter: SvgScenePainter(document), child: const SizedBox.expand()); +} diff --git a/test/widgets/svg/svg_painter_test.dart b/test/widgets/svg/svg_painter_test.dart new file mode 100644 index 00000000..da6252ff --- /dev/null +++ b/test/widgets/svg/svg_painter_test.dart @@ -0,0 +1,79 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:clide/src/svg/svg_document.dart'; +import 'package:clide/widgets/src/svg/svg_painter.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Pixel-probe tests: render the scene to a `ui.Image` and read back colours. +/// Deterministic for fills, independent of fonts/goldens. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Future render(String svg, double w, double h) async { + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder, Rect.fromLTWH(0, 0, w, h)); + paintSvg(canvas, Size(w, h), buildSvgDocument(svg)); + return recorder.endRecording().toImage(w.round(), h.round()); + } + + Future argbAt(ui.Image img, int x, int y) async { + final data = (await img.toByteData())!; + final i = (y * img.width + x) * 4; + return (data.getUint8(i + 3) << 24) | (data.getUint8(i) << 16) | (data.getUint8(i + 1) << 8) | data.getUint8(i + 2); + } + + int alpha(int argb) => (argb >> 24) & 0xFF; + + test('fills a rect with its colour', () async { + final img = await render('', 10, 10); + expect(await argbAt(img, 5, 5), 0xFFFF0000); + }); + + test('default fill is black', () async { + final img = await render('', 10, 10); + expect(await argbAt(img, 5, 5), 0xFF000000); + }); + + test('fill="none" leaves the interior transparent', () async { + final img = await render('', 10, 10); + expect(alpha(await argbAt(img, 5, 5)), 0); + }); + + test('circle centre is filled, corner is empty', () async { + final img = await render('', 10, 10); + expect(await argbAt(img, 5, 5), 0xFF0000FF); + expect(alpha(await argbAt(img, 0, 0)), 0); + }); + + test('viewBox scales up to the paint size', () async { + final img = await render('', 20, 20); + expect(await argbAt(img, 10, 10), 0xFFFF0000); + }); + + test('transform translates the shape', () async { + final img = await render('', 20, 20); + expect(await argbAt(img, 12, 12), 0xFFFF0000); + expect(alpha(await argbAt(img, 2, 2)), 0); + }); + + test('group opacity dims the contents', () async { + final img = await render('', 10, 10); + expect(alpha(await argbAt(img, 5, 5)), closeTo(128, 4)); + }); + + 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); + final data = (await img.toByteData())!; + var anyInk = false; + for (var i = 3; i < data.lengthInBytes; i += 4) { + if (data.getUint8(i) != 0) { + anyInk = true; + break; + } + } + expect(anyInk, isTrue); + }); +}