@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.
@@ -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
+ }