@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.
@@ -1,4 +1,16 @@
1
- import type { CanvasKit, Image as SkImage } from 'canvaskit-wasm'
1
+ import type { CanvasKit, Image as SkImage } from 'canvaskit-wasm';
2
+
3
+ const MAX_IMAGE_DIMENSION = 4096;
4
+ const MAX_IMAGE_PIXELS = MAX_IMAGE_DIMENSION * MAX_IMAGE_DIMENSION;
5
+
6
+ export interface ResolvedImageSource {
7
+ cacheKey: string;
8
+ loadUrl: string | null;
9
+ }
10
+
11
+ export interface ImageLoadStatus {
12
+ state: 'loading' | 'loaded' | 'missing' | 'error';
13
+ }
2
14
 
3
15
  /**
4
16
  * Async image loader for CanvasKit. Loads images via browser's native Image
@@ -6,88 +18,163 @@ import type { CanvasKit, Image as SkImage } from 'canvaskit-wasm'
6
18
  * then converts to CanvasKit Image for GPU rendering.
7
19
  */
8
20
  export class SkiaImageLoader {
9
- private ck: CanvasKit
10
- private cache = new Map<string, SkImage | null>()
11
- private loading = new Set<string>()
12
- private onLoaded: (() => void) | null = null
21
+ private ck: CanvasKit;
22
+ private cache = new Map<string, SkImage | null>();
23
+ private loading = new Set<string>();
24
+ /** In-flight load promises (separate from `loading` URL set, used for flushPending) */
25
+ private pendingPromises = new Set<Promise<unknown>>();
26
+ private status = new Map<string, ImageLoadStatus>();
27
+ private onLoaded: (() => void) | null = null;
28
+ private sourceResolver: (src: string) => ResolvedImageSource = (src) => ({
29
+ cacheKey: src,
30
+ loadUrl: src,
31
+ });
13
32
 
14
33
  constructor(ck: CanvasKit) {
15
- this.ck = ck
34
+ this.ck = ck;
16
35
  }
17
36
 
18
37
  /** Set callback to trigger re-render when an image finishes loading. */
19
38
  setOnLoaded(cb: () => void) {
20
- this.onLoaded = cb
39
+ this.onLoaded = cb;
40
+ }
41
+
42
+ setSourceResolver(resolver: (src: string) => ResolvedImageSource) {
43
+ this.sourceResolver = resolver;
21
44
  }
22
45
 
23
46
  /** Get a cached image, or null if not loaded / failed. Returns undefined if not yet requested. */
24
47
  get(src: string): SkImage | null | undefined {
25
- return this.cache.get(src)
48
+ const resolved = this.sourceResolver(src);
49
+ return this.cache.get(resolved.cacheKey);
50
+ }
51
+
52
+ getStatus(src: string): ImageLoadStatus | undefined {
53
+ const resolved = this.sourceResolver(src);
54
+ return this.status.get(resolved.cacheKey);
26
55
  }
27
56
 
28
57
  /** Start loading an image if not already cached or in progress. */
29
58
  request(src: string) {
30
- if (this.cache.has(src) || this.loading.has(src)) return
31
- this.loading.add(src)
32
- this.loadAsync(src)
59
+ const resolved = this.sourceResolver(src);
60
+ if (this.cache.has(resolved.cacheKey) || this.loading.has(resolved.cacheKey)) return;
61
+
62
+ if (!resolved.loadUrl) {
63
+ this.cache.set(resolved.cacheKey, null);
64
+ this.status.set(resolved.cacheKey, { state: 'missing' });
65
+ this.onLoaded?.();
66
+ return;
67
+ }
68
+
69
+ this.loading.add(resolved.cacheKey);
70
+ this.status.set(resolved.cacheKey, { state: 'loading' });
71
+ const pending = this.loadAsync(resolved);
72
+ this.pendingPromises.add(pending);
73
+ pending.finally(() => this.pendingPromises.delete(pending));
74
+ }
75
+
76
+ /** Number of in-flight image load promises. */
77
+ pendingCount(): number {
78
+ return this.pendingPromises.size;
79
+ }
80
+
81
+ /**
82
+ * Wait for every currently pending image load to settle.
83
+ * Used by SkiaEngine.waitForSettled to coordinate readback timing.
84
+ */
85
+ async flushPending(): Promise<void> {
86
+ const snapshot = Array.from(this.pendingPromises);
87
+ await Promise.all(snapshot.map((p) => p.catch(() => undefined)));
33
88
  }
34
89
 
35
90
  dispose() {
36
91
  for (const img of this.cache.values()) {
37
- img?.delete()
92
+ img?.delete();
38
93
  }
39
- this.cache.clear()
40
- this.loading.clear()
94
+ this.cache.clear();
95
+ this.loading.clear();
96
+ this.pendingPromises.clear();
97
+ this.status.clear();
41
98
  }
42
99
 
43
- private async loadAsync(src: string) {
100
+ private async loadAsync(source: ResolvedImageSource) {
44
101
  try {
45
102
  // Use browser Image element — supports all browser-supported formats
46
- const htmlImg = await this.loadHtmlImage(src)
47
- const skImg = this.htmlImageToSkia(htmlImg)
48
- this.cache.set(src, skImg)
49
- this.loading.delete(src)
50
- this.onLoaded?.()
103
+ const htmlImg = await this.loadHtmlImage(source.loadUrl!);
104
+ const skImg = this.htmlImageToSkia(htmlImg);
105
+ this.cache.set(source.cacheKey, skImg);
106
+ this.loading.delete(source.cacheKey);
107
+ this.status.set(source.cacheKey, { state: skImg ? 'loaded' : 'error' });
108
+ this.onLoaded?.();
51
109
  } catch (e) {
52
- console.warn('Failed to load image:', src.slice(0, 80), e)
53
- this.cache.set(src, null)
54
- this.loading.delete(src)
110
+ console.warn('Failed to load image:', source.loadUrl?.slice(0, 80), e);
111
+ this.cache.set(source.cacheKey, null);
112
+ this.loading.delete(source.cacheKey);
113
+ this.status.set(source.cacheKey, { state: 'error' });
114
+ this.onLoaded?.();
55
115
  }
56
116
  }
57
117
 
58
118
  private loadHtmlImage(src: string): Promise<HTMLImageElement> {
59
119
  return new Promise((resolve, reject) => {
60
- const img = new Image()
61
- img.crossOrigin = 'anonymous'
62
- img.onload = () => resolve(img)
63
- img.onerror = (e) => reject(new Error(`Image load failed: ${e}`))
64
- img.src = src
65
- })
120
+ const img = new Image();
121
+ if (/^https?:\/\//i.test(src)) {
122
+ img.crossOrigin = 'anonymous';
123
+ }
124
+ img.onload = () => resolve(img);
125
+ img.onerror = (e) => reject(new Error(`Image load failed: ${e}`));
126
+ img.src = src;
127
+ });
66
128
  }
67
129
 
68
130
  /** Rasterize an HTML Image to Canvas 2D, then convert to CanvasKit Image. */
69
131
  private htmlImageToSkia(htmlImg: HTMLImageElement): SkImage | null {
70
- const w = htmlImg.naturalWidth || htmlImg.width
71
- const h = htmlImg.naturalHeight || htmlImg.height
72
- if (w <= 0 || h <= 0) return null
73
-
74
- const canvas = document.createElement('canvas')
75
- canvas.width = w
76
- canvas.height = h
77
- const ctx = canvas.getContext('2d')!
78
- ctx.drawImage(htmlImg, 0, 0, w, h)
79
- const imageData = ctx.getImageData(0, 0, w, h)
80
-
81
- return this.ck.MakeImage(
82
- {
83
- width: w,
84
- height: h,
85
- alphaType: this.ck.AlphaType.Unpremul,
86
- colorType: this.ck.ColorType.RGBA_8888,
87
- colorSpace: this.ck.ColorSpace.SRGB,
88
- },
89
- imageData.data,
90
- w * 4,
91
- ) ?? null
132
+ const sourceW = htmlImg.naturalWidth || htmlImg.width;
133
+ const sourceH = htmlImg.naturalHeight || htmlImg.height;
134
+ if (sourceW <= 0 || sourceH <= 0) return null;
135
+
136
+ const { width, height } = this.getSafeRasterSize(sourceW, sourceH);
137
+
138
+ const canvas = document.createElement('canvas');
139
+ canvas.width = width;
140
+ canvas.height = height;
141
+ const ctx = canvas.getContext('2d');
142
+ if (!ctx) return null;
143
+
144
+ ctx.imageSmoothingEnabled = width !== sourceW || height !== sourceH;
145
+ ctx.drawImage(htmlImg, 0, 0, width, height);
146
+ const imageData = ctx.getImageData(0, 0, width, height);
147
+
148
+ return (
149
+ this.ck.MakeImage(
150
+ {
151
+ width,
152
+ height,
153
+ alphaType: this.ck.AlphaType.Unpremul,
154
+ colorType: this.ck.ColorType.RGBA_8888,
155
+ colorSpace: this.ck.ColorSpace.SRGB,
156
+ },
157
+ imageData.data,
158
+ width * 4,
159
+ ) ?? null
160
+ );
161
+ }
162
+
163
+ private getSafeRasterSize(sourceW: number, sourceH: number): { width: number; height: number } {
164
+ let scale = 1;
165
+ const maxDimension = Math.max(sourceW, sourceH);
166
+ if (maxDimension > MAX_IMAGE_DIMENSION) {
167
+ scale = Math.min(scale, MAX_IMAGE_DIMENSION / maxDimension);
168
+ }
169
+
170
+ const totalPixels = sourceW * sourceH;
171
+ if (totalPixels > MAX_IMAGE_PIXELS) {
172
+ scale = Math.min(scale, Math.sqrt(MAX_IMAGE_PIXELS / totalPixels));
173
+ }
174
+
175
+ return {
176
+ width: Math.max(1, Math.round(sourceW * scale)),
177
+ height: Math.max(1, Math.round(sourceH * scale)),
178
+ };
92
179
  }
93
180
  }
package/src/index.ts CHANGED
@@ -14,19 +14,20 @@
14
14
  */
15
15
 
16
16
  // ---- Primary API ----
17
- export { loadCanvasKit, getCanvasKit } from './init.js'
18
- export { PenRenderer } from './renderer.js'
17
+ export { loadCanvasKit, getCanvasKit } from './init.js';
18
+ export type { LoadCanvasKitOptions } from './init.js';
19
+ export { PenRenderer } from './renderer.js';
19
20
 
20
21
  // ---- Types ----
21
- export type { RenderNode, ViewportState, PenRendererOptions, IconLookupFn } from './types.js'
22
+ export type { RenderNode, ViewportState, PenRendererOptions, IconLookupFn } from './types.js';
22
23
 
23
24
  // ---- Low-level utilities (for apps/web editor re-use) ----
24
- export { SkiaNodeRenderer } from './node-renderer.js'
25
- export { SkiaTextRenderer } from './text-renderer.js'
26
- export { SkiaFontManager, BUNDLED_FONT_FAMILIES } from './font-manager.js'
27
- export type { FontManagerOptions } from './font-manager.js'
28
- export { SkiaImageLoader } from './image-loader.js'
29
- export { SpatialIndex } from './spatial-index.js'
25
+ export { SkiaNodeRenderer } from './node-renderer.js';
26
+ export { SkiaTextRenderer } from './text-renderer.js';
27
+ export { SkiaFontManager, BUNDLED_FONT_FAMILIES } from './font-manager.js';
28
+ export type { FontManagerOptions } from './font-manager.js';
29
+ export { SkiaImageLoader } from './image-loader.js';
30
+ export { SpatialIndex } from './spatial-index.js';
30
31
  export {
31
32
  flattenToRenderNodes,
32
33
  resolveRefs,
@@ -34,7 +35,7 @@ export {
34
35
  premeasureTextHeights,
35
36
  collectReusableIds,
36
37
  collectInstanceIds,
37
- } from './document-flattener.js'
38
+ } from './document-flattener.js';
38
39
  export {
39
40
  viewportMatrix,
40
41
  screenToScene,
@@ -42,7 +43,7 @@ export {
42
43
  zoomToPoint,
43
44
  getViewportBounds,
44
45
  isRectInViewport,
45
- } from './viewport.js'
46
+ } from './viewport.js';
46
47
  export {
47
48
  parseColor,
48
49
  cornerRadiusValue,
@@ -52,9 +53,9 @@ export {
52
53
  resolveStrokeWidth,
53
54
  wrapLine,
54
55
  cssFontFamily,
55
- } from './paint-utils.js'
56
- export {
57
- sanitizeSvgPath,
58
- hasInvalidNumbers,
59
- tryManualPathParse,
60
- } from './path-utils.js'
56
+ } from './paint-utils.js';
57
+ export { sanitizeSvgPath, hasInvalidNumbers, tryManualPathParse } from './path-utils.js';
58
+
59
+ // ---- Thumbnail helper (Phase 7c) ----
60
+ export { renderNodeThumbnail } from './render-node-thumbnail.js';
61
+ export type { ThumbnailContext } from './render-node-thumbnail.js';
package/src/init.ts CHANGED
@@ -1,44 +1,73 @@
1
- import type { CanvasKit } from 'canvaskit-wasm'
1
+ import type { CanvasKit } from 'canvaskit-wasm';
2
2
 
3
- let ckInstance: CanvasKit | null = null
4
- let ckPromise: Promise<CanvasKit> | null = null
3
+ let ckInstance: CanvasKit | null = null;
4
+ let ckPromise: Promise<CanvasKit> | null = null;
5
+
6
+ export interface LoadCanvasKitOptions {
7
+ locateFile?: string | ((file: string) => string);
8
+ onProgress?: (loaded: number, total: number) => void;
9
+ }
5
10
 
6
11
  /**
7
12
  * Load CanvasKit WASM singleton. Returns the same instance on subsequent calls.
8
13
  *
9
- * @param locateFile - Base path string (e.g. '/canvaskit/') or a function
10
- * `(file: string) => string` that resolves WASM file URLs. Defaults to '/canvaskit/'.
14
+ * @param locateFileOrOptions - Base path string (e.g. '/canvaskit/'), a resolver
15
+ * function `(file: string) => string`, or a `LoadCanvasKitOptions` object.
16
+ * Defaults to '/canvaskit/'.
11
17
  */
12
18
  export async function loadCanvasKit(
13
- locateFile?: string | ((file: string) => string),
19
+ locateFileOrOptions?: string | ((file: string) => string) | LoadCanvasKitOptions,
14
20
  ): Promise<CanvasKit> {
15
- if (ckInstance) return ckInstance
16
- if (ckPromise) return ckPromise
21
+ if (ckInstance) return ckInstance;
22
+ if (ckPromise) return ckPromise;
23
+
24
+ let resolver: (file: string) => string;
25
+ let onProgress: ((loaded: number, total: number) => void) | undefined;
17
26
 
18
- const resolver: (file: string) => string = typeof locateFile === 'function'
19
- ? locateFile
20
- : (file: string) => `${locateFile ?? '/canvaskit/'}${file}`
27
+ if (
28
+ typeof locateFileOrOptions === 'object' &&
29
+ locateFileOrOptions !== null &&
30
+ !('call' in locateFileOrOptions)
31
+ ) {
32
+ const opts = locateFileOrOptions as LoadCanvasKitOptions;
33
+ resolver =
34
+ typeof opts.locateFile === 'function'
35
+ ? opts.locateFile
36
+ : (file: string) => `${opts.locateFile ?? '/canvaskit/'}${file}`;
37
+ onProgress = opts.onProgress;
38
+ } else {
39
+ const locateFile = locateFileOrOptions as string | ((file: string) => string) | undefined;
40
+ resolver =
41
+ typeof locateFile === 'function'
42
+ ? locateFile
43
+ : (file: string) => `${locateFile ?? '/canvaskit/'}${file}`;
44
+ }
21
45
 
22
46
  ckPromise = (async () => {
23
47
  // canvaskit-wasm is a CJS module (module.exports = CanvasKitInit).
24
48
  // Depending on bundler interop, the init function may be on .default or the module itself.
25
- const mod = await import('canvaskit-wasm')
26
- const CanvasKitInit = typeof mod.default === 'function'
27
- ? mod.default
28
- : (mod as unknown as (opts?: { locateFile?: (file: string) => string }) => Promise<CanvasKit>)
49
+ const mod = await import('canvaskit-wasm');
50
+ const CanvasKitInit =
51
+ typeof mod.default === 'function'
52
+ ? mod.default
53
+ : (mod as unknown as (opts?: {
54
+ locateFile?: (file: string) => string;
55
+ }) => Promise<CanvasKit>);
29
56
  const ck = await CanvasKitInit({
30
57
  locateFile: resolver,
31
- })
32
- ckInstance = ck
33
- return ck
34
- })()
58
+ });
59
+ ckInstance = ck;
60
+ // Fire final progress (best effort)
61
+ onProgress?.(1, 1);
62
+ return ck;
63
+ })();
35
64
 
36
- return ckPromise
65
+ return ckPromise;
37
66
  }
38
67
 
39
68
  /**
40
69
  * Get the already-loaded CanvasKit instance. Returns null if not yet loaded.
41
70
  */
42
71
  export function getCanvasKit(): CanvasKit | null {
43
- return ckInstance
72
+ return ckInstance;
44
73
  }