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. |
||
|---|---|---|
| .. | ||
| src | ||
| CLAUDE.md | ||
| LICENSE | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
@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 |