silly-tae/taetype: A font engine in Rust and WebAssembly: decode, instance, subset, and shape TTF, OTF, TTC, and WOFF2

github.com · 2026-07-13 · by Long Nguyen ·github
A font engine in Rust and WebAssembly: decode, instance, subset, and shape TTF, OTF, TTC, and WOFF2 fonts. - silly-tae/taetype

silly-tae/taetype

A font engine in Rust and WebAssembly: decode, instance, subset, and shape TTF, OTF, TTC, and WOFF2 fonts. - silly-tae/taetype

1 stars · 0 forks · Rust Topics: rust, font, webassembly, wasm, opentype, woff2, truetype, harfbuzz, cff, font-shaping, variable-fonts, font-subsetting

README

taetype A font engine in Rust and WebAssembly: decode, instance, subset, and shape TTF, OTF, TTC, and WOFF2 fonts. Extracted from taepdf's internal font engine. Pure Rust at its core – no browser API required, no server, no external font tooling. Runs as WASM in the browser, as a native library in a backend or desktop app, or as a plain Rust dependency. npm i taetype cargo add taetype

What it does

Decode – TTF, OTF, and TTC (font collections). Reads the full table directory, not a fixed allowlist, so nothing in the source font gets dropped. WOFF2 – its own Brotli decompression, written in Rust. Not the browser's DecompressionStream('brotli') API, which isn't available everywhere (a real-world gap on Brave/Windows is what prompted this). Instance – the full OpenType Font Variations model (fvar, avar, gvar, hvar, vvar, mvar, cvar). Turn a variable font into a static instance at any weight or optical size. Subset – TrueType (glyf) and CFF, including CID-keyed CFF (FDArray/FDSelect) and seac composite accented glyphs. Keep only the glyphs a document actually uses. Shape – GSUB ligatures, GPOS kerning, complex scripts, vertical writing, via rustybuzz (a Rust port of HarfBuzz). Color fonts – COLR/CPAL layered glyphs, CBDT/CBLC and sbix bitmap strikes.

What it doesn't do Rasterization. taetype hands back glyph IDs, outlines, and metrics – not pixels. Pair it with whatever renderer fits your target:

Browser – canvas/DOM already rasterizes, no extra dependency needed Native Rust (CPU) – ab_glyph or fontdue Native Rust (mature, C-backed) – freetype-rs, if a C dependency is acceptable GPU / game engines – rasterize each glyph once into a texture atlas with any of the above, then render textured quads

CFF2 (variable CFF) fonts. Subsetting/embedding a CFF2 font returns an error; instancing silently returns the font unmodified instead of applying the requested weight/optical size. Register these as static, already-instanced files instead – most released variable fonts use the TrueType glyf/gvar model, which taetype fully supports. Use cases

Client-side document generation – its origin: taepdf uses it to shape text and embed only the glyphs a PDF actually needs, entirely in the browser, no server round-trip. Web font optimization – subset a font down to the glyphs a page uses as a build step, without shelling out to Python/fonttools. Variable-to-static conversion – serve a single variable font file but hand out fixed-weight instances to targets that don't understand variable fonts (older PDF viewers, some embedding pipelines). In-browser typesetting – real GSUB/GPOS shaping (ligatures, kerning, complex scripts, vertical writing) for canvas or custom text layout, without a full browser text stack. Server-side image generation – shape text server-side and rasterize straight to a PNG/JPEG: certificates, share cards, dynamic thumbnails, no browser or PDF viewer needed. E-book / print tooling – EPUB or other document generators that need subsetted, embedded fonts and don't want a browser in the loop. Font inspection in CI – check TTC member count, detect variable axes, read family/weight/style metadata, validate WOFF2 files, as a native Rust build step.

Building WASM, for the browser and for Node.js (requires wasm-pack): cargo install wasm-pack wasm-pack build --target web --out-dir pkg/web wasm-pack build --target nodejs --out-dir pkg/node Both share the same compiled .wasm binary – only the surrounding JS glue differs. package.json's exports field routes browsers/bundlers to pkg/web and Node.js to pkg/node automatically once published; see the end-to-end examples below for using either directly from source. Native Rust library: cargo build --release API reference Every function below is exported from both WASM builds (pkg/web for browsers, pkg/node for Node.js) and callable directly if you use taetype as a native Rust crate. Fonts are registered under a (name, style) key and referenced by that pair afterward. The browser build needs an explicit await init() before first use. The Node build doesn't – require()/import loads the WASM synchronously, so every function is ready immediately. See the end-to-end examples below for both. Registering fonts register_font_raw(name: string, raw_bytes: Uint8Array): void Register a plain TTF/OTF file. The simplest entry point – in the browser, call init() first; in Node, just call this directly. register_font_ttc(name: string, ttc_bytes: Uint8Array, index: number): void ttc_font_count(bytes: Uint8Array): number Register one member font of a TrueType Collection (.ttc). ttc_font_count tells you how many fonts a collection holds before you pick an index. register_font(name, style, weight, opsz, woff2_bytes, decompressed, index): void Register a WOFF2 font. Unlike the two above, this expects the Brotli stream already decompressed – see the WOFF2 section below for the full pipeline. weight/opsz let you register multiple static weights (e.g. Regular + Bold) under the same name; taetype auto-detects true variable fonts and serves every weight from one registration regardless of what you pass here. list_registered_fonts(): string[] Every registered "name:style" pair, for debugging or building a font picker. WOFF2 pipeline get_compressed_range(woff2_bytes: Uint8Array): { start: number, length: number } decompress_brotli(compressed: Uint8Array): Uint8Array read_font_meta(woff2_bytes, decompressed, index): { style, weight, isVariable } get_compressed_range tells you which byte range of the raw WOFF2 file is the actual Brotli-compressed table data (skipping the header). Slice that range out, run it through decompress_brotli, then either inspect it with read_font_meta or hand both buffers to register_font. All three run in Rust – no browser compression API involved. Glyphs and metrics get_glyph_ids(text: string, font_name: string, style: string, weight: number): Uint16Array font_has_glyph(font_name: string, style: string, codepoint: number): boolean get_advance_widths(font_name, style, weight, opsz, glyph_ids: Uint16Array): Float64Array get_vertical_advance(font_name, style, weight, opsz, gid: number): number measure_string_width(text, font_name, style, weight, opsz, font_size: number): number Per-character glyph lookup, coverage checks, and advance widths (1000 units/em). measure_string_width uses real shaping internally when available, so ligatures and kerning are reflected in the measurement, not just summed per-character widths. Shaping shape_text(text, font_name, style, weight, opsz, vertical: boolean): { glyphs: Uint16Array, advances: Float64Array, clusters: Uint32Array } | null Full GSUB/GPOS shaping via rustybuzz. clusters maps each output glyph back to the character index it came from, for cursor placement or highlighting. Returns null for fonts rustybuzz can't open – fall back to get_glyph_ids + get_advance_widths in that case. Subsetting subset_font_full(font_name, style, weight, opsz, glyph_ids: Uint16Array): { fontBytes, glyphMap, isCff, ascender, descender, capHeight, bbox, flags, italicAngle, fontName } Give it the glyph IDs a document actually uses (typically shape_text(...).glyphs), get back a font file containing only those glyphs plus the metrics needed to embed it. glyphMap is the old→new glyph ID remap for TrueType subsets (Uint16Array); it's null for CFF subsets, which blank out unused glyphs in place instead of renumbering. Color fonts get_glyph_bitmap(font_name, style, gid, target_ppem): { png, ppem, originX, originY } | null get_colr_layers(font_name, style, gid): Uint32Array get_glyph_bitmap pulls the nearest-size PNG strike from sbix or CBDT/CBLC. get_colr_layers returns a flattened [gid, r, g, b, a, isForeground, ...] array (6 values per layer) for COLR v0 glyphs; empty when the glyph has no color layers. End-to-end example Examples below import the bare specifier taetype (npm i taetype) – exports in package.json routes browsers/bundlers to the web build and Node.js to the node build automatically, same import either way. Building from source instead? Swap taetype for ./pkg/web/taetype.js or ./pkg/node/taetype.js (see Building). Browser TTF/OTF, the simplest path: import init, { register_font_raw, shape_text, subset_font_full } from 'taetype';

await init();

register_font_raw('MyFont', fontBytes); // Uint8Array of a .ttf/.otf file

const shaped = shape_text('Hello, world', 'MyFont', 'normal', 400, 0, false); const subset = subset_font_full('MyFont', 'normal', 400, 0, shaped.glyphs); // subset.fontBytes is a font file containing only the glyphs used above WOFF2, decompressing the Brotli stream yourself first: import init, { get_compressed_range, decompress_brotli, register_font, shape_text } from 'taetype';

await init();

const { start, length } = get_compressed_range(woff2Bytes); // Uint8Array of a .woff2 file const decompressed = decompress_brotli(woff2Bytes.slice(start, start + length));

register_font('MyFont', 'normal', 400, 0, woff2Bytes, decompressed, 0);

const shaped = shape_text('Hello, world', 'MyFont', 'normal', 400, 0, false); Node.js No init() step – requiring the module loads the WASM synchronously: const { register_font_raw, shape_text, subset_font_full } = require('taetype'); const fs = require('fs');

const fontBytes = fs.readFileSync('MyFont.ttf'); register_font_raw('MyFont', fontBytes);

const shaped = shape_text('Hello, world', 'MyFont', 'normal', 400, 0, false); const subset = subset_font_full('MyFont', 'normal', 400, 0, shaped.glyphs); // subset.fontBytes is a font file containing only the glyphs used above License MIT – see LICENSE.

source

github.com · 110 links

Comments (0)

No comments yet