6. Text engine

src/text shapes and lays out text identically on the host, in the CLI and in the webview on one machine (TXT-5; the exact claim is §6.5).

src/text shapes and lays out text identically on the host, in the CLI and in the webview on one machine (TXT-5; the exact claim is §6.5). It has no dependency on the rest of the repository: fonts and WASM bytes are given to it.

6.1 Shaping with HarfBuzz#

  • harfbuzz.wasm (from the harfbuzzjs build; only the .wasm is shipped) is loaded through a WasmLoader that each environment provides: fs.readFile in Node, fetch + WebAssembly.instantiateStreaming of a webview resource URI in the webview.
  • text/hb.ts is our own binding to the raw HarfBuzz C exports (blob, face, font, buffer, shape, glyph outlines through hb_font_draw_glyph, variation axes, OpenType features, hb_font_paint_glyph for colour glyphs). It avoids shipping JS glue that the webview CSP or the webview’s no-bundler rule would have trouble with.
  • Input runs are itemised by script, direction (UAX #9 bidi, own implementation over generated Unicode tables), font fallback, and style run (TXT-2), then shaped run by run with the OpenType features and variation axes of the run (TXT-4).

6.2 Fonts#

Source Where Resolution order
Document assets fonts stored in <name>.design.assets/ (FMT-001 §8), when the user chose to embed them 1
Workspace folders in ud.fonts.folders (default fonts/, assets/fonts/) 2
Bundled media/fonts/: Inter, Roboto, Noto Sans, JetBrains Mono, Source Serif (OFL). Latin, Greek and Cyrillic only: script fonts (Arabic, Hebrew, Devanagari, Thai, CJK) and colour emoji are not bundled in the VSIX, because of its size budget (§12); they resolve from the workspace, the document or the OS. 3
Operating system Windows %WINDIR%\Fonts and the per-user fonts folder; macOS /System/Library/Fonts, /Library/Fonts, ~/Library/Fonts; Linux the fontconfig directories (/usr/share/fonts, ~/.local/share/fonts, …) 4

Tests do not depend on the OS: test/fixtures/fonts/ holds a pinned, test-only font set with one face per script of the golden corpus (Noto Sans Arabic, Hebrew, Devanagari, Thai, Noto Sans CJK subset, Noto Color Emoji subset), registered as a workspace folder by the test harness and excluded from the package. It is how the text goldens (§6.5) reproduce on every CI runner.

The host (and the CLI) builds a font index by reading each file’s name, OS/2 and fvar tables in TypeScript (no native font API): family, Figma-style style name (“Semi Bold Italic”), weight, width, italic, axes, file and face index, and a content hash. The index is cached in global storage keyed by directory mtimes. A text node names a font as family + style (Figma’s FontName); the index maps it to a face hash.

The webview never reads the file system: it receives the font index at start and asks the host for a face’s bytes by hash (font.get), caching them per session. The CLI reads files directly. Both feed the same bytes to the same WASM, which is the basis of identical output.

A face that resolves nowhere is reported (TXT-6), drawn with the fallback chain (Inter, then Noto Sans, then any OS face that covers the script), and listed in the “Missing fonts” panel, from which text.replaceFont replaces it document-wide.

Embedding. “Embed fonts” (font.embed, FMT-001 §8.3) copies a face into <name>.design.assets/, and from there into git. The command reads the face’s OS/2 fsType first and embeds only when the bits allow it (0 installable, or 8 editable embedding; 2 restricted and 4 preview-and-print only refuse). A refused face is reported with the font’s name and the reason, the node keeps its reference, and the user is pointed to the workspace folder option instead. The same check runs in the CLI (ud font embed). Bundled and workspace faces are embedded without asking, since the user already placed them.

6.3 Line breaking and layout#

  • Break opportunities and grapheme clusters come from the ICU4X segmenter compiled to WebAssembly (icu_segmenter with its data baked in at a pinned CLDR/Unicode version, loaded through the same WasmLoader as HarfBuzz): UAX #14 line breaking, UAX #29 grapheme, word and sentence boundaries (caret movement, double click, TXT-7), and dictionary and LSTM breaking for Thai, Lao, Khmer, Burmese and Japanese, which rule-based UAX #14 cannot do. The data is part of the .wasm, so every surface breaks lines with the same tables. We do not use Intl.Segmenter, because its ICU version differs between Node and the webview, and because Figma’s own line breaks are not what either produces. The bidi classes and script tables of §6.1 stay generated (scripts/gen-unicode.mjs, same pinned Unicode version), since ICU4X does not expose them in the size we want.
  • Greedy line filling by shaped advance widths; a run that crosses a break point is reshaped at the break when the shaping context changes (Arabic joining, ligatures).
  • Line height (px, %, auto = the font’s ascender − descender + line gap), letter spacing, paragraph spacing and indent, lists with nesting, hanging punctuation, leading trim (TXT-3) are applied after line breaking.
  • Alignment including justification (stretching inter-word spaces, never letter spacing), then bidi reordering per line.
  • Truncation (TXT-1): lines beyond the maximum are cut and the last line ends with an ellipsis shaped in the last run’s font.

The result, TextLayout, holds lines with baselines, glyph runs (face hash, glyph ids, positions, size, variation coordinates, paint index), caret stops for editing (TXT-7), and the measured size.

6.4 Glyph path cache#

Glyph outlines are extracted once per (face hash, glyph id, variation coordinates) in font units and cached as flat Float32Array path commands, scaled by a matrix at draw time. The cache is an LRU with a 32 MB budget per process. Colour glyphs (COLR v0/v1) are cached as a small paint list; bitmap glyphs (sbix, CBDT) as an image. Text is drawn as paths everywhere (canvas, raster export, PDF when outlined), never with the platform’s text drawing, so line breaks and glyph positions cannot differ between surfaces.

6.5 Identity across surfaces#

Identity holds when the inputs are identical: same WASM builds (HarfBuzz, ICU4X), same font bytes, same Unicode tables, same layout code. The claim TXT-5 makes is therefore bounded:

  • on one machine, host, CLI and webview produce identical line breaks and glyph positions, whatever fonts the text resolves to, because all three read the same font files through the same index;
  • across machines, identity holds when every face resolves to a bundled, workspace or embedded font (§6.2), because those bytes travel with the extension or the repository. Text that falls back to an OS font (a script font that is not in the workspace, the system emoji font) may break differently on another machine, and ud validate --fonts lists such nodes so a team can embed or add the faces it needs.

The text golden test (test/text/) shapes a corpus (Latin with features, Arabic, Hebrew, Devanagari, Thai, CJK, emoji, mixed bidi) with the pinned test font set in Node and in Chromium and compares the TextLayout JSON exactly; it runs on every CI platform because it never touches an OS font.