@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 +21 -0
- package/README.md +153 -27
- package/package.json +23 -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/src/image-loader.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
this.loading.
|
|
32
|
-
|
|
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(
|
|
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(
|
|
47
|
-
const skImg = this.htmlImageToSkia(htmlImg)
|
|
48
|
-
this.cache.set(
|
|
49
|
-
this.loading.delete(
|
|
50
|
-
this.
|
|
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:',
|
|
53
|
-
this.cache.set(
|
|
54
|
-
this.loading.delete(
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
img.
|
|
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
|
|
71
|
-
const
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
canvas
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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 {
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
} from './
|
|
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
|
|
10
|
-
* `(file: string) => string
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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 =
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
}
|