@zseven-w/pen-renderer 0.6.0 → 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 +6 -6
- package/package.json +9 -9
- package/src/__tests__/document-flattener.test.ts +166 -90
- package/src/__tests__/font-manager.test.ts +65 -0
- package/src/__tests__/image-loader.test.ts +136 -0
- package/src/__tests__/paint-utils.test.ts +61 -0
- package/src/__tests__/render-node-thumbnail.test.ts +312 -0
- package/src/document-flattener.ts +222 -159
- package/src/font-manager.ts +221 -190
- package/src/image-loader.ts +138 -51
- package/src/index.ts +18 -17
- package/src/init.ts +50 -21
- package/src/node-renderer.ts +957 -386
- package/src/paint-utils.ts +99 -74
- package/src/path-utils.ts +235 -115
- package/src/render-node-thumbnail.ts +155 -0
- package/src/renderer.ts +196 -175
- package/src/spatial-index.ts +139 -27
- package/src/text-renderer.ts +360 -302
- package/src/types.ts +18 -22
- package/src/viewport.ts +28 -29
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.
|
|
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-
|
|
20
|
-
"@zseven-w/pen-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
7
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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',
|
|
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
|
-
{
|
|
21
|
-
|
|
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',
|
|
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',
|
|
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',
|
|
65
|
-
|
|
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', {
|
|
68
|
-
|
|
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',
|
|
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',
|
|
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
|
-
{
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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',
|
|
230
|
+
id: 'root',
|
|
231
|
+
width: 400,
|
|
232
|
+
height: 600,
|
|
154
233
|
cornerRadius: 12,
|
|
155
234
|
layout: 'vertical' as any,
|
|
156
|
-
children: [
|
|
157
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
+
});
|
|
@@ -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
|
+
});
|