sdxc

[ Content & formats ]

@sdxc/font

TrueType font reader: glyph lookup, metrics, kerning, outlines and line layout

npm add @sdxc/font
pnpm add @sdxc/font
yarn add @sdxc/font
bun add @sdxc/font
Installs with
@sdxc/result
Used by
blog

TrueType font reader: glyph lookup, metrics, kerning, outlines and line layout, in plain JavaScript with no WebAssembly.

Installation

npm add @sdxc/font @sdxc/result

parseFont answers a Result from @sdxc/result.

Usage

import { parseFont } from "@sdxc/font";
import { isFailure } from "@sdxc/result";

let result = parseFont(await (await fetch(fontUrl)).arrayBuffer());
if (isFailure(result)) throw result.error;
let font = result.data;

font.measure("Hello, world", 32); // width in pixels

Lay out a line and turn each glyph into a pixel-space path for a rasterizer to fill:

let line = font.layout("Sign and verify JWTs", 76, { tracking: -3 });
for (let { glyph, x } of line.glyphs) {
	let commands = font.path(glyph, 76, left + x, baseline);
	// commands: { type: "move" | "line", x, y } | { type: "quad", cx, cy, x, y } | { type: "close" }
}

API

parseFont(bytes: Uint8Array | ArrayBuffer): Result<Font, FontError>

Reads a .ttf (OpenType with glyf outlines). Opening a font reads only the table headers; glyphs are decoded on first use. Fails with FontError whose code is "unsupported" (CFF OTTO fonts, ttcf collections, no Unicode cmap), "missing-table" or "malformed", and whose table names the table at fault.

Font

  • unitsPerEm, ascender, descender (negative), lineGap — font units, from OS/2 when the font sets USE_TYPO_METRICS, hhea otherwise. family and subfamily come from name; glyphCount is the number of glyphs.

  • glyphIndex(codePoint) — the glyph for a character, 0 (.notdef) when the font has none.

  • advance(glyph) and kerning(left, right) — font units. Kerning reads the kern table, or GPOS pair positioning when the font has no kern table.

  • outline(glyph) — Font.Command[] in font units, y up, composite glyphs resolved.

  • path(glyph, size, x, y) — the outline at size pixels per em, y down, with (x, y) on the baseline.

  • layout(text, size, { tracking?, kerning? }) — { glyphs: { glyph, codePoint, x, advance }[], width } in pixels. tracking adds pixels between glyphs; kerning defaults to true.

  • measure(text, size, options?) — layout(...).width without the glyph list.

Layout maps one code point to one glyph: no ligatures, no GPOS mark positioning, no bidirectional text.

Pattern: Wrap a paragraph to a width

import type { Font } from "@sdxc/font";

function wrap(font: Font, text: string, size: number, maxWidth: number): string[] {
	let lines: string[] = [];
	let line = "";
	for (let word of text.split(/\s+/)) {
		let candidate = line === "" ? word : `${line} ${word}`;
		if (line !== "" && font.measure(candidate, size) > maxWidth) {
			lines.push(line);
			line = word;
		} else line = candidate;
	}
	if (line !== "") lines.push(line);
	return lines;
}

let lineHeight = ((font.ascender - font.descender + font.lineGap) / font.unitsPerEm) * 48;

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.