diff --git a/src-frontend/src/components/PaintDebugView.jsx b/src-frontend/src/components/PaintDebugView.jsx index 658ae4a9..cb2cc7e4 100644 --- a/src-frontend/src/components/PaintDebugView.jsx +++ b/src-frontend/src/components/PaintDebugView.jsx @@ -1,7 +1,8 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { listen } from '@tauri-apps/api/event' import * as tauri from '../hooks/useTauri.js' import { DEFAULT_PAINT_PARAMS } from '../hooks/useTauri.js' +import { renderRasterLayersToCanvas } from '../lib/rasterRender.js' const IS_DARWIN = typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent || '') @@ -98,7 +99,17 @@ export default function PaintDebugView({ passIdx = 0 }) { ) const [view, setView] = useState({ zoom: 1, panX: 0, panY: 0 }) const containerRef = useRef(null) + // Separate from `containerRef` (which is the outer panel container) — + // this one wraps just the SVG so we can size HTML overlays to its + // pixel dimensions. + const svgContainerRef = useRef(null) const svgRef = useRef(null) + // Canvas for raster layers (source/sdf/coverage/snapshot). Bypasses + // both WebKit's SVG nearest-neighbor bug AND the + // foreignObject transform issue. ctx.imageSmoothingEnabled = false + // is honored everywhere. + const rasterCanvasRef = useRef(null) + const rasterCacheRef = useRef(new Map()) // src → loaded HTMLImageElement const dragRef = useRef(null) const [hover, setHover] = useState(null) const [selBox, setSelBox] = useState(null) @@ -183,6 +194,71 @@ export default function PaintDebugView({ passIdx = 0 }) { return `${x0 - pad - view.panX} ${y0 - pad - view.panY} ${w / view.zoom} ${h / view.zoom}` }, [debug, view]) + // Bump on resize so the layout-effect below re-runs and re-measures + // the SVG container synchronously. The actual measurement happens + // in the layout-effect itself via getBoundingClientRect — keeping + // the size in state has been unreliable (initial mount measures + // zero, async update doesn't always fire before the canvas + // renders). Synchronous measurement on every render that matters + // is more robust than chasing state. + const [resizeTick, setResizeTick] = useState(0) + useEffect(() => { + const el = svgContainerRef.current + if (!el) return + const ro = new ResizeObserver(() => setResizeTick(t => t + 1)) + ro.observe(el) + return () => ro.disconnect() + }, []) + + // Render raster layers (source / sdf / coverage / pre-snapshot) onto + // the canvas every time inputs change. useLayoutEffect runs + // synchronously after DOM commit but before browser paint — the + // canvas ref is guaranteed populated and we measure the container's + // actual bounding rect right then. This avoids the first-render + // "containerSize is still 0" trap. Body is just a wrapper around + // `renderRasterLayersToCanvas` (pure, tested). + useLayoutEffect(() => { + const canvas = rasterCanvasRef.current + const divEl = svgContainerRef.current + if (!divEl) return + const cache = rasterCacheRef.current + const loadImage = (src) => { + const cached = cache.get(src) + if (cached && cached.complete && cached.naturalWidth > 0) { + return Promise.resolve(cached) + } + return new Promise(resolve => { + const img = new Image() + img.onload = () => { cache.set(src, img); resolve(img) } + img.onerror = () => resolve(null) + img.src = src + }) + } + let cancelled = false + renderRasterLayersToCanvas({ + canvas, + divRect: divEl.getBoundingClientRect(), + debug, + viewBox, + enabled, + opacity: { source: sourceOpacity, sdf: sdfOpacity, coverage: coverageOpacity }, + walkIdx, + loadImage, + isCancelled: () => cancelled, + }).then(result => { + // Surface unexpected early-outs in the console so a regression + // is loud, not silent. 'ok', 'no-debug', 'no-layers', + // 'cancelled' are all normal; the others mean something is + // wrong with refs, layout, or the raster pipeline. + if (result && !['ok', 'no-debug', 'no-layers', 'cancelled'].includes(result.reason)) { + console.warn('[paint-debug] raster render skipped:', result.reason, result) + } + }) + return () => { cancelled = true } + }, [debug, viewBox, resizeTick, + enabled.source, enabled.sdf, enabled.preSnapshot, enabled.coverage, + walkIdx, sourceOpacity, sdfOpacity, coverageOpacity, enabled]) + const onWheel = (e) => { e.preventDefault() if (!debug || !svgRef.current) return @@ -611,39 +687,48 @@ export default function PaintDebugView({ passIdx = 0 }) { -
+
+ + {/* Raster layers (source / sdf / coverage / snapshot) all + composite onto a single absolutely-positioned canvas + below the SVG. Canvas2D's `imageSmoothingEnabled = false` + is honored everywhere, so they stay sharp at any zoom. */} + + setCandHover(null)} onKeyDown={onSvgKeyDown} - style={{ cursor: 'grab', background: '#0f0f10', outline: 'none' }}> + style={{ + cursor: 'grab', outline: 'none', + // SVG stays in flex flow so the container has a height; + // HTML overlays are absolutely positioned over the + // SVG's rendered rect via `overlayStyle`. + position: 'relative', zIndex: 1, + }}> - {enabled.source && debug.source_b64 && ( - - )} - - {enabled.sdf && debug.sdf_b64 && ( - - )} + {/* Source / SDF / coverage / pre-snapshot rasters render as + HTML overlays outside the SVG (above) — see + `overlayStyle`. WebKit's SVG rasterizer can't be + forced to nearest-neighbor, but HTML with CSS + `image-rendering: pixelated` is honored. */} {/* Vector skeleton — polylines per segment between special nodes. Stays sharp at any zoom. Junction dots in green. */} @@ -712,37 +797,8 @@ export default function PaintDebugView({ passIdx = 0 }) { }) })()} - {/* Pre-walk unpainted snapshot for the currently-selected - walk's stroke. Shows what the walker SAW just before it - started — distinct from the final coverage layer which - shows what's left after ALL strokes. */} - {enabled.preSnapshot && (() => { - const snaps = debug.unpainted_snapshots ?? [] - const sIdx = walks[walkIdx]?.stroke_idx ?? 0 - const png = snaps[sIdx] - if (!png) return null - return ( - - ) - })()} - - {enabled.coverage && debug.coverage_b64 && ( - - )} + {/* preSnapshot + coverage are HTML overlays outside + the SVG (see top of container). */} {/* Brush sweep: each trajectory rendered as a fat translucent line of width = 2 × brush_radius. Shows what the brush diff --git a/src-frontend/src/lib/rasterRender.js b/src-frontend/src/lib/rasterRender.js new file mode 100644 index 00000000..aa97c538 --- /dev/null +++ b/src-frontend/src/lib/rasterRender.js @@ -0,0 +1,127 @@ +// Pure helpers for compositing raster debug-layers onto a canvas in +// the same coordinate frame the SVG uses for its vector overlays. +// Extracted from PaintDebugView so they can be unit-tested without +// React, and so the layout calculation has one source of truth. + +/// Mirror SVG `preserveAspectRatio="xMidYMid meet"`: uniform scale to +/// fit the viewBox inside the container, with letterbox-style centering +/// on the larger axis. Returns null if any input is missing or zero. +export function computeViewboxTransform(viewBoxStr, container) { + if (!viewBoxStr || !container) return null + const parts = String(viewBoxStr).trim().split(/\s+/).map(Number) + if (parts.length !== 4 || parts.some(v => !Number.isFinite(v))) return null + const [vbX, vbY, vbW, vbH] = parts + if (vbW <= 0 || vbH <= 0) return null + if (!container || container.w <= 0 || container.h <= 0) return null + const scale = Math.min(container.w / vbW, container.h / vbH) + const contentX = (container.w - vbW * scale) / 2 + const contentY = (container.h - vbH * scale) / 2 + return { vbX, vbY, scale, contentX, contentY } +} + +/// Convert hull bounds in SVG user-space to canvas-pixel draw rect. +export function computeDrawRect(bounds, transform) { + if (!bounds || !transform || bounds.length !== 4) return null + const [x0, y0, x1, y1] = bounds + const sw = x1 - x0 + 1 + const sh = y1 - y0 + 1 + return { + dx: (x0 - transform.vbX) * transform.scale + transform.contentX, + dy: (y0 - transform.vbY) * transform.scale + transform.contentY, + dw: sw * transform.scale, + dh: sh * transform.scale, + } +} + +/// Composite layers onto `ctx`. `layers` is an array of +/// `{ image: HTMLImageElement|null, opacity: number }`. Skips layers +/// whose image is null. Sets imageSmoothingEnabled=false so each +/// layer draws with nearest-neighbor sampling — sharp at any zoom. +export function drawRasterLayers(ctx, drawRect, layers) { + if (!ctx || !drawRect) return + ctx.imageSmoothingEnabled = false + for (const layer of layers) { + if (!layer || !layer.image) continue + ctx.globalAlpha = Math.max(0, Math.min(1, layer.opacity ?? 1)) + ctx.drawImage(layer.image, drawRect.dx, drawRect.dy, drawRect.dw, drawRect.dh) + } + ctx.globalAlpha = 1 +} + +/// Build the array of `{ src, opacity }` layer specs for a given debug +/// payload + UI state. Pulled out so the test can assert which layers +/// the React component would push to the canvas without instantiating +/// React. Pure — same inputs always produce same output. +export function buildLayerSpecs(debug, enabled, opacity, walkIdx) { + if (!debug) return [] + const specs = [] + if (enabled.source && debug.source_b64) { + specs.push({ src: debug.source_b64, opacity: opacity.source }) + } + if (enabled.sdf && debug.sdf_b64) { + specs.push({ src: debug.sdf_b64, opacity: opacity.sdf }) + } + if (enabled.preSnapshot) { + const sIdx = (debug.walks ?? [])[walkIdx]?.stroke_idx ?? 0 + const png = (debug.unpainted_snapshots ?? [])[sIdx] + if (png) specs.push({ src: png, opacity: opacity.coverage }) + } + if (enabled.coverage && debug.coverage_b64) { + specs.push({ src: debug.coverage_b64, opacity: opacity.coverage }) + } + return specs +} + +/// End-to-end render: measure → compute transform → resize canvas → +/// async-load each layer → composite. The React `useLayoutEffect` +/// is a thin shim around this; isolating the body here lets tests +/// run it against a mock canvas + mock loadImage with zero React. +/// +/// Returns a status object so the caller (and tests) can verify +/// what happened: how many layers actually drew, and the rect they +/// targeted. Returns `drew: 0, reason: ...` on any early-out. +export async function renderRasterLayersToCanvas(args) { + const { + canvas, + divRect, + debug, + viewBox, + enabled, + opacity, + walkIdx, + loadImage, + isCancelled = () => false, + } = args + + if (!canvas) return { drew: 0, reason: 'no-canvas' } + if (!divRect) return { drew: 0, reason: 'no-divrect' } + if (!debug) return { drew: 0, reason: 'no-debug' } + if (divRect.width <= 0 || divRect.height <= 0) return { drew: 0, reason: 'zero-size' } + + const transform = computeViewboxTransform(viewBox, { w: divRect.width, h: divRect.height }) + const drawRect = computeDrawRect(debug.bounds, transform) + if (!transform || !drawRect) return { drew: 0, reason: 'bad-transform' } + + // Resize the canvas raster to match its CSS rect (1:1 device pixels). + const W = Math.round(divRect.width) + const H = Math.round(divRect.height) + if (canvas.width !== W || canvas.height !== H) { + canvas.width = W + canvas.height = H + } + const ctx = canvas.getContext('2d') + if (!ctx) return { drew: 0, reason: 'no-ctx' } + ctx.clearRect(0, 0, W, H) + + const specs = buildLayerSpecs(debug, enabled, opacity, walkIdx) + if (specs.length === 0) return { drew: 0, reason: 'no-layers', drawRect } + + const imgs = await Promise.all(specs.map(s => loadImage(s.src))) + if (isCancelled()) return { drew: 0, reason: 'cancelled', drawRect } + + ctx.clearRect(0, 0, canvas.width, canvas.height) + const layers = specs.map((s, i) => ({ image: imgs[i], opacity: s.opacity })) + drawRasterLayers(ctx, drawRect, layers) + const drewCount = imgs.filter(i => !!i).length + return { drew: drewCount, reason: 'ok', drawRect } +} diff --git a/src-frontend/src/lib/rasterRender.test.js b/src-frontend/src/lib/rasterRender.test.js new file mode 100644 index 00000000..684fb9d9 --- /dev/null +++ b/src-frontend/src/lib/rasterRender.test.js @@ -0,0 +1,356 @@ +import { describe, test, expect, vi } from 'vitest' +import { + computeViewboxTransform, + computeDrawRect, + drawRasterLayers, + buildLayerSpecs, + renderRasterLayersToCanvas, +} from './rasterRender.js' + +describe('computeViewboxTransform', () => { + test('returns null on empty/zero inputs', () => { + expect(computeViewboxTransform('', { w: 100, h: 100 })).toBeNull() + expect(computeViewboxTransform('0 0 100 100', null)).toBeNull() + expect(computeViewboxTransform('0 0 100 100', { w: 0, h: 0 })).toBeNull() + expect(computeViewboxTransform('0 0 0 100', { w: 100, h: 100 })).toBeNull() + expect(computeViewboxTransform('not a viewbox', { w: 100, h: 100 })).toBeNull() + }) + + test('square container, square viewBox -> 1:1 scale, no offset', () => { + const t = computeViewboxTransform('0 0 100 100', { w: 100, h: 100 }) + expect(t).toEqual({ vbX: 0, vbY: 0, scale: 1, contentX: 0, contentY: 0 }) + }) + + test('container 800x600, viewBox 100x100 -> meet scales 6, centers x', () => { + // meet = scale to fit, smaller axis dominates + const t = computeViewboxTransform('0 0 100 100', { w: 800, h: 600 }) + expect(t.scale).toBe(6) + expect(t.contentX).toBe((800 - 600) / 2) // 100 + expect(t.contentY).toBe(0) + }) + + test('viewBox offset is preserved', () => { + const t = computeViewboxTransform('50 30 100 100', { w: 100, h: 100 }) + expect(t.vbX).toBe(50) + expect(t.vbY).toBe(30) + expect(t.scale).toBe(1) + }) +}) + +describe('computeDrawRect', () => { + test('returns null when transform is null', () => { + expect(computeDrawRect([0, 0, 10, 10], null)).toBeNull() + }) + + test('identity transform passes bounds through (with +1 inclusivity)', () => { + const t = { vbX: 0, vbY: 0, scale: 1, contentX: 0, contentY: 0 } + const r = computeDrawRect([10, 20, 39, 49], t) + expect(r).toEqual({ dx: 10, dy: 20, dw: 30, dh: 30 }) + }) + + test('viewBox offset shifts dx/dy correctly', () => { + const t = { vbX: 5, vbY: 10, scale: 2, contentX: 100, contentY: 50 } + const r = computeDrawRect([10, 20, 19, 29], t) + // dx = (10 - 5) * 2 + 100 = 110 + // dy = (20 - 10) * 2 + 50 = 70 + // dw = 10 * 2 = 20, dh = 10 * 2 = 20 + expect(r).toEqual({ dx: 110, dy: 70, dw: 20, dh: 20 }) + }) +}) + +describe('drawRasterLayers', () => { + // Build a fake canvas-2d-context that records calls. + function makeCtx() { + const calls = [] + return { + calls, + imageSmoothingEnabled: true, // start true so we verify we set it false + globalAlpha: 1, + drawImage(img, dx, dy, dw, dh) { + calls.push({ kind: 'drawImage', img, dx, dy, dw, dh, + alpha: this.globalAlpha, + smoothing: this.imageSmoothingEnabled }) + }, + clearRect(x, y, w, h) { + calls.push({ kind: 'clearRect', x, y, w, h }) + }, + } + } + + test('no-op on null drawRect or null ctx', () => { + const ctx = makeCtx() + drawRasterLayers(ctx, null, [{ image: 'fake-img', opacity: 1 }]) + drawRasterLayers(null, { dx: 0, dy: 0, dw: 10, dh: 10 }, []) + expect(ctx.calls.length).toBe(0) + }) + + test('disables smoothing and draws each image at the rect', () => { + const ctx = makeCtx() + const drawRect = { dx: 5, dy: 7, dw: 11, dh: 13 } + const img1 = { id: 'img1' } + const img2 = { id: 'img2' } + drawRasterLayers(ctx, drawRect, [ + { image: img1, opacity: 0.5 }, + { image: img2, opacity: 0.8 }, + ]) + expect(ctx.imageSmoothingEnabled).toBe(false) + const draws = ctx.calls.filter(c => c.kind === 'drawImage') + expect(draws.length).toBe(2) + expect(draws[0]).toMatchObject({ img: img1, dx: 5, dy: 7, dw: 11, dh: 13, + alpha: 0.5, smoothing: false }) + expect(draws[1]).toMatchObject({ img: img2, dx: 5, dy: 7, dw: 11, dh: 13, + alpha: 0.8, smoothing: false }) + }) + + test('skips layers whose image is null', () => { + const ctx = makeCtx() + drawRasterLayers(ctx, { dx: 0, dy: 0, dw: 10, dh: 10 }, [ + { image: null, opacity: 1 }, + { image: { id: 'real' }, opacity: 1 }, + null, + ]) + const draws = ctx.calls.filter(c => c.kind === 'drawImage') + expect(draws.length).toBe(1) + expect(draws[0].img).toEqual({ id: 'real' }) + }) + + test('clamps opacity to [0, 1]', () => { + const ctx = makeCtx() + drawRasterLayers(ctx, { dx: 0, dy: 0, dw: 10, dh: 10 }, [ + { image: { id: 'a' }, opacity: -0.5 }, + { image: { id: 'b' }, opacity: 2.0 }, + ]) + const draws = ctx.calls.filter(c => c.kind === 'drawImage') + expect(draws[0].alpha).toBe(0) + expect(draws[1].alpha).toBe(1) + }) + + test('resets globalAlpha to 1 after drawing', () => { + const ctx = makeCtx() + drawRasterLayers(ctx, { dx: 0, dy: 0, dw: 10, dh: 10 }, [ + { image: { id: 'a' }, opacity: 0.3 }, + ]) + expect(ctx.globalAlpha).toBe(1) + }) +}) + +describe('buildLayerSpecs', () => { + const dbg = { + source_b64: 'data:source', + sdf_b64: 'data:sdf', + coverage_b64: 'data:coverage', + walks: [{ stroke_idx: 2 }, { stroke_idx: 3 }], + unpainted_snapshots: ['snap0', 'snap1', 'snap2', 'snap3'], + } + const allOn = { source: true, sdf: true, preSnapshot: true, coverage: true } + const op = { source: 0.4, sdf: 0.5, coverage: 0.7 } + + test('null debug -> empty', () => { + expect(buildLayerSpecs(null, allOn, op, 0)).toEqual([]) + }) + test('all enabled -> all four specs', () => { + const specs = buildLayerSpecs(dbg, allOn, op, 0) + expect(specs.length).toBe(4) + expect(specs[0]).toEqual({ src: 'data:source', opacity: 0.4 }) + expect(specs[1]).toEqual({ src: 'data:sdf', opacity: 0.5 }) + expect(specs[2]).toEqual({ src: 'snap2', opacity: 0.7 }) // walks[0].stroke_idx + expect(specs[3]).toEqual({ src: 'data:coverage', opacity: 0.7 }) + }) + test('walkIdx selects which snapshot', () => { + const specs = buildLayerSpecs(dbg, allOn, op, 1) + // walks[1].stroke_idx = 3 -> snapshots[3] + expect(specs[2].src).toBe('snap3') + }) + test('partial layers respect their toggles', () => { + const specs = buildLayerSpecs(dbg, + { source: true, sdf: false, preSnapshot: false, coverage: false }, + op, 0) + expect(specs.length).toBe(1) + expect(specs[0].src).toBe('data:source') + }) + test('missing PNG fields skipped', () => { + const specs = buildLayerSpecs( + { ...dbg, sdf_b64: '' }, allOn, op, 0) + expect(specs.find(s => s.src === 'data:sdf')).toBeUndefined() + }) +}) + +// Mock canvas/context — drop-in replacement for HTMLCanvasElement that +// lets us assert which drawImage calls happened. The real React effect +// calls these via `canvas.getContext('2d')`. jsdom doesn't ship a +// canvas implementation, so this stub stands in for tests. +function makeMockCanvas() { + const calls = [] + const ctx = { + calls, + imageSmoothingEnabled: true, + globalAlpha: 1, + clearRect(x, y, w, h) { calls.push({ kind: 'clearRect', x, y, w, h }) }, + drawImage(img, dx, dy, dw, dh) { + calls.push({ kind: 'drawImage', img, dx, dy, dw, dh, + alpha: this.globalAlpha, + smoothing: this.imageSmoothingEnabled }) + }, + } + return { + width: 0, height: 0, + getContext: () => ctx, + _ctx: ctx, + } +} + +describe('renderRasterLayersToCanvas — integration', () => { + // Common fixture: an M-like glyph at 5mm/425dpi. + const debug = { + bounds: [108, 142, 207, 275], + source_b64: 'data:image/png;base64,SOURCEMOCK', + sdf_b64: 'data:image/png;base64,SDFMOCK', + coverage_b64: 'data:image/png;base64,COVMOCK', + walks: [{ stroke_idx: 0 }], + unpainted_snapshots: ['data:snap0'], + } + const viewBox = '104 138 107.92 141' // bounds + ~4 px pad + const enabled = { source: true, sdf: true, preSnapshot: false, coverage: false } + const opacity = { source: 0.4, sdf: 0.5, coverage: 0.7 } + // Mock loadImage returns a fake "image" object that drawImage + // accepts (it doesn't care what the type is, just that it's + // not null). + const fakeImage = (src) => ({ _mockSrc: src }) + const loadImage = (src) => Promise.resolve(fakeImage(src)) + + test('happy path: drawImage called with non-zero rect for every enabled layer', async () => { + const canvas = makeMockCanvas() + const divRect = { width: 800, height: 600 } + const result = await renderRasterLayersToCanvas({ + canvas, divRect, debug, viewBox, enabled, opacity, walkIdx: 0, loadImage, + }) + expect(result.reason).toBe('ok') + expect(result.drew).toBe(2) + expect(canvas.width).toBe(800) + expect(canvas.height).toBe(600) + + const draws = canvas._ctx.calls.filter(c => c.kind === 'drawImage') + expect(draws.length).toBe(2) + // Both draws must have non-zero rect. + for (const d of draws) { + expect(d.dw).toBeGreaterThan(0) + expect(d.dh).toBeGreaterThan(0) + expect(d.smoothing).toBe(false) // pixelated guarantee + } + // Verify the two draws went to the same rect (same overlay). + expect(draws[0].dw).toBeCloseTo(draws[1].dw) + expect(draws[0].dh).toBeCloseTo(draws[1].dh) + // Verify the rect occupies a meaningful portion of the canvas + // (the original bug had the imgs render at "tiny" 1:1 px size). + expect(Math.max(draws[0].dw / 800, draws[0].dh / 600)).toBeGreaterThan(0.3) + }) + + test('regression: zero-size container -> no draw, sensible reason', async () => { + const canvas = makeMockCanvas() + const result = await renderRasterLayersToCanvas({ + canvas, divRect: { width: 0, height: 0 }, + debug, viewBox, enabled, opacity, walkIdx: 0, loadImage, + }) + expect(result.drew).toBe(0) + expect(result.reason).toBe('zero-size') + }) + + test('regression: null debug -> no draw', async () => { + const canvas = makeMockCanvas() + const result = await renderRasterLayersToCanvas({ + canvas, divRect: { width: 800, height: 600 }, + debug: null, viewBox, enabled, opacity, walkIdx: 0, loadImage, + }) + expect(result.drew).toBe(0) + expect(result.reason).toBe('no-debug') + }) + + test('regression: all layer toggles off -> no-layers reason, no drawImage', async () => { + const canvas = makeMockCanvas() + const result = await renderRasterLayersToCanvas({ + canvas, divRect: { width: 800, height: 600 }, + debug, viewBox, + enabled: { source: false, sdf: false, preSnapshot: false, coverage: false }, + opacity, walkIdx: 0, loadImage, + }) + expect(result.drew).toBe(0) + expect(result.reason).toBe('no-layers') + const draws = canvas._ctx.calls.filter(c => c.kind === 'drawImage') + expect(draws.length).toBe(0) + }) + + test('regression: cancelled mid-load -> no draw', async () => { + const canvas = makeMockCanvas() + let cancelledFlag = false + // Make loadImage actually async so we can flip cancel mid-flight. + const slowLoad = (src) => new Promise(res => setTimeout(() => res(fakeImage(src)), 0)) + const promise = renderRasterLayersToCanvas({ + canvas, divRect: { width: 800, height: 600 }, + debug, viewBox, enabled, opacity, walkIdx: 0, + loadImage: slowLoad, + isCancelled: () => cancelledFlag, + }) + cancelledFlag = true + const result = await promise + expect(result.drew).toBe(0) + expect(result.reason).toBe('cancelled') + }) + + test('regression: tiny container (3mm/150dpi case) -> still produces correct rect', async () => { + // 3mm at 150dpi is ~17 px tall; bounds proportionally small. + const tinyDebug = { + ...debug, + bounds: [10, 10, 26, 26], // 17×17 hull + } + const tinyVB = '8 8 21 21' // bounds + 2px pad + const canvas = makeMockCanvas() + const result = await renderRasterLayersToCanvas({ + canvas, divRect: { width: 800, height: 600 }, + debug: tinyDebug, viewBox: tinyVB, enabled, opacity, walkIdx: 0, loadImage, + }) + expect(result.reason).toBe('ok') + const draws = canvas._ctx.calls.filter(c => c.kind === 'drawImage') + // Even tiny letters must fill a meaningful chunk of the canvas + // because the SVG's `meet` aspect-fit upscales them. + expect(draws[0].dw).toBeGreaterThan(100) + expect(draws[0].dh).toBeGreaterThan(100) + }) +}) + +// Integration-like test: end-to-end pipeline from viewBox + container +// + bounds → final draw call. This is the path the React component +// follows; if any of these three pure functions misbehaves the canvas +// shows nothing. +describe('end-to-end pipeline', () => { + test('typical M at 5mm/425dpi: image lands inside container with correct size', () => { + // M at 5mm/425dpi has bounds approximately: + // x: 108..207, y: 142..275 (per earlier diagnostic test) + // pad = max(2, (x1-x0)*0.04) = max(2, 4) = 4 + // viewBox = `${x0-pad} ${y0-pad} ${(x1-x0)+2*pad} ${(y1-y0)+2*pad}` + const bounds = [108, 142, 207, 275] + const pad = Math.max(2, (bounds[2] - bounds[0]) * 0.04) + const vb = `${bounds[0]-pad} ${bounds[1]-pad} ${(bounds[2]-bounds[0])+2*pad} ${(bounds[3]-bounds[1])+2*pad}` + const container = { w: 800, h: 600 } + const t = computeViewboxTransform(vb, container) + const r = computeDrawRect(bounds, t) + expect(t).not.toBeNull() + expect(r).not.toBeNull() + // The image should land WITHIN the container (not off-screen). + expect(r.dx).toBeGreaterThanOrEqual(0) + expect(r.dy).toBeGreaterThanOrEqual(0) + expect(r.dx + r.dw).toBeLessThanOrEqual(container.w + 1) + expect(r.dy + r.dh).toBeLessThanOrEqual(container.h + 1) + // The image should occupy a sensible fraction of the container — + // at least 1/3 of either dimension (otherwise the user would see + // it as "tiny", which is the original bug). + const fillFrac = Math.max(r.dw / container.w, r.dh / container.h) + expect(fillFrac).toBeGreaterThan(0.3) + }) + + test('regression: empty/zero container yields a null draw rect (skip render)', () => { + const t = computeViewboxTransform('0 0 100 100', { w: 0, h: 0 }) + expect(t).toBeNull() + const r = computeDrawRect([0, 0, 10, 10], t) + expect(r).toBeNull() + }) +})