packages/text/src/index.ts
This is the source snapshot used to build these API details. View this revision on GitHub.
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 version
Section titled “Documentation version”Documentation built with @pibbl/core 0.0.2, revision 272a94a. ALPHA — NOT FOR PRODUCTION USE.