// Canvas 2D backend for the frame plans of visual-engine.js. // // XZBT 0.1 realizes the renderer-neutral schema of 17.1 with Canvas 2D (PRD // 69). The plan arrives in device pixels with every scalar already converted by // the uniform factor of 19.3, so this module sets no transform for geometry: it // strokes with an explicit device-pixel `lineWidth`, which is what keeps a // stroke from being distorted by a nonuniform `stretch` fit. Text is the one // exception and carries its own matrix. import { renderVisualEffects } from './visual-effects.js'; const BLEND_OPERATIONS = Object.freeze({ normal: 'source-over', add: 'lighter', screen: 'screen', multiply: 'multiply', overlay: 'overlay', lighten: 'lighten', darken: 'darken', difference: 'difference' }); const FILTER_UNITS = Object.freeze({ brightness: '', contrast: '', saturate: '', 'hue-rotate': 'deg', grayscale: '', sepia: '', invert: '' }); function tracePath(context, node) { context.beginPath(); for (const subpath of node.subpaths ?? []) { context.moveTo(subpath.start[0], subpath.start[1]); for (const segment of subpath.segments) { if (segment.type === 'line') context.lineTo(segment.to[0], segment.to[1]); else context.bezierCurveTo(segment.c1[0], segment.c1[1], segment.c2[0], segment.c2[1], segment.to[0], segment.to[1]); } if (subpath.closed) context.closePath(); } } function buildPaint(context, paint) { if (!paint) return null; if (paint.type === 'color') return paint.color; let gradient; if (paint.type === 'linear-gradient') { gradient = context.createLinearGradient(paint.from[0], paint.from[1], paint.to[0], paint.to[1]); } else if (paint.type === 'radial-gradient') { gradient = context.createRadialGradient(paint.center[0], paint.center[1], paint.innerRadius ?? 0, paint.center[0], paint.center[1], paint.radius); } else if (paint.type === 'conic-gradient') { if (typeof context.createConicGradient !== 'function') return paint.stops[0]?.color ?? null; gradient = context.createConicGradient((paint.angle ?? 0) * (Math.PI / 180), paint.center[0], paint.center[1]); } else { return null; } for (const stop of paint.stops ?? []) gradient.addColorStop(stop.offset, stop.color); return gradient; } function filterString(node) { const parts = []; for (const entry of node.filters ?? []) { const unit = FILTER_UNITS[entry.type] ?? ''; parts.push(`${entry.type}(${entry.amount ?? 0}${unit})`); } if (node.blur > 0) parts.push(`blur(${node.blur}px)`); return parts.join(' '); } function applyClip(context, clip) { if (!clip) return; context.beginPath(); const [a, b, c, d] = clip.corners; if (clip.shape === 'ellipse') { // The clip rectangle's device corners describe the ellipse's bounding // parallelogram; its axes are the half-diagonals of that shape. const cx = clip.center[0]; const cy = clip.center[1]; const rx = Math.hypot(b[0] - a[0], b[1] - a[1]) / 2; const ry = Math.hypot(d[0] - a[0], d[1] - a[1]) / 2; const angle = Math.atan2(b[1] - a[1], b[0] - a[0]); context.ellipse(cx, cy, rx, ry, angle, 0, Math.PI * 2); } else { context.moveTo(a[0], a[1]); context.lineTo(b[0], b[1]); context.lineTo(c[0], c[1]); context.lineTo(d[0], d[1]); context.closePath(); } context.clip(); } /** * The compositing order of 17.12, stage for stage: shadow, geometry, glow, * blur, filters, alpha, clip and mask, blend. */ function drawNode(context, node, surface) { if (node.alpha === 0) return; // B1: When node.buffered is true, rasterize into a pool surface with default // state, then composite once with the node's alpha/blend (ยง17.12 stage order). if (node.buffered && surface && typeof surface.create === 'function') { const target = surface.create(); if (target?.context) { target.context.save(); target.context.globalAlpha = 1; target.context.globalCompositeOperation = 'source-over'; const unbuffered = { ...node, buffered: false, alpha: 1, blend: 'normal', clip: null }; drawNode(target.context, unbuffered, surface); target.context.restore(); context.save(); context.globalAlpha = node.alpha; context.globalCompositeOperation = BLEND_OPERATIONS[node.blend] ?? 'source-over'; applyClip(context, node.clip); const filters = filterString(node); if (filters && 'filter' in context) context.filter = filters; context.drawImage(target.canvas, 0, 0); context.restore(); surface.release(target); return; } } context.save(); context.globalAlpha = node.alpha; context.globalCompositeOperation = BLEND_OPERATIONS[node.blend] ?? 'source-over'; applyClip(context, node.clip); const filters = filterString(node); if (filters && 'filter' in context) context.filter = filters; if (node.kind === 'group') { if (node.mask) { const masked = surface.create(); for (const child of node.children) drawNode(masked.context, child, surface); masked.context.save(); masked.context.globalCompositeOperation = 'destination-in'; drawNode(masked.context, node.mask, surface); masked.context.restore(); context.drawImage(masked.canvas, 0, 0); surface.release(masked); } else { for (const child of node.children) drawNode(context, child, surface); } context.restore(); return; } if (node.shadow) { context.shadowColor = node.shadow.color; context.shadowBlur = node.shadow.blurRadius; context.shadowOffsetX = node.shadow.offsetX; context.shadowOffsetY = node.shadow.offsetY; } if (node.kind === 'point') { context.beginPath(); context.arc(node.center[0], node.center[1], node.diameter / 2, 0, Math.PI * 2); if (node.fill) { context.fillStyle = buildPaint(context, node.fill); context.fill(); } if (node.stroke && node.strokeWidth > 0) { context.strokeStyle = buildPaint(context, node.stroke); context.lineWidth = node.strokeWidth; context.stroke(); } } else if (node.kind === 'text') { const matrix = node.matrix; context.setTransform(matrix[0], matrix[1], matrix[2], matrix[3], matrix[4], matrix[5]); context.font = `${node.italic ? 'italic ' : ''}${node.weight === 'bold' ? 'bold ' : ''}${node.size}px ${node.font}`; context.textAlign = node.align === 'center' ? 'center' : node.align === 'right' ? 'right' : 'left'; context.textBaseline = node.baseline; if ('letterSpacing' in context) context.letterSpacing = `${node.letterSpacing}px`; // 17.13: `maxWidth` condenses horizontally about the align anchor and never // wraps or truncates. Canvas 2D's own maxWidth argument does exactly that. if (node.fill) { context.fillStyle = buildPaint(context, node.fill); context.fillText(node.text, 0, 0, node.maxWidth); } if (node.stroke && node.strokeWidth > 0) { context.strokeStyle = buildPaint(context, node.stroke); context.lineWidth = node.strokeWidth; context.strokeText(node.text, 0, 0, node.maxWidth); } } else { tracePath(context, node); if (node.fill) { context.fillStyle = buildPaint(context, node.fill); context.fill(node.fillRule === 'evenodd' ? 'evenodd' : 'nonzero'); } if (node.stroke && node.strokeWidth > 0) { context.strokeStyle = buildPaint(context, node.stroke); context.lineWidth = node.strokeWidth; context.lineCap = node.strokeCap; context.lineJoin = node.strokeJoin; if (node.strokeDash) context.setLineDash(node.strokeDash); context.lineDashOffset = node.strokeDashOffset; context.stroke(); } } // 17.12: glow is drawn *around* the result, not over it. We offset the // geometry far off-screen so only the blurred shadow (the halo) appears. if (node.glow && node.glow.strength > 0) { context.save(); context.globalAlpha = node.alpha * node.glow.strength; context.shadowColor = node.glow.color; context.shadowBlur = node.glow.radius; // Offset geometry far away; counter-offset the shadow so it lands in place. const glowShift = 1e5; context.shadowOffsetX = -glowShift; context.shadowOffsetY = 0; context.translate(glowShift, 0); if (node.kind === 'point') { context.beginPath(); context.arc(node.center[0], node.center[1], node.diameter / 2, 0, Math.PI * 2); context.fillStyle = 'rgba(0,0,0,1)'; context.fill(); } else if (node.kind === 'text') { const matrix = node.matrix; context.setTransform(matrix[0], matrix[1], matrix[2], matrix[3], matrix[4] + glowShift, matrix[5]); context.shadowOffsetX = -glowShift; context.shadowOffsetY = 0; context.font = `${node.italic ? 'italic ' : ''}${node.weight === 'bold' ? 'bold ' : ''}${node.size}px ${node.font}`; context.textAlign = node.align === 'center' ? 'center' : node.align === 'right' ? 'right' : 'left'; context.textBaseline = node.baseline; context.fillStyle = 'rgba(0,0,0,1)'; context.fillText(node.text, 0, 0, node.maxWidth); } else { tracePath(context, node); if (node.fill) { context.fillStyle = 'rgba(0,0,0,1)'; context.fill(node.fillRule === 'evenodd' ? 'evenodd' : 'nonzero'); } if (node.stroke && node.strokeWidth > 0) { context.strokeStyle = 'rgba(0,0,0,1)'; context.lineWidth = node.strokeWidth; context.stroke(); } } context.restore(); } context.restore(); } /** A pool of offscreen surfaces for buffered layers, masks, and groups. */ export class SurfacePool { constructor(factory, width, height) { this.factory = factory; this.width = width; this.height = height; this.free = []; this.allocated = 0; } create() { const reused = this.free.pop(); if (reused) { reused.context.setTransform(1, 0, 0, 1, 0, 0); reused.context.clearRect(0, 0, this.width, this.height); return reused; } this.allocated += 1; const canvas = this.factory(this.width, this.height); return { canvas, context: canvas.getContext('2d') }; } release(surface) { this.free.push(surface); } } /** * Render one frame plan into a Canvas 2D context. `createSurface(w, h)` returns * an offscreen canvas; in a browser that is `new OffscreenCanvas(w, h)` or a * detached ``. */ export function renderFrame(context, plan, { createSurface, warnEffect } = {}) { const { width, height } = plan.backing; context.setTransform(1, 0, 0, 1, 0, 0); context.globalAlpha = 1; context.globalCompositeOperation = 'source-over'; if ('filter' in context) context.filter = 'none'; context.fillStyle = plan.background; context.fillRect(0, 0, width, height); const surfaces = createSurface ? new SurfacePool(createSurface, width, height) : null; for (const layer of plan.layers) { if (!layer.visible || layer.opacity === 0) continue; const buffered = layer.buffered && surfaces !== null; const target = buffered ? surfaces.create() : null; const layerContext = buffered ? target.context : context; for (const node of layer.nodes) drawNode(layerContext, node, surfaces ?? { create: () => target, release: () => {} }); if (buffered) { context.save(); context.globalAlpha = layer.opacity; context.globalCompositeOperation = BLEND_OPERATIONS[layer.blend] ?? 'source-over'; context.drawImage(target.canvas, 0, 0); context.restore(); surfaces.release(target); } } renderVisualEffects(context, plan, warnEffect); return { allocated: surfaces?.allocated ?? 0 }; }