Codex stop-hook on the prior per-node try/catch caught a leak: the catch logged but didn't roll back canvas state. drawNode pushes canvas.save() once per ancestor clipStack entry (node-renderer.ts:548) plus more for rotation / flip (574, 583) and per-shape sub-paths (701, 1094, 1102). If drawNode throws mid-loop, every save() between its entry and the throw stays on the stack — the next node's draw operates inside a leaked clip / leaked transform, and the canvas either renders nothing or renders to the wrong region. Snapshot canvas.getSaveCount() before each drawNode call; on catch, canvas.restoreToCount(saveCount) pops everything back to the baseline. Wrap the restoreToCount itself in a no-op catch since it can throw if the snapshot count is somehow above the current depth (shouldn't happen but guarded so the error reporter still runs). Net effect: per-node failures are now genuinely isolated. The canvas state at the start of each iteration is identical to where the previous iteration left it; one bad node can't smear its leaked state across the rest of the frame.
427 lines
14 KiB
TypeScript
427 lines
14 KiB
TypeScript
import type { CanvasKit, Surface } from 'canvaskit-wasm';
|
|
import type { PenDocument, PenNode } from '@zseven-w/pen-types';
|
|
import {
|
|
getActivePageChildren,
|
|
getAllChildren,
|
|
getDefaultTheme,
|
|
resolveNodeForCanvas,
|
|
MIN_ZOOM,
|
|
MAX_ZOOM,
|
|
CANVAS_BACKGROUND_DARK,
|
|
FRAME_LABEL_FONT_SIZE,
|
|
FRAME_LABEL_OFFSET_Y,
|
|
FRAME_LABEL_COLOR,
|
|
setRootChildrenProvider,
|
|
} from '@zseven-w/pen-core';
|
|
import { SkiaNodeRenderer } from './node-renderer.js';
|
|
import { SpatialIndex } from './spatial-index.js';
|
|
import {
|
|
flattenToRenderNodes,
|
|
resolveRefs,
|
|
premeasureTextHeights,
|
|
collectReusableIds,
|
|
collectInstanceIds,
|
|
} from './document-flattener.js';
|
|
import { parseColor } from './paint-utils.js';
|
|
import { viewportMatrix, screenToScene, zoomToPoint as vpZoomToPoint } from './viewport.js';
|
|
import type { RenderNode, PenRendererOptions, ViewportState } from './types.js';
|
|
|
|
/**
|
|
* Standalone read-only renderer for OpenPencil (.op) design files.
|
|
* No React, no Zustand, no TanStack — just pure TypeScript + CanvasKit.
|
|
*
|
|
* @example
|
|
* ```ts
|
|
* import { loadCanvasKit, PenRenderer } from '@zseven-w/pen-renderer'
|
|
*
|
|
* const ck = await loadCanvasKit('/canvaskit/')
|
|
* const renderer = new PenRenderer(ck, { fontBasePath: '/fonts/' })
|
|
* renderer.init(document.getElementById('canvas') as HTMLCanvasElement)
|
|
* renderer.setDocument(myDocument)
|
|
* renderer.zoomToFit()
|
|
* ```
|
|
*/
|
|
export class PenRenderer {
|
|
private ck: CanvasKit;
|
|
private surface: Surface | null = null;
|
|
private canvasEl: HTMLCanvasElement | null = null;
|
|
private nodeRenderer: SkiaNodeRenderer;
|
|
private spatialIndex = new SpatialIndex();
|
|
private renderNodes: RenderNode[] = [];
|
|
private options: PenRendererOptions;
|
|
|
|
// Component/instance IDs for colored frame labels
|
|
private reusableIds = new Set<string>();
|
|
private instanceIds = new Set<string>();
|
|
|
|
// Viewport
|
|
private _zoom = 1;
|
|
private _panX = 0;
|
|
private _panY = 0;
|
|
private dirty = true;
|
|
private animFrameId = 0;
|
|
|
|
// Document
|
|
private document: PenDocument | null = null;
|
|
private activePageId: string | null = null;
|
|
|
|
constructor(ck: CanvasKit, options?: PenRendererOptions) {
|
|
this.ck = ck;
|
|
this.options = options ?? {};
|
|
this.nodeRenderer = new SkiaNodeRenderer(ck, {
|
|
fontBasePath: this.options.fontBasePath,
|
|
googleFontsCssUrl: this.options.googleFontsCssUrl,
|
|
});
|
|
if (this.options.iconLookup) {
|
|
this.nodeRenderer.setIconLookup(this.options.iconLookup);
|
|
}
|
|
if (this.options.devicePixelRatio !== undefined) {
|
|
this.nodeRenderer.devicePixelRatio = this.options.devicePixelRatio;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lifecycle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
init(canvas: HTMLCanvasElement) {
|
|
this.canvasEl = canvas;
|
|
const dpr = this.options.devicePixelRatio ?? window.devicePixelRatio ?? 1;
|
|
canvas.width = canvas.clientWidth * dpr;
|
|
canvas.height = canvas.clientHeight * dpr;
|
|
|
|
this.surface = this.ck.MakeWebGLCanvasSurface(canvas);
|
|
if (!this.surface) this.surface = this.ck.MakeSWCanvasSurface(canvas);
|
|
if (!this.surface) {
|
|
console.error('PenRenderer: Failed to create surface');
|
|
return;
|
|
}
|
|
|
|
this.nodeRenderer.init();
|
|
this.nodeRenderer.setRedrawCallback(() => this.markDirty());
|
|
(this.nodeRenderer as any).textRenderer._onFontLoaded = () => this.markDirty();
|
|
|
|
// Pre-load default fonts
|
|
const defaultFonts = this.options.defaultFonts ?? ['Inter', 'Noto Sans SC'];
|
|
for (const font of defaultFonts) {
|
|
this.nodeRenderer.fontManager.ensureFont(font).then(() => this.markDirty());
|
|
}
|
|
|
|
// Wire up root children provider for layout engine fill-width fallback
|
|
setRootChildrenProvider(() => this.document?.children ?? []);
|
|
|
|
this.startRenderLoop();
|
|
}
|
|
|
|
dispose() {
|
|
if (this.animFrameId) cancelAnimationFrame(this.animFrameId);
|
|
this.nodeRenderer.dispose();
|
|
this.surface?.delete();
|
|
this.surface = null;
|
|
}
|
|
|
|
resize(width: number, height: number) {
|
|
if (!this.canvasEl) return;
|
|
const dpr = this.options.devicePixelRatio ?? window.devicePixelRatio ?? 1;
|
|
this.canvasEl.width = width * dpr;
|
|
this.canvasEl.height = height * dpr;
|
|
this.surface?.delete();
|
|
this.surface = this.ck.MakeWebGLCanvasSurface(this.canvasEl);
|
|
if (!this.surface) this.surface = this.ck.MakeSWCanvasSurface(this.canvasEl);
|
|
this.markDirty();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Document
|
|
// ---------------------------------------------------------------------------
|
|
|
|
setDocument(doc: PenDocument) {
|
|
this.document = doc;
|
|
this.activePageId = doc.pages?.[0]?.id ?? null;
|
|
this.syncFromDocument();
|
|
}
|
|
|
|
getDocument(): PenDocument | null {
|
|
return this.document;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pages
|
|
// ---------------------------------------------------------------------------
|
|
|
|
setPage(pageId: string) {
|
|
this.activePageId = pageId;
|
|
this.syncFromDocument();
|
|
}
|
|
|
|
getPageIds(): string[] {
|
|
return this.document?.pages?.map((p) => p.id) ?? [];
|
|
}
|
|
|
|
getActivePageId(): string | null {
|
|
return this.activePageId;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Viewport
|
|
// ---------------------------------------------------------------------------
|
|
|
|
setViewport(zoom: number, panX: number, panY: number) {
|
|
this._zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom));
|
|
this._panX = panX;
|
|
this._panY = panY;
|
|
this.markDirty();
|
|
}
|
|
|
|
getViewport(): ViewportState {
|
|
return { zoom: this._zoom, panX: this._panX, panY: this._panY };
|
|
}
|
|
|
|
zoomToFit(padding = 64) {
|
|
if (!this.canvasEl || this.renderNodes.length === 0) return;
|
|
let minX = Infinity,
|
|
minY = Infinity,
|
|
maxX = -Infinity,
|
|
maxY = -Infinity;
|
|
for (const rn of this.renderNodes) {
|
|
if (rn.clipStack && rn.clipStack.length > 0) continue;
|
|
minX = Math.min(minX, rn.absX);
|
|
minY = Math.min(minY, rn.absY);
|
|
maxX = Math.max(maxX, rn.absX + rn.absW);
|
|
maxY = Math.max(maxY, rn.absY + rn.absH);
|
|
}
|
|
if (!isFinite(minX)) return;
|
|
|
|
const contentW = maxX - minX;
|
|
const contentH = maxY - minY;
|
|
const canvasW = this.canvasEl.clientWidth;
|
|
const canvasH = this.canvasEl.clientHeight;
|
|
const zoom = Math.min(
|
|
(canvasW - padding * 2) / contentW,
|
|
(canvasH - padding * 2) / contentH,
|
|
2,
|
|
);
|
|
const panX = (canvasW - contentW * zoom) / 2 - minX * zoom;
|
|
const panY = (canvasH - contentH * zoom) / 2 - minY * zoom;
|
|
this.setViewport(zoom, panX, panY);
|
|
}
|
|
|
|
zoomToPoint(screenX: number, screenY: number, newZoom: number) {
|
|
if (!this.canvasEl) return;
|
|
const rect = this.canvasEl.getBoundingClientRect();
|
|
const vp = vpZoomToPoint(
|
|
{ zoom: this._zoom, panX: this._panX, panY: this._panY },
|
|
screenX,
|
|
screenY,
|
|
rect,
|
|
newZoom,
|
|
);
|
|
this.setViewport(vp.zoom, vp.panX, vp.panY);
|
|
}
|
|
|
|
pan(dx: number, dy: number) {
|
|
this.setViewport(this._zoom, this._panX + dx, this._panY + dy);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Theme
|
|
// ---------------------------------------------------------------------------
|
|
|
|
setThemeVariant(variant: Record<string, string>) {
|
|
this.options.themeVariant = variant;
|
|
this.syncFromDocument();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Hit testing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
hitTest(screenX: number, screenY: number): PenNode | null {
|
|
if (!this.canvasEl) return null;
|
|
const rect = this.canvasEl.getBoundingClientRect();
|
|
const scene = screenToScene(screenX, screenY, rect, {
|
|
zoom: this._zoom,
|
|
panX: this._panX,
|
|
panY: this._panY,
|
|
});
|
|
const hits = this.spatialIndex.hitTest(scene.x, scene.y);
|
|
return hits.length > 0 ? hits[0].node : null;
|
|
}
|
|
|
|
getNodeBounds(nodeId: string): { x: number; y: number; w: number; h: number } | null {
|
|
const rn = this.spatialIndex.get(nodeId);
|
|
if (!rn) return null;
|
|
return { x: rn.absX, y: rn.absY, w: rn.absW, h: rn.absH };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal: Document sync
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private syncFromDocument() {
|
|
if (!this.document) return;
|
|
const pageChildren = getActivePageChildren(this.document, this.activePageId);
|
|
const allNodes = getAllChildren(this.document);
|
|
|
|
// Collect reusable/instance IDs
|
|
this.reusableIds.clear();
|
|
this.instanceIds.clear();
|
|
collectReusableIds(pageChildren, this.reusableIds);
|
|
collectInstanceIds(pageChildren, this.instanceIds);
|
|
|
|
// Resolve refs
|
|
const resolved = resolveRefs(pageChildren, allNodes);
|
|
|
|
// Resolve design variables
|
|
const variables = this.document.variables ?? {};
|
|
const themes = this.document.themes;
|
|
const activeTheme = this.options.themeVariant ?? getDefaultTheme(themes);
|
|
const variableResolved = resolved.map((n) => resolveNodeForCanvas(n, variables, activeTheme));
|
|
|
|
// Pre-measure text heights
|
|
const measured = premeasureTextHeights(variableResolved);
|
|
|
|
this.renderNodes = flattenToRenderNodes(measured);
|
|
this.spatialIndex.rebuild(this.renderNodes);
|
|
this.markDirty();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Render loop
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private markDirty() {
|
|
this.dirty = true;
|
|
}
|
|
|
|
private startRenderLoop() {
|
|
const loop = () => {
|
|
this.animFrameId = requestAnimationFrame(loop);
|
|
if (!this.dirty || !this.surface) return;
|
|
this.dirty = false;
|
|
this.render();
|
|
};
|
|
this.animFrameId = requestAnimationFrame(loop);
|
|
}
|
|
|
|
render() {
|
|
if (!this.surface || !this.canvasEl) return;
|
|
const canvas = this.surface.getCanvas();
|
|
const ck = this.ck;
|
|
const dpr = this.options.devicePixelRatio ?? window.devicePixelRatio ?? 1;
|
|
|
|
// Clear
|
|
const bgColor = this.options.backgroundColor ?? CANVAS_BACKGROUND_DARK;
|
|
canvas.clear(parseColor(ck, bgColor));
|
|
|
|
// Apply viewport transform
|
|
canvas.save();
|
|
canvas.scale(dpr, dpr);
|
|
canvas.concat(viewportMatrix({ zoom: this._zoom, panX: this._panX, panY: this._panY }));
|
|
|
|
// Pass current zoom to renderer
|
|
this.nodeRenderer.zoom = this._zoom;
|
|
|
|
// Draw all render nodes. Per-node try/catch isolates failures so a
|
|
// single bad node (malformed effects, NaN coordinates, missing fill)
|
|
// doesn't abort the whole render loop and blank the canvas. The
|
|
// 2026-05-10 user report (Bistro generation, layer panel populated,
|
|
// canvas empty) was traced to NaN feeding RRectXY when an LLM-emitted
|
|
// shadow omitted `spread` — the throw escaped the loop and every
|
|
// sibling stopped rendering. The shadow path now coerces missing
|
|
// numeric fields to 0; this catch is the structural backstop.
|
|
//
|
|
// Snapshot the canvas save count before each node so a throw mid-
|
|
// way through drawNode (which pushes save() / clip() per ancestor
|
|
// clipStack entry, plus more for rotation / flip — see node-
|
|
// renderer.ts:548, 574, 583, 701, 1094, 1102) can roll the canvas
|
|
// state back to a clean baseline. Without this, the leaked clip /
|
|
// rotation state corrupts every subsequent node's draw — Codex
|
|
// stop-hook 2026-05-10 caught the bare-catch leak.
|
|
for (const rn of this.renderNodes) {
|
|
const saveCount = canvas.getSaveCount();
|
|
try {
|
|
this.nodeRenderer.drawNode(canvas, rn);
|
|
} catch (err) {
|
|
try {
|
|
canvas.restoreToCount(saveCount);
|
|
} catch {
|
|
// restoreToCount itself can throw if the snapshot count is
|
|
// somehow above the current depth (shouldn't happen in
|
|
// practice — drawNode only pushes saves, never pops below
|
|
// its entry depth — but guard so the error reporter still
|
|
// logs).
|
|
}
|
|
const id = rn.node.id ?? '<no-id>';
|
|
console.error(`[pen-renderer] drawNode threw for node ${id}:`, err);
|
|
}
|
|
}
|
|
|
|
// Draw frame labels for root frames + reusable + instances
|
|
for (const rn of this.renderNodes) {
|
|
if (!rn.node.name) continue;
|
|
const isRootFrame =
|
|
rn.node.type === 'frame' && (rn.clipStack === undefined || rn.clipStack.length === 0);
|
|
const isReusable = this.reusableIds.has(rn.node.id);
|
|
const isInstance = this.instanceIds.has(rn.node.id);
|
|
if (!isRootFrame && !isReusable && !isInstance) continue;
|
|
this.drawFrameLabel(canvas, rn.node.name, rn.absX, rn.absY);
|
|
}
|
|
|
|
canvas.restore();
|
|
this.surface.flush();
|
|
}
|
|
|
|
/** Simple frame label drawing for read-only renderer. */
|
|
private drawFrameLabel(
|
|
canvas: ReturnType<Surface['getCanvas']>,
|
|
name: string,
|
|
x: number,
|
|
y: number,
|
|
) {
|
|
const ck = this.ck;
|
|
const fontSize = FRAME_LABEL_FONT_SIZE / this._zoom;
|
|
const offsetY = FRAME_LABEL_OFFSET_Y / this._zoom;
|
|
|
|
// Use Canvas 2D to rasterize the label text
|
|
const dpr = this.options.devicePixelRatio ?? window.devicePixelRatio ?? 1;
|
|
const scale = Math.min(this._zoom * dpr, 4);
|
|
const tmp = document.createElement('canvas');
|
|
const textW = Math.ceil(name.length * fontSize * 0.7 * scale) + 4;
|
|
const textH = Math.ceil(fontSize * 1.4 * scale) + 4;
|
|
tmp.width = textW;
|
|
tmp.height = textH;
|
|
const ctx = tmp.getContext('2d')!;
|
|
ctx.scale(scale, scale);
|
|
ctx.font = `500 ${fontSize}px Inter, system-ui, sans-serif`;
|
|
ctx.fillStyle = FRAME_LABEL_COLOR;
|
|
ctx.textBaseline = 'top';
|
|
ctx.fillText(name, 0, 0);
|
|
|
|
const imageData = ctx.getImageData(0, 0, textW, textH);
|
|
const img = ck.MakeImage(
|
|
{
|
|
width: textW,
|
|
height: textH,
|
|
alphaType: ck.AlphaType.Unpremul,
|
|
colorType: ck.ColorType.RGBA_8888,
|
|
colorSpace: ck.ColorSpace.SRGB,
|
|
},
|
|
imageData.data,
|
|
textW * 4,
|
|
);
|
|
if (img) {
|
|
const paint = new ck.Paint();
|
|
paint.setAntiAlias(true);
|
|
canvas.drawImageRect(
|
|
img,
|
|
ck.LTRBRect(0, 0, textW, textH),
|
|
ck.LTRBRect(x, y - offsetY - fontSize * 1.2, x + textW / scale, y - offsetY),
|
|
paint,
|
|
);
|
|
paint.delete();
|
|
img.delete();
|
|
}
|
|
}
|
|
}
|