openpencil/packages/pen-renderer
Fini b49060d59f fix(renderer): drop-shadow follows cornerRadius / ellipse outline
User-reported 2026-05-10 "圆角元素的尖角阴影" — rounded cards / hero
images had visibly square-cornered drop shadows poking out from
under the rounded shape. Forensic root cause: applyShadowDirect at
node-renderer.ts:447 was always drawing the shadow as a plain
`canvas.drawRect(...)`, completely ignoring the node's cornerRadius.
A frame with cornerRadius=24 + a subtle drop shadow would render the
rounded body cleanly but stamp a sharp-cornered shadow rectangle
just behind it, with the rectangle corners visible past the rounded
outline.

Fix: pass the node's cornerRadius into applyShadowDirect; when > 0
use `drawRRect` with `RRectXY(rect, cornerRadius+spread, ...)` so the
shadow's rounding stays parallel to the node's rounding (the +spread
correction keeps the visible curve aligned when spread expands /
contracts the bounds).

Ellipse / circle nodes (avatars, status dots) get cornerRadius =
min(w,h)/2 from the call site so their shadows render as stadium /
circle. Asymmetric-aspect ellipses get a stadium approximation
rather than a true ellipse — accepted simplification, the common
case is symmetric (avatar / dot).

Path / line / polygon nodes have no cornerRadius and fall through
with cr=0 — rectangular shadow stays correct for them.

This is a renderer-layer fix that detector-only paths can't reach;
ships in the same session as the typography / spacing detectors so
the user sees end-to-end aesthetic improvement on the next rebuild.
2026-05-10 22:53:58 +08:00
..
src fix(renderer): drop-shadow follows cornerRadius / ellipse outline 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 Merge origin/v0.8.0 into feat/rust-ification 2026-05-03 21:00: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 SpatialIndex — rebuild, hitTest, searchRect, get
Node SkiaNodeRenderer
Text SkiaTextRenderer
Font SkiaFontManager, BUNDLED_FONT_FAMILIES
Image SkiaImageLoader
Paint parseColor, sanitizeSvgPath, cssFontFamily
Thumbnail renderNodeThumbnail

License

MIT