openpencil/packages/pen-renderer
Fini 526b61c138 fix(renderer): per-node catch restores canvas save stack
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.
2026-05-10 22:53:58 +08:00
..
src fix(renderer): per-node catch restores canvas save stack 2026-05-10 22:53:58 +08:00
CLAUDE.md V0.7.1 (#102) 2026-04-13 21:30:23 +08:00
LICENSE V0.7.1 (#102) 2026-04-13 21:30:23 +08:00
package.json chore(release): bump to v0.8.0 2026-04-27 08:20:00 +08:00
README.md V0.7.1 (#102) 2026-04-13 21:30:23 +08:00
tsconfig.json V0.5.0 (#67) 2026-03-22 09:44:04 +08:00

@zseven-w/pen-renderer

Standalone CanvasKit/Skia renderer for OpenPencil design files. Render .op documents to a GPU-accelerated canvas — works in browsers, Node.js, and headless environments.

Install

npm install @zseven-w/pen-renderer canvaskit-wasm
# or
bun add @zseven-w/pen-renderer canvaskit-wasm

canvaskit-wasm is a peer dependency — you provide the WASM binary.

Overview

pen-renderer is a pure TypeScript + CanvasKit rendering pipeline with no React or framework dependency. It takes a PenDocument and renders it to a WebGL surface with GPU acceleration. The pipeline:

PenDocument → flattenToRenderNodes() → absolute positions → SkiaNodeRenderer → GPU canvas
                                           ↓
                                    SpatialIndex (R-tree) → hitTest / searchRect

Quick Start

import { loadCanvasKit, PenRenderer } from '@zseven-w/pen-renderer';

// 1. Initialize CanvasKit WASM (once, globally)
await loadCanvasKit();

// 2. Create renderer on a canvas element
const renderer = new PenRenderer(canvas, document, {
  width: 1920,
  height: 1080,
});

// 3. Render
renderer.render();

// 4. Interact
renderer.zoomToFit();
renderer.zoomTo(1.5, centerX, centerY);
renderer.pan(deltaX, deltaY);
const node = renderer.hitTest(mouseX, mouseY);

// 5. Cleanup
renderer.dispose();

Features

High-Level Renderer

PenRenderer provides a complete rendering solution with viewport, selection, and interaction:

const renderer = new PenRenderer(canvas, document, options);

renderer.setDocument(newDoc); // Update document
renderer.render(); // Trigger re-render
renderer.zoomToFit(); // Fit content to viewport
renderer.zoomTo(zoom, cx, cy); // Zoom to point
renderer.pan(dx, dy); // Pan viewport
renderer.hitTest(x, y); // Hit test at screen coords
renderer.dispose(); // Free resources

Document Flattening

Pre-process the document tree into flat render nodes with absolute positions:

import {
  flattenToRenderNodes,
  resolveRefs,
  premeasureTextHeights,
  remapIds,
} from '@zseven-w/pen-renderer';

// Flatten tree → absolute positions
const renderNodes = flattenToRenderNodes(children, viewport);

// Resolve $ref nodes to their source
const resolved = resolveRefs(renderNodes, document);

// Pre-measure text heights using Canvas 2D (for accurate layout)
premeasureTextHeights(renderNodes, canvasContext);

Viewport Math

Camera transforms for pan, zoom, and coordinate conversion:

import {
  viewportMatrix,
  screenToScene,
  sceneToScreen,
  zoomToPoint,
  getViewportBounds,
  isRectInViewport,
} from '@zseven-w/pen-renderer';

const matrix = viewportMatrix(zoom, panX, panY); // 3x3 CanvasKit matrix
const scene = screenToScene(mouseX, mouseY, viewport);
const screen = sceneToScreen(nodeX, nodeY, viewport);
const newVp = zoomToPoint(viewport, 2.0, centerX, centerY);

Spatial Index

R-tree backed spatial queries for click hit testing and marquee selection:

import { SpatialIndex } from '@zseven-w/pen-renderer';

const index = new SpatialIndex();
index.rebuild(renderNodes);

const clicked = index.hitTest(x, y); // topmost node at point
const selected = index.searchRect(x, y, w, h); // all nodes in rect
const node = index.get(nodeId); // lookup by ID

Low-Level Renderers

For custom rendering pipelines:

import {
  SkiaNodeRenderer,
  SkiaTextRenderer,
  SkiaFontManager,
  SkiaImageLoader,
} from '@zseven-w/pen-renderer';
Class Handles
SkiaNodeRenderer All node types — rectangles, ellipses, paths, images, icons, lines, polygons. Fills (solid, gradient, image), strokes, effects (shadow, blur), corner radius, clip, opacity, blend mode
SkiaTextRenderer Text layout and rendering via Paragraph API with bitmap fallback. FIFO caches (256 MB text, 64 MB paragraph)
SkiaFontManager Font loading — bundled fonts (Inter, Poppins, Roboto, etc.) + Google Fonts CSS fetching
SkiaImageLoader Async image loading with caching and custom source resolvers

Thumbnail Generation

Render individual nodes to offscreen thumbnails (used for git conflict UI, exports):

import { renderNodeThumbnail } from '@zseven-w/pen-renderer';

const dataUrl = renderNodeThumbnail(node, { width: 200, height: 200 });

Paint Utilities

import {
  parseColor,
  resolveFillColor,
  resolveStrokeColor,
  wrapLine,
  cssFontFamily,
  sanitizeSvgPath,
} from '@zseven-w/pen-renderer';

const color = parseColor('#2563EB'); // CanvasKit Color4f

API Reference

Category Exports
Init loadCanvasKit(options?), getCanvasKit()
Renderer PenRenderer
Flatten flattenToRenderNodes, resolveRefs, premeasureTextHeights, remapIds
Viewport viewportMatrix, screenToScene, sceneToScreen, zoomToPoint, getViewportBounds
Spatial SpatialIndexrebuild, hitTest, searchRect, get
Node SkiaNodeRenderer
Text SkiaTextRenderer
Font SkiaFontManager, BUNDLED_FONT_FAMILIES
Image SkiaImageLoader
Paint parseColor, sanitizeSvgPath, cssFontFamily
Thumbnail renderNodeThumbnail

License

MIT