@zseven-w/pen-renderer 0.5.2 → 0.7.0
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 +6 -6
- package/package.json +9 -9
- package/src/__tests__/document-flattener.test.ts +277 -0
- package/src/__tests__/font-manager.test.ts +65 -0
- package/src/__tests__/image-loader.test.ts +136 -0
- package/src/__tests__/paint-utils.test.ts +61 -0
- package/src/__tests__/render-node-thumbnail.test.ts +312 -0
- package/src/document-flattener.ts +228 -159
- package/src/font-manager.ts +221 -190
- package/src/image-loader.ts +138 -51
- package/src/index.ts +18 -17
- package/src/init.ts +50 -21
- package/src/node-renderer.ts +957 -386
- package/src/paint-utils.ts +99 -71
- package/src/path-utils.ts +235 -115
- package/src/render-node-thumbnail.ts +155 -0
- package/src/renderer.ts +196 -175
- package/src/spatial-index.ts +139 -27
- package/src/text-renderer.ts +367 -304
- package/src/types.ts +18 -22
- package/src/viewport.ts +28 -29
package/src/font-manager.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type { TypefaceFontProvider, CanvasKit } from 'canvaskit-wasm'
|
|
1
|
+
import type { TypefaceFontProvider, CanvasKit } from 'canvaskit-wasm';
|
|
2
2
|
|
|
3
3
|
export interface FontManagerOptions {
|
|
4
4
|
/** Base URL for bundled font files. Default: '/fonts/' */
|
|
5
|
-
fontBasePath?: string
|
|
5
|
+
fontBasePath?: string;
|
|
6
6
|
/** Custom Google Fonts CSS endpoint. Default: 'https://fonts.googleapis.com/css2' */
|
|
7
|
-
googleFontsCssUrl?: string
|
|
7
|
+
googleFontsCssUrl?: string;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
/**
|
|
@@ -22,52 +22,20 @@ const BUNDLED_FONTS: Record<string, string[]> = {
|
|
|
22
22
|
'inter-ext-600.woff2',
|
|
23
23
|
'inter-ext-700.woff2',
|
|
24
24
|
],
|
|
25
|
-
poppins: [
|
|
26
|
-
|
|
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
|
-
],
|
|
25
|
+
poppins: ['poppins-400.woff2', 'poppins-500.woff2', 'poppins-600.woff2', 'poppins-700.woff2'],
|
|
26
|
+
roboto: ['roboto-400.woff2', 'roboto-500.woff2', 'roboto-700.woff2'],
|
|
36
27
|
montserrat: [
|
|
37
28
|
'montserrat-400.woff2',
|
|
38
29
|
'montserrat-500.woff2',
|
|
39
30
|
'montserrat-600.woff2',
|
|
40
31
|
'montserrat-700.woff2',
|
|
41
32
|
],
|
|
42
|
-
'open sans': [
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
],
|
|
47
|
-
|
|
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
|
-
],
|
|
33
|
+
'open sans': ['open-sans-400.woff2', 'open-sans-600.woff2', 'open-sans-700.woff2'],
|
|
34
|
+
lato: ['lato-400.woff2', 'lato-700.woff2'],
|
|
35
|
+
raleway: ['raleway-400.woff2', 'raleway-500.woff2', 'raleway-600.woff2', 'raleway-700.woff2'],
|
|
36
|
+
'dm sans': ['dm-sans-400.woff2', 'dm-sans-500.woff2', 'dm-sans-700.woff2'],
|
|
37
|
+
'playfair display': ['playfair-display-400.woff2', 'playfair-display-700.woff2'],
|
|
38
|
+
nunito: ['nunito-400.woff2', 'nunito-600.woff2', 'nunito-700.woff2'],
|
|
71
39
|
'source sans 3': [
|
|
72
40
|
'source-sans-3-400.woff2',
|
|
73
41
|
'source-sans-3-600.woff2',
|
|
@@ -84,7 +52,7 @@ const BUNDLED_FONTS: Record<string, string[]> = {
|
|
|
84
52
|
'noto-sans-sc-latin-400.woff2',
|
|
85
53
|
'noto-sans-sc-latin-700.woff2',
|
|
86
54
|
],
|
|
87
|
-
}
|
|
55
|
+
};
|
|
88
56
|
|
|
89
57
|
/** List of all bundled font family names (for UI font picker) */
|
|
90
58
|
export const BUNDLED_FONT_FAMILIES = [
|
|
@@ -100,7 +68,7 @@ export const BUNDLED_FONT_FAMILIES = [
|
|
|
100
68
|
'Playfair Display',
|
|
101
69
|
'Nunito',
|
|
102
70
|
'Source Sans 3',
|
|
103
|
-
]
|
|
71
|
+
];
|
|
104
72
|
|
|
105
73
|
/**
|
|
106
74
|
* Manages font loading for CanvasKit's Paragraph API (vector text rendering).
|
|
@@ -109,43 +77,57 @@ export const BUNDLED_FONT_FAMILIES = [
|
|
|
109
77
|
* Google Fonts CDN. Once loaded, text is rendered as true vector glyphs.
|
|
110
78
|
*/
|
|
111
79
|
export class SkiaFontManager {
|
|
112
|
-
private provider: TypefaceFontProvider
|
|
113
|
-
private fontBasePath: string
|
|
114
|
-
private googleFontsCssUrl: string
|
|
80
|
+
private provider: TypefaceFontProvider;
|
|
81
|
+
private fontBasePath: string;
|
|
82
|
+
private googleFontsCssUrl: string;
|
|
115
83
|
/** Registered family names (lowercase) -> true once loaded */
|
|
116
|
-
private loadedFamilies = new Set<string>()
|
|
84
|
+
private loadedFamilies = new Set<string>();
|
|
117
85
|
/** Font families that failed to load */
|
|
118
|
-
private failedFamilies = new Set<string>()
|
|
86
|
+
private failedFamilies = new Set<string>();
|
|
119
87
|
/** System fonts that render via bitmap */
|
|
120
|
-
private systemFontFamilies = new Set<string>()
|
|
88
|
+
private systemFontFamilies = new Set<string>();
|
|
121
89
|
/** In-flight font fetch promises to avoid duplicate requests */
|
|
122
|
-
private pendingFetches = new Map<string, Promise<boolean>>()
|
|
90
|
+
private pendingFetches = new Map<string, Promise<boolean>>();
|
|
123
91
|
|
|
124
92
|
constructor(ck: CanvasKit, options?: FontManagerOptions) {
|
|
125
|
-
this.provider = ck.TypefaceFontProvider.Make()
|
|
126
|
-
this.fontBasePath = options?.fontBasePath ?? '/fonts/'
|
|
93
|
+
this.provider = ck.TypefaceFontProvider.Make();
|
|
94
|
+
this.fontBasePath = options?.fontBasePath ?? '/fonts/';
|
|
127
95
|
// Ensure trailing slash
|
|
128
|
-
if (!this.fontBasePath.endsWith('/')) this.fontBasePath += '/'
|
|
129
|
-
this.googleFontsCssUrl = options?.googleFontsCssUrl ?? 'https://fonts.googleapis.com/css2'
|
|
96
|
+
if (!this.fontBasePath.endsWith('/')) this.fontBasePath += '/';
|
|
97
|
+
this.googleFontsCssUrl = options?.googleFontsCssUrl ?? 'https://fonts.googleapis.com/css2';
|
|
130
98
|
}
|
|
131
99
|
|
|
132
100
|
getProvider(): TypefaceFontProvider {
|
|
133
|
-
return this.provider
|
|
101
|
+
return this.provider;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Number of in-flight font load promises. */
|
|
105
|
+
pendingCount(): number {
|
|
106
|
+
return this.pendingFetches.size;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Wait for every currently pending font fetch to settle.
|
|
111
|
+
* Used by SkiaEngine.waitForSettled to coordinate readback timing.
|
|
112
|
+
*/
|
|
113
|
+
async flushPending(): Promise<void> {
|
|
114
|
+
const snapshot = Array.from(this.pendingFetches.values());
|
|
115
|
+
await Promise.all(snapshot.map((p) => p.catch(() => false)));
|
|
134
116
|
}
|
|
135
117
|
|
|
136
118
|
/** Check if a font family is ready for use */
|
|
137
119
|
isFontReady(family: string): boolean {
|
|
138
|
-
return this.loadedFamilies.has(family.toLowerCase())
|
|
120
|
+
return this.loadedFamilies.has(family.toLowerCase());
|
|
139
121
|
}
|
|
140
122
|
|
|
141
123
|
/** Check if a font family is bundled (available offline) */
|
|
142
124
|
isBundled(family: string): boolean {
|
|
143
|
-
return family.toLowerCase() in BUNDLED_FONTS
|
|
125
|
+
return family.toLowerCase() in BUNDLED_FONTS;
|
|
144
126
|
}
|
|
145
127
|
|
|
146
128
|
/** Check if a font is a system font that should use bitmap rendering */
|
|
147
129
|
isSystemFont(family: string): boolean {
|
|
148
|
-
return this.systemFontFamilies.has(family.toLowerCase()) || isSystemFont(family)
|
|
130
|
+
return this.systemFontFamilies.has(family.toLowerCase()) || isSystemFont(family);
|
|
149
131
|
}
|
|
150
132
|
|
|
151
133
|
/**
|
|
@@ -153,43 +135,43 @@ export class SkiaFontManager {
|
|
|
153
135
|
* Only includes fonts actually registered in the TypefaceFontProvider.
|
|
154
136
|
*/
|
|
155
137
|
getFallbackChain(primaryFamily: string): string[] {
|
|
156
|
-
const chain: string[] = []
|
|
157
|
-
const lower = primaryFamily.toLowerCase()
|
|
138
|
+
const chain: string[] = [];
|
|
139
|
+
const lower = primaryFamily.toLowerCase();
|
|
158
140
|
if (this.loadedFamilies.has(lower)) {
|
|
159
|
-
chain.push(primaryFamily)
|
|
141
|
+
chain.push(primaryFamily);
|
|
160
142
|
}
|
|
161
143
|
if (this.loadedFamilies.has(lower + ' ext')) {
|
|
162
|
-
chain.push(primaryFamily + ' Ext')
|
|
144
|
+
chain.push(primaryFamily + ' Ext');
|
|
163
145
|
}
|
|
164
146
|
if (lower !== 'noto sans sc' && this.loadedFamilies.has('noto sans sc')) {
|
|
165
|
-
chain.push('Noto Sans SC')
|
|
147
|
+
chain.push('Noto Sans SC');
|
|
166
148
|
}
|
|
167
149
|
if (lower !== 'inter') {
|
|
168
|
-
if (this.loadedFamilies.has('inter')) chain.push('Inter')
|
|
169
|
-
if (this.loadedFamilies.has('inter ext')) chain.push('Inter Ext')
|
|
150
|
+
if (this.loadedFamilies.has('inter')) chain.push('Inter');
|
|
151
|
+
if (this.loadedFamilies.has('inter ext')) chain.push('Inter Ext');
|
|
170
152
|
}
|
|
171
|
-
if (chain.length === 0) chain.push('Inter')
|
|
172
|
-
return chain
|
|
153
|
+
if (chain.length === 0) chain.push('Inter');
|
|
154
|
+
return chain;
|
|
173
155
|
}
|
|
174
156
|
|
|
175
157
|
/**
|
|
176
158
|
* Check if there's at least one loaded fallback font for the given primary family.
|
|
177
159
|
*/
|
|
178
160
|
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')
|
|
161
|
+
const key = primaryFamily.toLowerCase();
|
|
162
|
+
if (key === 'inter' || key === 'noto sans sc') return false;
|
|
163
|
+
return this.loadedFamilies.has('inter') || this.loadedFamilies.has('noto sans sc');
|
|
182
164
|
}
|
|
183
165
|
|
|
184
166
|
/** Register a font from raw ArrayBuffer data */
|
|
185
167
|
registerFont(data: ArrayBuffer, familyName: string): boolean {
|
|
186
168
|
try {
|
|
187
|
-
this.provider.registerFont(data, familyName)
|
|
188
|
-
this.loadedFamilies.add(familyName.toLowerCase())
|
|
189
|
-
return true
|
|
169
|
+
this.provider.registerFont(data, familyName);
|
|
170
|
+
this.loadedFamilies.add(familyName.toLowerCase());
|
|
171
|
+
return true;
|
|
190
172
|
} catch (e) {
|
|
191
|
-
console.warn(`[FontManager] Failed to register "${familyName}":`, e)
|
|
192
|
-
return false
|
|
173
|
+
console.warn(`[FontManager] Failed to register "${familyName}":`, e);
|
|
174
|
+
return false;
|
|
193
175
|
}
|
|
194
176
|
}
|
|
195
177
|
|
|
@@ -197,80 +179,82 @@ export class SkiaFontManager {
|
|
|
197
179
|
* Ensure a font family is loaded. Tries bundled fonts first, then Google Fonts.
|
|
198
180
|
*/
|
|
199
181
|
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)
|
|
182
|
+
const key = family.toLowerCase();
|
|
183
|
+
if (this.loadedFamilies.has(key)) return true;
|
|
184
|
+
if (this.failedFamilies.has(key)) return false;
|
|
185
|
+
if (this.systemFontFamilies.has(key)) return false;
|
|
186
|
+
|
|
187
|
+
const existing = this.pendingFetches.get(key);
|
|
188
|
+
if (existing) return existing;
|
|
189
|
+
|
|
190
|
+
const promise = this._loadFont(family, weights);
|
|
191
|
+
this.pendingFetches.set(key, promise);
|
|
192
|
+
const result = await promise;
|
|
193
|
+
this.pendingFetches.delete(key);
|
|
212
194
|
if (!result) {
|
|
213
195
|
if (isSystemFont(family)) {
|
|
214
|
-
this.systemFontFamilies.add(key)
|
|
196
|
+
this.systemFontFamilies.add(key);
|
|
215
197
|
} else {
|
|
216
|
-
this.failedFamilies.add(key)
|
|
198
|
+
this.failedFamilies.add(key);
|
|
217
199
|
}
|
|
218
200
|
}
|
|
219
|
-
return result
|
|
201
|
+
return result;
|
|
220
202
|
}
|
|
221
203
|
|
|
222
204
|
/**
|
|
223
205
|
* Load multiple font families concurrently.
|
|
224
206
|
*/
|
|
225
207
|
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
|
-
|
|
229
|
-
)
|
|
230
|
-
const loaded = new Set<string>()
|
|
208
|
+
const unique = [...new Set(families.map((f) => f.trim()).filter(Boolean))];
|
|
209
|
+
const results = await Promise.allSettled(unique.map((f) => this.ensureFont(f)));
|
|
210
|
+
const loaded = new Set<string>();
|
|
231
211
|
results.forEach((r, i) => {
|
|
232
|
-
if (r.status === 'fulfilled' && r.value) loaded.add(unique[i])
|
|
233
|
-
})
|
|
234
|
-
return loaded
|
|
212
|
+
if (r.status === 'fulfilled' && r.value) loaded.add(unique[i]);
|
|
213
|
+
});
|
|
214
|
+
return loaded;
|
|
235
215
|
}
|
|
236
216
|
|
|
237
217
|
private async _loadFont(family: string, weights: number[]): Promise<boolean> {
|
|
238
218
|
// 1. Try bundled fonts first (no network dependency)
|
|
239
|
-
const bundled = BUNDLED_FONTS[family.toLowerCase()]
|
|
219
|
+
const bundled = BUNDLED_FONTS[family.toLowerCase()];
|
|
240
220
|
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
|
|
221
|
+
const urls = bundled.map((f) => `${this.fontBasePath}${f}`);
|
|
222
|
+
const ok = await this._fetchLocalFonts(family, urls, bundled);
|
|
223
|
+
if (ok) return true;
|
|
244
224
|
}
|
|
245
225
|
|
|
246
226
|
// 2. Skip Google Fonts for system/proprietary fonts
|
|
247
227
|
if (isSystemFont(family)) {
|
|
248
|
-
return false
|
|
228
|
+
return false;
|
|
249
229
|
}
|
|
250
230
|
|
|
251
231
|
// 3. Fall back to Google Fonts CDN
|
|
252
|
-
return this._fetchGoogleFont(family, weights)
|
|
232
|
+
return this._fetchGoogleFont(family, weights);
|
|
253
233
|
}
|
|
254
234
|
|
|
255
|
-
private async _fetchLocalFonts(
|
|
235
|
+
private async _fetchLocalFonts(
|
|
236
|
+
family: string,
|
|
237
|
+
urls: string[],
|
|
238
|
+
relPaths: string[],
|
|
239
|
+
): Promise<boolean> {
|
|
256
240
|
try {
|
|
257
241
|
const buffers = await Promise.all(
|
|
258
242
|
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
|
|
243
|
+
const resp = await fetch(url);
|
|
244
|
+
if (!resp.ok) return null;
|
|
245
|
+
return resp.arrayBuffer();
|
|
246
|
+
}),
|
|
247
|
+
);
|
|
248
|
+
let registered = 0;
|
|
265
249
|
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
|
|
250
|
+
const buf = buffers[i];
|
|
251
|
+
if (!buf) continue;
|
|
252
|
+
const regName = relPaths[i].includes('-ext-') ? family + ' Ext' : family;
|
|
253
|
+
if (this.registerFont(buf, regName)) registered++;
|
|
270
254
|
}
|
|
271
|
-
return registered > 0
|
|
255
|
+
return registered > 0;
|
|
272
256
|
} catch {
|
|
273
|
-
return false
|
|
257
|
+
return false;
|
|
274
258
|
}
|
|
275
259
|
}
|
|
276
260
|
|
|
@@ -278,9 +262,9 @@ export class SkiaFontManager {
|
|
|
278
262
|
* Fetch a font from Google Fonts CDN with China mirror fallback.
|
|
279
263
|
*/
|
|
280
264
|
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
|
|
265
|
+
const weightStr = weights.join(';');
|
|
266
|
+
const encodedFamily = encodeURIComponent(family);
|
|
267
|
+
const query = `family=${encodedFamily}:wght@${weightStr}&display=swap`;
|
|
284
268
|
|
|
285
269
|
const cdnConfigs = [
|
|
286
270
|
{
|
|
@@ -291,49 +275,51 @@ export class SkiaFontManager {
|
|
|
291
275
|
cssBase: 'https://fonts.font.im/css2',
|
|
292
276
|
fontUrlPattern: /url\((https?:\/\/[^)]+\.woff2)\)/g,
|
|
293
277
|
},
|
|
294
|
-
]
|
|
278
|
+
];
|
|
295
279
|
|
|
296
280
|
for (const cdn of cdnConfigs) {
|
|
297
281
|
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()
|
|
282
|
+
const cssUrl = `${cdn.cssBase}?${query}`;
|
|
283
|
+
const cssResp = await fetchWithTimeout(cssUrl, 4000);
|
|
284
|
+
if (!cssResp.ok) continue;
|
|
285
|
+
const css = await cssResp.text();
|
|
302
286
|
|
|
303
|
-
const urls: string[] = []
|
|
304
|
-
let match: RegExpExecArray | null
|
|
287
|
+
const urls: string[] = [];
|
|
288
|
+
let match: RegExpExecArray | null;
|
|
305
289
|
while ((match = cdn.fontUrlPattern.exec(css)) !== null) {
|
|
306
|
-
urls.push(match[1])
|
|
290
|
+
urls.push(match[1]);
|
|
307
291
|
}
|
|
308
|
-
if (urls.length === 0) continue
|
|
292
|
+
if (urls.length === 0) continue;
|
|
309
293
|
|
|
310
294
|
const fontBuffers = await Promise.all(
|
|
311
295
|
urls.map(async (url) => {
|
|
312
296
|
try {
|
|
313
|
-
const resp = await fetchWithTimeout(url, 8000)
|
|
314
|
-
return resp.ok ? resp.arrayBuffer() : null
|
|
315
|
-
} catch {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
297
|
+
const resp = await fetchWithTimeout(url, 8000);
|
|
298
|
+
return resp.ok ? resp.arrayBuffer() : null;
|
|
299
|
+
} catch {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
let registered = 0;
|
|
320
306
|
for (const buf of fontBuffers) {
|
|
321
|
-
if (buf && this.registerFont(buf, family)) registered
|
|
307
|
+
if (buf && this.registerFont(buf, family)) registered++;
|
|
322
308
|
}
|
|
323
|
-
if (registered > 0) return true
|
|
309
|
+
if (registered > 0) return true;
|
|
324
310
|
} catch {
|
|
325
311
|
// CDN failed, try next
|
|
326
312
|
}
|
|
327
313
|
}
|
|
328
|
-
return false
|
|
314
|
+
return false;
|
|
329
315
|
}
|
|
330
316
|
|
|
331
317
|
dispose() {
|
|
332
|
-
this.provider.delete()
|
|
333
|
-
this.loadedFamilies.clear()
|
|
334
|
-
this.failedFamilies.clear()
|
|
335
|
-
this.systemFontFamilies.clear()
|
|
336
|
-
this.pendingFetches.clear()
|
|
318
|
+
this.provider.delete();
|
|
319
|
+
this.loadedFamilies.clear();
|
|
320
|
+
this.failedFamilies.clear();
|
|
321
|
+
this.systemFontFamilies.clear();
|
|
322
|
+
this.pendingFetches.clear();
|
|
337
323
|
}
|
|
338
324
|
}
|
|
339
325
|
|
|
@@ -341,61 +327,106 @@ export class SkiaFontManager {
|
|
|
341
327
|
// System font detection (browser-only)
|
|
342
328
|
// ---------------------------------------------------------------------------
|
|
343
329
|
|
|
344
|
-
const localFontCache = new Map<string, boolean>()
|
|
330
|
+
const localFontCache = new Map<string, boolean>();
|
|
345
331
|
|
|
346
332
|
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
|
|
333
|
+
const key = family.toLowerCase();
|
|
334
|
+
const cached = localFontCache.get(key);
|
|
335
|
+
if (cached !== undefined) return cached;
|
|
336
|
+
|
|
337
|
+
if (typeof document === 'undefined') return false;
|
|
338
|
+
const canvas = document.createElement('canvas');
|
|
339
|
+
const ctx = canvas.getContext('2d');
|
|
340
|
+
if (!ctx) return false;
|
|
341
|
+
|
|
342
|
+
const testStr = 'mmmmmmmmmmlli1|';
|
|
343
|
+
ctx.font = '72px monospace';
|
|
344
|
+
const monoWidth = ctx.measureText(testStr).width;
|
|
345
|
+
ctx.font = '72px serif';
|
|
346
|
+
const serifWidth = ctx.measureText(testStr).width;
|
|
347
|
+
ctx.font = `72px "${family}", monospace`;
|
|
348
|
+
const testMonoWidth = ctx.measureText(testStr).width;
|
|
349
|
+
ctx.font = `72px "${family}", serif`;
|
|
350
|
+
const testSerifWidth = ctx.measureText(testStr).width;
|
|
351
|
+
|
|
352
|
+
const available = testMonoWidth !== monoWidth && testSerifWidth !== serifWidth;
|
|
353
|
+
localFontCache.set(key, available);
|
|
354
|
+
return available;
|
|
369
355
|
}
|
|
370
356
|
|
|
371
357
|
const NON_GOOGLE_FONT_PATTERNS = [
|
|
372
|
-
/^microsoft/i,
|
|
373
|
-
/^
|
|
374
|
-
/^
|
|
375
|
-
/^
|
|
376
|
-
/^
|
|
377
|
-
/^
|
|
378
|
-
/^
|
|
379
|
-
/^
|
|
380
|
-
/^
|
|
381
|
-
/^
|
|
382
|
-
/^
|
|
383
|
-
/^
|
|
384
|
-
/^
|
|
385
|
-
/^
|
|
386
|
-
/^
|
|
387
|
-
|
|
358
|
+
/^microsoft/i,
|
|
359
|
+
/^ms /i,
|
|
360
|
+
/^segoe/i,
|
|
361
|
+
/^simhei/i,
|
|
362
|
+
/^simsun/i,
|
|
363
|
+
/^kaiti/i,
|
|
364
|
+
/^fangsong/i,
|
|
365
|
+
/^youyuan/i,
|
|
366
|
+
/^lishu/i,
|
|
367
|
+
/^dengxian/i,
|
|
368
|
+
/^sf /i,
|
|
369
|
+
/^sf-/i,
|
|
370
|
+
/^apple/i,
|
|
371
|
+
/^pingfang/i,
|
|
372
|
+
/^hiragino/i,
|
|
373
|
+
/^helvetica/i,
|
|
374
|
+
/^menlo/i,
|
|
375
|
+
/^monaco/i,
|
|
376
|
+
/^lucida grande/i,
|
|
377
|
+
/^avenir/i,
|
|
378
|
+
/^\.apple/i,
|
|
379
|
+
/^d-din/i,
|
|
380
|
+
/^din[ -]/i,
|
|
381
|
+
/^din$/i,
|
|
382
|
+
/^proxima/i,
|
|
383
|
+
/^gotham/i,
|
|
384
|
+
/^futura/i,
|
|
385
|
+
/^akzidenz/i,
|
|
386
|
+
/^univers/i,
|
|
387
|
+
/^frutiger/i,
|
|
388
|
+
/^youshebiaotihei/i,
|
|
389
|
+
/^youshebiaoti/i,
|
|
390
|
+
/^fz/i,
|
|
391
|
+
/^alibaba/i,
|
|
392
|
+
/^huawen/i,
|
|
393
|
+
/^stk/i,
|
|
394
|
+
/^st[hf]/i,
|
|
395
|
+
/^source han /i,
|
|
396
|
+
/^noto sans cjk/i,
|
|
397
|
+
/^noto serif cjk/i,
|
|
398
|
+
/^yu gothic/i,
|
|
399
|
+
/^yu mincho/i,
|
|
400
|
+
/^meiryo/i,
|
|
401
|
+
/^ms gothic/i,
|
|
402
|
+
/^ms mincho/i,
|
|
403
|
+
/^system-ui/i,
|
|
404
|
+
/^-apple-system/i,
|
|
405
|
+
/^blinkmacsystemfont/i,
|
|
406
|
+
/^arial/i,
|
|
407
|
+
/^times new roman/i,
|
|
408
|
+
/^courier new/i,
|
|
409
|
+
/^georgia/i,
|
|
410
|
+
/^verdana/i,
|
|
411
|
+
/^tahoma/i,
|
|
412
|
+
/^trebuchet/i,
|
|
413
|
+
/^impact/i,
|
|
414
|
+
/^comic sans/i,
|
|
415
|
+
/^consolas/i,
|
|
416
|
+
/^calibri/i,
|
|
417
|
+
/^cambria/i,
|
|
418
|
+
];
|
|
388
419
|
|
|
389
420
|
function isKnownNonGoogleFont(family: string): boolean {
|
|
390
|
-
return NON_GOOGLE_FONT_PATTERNS.some(p => p.test(family.trim()))
|
|
421
|
+
return NON_GOOGLE_FONT_PATTERNS.some((p) => p.test(family.trim()));
|
|
391
422
|
}
|
|
392
423
|
|
|
393
424
|
function isSystemFont(family: string): boolean {
|
|
394
|
-
return isFontLocallyAvailable(family) || isKnownNonGoogleFont(family)
|
|
425
|
+
return isFontLocallyAvailable(family) || isKnownNonGoogleFont(family);
|
|
395
426
|
}
|
|
396
427
|
|
|
397
428
|
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))
|
|
429
|
+
const controller = new AbortController();
|
|
430
|
+
const timer = setTimeout(() => controller.abort(), ms);
|
|
431
|
+
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timer));
|
|
401
432
|
}
|