Files
skills/docs/media/fonts/font-decompress/src/unpacker/binary-font-unpacker/binary-font-unpacker.md
2026-05-29 08:20:56 +01:00

5.4 KiB
Raw Blame History

unpacker/binary-font-unpacker

(no submodules)

Purpose

BinaryFontUnpacker reverses the BTF (Binary TypeFace) wire format produced by BinaryFontPacker from @suaveplan/font-compress, returning a TypefaceFont object (from @suaveplan/font-convert) ready for Three.js's FontLoader, R3F text rendering, or any other typeface.js-compatible consumer. The unpacker is the runtime side of the BTF pipeline: build-time tooling packs once, then ships the compact Uint8Array; renderers unpack on demand.

The class is intentionally self-contained — no helpers imported from @suaveplan/font-compress, no shared mutable state. Boundary validation runs at every read so malformed or truncated buffers raise FontDecompressError synchronously rather than corrupting the returned font object.

Features

  • Round-trips every field a BinaryFontPacker writes: header strings, reversed flag, dictionary, metrics, glyphs, glyph outlines
  • Handles all five glyph commands (m/l/b/q/z) per the BTF coordinate-count table
  • Validates every buffer read for truncation; raises FontDecompressError with FONT_DECOMPRESS.INVALID_DATA
  • Wraps any underlying primitive exception (RangeError, TypeError from DataView/TextDecoder) as FontDecompressError with FONT_DECOMPRESS.UNPACK_FAILED while preserving cause
  • Stateless — a single instance is safe to reuse across many unpack(...) calls
  • Universal — no node:/bun: imports

Usage: Basic

import {
    BinaryFontUnpacker,
    createBinaryFontUnpacker,
} from "@suaveplan/font-decompress";

// Load BTF bytes from anywhere — fetch, fs, memory, etc.
const btfBuffer: Uint8Array = await fetch("/fonts/inter.btf")
    .then((r) => r.arrayBuffer())
    .then((b) => new Uint8Array(b));

const font = createBinaryFontUnpacker().unpack(btfBuffer);

// Feed `font` to Three.js's FontLoader.parse(font) or R3F's <Text3D font={font} />
console.log(font.familyName, font.glyphs["A"]?.ha);

Usage: Advanced

import {
    BinaryFontUnpacker,
    FONT_DECOMPRESS_CODES,
    FontDecompressError,
} from "@suaveplan/font-decompress";

const unpacker = new BinaryFontUnpacker(); // safe to reuse

async function loadFontWithFallback(
    url: string,
    fallback: Uint8Array,
): Promise<ReturnType<BinaryFontUnpacker["unpack"]>> {
    try {
        const buf = new Uint8Array(
            await (await fetch(url)).arrayBuffer(),
        );
        return unpacker.unpack(buf);
    } catch (err) {
        if (
            err instanceof FontDecompressError &&
            (err.code === FONT_DECOMPRESS_CODES.UNPACK_FAILED ||
                err.code === FONT_DECOMPRESS_CODES.INVALID_DATA)
        ) {
            // BTF corrupted in transit; fall back to the bundled font
            return unpacker.unpack(fallback);
        }
        throw err;
    }
}

API Reference

class BinaryFontUnpacker

Method Signature
unpack (data: Uint8Array) => TypefaceFont

createBinaryFontUnpacker(): BinaryFontUnpacker

Convenience factory equivalent to new BinaryFontUnpacker(). Use it when you prefer functional composition over new.

Error contract

Condition Code
Truncated buffer at any header / dictionary / glyph FONT_DECOMPRESS.INVALID_DATA
Out-of-range command byte (not 0–4) FONT_DECOMPRESS.INVALID_DATA
Out-of-range dictionary index FONT_DECOMPRESS.INVALID_DATA
indexCount < 3 for a glyph (missing ha/x_min/x_max) FONT_DECOMPRESS.INVALID_DATA
Underlying DataView / TextDecoder throw FONT_DECOMPRESS.UNPACK_FAILED

All errors are FontDecompressError instances and carry cause when wrapping a primitive throw.

Implementation Notes

The unpacker walks data once, advancing a single offset cursor. Reads use a shared DataView constructed over data.buffer + data.byteOffset so subarray inputs work without an extra slice. Every read goes through one of three private helpers:

  • readUint32(data, offset) — little-endian uint32 read
  • assertRemaining(data, offset, need, field) — throws INVALID_DATA if offset + need exceeds data.length, naming the field for diagnostics
  • dictAt(dict, index, field) — throws INVALID_DATA if index is out of range; otherwise returns dict[index]

The shared decodeString helper (in utils/decode-string.ts) handles the three header strings with the same boundary validation.

The BYTE_TO_CMD table (["m", "l", "b", "q", "z"]) and CMD_COORD_COUNTS map (m: 2, l: 2, b: 6, q: 4, z: 0) come from the package-local constants/index.ts module — a verbatim duplicate of the constants in @suaveplan/font-compress. Duplicating 19 lines of format magic keeps each package self-contained at the cost of zero maintenance burden (the BTF wire format is locked).

original_font_information is initialised to {} because the BTF format does not carry it — the field is preserved on the TypefaceFont shape for downstream compatibility but is always empty on the round-trip side.