@weasel-js/font 1.0.1 → 1.0.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.
@@ -0,0 +1,1008 @@
1
+ // src/FontAtlas.ts
2
+ var FIXTURE_FONT = {
3
+ info: { face: "Inter", size: 32 },
4
+ common: { lineHeight: 38, base: 29, scaleW: 512, scaleH: 512 },
5
+ chars: [
6
+ { id: 65, x: 0, y: 0, width: 22, height: 28, xoffset: 1, yoffset: 4, xadvance: 23, page: 0 },
7
+ { id: 66, x: 24, y: 0, width: 20, height: 28, xoffset: 2, yoffset: 4, xadvance: 22, page: 0 }
8
+ ],
9
+ kernings: [
10
+ { first: 65, second: 66, amount: -1 }
11
+ ]
12
+ };
13
+ function parseBmFont(raw) {
14
+ if (typeof raw !== "object" || raw === null) throw new Error("parseBmFont: expected object");
15
+ const r = raw;
16
+ if (!r.info || typeof r.info !== "object") throw new Error("parseBmFont: missing info");
17
+ if (!r.common || typeof r.common !== "object") throw new Error("parseBmFont: missing common");
18
+ if (!Array.isArray(r.chars)) throw new Error("parseBmFont: chars must be an array");
19
+ const info = r.info;
20
+ const common = r.common;
21
+ const chars = r.chars;
22
+ const kernings = Array.isArray(r.kernings) ? r.kernings : [];
23
+ const charMap = /* @__PURE__ */ new Map();
24
+ for (const ch of chars) charMap.set(ch.id, ch);
25
+ const kerningMap = /* @__PURE__ */ new Map();
26
+ for (const k of kernings) {
27
+ let inner = kerningMap.get(k.first);
28
+ if (!inner) {
29
+ inner = /* @__PURE__ */ new Map();
30
+ kerningMap.set(k.first, inner);
31
+ }
32
+ inner.set(k.second, k.amount);
33
+ }
34
+ return { info, common, chars, kernings, charMap, kerningMap };
35
+ }
36
+
37
+ // src/glyphReady.ts
38
+ var subscribers = /* @__PURE__ */ new Set();
39
+ var generation = 0;
40
+ function glyphGeneration() {
41
+ return generation;
42
+ }
43
+ function subscribeGlyphReady(cb) {
44
+ subscribers.add(cb);
45
+ return () => {
46
+ subscribers.delete(cb);
47
+ };
48
+ }
49
+ function notifyGlyphReady() {
50
+ generation++;
51
+ for (const cb of subscribers) cb();
52
+ }
53
+ function _clearGlyphReadySubscribers() {
54
+ subscribers.clear();
55
+ }
56
+
57
+ // src/fallback.ts
58
+ var policy = "substitute";
59
+ var defaultFamily = null;
60
+ function setFontFallbackPolicy(next) {
61
+ policy = next;
62
+ }
63
+ function getFontFallbackPolicy() {
64
+ return policy;
65
+ }
66
+ function setDefaultFontFamily(family) {
67
+ defaultFamily = family;
68
+ }
69
+ function getDefaultFontFamily() {
70
+ return defaultFamily;
71
+ }
72
+ var warnedFallbacks = /* @__PURE__ */ new Set();
73
+ function claimFallbackWarning(key) {
74
+ if (warnedFallbacks.has(key)) return false;
75
+ warnedFallbacks.add(key);
76
+ return true;
77
+ }
78
+ function _clearFallbackWarnings() {
79
+ warnedFallbacks.clear();
80
+ }
81
+ function _resetFallbackForTests() {
82
+ policy = "substitute";
83
+ defaultFamily = null;
84
+ warnedFallbacks.clear();
85
+ }
86
+
87
+ // src/dynamic/shelfPack.ts
88
+ var ShelfPacker = class {
89
+ constructor(pageSize, maxPages) {
90
+ this.pageSize = pageSize;
91
+ this.maxPages = maxPages;
92
+ }
93
+ pageSize;
94
+ maxPages;
95
+ pages = [];
96
+ warned = false;
97
+ get pageCount() {
98
+ return this.pages.length;
99
+ }
100
+ /** Allocate a w×h rect. Returns null (warning once) when capacity is out. */
101
+ alloc(w, h) {
102
+ if (w > this.pageSize || h > this.pageSize) return this.fail();
103
+ for (let p = 0; p < this.pages.length; p++) {
104
+ const spot = this.allocInPage(this.pages[p], w, h);
105
+ if (spot) return { page: p, ...spot };
106
+ }
107
+ if (this.pages.length < this.maxPages) {
108
+ const page = { shelves: [], nextY: 0 };
109
+ this.pages.push(page);
110
+ const spot = this.allocInPage(page, w, h);
111
+ if (spot) return { page: this.pages.length - 1, ...spot };
112
+ }
113
+ return this.fail();
114
+ }
115
+ allocInPage(page, w, h) {
116
+ for (const shelf of page.shelves) {
117
+ if (h <= shelf.height && shelf.x + w <= this.pageSize) {
118
+ const x = shelf.x;
119
+ shelf.x += w;
120
+ return { x, y: shelf.y };
121
+ }
122
+ }
123
+ if (page.nextY + h <= this.pageSize) {
124
+ const shelf = { y: page.nextY, height: h, x: w };
125
+ page.shelves.push(shelf);
126
+ page.nextY += h;
127
+ return { x: 0, y: shelf.y };
128
+ }
129
+ return null;
130
+ }
131
+ fail() {
132
+ if (!this.warned) {
133
+ this.warned = true;
134
+ console.warn(
135
+ `weasel DynamicGlyphAtlas: glyph pages full (${this.maxPages} \xD7 ${this.pageSize}\xB2); further dynamic glyphs will not render.`
136
+ );
137
+ }
138
+ return null;
139
+ }
140
+ };
141
+
142
+ // src/dynamic/distanceTransform.ts
143
+ var INF = 1e20;
144
+ function edt1d(f, d, v, z, n) {
145
+ v[0] = 0;
146
+ z[0] = -INF;
147
+ z[1] = INF;
148
+ let k = 0;
149
+ for (let q = 1; q < n; q++) {
150
+ let s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
151
+ while (s <= z[k]) {
152
+ k--;
153
+ s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
154
+ }
155
+ k++;
156
+ v[k] = q;
157
+ z[k] = s;
158
+ z[k + 1] = INF;
159
+ }
160
+ k = 0;
161
+ for (let q = 0; q < n; q++) {
162
+ while (z[k + 1] < q) k++;
163
+ d[q] = (q - v[k]) * (q - v[k]) + f[v[k]];
164
+ }
165
+ }
166
+ function edt2d(grid, width, height, f, d, v, z) {
167
+ for (let x = 0; x < width; x++) {
168
+ for (let y = 0; y < height; y++) f[y] = grid[y * width + x];
169
+ edt1d(f, d, v, z, height);
170
+ for (let y = 0; y < height; y++) grid[y * width + x] = d[y];
171
+ }
172
+ for (let y = 0; y < height; y++) {
173
+ for (let x = 0; x < width; x++) f[x] = grid[y * width + x];
174
+ edt1d(f, d, v, z, width);
175
+ for (let x = 0; x < width; x++) grid[y * width + x] = d[x];
176
+ }
177
+ }
178
+ function alphaToSdf(alpha, width, height, radius, cutoff) {
179
+ const n = width * height;
180
+ const gridOuter = new Float64Array(n);
181
+ const gridInner = new Float64Array(n);
182
+ const size = Math.max(width, height);
183
+ const f = new Float64Array(size);
184
+ const d = new Float64Array(size);
185
+ const v = new Int32Array(size);
186
+ const z = new Float64Array(size + 1);
187
+ for (let i = 0; i < n; i++) {
188
+ const a = alpha[i] / 255;
189
+ gridOuter[i] = a === 1 ? 0 : a === 0 ? INF : Math.max(0, 0.5 - a) ** 2;
190
+ gridInner[i] = a === 1 ? INF : a === 0 ? 0 : Math.max(0, a - 0.5) ** 2;
191
+ }
192
+ edt2d(gridOuter, width, height, f, d, v, z);
193
+ edt2d(gridInner, width, height, f, d, v, z);
194
+ const out = new Uint8Array(n);
195
+ for (let i = 0; i < n; i++) {
196
+ const dist = Math.sqrt(gridOuter[i]) - Math.sqrt(gridInner[i]);
197
+ const byte = Math.round(255 - 255 * (dist / radius + cutoff));
198
+ out[i] = byte < 0 ? 0 : byte > 255 ? 255 : byte;
199
+ }
200
+ return out;
201
+ }
202
+
203
+ // src/dynamic/glyphRasterizer.ts
204
+ var BAKE_SIZE = 48;
205
+ var PAD = 8;
206
+ function cssFontString(weight, style, family) {
207
+ return `${style === "italic" ? "italic " : ""}${weight} ${BAKE_SIZE}px ${JSON.stringify(family)}`;
208
+ }
209
+ function createCanvasRasterizer() {
210
+ let canvas;
211
+ if (typeof OffscreenCanvas !== "undefined") {
212
+ canvas = new OffscreenCanvas(BAKE_SIZE * 3, BAKE_SIZE * 3);
213
+ } else if (typeof document !== "undefined") {
214
+ canvas = document.createElement("canvas");
215
+ canvas.width = BAKE_SIZE * 3;
216
+ canvas.height = BAKE_SIZE * 3;
217
+ } else {
218
+ throw new Error("weasel DynamicGlyphAtlas: no canvas available for glyph rasterization");
219
+ }
220
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
221
+ if (!ctx) throw new Error("weasel DynamicGlyphAtlas: 2D context unavailable");
222
+ function setFont(family, weight, style) {
223
+ ctx.font = cssFontString(weight, style, family);
224
+ ctx.textBaseline = "alphabetic";
225
+ ctx.textAlign = "left";
226
+ }
227
+ return {
228
+ faceMetrics(family, weight, style) {
229
+ setFont(family, weight, style);
230
+ const m = ctx.measureText("Hg");
231
+ return {
232
+ ascent: m.fontBoundingBoxAscent ?? BAKE_SIZE * 0.8,
233
+ descent: m.fontBoundingBoxDescent ?? BAKE_SIZE * 0.2
234
+ };
235
+ },
236
+ rasterize(family, weight, style, codepoint) {
237
+ setFont(family, weight, style);
238
+ const chStr = String.fromCodePoint(codepoint);
239
+ const m = ctx.measureText(chStr);
240
+ const advance = m.width;
241
+ const inkLeft = Math.ceil(m.actualBoundingBoxLeft ?? 0);
242
+ const inkRight = Math.ceil(m.actualBoundingBoxRight ?? advance);
243
+ const inkAscent = Math.ceil(m.actualBoundingBoxAscent ?? BAKE_SIZE * 0.8);
244
+ const inkDescent = Math.ceil(m.actualBoundingBoxDescent ?? BAKE_SIZE * 0.2);
245
+ const inkW = inkLeft + inkRight;
246
+ const inkH = inkAscent + inkDescent;
247
+ if (inkW <= 0 || inkH <= 0) {
248
+ return { width: 0, height: 0, alpha: new Uint8ClampedArray(0), left: 0, top: 0, advance };
249
+ }
250
+ const w = inkW + 2 * PAD;
251
+ const h = inkH + 2 * PAD;
252
+ if (canvas.width < w || canvas.height < h) {
253
+ canvas.width = Math.max(canvas.width, w);
254
+ canvas.height = Math.max(canvas.height, h);
255
+ setFont(family, weight, style);
256
+ }
257
+ ctx.clearRect(0, 0, w, h);
258
+ ctx.fillStyle = "#fff";
259
+ ctx.fillText(chStr, PAD + inkLeft, PAD + inkAscent);
260
+ const img = ctx.getImageData(0, 0, w, h);
261
+ const alpha = new Uint8ClampedArray(w * h);
262
+ for (let i = 0; i < alpha.length; i++) alpha[i] = img.data[i * 4 + 3];
263
+ return {
264
+ width: w,
265
+ height: h,
266
+ alpha,
267
+ left: -(inkLeft + PAD),
268
+ top: inkAscent + PAD,
269
+ advance
270
+ };
271
+ }
272
+ };
273
+ }
274
+
275
+ // src/dynamic/dynamicAtlas.ts
276
+ var PAGE_SIZE = 1024;
277
+ var MAX_PAGES = 4;
278
+ var SDF_RADIUS = 8;
279
+ var SDF_CUTOFF = 0.5;
280
+ var DEFAULT_BAKE_BUDGET = 16;
281
+ var canvasFamilies = /* @__PURE__ */ new Set();
282
+ var autoEnrolledFamilies = /* @__PURE__ */ new Set();
283
+ var faces = /* @__PURE__ */ new Map();
284
+ var pages = [];
285
+ var packer = new ShelfPacker(PAGE_SIZE, MAX_PAGES);
286
+ var pending = [];
287
+ var flushScheduled = false;
288
+ var budget = DEFAULT_BAKE_BUDGET;
289
+ var rasterizer = null;
290
+ function getRasterizer() {
291
+ if (!rasterizer) rasterizer = createCanvasRasterizer();
292
+ return rasterizer;
293
+ }
294
+ function registerCanvasFont(family) {
295
+ canvasFamilies.add(family);
296
+ autoEnrolledFamilies.delete(family);
297
+ }
298
+ function isCanvasFont(family) {
299
+ if (!canvasFamilies.has(family)) return false;
300
+ return !autoEnrolledFamilies.has(family) || getFontFallbackPolicy() === "canvas";
301
+ }
302
+ function listCanvasFonts() {
303
+ const out = [];
304
+ for (const family of canvasFamilies) {
305
+ if (!isCanvasFont(family)) continue;
306
+ out.push({ family, enrollment: autoEnrolledFamilies.has(family) ? "auto" : "explicit" });
307
+ }
308
+ return out.sort((a, b) => a.family.localeCompare(b.family));
309
+ }
310
+ function autoEnrollCanvasFont(family) {
311
+ if (canvasFamilies.has(family) && !autoEnrolledFamilies.has(family)) return;
312
+ canvasFamilies.add(family);
313
+ autoEnrolledFamilies.add(family);
314
+ }
315
+ function isExplicitCanvasFont(family) {
316
+ return canvasFamilies.has(family) && !autoEnrolledFamilies.has(family);
317
+ }
318
+ function unregisterCanvasFont(family) {
319
+ canvasFamilies.delete(family);
320
+ autoEnrolledFamilies.delete(family);
321
+ for (const key of [...faces.keys()]) {
322
+ if (faces.get(key).family === family) faces.delete(key);
323
+ }
324
+ }
325
+ function getDynamicFace(family, weight, style) {
326
+ const key = `${family}|${weight}|${style}`;
327
+ const existing = faces.get(key);
328
+ if (existing) return existing;
329
+ const r = getRasterizer();
330
+ const metrics = r.faceMetrics(family, weight, style);
331
+ const base = Math.round(metrics.ascent);
332
+ const font = {
333
+ info: { face: family, size: BAKE_SIZE },
334
+ common: {
335
+ lineHeight: Math.round(metrics.ascent + metrics.descent),
336
+ base,
337
+ scaleW: PAGE_SIZE,
338
+ scaleH: PAGE_SIZE
339
+ },
340
+ chars: [],
341
+ kernings: [],
342
+ // no kerning in v1 (measured-pair kerning is future work)
343
+ charMap: /* @__PURE__ */ new Map(),
344
+ kerningMap: /* @__PURE__ */ new Map()
345
+ };
346
+ const face = {
347
+ family,
348
+ weight,
349
+ style,
350
+ font,
351
+ requestGlyph(cp) {
352
+ const cached = font.charMap.get(cp);
353
+ if (cached) return cached;
354
+ const raster = r.rasterize(family, weight, style, cp);
355
+ const char = {
356
+ id: cp,
357
+ x: 0,
358
+ y: 0,
359
+ width: 0,
360
+ height: 0,
361
+ xoffset: raster.left,
362
+ yoffset: base - raster.top,
363
+ xadvance: raster.advance,
364
+ page: -1
365
+ };
366
+ font.charMap.set(cp, char);
367
+ font.chars.push(char);
368
+ if (raster.width === 0 || raster.height === 0) {
369
+ char.page = 0;
370
+ return char;
371
+ }
372
+ if (budget > 0) {
373
+ budget--;
374
+ bake(char, raster);
375
+ } else {
376
+ pending.push({ char, raster });
377
+ scheduleFlush();
378
+ }
379
+ return char;
380
+ }
381
+ };
382
+ faces.set(key, face);
383
+ return face;
384
+ }
385
+ function bake(char, raster) {
386
+ const spot = packer.alloc(raster.width, raster.height);
387
+ if (!spot) return;
388
+ const sdf = alphaToSdf(raster.alpha, raster.width, raster.height, SDF_RADIUS, SDF_CUTOFF);
389
+ while (pages.length <= spot.page) {
390
+ pages.push({ data: new Uint8Array(PAGE_SIZE * PAGE_SIZE), version: 0, patches: [] });
391
+ }
392
+ const page = pages[spot.page];
393
+ for (let row = 0; row < raster.height; row++) {
394
+ page.data.set(
395
+ sdf.subarray(row * raster.width, (row + 1) * raster.width),
396
+ (spot.y + row) * PAGE_SIZE + spot.x
397
+ );
398
+ }
399
+ page.version++;
400
+ page.patches.push({ seq: page.version, x: spot.x, y: spot.y, w: raster.width, h: raster.height });
401
+ char.x = spot.x;
402
+ char.y = spot.y;
403
+ char.width = raster.width;
404
+ char.height = raster.height;
405
+ char.page = spot.page;
406
+ }
407
+ function scheduleFlush() {
408
+ if (flushScheduled) return;
409
+ flushScheduled = true;
410
+ setTimeout(flushPending, 0);
411
+ }
412
+ function flushPending() {
413
+ flushScheduled = false;
414
+ let n = 0;
415
+ while (pending.length > 0 && n < DEFAULT_BAKE_BUDGET) {
416
+ const job = pending.shift();
417
+ if (job.char.page !== -1) continue;
418
+ bake(job.char, job.raster);
419
+ n++;
420
+ }
421
+ if (pending.length > 0) scheduleFlush();
422
+ notifyGlyphReady();
423
+ }
424
+ function resetBakeBudget(n = DEFAULT_BAKE_BUDGET) {
425
+ budget = n;
426
+ }
427
+ function dynamicPageTextureId(page) {
428
+ return `weasel-dyn-sdf-page-${page}`;
429
+ }
430
+ var uploadedVersions = /* @__PURE__ */ new WeakMap();
431
+ function syncDynamicPageTexture(cache, pageIndex) {
432
+ const page = pages[pageIndex];
433
+ if (!page) return false;
434
+ const id = dynamicPageTextureId(pageIndex);
435
+ let seen = uploadedVersions.get(cache);
436
+ if (!seen) {
437
+ seen = /* @__PURE__ */ new Map();
438
+ uploadedVersions.set(cache, seen);
439
+ }
440
+ if (!cache.has(id)) {
441
+ cache.uploadR8(id, PAGE_SIZE, PAGE_SIZE, page.data);
442
+ seen.set(pageIndex, page.version);
443
+ return true;
444
+ }
445
+ const last = seen.get(pageIndex) ?? 0;
446
+ if (last >= page.version) return true;
447
+ for (const patch of page.patches) {
448
+ if (patch.seq <= last) continue;
449
+ const tight = new Uint8Array(patch.w * patch.h);
450
+ for (let row = 0; row < patch.h; row++) {
451
+ const src = (patch.y + row) * PAGE_SIZE + patch.x;
452
+ tight.set(page.data.subarray(src, src + patch.w), row * patch.w);
453
+ }
454
+ cache.subImageR8(id, patch.x, patch.y, patch.w, patch.h, tight);
455
+ }
456
+ seen.set(pageIndex, page.version);
457
+ return true;
458
+ }
459
+ function __setGlyphRasterizerForTests(r) {
460
+ rasterizer = r;
461
+ }
462
+ function _getPagesForTests() {
463
+ return pages;
464
+ }
465
+ function _resetDynamicFontsForTests() {
466
+ canvasFamilies.clear();
467
+ autoEnrolledFamilies.clear();
468
+ faces = /* @__PURE__ */ new Map();
469
+ pages = [];
470
+ packer = new ShelfPacker(PAGE_SIZE, MAX_PAGES);
471
+ pending = [];
472
+ flushScheduled = false;
473
+ budget = DEFAULT_BAKE_BUDGET;
474
+ _clearGlyphReadySubscribers();
475
+ rasterizer = null;
476
+ }
477
+
478
+ // src/registerFont.ts
479
+ var registry = /* @__PURE__ */ new Map();
480
+ function variantKey(weight, style) {
481
+ return `${weight}|${style}`;
482
+ }
483
+ function normalizeVariant(v) {
484
+ return {
485
+ weight: v.weight ?? 400,
486
+ style: v.style ?? "normal"
487
+ };
488
+ }
489
+ function _resetFontRegistryForTests() {
490
+ registry = /* @__PURE__ */ new Map();
491
+ _clearFallbackWarnings();
492
+ }
493
+ function getFont(family, weight = 400, style = "normal") {
494
+ return registry.get(family)?.get(variantKey(weight, style)) ?? null;
495
+ }
496
+ function listFonts() {
497
+ const out = [];
498
+ for (const [family, variantMap] of registry) {
499
+ const variants = [...variantMap.keys()].map((key) => {
500
+ const [w, s] = key.split("|");
501
+ return { weight: Number(w), style: s };
502
+ }).sort((a, b) => a.weight - b.weight || a.style.localeCompare(b.style));
503
+ out.push({ family, variants });
504
+ }
505
+ return out;
506
+ }
507
+ async function registerFont(family, variant, metricsUrl, atlasUrl) {
508
+ const { weight, style } = normalizeVariant(variant);
509
+ const key = variantKey(weight, style);
510
+ if (registry.get(family)?.has(key)) return;
511
+ try {
512
+ const [metricsRes, atlasRes] = await Promise.all([
513
+ fetch(metricsUrl),
514
+ fetch(atlasUrl)
515
+ ]);
516
+ if (!metricsRes.ok) {
517
+ throw new Error(`HTTP ${metricsRes.status} fetching metrics from ${metricsUrl}`);
518
+ }
519
+ if (!atlasRes.ok) {
520
+ throw new Error(`HTTP ${atlasRes.status} fetching atlas from ${atlasUrl}`);
521
+ }
522
+ const [rawJson, blob] = await Promise.all([
523
+ metricsRes.json(),
524
+ atlasRes.blob()
525
+ ]);
526
+ const font = parseBmFont(rawJson);
527
+ const bitmap = await createImageBitmap(blob);
528
+ let familyMap = registry.get(family);
529
+ if (!familyMap) {
530
+ familyMap = /* @__PURE__ */ new Map();
531
+ registry.set(family, familyMap);
532
+ }
533
+ familyMap.set(key, { font, bitmap });
534
+ notifyGlyphReady();
535
+ } catch (err) {
536
+ throw new Error(
537
+ `weasel registerFont("${family}" ${weight}/${style}): ${err instanceof Error ? err.message : String(err)}`
538
+ );
539
+ }
540
+ }
541
+ function ensureFontTexture(family, weight, style, textureCache) {
542
+ const entry = getFont(family, weight, style);
543
+ if (!entry) return false;
544
+ textureCache.upload(textureCacheKey(family, weight, style), entry.bitmap);
545
+ return true;
546
+ }
547
+ function textureCacheKey(family, weight, style) {
548
+ return `${family}|${weight}|${style}`;
549
+ }
550
+ function markAllFontsNotUploaded() {
551
+ }
552
+ function missResolveResult(family, weight, style, suppressWarn = false) {
553
+ if (isExplicitCanvasFont(family)) {
554
+ return {
555
+ entry: null,
556
+ dynamicFace: getDynamicFace(family, weight, style),
557
+ resolved: { family, weight, style },
558
+ synthetic: { bold: false, italic: false },
559
+ source: "canvas"
560
+ };
561
+ }
562
+ const policy2 = getFontFallbackPolicy();
563
+ if (policy2 === "canvas") {
564
+ autoEnrollCanvasFont(family);
565
+ return {
566
+ entry: null,
567
+ dynamicFace: getDynamicFace(family, weight, style),
568
+ resolved: { family, weight, style },
569
+ synthetic: { bold: false, italic: false },
570
+ source: "canvas"
571
+ };
572
+ }
573
+ if (policy2 === "substitute") {
574
+ const fallback = getDefaultFontFamily() ?? firstRegisteredFamily();
575
+ if (fallback !== null && fallback !== family) {
576
+ if (registry.has(fallback) || isCanvasFont(fallback)) {
577
+ const result = resolveFontVariantInternal(fallback, weight, style, true);
578
+ if (result.entry !== null || result.dynamicFace !== void 0) {
579
+ if (!suppressWarn) warnMissingFamilyOnce(family, weight, style, fallback);
580
+ return { ...result, substituted: { requested: family, resolved: fallback } };
581
+ }
582
+ }
583
+ if (!suppressWarn) warnUnusableDefaultOnce(family, weight, style, fallback);
584
+ } else if (fallback === family && !suppressWarn) {
585
+ if (registry.has(family)) {
586
+ warnSelfUnusableDefaultOnce(family, weight, style);
587
+ } else {
588
+ warnUnregisteredDefaultOnce(family, weight, style);
589
+ }
590
+ }
591
+ }
592
+ return {
593
+ entry: null,
594
+ resolved: { family, weight, style },
595
+ synthetic: { bold: false, italic: false },
596
+ source: "atlas"
597
+ };
598
+ }
599
+ function firstRegisteredFamily() {
600
+ for (const family of registry.keys()) return family;
601
+ return null;
602
+ }
603
+ function warnMissingFamilyOnce(family, weight, style, resolved) {
604
+ if (!claimFallbackWarning(`substituted|${family}|${weight}|${style}`)) return;
605
+ const cause = registry.has(family) ? `has no variant matching ${weight}/${style}, and none of its registered variants are close enough for the within-family chain to substitute \u2014 rendering with "${resolved}" instead. Bake that variant with registerFont("${family}", { weight: ${weight}, style: '${style}' }, \u2026)` : `is not registered \u2014 rendering with "${resolved}" instead. Call registerFont("${family}", \u2026)`;
606
+ console.warn(
607
+ `weasel: font family "${family}" (${weight}/${style}) ${cause}. Advance widths will differ from the requested font. Use setFontFallbackPolicy('none') to make this a hard miss instead.`
608
+ );
609
+ }
610
+ function warnUnusableDefaultOnce(family, weight, style, fallback) {
611
+ if (!claimFallbackWarning(`unusable-default|${family}|${weight}|${style}`)) return;
612
+ const origin = getDefaultFontFamily() === fallback ? "set via setDefaultFontFamily" : "the first registered family, since setDefaultFontFamily was never called";
613
+ const gap = registry.has(fallback) || isCanvasFont(fallback) ? `has no variant that can serve ${weight}/${style}` : "is not registered either";
614
+ console.warn(
615
+ `weasel: font family "${family}" (${weight}/${style}) is not available, and the fallback family "${fallback}" (${origin}) ${gap} \u2014 this text will not render at all. Bake that variant with registerFont("${fallback}", { weight: ${weight}, style: '${style}' }, \u2026), or point setDefaultFontFamily() at a family that covers it.`
616
+ );
617
+ }
618
+ function warnSelfUnusableDefaultOnce(family, weight, style) {
619
+ if (!claimFallbackWarning(`self-unusable-default|${family}|${weight}|${style}`)) return;
620
+ const origin = getDefaultFontFamily() === family ? "set via setDefaultFontFamily" : "the first registered family, since setDefaultFontFamily was never called";
621
+ console.warn(
622
+ `weasel: font family "${family}" (${weight}/${style}) has no variant that can serve this request, and "${family}" is also the fallback family (${origin}) \u2014 there is nothing left to fall back to, so this text will not render at all. Bake that variant with registerFont("${family}", { weight: ${weight}, style: '${style}' }, \u2026), or point setDefaultFontFamily() at a different family.`
623
+ );
624
+ }
625
+ function warnUnregisteredDefaultOnce(family, weight, style) {
626
+ if (!claimFallbackWarning(`unregistered-default|${family}|${weight}|${style}`)) return;
627
+ console.warn(
628
+ `weasel: font family "${family}" (${weight}/${style}) was never registered, and it is also the fallback family \u2014 setDefaultFontFamily("${family}") names a family with no registered variants at all, so there is nothing left to fall back to and this text will not render at all. Call registerFont("${family}", { weight: ${weight}, style: '${style}' }, \u2026), or point setDefaultFontFamily() at a family you have registered.`
629
+ );
630
+ }
631
+ function weightBucket(w) {
632
+ return w >= 600 ? "bold" : "regular";
633
+ }
634
+ function resolveFontVariant(family, weight, style) {
635
+ return resolveFontVariantInternal(family, weight, style, false);
636
+ }
637
+ function resolveGlyphFallback(family, weight, style) {
638
+ if (getFontFallbackPolicy() === "none") return null;
639
+ try {
640
+ return {
641
+ entry: null,
642
+ dynamicFace: getDynamicFace(family, weight, style),
643
+ resolved: { family, weight, style },
644
+ // The dynamic tier rasterizes the real weight and style, so there is
645
+ // nothing for the shader to fake.
646
+ synthetic: { bold: false, italic: false },
647
+ source: "canvas"
648
+ };
649
+ } catch {
650
+ return null;
651
+ }
652
+ }
653
+ function resolveFontVariantInternal(family, weight, style, suppressWarn) {
654
+ const familyMap = registry.get(family);
655
+ if (!familyMap || familyMap.size === 0) {
656
+ return missResolveResult(family, weight, style, suppressWarn);
657
+ }
658
+ const exact = familyMap.get(variantKey(weight, style));
659
+ if (exact) {
660
+ return {
661
+ entry: exact,
662
+ resolved: { family, weight, style },
663
+ synthetic: { bold: false, italic: false },
664
+ source: "atlas"
665
+ };
666
+ }
667
+ const requestedBucket = weightBucket(weight);
668
+ let bestSameStyle = null;
669
+ for (const [key, entry] of familyMap) {
670
+ const [wStr, s] = key.split("|");
671
+ const w = Number(wStr);
672
+ if (s !== style) continue;
673
+ if (weightBucket(w) !== requestedBucket) continue;
674
+ const distance = Math.abs(w - weight);
675
+ if (bestSameStyle === null || distance < bestSameStyle.distance || distance === bestSameStyle.distance && w > bestSameStyle.weight) {
676
+ bestSameStyle = { entry, weight: w, distance };
677
+ }
678
+ }
679
+ if (bestSameStyle) {
680
+ return {
681
+ entry: bestSameStyle.entry,
682
+ resolved: { family, weight: bestSameStyle.weight, style },
683
+ synthetic: { bold: false, italic: false },
684
+ source: "atlas"
685
+ };
686
+ }
687
+ const sameStyleRegular = familyMap.get(variantKey(400, style));
688
+ if (sameStyleRegular) {
689
+ return {
690
+ entry: sameStyleRegular,
691
+ resolved: { family, weight: 400, style },
692
+ synthetic: {
693
+ bold: weight >= 600,
694
+ italic: false
695
+ },
696
+ source: "atlas"
697
+ };
698
+ }
699
+ const sameWeightNormal = familyMap.get(variantKey(weight, "normal"));
700
+ if (sameWeightNormal) {
701
+ return {
702
+ entry: sameWeightNormal,
703
+ resolved: { family, weight, style: "normal" },
704
+ synthetic: {
705
+ bold: false,
706
+ italic: style === "italic"
707
+ },
708
+ source: "atlas"
709
+ };
710
+ }
711
+ let bestNormal = null;
712
+ for (const [key, entry] of familyMap) {
713
+ const [wStr, s] = key.split("|");
714
+ const w = Number(wStr);
715
+ if (s !== "normal") continue;
716
+ if (weightBucket(w) !== requestedBucket) continue;
717
+ const distance = Math.abs(w - weight);
718
+ if (bestNormal === null || distance < bestNormal.distance || distance === bestNormal.distance && w > bestNormal.weight) {
719
+ bestNormal = { entry, weight: w, distance };
720
+ }
721
+ }
722
+ if (bestNormal) {
723
+ return {
724
+ entry: bestNormal.entry,
725
+ resolved: { family, weight: bestNormal.weight, style: "normal" },
726
+ synthetic: {
727
+ bold: false,
728
+ italic: style === "italic"
729
+ },
730
+ source: "atlas"
731
+ };
732
+ }
733
+ const regular = familyMap.get(variantKey(400, "normal"));
734
+ if (regular) {
735
+ return {
736
+ entry: regular,
737
+ resolved: { family, weight: 400, style: "normal" },
738
+ synthetic: {
739
+ bold: weight >= 600,
740
+ italic: style === "italic"
741
+ },
742
+ source: "atlas"
743
+ };
744
+ }
745
+ return missResolveResult(family, weight, style, suppressWarn);
746
+ }
747
+
748
+ // src/outline/OutlineFace.ts
749
+ var OUTLINE_PRECISION = 5;
750
+
751
+ // src/outline/sfnt.ts
752
+ var SFNT_HEADER_BYTES = 12;
753
+ var TABLE_RECORD_BYTES = 16;
754
+ function tagAt(view, offset) {
755
+ return String.fromCharCode(
756
+ view.getUint8(offset),
757
+ view.getUint8(offset + 1),
758
+ view.getUint8(offset + 2),
759
+ view.getUint8(offset + 3)
760
+ );
761
+ }
762
+ function isFontCollection(bytes) {
763
+ if (bytes.byteLength < 4) return false;
764
+ return tagAt(new DataView(bytes), 0) === "ttcf";
765
+ }
766
+ var FONT_SIGNATURES = /* @__PURE__ */ new Set([
767
+ "\0\0\0",
768
+ "true",
769
+ "typ1",
770
+ "OTTO",
771
+ "ttcf",
772
+ "wOFF",
773
+ "wOF2"
774
+ ]);
775
+ var RESOURCE_HEADER_BYTES = 16;
776
+ function isDataForkFont(bytes) {
777
+ if (bytes.byteLength < RESOURCE_HEADER_BYTES) return false;
778
+ const view = new DataView(bytes);
779
+ if (FONT_SIGNATURES.has(tagAt(view, 0))) return false;
780
+ const dataOffset = view.getUint32(0);
781
+ const mapOffset = view.getUint32(4);
782
+ const dataLength = view.getUint32(8);
783
+ const mapLength = view.getUint32(12);
784
+ return dataOffset >= RESOURCE_HEADER_BYTES && dataOffset + dataLength === mapOffset && mapOffset + mapLength <= bytes.byteLength;
785
+ }
786
+ function readTableDirectory(view, dirOffset) {
787
+ const numTables = view.getUint16(dirOffset + 4);
788
+ const records = [];
789
+ for (let i = 0; i < numTables; i++) {
790
+ const at = dirOffset + SFNT_HEADER_BYTES + i * TABLE_RECORD_BYTES;
791
+ records.push({
792
+ tag: tagAt(view, at),
793
+ checksum: view.getUint32(at + 4),
794
+ offset: view.getUint32(at + 8),
795
+ length: view.getUint32(at + 12)
796
+ });
797
+ }
798
+ return records;
799
+ }
800
+ function postScriptNameAt(view, records) {
801
+ const name = records.find((r) => r.tag === "name");
802
+ if (!name) return null;
803
+ const base = name.offset;
804
+ const count = view.getUint16(base + 2);
805
+ const stringOffset = view.getUint16(base + 4);
806
+ for (let i = 0; i < count; i++) {
807
+ const rec = base + 6 + i * 12;
808
+ if (view.getUint16(rec + 6) !== 6) continue;
809
+ const length = view.getUint16(rec + 8);
810
+ const offset = view.getUint16(rec + 10);
811
+ let out = "";
812
+ for (let b = 0; b < length; b++) {
813
+ const code = view.getUint8(base + stringOffset + offset + b);
814
+ if (code !== 0) out += String.fromCharCode(code);
815
+ }
816
+ if (out.length > 0) return out;
817
+ }
818
+ return null;
819
+ }
820
+ function extractFont(source, view, dirOffset) {
821
+ const records = readTableDirectory(view, dirOffset);
822
+ const padded = (n) => n + 3 & -4;
823
+ let total = SFNT_HEADER_BYTES + records.length * TABLE_RECORD_BYTES;
824
+ for (const r of records) total += padded(r.length);
825
+ const out = new ArrayBuffer(total);
826
+ const dst = new DataView(out);
827
+ const dstBytes = new Uint8Array(out);
828
+ const srcBytes = new Uint8Array(source);
829
+ dst.setUint32(0, view.getUint32(dirOffset));
830
+ dst.setUint16(4, records.length);
831
+ const entrySelector = Math.floor(Math.log2(records.length));
832
+ const searchRange = 2 ** entrySelector * 16;
833
+ dst.setUint16(6, searchRange);
834
+ dst.setUint16(8, entrySelector);
835
+ dst.setUint16(10, records.length * 16 - searchRange);
836
+ let cursor = SFNT_HEADER_BYTES + records.length * TABLE_RECORD_BYTES;
837
+ records.forEach((r, i) => {
838
+ const at = SFNT_HEADER_BYTES + i * TABLE_RECORD_BYTES;
839
+ for (let b = 0; b < 4; b++) dst.setUint8(at + b, r.tag.charCodeAt(b));
840
+ dst.setUint32(at + 4, r.checksum);
841
+ dst.setUint32(at + 8, cursor);
842
+ dst.setUint32(at + 12, r.length);
843
+ dstBytes.set(srcBytes.subarray(r.offset, r.offset + r.length), cursor);
844
+ cursor += padded(r.length);
845
+ });
846
+ return out;
847
+ }
848
+ function sfntFromCollection(bytes, postScriptName) {
849
+ if (isDataForkFont(bytes)) {
850
+ throw new Error(
851
+ "Datafork TrueType (.dfont) is not supported \u2014 the outline tier reads sfnt tables and a .dfont holds them inside a Macintosh resource map."
852
+ );
853
+ }
854
+ if (!isFontCollection(bytes)) return { bytes, matched: true };
855
+ const view = new DataView(bytes);
856
+ const numFonts = view.getUint32(8);
857
+ if (numFonts === 0) throw new Error("font collection contains no fonts");
858
+ const offsets = [];
859
+ for (let i = 0; i < numFonts; i++) offsets.push(view.getUint32(12 + i * 4));
860
+ if (postScriptName) {
861
+ for (const dirOffset of offsets) {
862
+ const records = readTableDirectory(view, dirOffset);
863
+ if (postScriptNameAt(view, records) === postScriptName) {
864
+ return { bytes: extractFont(bytes, view, dirOffset), matched: true };
865
+ }
866
+ }
867
+ }
868
+ return { bytes: extractFont(bytes, view, offsets[0]), matched: !postScriptName };
869
+ }
870
+
871
+ // src/outline/opentypeParser.ts
872
+ var modulePromise = null;
873
+ function loadOpenType() {
874
+ modulePromise ??= import('opentype.js');
875
+ return modulePromise;
876
+ }
877
+ function faceFor(font) {
878
+ return {
879
+ unitsPerEm: font.unitsPerEm,
880
+ glyphD(cp) {
881
+ const index = font.charToGlyphIndex(String.fromCodePoint(cp));
882
+ if (!index) return null;
883
+ const d = font.glyphs.get(index).getPath(0, 0, 1).toPathData(OUTLINE_PRECISION);
884
+ return d.length > 0 ? d : null;
885
+ }
886
+ };
887
+ }
888
+ function createOpenTypeParser(postScriptName) {
889
+ return async (bytes) => {
890
+ const opentype = await loadOpenType();
891
+ const { bytes: single } = sfntFromCollection(bytes, postScriptName);
892
+ return faceFor(opentype.parse(single));
893
+ };
894
+ }
895
+ var openTypeParser = createOpenTypeParser();
896
+
897
+ // src/outline/outlineRegistry.ts
898
+ var slots = /* @__PURE__ */ new Map();
899
+ function slotKey(family, weight, style) {
900
+ return `${family}|${weight}|${style}`;
901
+ }
902
+ function normalize(v) {
903
+ return { weight: v.weight ?? 400, style: v.style ?? "normal" };
904
+ }
905
+ function registerFontOutlines(family, variant, source, opts = {}) {
906
+ const { weight, style } = normalize(variant);
907
+ slots.set(slotKey(family, weight, style), {
908
+ family,
909
+ weight,
910
+ style,
911
+ source,
912
+ parser: opts.parser ?? openTypeParser,
913
+ status: "idle",
914
+ face: null,
915
+ glyphs: /* @__PURE__ */ new Map()
916
+ });
917
+ }
918
+ function unregisterFontOutlines(family, variant = {}) {
919
+ const { weight, style } = normalize(variant);
920
+ slots.delete(slotKey(family, weight, style));
921
+ }
922
+ function hasFontOutlines(family, weight = 400, style = "normal") {
923
+ return slots.has(slotKey(family, weight, style));
924
+ }
925
+ function outlineStatus(family, weight = 400, style = "normal") {
926
+ return slots.get(slotKey(family, weight, style))?.status ?? null;
927
+ }
928
+ function listFontOutlines() {
929
+ return [...slots.values()].map(({ family, weight, style, status }) => ({ family, weight, style, status })).sort((a, b) => a.family.localeCompare(b.family) || a.weight - b.weight || a.style.localeCompare(b.style));
930
+ }
931
+ function closeContours(d) {
932
+ let out = "";
933
+ let start = 0;
934
+ for (let i = 1; i < d.length; i++) {
935
+ const c = d[i];
936
+ if (c !== "M" && c !== "m") continue;
937
+ out += closeOne(d.slice(start, i));
938
+ start = i;
939
+ }
940
+ return out + closeOne(d.slice(start));
941
+ }
942
+ function closeOne(contour) {
943
+ const trimmed = contour.trimEnd();
944
+ if (trimmed.length === 0) return "";
945
+ const last = trimmed[trimmed.length - 1];
946
+ return last === "Z" || last === "z" ? trimmed : `${trimmed}Z`;
947
+ }
948
+ function glyphOutline(family, weight, style, cp) {
949
+ const slot = slots.get(slotKey(family, weight, style));
950
+ if (!slot) return null;
951
+ if (slot.status === "idle") {
952
+ void beginLoad(slot);
953
+ return null;
954
+ }
955
+ if (slot.status !== "ready") return null;
956
+ const cached = slot.glyphs.get(cp);
957
+ if (cached !== void 0) return cached;
958
+ let d = null;
959
+ try {
960
+ const raw = slot.face.glyphD(cp);
961
+ d = raw === null ? null : closeContours(raw);
962
+ } catch (err) {
963
+ warnOnce(
964
+ `glyph|${slotKey(family, weight, style)}`,
965
+ `weasel: outline face "${family}" (${weight}/${style}) could not produce a glyph for U+${cp.toString(16).toUpperCase().padStart(4, "0")} \u2014 ${err instanceof Error ? err.message : String(err)}. Falling back to the SDF tier.`
966
+ );
967
+ }
968
+ slot.glyphs.set(cp, d);
969
+ return d;
970
+ }
971
+ async function beginLoad(slot) {
972
+ slot.status = "loading";
973
+ try {
974
+ slot.face = await slot.parser(await readSource(slot.source));
975
+ slot.status = "ready";
976
+ notifyGlyphReady();
977
+ } catch (err) {
978
+ slot.status = "failed";
979
+ warnOnce(
980
+ `load|${slotKey(slot.family, slot.weight, slot.style)}`,
981
+ `weasel registerFontOutlines("${slot.family}" ${slot.weight}/${slot.style}): ${err instanceof Error ? err.message : String(err)}. Large text in this face keeps rendering from the SDF tier.`
982
+ );
983
+ }
984
+ }
985
+ async function readSource(source) {
986
+ const resolved = typeof source === "function" ? await source() : source;
987
+ if (typeof resolved === "string") {
988
+ const res = await fetch(resolved);
989
+ if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${resolved}`);
990
+ return res.arrayBuffer();
991
+ }
992
+ if (resolved instanceof ArrayBuffer) return resolved;
993
+ return resolved.arrayBuffer();
994
+ }
995
+ var warned = /* @__PURE__ */ new Set();
996
+ function warnOnce(key, message) {
997
+ if (warned.has(key)) return;
998
+ warned.add(key);
999
+ console.warn(message);
1000
+ }
1001
+ function _resetFontOutlinesForTests() {
1002
+ slots = /* @__PURE__ */ new Map();
1003
+ warned.clear();
1004
+ }
1005
+
1006
+ export { DEFAULT_BAKE_BUDGET, FIXTURE_FONT, __setGlyphRasterizerForTests, _getPagesForTests, _resetDynamicFontsForTests, _resetFallbackForTests, _resetFontOutlinesForTests, _resetFontRegistryForTests, createOpenTypeParser, dynamicPageTextureId, ensureFontTexture, getDefaultFontFamily, getFont, getFontFallbackPolicy, glyphGeneration, glyphOutline, hasFontOutlines, isCanvasFont, listCanvasFonts, listFontOutlines, listFonts, markAllFontsNotUploaded, outlineStatus, parseBmFont, registerCanvasFont, registerFont, registerFontOutlines, resetBakeBudget, resolveFontVariant, resolveGlyphFallback, setDefaultFontFamily, setFontFallbackPolicy, subscribeGlyphReady, syncDynamicPageTexture, textureCacheKey, unregisterCanvasFont, unregisterFontOutlines };
1007
+ //# sourceMappingURL=chunk-3RSTZDXH.js.map
1008
+ //# sourceMappingURL=chunk-3RSTZDXH.js.map