@zseven-w/pen-renderer 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ZSeven—W
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,64 +1,190 @@
1
1
  # @zseven-w/pen-renderer
2
2
 
3
- Standalone CanvasKit/Skia renderer for [OpenPencil](https://github.com/nicepkg/openpencil) design files. Render `.op` documents to a GPU-accelerated canvas — works in browsers, Node.js, and headless environments.
3
+ Standalone CanvasKit/Skia renderer for [OpenPencil](https://github.com/ZSeven-W/openpencil) design files. Render `.op` documents to a GPU-accelerated canvas — works in browsers, Node.js, and headless environments.
4
4
 
5
5
  ## Install
6
6
 
7
7
  ```bash
8
8
  npm install @zseven-w/pen-renderer canvaskit-wasm
9
+ # or
10
+ bun add @zseven-w/pen-renderer canvaskit-wasm
9
11
  ```
10
12
 
11
13
  `canvaskit-wasm` is a peer dependency — you provide the WASM binary.
12
14
 
13
- ## Usage
15
+ ## Overview
14
16
 
15
- ```ts
17
+ `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:
18
+
19
+ ```
20
+ PenDocument → flattenToRenderNodes() → absolute positions → SkiaNodeRenderer → GPU canvas
21
+
22
+ SpatialIndex (R-tree) → hitTest / searchRect
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ ```typescript
16
28
  import { loadCanvasKit, PenRenderer } from '@zseven-w/pen-renderer';
17
29
 
18
- // Initialize CanvasKit
30
+ // 1. Initialize CanvasKit WASM (once, globally)
19
31
  await loadCanvasKit();
20
32
 
21
- // Create renderer on a canvas element
33
+ // 2. Create renderer on a canvas element
22
34
  const renderer = new PenRenderer(canvas, document, {
23
35
  width: 1920,
24
36
  height: 1080,
25
37
  });
26
38
 
27
- // Render
39
+ // 3. Render
28
40
  renderer.render();
41
+
42
+ // 4. Interact
43
+ renderer.zoomToFit();
44
+ renderer.zoomTo(1.5, centerX, centerY);
45
+ renderer.pan(deltaX, deltaY);
46
+ const node = renderer.hitTest(mouseX, mouseY);
47
+
48
+ // 5. Cleanup
49
+ renderer.dispose();
29
50
  ```
30
51
 
31
- ## API
52
+ ## Features
32
53
 
33
- ### High-level
54
+ ### High-Level Renderer
34
55
 
35
- - **`loadCanvasKit()`** Initialize the CanvasKit WASM module
36
- - **`PenRenderer`** — Full-featured renderer with viewport, selection, and interaction support
56
+ `PenRenderer` provides a complete rendering solution with viewport, selection, and interaction:
57
+
58
+ ```typescript
59
+ const renderer = new PenRenderer(canvas, document, options);
60
+
61
+ renderer.setDocument(newDoc); // Update document
62
+ renderer.render(); // Trigger re-render
63
+ renderer.zoomToFit(); // Fit content to viewport
64
+ renderer.zoomTo(zoom, cx, cy); // Zoom to point
65
+ renderer.pan(dx, dy); // Pan viewport
66
+ renderer.hitTest(x, y); // Hit test at screen coords
67
+ renderer.dispose(); // Free resources
68
+ ```
37
69
 
38
70
  ### Document Flattening
39
71
 
40
- Pre-process documents for rendering:
72
+ Pre-process the document tree into flat render nodes with absolute positions:
73
+
74
+ ```typescript
75
+ import {
76
+ flattenToRenderNodes,
77
+ resolveRefs,
78
+ premeasureTextHeights,
79
+ remapIds,
80
+ } from '@zseven-w/pen-renderer';
81
+
82
+ // Flatten tree → absolute positions
83
+ const renderNodes = flattenToRenderNodes(children, viewport);
84
+
85
+ // Resolve $ref nodes to their source
86
+ const resolved = resolveRefs(renderNodes, document);
41
87
 
42
- ```ts
43
- import { flattenToRenderNodes, resolveRefs, premeasureTextHeights } from '@zseven-w/pen-renderer';
88
+ // Pre-measure text heights using Canvas 2D (for accurate layout)
89
+ premeasureTextHeights(renderNodes, canvasContext);
44
90
  ```
45
91
 
46
- ### Viewport Utilities
92
+ ### Viewport Math
47
93
 
48
- ```ts
49
- import { viewportMatrix, screenToScene, sceneToScreen, zoomToPoint } from '@zseven-w/pen-renderer';
94
+ Camera transforms for pan, zoom, and coordinate conversion:
95
+
96
+ ```typescript
97
+ import {
98
+ viewportMatrix,
99
+ screenToScene,
100
+ sceneToScreen,
101
+ zoomToPoint,
102
+ getViewportBounds,
103
+ isRectInViewport,
104
+ } from '@zseven-w/pen-renderer';
105
+
106
+ const matrix = viewportMatrix(zoom, panX, panY); // 3x3 CanvasKit matrix
107
+ const scene = screenToScene(mouseX, mouseY, viewport);
108
+ const screen = sceneToScreen(nodeX, nodeY, viewport);
109
+ const newVp = zoomToPoint(viewport, 2.0, centerX, centerY);
50
110
  ```
51
111
 
52
- ### Low-level Renderers
112
+ ### Spatial Index
113
+
114
+ R-tree backed spatial queries for click hit testing and marquee selection:
115
+
116
+ ```typescript
117
+ import { SpatialIndex } from '@zseven-w/pen-renderer';
118
+
119
+ const index = new SpatialIndex();
120
+ index.rebuild(renderNodes);
121
+
122
+ const clicked = index.hitTest(x, y); // topmost node at point
123
+ const selected = index.searchRect(x, y, w, h); // all nodes in rect
124
+ const node = index.get(nodeId); // lookup by ID
125
+ ```
126
+
127
+ ### Low-Level Renderers
53
128
 
54
129
  For custom rendering pipelines:
55
130
 
56
- - `SkiaNodeRenderer` — Renders individual nodes to a Skia canvas
57
- - `SkiaTextRenderer` — Text layout and rendering
58
- - `SkiaFontManager` — Font loading and management
59
- - `SkiaImageLoader` — Async image loading with caching
60
- - `SpatialIndex` — R-tree spatial index for hit testing
131
+ ```typescript
132
+ import {
133
+ SkiaNodeRenderer,
134
+ SkiaTextRenderer,
135
+ SkiaFontManager,
136
+ SkiaImageLoader,
137
+ } from '@zseven-w/pen-renderer';
138
+ ```
139
+
140
+ | Class | Handles |
141
+ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
142
+ | `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 |
143
+ | `SkiaTextRenderer` | Text layout and rendering via Paragraph API with bitmap fallback. FIFO caches (256 MB text, 64 MB paragraph) |
144
+ | `SkiaFontManager` | Font loading — bundled fonts (Inter, Poppins, Roboto, etc.) + Google Fonts CSS fetching |
145
+ | `SkiaImageLoader` | Async image loading with caching and custom source resolvers |
146
+
147
+ ### Thumbnail Generation
148
+
149
+ Render individual nodes to offscreen thumbnails (used for git conflict UI, exports):
150
+
151
+ ```typescript
152
+ import { renderNodeThumbnail } from '@zseven-w/pen-renderer';
153
+
154
+ const dataUrl = renderNodeThumbnail(node, { width: 200, height: 200 });
155
+ ```
156
+
157
+ ### Paint Utilities
158
+
159
+ ```typescript
160
+ import {
161
+ parseColor,
162
+ resolveFillColor,
163
+ resolveStrokeColor,
164
+ wrapLine,
165
+ cssFontFamily,
166
+ sanitizeSvgPath,
167
+ } from '@zseven-w/pen-renderer';
168
+
169
+ const color = parseColor('#2563EB'); // CanvasKit Color4f
170
+ ```
171
+
172
+ ## API Reference
173
+
174
+ | Category | Exports |
175
+ | ------------- | -------------------------------------------------------------------------------------- |
176
+ | **Init** | `loadCanvasKit(options?)`, `getCanvasKit()` |
177
+ | **Renderer** | `PenRenderer` |
178
+ | **Flatten** | `flattenToRenderNodes`, `resolveRefs`, `premeasureTextHeights`, `remapIds` |
179
+ | **Viewport** | `viewportMatrix`, `screenToScene`, `sceneToScreen`, `zoomToPoint`, `getViewportBounds` |
180
+ | **Spatial** | `SpatialIndex` — `rebuild`, `hitTest`, `searchRect`, `get` |
181
+ | **Node** | `SkiaNodeRenderer` |
182
+ | **Text** | `SkiaTextRenderer` |
183
+ | **Font** | `SkiaFontManager`, `BUNDLED_FONT_FAMILIES` |
184
+ | **Image** | `SkiaImageLoader` |
185
+ | **Paint** | `parseColor`, `sanitizeSvgPath`, `cssFontFamily` |
186
+ | **Thumbnail** | `renderNodeThumbnail` |
61
187
 
62
188
  ## License
63
189
 
64
- MIT
190
+ [MIT](./LICENSE)
package/package.json CHANGED
@@ -1,7 +1,21 @@
1
1
  {
2
2
  "name": "@zseven-w/pen-renderer",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Standalone CanvasKit/Skia renderer for OpenPencil (.op) design files",
5
+ "homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-renderer",
6
+ "bugs": {
7
+ "url": "https://github.com/ZSeven-W/openpencil/issues"
8
+ },
9
+ "license": "MIT",
10
+ "author": {
11
+ "name": "ZSeven-W",
12
+ "email": "xkayshen@gmail.com"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "https://github.com/ZSeven-W/openpencil.git",
17
+ "directory": "packages/pen-renderer"
18
+ },
5
19
  "files": [
6
20
  "src"
7
21
  ],
@@ -16,8 +30,8 @@
16
30
  "typecheck": "tsc --noEmit"
17
31
  },
18
32
  "dependencies": {
19
- "@zseven-w/pen-core": "0.7.0",
20
- "@zseven-w/pen-types": "0.7.0",
33
+ "@zseven-w/pen-core": "0.7.2",
34
+ "@zseven-w/pen-types": "0.7.2",
21
35
  "rbush": "^4.0.1"
22
36
  },
23
37
  "devDependencies": {
@@ -274,4 +274,35 @@ describe('flattenToRenderNodes — dimension consistency', () => {
274
274
  expect(t1.clipRect!.h).toBe(rootRN.absH);
275
275
  expect(t1.clipRect!.w).toBe(rootRN.absW);
276
276
  });
277
+
278
+ it('nested frame with clipContent clips its descendants using its own bounds/radius', () => {
279
+ const root = frame({
280
+ id: 'root',
281
+ width: 400,
282
+ height: 400,
283
+ children: [
284
+ frame({
285
+ id: 'card',
286
+ x: 40,
287
+ y: 50,
288
+ width: 200,
289
+ height: 120,
290
+ cornerRadius: 16,
291
+ clipContent: true,
292
+ children: [text('inner', 'Nested content', { width: 'fill_container' as any })],
293
+ }),
294
+ ],
295
+ });
296
+
297
+ const nodes = flattenToRenderNodes([root]);
298
+ const card = nodes.find((rn) => rn.node.id === 'card')!;
299
+ const inner = nodes.find((rn) => rn.node.id === 'inner')!;
300
+
301
+ expect(inner.clipRect).toBeDefined();
302
+ expect(inner.clipRect!.x).toBe(card.absX);
303
+ expect(inner.clipRect!.y).toBe(card.absY);
304
+ expect(inner.clipRect!.w).toBe(card.absW);
305
+ expect(inner.clipRect!.h).toBe(card.absH);
306
+ expect(inner.clipRect!.rx).toBe(16);
307
+ });
277
308
  });
@@ -241,10 +241,12 @@ export function flattenToRenderNodes(
241
241
  const positioned =
242
242
  layout && layout !== 'none' ? computeLayoutPositions(resolved, children) : children;
243
243
 
244
- // Clipping — only clip for root frames (artboard behavior).
244
+ // Clipping — root frames always clip like artboards. Nested containers
245
+ // clip only when clipContent is enabled.
245
246
  let childClip = clipCtx;
246
247
  const isRootFrame = node.type === 'frame' && depth === 0;
247
- if (isRootFrame) {
248
+ const explicitClip = 'clipContent' in resolved && resolved.clipContent === true;
249
+ if (isRootFrame || explicitClip) {
248
250
  const crRaw = 'cornerRadius' in node ? cornerRadiusVal(node.cornerRadius) : 0;
249
251
  const cr = Math.min(crRaw, nodeH / 2);
250
252
  childClip = { x: absX, y: absY, w: nodeW, h: nodeH, rx: cr };