Skip to content

packages/text/src/index.ts

Read as Markdown

This is the source snapshot used to build these API details. View this revision on GitHub.

Back to reference

1 import { activeProfile } from "./lib/profiling.js";
2 import { validateFontContainer } from "./lib/font-container.js";
3 import { fontBytes } from "./lib/font-bytes.js";
4 import { getEngine, type Engine } from "./lib/engine.js";
5 import { PathGeometry } from "@pibbl/core";
6 import { inkBounds, type InkBounds } from "./lib/bounds.js";
7 export type { InkBounds } from "./lib/bounds.js";
8 
9 /**
10  * An explicitly owned native font loaded for text shaping. Dispose it when no more geometry will be
11  * created from it; disposal is idempotent.
12  *
13  * @see {@link loadOutlineFont}
14  * @see {@link createTextGeometry}
15  */
16 export interface OutlineFont extends Disposable {
17   /**
18    * Releases the native font owner; repeated calls are harmless and later shaping with this font
19    * fails. See {@link OutlineFont}.
20    */
21   dispose(): void;
22 }
23 /**
24  * Cancellation and monochrome-fallback policy used when loading an outline font.
25  *
26  * @see {@link loadOutlineFont}
27  */
28 export interface OutlineFontOptions {
29   /** Abort signal for font loading and validation. See {@link OutlineFontOptions}. */
30   signal?: AbortSignal;
31   /**
32    * Allows supported monochrome outlines when the font includes color glyph data. See
33    * {@link OutlineFontOptions}.
34    */
35   monochromeFallback?: boolean;
36 }
37 /**
38  * Font size, shaping direction, OpenType features, and work limits for one text run.
39  *
40  * @see {@link createTextGeometry}
41  */
42 export interface TextGeometryOptions {
43   /** Positive finite output font size in logical units. See {@link TextGeometryOptions}. */
44   fontSize: number;
45   /** Upper bound on the number of output path segments. See {@link TextGeometryOptions}. */
46   maxSegments?: number;
47   /**
48    * Whether an absent glyph fails shaping or uses the font's .notdef glyph. See
49    * {@link TextGeometryOptions}.
50    */
51   missingGlyphs?: "error" | "notdef";
52   /**
53    * Explicit left-to-right or right-to-left shaping direction; omitted direction is inferred. See
54    * {@link TextGeometryOptions}.
55    */
56   direction?: "ltr" | "rtl";
57   /**
58    * Four-character OpenType script tag overriding script inference. See
59    * {@link TextGeometryOptions}.
60    */
61   script?: string;
62   /**
63    * Language tag used for shaping; distinct interned tags are bounded by the engine. See
64    * {@link TextGeometryOptions}.
65    */
66   language?: string;
67   /** OpenType feature overrides keyed by four-character tags. See {@link TextGeometryOptions}. */
68   features?: Readonly<Record<string, boolean>>;
69 }
70 /**
71  * Shaped glyph identity, UTF-16 source cluster, outline segment range, and scaled placement metrics.
72  *
73  * @see {@link TextGeometryResult}
74  */
75 export interface TextGlyph {
76   /** Font-specific identifier of the shaped glyph. See {@link TextGlyph}. */
77   readonly glyphId: number;
78   /** UTF-16 offset of the source cluster associated with this glyph. See {@link TextGlyph}. */
79   readonly cluster: number;
80   /** Index of the glyph's first segment in the returned geometry. See {@link TextGlyph}. */
81   readonly segmentStart: number;
82   /** Number of outline segments belonging to this glyph. See {@link TextGlyph}. */
83   readonly segmentCount: number;
84   /**
85    * Horizontal coordinate or displacement in the containing coordinate system. See
86    * {@link TextGlyph}.
87    */
88   readonly x: number;
89   /**
90    * Vertical coordinate or displacement in the containing coordinate system. See {@link TextGlyph}
91    * .
92    */
93   readonly y: number;
94   /** Horizontal pen advance in scaled logical units. See {@link TextGlyph}. */
95   readonly xAdvance: number;
96   /** Vertical pen advance in Canvas-oriented logical units. See {@link TextGlyph}. */
97   readonly yAdvance: number;
98 }
99 /**
100  * Independent path geometry, optional ink bounds, advances, and glyph metadata for a shaped text run.
101  *
102  * @see {@link PathGeometry}
103  * @see {@link InkBounds}
104  * @see {@link TextGlyph}
105  * @see {@link createTextGeometry}
106  */
107 export interface TextGeometryResult {
108   /**
109    * Independent mutable outline geometry in Canvas-oriented output coordinates. See
110    * {@link PathGeometry}.
111    */
112   readonly geometry: PathGeometry;
113   /** Bounds of visible outlines, or null when the run has no ink. See {@link InkBounds}. */
114   readonly inkBounds: Readonly<InkBounds> | null;
115   /**
116    * Final pen displacement, including glyph advances even when no ink is drawn. See
117    * {@link TextGeometryResult}.
118    */
119   readonly advance: Readonly<{
120     /**
121      * Horizontal coordinate or displacement in the containing coordinate system. See
122      * {@link TextGeometryResult}.
123      */
124     x: number;
125     /**
126      * Vertical coordinate or displacement in the containing coordinate system. See
127      * {@link TextGeometryResult}.
128      */
129     y: number;
130   }>;
131   /**
132    * Ordered shaped-glyph metadata corresponding to the returned outline segments. See
133    * {@link TextGlyph}.
134    */
135   readonly glyphs: readonly TextGlyph[];
136 }
137 // HarfBuzz interns languages until module teardown. Bound that shared lifetime
138 // separately from font ownership; canonical case avoids duplicate reservations.
139 const internedLanguages = new Set<string>();
140 const owners = new WeakMap<
141   OutlineFont,
142   { engine: Engine; handle: number; upem: number }
143 >();
144 /**
145  * Loads and validates font bytes from a URL or buffer and returns an explicitly disposable font.
146  * Rejects on cancellation, unsupported font data, or allocation failure.
147  *
148  * @param source - Font URL to fetch, or in-memory font bytes to copy into the shaping engine.
149  * @param options - Cancellation signal and explicit monochrome fallback policy. See
150  * {@link OutlineFontOptions} .
151  * @returns A promise for an owned font; dispose it when no further shaping will use it. See
152  * {@link OutlineFont} .
153  *
154  * @see {@link OutlineFontOptions}
155  * @see {@link OutlineFont}
156  */
157 export async function loadOutlineFont(
158   source: string | URL | ArrayBuffer | Uint8Array,
159   options: OutlineFontOptions = {},
160 ): Promise<OutlineFont> {
161   const { signal, monochromeFallback } = options;
162   if (
163     monochromeFallback !== undefined &&
164     typeof monochromeFallback !== "boolean"
165   )
166     throw new TypeError("monochromeFallback must be boolean.");
167   signal?.throwIfAborted();
168   const controller = new AbortController();
169   const abort = () => controller.abort(signal?.reason);
170   signal?.addEventListener("abort", abort, { once: true });
171   let engine: Engine, bytes: Uint8Array;
172   try {
173     [engine, bytes] = await Promise.all([
174       getEngine(),
175       fontBytes(source, controller.signal),
176     ]);
177   } catch (error) {
178     controller.abort(error);
179     throw error;
180   } finally {
181     signal?.removeEventListener("abort", abort);
182   }
183   signal?.throwIfAborted();
184   validateFontContainer(bytes, monochromeFallback ?? false);
185   const pointer = engine._malloc(bytes.byteLength);
186   if (!pointer) throw new Error("Font allocation failed.");
187   let handle: number;
188   try {
189     engine.HEAPU8.set(bytes, pointer);
190     handle = engine._pibbl_font_create(pointer, bytes.length);
191   } finally {
192     engine._free(pointer);
193   }
194   if (!handle)
195     throw new Error("Invalid font or native font allocation failed.");
196   const font: OutlineFont = {
197     dispose() {
198       const state = owners.get(font);
199       if (!state) return;
200       owners.delete(font);
201       state.engine._pibbl_font_release(state.handle);
202     },
203     [Symbol.dispose]() {
204       font.dispose();
205     },
206   };
207   owners.set(font, { engine, handle, upem: engine._pibbl_font_upem(handle) });
208   return font;
209 }
210 /**
211  * Synchronously shapes a single text run into independent Canvas-oriented outlines. The font must
212  * be live, fontSize positive and finite, and text must not contain line breaks or tabs.
213  *
214  * @param font - Live font returned by loadOutlineFont; it must not have been disposed. See
215  * {@link OutlineFont} .
216  * @param text - Text to shape as one run.
217  * @param options - Font size, shaping overrides, missing-glyph policy, and work limits. See
218  * {@link TextGeometryOptions} .
219  * @returns Independent mutable path geometry plus glyph placements, ink bounds, and advances; the
220  * result remains usable after font disposal. See {@link TextGeometryResult} .
221  *
222  * @see {@link OutlineFont}
223  * @see {@link TextGeometryOptions}
224  * @see {@link TextGeometryResult}
225  */
226 export function createTextGeometry(
227   font: OutlineFont,
228   text: string,
229   options: TextGeometryOptions,
230 ): TextGeometryResult {
231   const profile = activeProfile;
232   let phaseStart = profile ? performance.now() : 0;
233   const owner = owners.get(font);
234   if (!owner) throw new Error("Font is disposed or is not an OutlineFont.");
235   if (
236     typeof text !== "string" ||
237     text.length > 100000 ||
238     /[\r\n\t]/u.test(text)
239   )
240     throw new TypeError(
241       "Expected a single text run of at most 100000 UTF-16 units.",
242     );
243   if (!Number.isFinite(options.fontSize) || options.fontSize <= 0)
244     throw new RangeError("fontSize must be finite and positive.");
245   if (
246     options.missingGlyphs !== undefined &&
247     options.missingGlyphs !== "error" &&
248     options.missingGlyphs !== "notdef"
249   )
250     throw new TypeError("missingGlyphs must be error or notdef.");
251   const limit = options.maxSegments ?? 1000000;
252   if (!Number.isInteger(limit) || limit < 1 || limit > 1000000)
253     throw new RangeError("maxSegments must be an integer from 1 to 1000000.");
254   const tag = (value: string) => {
255     if (!/^[ -~]{4}$/u.test(value))
256       throw new TypeError("OpenType tags must contain four ASCII characters.");
257     return (
258       ((value.charCodeAt(0) << 24) |
259         (value.charCodeAt(1) << 16) |
260         (value.charCodeAt(2) << 8) |
261         value.charCodeAt(3)) >>>
262       0
263     );
264   };
265   if (
266     options.direction !== undefined &&
267     options.direction !== "ltr" &&
268     options.direction !== "rtl"
269   )
270     throw new TypeError("Expected ltr or rtl direction.");
271   const script = options.script === undefined ? 0 : tag(options.script);
272   if (
273     options.language !== undefined &&
274     !/^[A-Za-z0-9-]{1,63}$/u.test(options.language)
275   )
276     throw new TypeError("Invalid language tag.");
277   const languageTag = options.language?.toLowerCase();
278   if (
279     languageTag !== undefined &&
280     !internedLanguages.has(languageTag) &&
281     internedLanguages.size >= 256
282   )
283     throw new RangeError(
284       "At most 256 distinct language tags may be used per text engine instance.",
285     );
286   const language =
287     languageTag === undefined
288       ? new Uint8Array(0)
289       : new TextEncoder().encode(languageTag + "\0");
290   const features = Object.entries(options.features ?? {});
291   if (features.length > 64)
292     throw new RangeError(
293       "At most 64 OpenType feature overrides are supported.",
294     );
295   const featureData = new Uint32Array(features.length * 4);
296   features.forEach(([name, enabled], index) => {
297     if (typeof enabled !== "boolean")
298       throw new TypeError("Feature overrides must be boolean.");
299     featureData.set([tag(name), Number(enabled), 0, 0xffffffff], index * 4);
300   });
301   const { engine, handle, upem } = owner;
302   const textBytes = Math.max(4, Math.ceil((text.length * 2) / 4) * 4);
303   const pointer = engine._malloc(
304     textBytes + featureData.byteLength + language.length,
305   );
306   if (!pointer) throw new Error("Text allocation failed.");
307   let result: number;
308   try {
309     const units = new Uint16Array(engine.HEAPU8.buffer, pointer, text.length);
310     for (let i = 0; i < text.length; i++) units[i] = text.charCodeAt(i);
311     engine.HEAPU8.set(new Uint8Array(featureData.buffer), pointer + textBytes);
312     engine.HEAPU8.set(language, pointer + textBytes + featureData.byteLength);
313     if (languageTag !== undefined) internedLanguages.add(languageTag);
314     if (profile) {
315       profile.inputMs = performance.now() - phaseStart;
316       engine._pibbl_profile_enabled(1);
317     }
318     result = engine._pibbl_shape(
319       handle,
320       pointer,
321       text.length,
322       limit,
323       options.direction === "ltr" ? 1 : options.direction === "rtl" ? 2 : 0,
324       script,
325       language.length ? pointer + textBytes + featureData.byteLength : 0,
326       pointer + textBytes,
327       features.length,
328       options.missingGlyphs === "notdef" ? 1 : 0,
329     );
330   } finally {
331     if (profile) {
332       profile.nativeSetupMs = engine._pibbl_profile_time(0);
333       profile.shapingMs = engine._pibbl_profile_time(1);
334       profile.outlineMs = engine._pibbl_profile_time(2);
335       engine._pibbl_profile_enabled(0);
336     }
337     engine._free(pointer);
338   }
339   if (!result)
340     throw new Error(
341       [
342         "Text conversion failed.",
343         "Font is missing a required glyph.",
344         "Text exceeded the outline work or output segment limit.",
345         "Native shaping allocation failed.",
346         "Font glyph has no valid supported outline.",
347       ][engine._pibbl_error()] ?? "Text conversion failed.",
348     );
349   try {
350     if (profile) phaseStart = performance.now();
351     const lengths = [0, 1, 2, 3].map((field) =>
352       engine._pibbl_result_length(result, field),
353     );
354     const offsets = [0, 1, 2, 3].map((field) =>
355       engine._pibbl_result_data(result, field),
356     );
357     const memory = engine.HEAPU8.buffer;
358     const commands = new Uint8Array(memory, offsets[0], lengths[0]);
359     const coords = new Float64Array(memory, offsets[1], lengths[1]);
360     const ids = new Uint32Array(memory, offsets[2], lengths[2]);
361     const positions = new Float64Array(memory, offsets[3], lengths[3]);
362     if (profile) {
363       profile.bufferViewsMs = performance.now() - phaseStart;
364       phaseStart = performance.now();
365     }
366     const scale = options.fontSize / upem;
367     const geometry = new PathGeometry();
368     let index = 0;
369     const coordinate = () => {
370       const value = coords[index] * scale * (index++ & 1 ? -1 : 1);
371       if (!Number.isFinite(value))
372         throw new RangeError("Nonfinite outline coordinate.");
373       return value;
374     };
375     for (const command of commands) {
376       switch (command) {
377         case 0:
378           geometry.moveTo(coordinate(), coordinate());
379           break;
380         case 1:
381           geometry.lineTo(coordinate(), coordinate());
382           break;
383         case 2:
384           geometry.quadraticCurveTo(
385             coordinate(),
386             coordinate(),
387             coordinate(),
388             coordinate(),
389           );
390           break;
391         case 3:
392           geometry.bezierCurveTo(
393             coordinate(),
394             coordinate(),
395             coordinate(),
396             coordinate(),
397             coordinate(),
398             coordinate(),
399           );
400           break;
401         case 4:
402           geometry.closePath();
403           break;
404         default:
405           throw new Error("Invalid outline command.");
406       }
407     }
408     if (
409       index !== coords.length ||
410       ids.length % 4 ||
411       positions.length !== ids.length
412     )
413       throw new Error("Invalid native result.");
414     const finite = (value: number) => {
415       if (!Number.isFinite(value))
416         throw new RangeError("Nonfinite text metric.");
417       return value === 0 ? 0 : value;
418     };
419     const glyphs: TextGlyph[] = [];
420     let segmentEnd = 0;
421     for (let i = 0; i < ids.length; i += 4) {
422       if (
423         ids[i + 1] >= text.length ||
424         ids[i + 2] !== segmentEnd ||
425         ids[i + 3] > commands.length - segmentEnd
426       ) {
427         throw new Error("Invalid native glyph metadata.");
428       }
429       segmentEnd += ids[i + 3];
430       glyphs.push(
431         Object.freeze({
432           glyphId: ids[i],
433           cluster: ids[i + 1],
434           segmentStart: ids[i + 2],
435           segmentCount: ids[i + 3],
436           x: finite(positions[i] * scale),
437           y: finite(-positions[i + 1] * scale),
438           xAdvance: finite(positions[i + 2] * scale),
439           yAdvance: finite(-positions[i + 3] * scale),
440         }),
441       );
442     }
443     if (segmentEnd !== commands.length)
444       throw new Error("Invalid native glyph segment coverage.");
445     if (profile) {
446       profile.materializationMs = performance.now() - phaseStart;
447       phaseStart = performance.now();
448     }
449     const bounds = inkBounds(geometry);
450     if (profile) profile.boundsMs = performance.now() - phaseStart;
451     return Object.freeze({
452       geometry,
453       inkBounds: bounds,
454       glyphs: Object.freeze(glyphs),
455       advance: Object.freeze({
456         x: finite(engine._pibbl_result_advance(result, 0) * scale),
457         y: finite(-engine._pibbl_result_advance(result, 1) * scale),
458       }),
459     });
460   } finally {
461     engine._pibbl_result_release(result);
462   }
463 }
464 

Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.