@zseven-w/pen-renderer 0.7.1 → 0.7.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zseven-w/pen-renderer",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "Standalone CanvasKit/Skia renderer for OpenPencil (.op) design files",
5
5
  "homepage": "https://github.com/ZSeven-W/openpencil/tree/main/packages/pen-renderer",
6
6
  "bugs": {
@@ -30,8 +30,8 @@
30
30
  "typecheck": "tsc --noEmit"
31
31
  },
32
32
  "dependencies": {
33
- "@zseven-w/pen-core": "0.7.1",
34
- "@zseven-w/pen-types": "0.7.1",
33
+ "@zseven-w/pen-core": "0.7.3",
34
+ "@zseven-w/pen-types": "0.7.3",
35
35
  "rbush": "^4.0.1"
36
36
  },
37
37
  "devDependencies": {
@@ -274,4 +274,35 @@ describe('flattenToRenderNodes — dimension consistency', () => {
274
274
  expect(t1.clipRect!.h).toBe(rootRN.absH);
275
275
  expect(t1.clipRect!.w).toBe(rootRN.absW);
276
276
  });
277
+
278
+ it('nested frame with clipContent clips its descendants using its own bounds/radius', () => {
279
+ const root = frame({
280
+ id: 'root',
281
+ width: 400,
282
+ height: 400,
283
+ children: [
284
+ frame({
285
+ id: 'card',
286
+ x: 40,
287
+ y: 50,
288
+ width: 200,
289
+ height: 120,
290
+ cornerRadius: 16,
291
+ clipContent: true,
292
+ children: [text('inner', 'Nested content', { width: 'fill_container' as any })],
293
+ }),
294
+ ],
295
+ });
296
+
297
+ const nodes = flattenToRenderNodes([root]);
298
+ const card = nodes.find((rn) => rn.node.id === 'card')!;
299
+ const inner = nodes.find((rn) => rn.node.id === 'inner')!;
300
+
301
+ expect(inner.clipRect).toBeDefined();
302
+ expect(inner.clipRect!.x).toBe(card.absX);
303
+ expect(inner.clipRect!.y).toBe(card.absY);
304
+ expect(inner.clipRect!.w).toBe(card.absW);
305
+ expect(inner.clipRect!.h).toBe(card.absH);
306
+ expect(inner.clipRect!.rx).toBe(16);
307
+ });
277
308
  });
@@ -1,4 +1,4 @@
1
- import { describe, it, expect } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import { SkiaFontManager } from '../font-manager';
3
3
 
4
4
  // Minimal mock CanvasKit shim — only the bits SkiaFontManager constructor touches.
@@ -63,3 +63,273 @@ describe('SkiaFontManager.pendingCount / flushPending', () => {
63
63
  expect(flushResolved).toBe(true);
64
64
  });
65
65
  });
66
+
67
+ describe('system font detection via Local Font Access API', () => {
68
+ let originalFetch: typeof globalThis.fetch;
69
+
70
+ beforeEach(() => {
71
+ originalFetch = globalThis.fetch;
72
+ });
73
+
74
+ afterEach(() => {
75
+ globalThis.fetch = originalFetch;
76
+ vi.restoreAllMocks();
77
+ });
78
+
79
+ it('marks unknown local font as systemFont when Google Fonts fails', async () => {
80
+ // Mock fetch to simulate Google Fonts returning 400
81
+ globalThis.fetch = vi
82
+ .fn()
83
+ .mockResolvedValue(new Response(null, { status: 400, statusText: 'Bad Request' }));
84
+
85
+ // Mock window.queryLocalFonts to include "HeyMeow Rnd"
86
+ const origQuery = (globalThis as unknown as Record<string, unknown>).queryLocalFonts;
87
+ (globalThis as unknown as Record<string, unknown>).queryLocalFonts = async () => [
88
+ {
89
+ family: 'HeyMeow Rnd',
90
+ fullName: 'HeyMeow Rnd Regular',
91
+ postscriptName: 'HeyMeowRnd-Regular',
92
+ style: 'Regular',
93
+ blob: async () => new Blob([]),
94
+ },
95
+ {
96
+ family: 'Arial',
97
+ fullName: 'Arial Regular',
98
+ postscriptName: 'ArialMT',
99
+ style: 'Regular',
100
+ blob: async () => new Blob([]),
101
+ },
102
+ ];
103
+
104
+ // Mock window for the Local Font Access API check
105
+ const origWindow = globalThis.window;
106
+ vi.stubGlobal('window', {
107
+ queryLocalFonts: (
108
+ globalThis as unknown as Record<string, () => Promise<Array<{ family: string }>>>
109
+ ).queryLocalFonts,
110
+ });
111
+
112
+ const fm = new SkiaFontManager(makeMockCk() as never);
113
+
114
+ // "HeyMeow Rnd" is not bundled and not in NON_GOOGLE_FONT_PATTERNS
115
+ const result = await fm.ensureFont('HeyMeow Rnd');
116
+
117
+ // Should return false (not loaded into CanvasKit) but be classified as system font
118
+ expect(result).toBe(false);
119
+ expect(fm.isSystemFont('HeyMeow Rnd')).toBe(true);
120
+
121
+ // Cleanup
122
+ vi.unstubAllGlobals();
123
+ if (origWindow !== undefined) {
124
+ globalThis.window = origWindow as Window & typeof globalThis;
125
+ }
126
+ (globalThis as unknown as Record<string, unknown>).queryLocalFonts = origQuery;
127
+ });
128
+
129
+ it('marks font as failed when not found locally either', async () => {
130
+ // Mock fetch to simulate Google Fonts returning 400
131
+ globalThis.fetch = vi
132
+ .fn()
133
+ .mockResolvedValue(new Response(null, { status: 400, statusText: 'Bad Request' }));
134
+
135
+ // Mock window.queryLocalFonts WITHOUT "SomeRandomFont"
136
+ vi.stubGlobal('window', {
137
+ queryLocalFonts: async () => [
138
+ {
139
+ family: 'Arial',
140
+ fullName: 'Arial Regular',
141
+ postscriptName: 'ArialMT',
142
+ style: 'Regular',
143
+ blob: async () => new Blob([]),
144
+ },
145
+ {
146
+ family: 'Inter',
147
+ fullName: 'Inter Regular',
148
+ postscriptName: 'Inter-Regular',
149
+ style: 'Regular',
150
+ blob: async () => new Blob([]),
151
+ },
152
+ ],
153
+ });
154
+
155
+ const fm = new SkiaFontManager(makeMockCk() as never);
156
+ const result = await fm.ensureFont('SomeRandomFont');
157
+
158
+ expect(result).toBe(false);
159
+ expect(fm.isSystemFont('SomeRandomFont')).toBe(false);
160
+
161
+ // Cleanup
162
+ vi.unstubAllGlobals();
163
+ });
164
+ });
165
+
166
+ describe('requestLocalFontAccess', () => {
167
+ afterEach(() => {
168
+ vi.restoreAllMocks();
169
+ });
170
+
171
+ it('returns true and populates localFontMap when permission granted', async () => {
172
+ const mockFontData = new Blob([new ArrayBuffer(100)]);
173
+ vi.stubGlobal('window', {
174
+ queryLocalFonts: async () => [
175
+ {
176
+ family: 'Segoe UI',
177
+ fullName: 'Segoe UI Regular',
178
+ postscriptName: 'SegoeUI-Regular',
179
+ style: 'Regular',
180
+ blob: async () => mockFontData,
181
+ },
182
+ {
183
+ family: 'Segoe UI',
184
+ fullName: 'Segoe UI Bold',
185
+ postscriptName: 'SegoeUI-Bold',
186
+ style: 'Bold',
187
+ blob: async () => mockFontData,
188
+ },
189
+ ],
190
+ });
191
+
192
+ const fm = new SkiaFontManager(makeMockCk() as never);
193
+ const result = await fm.requestNativeFontAccess();
194
+
195
+ expect(result).toBe(true);
196
+ expect(fm.nativeFontPermission).toBe('granted');
197
+
198
+ // Check nativeFontMap has entries
199
+ const map = (fm as unknown as { nativeFontMap: Map<string, unknown[]> }).nativeFontMap;
200
+ expect(map.has('segoe ui')).toBe(true);
201
+ expect(map.get('segoe ui')?.length).toBe(2);
202
+
203
+ vi.unstubAllGlobals();
204
+ });
205
+
206
+ it('returns false and sets denied when permission denied', async () => {
207
+ vi.stubGlobal('window', {
208
+ queryLocalFonts: async () => {
209
+ throw new DOMException('Permission denied', 'NotAllowedError');
210
+ },
211
+ });
212
+
213
+ const fm = new SkiaFontManager(makeMockCk() as never);
214
+ const result = await fm.requestNativeFontAccess();
215
+
216
+ expect(result).toBe(false);
217
+ expect(fm.nativeFontPermission).toBe('denied');
218
+
219
+ vi.unstubAllGlobals();
220
+ });
221
+
222
+ it('returns false and sets unavailable when API not present', async () => {
223
+ vi.stubGlobal('window', {});
224
+
225
+ const fm = new SkiaFontManager(makeMockCk() as never);
226
+ const result = await fm.requestNativeFontAccess();
227
+
228
+ expect(result).toBe(false);
229
+ expect(fm.nativeFontPermission).toBe('unavailable');
230
+
231
+ vi.unstubAllGlobals();
232
+ });
233
+ });
234
+
235
+ describe('local font blob loading into CanvasKit', () => {
236
+ let originalFetch: typeof globalThis.fetch;
237
+
238
+ beforeEach(() => {
239
+ originalFetch = globalThis.fetch;
240
+ });
241
+
242
+ afterEach(() => {
243
+ globalThis.fetch = originalFetch;
244
+ vi.restoreAllMocks();
245
+ });
246
+
247
+ it('loads local font data into CanvasKit when blob provides valid data', async () => {
248
+ // Create mock font data (enough bytes to look like a font file)
249
+ const fontBuffer = new ArrayBuffer(100);
250
+ const mockBlob = new Blob([fontBuffer]);
251
+
252
+ // Mock fetch to fail (no bundled, no Google Fonts)
253
+ globalThis.fetch = vi
254
+ .fn()
255
+ .mockResolvedValue(new Response(null, { status: 400, statusText: 'Bad Request' }));
256
+
257
+ vi.stubGlobal('window', {
258
+ queryLocalFonts: async () => [
259
+ {
260
+ family: 'TestFont',
261
+ fullName: 'TestFont Regular',
262
+ postscriptName: 'TestFont-Regular',
263
+ style: 'Regular',
264
+ blob: async () => mockBlob,
265
+ },
266
+ ],
267
+ });
268
+
269
+ const fm = new SkiaFontManager(makeMockCk() as never);
270
+
271
+ // Grant access first
272
+ await fm.requestNativeFontAccess();
273
+ expect(fm.nativeFontPermission).toBe('granted');
274
+
275
+ // Now try to ensure the font — should attempt blob loading
276
+ await fm.ensureFont('TestFont');
277
+
278
+ // Even if registerFont fails with mock data, the font should be recognized
279
+ // The mock CK registerFont is a no-op, so it won't actually register
280
+ // But the flow should exercise the blob loading path
281
+ expect(fm.nativeFontPermission).toBe('granted');
282
+
283
+ vi.unstubAllGlobals();
284
+ });
285
+ });
286
+
287
+ describe('requestNativeFontAccess prompt-preservation', () => {
288
+ afterEach(() => {
289
+ vi.restoreAllMocks();
290
+ });
291
+
292
+ it('preserves prompt state when queryLocalFonts fails and permissions.query returns prompt', async () => {
293
+ vi.stubGlobal('window', {
294
+ queryLocalFonts: async () => {
295
+ throw new DOMException('Permission denied', 'NotAllowedError');
296
+ },
297
+ });
298
+ // Stub navigator.permissions for Node.js test environment
299
+ vi.stubGlobal('navigator', {
300
+ permissions: {
301
+ query: async () => ({ state: 'prompt' }),
302
+ },
303
+ });
304
+
305
+ const fm = new SkiaFontManager(makeMockCk() as never);
306
+ const result = await fm.requestNativeFontAccess();
307
+
308
+ expect(result).toBe(false);
309
+ expect(fm.nativeFontPermission).toBe('prompt');
310
+
311
+ vi.unstubAllGlobals();
312
+ });
313
+
314
+ it('sets denied when queryLocalFonts fails and permissions.query returns denied', async () => {
315
+ vi.stubGlobal('window', {
316
+ queryLocalFonts: async () => {
317
+ throw new DOMException('Permission denied', 'NotAllowedError');
318
+ },
319
+ });
320
+ // Stub navigator.permissions for Node.js test environment
321
+ vi.stubGlobal('navigator', {
322
+ permissions: {
323
+ query: async () => ({ state: 'denied' }),
324
+ },
325
+ });
326
+
327
+ const fm = new SkiaFontManager(makeMockCk() as never);
328
+ const result = await fm.requestNativeFontAccess();
329
+
330
+ expect(result).toBe(false);
331
+ expect(fm.nativeFontPermission).toBe('denied');
332
+
333
+ vi.unstubAllGlobals();
334
+ });
335
+ });
@@ -241,10 +241,12 @@ export function flattenToRenderNodes(
241
241
  const positioned =
242
242
  layout && layout !== 'none' ? computeLayoutPositions(resolved, children) : children;
243
243
 
244
- // Clipping — only clip for root frames (artboard behavior).
244
+ // Clipping — root frames always clip like artboards. Nested containers
245
+ // clip only when clipContent is enabled.
245
246
  let childClip = clipCtx;
246
247
  const isRootFrame = node.type === 'frame' && depth === 0;
247
- if (isRootFrame) {
248
+ const explicitClip = 'clipContent' in resolved && resolved.clipContent === true;
249
+ if (isRootFrame || explicitClip) {
248
250
  const crRaw = 'cornerRadius' in node ? cornerRadiusVal(node.cornerRadius) : 0;
249
251
  const cr = Math.min(crRaw, nodeH / 2);
250
252
  childClip = { x: absX, y: absY, w: nodeW, h: nodeH, rx: cr };
@@ -7,6 +7,18 @@ export interface FontManagerOptions {
7
7
  googleFontsCssUrl?: string;
8
8
  }
9
9
 
10
+ /** Permission state for the native font access (Local Font Access API) */
11
+ export type NativeFontPermission = 'prompt' | 'granted' | 'denied' | 'unavailable';
12
+
13
+ /** Native font entry from the Local Font Access API with blob accessor */
14
+ interface NativeFontEntry {
15
+ family: string;
16
+ fullName: string;
17
+ postscriptName: string;
18
+ style: string;
19
+ blob: () => Promise<Blob>;
20
+ }
21
+
10
22
  /**
11
23
  * Bundled font files (relative paths, prepended with fontBasePath at load time).
12
24
  * Key = lowercase family name, values = relative file names.
@@ -88,6 +100,12 @@ export class SkiaFontManager {
88
100
  private systemFontFamilies = new Set<string>();
89
101
  /** In-flight font fetch promises to avoid duplicate requests */
90
102
  private pendingFetches = new Map<string, Promise<boolean>>();
103
+ /** Cached set of native (OS-installed) font families from Local Font Access API (lowercase) */
104
+ private nativeFontSet: Set<string> | null = null;
105
+ /** Full native font entries with blob accessors, keyed by lowercase family name */
106
+ private nativeFontMap = new Map<string, NativeFontEntry[]>();
107
+ /** Current permission state for native font access (Local Font Access API) */
108
+ nativeFontPermission: NativeFontPermission = 'prompt';
91
109
 
92
110
  constructor(ck: CanvasKit, options?: FontManagerOptions) {
93
111
  this.provider = ck.TypefaceFontProvider.Make();
@@ -95,6 +113,9 @@ export class SkiaFontManager {
95
113
  // Ensure trailing slash
96
114
  if (!this.fontBasePath.endsWith('/')) this.fontBasePath += '/';
97
115
  this.googleFontsCssUrl = options?.googleFontsCssUrl ?? 'https://fonts.googleapis.com/css2';
116
+
117
+ // Check initial permission state (non-blocking)
118
+ this._checkPermissionState();
98
119
  }
99
120
 
100
121
  getProvider(): TypefaceFontProvider {
@@ -176,7 +197,8 @@ export class SkiaFontManager {
176
197
  }
177
198
 
178
199
  /**
179
- * Ensure a font family is loaded. Tries bundled fonts first, then Google Fonts.
200
+ * Ensure a font family is loaded. Tries bundled fonts first, then native
201
+ * fonts (Local Font Access API + canvas heuristic), then Google Fonts CDN.
180
202
  */
181
203
  async ensureFont(family: string, weights: number[] = [400, 500, 600, 700]): Promise<boolean> {
182
204
  const key = family.toLowerCase();
@@ -193,6 +215,7 @@ export class SkiaFontManager {
193
215
  this.pendingFetches.delete(key);
194
216
  if (!result) {
195
217
  if (isSystemFont(family)) {
218
+ console.warn(`[FontManager] "${family}" is now a system font fallback after failed load.`);
196
219
  this.systemFontFamilies.add(key);
197
220
  } else {
198
221
  this.failedFamilies.add(key);
@@ -214,6 +237,117 @@ export class SkiaFontManager {
214
237
  return loaded;
215
238
  }
216
239
 
240
+ /**
241
+ * Request native font access from the user via the Local Font Access API.
242
+ * Must be called from a user gesture context (click handler) for the
243
+ * browser to show the permission prompt.
244
+ *
245
+ * Returns true if access was granted and fonts were enumerated.
246
+ */
247
+ async requestNativeFontAccess(): Promise<boolean> {
248
+ if (typeof window === 'undefined') {
249
+ this.nativeFontPermission = 'unavailable';
250
+ return false;
251
+ }
252
+
253
+ if (!('queryLocalFonts' in window)) {
254
+ console.warn('[FontManager] Local Font Access API not available in this browser.');
255
+ this.nativeFontPermission = 'unavailable';
256
+ return false;
257
+ }
258
+
259
+ try {
260
+ const fonts = await (
261
+ window as unknown as {
262
+ queryLocalFonts(): Promise<
263
+ Array<{
264
+ family: string;
265
+ fullName: string;
266
+ postscriptName: string;
267
+ style: string;
268
+ blob(): Promise<Blob>;
269
+ }>
270
+ >;
271
+ }
272
+ ).queryLocalFonts();
273
+
274
+ const families = new Set<string>();
275
+ this.nativeFontMap.clear();
276
+
277
+ for (const f of fonts) {
278
+ const key = f.family.toLowerCase();
279
+ families.add(key);
280
+
281
+ const entry: NativeFontEntry = {
282
+ family: f.family,
283
+ fullName: f.fullName,
284
+ postscriptName: f.postscriptName,
285
+ style: f.style,
286
+ blob: f.blob.bind(f),
287
+ };
288
+
289
+ const existing = this.nativeFontMap.get(key) ?? [];
290
+ existing.push(entry);
291
+ this.nativeFontMap.set(key, existing);
292
+ }
293
+
294
+ this.nativeFontSet = families;
295
+ this.nativeFontPermission = 'granted';
296
+ console.log(`[FontManager] Native font access granted — ${families.size} families found.`);
297
+ return true;
298
+ } catch (e: unknown) {
299
+ const errMsg = e instanceof Error ? e.message : String(e);
300
+ if (e instanceof DOMException && e.name === 'NotAllowedError') {
301
+ // Distinguish: never prompted (no user gesture) vs. user actually denied
302
+ try {
303
+ const status = await navigator.permissions.query({
304
+ name: 'local-fonts' as PermissionName,
305
+ });
306
+ if (status.state === 'prompt') {
307
+ // Not yet prompted — likely called without user gesture. Keep prompt
308
+ // state so getNativeFontSet() will retry on the next ensureFont() call.
309
+ console.warn(
310
+ '[FontManager] Native font access not yet prompted — will retry on next user gesture.',
311
+ );
312
+ this.nativeFontPermission = 'prompt';
313
+ this.nativeFontSet = null;
314
+ return false;
315
+ }
316
+ } catch {
317
+ // permissions.query() not supported — assume denied
318
+ }
319
+ console.warn('[FontManager] Native font access denied by user.');
320
+ this.nativeFontPermission = 'denied';
321
+ } else {
322
+ console.warn('[FontManager] Native font access failed:', errMsg);
323
+ this.nativeFontPermission = 'denied';
324
+ }
325
+ this.nativeFontSet = new Set();
326
+ return false;
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Check the current permission state without triggering a prompt.
332
+ */
333
+ private async _checkPermissionState(): Promise<void> {
334
+ if (typeof navigator === 'undefined' || !navigator.permissions) {
335
+ return;
336
+ }
337
+ try {
338
+ const result = await navigator.permissions.query({
339
+ name: 'local-fonts' as PermissionName,
340
+ });
341
+ this.nativeFontPermission = result.state as NativeFontPermission;
342
+ // If already granted, eagerly enumerate fonts
343
+ if (result.state === 'granted') {
344
+ this.requestNativeFontAccess().catch(() => {});
345
+ }
346
+ } catch {
347
+ // Permission name not supported — will need explicit request
348
+ }
349
+ }
350
+
217
351
  private async _loadFont(family: string, weights: number[]): Promise<boolean> {
218
352
  // 1. Try bundled fonts first (no network dependency)
219
353
  const bundled = BUNDLED_FONTS[family.toLowerCase()];
@@ -223,13 +357,74 @@ export class SkiaFontManager {
223
357
  if (ok) return true;
224
358
  }
225
359
 
226
- // 2. Skip Google Fonts for system/proprietary fonts
227
- if (isSystemFont(family)) {
360
+ // 2. Skip further loading for known system/proprietary fonts
361
+ if (isKnownNonGoogleFont(family)) {
228
362
  return false;
229
363
  }
230
364
 
231
- // 3. Fall back to Google Fonts CDN
232
- return this._fetchGoogleFont(family, weights);
365
+ // 3. Try loading from native fonts via Local Font Access API (vector rendering)
366
+ // If we have the font data cached, load it into CanvasKit for vector rendering.
367
+ const nativeLoaded = await this._loadNativeFontData(family);
368
+ if (nativeLoaded) return true;
369
+
370
+ // 4. Check if font is installed natively via Local Font Access API
371
+ const nativeFonts = await this.getNativeFontSet();
372
+ if (nativeFonts.has(family.toLowerCase())) {
373
+ // Found natively — try blob loading for vector rendering
374
+ const blobLoaded = await this._loadNativeFontData(family);
375
+ if (blobLoaded) return true;
376
+ // Can't load blob data — mark as system font for bitmap fallback
377
+ this.systemFontFamilies.add(family.toLowerCase());
378
+ return false;
379
+ }
380
+
381
+ // 5. Canvas-based width comparison heuristic (fast, no permission needed)
382
+ if (isFontLocallyAvailable(family)) {
383
+ this.systemFontFamilies.add(family.toLowerCase());
384
+ return false;
385
+ }
386
+
387
+ // 6. Fall back to Google Fonts CDN (network request — last resort)
388
+ const isFontFromGoogle = await this._fetchGoogleFont(family, weights);
389
+ if (isFontFromGoogle) return true;
390
+
391
+ return false;
392
+ }
393
+
394
+ /**
395
+ * Attempt to load a native font from the Local Font Access API blob data
396
+ * into CanvasKit's TypefaceFontProvider for vector rendering.
397
+ */
398
+ private async _loadNativeFontData(family: string): Promise<boolean> {
399
+ const key = family.toLowerCase();
400
+ const entries = this.nativeFontMap.get(key);
401
+ if (!entries || entries.length === 0) return false;
402
+
403
+ // Try each variant (regular, bold, italic, etc.)
404
+ let registered = 0;
405
+ for (const entry of entries) {
406
+ try {
407
+ const blob = await entry.blob();
408
+ const buffer = await blob.arrayBuffer();
409
+ if (buffer.byteLength > 0 && this.registerFont(buffer, family)) {
410
+ registered++;
411
+ }
412
+ } catch (e) {
413
+ // Individual variant may fail — try the next one
414
+ console.warn(
415
+ `[FontManager] Failed to load native font blob for "${entry.fullName}":`,
416
+ e instanceof Error ? e.message : String(e),
417
+ );
418
+ }
419
+ }
420
+
421
+ if (registered > 0) {
422
+ console.log(
423
+ `[FontManager] Loaded "${family}" from native fonts (${registered}/${entries.length} variants).`,
424
+ );
425
+ return true;
426
+ }
427
+ return false;
233
428
  }
234
429
 
235
430
  private async _fetchLocalFonts(
@@ -294,8 +489,8 @@ export class SkiaFontManager {
294
489
  const fontBuffers = await Promise.all(
295
490
  urls.map(async (url) => {
296
491
  try {
297
- const resp = await fetchWithTimeout(url, 8000);
298
- return resp.ok ? resp.arrayBuffer() : null;
492
+ const resp = fetchWithTimeout(url, 8000);
493
+ return (await resp).ok ? (await resp).arrayBuffer() : null;
299
494
  } catch {
300
495
  return null;
301
496
  }
@@ -314,12 +509,31 @@ export class SkiaFontManager {
314
509
  return false;
315
510
  }
316
511
 
512
+ /**
513
+ * Build a set of all native (OS-installed) font families using the
514
+ * Local Font Access API (Chrome 103+, Edge 103+).
515
+ * Falls back to empty set if API is unavailable or permission denied.
516
+ * Results are cached after the first successful call.
517
+ */
518
+ private async getNativeFontSet(): Promise<Set<string>> {
519
+ if (this.nativeFontSet) return this.nativeFontSet;
520
+
521
+ // Try to enumerate native fonts if we haven't already
522
+ if (this.nativeFontPermission !== 'denied' && this.nativeFontPermission !== 'unavailable') {
523
+ await this.requestNativeFontAccess();
524
+ }
525
+
526
+ return this.nativeFontSet ?? new Set();
527
+ }
528
+
317
529
  dispose() {
318
530
  this.provider.delete();
319
531
  this.loadedFamilies.clear();
320
532
  this.failedFamilies.clear();
321
533
  this.systemFontFamilies.clear();
322
534
  this.pendingFetches.clear();
535
+ this.nativeFontSet = null;
536
+ this.nativeFontMap.clear();
323
537
  }
324
538
  }
325
539
 
package/src/index.ts CHANGED
@@ -25,7 +25,10 @@ export type { RenderNode, ViewportState, PenRendererOptions, IconLookupFn } from
25
25
  export { SkiaNodeRenderer } from './node-renderer.js';
26
26
  export { SkiaTextRenderer } from './text-renderer.js';
27
27
  export { SkiaFontManager, BUNDLED_FONT_FAMILIES } from './font-manager.js';
28
- export type { FontManagerOptions } from './font-manager.js';
28
+ export type {
29
+ FontManagerOptions,
30
+ NativeFontPermission as LocalFontPermission,
31
+ } from './font-manager.js';
29
32
  export { SkiaImageLoader } from './image-loader.js';
30
33
  export { SpatialIndex } from './spatial-index.js';
31
34
  export {