@navaramap/font 0.2.0 → 0.3.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.
@@ -1,4 +1,3 @@
1
- import type { ConcurrencyManager } from "@navaramap/worker";
2
1
  import {
3
2
  DataTexture,
4
3
  LinearFilter,
@@ -92,9 +91,10 @@ export class FontManager {
92
91
  private _workerUrl: string | URL;
93
92
  private _client: FontWorkerClient | undefined;
94
93
  private _clientPromise: Promise<FontWorkerClient> | undefined;
95
- private _concurrencyManager: ConcurrencyManager | undefined;
96
94
  /** Worker cache budget, delivered when the (lazy) client is created. */
97
95
  private _fontBudgetBytes: number | undefined;
96
+ /** Delivered when the (lazy) client is created; see `setWorkerCoreReservation`. */
97
+ private _reserveWorkerCore: (() => () => void) | undefined;
98
98
  /** Tracks in-flight fetch promises to avoid duplicate requests. */
99
99
  private _pending = new Map<string, Promise<void>>();
100
100
  /** Tracks fonts that have been successfully loaded. */
@@ -167,10 +167,6 @@ export class FontManager {
167
167
  this._workerUrl = workerUrl;
168
168
  }
169
169
 
170
- setConcurrencyManager(concurrencyManager: ConcurrencyManager) {
171
- this._concurrencyManager = concurrencyManager;
172
- }
173
-
174
170
  /**
175
171
  * Register a font family with multiple faces.
176
172
  * No font files are loaded until text is prepared.
@@ -282,15 +278,10 @@ export class FontManager {
282
278
  private _ensureClient(): Promise<FontWorkerClient> {
283
279
  if (this._client) return Promise.resolve(this._client);
284
280
  if (this._clientPromise) return this._clientPromise;
285
- if (!this._concurrencyManager) {
286
- return Promise.reject(
287
- new Error("FontManager: concurrencyManager not set"),
288
- );
289
- }
290
- const cm = this._concurrencyManager;
291
281
  this._clientPromise = (async () => {
292
- const client = new FontWorkerClient(this._workerUrl, cm, {
282
+ const client = new FontWorkerClient(this._workerUrl, {
293
283
  fontBudgetBytes: this._fontBudgetBytes,
284
+ reserveWorkerCore: this._reserveWorkerCore,
294
285
  });
295
286
  await client.ready();
296
287
  this._client = client;
@@ -311,6 +302,16 @@ export class FontManager {
311
302
  this._fontBudgetBytes = bytes;
312
303
  }
313
304
 
305
+ /**
306
+ * Lets the font worker hold a core of the view's worker pool while it
307
+ * loads fonts or shapes text (see `reserveWorkerCore` in
308
+ * `@navaramap/worker`). Must be called before the first font loads, like
309
+ * {@link setMemoryBudget}.
310
+ */
311
+ setWorkerCoreReservation(reserve: () => () => void): void {
312
+ this._reserveWorkerCore = reserve;
313
+ }
314
+
314
315
  /**
315
316
  * Snapshot of the font worker's memory usage, or `undefined` while no
316
317
  * worker has been spawned (no fonts in use = zero cost).
@@ -604,6 +605,9 @@ export class FontManager {
604
605
  let ascender = 0;
605
606
  let descender = 0;
606
607
  let lineGap = 0;
608
+ // Each face run reports on its own substring, so the label allows spacing
609
+ // only when every run does.
610
+ let allowsLetterSpacing = true;
607
611
 
608
612
  for (const seg of segments) {
609
613
  const result = this._shapeCache
@@ -624,6 +628,7 @@ export class FontManager {
624
628
  lineGap = result.lineGap;
625
629
  }
626
630
  const scale = targetUnitsPerEm / result.unitsPerEm;
631
+ allowsLetterSpacing &&= result.allowsLetterSpacing;
627
632
 
628
633
  for (const g of result.glyphs) {
629
634
  allGlyphs.push(
@@ -659,6 +664,7 @@ export class FontManager {
659
664
  ascender: 800,
660
665
  descender: -200,
661
666
  lineGap: 0,
667
+ allowsLetterSpacing,
662
668
  };
663
669
  }
664
670
 
@@ -669,6 +675,7 @@ export class FontManager {
669
675
  ascender,
670
676
  descender,
671
677
  lineGap,
678
+ allowsLetterSpacing,
672
679
  };
673
680
  }
674
681
 
@@ -0,0 +1,114 @@
1
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
2
+
3
+ import { FontWorkerClient } from "./FontWorkerClient";
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ reserveWorkerCore: vi.fn(),
7
+ release: vi.fn(),
8
+ }));
9
+
10
+ type Message = { id: number; type: string };
11
+
12
+ /** A worker that answers only when a test tells it to. */
13
+ class MockWorker {
14
+ static last: MockWorker;
15
+ onmessage: ((e: MessageEvent) => void) | null = null;
16
+ onerror: ((e: unknown) => void) | null = null;
17
+ received: Message[] = [];
18
+ constructor() {
19
+ MockWorker.last = this;
20
+ }
21
+ postMessage(message: Message) {
22
+ this.received.push(message);
23
+ }
24
+ terminate() {}
25
+ /** Answer the oldest unanswered request of `type`. */
26
+ reply(type: string) {
27
+ const index = this.received.findIndex((m) => m.type === type);
28
+ const [message] = this.received.splice(index, 1);
29
+ this.onmessage?.({
30
+ data: { id: message.id, type: "ok", payload: { ok: true, results: [] } },
31
+ } as MessageEvent);
32
+ }
33
+ }
34
+
35
+ beforeEach(() => {
36
+ vi.useFakeTimers();
37
+ vi.stubGlobal("Worker", MockWorker);
38
+ mocks.reserveWorkerCore.mockReset().mockReturnValue(mocks.release);
39
+ mocks.release.mockReset();
40
+ });
41
+
42
+ afterEach(() => {
43
+ vi.useRealTimers();
44
+ vi.unstubAllGlobals();
45
+ });
46
+
47
+ it("holds one pool core while heavy requests run and releases it after a short idle", async () => {
48
+ const client = new FontWorkerClient("font-worker.js", {
49
+ reserveWorkerCore: mocks.reserveWorkerCore,
50
+ });
51
+ const worker = MockWorker.last;
52
+ expect(mocks.reserveWorkerCore).toHaveBeenCalledOnce();
53
+
54
+ const shaping = client.prepareTextBatch("font.ttf", ["a"]);
55
+ worker.reply("init");
56
+ await vi.advanceTimersByTimeAsync(200);
57
+ // Shaping is still in flight.
58
+ expect(mocks.release).not.toHaveBeenCalled();
59
+
60
+ worker.reply("prepareTextBatch");
61
+ await shaping;
62
+ await vi.advanceTimersByTimeAsync(50);
63
+ // A request within the delay keeps the same reservation.
64
+ const loading = client.loadFont(
65
+ "b.ttf",
66
+ new ArrayBuffer(1),
67
+ undefined,
68
+ false,
69
+ );
70
+ worker.reply("loadFont");
71
+ await loading;
72
+ await vi.advanceTimersByTimeAsync(99);
73
+ expect(mocks.release).not.toHaveBeenCalled();
74
+ expect(mocks.reserveWorkerCore).toHaveBeenCalledOnce();
75
+
76
+ await vi.advanceTimersByTimeAsync(1);
77
+ expect(mocks.release).toHaveBeenCalledOnce();
78
+
79
+ client.dispose();
80
+ expect(mocks.release).toHaveBeenCalledOnce();
81
+ });
82
+
83
+ it("does not hold a core for glyph reference updates", async () => {
84
+ const client = new FontWorkerClient("font-worker.js", {
85
+ reserveWorkerCore: mocks.reserveWorkerCore,
86
+ });
87
+ const worker = MockWorker.last;
88
+ worker.reply("init");
89
+ await vi.advanceTimersByTimeAsync(100);
90
+ expect(mocks.release).toHaveBeenCalledOnce();
91
+
92
+ client.retainGlyphs("atlas", new BigUint64Array([1n]));
93
+ expect(mocks.reserveWorkerCore).toHaveBeenCalledOnce();
94
+ worker.reply("retainGlyphs");
95
+ client.dispose();
96
+ });
97
+
98
+ it("holds no core without a reservation function", () => {
99
+ const client = new FontWorkerClient("font-worker.js");
100
+ MockWorker.last.reply("init");
101
+ client.dispose();
102
+ expect(mocks.reserveWorkerCore).not.toHaveBeenCalled();
103
+ });
104
+
105
+ it("releases the core at once when disposed", async () => {
106
+ const client = new FontWorkerClient("font-worker.js", {
107
+ reserveWorkerCore: mocks.reserveWorkerCore,
108
+ });
109
+ // The unanswered init is rejected by the dispose.
110
+ const ready = client.ready().catch(() => undefined);
111
+ client.dispose();
112
+ expect(mocks.release).toHaveBeenCalledOnce();
113
+ await ready;
114
+ });
@@ -1,5 +1,3 @@
1
- import type { ConcurrencyManager } from "@navaramap/worker";
2
-
3
1
  import type {
4
2
  ShapeTextResult,
5
3
  FontAtlasData,
@@ -11,29 +9,38 @@ export type FontWorkerClientOptions = {
11
9
  /** Memory budget for the worker's font caches (font data + atlas pixels).
12
10
  * Caps further atlas growth; the WASM heap itself never shrinks. */
13
11
  fontBudgetBytes?: number;
12
+ /** Holds a worker-pool core while the font worker is busy and returns
13
+ * its release (`reserveWorkerCore` of the view's worker pool). Passed in
14
+ * rather than imported: this package bundles its own copy of
15
+ * `@navaramap/worker`, which would hold a core on a pool that does not
16
+ * exist. */
17
+ reserveWorkerCore?: () => () => void;
14
18
  };
15
19
 
20
+ /** How long the worker's core stays held after its last heavy request, so a
21
+ * burst of requests does not flip the reservation per request. */
22
+ const CORE_RELEASE_DELAY_MS = 100;
23
+
16
24
  /**
17
25
  * Main-thread client that communicates with the dedicated font Web Worker.
18
26
  * Uses a request/response protocol with incrementing message IDs.
19
27
  */
20
28
  export class FontWorkerClient {
21
29
  private _worker: Worker;
22
- private _concurrencyManager: ConcurrencyManager;
23
30
  private _nextId = 0;
24
31
  private _pending = new Map<
25
32
  number,
26
33
  { resolve: (value: unknown) => void; reject: (reason: unknown) => void }
27
34
  >();
28
35
  private _ready: Promise<void>;
29
-
30
- constructor(
31
- workerUrl: string | URL,
32
- concurrencyManager: ConcurrencyManager,
33
- options?: FontWorkerClientOptions,
34
- ) {
35
- this._concurrencyManager = concurrencyManager;
36
-
36
+ /** Heavy requests (init, font loads, text shaping) still in flight. */
37
+ private _heavyRequests = 0;
38
+ private _releaseCore: (() => void) | undefined;
39
+ private _releaseCoreTimer: ReturnType<typeof setTimeout> | undefined;
40
+ private _reserveWorkerCore: (() => () => void) | undefined;
41
+
42
+ constructor(workerUrl: string | URL, options?: FontWorkerClientOptions) {
43
+ this._reserveWorkerCore = options?.reserveWorkerCore;
37
44
  this._worker = new Worker(workerUrl, {
38
45
  type: import.meta.env.PROD ? undefined : "module",
39
46
  });
@@ -59,10 +66,11 @@ export class FontWorkerClient {
59
66
 
60
67
  // Trigger WASM init and resolve once the worker is ready; the init
61
68
  // payload also delivers the memory budget.
62
- this._ready = this._send("init", {
63
- fontBudgetBytes: options?.fontBudgetBytes,
64
- }).then(() => undefined);
65
- this._concurrencyManager.increment();
69
+ this._ready = this._holdCore(
70
+ this._send("init", {
71
+ fontBudgetBytes: options?.fontBudgetBytes,
72
+ }),
73
+ ).then(() => undefined);
66
74
  }
67
75
 
68
76
  /** Wait for the WASM module to be initialized in the worker. */
@@ -87,9 +95,9 @@ export class FontWorkerClient {
87
95
  atlasKey: string | undefined,
88
96
  highQuality: boolean,
89
97
  ): Promise<{ ok: boolean; cmapRanges: Uint32Array | null }> {
90
- return this._send("loadFont", { url, data, atlasKey, highQuality }, [
91
- data,
92
- ]) as Promise<{
98
+ return this._holdCore(
99
+ this._send("loadFont", { url, data, atlasKey, highQuality }, [data]),
100
+ ) as Promise<{
93
101
  ok: boolean;
94
102
  cmapRanges: Uint32Array | null;
95
103
  }>;
@@ -122,10 +130,12 @@ export class FontWorkerClient {
122
130
  height: number;
123
131
  channels: number;
124
132
  };
125
- const raw = (await this._send("prepareTextBatch", {
126
- fontUrl,
127
- texts,
128
- })) as {
133
+ const raw = (await this._holdCore(
134
+ this._send("prepareTextBatch", {
135
+ fontUrl,
136
+ texts,
137
+ }),
138
+ )) as {
129
139
  results: { text: string; shapeResult: ShapeTextResult | null }[];
130
140
  atlas: RawAtlas | null;
131
141
  colorAtlas: RawAtlas | null;
@@ -161,12 +171,41 @@ export class FontWorkerClient {
161
171
  }
162
172
 
163
173
  dispose(): void {
174
+ if (this._releaseCoreTimer !== undefined) {
175
+ clearTimeout(this._releaseCoreTimer);
176
+ this._releaseCoreTimer = undefined;
177
+ }
178
+ this._releaseCore?.();
179
+ this._releaseCore = undefined;
164
180
  this._worker.terminate();
165
181
  for (const pending of this._pending.values()) {
166
182
  pending.reject(new Error("FontWorkerClient disposed"));
167
183
  }
168
184
  this._pending.clear();
169
- this._concurrencyManager.decrement();
185
+ }
186
+
187
+ /**
188
+ * Hold a worker-pool core while `request` keeps the font worker busy, so
189
+ * the pool leaves the worker a core of its own (see `reserveWorkerCore`).
190
+ */
191
+ private _holdCore(request: Promise<unknown>): Promise<unknown> {
192
+ if (this._releaseCoreTimer !== undefined) {
193
+ clearTimeout(this._releaseCoreTimer);
194
+ this._releaseCoreTimer = undefined;
195
+ }
196
+ this._releaseCore ??= this._reserveWorkerCore?.();
197
+ this._heavyRequests++;
198
+ const settle = () => {
199
+ this._heavyRequests--;
200
+ if (this._heavyRequests > 0 || !this._releaseCore) return;
201
+ this._releaseCoreTimer = setTimeout(() => {
202
+ this._releaseCoreTimer = undefined;
203
+ this._releaseCore?.();
204
+ this._releaseCore = undefined;
205
+ }, CORE_RELEASE_DELAY_MS);
206
+ };
207
+ request.then(settle, settle);
208
+ return request;
170
209
  }
171
210
 
172
211
  private _send(
@@ -41,13 +41,19 @@ export type FetchCssFontFamilyOptions = CssFontFaceFilter & {
41
41
  // initialize that WASM module and delegate. Parsing correctness is covered by
42
42
  // the crate's Rust tests; the TS tests here cover the fetch orchestration.
43
43
  //
44
- // The WASM module is instantiated lazily and at most once — the promise is
45
- // memoized so concurrent callers share a single init (`__wbg_init` itself is
46
- // idempotent once resolved, but memoizing avoids a redundant instantiate while
47
- // the first is still in flight).
48
- let apiReady: Promise<unknown> | undefined;
44
+ // The WASM module is instantiated lazily and at most once. `__wbg_init` is
45
+ // idempotent only once resolved, and a second instantiation while the first
46
+ // is in flight replaces the module other callers' objects live in, so every
47
+ // caller, including @navaramap/three-api's `initNavaraApi`, shares one
48
+ // promise under a global key.
49
+ const API_READY = Symbol.for("@navaramap/engine-api/init");
50
+ type ApiReadyHolder = { [API_READY]?: Promise<unknown> };
49
51
  function ensureApi(): Promise<unknown> {
50
- return (apiReady ??= initApi());
52
+ const holder = globalThis as ApiReadyHolder;
53
+ return (holder[API_READY] ??= initApi().catch((e: unknown) => {
54
+ holder[API_READY] = undefined;
55
+ throw e;
56
+ }));
51
57
  }
52
58
 
53
59
  /**
package/src/fontWorker.ts CHANGED
@@ -32,6 +32,8 @@ function convertGlyphs(glyphs: WasmShapedGlyph[]) {
32
32
  xOffset: g.x_offset,
33
33
  yOffset: g.y_offset,
34
34
  charClass: g.char_class,
35
+ continuesCluster: g.continues_cluster,
36
+ direction: g.direction,
35
37
  };
36
38
  g.free();
37
39
  return out;
@@ -66,6 +68,7 @@ function convertShapeResult(sr: WasmShapeTextResult | undefined) {
66
68
  ascender: sr.ascender,
67
69
  descender: sr.descender,
68
70
  lineGap: sr.line_gap,
71
+ allowsLetterSpacing: sr.allows_letter_spacing,
69
72
  };
70
73
  sr.free();
71
74
  return result;
package/src/index.ts CHANGED
@@ -18,7 +18,9 @@ export { LRUMap } from "./LRUMap";
18
18
  export {
19
19
  COLOR_GLYPH_PX_SIZE,
20
20
  GlyphCharClass,
21
+ GlyphDirection,
21
22
  SDF_PX_SIZE,
23
+ atlasPaddingPx,
22
24
  atlasRangePx,
23
25
  } from "./types";
24
26
  export type {
package/src/types.ts CHANGED
@@ -23,6 +23,16 @@ export const SDF_PX_SIZE = 64.0;
23
23
  export const atlasRangePx = (highQuality: boolean): number =>
24
24
  highQuality ? 16.0 : 35.0;
25
25
 
26
+ /**
27
+ * Pixels of distance field a quality's glyph rect keeps around the outline.
28
+ * The text shader grows a glyph's quad past it for wider outlines.
29
+ *
30
+ * SDF: `SDF_BUFFER` (12) in `crates/navara_wasm_font_worker/src/atlas.rs`.
31
+ * MSDF: `MSDF_PADDING_PX` (8) in `crates/navara_wasm_font_worker/src/msdf.rs`.
32
+ */
33
+ export const atlasPaddingPx = (highQuality: boolean): number =>
34
+ highQuality ? 8.0 : 12.0;
35
+
26
36
  /** Glyph metrics from either the SDF or the color atlas. */
27
37
  export type GlyphMetrics = {
28
38
  glyphId: number;
@@ -55,6 +65,21 @@ export const GlyphCharClass = {
55
65
  Ideographic: 3,
56
66
  } as const;
57
67
 
68
+ /**
69
+ * Direction of the `\n`-separated segment a glyph was shaped in. Must match
70
+ * the `DIRECTION_*` constants in
71
+ * `crates/navara_wasm_font_worker/src/shaping.rs`.
72
+ */
73
+ export const GlyphDirection = {
74
+ /** No character with a script of its own (digits, punctuation, spaces).
75
+ * Font-family fallback shapes such runs apart from the letters around
76
+ * them, so a line's direction is its first non-neutral glyph's. */
77
+ Neutral: 0,
78
+ Ltr: 1,
79
+ /** The segment's glyphs arrive in visual order (reversed logical order). */
80
+ Rtl: 2,
81
+ } as const;
82
+
58
83
  /** A single shaped glyph with positioning info. */
59
84
  export type ShapedGlyph = {
60
85
  glyphId: number;
@@ -68,6 +93,11 @@ export type ShapedGlyph = {
68
93
  yOffset: number;
69
94
  /** One of `GlyphCharClass` (line-break info). */
70
95
  charClass: number;
96
+ /** Same shaping cluster as the previous glyph (combining marks, pre-base
97
+ * vowels). Letter spacing is inserted only before cluster starts. */
98
+ continuesCluster?: boolean;
99
+ /** One of `GlyphDirection`; absent means neutral. */
100
+ direction?: number;
71
101
  };
72
102
 
73
103
  /** Result from shaping text: glyph positions + atlas metrics. */
@@ -82,6 +112,10 @@ export type ShapeTextResult = {
82
112
  descender: number;
83
113
  /** Extra line gap in font units (hhea). */
84
114
  lineGap: number;
115
+ /** False when letter spacing must not be applied: tracking pulls apart the
116
+ * joined letterforms of cursive Arabic, so (like MapLibre) any Arabic
117
+ * character disables it for the whole label. */
118
+ allowsLetterSpacing: boolean;
85
119
  /** FontManager-internal: the atlas generation these metrics were built
86
120
  * under. When the atlas evicts glyphs its generation bumps, marking older
87
121
  * cached results stale so they are re-shaped on next use. Unset on results