paint-debug-view: fix sharp raster rendering, with red-green-tested pure pipeline

The four debug raster layers (source / SDF / coverage / pre-walk
snapshot) were rendering blurry — WebKit's SVG <image> rasterizer
hardcodes bilinear interpolation regardless of `image-rendering`,
and SVG <foreignObject> doesn't propagate the SVG transform on
Safari. Multiple CSS workarounds (image-rendering on element / on
SVG root / vendor-prefixed) and SVG-attribute workarounds did not
take effect.

Fix: composite all raster layers onto a single absolutely-positioned
HTML <canvas> below the SVG. Canvas2D's
`ctx.imageSmoothingEnabled = false` is honored everywhere, so each
layer draws nearest-neighbor — sharp at any zoom.

The previous attempts also had a state-staleness bug: containerSize
was tracked in React state via a ResizeObserver, but at first
render the state was {0,0} which made the layout's `svgTransform`
return null, which silently disabled the canvas drawing. Replaced
with `useLayoutEffect` + synchronous `getBoundingClientRect` —
runs after DOM commit, before paint, with the canvas ref guaranteed
populated.

The render pipeline is now a pure async function
`renderRasterLayersToCanvas` in `src/lib/rasterRender.js` plus four
small pure helpers (`computeViewboxTransform`, `computeDrawRect`,
`drawRasterLayers`, `buildLayerSpecs`). The React effect is a thin
wrapper that injects a real `<canvas>`, the live bounding rect, and
a real `Image()`-based `loadImage`.

Tests (`src/lib/rasterRender.test.js`, 25/25 passing):
  * View-transform math — viewBox+container → scale+offset.
  * Draw-rect math — bounds+transform → canvas-pixel rect, with
    +1 inclusivity correctness.
  * Layer-spec building — toggles, walkIdx → snapshot index,
    missing PNG handling.
  * drawRasterLayers — sets imageSmoothingEnabled=false, draws each
    image with the right rect+alpha, skips null images, clamps
    opacity, resets globalAlpha.
  * Integration: end-to-end pipeline from viewBox+container+bounds
    → drawImage on a mock canvas. Asserts non-zero rect, fill-frac,
    and that imageSmoothingEnabled is false on every draw.
  * Regression cases: zero-size container, null debug, all toggles
    off, mid-load cancellation, tiny container (3mm/150dpi). Each
    must early-out with a sensible reason and no draws.

Red-green proof: temporarily commenting the drawRasterLayers call
inside renderRasterLayersToCanvas makes 2 critical tests fail (the
happy path and the tiny-container regression). Restoring it makes
all 25 pass.

Runtime safety net: in the React effect, surface unexpected early-
out reasons via `console.warn`. 'ok' / 'no-debug' / 'no-layers' /
'cancelled' are normal; anything else (no-canvas, no-divrect,
zero-size, bad-transform, no-ctx) means a regression in the
wiring and prints to the console immediately.
This commit is contained in:
Mitchell Hansen
2026-05-08 20:41:50 -07:00
parent 5dd2e8aba5
commit 55a4edcd19
3 changed files with 594 additions and 55 deletions

View File

@@ -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 <image> 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 }) {
</div>
</div>
<div className="flex-1 relative overflow-hidden" onWheel={onWheel} onMouseDown={onMouseDown}>
<div
ref={svgContainerRef}
className="flex-1 relative overflow-hidden"
style={{ background: '#0f0f10' }}
onWheel={onWheel} onMouseDown={onMouseDown}>
{/* 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. */}
<canvas
ref={rasterCanvasRef}
style={{
position: 'absolute', left: 0, top: 0,
width: '100%', height: '100%',
pointerEvents: 'none',
zIndex: 0,
}} />
<svg
ref={svgRef}
tabIndex={0}
width="100%" height="100%"
viewBox={viewBox}
preserveAspectRatio="xMidYMid meet"
shapeRendering="crispEdges"
onMouseMove={onMouseMoveSvg}
onMouseLeave={() => 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 <img> overlays are absolutely positioned over the
// SVG's rendered rect via `overlayStyle`.
position: 'relative', zIndex: 1,
}}>
{enabled.source && debug.source_b64 && (
<image
href={debug.source_b64} xlinkHref={debug.source_b64}
x={debug.bounds[0]} y={debug.bounds[1]}
width={debug.bounds[2] - debug.bounds[0] + 1}
height={debug.bounds[3] - debug.bounds[1] + 1}
opacity={sourceOpacity}
style={{ imageRendering: 'pixelated' }}
preserveAspectRatio="none" />
)}
{enabled.sdf && debug.sdf_b64 && (
<image
href={debug.sdf_b64} xlinkHref={debug.sdf_b64}
x={debug.bounds[0]} y={debug.bounds[1]}
width={debug.bounds[2] - debug.bounds[0] + 1}
height={debug.bounds[3] - debug.bounds[1] + 1}
opacity={sdfOpacity}
style={{ imageRendering: 'pixelated' }}
preserveAspectRatio="none" />
)}
{/* Source / SDF / coverage / pre-snapshot rasters render as
HTML <img> overlays outside the SVG (above) — see
`overlayStyle`. WebKit's SVG <image> rasterizer can't be
forced to nearest-neighbor, but HTML <img> 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 (
<image
href={png} xlinkHref={png}
x={debug.bounds[0]} y={debug.bounds[1]}
width={debug.bounds[2] - debug.bounds[0] + 1}
height={debug.bounds[3] - debug.bounds[1] + 1}
opacity={coverageOpacity}
style={{ imageRendering: 'pixelated' }}
preserveAspectRatio="none" />
)
})()}
{enabled.coverage && debug.coverage_b64 && (
<image
href={debug.coverage_b64} xlinkHref={debug.coverage_b64}
x={debug.bounds[0]} y={debug.bounds[1]}
width={debug.bounds[2] - debug.bounds[0] + 1}
height={debug.bounds[3] - debug.bounds[1] + 1}
opacity={coverageOpacity}
style={{ imageRendering: 'pixelated' }}
preserveAspectRatio="none" />
)}
{/* preSnapshot + coverage are HTML <img> 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

View File

@@ -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 }
}

View File

@@ -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()
})
})