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:
@@ -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
|
||||
|
||||
127
src-frontend/src/lib/rasterRender.js
Normal file
127
src-frontend/src/lib/rasterRender.js
Normal 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 }
|
||||
}
|
||||
356
src-frontend/src/lib/rasterRender.test.js
Normal file
356
src-frontend/src/lib/rasterRender.test.js
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user