@zseven-w/pen-renderer 0.0.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/README.md +64 -0
- package/package.json +31 -0
- package/src/document-flattener.ts +340 -0
- package/src/font-manager.ts +401 -0
- package/src/image-loader.ts +93 -0
- package/src/index.ts +60 -0
- package/src/init.ts +44 -0
- package/src/node-renderer.ts +599 -0
- package/src/paint-utils.ts +148 -0
- package/src/path-utils.ts +225 -0
- package/src/renderer.ts +374 -0
- package/src/spatial-index.ts +89 -0
- package/src/text-renderer.ts +531 -0
- package/src/types.ts +40 -0
- package/src/viewport.ts +102 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import type { TypefaceFontProvider, CanvasKit } from 'canvaskit-wasm'
|
|
2
|
+
|
|
3
|
+
export interface FontManagerOptions {
|
|
4
|
+
/** Base URL for bundled font files. Default: '/fonts/' */
|
|
5
|
+
fontBasePath?: string
|
|
6
|
+
/** Custom Google Fonts CSS endpoint. Default: 'https://fonts.googleapis.com/css2' */
|
|
7
|
+
googleFontsCssUrl?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Bundled font files (relative paths, prepended with fontBasePath at load time).
|
|
12
|
+
* Key = lowercase family name, values = relative file names.
|
|
13
|
+
*/
|
|
14
|
+
const BUNDLED_FONTS: Record<string, string[]> = {
|
|
15
|
+
inter: [
|
|
16
|
+
'inter-400.woff2',
|
|
17
|
+
'inter-500.woff2',
|
|
18
|
+
'inter-600.woff2',
|
|
19
|
+
'inter-700.woff2',
|
|
20
|
+
'inter-ext-400.woff2',
|
|
21
|
+
'inter-ext-500.woff2',
|
|
22
|
+
'inter-ext-600.woff2',
|
|
23
|
+
'inter-ext-700.woff2',
|
|
24
|
+
],
|
|
25
|
+
poppins: [
|
|
26
|
+
'poppins-400.woff2',
|
|
27
|
+
'poppins-500.woff2',
|
|
28
|
+
'poppins-600.woff2',
|
|
29
|
+
'poppins-700.woff2',
|
|
30
|
+
],
|
|
31
|
+
roboto: [
|
|
32
|
+
'roboto-400.woff2',
|
|
33
|
+
'roboto-500.woff2',
|
|
34
|
+
'roboto-700.woff2',
|
|
35
|
+
],
|
|
36
|
+
montserrat: [
|
|
37
|
+
'montserrat-400.woff2',
|
|
38
|
+
'montserrat-500.woff2',
|
|
39
|
+
'montserrat-600.woff2',
|
|
40
|
+
'montserrat-700.woff2',
|
|
41
|
+
],
|
|
42
|
+
'open sans': [
|
|
43
|
+
'open-sans-400.woff2',
|
|
44
|
+
'open-sans-600.woff2',
|
|
45
|
+
'open-sans-700.woff2',
|
|
46
|
+
],
|
|
47
|
+
lato: [
|
|
48
|
+
'lato-400.woff2',
|
|
49
|
+
'lato-700.woff2',
|
|
50
|
+
],
|
|
51
|
+
raleway: [
|
|
52
|
+
'raleway-400.woff2',
|
|
53
|
+
'raleway-500.woff2',
|
|
54
|
+
'raleway-600.woff2',
|
|
55
|
+
'raleway-700.woff2',
|
|
56
|
+
],
|
|
57
|
+
'dm sans': [
|
|
58
|
+
'dm-sans-400.woff2',
|
|
59
|
+
'dm-sans-500.woff2',
|
|
60
|
+
'dm-sans-700.woff2',
|
|
61
|
+
],
|
|
62
|
+
'playfair display': [
|
|
63
|
+
'playfair-display-400.woff2',
|
|
64
|
+
'playfair-display-700.woff2',
|
|
65
|
+
],
|
|
66
|
+
nunito: [
|
|
67
|
+
'nunito-400.woff2',
|
|
68
|
+
'nunito-600.woff2',
|
|
69
|
+
'nunito-700.woff2',
|
|
70
|
+
],
|
|
71
|
+
'source sans 3': [
|
|
72
|
+
'source-sans-3-400.woff2',
|
|
73
|
+
'source-sans-3-600.woff2',
|
|
74
|
+
'source-sans-3-700.woff2',
|
|
75
|
+
],
|
|
76
|
+
'source sans pro': [
|
|
77
|
+
'source-sans-3-400.woff2',
|
|
78
|
+
'source-sans-3-600.woff2',
|
|
79
|
+
'source-sans-3-700.woff2',
|
|
80
|
+
],
|
|
81
|
+
'noto sans sc': [
|
|
82
|
+
'noto-sans-sc-400.woff2',
|
|
83
|
+
'noto-sans-sc-700.woff2',
|
|
84
|
+
'noto-sans-sc-latin-400.woff2',
|
|
85
|
+
'noto-sans-sc-latin-700.woff2',
|
|
86
|
+
],
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** List of all bundled font family names (for UI font picker) */
|
|
90
|
+
export const BUNDLED_FONT_FAMILIES = [
|
|
91
|
+
'Inter',
|
|
92
|
+
'Noto Sans SC',
|
|
93
|
+
'Poppins',
|
|
94
|
+
'Roboto',
|
|
95
|
+
'Montserrat',
|
|
96
|
+
'Open Sans',
|
|
97
|
+
'Lato',
|
|
98
|
+
'Raleway',
|
|
99
|
+
'DM Sans',
|
|
100
|
+
'Playfair Display',
|
|
101
|
+
'Nunito',
|
|
102
|
+
'Source Sans 3',
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Manages font loading for CanvasKit's Paragraph API (vector text rendering).
|
|
107
|
+
*
|
|
108
|
+
* Fonts are loaded from a configurable base path first, falling back to
|
|
109
|
+
* Google Fonts CDN. Once loaded, text is rendered as true vector glyphs.
|
|
110
|
+
*/
|
|
111
|
+
export class SkiaFontManager {
|
|
112
|
+
private provider: TypefaceFontProvider
|
|
113
|
+
private fontBasePath: string
|
|
114
|
+
private googleFontsCssUrl: string
|
|
115
|
+
/** Registered family names (lowercase) -> true once loaded */
|
|
116
|
+
private loadedFamilies = new Set<string>()
|
|
117
|
+
/** Font families that failed to load */
|
|
118
|
+
private failedFamilies = new Set<string>()
|
|
119
|
+
/** System fonts that render via bitmap */
|
|
120
|
+
private systemFontFamilies = new Set<string>()
|
|
121
|
+
/** In-flight font fetch promises to avoid duplicate requests */
|
|
122
|
+
private pendingFetches = new Map<string, Promise<boolean>>()
|
|
123
|
+
|
|
124
|
+
constructor(ck: CanvasKit, options?: FontManagerOptions) {
|
|
125
|
+
this.provider = ck.TypefaceFontProvider.Make()
|
|
126
|
+
this.fontBasePath = options?.fontBasePath ?? '/fonts/'
|
|
127
|
+
// Ensure trailing slash
|
|
128
|
+
if (!this.fontBasePath.endsWith('/')) this.fontBasePath += '/'
|
|
129
|
+
this.googleFontsCssUrl = options?.googleFontsCssUrl ?? 'https://fonts.googleapis.com/css2'
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
getProvider(): TypefaceFontProvider {
|
|
133
|
+
return this.provider
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Check if a font family is ready for use */
|
|
137
|
+
isFontReady(family: string): boolean {
|
|
138
|
+
return this.loadedFamilies.has(family.toLowerCase())
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Check if a font family is bundled (available offline) */
|
|
142
|
+
isBundled(family: string): boolean {
|
|
143
|
+
return family.toLowerCase() in BUNDLED_FONTS
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Check if a font is a system font that should use bitmap rendering */
|
|
147
|
+
isSystemFont(family: string): boolean {
|
|
148
|
+
return this.systemFontFamilies.has(family.toLowerCase()) || isSystemFont(family)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Build a font fallback chain for the Paragraph API.
|
|
153
|
+
* Only includes fonts actually registered in the TypefaceFontProvider.
|
|
154
|
+
*/
|
|
155
|
+
getFallbackChain(primaryFamily: string): string[] {
|
|
156
|
+
const chain: string[] = []
|
|
157
|
+
const lower = primaryFamily.toLowerCase()
|
|
158
|
+
if (this.loadedFamilies.has(lower)) {
|
|
159
|
+
chain.push(primaryFamily)
|
|
160
|
+
}
|
|
161
|
+
if (this.loadedFamilies.has(lower + ' ext')) {
|
|
162
|
+
chain.push(primaryFamily + ' Ext')
|
|
163
|
+
}
|
|
164
|
+
if (lower !== 'noto sans sc' && this.loadedFamilies.has('noto sans sc')) {
|
|
165
|
+
chain.push('Noto Sans SC')
|
|
166
|
+
}
|
|
167
|
+
if (lower !== 'inter') {
|
|
168
|
+
if (this.loadedFamilies.has('inter')) chain.push('Inter')
|
|
169
|
+
if (this.loadedFamilies.has('inter ext')) chain.push('Inter Ext')
|
|
170
|
+
}
|
|
171
|
+
if (chain.length === 0) chain.push('Inter')
|
|
172
|
+
return chain
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Check if there's at least one loaded fallback font for the given primary family.
|
|
177
|
+
*/
|
|
178
|
+
hasAnyFallback(primaryFamily: string): boolean {
|
|
179
|
+
const key = primaryFamily.toLowerCase()
|
|
180
|
+
if (key === 'inter' || key === 'noto sans sc') return false
|
|
181
|
+
return this.loadedFamilies.has('inter') || this.loadedFamilies.has('noto sans sc')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Register a font from raw ArrayBuffer data */
|
|
185
|
+
registerFont(data: ArrayBuffer, familyName: string): boolean {
|
|
186
|
+
try {
|
|
187
|
+
this.provider.registerFont(data, familyName)
|
|
188
|
+
this.loadedFamilies.add(familyName.toLowerCase())
|
|
189
|
+
return true
|
|
190
|
+
} catch (e) {
|
|
191
|
+
console.warn(`[FontManager] Failed to register "${familyName}":`, e)
|
|
192
|
+
return false
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Ensure a font family is loaded. Tries bundled fonts first, then Google Fonts.
|
|
198
|
+
*/
|
|
199
|
+
async ensureFont(family: string, weights: number[] = [400, 500, 600, 700]): Promise<boolean> {
|
|
200
|
+
const key = family.toLowerCase()
|
|
201
|
+
if (this.loadedFamilies.has(key)) return true
|
|
202
|
+
if (this.failedFamilies.has(key)) return false
|
|
203
|
+
if (this.systemFontFamilies.has(key)) return false
|
|
204
|
+
|
|
205
|
+
const existing = this.pendingFetches.get(key)
|
|
206
|
+
if (existing) return existing
|
|
207
|
+
|
|
208
|
+
const promise = this._loadFont(family, weights)
|
|
209
|
+
this.pendingFetches.set(key, promise)
|
|
210
|
+
const result = await promise
|
|
211
|
+
this.pendingFetches.delete(key)
|
|
212
|
+
if (!result) {
|
|
213
|
+
if (isSystemFont(family)) {
|
|
214
|
+
this.systemFontFamilies.add(key)
|
|
215
|
+
} else {
|
|
216
|
+
this.failedFamilies.add(key)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return result
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Load multiple font families concurrently.
|
|
224
|
+
*/
|
|
225
|
+
async ensureFonts(families: string[]): Promise<Set<string>> {
|
|
226
|
+
const unique = [...new Set(families.map(f => f.trim()).filter(Boolean))]
|
|
227
|
+
const results = await Promise.allSettled(
|
|
228
|
+
unique.map(f => this.ensureFont(f))
|
|
229
|
+
)
|
|
230
|
+
const loaded = new Set<string>()
|
|
231
|
+
results.forEach((r, i) => {
|
|
232
|
+
if (r.status === 'fulfilled' && r.value) loaded.add(unique[i])
|
|
233
|
+
})
|
|
234
|
+
return loaded
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private async _loadFont(family: string, weights: number[]): Promise<boolean> {
|
|
238
|
+
// 1. Try bundled fonts first (no network dependency)
|
|
239
|
+
const bundled = BUNDLED_FONTS[family.toLowerCase()]
|
|
240
|
+
if (bundled) {
|
|
241
|
+
const urls = bundled.map(f => `${this.fontBasePath}${f}`)
|
|
242
|
+
const ok = await this._fetchLocalFonts(family, urls, bundled)
|
|
243
|
+
if (ok) return true
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// 2. Skip Google Fonts for system/proprietary fonts
|
|
247
|
+
if (isSystemFont(family)) {
|
|
248
|
+
return false
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 3. Fall back to Google Fonts CDN
|
|
252
|
+
return this._fetchGoogleFont(family, weights)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private async _fetchLocalFonts(family: string, urls: string[], relPaths: string[]): Promise<boolean> {
|
|
256
|
+
try {
|
|
257
|
+
const buffers = await Promise.all(
|
|
258
|
+
urls.map(async (url) => {
|
|
259
|
+
const resp = await fetch(url)
|
|
260
|
+
if (!resp.ok) return null
|
|
261
|
+
return resp.arrayBuffer()
|
|
262
|
+
})
|
|
263
|
+
)
|
|
264
|
+
let registered = 0
|
|
265
|
+
for (let i = 0; i < buffers.length; i++) {
|
|
266
|
+
const buf = buffers[i]
|
|
267
|
+
if (!buf) continue
|
|
268
|
+
const regName = relPaths[i].includes('-ext-') ? family + ' Ext' : family
|
|
269
|
+
if (this.registerFont(buf, regName)) registered++
|
|
270
|
+
}
|
|
271
|
+
return registered > 0
|
|
272
|
+
} catch {
|
|
273
|
+
return false
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Fetch a font from Google Fonts CDN with China mirror fallback.
|
|
279
|
+
*/
|
|
280
|
+
private async _fetchGoogleFont(family: string, weights: number[]): Promise<boolean> {
|
|
281
|
+
const weightStr = weights.join(';')
|
|
282
|
+
const encodedFamily = encodeURIComponent(family)
|
|
283
|
+
const query = `family=${encodedFamily}:wght@${weightStr}&display=swap`
|
|
284
|
+
|
|
285
|
+
const cdnConfigs = [
|
|
286
|
+
{
|
|
287
|
+
cssBase: this.googleFontsCssUrl,
|
|
288
|
+
fontUrlPattern: /url\((https?:\/\/[^)]+\.woff2)\)/g,
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
cssBase: 'https://fonts.font.im/css2',
|
|
292
|
+
fontUrlPattern: /url\((https?:\/\/[^)]+\.woff2)\)/g,
|
|
293
|
+
},
|
|
294
|
+
]
|
|
295
|
+
|
|
296
|
+
for (const cdn of cdnConfigs) {
|
|
297
|
+
try {
|
|
298
|
+
const cssUrl = `${cdn.cssBase}?${query}`
|
|
299
|
+
const cssResp = await fetchWithTimeout(cssUrl, 4000)
|
|
300
|
+
if (!cssResp.ok) continue
|
|
301
|
+
const css = await cssResp.text()
|
|
302
|
+
|
|
303
|
+
const urls: string[] = []
|
|
304
|
+
let match: RegExpExecArray | null
|
|
305
|
+
while ((match = cdn.fontUrlPattern.exec(css)) !== null) {
|
|
306
|
+
urls.push(match[1])
|
|
307
|
+
}
|
|
308
|
+
if (urls.length === 0) continue
|
|
309
|
+
|
|
310
|
+
const fontBuffers = await Promise.all(
|
|
311
|
+
urls.map(async (url) => {
|
|
312
|
+
try {
|
|
313
|
+
const resp = await fetchWithTimeout(url, 8000)
|
|
314
|
+
return resp.ok ? resp.arrayBuffer() : null
|
|
315
|
+
} catch { return null }
|
|
316
|
+
})
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
let registered = 0
|
|
320
|
+
for (const buf of fontBuffers) {
|
|
321
|
+
if (buf && this.registerFont(buf, family)) registered++
|
|
322
|
+
}
|
|
323
|
+
if (registered > 0) return true
|
|
324
|
+
} catch {
|
|
325
|
+
// CDN failed, try next
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return false
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
dispose() {
|
|
332
|
+
this.provider.delete()
|
|
333
|
+
this.loadedFamilies.clear()
|
|
334
|
+
this.failedFamilies.clear()
|
|
335
|
+
this.systemFontFamilies.clear()
|
|
336
|
+
this.pendingFetches.clear()
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
// System font detection (browser-only)
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
const localFontCache = new Map<string, boolean>()
|
|
345
|
+
|
|
346
|
+
function isFontLocallyAvailable(family: string): boolean {
|
|
347
|
+
const key = family.toLowerCase()
|
|
348
|
+
const cached = localFontCache.get(key)
|
|
349
|
+
if (cached !== undefined) return cached
|
|
350
|
+
|
|
351
|
+
if (typeof document === 'undefined') return false
|
|
352
|
+
const canvas = document.createElement('canvas')
|
|
353
|
+
const ctx = canvas.getContext('2d')
|
|
354
|
+
if (!ctx) return false
|
|
355
|
+
|
|
356
|
+
const testStr = 'mmmmmmmmmmlli1|'
|
|
357
|
+
ctx.font = '72px monospace'
|
|
358
|
+
const monoWidth = ctx.measureText(testStr).width
|
|
359
|
+
ctx.font = '72px serif'
|
|
360
|
+
const serifWidth = ctx.measureText(testStr).width
|
|
361
|
+
ctx.font = `72px "${family}", monospace`
|
|
362
|
+
const testMonoWidth = ctx.measureText(testStr).width
|
|
363
|
+
ctx.font = `72px "${family}", serif`
|
|
364
|
+
const testSerifWidth = ctx.measureText(testStr).width
|
|
365
|
+
|
|
366
|
+
const available = testMonoWidth !== monoWidth && testSerifWidth !== serifWidth
|
|
367
|
+
localFontCache.set(key, available)
|
|
368
|
+
return available
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const NON_GOOGLE_FONT_PATTERNS = [
|
|
372
|
+
/^microsoft/i, /^ms /i, /^segoe/i, /^simhei/i, /^simsun/i,
|
|
373
|
+
/^kaiti/i, /^fangsong/i, /^youyuan/i, /^lishu/i, /^dengxian/i,
|
|
374
|
+
/^sf /i, /^sf-/i, /^apple/i, /^pingfang/i, /^hiragino/i,
|
|
375
|
+
/^helvetica/i, /^menlo/i, /^monaco/i, /^lucida grande/i,
|
|
376
|
+
/^avenir/i, /^\.apple/i,
|
|
377
|
+
/^d-din/i, /^din[ -]/i, /^din$/i, /^proxima/i, /^gotham/i,
|
|
378
|
+
/^futura/i, /^akzidenz/i, /^univers/i, /^frutiger/i,
|
|
379
|
+
/^youshebiaotihei/i, /^youshebiaoti/i,
|
|
380
|
+
/^fz/i, /^alibaba/i, /^huawen/i, /^stk/i, /^st[hf]/i,
|
|
381
|
+
/^source han /i, /^noto sans cjk/i, /^noto serif cjk/i,
|
|
382
|
+
/^yu gothic/i, /^yu mincho/i, /^meiryo/i, /^ms gothic/i, /^ms mincho/i,
|
|
383
|
+
/^system-ui/i, /^-apple-system/i, /^blinkmacsystemfont/i,
|
|
384
|
+
/^arial/i, /^times new roman/i, /^courier new/i, /^georgia/i,
|
|
385
|
+
/^verdana/i, /^tahoma/i, /^trebuchet/i, /^impact/i,
|
|
386
|
+
/^comic sans/i, /^consolas/i, /^calibri/i, /^cambria/i,
|
|
387
|
+
]
|
|
388
|
+
|
|
389
|
+
function isKnownNonGoogleFont(family: string): boolean {
|
|
390
|
+
return NON_GOOGLE_FONT_PATTERNS.some(p => p.test(family.trim()))
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function isSystemFont(family: string): boolean {
|
|
394
|
+
return isFontLocallyAvailable(family) || isKnownNonGoogleFont(family)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function fetchWithTimeout(url: string, ms: number): Promise<Response> {
|
|
398
|
+
const controller = new AbortController()
|
|
399
|
+
const timer = setTimeout(() => controller.abort(), ms)
|
|
400
|
+
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timer))
|
|
401
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { CanvasKit, Image as SkImage } from 'canvaskit-wasm'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Async image loader for CanvasKit. Loads images via browser's native Image
|
|
5
|
+
* element (supports all browser-supported formats), rasterizes to Canvas 2D,
|
|
6
|
+
* then converts to CanvasKit Image for GPU rendering.
|
|
7
|
+
*/
|
|
8
|
+
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
|
|
13
|
+
|
|
14
|
+
constructor(ck: CanvasKit) {
|
|
15
|
+
this.ck = ck
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Set callback to trigger re-render when an image finishes loading. */
|
|
19
|
+
setOnLoaded(cb: () => void) {
|
|
20
|
+
this.onLoaded = cb
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Get a cached image, or null if not loaded / failed. Returns undefined if not yet requested. */
|
|
24
|
+
get(src: string): SkImage | null | undefined {
|
|
25
|
+
return this.cache.get(src)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Start loading an image if not already cached or in progress. */
|
|
29
|
+
request(src: string) {
|
|
30
|
+
if (this.cache.has(src) || this.loading.has(src)) return
|
|
31
|
+
this.loading.add(src)
|
|
32
|
+
this.loadAsync(src)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
dispose() {
|
|
36
|
+
for (const img of this.cache.values()) {
|
|
37
|
+
img?.delete()
|
|
38
|
+
}
|
|
39
|
+
this.cache.clear()
|
|
40
|
+
this.loading.clear()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private async loadAsync(src: string) {
|
|
44
|
+
try {
|
|
45
|
+
// 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?.()
|
|
51
|
+
} 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)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private loadHtmlImage(src: string): Promise<HTMLImageElement> {
|
|
59
|
+
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
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Rasterize an HTML Image to Canvas 2D, then convert to CanvasKit Image. */
|
|
69
|
+
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
|
|
92
|
+
}
|
|
93
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zseven-w/pen-renderer — Standalone CanvasKit/Skia renderer for OpenPencil (.op) files
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```ts
|
|
6
|
+
* import { loadCanvasKit, PenRenderer } from '@zseven-w/pen-renderer'
|
|
7
|
+
*
|
|
8
|
+
* const ck = await loadCanvasKit('/canvaskit/')
|
|
9
|
+
* const renderer = new PenRenderer(ck, { fontBasePath: '/fonts/' })
|
|
10
|
+
* renderer.init(canvas)
|
|
11
|
+
* renderer.setDocument(doc)
|
|
12
|
+
* renderer.zoomToFit()
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// ---- Primary API ----
|
|
17
|
+
export { loadCanvasKit, getCanvasKit } from './init.js'
|
|
18
|
+
export { PenRenderer } from './renderer.js'
|
|
19
|
+
|
|
20
|
+
// ---- Types ----
|
|
21
|
+
export type { RenderNode, ViewportState, PenRendererOptions, IconLookupFn } from './types.js'
|
|
22
|
+
|
|
23
|
+
// ---- 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'
|
|
30
|
+
export {
|
|
31
|
+
flattenToRenderNodes,
|
|
32
|
+
resolveRefs,
|
|
33
|
+
remapIds,
|
|
34
|
+
premeasureTextHeights,
|
|
35
|
+
collectReusableIds,
|
|
36
|
+
collectInstanceIds,
|
|
37
|
+
} from './document-flattener.js'
|
|
38
|
+
export {
|
|
39
|
+
viewportMatrix,
|
|
40
|
+
screenToScene,
|
|
41
|
+
sceneToScreen,
|
|
42
|
+
zoomToPoint,
|
|
43
|
+
getViewportBounds,
|
|
44
|
+
isRectInViewport,
|
|
45
|
+
} from './viewport.js'
|
|
46
|
+
export {
|
|
47
|
+
parseColor,
|
|
48
|
+
cornerRadiusValue,
|
|
49
|
+
cornerRadii,
|
|
50
|
+
resolveFillColor,
|
|
51
|
+
resolveStrokeColor,
|
|
52
|
+
resolveStrokeWidth,
|
|
53
|
+
wrapLine,
|
|
54
|
+
cssFontFamily,
|
|
55
|
+
} from './paint-utils.js'
|
|
56
|
+
export {
|
|
57
|
+
sanitizeSvgPath,
|
|
58
|
+
hasInvalidNumbers,
|
|
59
|
+
tryManualPathParse,
|
|
60
|
+
} from './path-utils.js'
|
package/src/init.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { CanvasKit } from 'canvaskit-wasm'
|
|
2
|
+
|
|
3
|
+
let ckInstance: CanvasKit | null = null
|
|
4
|
+
let ckPromise: Promise<CanvasKit> | null = null
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Load CanvasKit WASM singleton. Returns the same instance on subsequent calls.
|
|
8
|
+
*
|
|
9
|
+
* @param locateFile - Base path string (e.g. '/canvaskit/') or a function
|
|
10
|
+
* `(file: string) => string` that resolves WASM file URLs. Defaults to '/canvaskit/'.
|
|
11
|
+
*/
|
|
12
|
+
export async function loadCanvasKit(
|
|
13
|
+
locateFile?: string | ((file: string) => string),
|
|
14
|
+
): Promise<CanvasKit> {
|
|
15
|
+
if (ckInstance) return ckInstance
|
|
16
|
+
if (ckPromise) return ckPromise
|
|
17
|
+
|
|
18
|
+
const resolver: (file: string) => string = typeof locateFile === 'function'
|
|
19
|
+
? locateFile
|
|
20
|
+
: (file: string) => `${locateFile ?? '/canvaskit/'}${file}`
|
|
21
|
+
|
|
22
|
+
ckPromise = (async () => {
|
|
23
|
+
// canvaskit-wasm is a CJS module (module.exports = CanvasKitInit).
|
|
24
|
+
// 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>)
|
|
29
|
+
const ck = await CanvasKitInit({
|
|
30
|
+
locateFile: resolver,
|
|
31
|
+
})
|
|
32
|
+
ckInstance = ck
|
|
33
|
+
return ck
|
|
34
|
+
})()
|
|
35
|
+
|
|
36
|
+
return ckPromise
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get the already-loaded CanvasKit instance. Returns null if not yet loaded.
|
|
41
|
+
*/
|
|
42
|
+
export function getCanvasKit(): CanvasKit | null {
|
|
43
|
+
return ckInstance
|
|
44
|
+
}
|