@zseven-w/pen-renderer 0.5.2 → 0.7.0

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/README.md CHANGED
@@ -13,19 +13,19 @@ npm install @zseven-w/pen-renderer canvaskit-wasm
13
13
  ## Usage
14
14
 
15
15
  ```ts
16
- import { loadCanvasKit, PenRenderer } from '@zseven-w/pen-renderer'
16
+ import { loadCanvasKit, PenRenderer } from '@zseven-w/pen-renderer';
17
17
 
18
18
  // Initialize CanvasKit
19
- await loadCanvasKit()
19
+ await loadCanvasKit();
20
20
 
21
21
  // Create renderer on a canvas element
22
22
  const renderer = new PenRenderer(canvas, document, {
23
23
  width: 1920,
24
24
  height: 1080,
25
- })
25
+ });
26
26
 
27
27
  // Render
28
- renderer.render()
28
+ renderer.render();
29
29
  ```
30
30
 
31
31
  ## API
@@ -40,13 +40,13 @@ renderer.render()
40
40
  Pre-process documents for rendering:
41
41
 
42
42
  ```ts
43
- import { flattenToRenderNodes, resolveRefs, premeasureTextHeights } from '@zseven-w/pen-renderer'
43
+ import { flattenToRenderNodes, resolveRefs, premeasureTextHeights } from '@zseven-w/pen-renderer';
44
44
  ```
45
45
 
46
46
  ### Viewport Utilities
47
47
 
48
48
  ```ts
49
- import { viewportMatrix, screenToScene, sceneToScreen, zoomToPoint } from '@zseven-w/pen-renderer'
49
+ import { viewportMatrix, screenToScene, sceneToScreen, zoomToPoint } from '@zseven-w/pen-renderer';
50
50
  ```
51
51
 
52
52
  ### Low-level Renderers
package/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "@zseven-w/pen-renderer",
3
- "version": "0.5.2",
3
+ "version": "0.7.0",
4
4
  "description": "Standalone CanvasKit/Skia renderer for OpenPencil (.op) design files",
5
+ "files": [
6
+ "src"
7
+ ],
5
8
  "type": "module",
6
9
  "exports": {
7
10
  ".": {
@@ -9,23 +12,20 @@
9
12
  "import": "./src/index.ts"
10
13
  }
11
14
  },
12
- "files": [
13
- "src"
14
- ],
15
15
  "scripts": {
16
16
  "typecheck": "tsc --noEmit"
17
17
  },
18
18
  "dependencies": {
19
- "@zseven-w/pen-types": "0.5.2",
20
- "@zseven-w/pen-core": "0.5.2",
19
+ "@zseven-w/pen-core": "0.7.0",
20
+ "@zseven-w/pen-types": "0.7.0",
21
21
  "rbush": "^4.0.1"
22
22
  },
23
- "peerDependencies": {
24
- "canvaskit-wasm": "^0.40.0"
25
- },
26
23
  "devDependencies": {
27
24
  "@types/rbush": "^4.0.0",
28
25
  "canvaskit-wasm": "^0.40.0",
29
26
  "typescript": "^5.7.2"
27
+ },
28
+ "peerDependencies": {
29
+ "canvaskit-wasm": "^0.40.0"
30
30
  }
31
31
  }
@@ -0,0 +1,277 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { PenNode } from '@zseven-w/pen-types';
3
+ import { flattenToRenderNodes } from '../document-flattener';
4
+
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;
13
+
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;
24
+
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
+
51
+ it('absH uses getNodeHeight for text without height, not sizeToNumber 100 fallback', () => {
52
+ // Simulates text after fixTextHeights deleted height
53
+ const root = frame({
54
+ id: 'root',
55
+ width: 400,
56
+ height: 600,
57
+ layout: 'vertical' as any,
58
+ children: [
59
+ // Text with no height property (deleted by fixTextHeights)
60
+ {
61
+ id: 't1',
62
+ type: 'text',
63
+ content: 'Hello world',
64
+ fontSize: 16,
65
+ width: 'fill_container' as any,
66
+ } as PenNode,
67
+ ],
68
+ });
69
+
70
+ const nodes = flattenToRenderNodes([root]);
71
+ const t1 = nodes.find((rn) => rn.node.id === 't1')!;
72
+
73
+ // absH should reflect estimated text height (~18-24px for single line at 16px),
74
+ // NOT the 100px sizeToNumber fallback
75
+ expect(t1.absH).toBeLessThan(50);
76
+ expect(t1.absH).toBeGreaterThan(10);
77
+ });
78
+
79
+ it('absW matches child layout width for frame with no explicit width', () => {
80
+ const root = frame({
81
+ id: 'root',
82
+ width: 400,
83
+ height: 600,
84
+ layout: 'vertical' as any,
85
+ children: [
86
+ frame({
87
+ id: 'inner',
88
+ // No explicit width — getNodeWidth should compute from children
89
+ height: 100,
90
+ children: [
91
+ { id: 'r1', type: 'rectangle', x: 0, y: 0, width: 200, height: 50 } as PenNode,
92
+ ],
93
+ }),
94
+ ],
95
+ });
96
+
97
+ const nodes = flattenToRenderNodes([root]);
98
+ const inner = nodes.find((rn) => rn.node.id === 'inner')!;
99
+
100
+ // inner absW should come from getNodeWidth (fitContentWidth → 200),
101
+ // not the sizeToNumber fallback of 100
102
+ expect(inner.absW).toBeGreaterThanOrEqual(200);
103
+ });
104
+
105
+ it('nested text nodes get correct positions and non-zero dimensions', () => {
106
+ const root = frame({
107
+ id: 'root',
108
+ width: 375,
109
+ height: 812,
110
+ layout: 'vertical' as any,
111
+ padding: [20, 16],
112
+ gap: 8,
113
+ children: [
114
+ frame({
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,
121
+ children: [
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
+ }),
131
+ ],
132
+ }),
133
+ ],
134
+ });
135
+
136
+ const nodes = flattenToRenderNodes([root]);
137
+
138
+ for (const rn of nodes) {
139
+ expect(rn.absW, `${rn.node.id} width > 0`).toBeGreaterThan(0);
140
+ expect(rn.absH, `${rn.node.id} height > 0`).toBeGreaterThan(0);
141
+ }
142
+
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')!;
146
+
147
+ // title inside card
148
+ expect(title.absX).toBeGreaterThan(card.absX);
149
+ expect(title.absY).toBeGreaterThan(card.absY);
150
+
151
+ // desc below title
152
+ expect(desc.absY).toBeGreaterThan(title.absY);
153
+ });
154
+
155
+ it('absW/absH match nodeW/nodeH for frame without explicit dimensions', () => {
156
+ // Frame has children but no explicit width — not inside a layout parent,
157
+ // so computeLayoutPositions does NOT set width. This exposes the divergence
158
+ // between sizeToNumber (fallback 100) and getNodeWidth (fitContent → 200).
159
+ const root = frame({
160
+ id: 'root',
161
+ width: 400,
162
+ height: 600,
163
+ // No layout, gap, padding, or fill_container children → inferLayout returns undefined
164
+ children: [
165
+ frame({
166
+ id: 'inner',
167
+ // No explicit width or height
168
+ children: [
169
+ { id: 'r1', type: 'rectangle', x: 10, y: 10, width: 200, height: 50 } as PenNode,
170
+ ],
171
+ }),
172
+ ],
173
+ });
174
+
175
+ const nodes = flattenToRenderNodes([root]);
176
+ const inner = nodes.find((rn) => rn.node.id === 'inner')!;
177
+
178
+ // getNodeWidth → fitContentWidth → 200 (from child rectangle)
179
+ // Before fix: absW = 100 (sizeToNumber fallback). After fix: absW = 200.
180
+ expect(inner.absW).toBeGreaterThanOrEqual(200);
181
+ // getNodeHeight → fitContentHeight → 50 (from child rectangle)
182
+ // Before fix: absH = 100 (fallback). After fix: absH = 50 (or greater).
183
+ expect(inner.absH).toBeGreaterThanOrEqual(50);
184
+ expect(inner.absH).toBeLessThan(100); // not the 100 fallback
185
+ });
186
+
187
+ it('children with stripped x/y in layout frame get correct positions', () => {
188
+ const root = frame({
189
+ id: 'root',
190
+ width: 400,
191
+ height: 600,
192
+ layout: 'vertical' as any,
193
+ padding: [20, 16],
194
+ gap: 12,
195
+ children: [
196
+ // x/y stripped by sanitizeLayoutChildPositions
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,
211
+ ],
212
+ });
213
+
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')!;
217
+
218
+ // t1 at padding offset
219
+ expect(t1.absX).toBe(16); // pad.left
220
+ expect(t1.absY).toBe(20); // pad.top
221
+
222
+ // t2 below t1 + gap
223
+ expect(t2.absY).toBeGreaterThan(t1.absY + t1.absH);
224
+ });
225
+
226
+ it('root frame clipRect matches absW/absH, not a divergent nodeW/nodeH', () => {
227
+ // Root frame (depth=0) creates a clipRect for its children.
228
+ // clipRect must use the same dimensions as the RenderNode's absW/absH.
229
+ const root = frame({
230
+ id: 'root',
231
+ width: 400,
232
+ height: 600,
233
+ cornerRadius: 12,
234
+ layout: 'vertical' as any,
235
+ children: [text('t1', 'Hello', { width: 'fill_container' as any })],
236
+ });
237
+
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')!;
241
+
242
+ // Root frame itself has no clipRect (it IS the clip source)
243
+ expect(rootRN.clipRect).toBeUndefined();
244
+
245
+ // Child inherits root's clip — must match root's rendered dimensions
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
+ });
252
+
253
+ it('root frame clipRect matches absW/absH for frame without explicit height', () => {
254
+ // Frame with fit_content height — getNodeHeight computes from children.
255
+ // clipRect.h must equal the RenderNode's absH, not a stale fallback.
256
+ const root = frame({
257
+ id: 'root',
258
+ width: 375,
259
+ // No explicit height — relies on getNodeHeight → fitContentHeight
260
+ layout: 'vertical' as any,
261
+ padding: [20, 16],
262
+ children: [text('t1', 'Card title', { width: 'fill_container' as any, fontSize: 18 })],
263
+ });
264
+
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')!;
268
+
269
+ // absH should be computed from content, not 100 fallback
270
+ expect(rootRN.absH).toBeGreaterThan(0);
271
+
272
+ // clipRect must match absH
273
+ expect(t1.clipRect).toBeDefined();
274
+ expect(t1.clipRect!.h).toBe(rootRN.absH);
275
+ expect(t1.clipRect!.w).toBe(rootRN.absW);
276
+ });
277
+ });
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { SkiaFontManager } from '../font-manager';
3
+
4
+ // Minimal mock CanvasKit shim — only the bits SkiaFontManager constructor touches.
5
+ function makeMockCk(): unknown {
6
+ return {
7
+ TypefaceFontProvider: {
8
+ Make: () => ({ registerFont: () => {} }),
9
+ },
10
+ };
11
+ }
12
+
13
+ describe('SkiaFontManager.pendingCount / flushPending', () => {
14
+ it('starts with pendingCount = 0', () => {
15
+ const fm = new SkiaFontManager(makeMockCk() as never);
16
+ expect(fm.pendingCount()).toBe(0);
17
+ });
18
+
19
+ it('flushPending resolves immediately when nothing is pending', async () => {
20
+ const fm = new SkiaFontManager(makeMockCk() as never);
21
+ let resolved = false;
22
+ await fm.flushPending().then(() => {
23
+ resolved = true;
24
+ });
25
+ expect(resolved).toBe(true);
26
+ });
27
+
28
+ it('tracks in-flight promises injected via the pendingFetches map', async () => {
29
+ const fm = new SkiaFontManager(makeMockCk() as never);
30
+ // Use private access via cast — testing internals to verify the new
31
+ // public methods read the map correctly without coupling tests to
32
+ // network/font-loading machinery.
33
+ let releaseA: () => void = () => {};
34
+ let releaseB: () => void = () => {};
35
+ const pA = new Promise<boolean>((resolve) => {
36
+ releaseA = () => resolve(true);
37
+ });
38
+ const pB = new Promise<boolean>((resolve) => {
39
+ releaseB = () => resolve(true);
40
+ });
41
+ (fm as unknown as { pendingFetches: Map<string, Promise<boolean>> }).pendingFetches.set(
42
+ 'a',
43
+ pA,
44
+ );
45
+ (fm as unknown as { pendingFetches: Map<string, Promise<boolean>> }).pendingFetches.set(
46
+ 'b',
47
+ pB,
48
+ );
49
+ expect(fm.pendingCount()).toBe(2);
50
+
51
+ let flushResolved = false;
52
+ const flushed = fm.flushPending().then(() => {
53
+ flushResolved = true;
54
+ });
55
+ await new Promise((r) => setTimeout(r, 10));
56
+ expect(flushResolved).toBe(false);
57
+
58
+ releaseA();
59
+ releaseB();
60
+ await pA;
61
+ await pB;
62
+ await flushed;
63
+ expect(flushResolved).toBe(true);
64
+ });
65
+ });
@@ -0,0 +1,136 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { SkiaImageLoader } from '../image-loader';
4
+
5
+ function makeMockCk(): unknown {
6
+ return {};
7
+ }
8
+
9
+ const originalDocument = globalThis.document;
10
+ const OriginalImage = globalThis.Image;
11
+
12
+ describe('SkiaImageLoader', () => {
13
+ it('starts with pendingCount = 0', () => {
14
+ const loader = new SkiaImageLoader(makeMockCk() as never);
15
+ expect(loader.pendingCount()).toBe(0);
16
+ });
17
+
18
+ it('flushPending resolves immediately when nothing is pending', async () => {
19
+ const loader = new SkiaImageLoader(makeMockCk() as never);
20
+ let resolved = false;
21
+ await loader.flushPending().then(() => {
22
+ resolved = true;
23
+ });
24
+ expect(resolved).toBe(true);
25
+ });
26
+
27
+ it('tracks in-flight promises injected via the pendingPromises set', async () => {
28
+ const loader = new SkiaImageLoader(makeMockCk() as never);
29
+ let release: () => void = () => {};
30
+ const pending = new Promise<void>((resolve) => {
31
+ release = () => resolve();
32
+ });
33
+ (loader as unknown as { pendingPromises: Set<Promise<unknown>> }).pendingPromises.add(pending);
34
+ expect(loader.pendingCount()).toBe(1);
35
+
36
+ let flushResolved = false;
37
+ const flushed = loader.flushPending().then(() => {
38
+ flushResolved = true;
39
+ });
40
+ await new Promise((r) => setTimeout(r, 10));
41
+ expect(flushResolved).toBe(false);
42
+
43
+ release();
44
+ await pending;
45
+ await flushed;
46
+ expect(flushResolved).toBe(true);
47
+ });
48
+
49
+ it('downscales oversized decoded images before creating a CanvasKit image', async () => {
50
+ let drawSize: { width: number; height: number } | null = null;
51
+ let imageDataSize: { width: number; height: number } | null = null;
52
+ let makeImageSize: { width: number; height: number } | null = null;
53
+
54
+ (globalThis as { document?: Document }).document = {
55
+ createElement(tag: string) {
56
+ expect(tag).toBe('canvas');
57
+
58
+ const canvas = {
59
+ width: 0,
60
+ height: 0,
61
+ getContext() {
62
+ return {
63
+ drawImage(_img: unknown, _x: number, _y: number, width: number, height: number) {
64
+ drawSize = { width, height };
65
+ },
66
+ getImageData(_x: number, _y: number, width: number, height: number) {
67
+ imageDataSize = { width, height };
68
+ return { data: new Uint8ClampedArray(width * height * 4) };
69
+ },
70
+ };
71
+ },
72
+ };
73
+
74
+ return canvas as unknown as HTMLCanvasElement;
75
+ },
76
+ } as Document;
77
+
78
+ class MockImage {
79
+ naturalWidth = 8192;
80
+ naturalHeight = 4096;
81
+ width = 8192;
82
+ height = 4096;
83
+ onload: ((event: Event) => void) | null = null;
84
+ onerror: ((event: string | Event) => void) | null = null;
85
+
86
+ set src(_value: string) {
87
+ queueMicrotask(() => {
88
+ this.onload?.({} as Event);
89
+ });
90
+ }
91
+ }
92
+
93
+ (globalThis as { Image?: typeof Image }).Image = MockImage as unknown as typeof Image;
94
+
95
+ try {
96
+ const ck = {
97
+ AlphaType: { Unpremul: 0 },
98
+ ColorType: { RGBA_8888: 0 },
99
+ ColorSpace: { SRGB: 0 },
100
+ MakeImage(info: { width: number; height: number }) {
101
+ makeImageSize = { width: info.width, height: info.height };
102
+ return {
103
+ delete() {},
104
+ width: () => info.width,
105
+ height: () => info.height,
106
+ };
107
+ },
108
+ };
109
+
110
+ const loader = new SkiaImageLoader(ck as any);
111
+ const loaded = new Promise<void>((resolve) => {
112
+ loader.setOnLoaded(resolve);
113
+ });
114
+
115
+ loader.request('large-image.png');
116
+ await loaded;
117
+
118
+ expect(drawSize).toEqual({ width: 4096, height: 2048 });
119
+ expect(imageDataSize).toEqual({ width: 4096, height: 2048 });
120
+ expect(makeImageSize).toEqual({ width: 4096, height: 2048 });
121
+ expect(loader.getStatus('large-image.png')).toEqual({ state: 'loaded' });
122
+ } finally {
123
+ if (originalDocument === undefined) {
124
+ delete (globalThis as { document?: Document }).document;
125
+ } else {
126
+ (globalThis as { document?: Document }).document = originalDocument;
127
+ }
128
+
129
+ if (OriginalImage === undefined) {
130
+ delete (globalThis as { Image?: typeof Image }).Image;
131
+ } else {
132
+ (globalThis as { Image?: typeof Image }).Image = OriginalImage;
133
+ }
134
+ }
135
+ });
136
+ });
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { hasVisibleStroke, shouldUseTransparentFallbackFill } from '../paint-utils';
3
+
4
+ describe('hasVisibleStroke', () => {
5
+ it('returns true for a normal colored stroke', () => {
6
+ expect(
7
+ hasVisibleStroke({
8
+ thickness: 4,
9
+ fill: [{ type: 'solid', color: '#60A5FA' }],
10
+ }),
11
+ ).toBe(true);
12
+ });
13
+
14
+ it('returns false when stroke is missing a visible color', () => {
15
+ expect(
16
+ hasVisibleStroke({
17
+ thickness: 4,
18
+ fill: [{ type: 'solid', color: '#00000000' }],
19
+ }),
20
+ ).toBe(true);
21
+ expect(
22
+ hasVisibleStroke({
23
+ thickness: 4,
24
+ fill: [],
25
+ }),
26
+ ).toBe(false);
27
+ });
28
+
29
+ it('returns false when stroke width resolves to zero', () => {
30
+ expect(
31
+ hasVisibleStroke({
32
+ thickness: 0,
33
+ fill: [{ type: 'solid', color: '#60A5FA' }],
34
+ }),
35
+ ).toBe(false);
36
+ });
37
+ });
38
+
39
+ describe('shouldUseTransparentFallbackFill', () => {
40
+ it('keeps stroke-only shapes hollow instead of falling back to default fill', () => {
41
+ expect(
42
+ shouldUseTransparentFallbackFill(undefined, {
43
+ thickness: 12,
44
+ fill: [{ type: 'solid', color: '#22C55E' }],
45
+ }),
46
+ ).toBe(true);
47
+ });
48
+
49
+ it('keeps fill-less containers transparent', () => {
50
+ expect(shouldUseTransparentFallbackFill(undefined, undefined, true)).toBe(true);
51
+ });
52
+
53
+ it('does not override explicit fills', () => {
54
+ expect(
55
+ shouldUseTransparentFallbackFill([{ type: 'solid', color: '#00000000' }], {
56
+ thickness: 4,
57
+ fill: [{ type: 'solid', color: '#60A5FA' }],
58
+ }),
59
+ ).toBe(false);
60
+ });
61
+ });