@zseven-w/pen-renderer 0.6.0 → 0.7.1

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
16
- import { loadCanvasKit, PenRenderer } from '@zseven-w/pen-renderer'
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:
17
18
 
18
- // Initialize CanvasKit
19
- await loadCanvasKit()
19
+ ```
20
+ PenDocument → flattenToRenderNodes() → absolute positions → SkiaNodeRenderer → GPU canvas
21
+
22
+ SpatialIndex (R-tree) → hitTest / searchRect
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ ```typescript
28
+ import { loadCanvasKit, PenRenderer } from '@zseven-w/pen-renderer';
29
+
30
+ // 1. Initialize CanvasKit WASM (once, globally)
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
28
- renderer.render()
39
+ // 3. Render
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,24 @@
1
1
  {
2
2
  "name": "@zseven-w/pen-renderer",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
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
+ },
19
+ "files": [
20
+ "src"
21
+ ],
5
22
  "type": "module",
6
23
  "exports": {
7
24
  ".": {
@@ -9,23 +26,20 @@
9
26
  "import": "./src/index.ts"
10
27
  }
11
28
  },
12
- "files": [
13
- "src"
14
- ],
15
29
  "scripts": {
16
30
  "typecheck": "tsc --noEmit"
17
31
  },
18
32
  "dependencies": {
19
- "@zseven-w/pen-types": "0.6.0",
20
- "@zseven-w/pen-core": "0.6.0",
33
+ "@zseven-w/pen-core": "0.7.1",
34
+ "@zseven-w/pen-types": "0.7.1",
21
35
  "rbush": "^4.0.1"
22
36
  },
23
- "peerDependencies": {
24
- "canvaskit-wasm": "^0.40.0"
25
- },
26
37
  "devDependencies": {
27
38
  "@types/rbush": "^4.0.0",
28
39
  "canvaskit-wasm": "^0.40.0",
29
40
  "typescript": "^5.7.2"
41
+ },
42
+ "peerDependencies": {
43
+ "canvaskit-wasm": "^0.40.0"
30
44
  }
31
45
  }
@@ -1,39 +1,87 @@
1
- import { describe, it, expect } from 'vitest'
2
- import type { PenNode } from '@zseven-w/pen-types'
3
- import { flattenToRenderNodes } from '../document-flattener'
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { PenNode } from '@zseven-w/pen-types';
3
+ import { flattenToRenderNodes } from '../document-flattener';
4
4
 
5
- const frame = (props: Partial<PenNode> & { children?: PenNode[] }): PenNode => ({
6
- id: 'f1', type: 'frame', x: 0, y: 0, ...props,
7
- } as PenNode)
5
+ const frame = (props: Partial<PenNode> & { children?: PenNode[] }): PenNode =>
6
+ ({
7
+ id: 'f1',
8
+ type: 'frame',
9
+ x: 0,
10
+ y: 0,
11
+ ...props,
12
+ }) as PenNode;
8
13
 
9
- const text = (id: string, content: string, props: Partial<PenNode> = {}): PenNode => ({
10
- id, type: 'text', x: 0, y: 0, content, fontSize: 16, ...props,
11
- } as PenNode)
14
+ const text = (id: string, content: string, props: Partial<PenNode> = {}): PenNode =>
15
+ ({
16
+ id,
17
+ type: 'text',
18
+ x: 0,
19
+ y: 0,
20
+ content,
21
+ fontSize: 16,
22
+ ...props,
23
+ }) as PenNode;
12
24
 
13
25
  describe('flattenToRenderNodes — dimension consistency', () => {
26
+ it('skips nodes disabled via enabled=false', () => {
27
+ const root = frame({
28
+ id: 'root',
29
+ width: 400,
30
+ height: 600,
31
+ children: [
32
+ { id: 'visible', type: 'rectangle', x: 0, y: 0, width: 120, height: 80 } as PenNode,
33
+ {
34
+ id: 'disabled',
35
+ type: 'rectangle',
36
+ x: 20,
37
+ y: 20,
38
+ width: 120,
39
+ height: 80,
40
+ enabled: false,
41
+ } as PenNode,
42
+ ],
43
+ });
44
+
45
+ const nodes = flattenToRenderNodes([root]);
46
+
47
+ expect(nodes.some((rn) => rn.node.id === 'visible')).toBe(true);
48
+ expect(nodes.some((rn) => rn.node.id === 'disabled')).toBe(false);
49
+ });
50
+
14
51
  it('absH uses getNodeHeight for text without height, not sizeToNumber 100 fallback', () => {
15
52
  // Simulates text after fixTextHeights deleted height
16
53
  const root = frame({
17
- id: 'root', width: 400, height: 600, layout: 'vertical' as any,
54
+ id: 'root',
55
+ width: 400,
56
+ height: 600,
57
+ layout: 'vertical' as any,
18
58
  children: [
19
59
  // Text with no height property (deleted by fixTextHeights)
20
- { id: 't1', type: 'text', content: 'Hello world', fontSize: 16,
21
- width: 'fill_container' as any } as PenNode,
60
+ {
61
+ id: 't1',
62
+ type: 'text',
63
+ content: 'Hello world',
64
+ fontSize: 16,
65
+ width: 'fill_container' as any,
66
+ } as PenNode,
22
67
  ],
23
- })
68
+ });
24
69
 
25
- const nodes = flattenToRenderNodes([root])
26
- const t1 = nodes.find(rn => rn.node.id === 't1')!
70
+ const nodes = flattenToRenderNodes([root]);
71
+ const t1 = nodes.find((rn) => rn.node.id === 't1')!;
27
72
 
28
73
  // absH should reflect estimated text height (~18-24px for single line at 16px),
29
74
  // NOT the 100px sizeToNumber fallback
30
- expect(t1.absH).toBeLessThan(50)
31
- expect(t1.absH).toBeGreaterThan(10)
32
- })
75
+ expect(t1.absH).toBeLessThan(50);
76
+ expect(t1.absH).toBeGreaterThan(10);
77
+ });
33
78
 
34
79
  it('absW matches child layout width for frame with no explicit width', () => {
35
80
  const root = frame({
36
- id: 'root', width: 400, height: 600, layout: 'vertical' as any,
81
+ id: 'root',
82
+ width: 400,
83
+ height: 600,
84
+ layout: 'vertical' as any,
37
85
  children: [
38
86
  frame({
39
87
  id: 'inner',
@@ -44,58 +92,74 @@ describe('flattenToRenderNodes — dimension consistency', () => {
44
92
  ],
45
93
  }),
46
94
  ],
47
- })
95
+ });
48
96
 
49
- const nodes = flattenToRenderNodes([root])
50
- const inner = nodes.find(rn => rn.node.id === 'inner')!
97
+ const nodes = flattenToRenderNodes([root]);
98
+ const inner = nodes.find((rn) => rn.node.id === 'inner')!;
51
99
 
52
100
  // inner absW should come from getNodeWidth (fitContentWidth → 200),
53
101
  // not the sizeToNumber fallback of 100
54
- expect(inner.absW).toBeGreaterThanOrEqual(200)
55
- })
102
+ expect(inner.absW).toBeGreaterThanOrEqual(200);
103
+ });
56
104
 
57
105
  it('nested text nodes get correct positions and non-zero dimensions', () => {
58
106
  const root = frame({
59
- id: 'root', width: 375, height: 812, layout: 'vertical' as any,
107
+ id: 'root',
108
+ width: 375,
109
+ height: 812,
110
+ layout: 'vertical' as any,
60
111
  padding: [20, 16],
61
112
  gap: 8,
62
113
  children: [
63
114
  frame({
64
- id: 'card', width: 'fill_container' as any, height: 'fit_content' as any,
65
- layout: 'vertical' as any, padding: [16, 16], gap: 8,
115
+ id: 'card',
116
+ width: 'fill_container' as any,
117
+ height: 'fit_content' as any,
118
+ layout: 'vertical' as any,
119
+ padding: [16, 16],
120
+ gap: 8,
66
121
  children: [
67
- text('title', 'Card Title', { width: 'fill_container' as any, fontSize: 18, fontWeight: '600' }),
68
- text('desc', 'Description text that may wrap.', { width: 'fill_container' as any, fontSize: 14 }),
122
+ text('title', 'Card Title', {
123
+ width: 'fill_container' as any,
124
+ fontSize: 18,
125
+ fontWeight: '600',
126
+ }),
127
+ text('desc', 'Description text that may wrap.', {
128
+ width: 'fill_container' as any,
129
+ fontSize: 14,
130
+ }),
69
131
  ],
70
132
  }),
71
133
  ],
72
- })
134
+ });
73
135
 
74
- const nodes = flattenToRenderNodes([root])
136
+ const nodes = flattenToRenderNodes([root]);
75
137
 
76
138
  for (const rn of nodes) {
77
- expect(rn.absW, `${rn.node.id} width > 0`).toBeGreaterThan(0)
78
- expect(rn.absH, `${rn.node.id} height > 0`).toBeGreaterThan(0)
139
+ expect(rn.absW, `${rn.node.id} width > 0`).toBeGreaterThan(0);
140
+ expect(rn.absH, `${rn.node.id} height > 0`).toBeGreaterThan(0);
79
141
  }
80
142
 
81
- const card = nodes.find(rn => rn.node.id === 'card')!
82
- const title = nodes.find(rn => rn.node.id === 'title')!
83
- const desc = nodes.find(rn => rn.node.id === 'desc')!
143
+ const card = nodes.find((rn) => rn.node.id === 'card')!;
144
+ const title = nodes.find((rn) => rn.node.id === 'title')!;
145
+ const desc = nodes.find((rn) => rn.node.id === 'desc')!;
84
146
 
85
147
  // title inside card
86
- expect(title.absX).toBeGreaterThan(card.absX)
87
- expect(title.absY).toBeGreaterThan(card.absY)
148
+ expect(title.absX).toBeGreaterThan(card.absX);
149
+ expect(title.absY).toBeGreaterThan(card.absY);
88
150
 
89
151
  // desc below title
90
- expect(desc.absY).toBeGreaterThan(title.absY)
91
- })
152
+ expect(desc.absY).toBeGreaterThan(title.absY);
153
+ });
92
154
 
93
155
  it('absW/absH match nodeW/nodeH for frame without explicit dimensions', () => {
94
156
  // Frame has children but no explicit width — not inside a layout parent,
95
157
  // so computeLayoutPositions does NOT set width. This exposes the divergence
96
158
  // between sizeToNumber (fallback 100) and getNodeWidth (fitContent → 200).
97
159
  const root = frame({
98
- id: 'root', width: 400, height: 600,
160
+ id: 'root',
161
+ width: 400,
162
+ height: 600,
99
163
  // No layout, gap, padding, or fill_container children → inferLayout returns undefined
100
164
  children: [
101
165
  frame({
@@ -106,96 +170,108 @@ describe('flattenToRenderNodes — dimension consistency', () => {
106
170
  ],
107
171
  }),
108
172
  ],
109
- })
173
+ });
110
174
 
111
- const nodes = flattenToRenderNodes([root])
112
- const inner = nodes.find(rn => rn.node.id === 'inner')!
175
+ const nodes = flattenToRenderNodes([root]);
176
+ const inner = nodes.find((rn) => rn.node.id === 'inner')!;
113
177
 
114
178
  // getNodeWidth → fitContentWidth → 200 (from child rectangle)
115
179
  // Before fix: absW = 100 (sizeToNumber fallback). After fix: absW = 200.
116
- expect(inner.absW).toBeGreaterThanOrEqual(200)
180
+ expect(inner.absW).toBeGreaterThanOrEqual(200);
117
181
  // getNodeHeight → fitContentHeight → 50 (from child rectangle)
118
182
  // Before fix: absH = 100 (fallback). After fix: absH = 50 (or greater).
119
- expect(inner.absH).toBeGreaterThanOrEqual(50)
120
- expect(inner.absH).toBeLessThan(100) // not the 100 fallback
121
- })
183
+ expect(inner.absH).toBeGreaterThanOrEqual(50);
184
+ expect(inner.absH).toBeLessThan(100); // not the 100 fallback
185
+ });
122
186
 
123
187
  it('children with stripped x/y in layout frame get correct positions', () => {
124
188
  const root = frame({
125
- id: 'root', width: 400, height: 600, layout: 'vertical' as any,
189
+ id: 'root',
190
+ width: 400,
191
+ height: 600,
192
+ layout: 'vertical' as any,
126
193
  padding: [20, 16],
127
194
  gap: 12,
128
195
  children: [
129
196
  // x/y stripped by sanitizeLayoutChildPositions
130
- { id: 't1', type: 'text', content: 'First', fontSize: 16,
131
- width: 'fill_container' as any } as PenNode,
132
- { id: 't2', type: 'text', content: 'Second', fontSize: 16,
133
- width: 'fill_container' as any } as PenNode,
197
+ {
198
+ id: 't1',
199
+ type: 'text',
200
+ content: 'First',
201
+ fontSize: 16,
202
+ width: 'fill_container' as any,
203
+ } as PenNode,
204
+ {
205
+ id: 't2',
206
+ type: 'text',
207
+ content: 'Second',
208
+ fontSize: 16,
209
+ width: 'fill_container' as any,
210
+ } as PenNode,
134
211
  ],
135
- })
212
+ });
136
213
 
137
- const nodes = flattenToRenderNodes([root])
138
- const t1 = nodes.find(rn => rn.node.id === 't1')!
139
- const t2 = nodes.find(rn => rn.node.id === 't2')!
214
+ const nodes = flattenToRenderNodes([root]);
215
+ const t1 = nodes.find((rn) => rn.node.id === 't1')!;
216
+ const t2 = nodes.find((rn) => rn.node.id === 't2')!;
140
217
 
141
218
  // t1 at padding offset
142
- expect(t1.absX).toBe(16) // pad.left
143
- expect(t1.absY).toBe(20) // pad.top
219
+ expect(t1.absX).toBe(16); // pad.left
220
+ expect(t1.absY).toBe(20); // pad.top
144
221
 
145
222
  // t2 below t1 + gap
146
- expect(t2.absY).toBeGreaterThan(t1.absY + t1.absH)
147
- })
223
+ expect(t2.absY).toBeGreaterThan(t1.absY + t1.absH);
224
+ });
148
225
 
149
226
  it('root frame clipRect matches absW/absH, not a divergent nodeW/nodeH', () => {
150
227
  // Root frame (depth=0) creates a clipRect for its children.
151
228
  // clipRect must use the same dimensions as the RenderNode's absW/absH.
152
229
  const root = frame({
153
- id: 'root', width: 400, height: 600,
230
+ id: 'root',
231
+ width: 400,
232
+ height: 600,
154
233
  cornerRadius: 12,
155
234
  layout: 'vertical' as any,
156
- children: [
157
- text('t1', 'Hello', { width: 'fill_container' as any }),
158
- ],
159
- })
235
+ children: [text('t1', 'Hello', { width: 'fill_container' as any })],
236
+ });
160
237
 
161
- const nodes = flattenToRenderNodes([root])
162
- const rootRN = nodes.find(rn => rn.node.id === 'root')!
163
- const t1 = nodes.find(rn => rn.node.id === 't1')!
238
+ const nodes = flattenToRenderNodes([root]);
239
+ const rootRN = nodes.find((rn) => rn.node.id === 'root')!;
240
+ const t1 = nodes.find((rn) => rn.node.id === 't1')!;
164
241
 
165
242
  // Root frame itself has no clipRect (it IS the clip source)
166
- expect(rootRN.clipRect).toBeUndefined()
243
+ expect(rootRN.clipRect).toBeUndefined();
167
244
 
168
245
  // Child inherits root's clip — must match root's rendered dimensions
169
- expect(t1.clipRect).toBeDefined()
170
- expect(t1.clipRect!.w).toBe(rootRN.absW)
171
- expect(t1.clipRect!.h).toBe(rootRN.absH)
172
- expect(t1.clipRect!.x).toBe(rootRN.absX)
173
- expect(t1.clipRect!.y).toBe(rootRN.absY)
174
- })
246
+ expect(t1.clipRect).toBeDefined();
247
+ expect(t1.clipRect!.w).toBe(rootRN.absW);
248
+ expect(t1.clipRect!.h).toBe(rootRN.absH);
249
+ expect(t1.clipRect!.x).toBe(rootRN.absX);
250
+ expect(t1.clipRect!.y).toBe(rootRN.absY);
251
+ });
175
252
 
176
253
  it('root frame clipRect matches absW/absH for frame without explicit height', () => {
177
254
  // Frame with fit_content height — getNodeHeight computes from children.
178
255
  // clipRect.h must equal the RenderNode's absH, not a stale fallback.
179
256
  const root = frame({
180
- id: 'root', width: 375,
257
+ id: 'root',
258
+ width: 375,
181
259
  // No explicit height — relies on getNodeHeight → fitContentHeight
182
260
  layout: 'vertical' as any,
183
261
  padding: [20, 16],
184
- children: [
185
- text('t1', 'Card title', { width: 'fill_container' as any, fontSize: 18 }),
186
- ],
187
- })
262
+ children: [text('t1', 'Card title', { width: 'fill_container' as any, fontSize: 18 })],
263
+ });
188
264
 
189
- const nodes = flattenToRenderNodes([root])
190
- const rootRN = nodes.find(rn => rn.node.id === 'root')!
191
- const t1 = nodes.find(rn => rn.node.id === 't1')!
265
+ const nodes = flattenToRenderNodes([root]);
266
+ const rootRN = nodes.find((rn) => rn.node.id === 'root')!;
267
+ const t1 = nodes.find((rn) => rn.node.id === 't1')!;
192
268
 
193
269
  // absH should be computed from content, not 100 fallback
194
- expect(rootRN.absH).toBeGreaterThan(0)
270
+ expect(rootRN.absH).toBeGreaterThan(0);
195
271
 
196
272
  // clipRect must match absH
197
- expect(t1.clipRect).toBeDefined()
198
- expect(t1.clipRect!.h).toBe(rootRN.absH)
199
- expect(t1.clipRect!.w).toBe(rootRN.absW)
200
- })
201
- })
273
+ expect(t1.clipRect).toBeDefined();
274
+ expect(t1.clipRect!.h).toBe(rootRN.absH);
275
+ expect(t1.clipRect!.w).toBe(rootRN.absW);
276
+ });
277
+ });