@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 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.6.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-types": "0.6.0",
20
- "@zseven-w/pen-core": "0.6.0",
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
- 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
+ });
@@ -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
+ });