@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.
|
|
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.
|
|
34
|
-
"@zseven-w/pen-types": "0.7.
|
|
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 —
|
|
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
|
-
|
|
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 };
|
package/src/font-manager.ts
CHANGED
|
@@ -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
|
|
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
|
|
227
|
-
if (
|
|
360
|
+
// 2. Skip further loading for known system/proprietary fonts
|
|
361
|
+
if (isKnownNonGoogleFont(family)) {
|
|
228
362
|
return false;
|
|
229
363
|
}
|
|
230
364
|
|
|
231
|
-
// 3.
|
|
232
|
-
|
|
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 =
|
|
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 {
|
|
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 {
|