feat(draw): compare template + aspect-fit image painting (T-319)

compareTemplateHandler lowers an `images` array to an SVG of side-by-side
<image> cells, each with the per-object data-label/description caption and
data-lightbox; paths resolve to absolute up front (injected, honest error
on a miss). _paintImage now aspect-fits (contain, centered) so cells of
differing-shape images don't distort. Flutter-free handler + painter pixel
tests. Card image-loading + registration next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 13:57:47 +02:00
co-authored by Claude Opus 4.8
parent 281fb2a869
commit a651c9e026
6 changed files with 361 additions and 1 deletions
+57
View File
@@ -0,0 +1,57 @@
/// The `compare` drawing-card template (T-319 / D-91 / D-103).
///
/// A before/after (or N-up) comparison: the doc's `images` array — each
/// `{path, label, description}` — lowers to an SVG of side-by-side `<image>`
/// cells, every cell carrying the per-object `data-label` / `data-description`
/// (the T-318 caption overlay) and `data-lightbox` (tap-to-zoom, shared with the
/// image template). The same renderer (T-320) paints it; the painter aspect-fits
/// each image into its cell so differing shapes don't distort.
///
/// Paths are resolved to absolute up front via an injected resolver (like
/// image.show), so the card loader just reads the file — and an unresolvable
/// path is an honest [DrawErr], not a broken cell.
///
/// Flutter-free: pure Dart, runs under `dart test`.
library;
import 'draw_dispatch.dart';
/// Resolves a user-supplied image path to an absolute existing path, or null.
typedef ComparePathResolver = String? Function(String path);
const _cellW = 320, _cellH = 240, _gap = 20, _capH = 56;
/// Handler for `template: "compare"` — reads the doc's `images` array. Register
/// this in the [DrawingRegistry] with a path [resolvePath] (wired to the
/// workspace root in main.dart).
DrawingTemplateHandler compareTemplateHandler({required ComparePathResolver resolvePath}) {
return (doc) async {
final images = doc.fields['images'];
if (images is! List || images.isEmpty) {
return const DrawErr('the compare template needs a non-empty "images" array of {path,label,description}');
}
final cells = StringBuffer();
for (var i = 0; i < images.length; i++) {
final item = images[i];
if (item is! Map) return const DrawErr('each compare image must be an object with a "path"');
final path = _str(item['path']);
if (path == null) return const DrawErr('each compare image needs a "path"');
final abs = resolvePath(path);
if (abs == null) return DrawErr('no such image: $path');
final x = i * (_cellW + _gap);
final label = _str(item['label']);
final desc = _str(item['description']);
cells.write('<image href="${_esc(abs)}" x="$x" y="0" width="$_cellW" height="$_cellH"');
if (label != null) cells.write(' data-label="${_esc(label)}"');
if (desc != null) cells.write(' data-description="${_esc(desc)}"');
cells.write(' data-lightbox=""/>');
}
final n = images.length;
final totalW = n * _cellW + (n - 1) * _gap;
return DrawOk('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 $totalW ${_cellH + _capH}">$cells</svg>');
};
}
String? _str(Object? v) => v is String && v.trim().isNotEmpty ? v.trim() : null;
String _esc(String s) => s.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;');
+14 -1
View File
@@ -97,7 +97,20 @@ void _paintNode(ui.Canvas canvas, SvgNode node, _Ctx ctx) {
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());
final src = Rect.fromLTWH(0, 0, img.width.toDouble(), img.height.toDouble());
// Aspect-fit (contain) the image within its target rect, centered — so a
// compare card never distorts images of differing shapes (T-319). A degenerate
// (zero-size) image or rect falls back to the plain rect.
canvas.drawImageRect(img, src, _containRect(im.x, im.y, im.width, im.height, img.width.toDouble(), img.height.toDouble()), ui.Paint());
}
/// The largest rect with the source's aspect ratio that fits inside the target
/// box [x],[y],[w],[h], centered within it.
Rect _containRect(double x, double y, double w, double h, double srcW, double srcH) {
if (srcW <= 0 || srcH <= 0 || w <= 0 || h <= 0) return Rect.fromLTWH(x, y, w, h);
final scale = (w / srcW) < (h / srcH) ? w / srcW : h / srcH;
final fitW = srcW * scale, fitH = srcH * scale;
return Rect.fromLTWH(x + (w - fitW) / 2, y + (h - fitH) / 2, fitW, fitH);
}
void _paintShape(ui.Canvas canvas, SvgNode node, _Ctx ctx) {