first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
# `fonts`
Font packing, compression, subsetting, and conversion tools.
<!-- begin:auto-docs -->
## Packages in `fonts`
4 packages.
| Package | Description |
|---|---|
| [`@suaveplan/font-compress`](./font-compress/README.md) | BTF (Binary TypeFace) binary packing and unpacking. Converts `TypefaceFont` (typeface.js JSON format) to a compact `Uint8Array` using dictionary-based integer… |
| [`@suaveplan/font-convert`](./font-convert/README.md) | Converts TTF/OTF/WOFF/WOFF2 font files to the typeface.js JSON format used by Three.js `FontLoader`. Also defines the canonical `TypefaceFont`, `TypefaceGlyph`… |
| [`@suaveplan/font-decompress`](./font-decompress/README.md) | BTF (Binary TypeFace) binary unpacking. Reverses `@suaveplan/font-compress`'s `BinaryFontPacker` output into a typeface.js JSON shape ready for Three.js / R3F… |
| [`@suaveplan/font-subset`](./font-subset/README.md) | Font subsetting and compression library with a codec-agnostic adapter interface. Supports Unicode range subsetting, WOFF2/WOFF/OTF/TTF output, OpenType feature… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Font packing, compression, subsetting, and conversion tools.

View File

@@ -0,0 +1,111 @@
# @suaveplan/font-compress - BTF (Binary TypeFace) binary packing — packer-only since the 2026-05-17 split. Converts typeface.js JSON to compact Uint8Array using dictionary-based compression. Pairs with @suaveplan/font-decompress for the runtime unpack side.
## Summary
BTF (Binary TypeFace) binary packing and unpacking. Converts `TypefaceFont` (typeface.js JSON format) to a compact `Uint8Array` using dictionary-based integer compression, and reverses it.
Designed for Three.js / game-engine font pipelines where fonts must be shipped as efficient binary assets.
<!-- begin:auto-docs -->
## Documentation tree
- [auto-boot](./src/bin/auto-boot.md) — Pure side-effect entry imported by `suaveplan-font-compress.ts` for the shebang-launch case. The only behaviour is: if…
- [dispatch](./src/bin/dispatch.md) — Per-file orchestration extracted from the bin entry. Reads a single input file from disk, routes binary inputs (TTF/OTF…
- [format-utils](./src/bin/format-utils.md) — Pure formatting helpers backing the operator-facing output of the `suaveplan-font-compress` bin. The helpers exist so t…
- [parser](./src/bin/parser.md) — Bin-specific wrapper around `@suaveplan/cli`'s generic `parseArgs` that enforces the `suaveplan-font-compress` flag set…
- [printer](./src/bin/printer.md) — Stdout / stderr renderers used by the `suaveplan-font-compress` bin. Owns the help screen, banner, per-file progress li…
- [suaveplan-font-compress](./src/bin/suaveplan-font-compress.md) — Operator-facing CLI that wires `@suaveplan/font-convert` (`FontConverter` / `BatchConverter`) into this package's `Bina…
- [error-codes](./src/error-codes/error-codes.md) — `FontCompressError` is the structured error type for the `@suaveplan/font-compress` package, extending `SuaveplanError`…
- [error-codes](./src/error-codes/error-codes.md) — `FontCompressError` is the structured error type for the `@suaveplan/font-compress` package, extending `SuaveplanError`…
- [binary-font-packer](./src/packer/binary-font-packer/binary-font-packer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [binary-font-packer](./src/packer/binary-font-packer/binary-font-packer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [encode-string](./src/utils/encode-string.md) — Utilities for encoding and decoding length-prefixed UTF-8 strings in the BTF binary format. They provide the wire encod…
- [parse-path](./src/utils/parse-path.md) — Parses a typeface.js outline string into an array of structured command objects. The typeface.js format encodes glyph o…
<!-- end:auto-docs --> - [binary-font-packer](./src/packer/binary-font-packer/binary-font-packer.md) — <!-- begin:auto-docs --> _(no submodules)_- [binary-font-unpacker](./src/unpacker/binary-font-unpacker/binary-font-unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_- [binary-font-unpacker](./src/unpacker/binary-font-unpacker/binary-font-unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_- [encode-string](./src/utils/encode-string.md) — Utilities for encoding and decoding length-prefixed UTF-8 strings in the BTF binary format. They provide the wire encod…
- [parse-path](./src/utils/parse-path.md) — Parses a typeface.js outline string into an array of structured command objects. The typeface.js format encodes glyph o…
- [error-codes](./src/error-codes.md) — `FontCompressError` is the structured error type for the `@suaveplan/font-compress` package, extending `SuaveplanError`…
- [binary-font-packer](./src/packer/binary-font-packer/binary-font-packer.md) — <!-- begin:auto-docs --> _(no submodules)_- [binary-font-unpacker](./src/unpacker/binary-font-unpacker/binary-font-unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_- [binary-font-unpacker](./src/unpacker/binary-font-unpacker/binary-font-unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_- [encode-string](./src/utils/encode-string.md) — Utilities for encoding and decoding length-prefixed UTF-8 strings in the BTF binary format. They provide the wire encod…
- [parse-path](./src/utils/parse-path.md) — Parses a typeface.js outline string into an array of structured command objects. The typeface.js format encodes glyph o…
- [error-codes](./src/error-codes.md) — `FontCompressError` is the structured error type for the `@suaveplan/font-compress` package, extending `SuaveplanError`…
- [binary-font-packer](./src/packer/binary-font-packer/binary-font-packer.md) — <!-- begin:auto-docs --> _(no submodules)_- [binary-font-unpacker](./src/unpacker/binary-font-unpacker/binary-font-unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_- [binary-font-unpacker](./src/unpacker/binary-font-unpacker/binary-font-unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_- [encode-string](./src/utils/encode-string.md) — Utilities for encoding and decoding length-prefixed UTF-8 strings in the BTF binary format. They provide the wire encod…
- [parse-path](./src/utils/parse-path.md) — Parses a typeface.js outline string into an array of structured command objects. The typeface.js format encodes glyph o…
- [error-codes](./src/error-codes.md) — `FontCompressError` is the structured error type for the `@suaveplan/font-compress` package, extending `SuaveplanError`…
- [binary-font-packer](./src/packer/binary-font-packer/binary-font-packer.md) — <!-- begin:auto-docs --> _(no submodules)_- [binary-font-unpacker](./src/unpacker/binary-font-unpacker/binary-font-unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_ - [binary-font-unpacker](./src/unpacker/binary-font-unpacker/binary-font-unpacker.md) — <!-- begin:auto-docs --> _(no submodules)_- [encode-string](./src/utils/encode-string.md) — Utilities for encoding and decoding length-prefixed UTF-8 strings in the BTF binary format. They provide the wire encod…
- [parse-path](./src/utils/parse-path.md) — Parses a typeface.js outline string into an array of structured command objects. The typeface.js format encodes glyph o…
- [error-codes](./src/error-codes.md) — `FontCompressError` is the structured error type for the `@suaveplan/font-compress` package, extending `SuaveplanError`…
## Packages
| Module | Description |
|--------|-------------|
| [`BinaryFontPacker`](src/packer/binary-font-packer/binary-font-packer.md) | Packs `TypefaceFont` → BTF `Uint8Array` |
| [`BinaryFontUnpacker`](src/unpacker/binary-font-unpacker/binary-font-unpacker.md) | Unpacks BTF `Uint8Array` → `TypefaceFont` |
| [`FontCompressError`](src/error-codes.md) | Structured error with stable codes |
| [`parsePath`](src/utils/parse-path.md) | Tokenises typeface.js outline strings |
| [`encodeString`/`decodeString`](src/utils/encode-string.md) | Length-prefixed UTF-8 string encoding |
## Install
```bash
bun add @suaveplan/font-compress @suaveplan/font-convert
```
## Quick Start
```typescript
import { BinaryFontPacker, BinaryFontUnpacker } from "@suaveplan/font-compress";
import type { TypefaceFont } from "@suaveplan/font-convert";
const font: TypefaceFont = /* from @suaveplan/font-convert */;
// Pack to binary
const packed = new BinaryFontPacker().pack(font);
// Unpack back to TypefaceFont
const restored = new BinaryFontUnpacker().unpack(packed);
```
## BTF Binary Format
```text
[familyName: uint32-len + UTF-8]
[cssFontWeight: uint32-len + UTF-8]
[cssFontStyle: uint32-len + UTF-8]
[reversed: uint8]
[dictLen: uint32 LE]
[dict: Int16Array × dictLen] ← all unique integer values
[metricsIdxs: Uint16Array × 9] ← ascender,descender,ulPos,ulThk,res,yMin,xMin,yMax,xMax
[glyphCount: uint32 LE]
per-glyph:
[charCode: uint32 LE]
[indexCount: uint32 LE]
[indexes: Uint16Array × indexCount] ← ha,x_min,x_max, then cmd_byte+coord_indexes
```
Command bytes: `0=m`, `1=l`, `2=b(cubic)`, `3=q(quadratic)`, `4=z`.
## CLI
The package ships a `suaveplan-font-compress` bin that composes
[`@suaveplan/font-convert`](../font-convert/README.md) (`FontConverter` /
`BatchConverter`) with `BinaryFontPacker` to turn TTF/OTF/WOFF/WOFF2 (or
already-converted typeface JSON) into compact `.btf` artefacts. See
[`src/bin/suaveplan-font-compress.md`](./src/bin/suaveplan-font-compress.md)
for the full flag reference, exit-code table, and in-process API.
End-to-end example:
```sh
# Bake every font under ./fonts into ./out (mirrored layout, BTF default).
bunx suaveplan-font-compress ./fonts -o ./out
# Inspect the typeface intermediate (skips the BTF pack step).
bunx suaveplan-font-compress ./fonts/Inter.ttf -o ./debug --format=json
# Overwrite existing outputs.
bunx suaveplan-font-compress ./fonts -o ./out --force
```
Exit codes: `0` success, `1` per-file failure(s), `2` invalid CLI args,
`3` missing input path.

View File

@@ -0,0 +1,69 @@
# auto-boot
## Purpose
Pure side-effect entry imported by `suaveplan-font-compress.ts` for the
shebang-launch case. The only behaviour is: if the running file is the
`dist/bin/suaveplan-font-compress.js` binary (detected by the `argv[1]`
basename), dynamically import the bin entry and run `main(process.argv.slice(2))`,
then `process.exit` with the resulting code. The split exists so the
`process.exit` branch — which cannot be honestly exercised from in-process
without spawning a subprocess — can be excluded from coverage as a pure
boot stub while the surrounding `main()` (which does the actual work) is
fully covered by the in-process suites in this directory.
## Features
- Detects direct invocation via `process.argv[1]` rather than
`fileURLToPath(import.meta.url)` so the check works regardless of the
module's own location (auto-boot is a sibling of the entry, not the
entry itself).
- Dynamic import of `./suaveplan-font-compress.js` so this module's static
init does not depend on the bin entry's static init, avoiding any
cyclic-import surprises with bundlers.
- Maps a successful `main()` resolve to `process.exit(code)`; maps a
rejected promise to a stderr-printed message and `process.exit(1)`.
## Usage: Basic
The module has no exports — importing it as a side-effect is the entire
API:
```ts
// In suaveplan-font-compress.ts
import "./auto-boot.js";
```
## Usage: Advanced
```sh
# Direct shebang launch — boot runs through this module.
node dist/bin/suaveplan-font-compress.js ./fonts -o ./out
# In-process import — boot detects argv[1] does not end with the binary
# name and stays silent; tests can then call `main()` directly without
# accidentally launching a second instance.
import { main } from "@suaveplan/font-compress/bin/suaveplan-font-compress";
await main(["./fonts", "-o", "./out"]);
```
## API Reference
No public API. The module's side effect runs once at import time:
1. Check `process.argv[1]` for the binary basename suffix.
2. If matched, dynamically import the bin entry and call its `main`.
3. Map the resolved exit code to `process.exit(code)`; map a rejection
to stderr write + `process.exit(1)`.
## Implementation Notes
`process.argv[1].endsWith("suaveplan-font-compress.js")` is intentionally
basename-based — the absolute path comparison used by single-file bin
patterns does not work here because auto-boot is a sibling, not the entry.
The `try/catch` around the check guards against environments where
`process` is undefined (some non-Node runtimes) and against any pathologic
`argv` shapes; both fall back to "not direct invocation" which is the safe
default for an import-only module. Coverage excludes this file because
every branch is either a `process.argv` predicate or a `process.exit`
side-effect — neither can be honestly faked from in-process.

View File

@@ -0,0 +1,77 @@
# dispatch
## Purpose
Per-file orchestration extracted from the bin entry. Reads a single input
file from disk, routes binary inputs (TTF/OTF/WOFF/WOFF2) through the
injected `FontConverter` to produce a `TypefaceFont`, routes `.json` inputs
through pass-through parsing (no conversion), then either serialises the
`TypefaceFont` back to JSON (when `--format=json`) or packs it via
`BinaryFontPacker` and writes the resulting `.btf`. Progress, skip, and
error events are streamed through the injected printer / logger. Lives in
its own module so the entry can stay small enough to satisfy the file-size
budget while keeping a single round-trip testable in isolation.
## Features
- Discriminates input by extension: `.ttf`/`.otf`/`.woff`/`.woff2` goes to
the converter; `.json` goes to pass-through validation.
- Computes destination paths honouring `--preserve-structure` (mirror the
input tree under the output root) vs the flat default.
- Honours `--force`: existing outputs are skipped silently unless `--force`
is set, in which case they are overwritten.
- Captures any throwable from the converter or packer per file, prints a
failure line, logs through `LoggerLike`, and returns `{ ok: false }` so
the batch can continue and the entry exits `1`.
## Usage: Basic
```ts
import { FontConverter } from "@suaveplan/font-convert";
import { BinaryFontPacker } from "../packer/binary-font-packer/binary-font-packer.js";
import { dispatchOne } from "./dispatch.js";
const outcome = await dispatchOne(
"./fonts/Inter-Regular.ttf",
new FontConverter(),
new BinaryFontPacker(),
options,
"./fonts",
"./out",
deps
);
// outcome.ok === true, outcome.compressedBytes > 0
```
## Usage: Advanced
```ts
const failures: string[] = [];
for (const file of files) {
const r = await dispatchOne(file, converter, packer, options, root, out, deps);
if (!r.ok) failures.push(file);
}
if (failures.length > 0) {
deps.logger.error("font-compress.cli.batch_partial", { failures });
}
```
## API Reference
| Name | Signature | Notes |
|---|---|---|
| `dispatchOne` | `(file, converter, packer, options, inputRoot, outputRoot, deps) => Promise<FileOutcome>` | Never throws; all failures are reported via the returned outcome. |
| `FileOutcome` | `{ ok; originalBytes; compressedBytes }` | `compressedBytes === 0` means the file was skipped without overwrite. |
| `DispatchDeps` | `{ logger; stdout; stderr }` | Injected so a test can capture stream output. |
## Implementation Notes
Destination resolution intentionally does NOT call `path.relative` —
prefix-strip plus `replace(/^[\\/]+/)` is enough and lets us mirror the
upstream legacy semantics where Windows-style separators round-trip
unchanged. The dispatcher always creates the parent directory with
`mkdir({ recursive: true })` before writing, so users do not need to
pre-create the output tree. JSON inputs are validated by `JSON.parse` only —
the typeface schema is enforced downstream by `BinaryFontPacker`, which
throws `FontCompressError(PACK_FAILED)` for malformed shapes, and the catch
arm here converts that into a clean per-file failure line.

View File

@@ -0,0 +1,86 @@
# format-utils
## Purpose
Pure formatting helpers backing the operator-facing output of the
`suaveplan-font-compress` bin. The helpers exist so the bin entry can focus on
orchestration (argument parsing, dispatch, FS I/O) while every cosmetic detail
— byte sizing, compression ratio, ANSI tone wrappers — lives in a small,
exhaustively testable module. Keeping them separate also keeps the bin entry
under the AGENTS.md §3 file-size budget and makes the renderer reusable for
custom hosts (CI dashboards, web previews) that want to share the same
output format the operator sees on stdout.
## Features
- `formatBytes(bytes)` — human-readable size string (`B`/`KB`/`MB`/`GB`) with
two-decimal rounding and graceful handling of zero, negative, and non-finite
inputs.
- `formatRatio(original, compressed)` — compression ratio rendered as `Nx`
with one-decimal precision; returns `0.0x` for any non-positive denominator.
- `formatSavings(original, compressed)` — percentage of bytes saved by
compression, rounded to one decimal.
- `ANSI` — frozen palette of the eight terminal escape codes used by the bin
(reset, bold, dim, red, green, yellow, blue, cyan).
- `colour(code, text)` — wrap a string between an ANSI prefix and the reset
sequence.
- `tint.*` — convenience wrappers (`tint.bold`, `tint.green`, …) for the
common combinations.
## Usage: Basic
```ts
import { formatBytes, formatRatio, tint } from "@suaveplan/font-compress/bin/suaveplan-font-compress";
console.log(`Compressed: ${formatBytes(2048)}`); // "Compressed: 2 KB"
console.log(tint.green(`Ratio: ${formatRatio(1000, 250)}`)); // green "Ratio: 4.0x"
```
## Usage: Advanced
```ts
import {
ANSI,
colour,
formatBytes,
formatRatio,
formatSavings,
} from "./format-utils.js";
function renderRow(name: string, original: number, packed: number): string {
return [
colour(ANSI.bold, name.padEnd(20)),
formatBytes(original).padStart(10),
formatBytes(packed).padStart(10),
colour(ANSI.green, formatRatio(original, packed).padStart(8)),
colour(ANSI.dim, formatSavings(original, packed).padStart(8)),
].join(" ");
}
renderRow("Inter-Regular.ttf", 287_000, 64_500);
```
## API Reference
| Name | Signature | Notes |
|---|---|---|
| `formatBytes` | `(bytes: number) => string` | Clamps to `GB` for inputs ≥ 1 TiB so it never indexes past the unit table. |
| `formatRatio` | `(orig: number, packed: number) => string` | Guards against zero / negative / non-finite values. |
| `formatSavings` | `(orig: number, packed: number) => string` | Returns `0.0%` for any non-positive original. |
| `ANSI` | `Readonly<Record<string, string>>` | The eight escape codes used by the bin. |
| `colour` | `(code: string, text: string) => string` | Always closes with `ANSI.reset`. |
| `tint.*` | `(text: string) => string` | One pre-bound `colour` wrapper per palette entry. |
## Implementation Notes
The helpers are pure, side-effect-free, and runtime-agnostic — they take and
return strings/numbers only, never read globals, and never touch I/O. This is
deliberate: the bin entry composes them into stdout writes through an
injected `LoggerLike`, so the same helpers can be used from a hosted UI that
captures the output to a buffer or DOM node. ANSI escape sequences are
emitted unconditionally; callers that need plain text strip them at the
output boundary rather than asking each helper to branch on a `useColour`
flag. Inputs that violate the contract (`NaN`, negative bytes) fall back to a
benign sentinel (`"0 B"`, `"0.0x"`, `"0.0%"`) so a single corrupt summary
record never propagates `NaN` into the operator's terminal — defensive but
honest.

View File

@@ -0,0 +1,74 @@
# parser
## Purpose
Bin-specific wrapper around `@suaveplan/cli`'s generic `parseArgs` that
enforces the `suaveplan-font-compress` flag set, detects mutually-exclusive
flag pairs, and converts any upstream parser error into a coded
`FontCompressError(CLI_INVALID_ARGS)` so the entry's top-level handler can
deterministically map the failure to exit code `2`. Isolated from the entry
so the parser can be unit-tested in milliseconds without spinning up the FS
pipeline.
## Features
- Recognises every flag in the spec delta: `--input`/`-i`, `--output`/`-o`,
`--format=btf|json`, `--force`/`-f`, `--recursive`/`-r`, `--no-recursive`,
`--preserve-structure`, `--no-preserve-structure`, `--help`/`-h`,
`--version`/`-v`, and the positional `<input>`.
- Detects conflicts between `--recursive` and `--no-recursive` and between
the two `--preserve-structure` variants and throws the coded error before
any FS work happens.
- Maps unknown long flags (`--no-such-flag`) and unknown short flags
(`-X`) into `FontCompressError(CLI_INVALID_ARGS)` with the offending flag
captured in `error.context.flag`.
- Returns a frozen-shape `CliOptions` discriminator that downstream
helpers (`dispatch.ts`, the entry's pipeline) can consume with no
further casting.
## Usage: Basic
```ts
import { parseCliArgs } from "./parser.js";
const options = parseCliArgs(["./fonts", "-o", "./out", "--format=json"]);
// → { input: "./fonts", output: "./out", format: "json", … }
```
## Usage: Advanced
```ts
import { FONT_COMPRESS_CODES, FontCompressError } from "../error-codes.js";
import { parseCliArgs } from "./parser.js";
try {
parseCliArgs(["./fonts", "--recursive", "--no-recursive"]);
} catch (err) {
if (
err instanceof FontCompressError &&
err.code === FONT_COMPRESS_CODES["CLI_INVALID_ARGS"]
) {
// err.context.flags === ["--recursive", "--no-recursive"]
process.exit(2);
}
throw err;
}
```
## API Reference
| Name | Signature | Notes |
|---|---|---|
| `parseCliArgs` | `(argv: readonly string[]) => CliOptions` | Throws `FontCompressError(CLI_INVALID_ARGS)` on conflict / unknown flag. |
| `CliOptions` | discriminated record | Includes `input`, `output`, `outputSpecified`, `format`, `force`, `recursive`, `preserveStructure`, `help`, `version`. |
## Implementation Notes
The `--no-*` negations are handled inside the upstream parser (it sets the
camelCase key to `false`), so this wrapper detects only the *pair* of
contradictory flags being present. Unknown-flag detection runs after the
upstream parser throws because the upstream error message is too generic to
re-emit; we scan `argv` for the first long or short token that is not in the
allowlist and propagate it as `error.context.flag`. The `--input` long flag
takes precedence over the positional when both are present (which mirrors
upstream legacy and is documented in the spec delta source-mapping table).

View File

@@ -0,0 +1,79 @@
# printer
## Purpose
Stdout / stderr renderers used by the `suaveplan-font-compress` bin. Owns
the help screen, banner, per-file progress lines (success / skip / error),
and the end-of-run summary block. Every renderer writes through an injected
`NodeJS.WritableStream` so a host (test harness, CI dashboard, web preview)
can capture the output without monkey-patching the global `process.stdout`.
Lives in its own module so the entry can stay under the AGENTS.md §3 file
size budget while keeping the operator-visible output independently
inspectable and unit-testable.
## Features
- `HELP_TEXT` — the full `--help` screen, including every flag in the spec
delta plus the exit-code table.
- `printBanner` — one-shot summary printed before any per-file work,
showing input, output, format, and force state.
- `printSuccessLine` — `✓` line with the family name and either the
compressed byte count (`--format=json`) or the compression ratio
(`--format=btf`).
- `printSkipLine` — `⏭` line when an output already exists and `--force`
was not set.
- `printErrorLine` — `✗` line written to stderr with the file basename and
the failure message.
- `printSummary` — block with total / converted / errors / original size /
compressed size / ratio / savings, terminated by the output path.
## Usage: Basic
```ts
import { HELP_TEXT, printBanner, printSummary } from "./printer.js";
process.stdout.write(HELP_TEXT);
printBanner(process.stdout, "/fonts", "/out", "btf", false);
printSummary(process.stdout, summary, "/out");
```
## Usage: Advanced
```ts
import { Writable } from "node:stream";
import { printSummary } from "./printer.js";
const chunks: string[] = [];
const captured = new Writable({
write(chunk, _enc, cb) {
chunks.push(chunk.toString("utf8"));
cb();
},
});
printSummary(captured, summary, "/out");
// chunks.join("") now holds the rendered block — assert in tests / pipe
// to a CI dashboard webhook.
```
## API Reference
| Name | Signature | Notes |
|---|---|---|
| `HELP_TEXT` | `string` | Pre-rendered help screen (ANSI tones included). |
| `printBanner` | `(stdout, inputPath, outputPath, format, force) => void` | Writes the run-start block. |
| `printSuccessLine` | `(stdout, font, originalBytes, compressedBytes, format) => void` | Emits `✓` line; format choice changes the tail. |
| `printSkipLine` | `(stdout, file) => void` | Emits `⏭` for already-existing outputs. |
| `printErrorLine` | `(stderr, file, message) => void` | Emits `✗` to stderr. |
| `printSummary` | `(stdout, summary, outputRoot) => void` | Emits the end-of-run block. |
| `RunSummary` | record | `{ totalFiles, successCount, errorCount, totalOriginalSize, totalCompressedSize }`. |
## Implementation Notes
ANSI tones come from `format-utils.ts` so the palette is single-sourced.
Every line is composed by `Array#join("\n")` rather than template literals
with leading newlines — the test harness can split on `\n` and assert on
exact strings without worrying about stray indentation. The summary block
always renders the savings percentage even when no bytes were processed
(`formatSavings` falls back to `"0.0%"` for non-positive inputs) so the
output shape stays stable when an operator runs the bin against an empty
directory.

View File

@@ -0,0 +1,123 @@
# suaveplan-font-compress
## Purpose
Operator-facing CLI that wires `@suaveplan/font-convert`
(`FontConverter` / `BatchConverter`) into this package's
`BinaryFontPacker` to convert TTF/OTF/WOFF/WOFF2 (and already-converted
typeface JSON) into compact `.btf` artefacts on disk. The CLI is the
shipping shape of the `@suaveplan/font-compress` package's "build-time
asset baker" surface and is invoked from package scripts, CI workflows, and
ad-hoc developer workstations — the underlying library
(`BinaryFontPacker` / `BinaryFontUnpacker`) remains the supported in-process
surface for runtime callers that already hold a `TypefaceFont` in memory.
## Features
- Single-file or whole-directory input via positional `<input>` or
`--input`/`-i`.
- Recursive directory walk (default `true`; disable with `--no-recursive`).
- Layout strategy: mirror the source tree (`--preserve-structure`) or flatten
to the output root (the default when `--output` is set).
- Format dispatch: emit packed BTF binaries (default `--format=btf`) or the
intermediate typeface JSON (`--format=json`) for pipeline debugging.
- `--force`/`-f` to overwrite existing outputs; otherwise existing files are
skipped with a `⏭` line.
- Structured exit codes (`0`/`1`/`2`/`3`) with the meaning pinned in the
spec delta.
- Coded errors: argument-parsing failures throw
`FontCompressError(FONT_COMPRESS.CLI_INVALID_ARGS)` which the top-level
handler maps to exit `2`.
- OTel span `font-compress.cli.run` emitted for every invocation with
`font-compress.files`, `font-compress.errors`, and `font-compress.exit_code`
attributes set as the run progresses.
- Injectable `LoggerLike` and `stdout`/`stderr` streams via the exported
`main(argv, deps)` so the CLI is testable in-process without spawning a
subprocess.
## Usage: Basic
```sh
# Compress a single file in place (writes Inter-Regular.btf next to it).
suaveplan-font-compress ./fonts/Inter-Regular.ttf
# Compress a whole directory recursively to a sibling output tree.
suaveplan-font-compress ./fonts -o ./out
```
## Usage: Advanced
```sh
# JSON intermediate — useful for inspecting what the converter emitted
# before the BTF pack step. Round-trips through the same packer.
suaveplan-font-compress ./fonts/Inter-Regular.ttf --format=json -o ./debug
# Force-overwrite existing outputs (the default is to skip with a ⏭ line).
suaveplan-font-compress ./fonts -o ./out --force
# Flatten the output tree under ./out, regardless of the input layout.
suaveplan-font-compress ./fonts -o ./out --no-preserve-structure
# In-process invocation from a Node host (no subprocess, captured streams).
import { main } from "@suaveplan/font-compress/bin/suaveplan-font-compress";
import { Writable } from "node:stream";
const out: string[] = [];
const sink = new Writable({
write(chunk, _enc, cb) {
out.push(chunk.toString("utf8"));
cb();
},
});
const code = await main(["./fonts", "-o", "./out"], { stdout: sink, stderr: sink });
process.exit(code);
```
## API Reference
| Flag | Type | Default | Notes |
|---|---|---|---|
| `<input>` | positional | required | File or directory. |
| `--input` / `-i` | string | unset | Alternative to positional. |
| `--output` / `-o` | string | input path | Output directory. |
| `--format` | `btf` \| `json` | `btf` | Output format. |
| `--force` / `-f` | boolean | `false` | Overwrite existing outputs. |
| `--recursive` / `-r` | boolean | `true` | Recurse directories. |
| `--no-recursive` | boolean | — | Disable recursion. |
| `--preserve-structure` | boolean | derived | Mirror source tree. |
| `--no-preserve-structure` | boolean | — | Flatten under output root. |
| `--help` / `-h` | boolean | `false` | Print usage and exit `0`. |
| `--version` / `-v` | boolean | `false` | Print version and exit `0`. |
### Exit codes
| Code | Meaning |
|---|---|
| `0` | All inputs processed successfully. |
| `1` | One or more inputs failed during conversion or packing. |
| `2` | Invalid CLI arguments (unknown flag, conflict, missing positional). |
| `3` | The input path does not exist on disk. |
### Exported in-process API
- `main(argv, deps?)` — returns the would-be exit code; never calls
`process.exit` itself when invoked in-process.
- `parseCliArgs(argv)` — pure parser; throws
`FontCompressError(CLI_INVALID_ARGS)` on conflict / unknown flag.
- `CliOptions`, `RunDeps` — discriminator and dep-bundle types.
## Implementation Notes
The pipeline always instantiates both `FontConverter` and `BatchConverter`
even on single-file inputs — the spec scenario "directory input uses
BatchConverter" is satisfied by the constructor side effect, and the
file-by-file loop keeps memory consumption bounded for very large font
trees instead of materialising every binary in a single `BatchConverter`
call. Output paths are computed by stripping the input root from the file
path and re-rooting at the output directory; this preserves the upstream
legacy semantics where Windows-style separators round-trip unchanged. The
top-level handler is the ONLY place that maps a thrown
`FontCompressError(CLI_INVALID_ARGS)` to exit `2`; deeper code paths never
own exit codes directly. Per-file failures from the converter or packer are
caught in `dispatch.ts` and surfaced as `{ ok: false }`, allowing the batch
to continue and the entry to exit `1` after the summary block.

View File

@@ -0,0 +1,78 @@
# FontCompressError / FONT_COMPRESS_CODES
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`FontCompressError` is the structured error type for the `@suaveplan/font-compress` package, extending `SuaveplanError` from `@suaveplan/error`. It carries a stable code from the `FONT_COMPRESS_CODES` registry, an optional `cause`, and an optional `context` object for structured diagnostics.
All public operations in the package (`BinaryFontPacker.pack`, `BinaryFontUnpacker.unpack`) throw `FontCompressError` on failure rather than raw `Error` or `RangeError`, enabling callers to branch on specific codes and forward structured error data to logging and observability systems.
## Features
- Three stable error codes: `PACK_FAILED`, `UNPACK_FAILED`, `INVALID_DATA`
- Codes are prefixed with the package domain: `FONT_COMPRESS.PACK_FAILED`
- Supports `cause` (original error) and `context` (arbitrary key/value metadata)
- Extends `SuaveplanError` for integration with the project error infrastructure
## Usage: Basic Example
```typescript
import { FontCompressError, FONT_COMPRESS_CODES } from "@suaveplan/font-compress";
try {
packer.pack(font);
} catch (err) {
if (err instanceof FontCompressError) {
console.error(err.code); // "FONT_COMPRESS.PACK_FAILED"
}
}
```
## Usage: Advanced Example
```typescript
import { FontCompressError, FONT_COMPRESS_CODES } from "@suaveplan/font-compress";
function safePack(font: TypefaceFont): Uint8Array | null {
try {
return packer.pack(font);
} catch (err) {
if (
err instanceof FontCompressError &&
err.code === FONT_COMPRESS_CODES["PACK_FAILED"]
) {
logger.error("pack failed", { code: err.code, cause: err.cause });
return null;
}
throw err;
}
}
```
## API Reference
### `FONT_COMPRESS_CODES`
Object with keys `PACK_FAILED`, `UNPACK_FAILED`, `INVALID_DATA`, each mapping to its namespaced string code.
### `FontCompressErrorCode`
Union type of all code values in `FONT_COMPRESS_CODES`.
### `FontCompressError`
Extends `SuaveplanError<FontCompressErrorCode>`.
**Constructor:** `new FontCompressError(code, message, options?)`
- `code` — one of the `FONT_COMPRESS_CODES` values
- `message` — human-readable description
- `options.cause` — original `Error` (stored on `.cause`)
- `options.context` — additional `Record<string, unknown>` metadata
## Implementation Notes
`SuaveplanError` uses a `protected constructor`, so `FontCompressError` must declare an explicit `public constructor` that calls `super()`. This is a pattern used consistently across all `@suaveplan/*` error classes.

View File

@@ -0,0 +1,99 @@
# BinaryFontPacker
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`BinaryFontPacker` converts a `TypefaceFont` (the typeface.js JSON structure) into a compact binary BTF (Binary TypeFace) buffer. It builds a shared integer dictionary from all unique coordinate values in the font and encodes every number as a 16-bit dictionary index rather than a string, dramatically reducing the payload compared to raw JSON.
The BTF format is designed for Three.js and game-engine font pipelines where fonts must be shipped as efficient binary assets rather than verbose JSON strings.
## Features
- Dictionary-based compression: all unique integer values stored once in an `Int16Array`; all subsequent references are 16-bit indexes
- Full font round-trip: header strings, reversed flag, 9 scalar metrics, bounding box, per-glyph ha/x_min/x_max, and full outline data
- Supports all typeface.js command types: `m`, `l`, `b` (cubic), `q` (quadratic), `z`
- Little-endian byte layout throughout for cross-platform compatibility
- Wraps internal errors in `FontCompressError` with stable code `PACK_FAILED`
## Usage: Basic Example
```typescript
import { createBinaryFontPacker } from "@suaveplan/font-compress/packer/binary-font-packer";
import type { TypefaceFont } from "@suaveplan/font-convert";
const font: TypefaceFont = { /* ... */ };
const packer = createBinaryFontPacker();
const buffer: Uint8Array = packer.pack(font);
```
## Usage: Advanced Example
```typescript
import { BinaryFontPacker } from "@suaveplan/font-compress/packer/binary-font-packer";
import { FontCompressError } from "@suaveplan/font-compress";
const packer = new BinaryFontPacker();
try {
const packed = packer.pack(font);
// Write to disk, send over the wire, or store in IndexedDB
await Bun.write("font.btf", packed);
} catch (err) {
if (err instanceof FontCompressError) {
console.error("Pack failed:", err.code, err.message);
}
}
```
## API Reference
### `BinaryFontPacker`
#### `pack(font: TypefaceFont): Uint8Array`
Packs a `TypefaceFont` into a BTF binary buffer.
**Parameters:**
- `font` — the typeface.js font object, as produced by `@suaveplan/font-convert`
**Returns:** `Uint8Array` containing the binary BTF data.
**Throws:** `FontCompressError` with code `PACK_FAILED` if packing fails for any reason.
### `createBinaryFontPacker(): BinaryFontPacker`
Factory function that creates a new `BinaryFontPacker` instance.
## Implementation Notes
### BTF Binary Layout
```text
[familyName: encodeString]
[cssFontWeight: encodeString]
[cssFontStyle: encodeString]
[reversed: uint8 (0 or 1)]
[dictLen: uint32 LE]
[dict: Int16Array × dictLen]
[metricsIdxs: Uint16Array × 9] ← ascender,descender,ulPos,ulThk,res,yMin,xMin,yMax,xMax
[glyphCount: uint32 LE]
per-glyph:
[charCode: uint32 LE]
[indexCount: uint32 LE]
[indexes: Uint16Array × indexCount] ← ha,x_min,x_max, then (cmd_byte+coord_indexes)...
```
`encodeString` format: `[uint32 length LE][UTF-8 bytes]`.
Command bytes map to: `0=m`, `1=l`, `2=b`, `3=q`, `4=z`. Coordinate counts per command: `m=2`, `l=2`, `b=6`, `q=4`, `z=0`.
### Dictionary Construction
All unique coordinate values across the entire font (metrics, bounding box, all glyph coordinates) are collected into a sorted `Int16Array`. This single pass over the font data is O(n) in glyph count and produces optimal deduplication. Because typeface.js coordinates are integers in a bounded range (typically ±2048), the dictionary fits comfortably in an Int16Array.
### Alignment
All multi-byte typed arrays are written via `Uint8Array` byte copies, not direct TypedArray buffer views, so there are no alignment requirements on the output buffer. Readers (BinaryFontUnpacker) use `DataView` to safely read values at arbitrary offsets.

View File

@@ -0,0 +1,60 @@
# encode-string
## Purpose
Utilities for encoding and decoding length-prefixed UTF-8 strings in the BTF binary format. They provide the wire encoding used by `BinaryFontPacker` to store font family name, CSS weight, and CSS style as inline strings in the binary header.
Every string is encoded as `[uint32 byte-length LE][UTF-8 bytes]`, which allows safe decoding without a sentinel character and handles arbitrary Unicode content including multi-byte sequences.
## Features
- `encodeString`: encodes a JS string as `[uint32 LE length][UTF-8 bytes]`
- `decodeString`: reads a length-prefixed string from a buffer at a given byte offset and returns the decoded value and the new offset after the string
## Usage: Basic Example
```typescript
import { encodeString, decodeString } from "@suaveplan/font-compress/utils/encode-string";
const encoded = encodeString("Arial");
// [5, 0, 0, 0, 65, 114, 105, 97, 108]
const { value, offset } = decodeString(encoded, 0);
// value = "Arial", offset = 9
```
## Usage: Advanced Example
```typescript
// Chaining multiple string reads during unpack
let off = 0;
const r1 = decodeString(data, off); off = r1.offset;
const r2 = decodeString(data, off); off = r2.offset;
const r3 = decodeString(data, off); off = r3.offset;
// r1.value = familyName, r2.value = cssFontWeight, r3.value = cssFontStyle
```
## API Reference
### `encodeString(str: string): Uint8Array`
Encodes `str` as `[uint32 LE byte-length][UTF-8 bytes]`.
**Parameters:** `str` — any JS string including multi-byte Unicode.
**Returns:** `Uint8Array` of `4 + byteLength` bytes.
### `decodeString(data: Uint8Array, offset: number): { value: string; offset: number }`
Reads a length-prefixed string at `offset` in `data`.
**Parameters:**
- `data` — source buffer
- `offset` — byte position of the uint32 length prefix
**Returns:** `{ value: string; offset: number }` where `offset` is the position immediately after the string bytes.
## Implementation Notes
Uses the platform `TextEncoder` / `TextDecoder` singletons. The returned `offset` from `decodeString` is the cursor position immediately following the string's last byte, suitable for passing directly to the next read operation.

View File

@@ -0,0 +1,67 @@
# parse-path
## Purpose
Parses a typeface.js outline string into an array of structured command objects. The typeface.js format encodes glyph outlines as space-separated tokens: a command letter (`m`, `l`, `b`, `q`, `z`) followed by its coordinate arguments. `parsePath` tokenises this string and groups each command with its coordinates for downstream processing.
This utility is used by `BinaryFontPacker` to enumerate all glyph coordinates during dictionary construction and to encode each command into the BTF binary stream.
## Features
- Parses all five typeface.js commands: `m` (moveto), `l` (lineto), `b` (cubic bezier), `q` (quadratic bezier), `z` (close path)
- Returns coordinate arrays of the correct length per command: `m/l=2`, `q=4`, `b=6`, `z=0`
- Silently skips unrecognised tokens
- Handles empty strings and trailing whitespace gracefully
## Usage: Basic Example
```typescript
import { parsePath } from "@suaveplan/font-compress/utils/parse-path";
const commands = parsePath("m 0 0 l 100 0 z ");
// [
// { cmd: "m", coords: [0, 0] },
// { cmd: "l", coords: [100, 0] },
// { cmd: "z", coords: [] },
// ]
```
## Usage: Advanced Example
```typescript
import { parsePath } from "@suaveplan/font-compress/utils/parse-path";
// Complex glyph outline with all command types
const commands = parsePath("m 0 0 l 100 0 b 10 20 30 40 50 60 q 5 10 15 20 z ");
for (const { cmd, coords } of commands) {
console.log(cmd, coords.length, "coords");
}
// m 2 coords
// l 2 coords
// b 6 coords
// q 4 coords
// z 0 coords
```
## API Reference
### `ParsedCommand`
```typescript
interface ParsedCommand {
cmd: string;
coords: number[];
}
```
### `parsePath(pathString: string): ParsedCommand[]`
Parses a typeface.js outline string.
**Parameters:** `pathString` — the `o` field of a `TypefaceGlyph`.
**Returns:** array of `ParsedCommand` objects in outline order. Returns `[]` for an empty string.
## Implementation Notes
The parser advances by one token at a time. When it encounters a known command, it reads the next N tokens as floating-point coordinates. If a coordinate token is missing or non-numeric it is skipped; this handles malformed paths without throwing. Unrecognised tokens (not in the set `m l b q z`) are consumed and discarded without advancing the coordinate counter, preserving correct alignment for subsequent commands.

View File

@@ -0,0 +1,56 @@
# @suaveplan/font-convert - Convert TTF/OTF/WOFF/WOFF2 font files to typeface.js JSON format. Defines canonical TypefaceFont, TypefaceGlyph, and PathCommand types used by font-compress and font-decompress.
## Summary
Converts TTF/OTF/WOFF/WOFF2 font files to the [typeface.js](https://gero3.github.io/facetype.js/) JSON format used by [Three.js `FontLoader`](https://threejs.org/docs/#examples/en/loaders/FontLoader). Also defines the canonical `TypefaceFont`, `TypefaceGlyph`, and `PathCommand` types consumed by `@suaveplan/font-compress`.
<!-- begin:auto-docs -->
## Documentation tree
- [batch-converter](./src/core/batch-converter/batch-converter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [batch-converter](./src/core/batch-converter/batch-converter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [typeface](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [outline-to-string](./src/utils/outline-to-string.md) — Serialises an array of `PathCommand` objects to the space-separated token string stored in a `TypefaceGlyph`'s `o` fiel…
- [reverse-winding](./src/utils/reverse-winding.md) — Reverses the winding order of a typeface.js path command sequence. Three.js `FontLoader` expects counter-clockwise wind…
<!-- end:auto-docs --> - [batch-converter](./src/core/batch-converter/batch-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_- [typeface](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_- [outline-to-string](./src/utils/outline-to-string.md) — Serialises an array of `PathCommand` objects to the space-separated token string stored in a `TypefaceGlyph`'s `o` fiel…
- [reverse-winding](./src/utils/reverse-winding.md) — Reverses the winding order of a typeface.js path command sequence. Three.js `FontLoader` expects counter-clockwise wind…
- [batch-converter](./src/core/batch-converter/batch-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_- [typeface](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_- [outline-to-string](./src/utils/outline-to-string.md) — Serialises an array of `PathCommand` objects to the space-separated token string stored in a `TypefaceGlyph`'s `o` fiel…
- [reverse-winding](./src/utils/reverse-winding.md) — Reverses the winding order of a typeface.js path command sequence. Three.js `FontLoader` expects counter-clockwise wind…
- [batch-converter](./src/core/batch-converter/batch-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_- [typeface](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_- [outline-to-string](./src/utils/outline-to-string.md) — Serialises an array of `PathCommand` objects to the space-separated token string stored in a `TypefaceGlyph`'s `o` fiel…
- [reverse-winding](./src/utils/reverse-winding.md) — Reverses the winding order of a typeface.js path command sequence. Three.js `FontLoader` expects counter-clockwise wind…
- [batch-converter](./src/core/batch-converter/batch-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_ - [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_ - [typeface](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_- [outline-to-string](./src/utils/outline-to-string.md) — Serialises an array of `PathCommand` objects to the space-separated token string stored in a `TypefaceGlyph`'s `o` fiel…
- [reverse-winding](./src/utils/reverse-winding.md) — Reverses the winding order of a typeface.js path command sequence. Three.js `FontLoader` expects counter-clockwise wind…
## Packages in the font family
| Package | Purpose |
|---|---|
| `@suaveplan/font-subset` | Unicode subset, WOFF2/WOFF encode, hinting strip |
| **`@suaveplan/font-convert`** | **TTF/OTF → typeface.js JSON (this package)** |
| `@suaveplan/font-compress` | typeface.js JSON → BTF binary pack/unpack |
## Quick start
```ts
import { createFontConverter } from "@suaveplan/font-convert";
import { readFileSync } from "node:fs";
const converter = createFontConverter();
const font = converter.convert(readFileSync("MyFont.ttf"));
console.log(font.familyName); // "MyFont"
console.log(font.cssFontWeight); // "normal" | "bold"
```
## Modules
| Module | Description |
|---|---|
| [FontConverter](src/core/font-converter/font-converter.md) | Parse font binary → TypefaceFont |
| [BatchConverter](src/core/batch-converter/batch-converter.md) | Convert multiple fonts in one call |
| [reverseWinding](src/utils/reverse-winding.md) | Reverse glyph winding order |
| [outlineToString](src/utils/outline-to-string.md) | Serialise PathCommand[] to outline string |
| [TypefaceFont types](src/types/typeface.md) | Canonical shared types |

View File

@@ -0,0 +1,102 @@
# BatchConverter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Converts an array of font buffers to `TypefaceFont` JSON objects in a single call. It wraps `FontConverter` and applies the same conversion options to every item in the batch, returning an array of `{ name, font }` pairs. Useful for build pipelines that process multiple font files in a single pass before packing them to BTF binary.
## Features
- Accepts an array of `{ name: string; data: Uint8Array }` batch items
- Returns `{ name: string; font: TypefaceFont }[]` in the same order as the input
- Shares a single `FontConverter` instance across all items
- Passes `ConversionOptions` uniformly to every conversion
- Propagates `FontConvertError` immediately on the first failing item
- Returns an empty array when given no items
## Usage: Basic Example
```ts
import { BatchConverter } from "@suaveplan/font-convert";
import { readFileSync } from "node:fs";
const batch = new BatchConverter();
const results = batch.convertAll([
{ name: "Roboto-Regular", data: readFileSync("Roboto-Regular.ttf") },
{ name: "Roboto-Bold", data: readFileSync("Roboto-Bold.ttf") },
]);
for (const { name, font } of results) {
console.log(name, font.cssFontWeight);
}
```
## Usage: Advanced Example
```ts
import { BatchConverter } from "@suaveplan/font-convert";
const batch = new BatchConverter({ logger: myLogger });
const results = batch.convertAll(
[
{ name: "Helvetica-Regular", data: helData },
{ name: "Helvetica-Bold", data: boldData },
],
{
reverseTypeface: true,
restrictCharacters: true,
restrictCharacterSet: "32-126", // printable ASCII only
},
);
results.forEach(({ name, font }) => {
console.log(name, Object.keys(font.glyphs).length);
});
```
## API Reference
### `class BatchConverter`
#### `constructor(options?: FontConverterOptions)`
Accepts the same options as `FontConverter`.
#### `convertAll(items: BatchItem[], options?: ConversionOptions): BatchResult[]`
| Parameter | Type | Description |
|---|---|---|
| `items` | `BatchItem[]` | Array of `{ name, data }` font inputs |
| `options` | `ConversionOptions?` | Conversion options applied to all items |
**Returns**: `BatchResult[]` — `{ name: string; font: TypefaceFont }[]` in input order.
**Throws**: `FontConvertError` on the first item that fails to convert.
### `BatchItem`
```ts
interface BatchItem {
name: string;
data: Uint8Array;
}
```
### `BatchResult`
```ts
interface BatchResult {
name: string;
font: TypefaceFont;
}
```
## Implementation Notes
`BatchConverter` processes items sequentially with `Array.map`. There is no parallelism or streaming — fonts are converted one at a time in the order provided. This keeps error attribution simple (the first failure surfaces immediately) and avoids concurrency complications in build pipelines where ordering matters.
For very large batches (100+ fonts), consider chunking the items and processing chunks in separate worker threads if total wall-clock conversion time becomes a concern.

View File

@@ -0,0 +1,97 @@
# FontConverter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Converts a TTF, OTF, WOFF, or WOFF2 font binary to the `TypefaceFont` JSON format used by Three.js `FontLoader`. It parses the binary via `opentype.js`, scales all coordinates to the 1 000-unit typeface.js resolution, and constructs a complete `TypefaceFont` object ready for BTF binary packing by `@suaveplan/font-compress`. `FontConverter` is the primary consumer-facing class; `createFontConverter` is the factory function.
## Features
- Accepts raw font data as `Uint8Array` or `ArrayBuffer`
- Parses TTF, OTF, WOFF, and WOFF2 formats via `opentype.js`
- Applies the typeface.js normalisation scale: `(1000 × 100) / (unitsPerEm × 72)`
- Converts all command types including cubic bezier (`C` → `b` in typeface.js notation)
- Optional winding reversal via `reverseTypeface` option (delegates to `reverseWinding`)
- Optional character restriction by set string or numeric codepoint range (`"65-90"`)
- Falls back to `font.familyName` and `"Unknown"` when name table fields are absent
- Detects `bold`/`italic` from the font subfamily name (case-insensitive)
- Accepts a pre-parsed `opentype.Font` via `convertFromParsed` for batch or pipeline use
- Wraps all errors in `FontConvertError` with stable error codes
## Usage: Basic Example
```ts
import { createFontConverter } from "@suaveplan/font-convert";
import { readFileSync } from "node:fs";
const data = readFileSync("Roboto-Regular.ttf");
const converter = createFontConverter();
const font = converter.convert(data);
console.log(font.familyName); // "Roboto"
console.log(font.cssFontWeight); // "normal"
console.log(Object.keys(font.glyphs).length); // glyph count
```
## Usage: Advanced Example
```ts
import { createFontConverter } from "@suaveplan/font-convert";
import * as opentype from "opentype.js";
import { readFileSync } from "node:fs";
const buffer = readFileSync("MyFont-Bold.ttf").buffer;
const parsed = opentype.parse(buffer);
const converter = createFontConverter();
// Convert from already-parsed font (avoids double-parse in pipelines)
const font = converter.convertFromParsed(parsed, {
reverseTypeface: true, // flip winding for TrueType CFF fonts
restrictCharacters: true,
restrictCharacterSet: "65-122", // only A–Z and a–z
});
console.log(font.cssFontWeight); // "bold" — detected from subfamily
console.log(font.reversed); // true
```
## API Reference
### `class FontConverter`
#### `constructor(options?: FontConverterOptions)`
| Option | Type | Description |
|---|---|---|
| `logger` | `LoggerLike?` | Optional structured logger |
#### `convert(data: Uint8Array \| ArrayBuffer, options?: ConversionOptions): TypefaceFont`
Parses and converts a font binary. Throws `FontConvertError` with code `FONT_CONVERT.PARSE_FAILED` if the binary cannot be parsed.
#### `convertFromParsed(font: opentype.Font, options?: ConversionOptions): TypefaceFont`
Converts an already-parsed `opentype.Font` object. Throws `FontConvertError` with code `FONT_CONVERT.ENCODE_FAILED` on failure.
### `ConversionOptions`
| Option | Type | Default | Description |
|---|---|---|---|
| `reverseTypeface` | `boolean?` | `false` | Reverse glyph winding order |
| `restrictCharacters` | `boolean?` | `false` | Enable character filtering |
| `restrictCharacterSet` | `string?` | — | Characters or `"start-end"` range |
### `createFontConverter(options?: FontConverterOptions): FontConverter`
Factory function. Equivalent to `new FontConverter(options)`.
## Implementation Notes
The scale factor `(1000 × 100) / (unitsPerEm × 72)` is the canonical typeface.js normalisation used by the original `facetype.js` CLI and `three.js` font generation scripts. The `× 100` and `÷ 72` factors preserve the original script's DPI-to-point conversion. A fallback of `unitsPerEm = 2048` (the TrueType default) is used when the parsed font reports `unitsPerEm = 0`.
Cubic bezier commands are renamed from SVG's `C`/`c` to typeface.js's `b` during command mapping. All other command types (`m`, `l`, `q`, `z`) are lowercased but otherwise unchanged.
Character restriction supports two modes: a literal character-set string (e.g. `"ABC"`) and a numeric codepoint range in the form `"start-end"` (e.g. `"65-90"` for ASCII A–Z). The range form is detected when the string splits on `-` into exactly two valid integers; anything else is treated as a literal character set.

View File

@@ -0,0 +1,125 @@
# TypefaceFont Types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the canonical shared types for the typeface.js font format used throughout the `@suaveplan/font-*` package family. `TypefaceFont` is the output of `FontConverter` and the input to `BinaryFontPacker`; `TypefaceGlyph` describes a single glyph's outline; `PathCommand` encodes a single vector drawing operation in the outline string.
These types are the authoritative source — `@suaveplan/font-compress` and `@suaveplan/font-decompress` both import from here rather than defining their own.
## Features
- `PathCommandType` — union of the five typeface.js command tokens: `m` (moveto), `l` (lineto), `q` (quadratic bezier), `b` (cubic bezier), `z` (close path)
- `PathCommand` — structured representation of a single outline command with optional coordinate fields
- `TypefaceGlyph` — horizontal advance, bounding box extremes, and the serialised outline string
- `TypefaceFont` — complete typeface.js document: glyph dictionary, font metadata, bounding box, CSS weight/style, and an optional `reversed` flag for winding-order correction
## Usage: Basic Example
```ts
import type { TypefaceFont, TypefaceGlyph, PathCommand } from "@suaveplan/font-convert/types/typeface";
const glyph: TypefaceGlyph = {
ha: 1234,
x_min: 10,
x_max: 800,
o: "m 100 0 l 100 700 l 500 700 z ",
};
const font: TypefaceFont = {
glyphs: { A: glyph },
familyName: "MyFont",
ascender: 800,
descender: -200,
underlinePosition: -100,
underlineThickness: 50,
boundingBox: { yMin: -200, xMin: 0, yMax: 800, xMax: 1000 },
resolution: 1000,
original_font_information: {},
cssFontWeight: "normal",
cssFontStyle: "normal",
};
```
## Usage: Advanced Example
```ts
import type { PathCommand, PathCommandType } from "@suaveplan/font-convert/types/typeface";
function parseOutlineString(outline: string): PathCommand[] {
const tokens = outline.trim().split(/\s+/);
const commands: PathCommand[] = [];
let i = 0;
while (i < tokens.length) {
const type = tokens[i++] as PathCommandType;
const cmd: PathCommand = { type };
if (type === "m" || type === "l") {
cmd.x = Number(tokens[i++]);
cmd.y = Number(tokens[i++]);
} else if (type === "q") {
cmd.x1 = Number(tokens[i++]);
cmd.y1 = Number(tokens[i++]);
cmd.x = Number(tokens[i++]);
cmd.y = Number(tokens[i++]);
} else if (type === "b") {
cmd.x1 = Number(tokens[i++]);
cmd.y1 = Number(tokens[i++]);
cmd.x2 = Number(tokens[i++]);
cmd.y2 = Number(tokens[i++]);
cmd.x = Number(tokens[i++]);
cmd.y = Number(tokens[i++]);
}
commands.push(cmd);
}
return commands;
}
```
## API Reference
### `PathCommandType`
String union: `"m" | "l" | "q" | "b" | "z"`. Maps to typeface.js outline token types (note: cubic bezier uses `"b"`, not SVG's `"c"`).
### `PathCommand`
| Field | Type | Present for |
|---|---|---|
| `type` | `PathCommandType` | all |
| `x`, `y` | `number` | `m`, `l`, `q`, `b` |
| `x1`, `y1` | `number` | `q`, `b` |
| `x2`, `y2` | `number` | `b` only |
### `TypefaceGlyph`
| Field | Type | Description |
|---|---|---|
| `ha` | `number` | Horizontal advance (scaled units) |
| `x_min` | `number` | Left extent of the glyph bounding box |
| `x_max` | `number` | Right extent of the glyph bounding box |
| `o` | `string` | Space-separated outline command tokens |
### `TypefaceFont`
| Field | Type | Description |
|---|---|---|
| `glyphs` | `Record<string, TypefaceGlyph>` | Map from character to glyph |
| `familyName` | `string` | Font family name |
| `ascender` | `number` | Ascender height (scaled) |
| `descender` | `number` | Descender depth (scaled, negative) |
| `underlinePosition` | `number` | Underline Y position |
| `underlineThickness` | `number` | Underline stroke width |
| `boundingBox` | `{ yMin, xMin, yMax, xMax }` | Overall font bounding box |
| `resolution` | `number` | Always `1000` — typeface.js coordinate scale |
| `original_font_information` | `unknown` | Raw name table from the source font |
| `cssFontWeight` | `string` | `"normal"` or `"bold"` |
| `cssFontStyle` | `string` | `"normal"` or `"italic"` |
| `reversed` | `boolean?` | `true` when winding was reversed for TrueType CFF compat |
## Implementation Notes
All coordinate values stored in a `TypefaceFont` are pre-scaled by `(1000 × 100) / (unitsPerEm × 72)`. This scale factor normalises any source font's internal coordinate system to the 1000-unit resolution expected by typeface.js and Three.js `FontLoader`. The `resolution: 1000` field documents this contract.
The `reversed` flag is informational only; it tells downstream consumers (e.g. Three.js path renderers) that the winding correction has already been applied so they should not apply their own.

View File

@@ -0,0 +1,76 @@
# outlineToString
## Purpose
Serialises an array of `PathCommand` objects to the space-separated token string stored in a `TypefaceGlyph`'s `o` field. The typeface.js format requires each command to be written as `"type [x y] [x1 y1] [x2 y2]"` with a trailing space after each coordinate group. `outlineToString` encapsulates this serialisation contract and applies an optional scale factor so that internal font-unit coordinates are normalised to the 1 000-unit resolution expected by Three.js `FontLoader`.
## Features
- Handles all five typeface.js command types: `m`, `l`, `q`, `b`, `z`
- Emits only the coordinate fields present on each command
- Applies a floating-point scale multiplier and rounds each coordinate to the nearest integer
- Returns an empty string for an empty command array
- Does not mutate the input array
## Usage: Basic Example
```ts
import { outlineToString } from "@suaveplan/font-convert";
import type { PathCommand } from "@suaveplan/font-convert";
const commands: PathCommand[] = [
{ type: "m", x: 0, y: 0 },
{ type: "l", x: 100, y: 0 },
{ type: "z" },
];
const outline = outlineToString(commands);
// → "m 0 0 l 100 0 z "
```
## Usage: Advanced Example
```ts
import { outlineToString } from "@suaveplan/font-convert";
import type { PathCommand } from "@suaveplan/font-convert";
// Apply a scale factor when coordinates are in raw font units
const unitsPerEm = 1000;
const scale = (1000 * 100) / (unitsPerEm * 72); // ≈ 1.389
const commands: PathCommand[] = [
{ type: "m", x: 0, y: 0 },
{ type: "q", x1: 360, y1: 720, x: 720, y: 0 },
{ type: "b", x1: 100, y1: 200, x2: 300, y2: 400, x: 1000, y: 0 },
];
const outline = outlineToString(commands, scale);
// coordinates are Math.round(value * scale)
```
## API Reference
### `outlineToString(commands: PathCommand[], scale?: number): string`
| Parameter | Type | Default | Description |
|---|---|---|---|
| `commands` | `PathCommand[]` | — | Path commands to serialise |
| `scale` | `number` | `1` | Multiplier applied to all coordinate values before rounding |
**Returns**: The typeface.js outline string. Each command contributes one token group ending with a trailing space. Returns `""` when `commands` is empty.
**Coordinate order per command type:**
| Type | Token sequence |
|---|---|
| `m` | `m x y` |
| `l` | `l x y` |
| `z` | `z` |
| `q` | `q x y x1 y1` |
| `b` | `b x y x1 y1 x2 y2` |
## Implementation Notes
The coordinate emission order matches what `FontConverter` and the reference `typeface.js` CLI produce: the endpoint `x/y` is written before control points `x1/y1` and `x2/y2`. This is the order required by the Three.js `FontLoader` parser. Changing this order would silently corrupt all outlines that use bezier curves.
The scale factor default of `1` makes `outlineToString` directly testable with integer coordinates without requiring the caller to know the actual font resolution.

View File

@@ -0,0 +1,66 @@
# reverseWinding
## Purpose
Reverses the winding order of a typeface.js path command sequence. Three.js `FontLoader` expects counter-clockwise winding for outer contours, while some TrueType and CFF fonts ship with clockwise winding. Calling `reverseWinding` before serialising glyphs ensures the outline direction is corrected without reprocessing the source binary.
## Features
- Splits commands at each `M`/`m` moveto to identify individual subpaths
- Drops `Z`/`z` close-path commands (they are implicit in typeface.js rendering)
- Reverses command order within each subpath
- Swaps cubic bezier control point pairs (`x1/y1` ↔ `x2/y2`) to maintain curve shape
- Preserves quadratic bezier single control point after destination flip
- Works with zero commands (returns empty array, no error)
- Operates entirely on the `PathCommand` type from `@suaveplan/font-convert`
## Usage: Basic Example
```ts
import { reverseWinding } from "@suaveplan/font-convert";
import type { PathCommand } from "@suaveplan/font-convert";
const commands: PathCommand[] = [
{ type: "m", x: 0, y: 0 },
{ type: "l", x: 100, y: 0 },
{ type: "l", x: 50, y: 100 },
{ type: "z" },
];
const reversed = reverseWinding(commands);
// reversed[0] = { type: "m", x: 50, y: 100 } — last point becomes new start
// reversed[1] = { type: "l", x: 100, y: 0 }
// reversed[2] = { type: "l", x: 0, y: 0 }
```
## Usage: Advanced Example
```ts
import { reverseWinding } from "@suaveplan/font-convert";
import type { PathCommand } from "@suaveplan/font-convert";
// Cubic bezier: reversal swaps x1/y1 and x2/y2
const cubicCommands: PathCommand[] = [
{ type: "m", x: 0, y: 0 },
{ type: "b", x1: 10, y1: 20, x2: 30, y2: 40, x: 100, y: 0 },
];
const rev = reverseWinding(cubicCommands);
// rev[0] = { type: "m", x: 100, y: 0 }
// rev[1] = { type: "b", x1: 30, y1: 40, x2: 10, y2: 20, x: 0, y: 0 }
// ^^^ x2/y2 and x1/y1 are swapped relative to the original
```
## API Reference
### `reverseWinding(commands: PathCommand[]): PathCommand[]`
| Parameter | Type | Description |
|---|---|---|
| `commands` | `PathCommand[]` | Array of path commands as produced by `FontConverter` |
**Returns**: A new array of `PathCommand` objects in reversed winding order. The input array is not mutated.
## Implementation Notes
The function mirrors the winding-reversal algorithm used in `typeface.js`'s own CLI tooling and the reference `FontConverter.ts`. Subpath splitting is done by scanning for `m`/`M` commands; `z`/`Z` close-path commands are discarded because typeface.js renderers treat each subpath as implicitly closed. The reversed list starts with a new `m` command pointing at the last non-close command of the original path, then walks the original path backwards, placing each point at the previous command's destination.

View File

@@ -0,0 +1,131 @@
# @suaveplan/font-decompress - BTF (Binary TypeFace) binary unpacking. Reverses @suaveplan/font-compress's BinaryFontPacker output back into typeface.js JSON, ready for Three.js / R3F / game-font rendering pipelines. Universal.
BTF (Binary TypeFace) binary unpacking. Reverses
`@suaveplan/font-compress`'s `BinaryFontPacker` output into a
typeface.js JSON shape ready for Three.js / R3F / game-font
rendering. Store-agnostic, free of `node:` / `bun:`
imports.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — The `@suaveplan/font-decompress` package centralises every failure mode it can produce behind a single registry built w…
- [error-codes](./src/error-codes/error-codes.md) — The `@suaveplan/font-decompress` package centralises every failure mode it can produce behind a single registry built w…
- [binary-font-unpacker](./src/unpacker/binary-font-unpacker/binary-font-unpacker.md) — `BinaryFontUnpacker` reverses the BTF (Binary TypeFace) wire format produced by `BinaryFontPacker` from `@suaveplan/fon…
- [binary-font-unpacker](./src/unpacker/binary-font-unpacker/binary-font-unpacker.md) — `BinaryFontUnpacker` reverses the BTF (Binary TypeFace) wire format produced by `BinaryFontPacker` from `@suaveplan/fon…
- [utils](./src/utils/decode-string.md) — `decodeString` reads a length-prefixed UTF-8 string from a `Uint8Array` at a given offset. The BTF wire format encodes…
- [decode-string](./src/utils/decode-string.md) — `decodeString` reads a length-prefixed UTF-8 string from a `Uint8Array` at a given offset. The BTF wire format encodes…
<!-- end:auto-docs -->
## Purpose
Compressed BTF buffers ship from build-time tooling to the renderer
as compact `Uint8Array` blobs — they are far smaller than the
underlying typeface.js JSON for non-trivial fonts. The renderer needs
the JSON back to hand to Three.js's `FontLoader.parse(...)`. This
package owns that reverse step; the sibling `@suaveplan/font-compress`
owns the forward step.
The split exists because pack and unpack are independent concerns:
build tooling never unpacks, runtime renderers never pack. Shipping a
unified package forces consumers to drag the other half. Splitting
lets each side install only what it actually executes.
## Features
- `BinaryFontUnpacker` class + `createBinaryFontUnpacker()` factory
- Strict boundary validation on every read — raises `FontDecompressError` with `FONT_DECOMPRESS.INVALID_DATA`
- Underlying primitive exceptions (DataView, TextDecoder) wrapped as `FontDecompressError` with `FONT_DECOMPRESS.UNPACK_FAILED` and `cause` preserved
- All five BTF glyph commands (`m`/`l`/`b`/`q`/`z`) round-trip byte-for-byte
- Unicode glyph keys (multi-byte UTF-8) round-trip correctly
- Negative coordinates round-trip correctly
- 100% statement / branch / function / line Istanbul coverage
- 5 tree-shakeable subpath exports
## Usage: Basic
```ts
import { createBinaryFontUnpacker } from "@suaveplan/font-decompress";
const unpacker = createBinaryFontUnpacker();
const font = unpacker.unpack(btfBuffer);
console.log(font.familyName); // "Inter Variable"
console.log(font.glyphs["A"]?.ha); // glyph advance width
```
## Usage: Advanced
```ts
import {
BinaryFontUnpacker,
FONT_DECOMPRESS_CODES,
FontDecompressError,
} from "@suaveplan/font-decompress";
// Reuse a single instance — stateless and safe.
const unpacker = new BinaryFontUnpacker();
async function loadCdnFont(url: string) {
const buf = new Uint8Array(
await (await fetch(url)).arrayBuffer(),
);
try {
return unpacker.unpack(buf);
} catch (err) {
if (
err instanceof FontDecompressError &&
err.code === FONT_DECOMPRESS_CODES.INVALID_DATA
) {
console.warn("Corrupted BTF from CDN:", err.message);
return null;
}
throw err;
}
}
```
## API Reference
### Public exports
- `class BinaryFontUnpacker` — `unpack(data: Uint8Array): TypefaceFont`
- `createBinaryFontUnpacker(): BinaryFontUnpacker`
- `FONT_DECOMPRESS_CODES` — frozen registry with `UNPACK_FAILED`, `INVALID_DATA`
- `class FontDecompressError extends SuaveplanError<FontDecompressErrorCode>`
- `type FontDecompressErrorCode`
The `TypefaceFont` and `PathCommand` types come from
`@suaveplan/font-convert`; consumers import them directly from there
rather than via this package's surface.
### Subpath exports
| Subpath | What |
| -------------------------------------------- | ----------------------------- |
| `.` | Root barrel (above) |
| `./error-codes` | `FONT_DECOMPRESS_CODES`, `FontDecompressError` |
| `./constants` | BTF wire-format constants |
| `./utils/decode-string` | `decodeString` helper |
| `./unpacker/binary-font-unpacker` | `BinaryFontUnpacker`, factory |
## Implementation Notes
The package is **universal**: zero `node:` / `bun:` imports
anywhere in `src/`. The only platform-aware calls are `DataView`,
`TextDecoder`, and `String.fromCharCode` — all available in every
supported runtime (Node ≥ 18, Bun, modern browsers, Web Workers).
The BTF wire-format constants (`CMD_M` … `CMD_Z`, `BYTE_TO_CMD`,
`CMD_COORD_COUNTS`, `METRICS_COUNT`) are duplicated from
`@suaveplan/font-compress`. Duplicating 19 lines of magic numbers
avoids a third "fonts-shared" package and keeps each package
self-contained. If the BTF format ever changes — which is a
coordinated breaking event — both packages get updated in the same
commit.
`@suaveplan/font-compress` is a **devDependency** for round-trip
tests only. The runtime dependency graph is `font-decompress →
font-convert + error`; the test surface adds `font-compress` solely
to generate known-vector fixtures.

View File

@@ -0,0 +1,115 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `@suaveplan/font-decompress` package centralises every failure mode it
can produce behind a single registry built with
`defineCodes("FONT_DECOMPRESS", [...])` from `@suaveplan/error`. The
registry is the stable contract callers depend on: code strings are
documented, frozen at module load, and never renamed without an
explicit migration. This module exports the `FONT_DECOMPRESS_CODES`
registry, the derived `FontDecompressErrorCode` literal-union type,
and the `FontDecompressError` class that every public failure path
raises.
`FontDecompressError` extends `SuaveplanError<FontDecompressErrorCode>`
so `instanceof FontDecompressError` narrows `code` to the union of
`FONT_DECOMPRESS.*` strings. The class threads `cause` straight
through `SuaveplanError`, so primitive-layer exceptions
(`RangeError` from `DataView`, `TypeError` from `TextDecoder`,
`@suaveplan/font-convert` errors) remain inspectable through
`error.cause`.
## Features
- Frozen `FONT_DECOMPRESS_CODES` registry with two namespaced codes
- `FontDecompressErrorCode` literal-union type derived from the registry
- `FontDecompressError` class extending `SuaveplanError<FontDecompressErrorCode>`
- Full `cause`-chain preservation via `SuaveplanError`
- Distinct from `FONT_COMPRESS.*` codes — different namespace, different class
## Usage: Basic
```ts
import {
FONT_DECOMPRESS_CODES,
FontDecompressError,
} from "@suaveplan/font-decompress";
if (data.byteLength < 1) {
throw new FontDecompressError(
FONT_DECOMPRESS_CODES.INVALID_DATA,
"BTF buffer is empty",
);
}
```
## Usage: Advanced
```ts
import {
FONT_DECOMPRESS_CODES,
FontDecompressError,
} from "@suaveplan/font-decompress";
try {
const font = unpacker.unpack(buffer);
} catch (err) {
if (err instanceof FontDecompressError) {
if (err.code === FONT_DECOMPRESS_CODES.UNPACK_FAILED) {
telemetry.increment("font.unpack.failure", {
root: err.cause instanceof Error ? err.cause.name : "unknown",
});
}
}
throw err;
}
```
## API Reference
### `FONT_DECOMPRESS_CODES`
A frozen object literal of two namespaced codes:
| Property | Value |
| --------------- | -------------------------------- |
| `UNPACK_FAILED` | `"FONT_DECOMPRESS.UNPACK_FAILED"` |
| `INVALID_DATA` | `"FONT_DECOMPRESS.INVALID_DATA"` |
### `type FontDecompressErrorCode`
The literal union of every value in `FONT_DECOMPRESS_CODES`.
### `class FontDecompressError extends SuaveplanError<FontDecompressErrorCode>`
| Member | Type |
| ------------------ | ---------------------------------------------------------- |
| `code` | `FontDecompressErrorCode` (narrowed) |
| `cause?` | `unknown` (inherited) |
| `timestamp` | `number` (inherited; `Date.now()` at construction) |
| `constructor(...)` | `(code, message, options?: SuaveplanErrorOptions)` |
## Implementation Notes
`defineCodes` returns a `Object.freeze`d record with `null` prototype,
so the registry cannot be mutated, reassigned, or extended at runtime.
The literal-type machinery preserves the exact `${Namespace}.${Reason}`
string template across every consumer, so `switch (err.code)` enjoys
exhaustive narrowing without runtime cost.
The class's constructor passes `message` through to `SuaveplanError`'s
`messageKey` parameter. The base class resolves the key via the i18n
resolver if one has been registered with `setMessageResolver`, falling
back to the raw string otherwise — so this package works without i18n
wiring and lights up automatically when `@suaveplan/i18n` is present.
The split from `@suaveplan/font-compress` preserves namespace
distinctness: an `INVALID_DATA` raised by the packer is
`FONT_COMPRESS.INVALID_DATA`; an `INVALID_DATA` raised by the unpacker
is `FONT_DECOMPRESS.INVALID_DATA`. Consumers dispatching on either
class plus its code get fully unambiguous error handling.

View File

@@ -0,0 +1,134 @@
# unpacker/binary-font-unpacker
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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
```ts
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
```ts
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.

View File

@@ -0,0 +1,96 @@
# utils/decode-string
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`decodeString` reads a length-prefixed UTF-8 string from a `Uint8Array`
at a given offset. The BTF wire format encodes strings as
`[uint32 length LE][utf8 bytes]`, and the unpacker calls this helper to
read the three header strings (`familyName`, `cssFontWeight`,
`cssFontStyle`) before walking into the dictionary section. Centralising
the helper here keeps the unpacker focused on the structural layout
and lets the boundary-validation logic (offset bounds, length-overrun
detection) live in one auditable place.
The function is the inverse of `encodeString` from
`@suaveplan/font-compress`. Together they form the wire-format
agreement for length-prefixed strings used by the BTF header.
## Features
- Reads `[uint32 LE length][utf8 bytes]` and returns the decoded string + next offset
- Rejects negative offsets via `FontDecompressError` with `FONT_DECOMPRESS.INVALID_DATA`
- Rejects offsets where fewer than 4 bytes remain via the same code
- Rejects declared lengths that would read past the buffer end via the same code
- Handles empty strings (`length === 0`) correctly
- Handles multi-byte UTF-8 (BMP and surrogate-pair) correctly via `TextDecoder`
## Usage: Basic
```ts
import { decodeString } from "@suaveplan/font-decompress/utils/decode-string";
const bytes = new Uint8Array([5, 0, 0, 0, 104, 101, 108, 108, 111]); // length 5 + "hello"
const { value, offset } = decodeString(bytes, 0);
console.log(value); // "hello"
console.log(offset); // 9
```
## Usage: Advanced
```ts
import { decodeString } from "@suaveplan/font-decompress/utils/decode-string";
import { FONT_DECOMPRESS_CODES, FontDecompressError } from "@suaveplan/font-decompress";
let cursor = 0;
try {
const familyName = decodeString(buffer, cursor);
cursor = familyName.offset;
const cssFontWeight = decodeString(buffer, cursor);
cursor = cssFontWeight.offset;
const cssFontStyle = decodeString(buffer, cursor);
cursor = cssFontStyle.offset;
} catch (err) {
if (err instanceof FontDecompressError && err.code === FONT_DECOMPRESS_CODES.INVALID_DATA) {
// Truncated BTF buffer — log and surface to caller
}
throw err;
}
```
## API Reference
### `decodeString(data: Uint8Array, offset: number): { value: string; offset: number }`
| Parameter | Type | Meaning |
| --------- | ------------ | ---------------------------------------- |
| `data` | `Uint8Array` | The buffer containing the encoded string |
| `offset` | `number` | Byte offset to start reading from |
**Returns** `{ value: string; offset: number }` — the decoded string plus the next byte position. The new offset equals `offset + 4 + utf8ByteLength`.
**Throws** `FontDecompressError` with `code === "FONT_DECOMPRESS.INVALID_DATA"` when:
- `offset < 0`
- `offset + 4 > data.length` (not enough bytes for the length prefix)
- `offset + 4 + length > data.length` (declared length reads past buffer end)
## Implementation Notes
The helper uses a module-level `TextDecoder` rather than constructing one
per call. `TextDecoder` is sturdy under reuse and the BTF format never
nests its strings, so a single shared decoder is safe and faster.
Length is read as little-endian `uint32` via `DataView.getUint32(..., true)`,
matching the encoder side. The DataView is constructed with the
`byteOffset` of the input array so callers can pass subarrays without
extra slicing.
Boundary validation happens up-front: the function checks the offset
and length BEFORE reading any bytes via `TextDecoder`. This avoids any
ambiguity about whether a partial read might emit a surrogate-half or
similar misuse — the throw is synchronous and the wire format is
either valid or rejected.

View File

@@ -0,0 +1,125 @@
# @suaveplan/font-compress - Font subsetting and compression library with a codec-agnostic adapter interface. Supports Unicode range subsetting, WOFF2/WOFF/OTF/TTF output, OpenType feature filtering, hinting strip, and metadata strip.
## Summary
Font subsetting and compression library with a codec-agnostic adapter interface. Supports Unicode range subsetting, WOFF2/WOFF/OTF/TTF output, OpenType feature filtering, hinting strip, and metadata strip. Adapter implementations supply the actual codec; this package provides the orchestration, validation, format detection, and OTel instrumentation.
<!-- begin:auto-docs -->
## Documentation tree
- [null](./src/adapters/null/null.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [null](./src/adapters/null/null.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [font-compressor](./src/core/font-compressor/font-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [font-compressor](./src/core/font-compressor/font-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [format-detect](./src/utils/format-detect.md) — Provides magic-byte font format detection from raw binary data. Before compressing or probing a font file, `FontCompres…
- [unicode-ranges](./src/utils/unicode-ranges.md) — Parses CSS/OpenType-style Unicode range notation into a `Set<number>` of integer codepoints. Font subsetting workflows…
<!-- end:auto-docs --> - [null](./src/adapters/null/null.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-compressor](./src/core/font-compressor/font-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-compressor](./src/core/font-compressor/font-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [format-detect](./src/utils/format-detect.md) — Provides magic-byte font format detection from raw binary data. Before compressing or probing a font file, `FontCompres…
- [unicode-ranges](./src/utils/unicode-ranges.md) — Parses CSS/OpenType-style Unicode range notation into a `Set<number>` of integer codepoints. Font subsetting workflows…
- [null](./src/adapters/null/null.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-compressor](./src/core/font-compressor/font-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-compressor](./src/core/font-compressor/font-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [format-detect](./src/utils/format-detect.md) — Provides magic-byte font format detection from raw binary data. Before compressing or probing a font file, `FontCompres…
- [unicode-ranges](./src/utils/unicode-ranges.md) — Parses CSS/OpenType-style Unicode range notation into a `Set<number>` of integer codepoints. Font subsetting workflows…
- [null](./src/adapters/null/null.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-compressor](./src/core/font-compressor/font-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-compressor](./src/core/font-compressor/font-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [format-detect](./src/utils/format-detect.md) — Provides magic-byte font format detection from raw binary data. Before compressing or probing a font file, `FontCompres…
- [unicode-ranges](./src/utils/unicode-ranges.md) — Parses CSS/OpenType-style Unicode range notation into a `Set<number>` of integer codepoints. Font subsetting workflows…
- [null](./src/adapters/null/null.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-compressor](./src/core/font-compressor/font-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_ - [font-compressor](./src/core/font-compressor/font-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_ - [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [format-detect](./src/utils/format-detect.md) — Provides magic-byte font format detection from raw binary data. Before compressing or probing a font file, `FontCompres…
- [unicode-ranges](./src/utils/unicode-ranges.md) — Parses CSS/OpenType-style Unicode range notation into a `Set<number>` of integer codepoints. Font subsetting workflows…
## Packages
| Module | Description |
|---|---|
| [FontCompressor](src/core/font-compressor/font-compressor.md) | Core orchestrator: validation, format checks, AbortSignal, OTel |
| [NullFontCompressAdapter](src/adapters/null-adapter/null-adapter.md) | No-op adapter for unit/integration tests |
| [parseUnicodeRanges](src/utils/unicode-ranges.md) | Unicode range string → `Set<number>` codepoint parser |
| [detectFontFormat](src/utils/format-detect.md) | Magic-byte font format detection |
| [Types & Schemas](src/types/common.md) | All public types and Zod validation schemas |
## Installation
```bash
bun add @suaveplan/font-compress
```
## Quick Start
```typescript
import {
FontCompressor,
NullFontCompressAdapter,
} from "@suaveplan/font-compress";
// Replace NullFontCompressAdapter with a real codec adapter in production
const adapter = new NullFontCompressAdapter();
const compressor = new FontCompressor({ adapter });
const fontBytes = await Bun.file("Roboto-Regular.ttf").bytes();
const result = await compressor.compress(fontBytes, {
subset: { unicodeRanges: ["U+0020-007F"] },
outputFormat: "woff2",
stripHinting: true,
});
console.log(`Compressed ${result.originalSize} → ${result.compressedSize} bytes`);
console.log(`Ratio: ${(result.ratio * 100).toFixed(1)}%`);
```
## Providing a Real Codec Adapter
Implement `FontCompressAdapter` to wrap any real font codec:
```typescript
import type { FontCompressAdapter, FontMetadata, FontCompressResult, CompressFontOptions } from "@suaveplan/font-compress";
class WoffToolsAdapter implements FontCompressAdapter {
readonly supportedInputFormats = ["ttf", "otf"] as const;
readonly supportedOutputFormats = ["woff2"] as const;
async probe(data: Uint8Array): Promise<FontMetadata> {
// parse font tables to extract metadata
}
async compress(data: Uint8Array, options: CompressFontOptions): Promise<FontCompressResult> {
// call woff2-encoder or similar
}
}
```
## Error Handling
All errors thrown by this package are instances of `FontCompressError`, which extends `SuaveplanError`. Use the `code` property for programmatic error handling:
```typescript
import { FONT_COMPRESS_CODES, FontCompressError } from "@suaveplan/font-compress";
try {
await compressor.compress(data, { outputFormat: "woff2" });
} catch (e) {
if (e instanceof FontCompressError) {
switch (e.code) {
case FONT_COMPRESS_CODES.UNSUPPORTED_FORMAT: /* ... */
case FONT_COMPRESS_CODES.PARSE_FAILED: /* ... */
case FONT_COMPRESS_CODES.ENCODE_FAILED: /* ... */
case FONT_COMPRESS_CODES.INVALID_OPTIONS: /* ... */
case FONT_COMPRESS_CODES.SUBSET_FAILED: /* ... */
}
}
}
```
## Unicode Range Subsetting
```typescript
import { parseUnicodeRanges } from "@suaveplan/font-compress/utils/unicode-ranges";
const codepoints = parseUnicodeRanges(["U+0020-007F", "U+00A0-00FF"]);
console.log(codepoints.size); // 192 codepoints
```
## Format Detection
```typescript
import { detectFontFormat } from "@suaveplan/font-compress/utils/format-detect";
const fmt = detectFontFormat(await Bun.file("font.woff2").bytes());
console.log(fmt); // "woff2"
```

View File

@@ -0,0 +1,76 @@
# NullFontCompressAdapter
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides a no-op `FontCompressAdapter` implementation designed exclusively for unit and integration testing. Rather than invoking any real font codec, `NullFontCompressAdapter` returns configurable fixture values, allowing test suites to verify `FontCompressor` orchestration logic (abort handling, format validation, OTel wiring, option parsing) in complete isolation from external binary dependencies.
## Features
- Implements the full `FontCompressAdapter` contract without external dependencies
- `probe` returns deterministic fixture `FontMetadata` derived from input byte length
- `probe` accepts a fully custom `FontMetadata` override via constructor options
- `compress` echoes the input `Uint8Array` back unchanged with a synthetic `ratio` of `0.9`
- `compress` respects `outputFormat` from options (defaults to `"woff2"`)
- All four formats (`ttf`, `otf`, `woff`, `woff2`) listed in both `supportedInputFormats` and `supportedOutputFormats`
- Fully synchronous-safe — all async methods resolve immediately with no I/O
## Usage: Basic Example
```typescript
import { NullFontCompressAdapter, FontCompressor } from "@suaveplan/font-compress";
const adapter = new NullFontCompressAdapter();
const compressor = new FontCompressor({ adapter });
const data = new Uint8Array([0x00, 0x01, 0x00, 0x00, ...new Array(96).fill(0)]);
const result = await compressor.compress(data, { outputFormat: "woff2" });
// result.format === "woff2", result.ratio === 0.9
```
## Usage: Advanced Example
```typescript
import { NullFontCompressAdapter, FontCompressor } from "@suaveplan/font-compress";
import type { FontMetadata } from "@suaveplan/font-compress";
const customMeta: FontMetadata = {
format: "woff2",
mimeType: "font/woff2",
byteSize: 51200,
familyName: "TestFont",
glyphCount: 256,
};
const adapter = new NullFontCompressAdapter({ probeResult: customMeta });
const compressor = new FontCompressor({ adapter });
const meta = await compressor.probe(new Uint8Array(10));
console.log(meta.familyName); // "TestFont"
```
## API Reference
### `NullFontCompressAdapterOptions`
| Field | Type | Description |
|---|---|---|
| `probeResult` | `FontMetadata` | Custom metadata returned by every `probe()` call |
### `new NullFontCompressAdapter(options?)`
Constructs the adapter. All fields in `options` are optional.
### `probe(data: Uint8Array): Promise<FontMetadata>`
Returns `probeResult` if provided, otherwise returns `{ format: "ttf", mimeType: "font/ttf", byteSize: data.byteLength }`.
### `compress(data: Uint8Array, options: CompressFontOptions): Promise<FontCompressResult>`
Returns the input `data` unchanged as `result.data`. The `format` and `mimeType` are derived from `options.outputFormat ?? "woff2"`. `ratio` is always `0.9`.
## Implementation Notes
The `0.9` ratio constant is arbitrary but non-trivial: it satisfies the property test invariant (`ratio > 0`) while remaining obviously synthetic. Callers testing ratio bounds should use a custom adapter. The `NullFontCompressAdapter` deliberately does not validate the `data` contents — it trusts the `FontCompressor` to perform upstream format checks before delegating here.

View File

@@ -0,0 +1,103 @@
# FontCompressor
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The central orchestrator for font compression operations. `FontCompressor` wraps any `FontCompressAdapter` implementation and adds the cross-cutting concerns that all production uses require: Zod option validation, format support checks, `AbortSignal` propagation, and OTel span/metric instrumentation. Application code constructs one `FontCompressor` per adapter and calls `probe` or `compress` without needing to manage any of that plumbing directly.
## Features
- `probe(data)` — delegates to the adapter's probe method and maps adapter errors to `PARSE_FAILED`
- `compress(data, options?, signal?)` — validates options with Zod, checks both input and output format support, delegates compress, wraps errors as `ENCODE_FAILED`
- `AbortSignal` — checked before compression begins; throws `ENCODE_FAILED` immediately when aborted
- Zod schema validation — `CompressFontOptionsSchema.safeParse` on every compress call; throws `INVALID_OPTIONS` on failure
- Format support checks — `UNSUPPORTED_FORMAT` when the probed input format or the requested output format is not listed in the adapter's supported lists
- OTel span `font.compress` / `font.probe` — status set to OK or ERROR with message
- OTel counter `font_compress_total` with `{ format }` attribute
- OTel histograms `font_compress_duration_ms` and `font_compress_ratio`
- `createFontCompressor` factory for ergonomic construction without `new`
## Usage: Basic Example
```typescript
import { FontCompressor, NullFontCompressAdapter } from "@suaveplan/font-compress";
const data = new Uint8Array([0x00, 0x01, 0x00, 0x00, /* TTF bytes */ ]);
const adapter = new NullFontCompressAdapter();
const compressor = new FontCompressor({ adapter });
const result = await compressor.compress(data, { outputFormat: "woff2" });
console.log(result.format); // "woff2"
console.log(result.ratio); // e.g. 0.9
```
## Usage: Advanced Example
```typescript
import {
createFontCompressor,
NullFontCompressAdapter,
FONT_COMPRESS_CODES,
FontCompressError,
} from "@suaveplan/font-compress";
import type { LoggerLike, Clock } from "@suaveplan/types";
declare const logger: LoggerLike;
declare const clock: Clock;
declare const realAdapter: import("@suaveplan/font-compress").FontCompressAdapter;
const compressor = createFontCompressor({ adapter: realAdapter, logger, clock });
// Abort long-running compression
const ctrl = new AbortController();
setTimeout(() => ctrl.abort(), 2000);
try {
const result = await compressor.compress(fontBytes, {
subset: { unicodeRanges: ["U+0020-007F"] },
outputFormat: "woff2",
stripHinting: true,
}, ctrl.signal);
return result;
} catch (e) {
if (e instanceof FontCompressError) {
if (e.code === FONT_COMPRESS_CODES.ENCODE_FAILED) {
// aborted or codec failure
}
}
throw e;
}
```
## API Reference
### `new FontCompressor(options: FontCompressorOptions)`
| Field | Type | Required | Description |
|---|---|---|---|
| `adapter` | `FontCompressAdapter` | ✓ | Codec implementation |
| `logger` | `LoggerLike` | — | Structured logger |
| `clock` | `Clock` | — | Injectable clock |
### `probe(data: Uint8Array): Promise<FontMetadata>`
Delegates to `adapter.probe`. Wraps non-`FontCompressError` throws as `PARSE_FAILED`.
### `compress(data, options?, signal?): Promise<FontCompressResult>`
1. Checks `signal.aborted` → throws `ENCODE_FAILED`
2. Validates `options` via `CompressFontOptionsSchema` → throws `INVALID_OPTIONS`
3. Calls `probe` to determine input format → throws `UNSUPPORTED_FORMAT` if not in adapter list
4. Checks output format support → throws `UNSUPPORTED_FORMAT`
5. Calls `adapter.compress` → wraps errors as `ENCODE_FAILED`
6. Records OTel metrics and closes span
### `createFontCompressor(options): FontCompressor`
Factory convenience function identical to `new FontCompressor(options)`.
## Implementation Notes
Options default values (`stripHinting: true`, `stripMetadata: false`) are applied by Zod's `.default()` inside `CompressFontOptionsSchema.safeParse`, so the adapter always receives a fully-populated options object. The OTel meter and tracer are obtained via the global provider; if no SDK is configured the NoopMeter/NoopTracer are used automatically, incurring no overhead.

View File

@@ -0,0 +1,107 @@
# Types: common
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the complete public type surface for `@suaveplan/font-compress`. All option bundles, result shapes, the adapter contract, and Zod validation schemas live here, ensuring every layer of the package shares a single authoritative definition. Keeping types co-located with their Zod schemas eliminates the risk of runtime shapes drifting from TypeScript definitions.
## Features
- `FontFormat` union type covering `ttf`, `otf`, `woff`, `woff2`, and `unknown`
- `FontMetadata` — probe result carrying format, MIME type, byte size, and optional glyph/family metadata
- `SubsetOptions` — Unicode range strings, literal character string, and explicit glyph ID list
- `CompressFontOptions` — full option bundle: subset, output format, strip-hinting, strip-metadata, feature tags; defaults via Zod `.default()`
- `FontCompressResult` — compression output: data bytes, format, MIME type, size pair, compression ratio, and optional glyph count
- `FontCompressAdapter` — codec-agnostic interface; implementations supply `probe` and `compress` with async semantics
- `FontCompressorOptions` — constructor options for `FontCompressor`: required adapter plus optional logger and clock for observability injection
- Zod schemas for every public type, enabling runtime validation at package boundaries
## Usage: Basic Example
```typescript
import type { CompressFontOptions, FontMetadata } from "@suaveplan/font-compress";
const opts: CompressFontOptions = {
outputFormat: "woff2",
stripHinting: true,
};
const meta: FontMetadata = {
format: "ttf",
mimeType: "font/ttf",
byteSize: 204800,
familyName: "Roboto",
};
```
## Usage: Advanced Example
```typescript
import { CompressFontOptionsSchema } from "@suaveplan/font-compress";
// Validate user-supplied options at an API boundary
const raw: unknown = { outputFormat: "woff2", subset: { unicodeRanges: ["U+0020-007F"] } };
const parsed = CompressFontOptionsSchema.safeParse(raw);
if (!parsed.success) {
console.error(parsed.error.flatten());
} else {
const opts = parsed.data; // stripHinting defaults to true, stripMetadata to false
}
```
## API Reference
### `FontFormat`
```typescript
type FontFormat = "ttf" | "otf" | "woff" | "woff2" | "unknown";
```
Union of supported font formats, with `"unknown"` used when detection fails.
### `FontMetadata`
| Field | Type | Required | Description |
|---|---|---|---|
| `format` | `FontFormat` | ✓ | Detected format |
| `mimeType` | `string` | ✓ | IANA MIME type |
| `byteSize` | `number` | ✓ | Raw byte count |
| `glyphCount` | `number` | — | Total glyphs, if known |
| `familyName` | `string` | — | Font family name |
| `subFamilyName` | `string` | — | Style variant name |
| `version` | `string` | — | Font version string |
### `SubsetOptions`
| Field | Type | Description |
|---|---|---|
| `unicodeRanges` | `string[]` | Range strings e.g. `["U+0020-007F"]` |
| `characters` | `string` | Literal chars to include |
| `glyphIds` | `number[]` | Explicit glyph IDs |
### `CompressFontOptions`
| Field | Type | Default | Description |
|---|---|---|---|
| `subset` | `SubsetOptions` | — | Subsetting parameters |
| `outputFormat` | `FontFormat` | — | Target format |
| `stripHinting` | `boolean` | `true` | Remove hinting tables |
| `stripMetadata` | `boolean` | `false` | Strip name/OS2 metadata |
| `features` | `string[]` | — | OT feature tags to retain |
### `FontCompressAdapter`
```typescript
interface FontCompressAdapter {
readonly supportedInputFormats: readonly FontFormat[];
readonly supportedOutputFormats: readonly FontFormat[];
probe(data: Uint8Array): Promise<FontMetadata>;
compress(data: Uint8Array, options: CompressFontOptions): Promise<FontCompressResult>;
}
```
## Implementation Notes
Zod schemas use `.default()` on `stripHinting` (true) and `stripMetadata` (false) so that callers can omit them without `undefined` propagating to the adapter. `FontCompressorOptionsSchema` uses `z.custom<LoggerLike>()` and `z.custom<Clock>()` to allow structural compatibility without importing those interfaces at runtime — they carry no validation logic. The `FontCompressAdapter` interface is intentionally minimal; adapters that wrap real codec libraries (e.g. woff2, fonttools) need only implement two async methods.

View File

@@ -0,0 +1,56 @@
# Utils: format-detect
## Purpose
Provides magic-byte font format detection from raw binary data. Before compressing or probing a font file, `FontCompressor` needs to know the input format to validate adapter support. This utility performs a fast 4-byte prefix check covering the four formats that matter for web font workflows: TTF, OTF, WOFF, and WOFF2.
## Features
- Detects TTF via `00 01 00 00` (TrueType scalar type)
- Detects OTF via `4F 54 54 4F` ("OTTO" — OpenType CFF)
- Detects WOFF via `77 4F 46 46` ("wOFF")
- Detects WOFF2 via `77 4F 46 32` ("wOF2")
- Returns `"unknown"` for unrecognised or too-short buffers (< 4 bytes)
- Zero external dependencies — direct byte comparison without any registry
## Usage: Basic Example
```typescript
import { detectFontFormat } from "@suaveplan/font-compress/utils/format-detect";
const bytes = new Uint8Array([0x77, 0x4f, 0x46, 0x32, /* ... */]);
console.log(detectFontFormat(bytes)); // "woff2"
```
## Usage: Advanced Example
```typescript
import { detectFontFormat } from "@suaveplan/font-compress/utils/format-detect";
import { FONT_COMPRESS_CODES, FontCompressError } from "@suaveplan/font-compress";
function requireKnownFormat(data: Uint8Array): void {
const fmt = detectFontFormat(data);
if (fmt === "unknown") {
throw new FontCompressError(
FONT_COMPRESS_CODES.UNSUPPORTED_FORMAT,
"Unrecognised font format — expected TTF, OTF, WOFF, or WOFF2"
);
}
}
```
## API Reference
### `detectFontFormat(data: Uint8Array): FontFormat`
| Parameter | Type | Description |
|---|---|---|
| `data` | `Uint8Array` | Raw font bytes to inspect |
**Returns** one of `"ttf"`, `"otf"`, `"woff"`, `"woff2"`, or `"unknown"`.
Never throws — always returns a `FontFormat` value.
## Implementation Notes
The implementation avoids the `@suaveplan/mime` signature registry for font detection because the published mime package uses `"sideEffects": false`, which causes Vite to tree-shake the side-effect import (`import "./signatures/index.js"`) that registers magic-byte patterns at load time. The result is that `detectFromMagicBytes` returns `undefined` for all inputs in the built package. Inlining the four font signatures here is simpler, zero-dependency, and correct. Should `@suaveplan/mime` be rebuilt with explicit signatures exports, this module can be updated to delegate to it.

View File

@@ -0,0 +1,65 @@
# Utils: unicode-ranges
## Purpose
Parses CSS/OpenType-style Unicode range notation into a `Set<number>` of integer codepoints. Font subsetting workflows need to know which codepoints to retain; this utility bridges the human-readable range format (e.g. `U+0020-007F`) and the numeric representation that font adapters operate on.
## Features
- Parses single codepoints: `U+XXXX`
- Parses inclusive ranges: `U+XXXX-XXXX`
- Case-insensitive for both the `U+` prefix and hex digits
- Merges multiple ranges into a single `Set<number>` with no duplicates
- Throws `FontCompressError(INVALID_OPTIONS)` for malformed syntax or reversed ranges
- Zero external dependencies
## Usage: Basic Example
```typescript
import { parseUnicodeRanges } from "@suaveplan/font-compress/utils/unicode-ranges";
// ASCII printable range
const ascii = parseUnicodeRanges(["U+0020-007F"]);
console.log(ascii.has(0x41)); // true ("A")
console.log(ascii.size); // 96
```
## Usage: Advanced Example
```typescript
import { parseUnicodeRanges } from "@suaveplan/font-compress/utils/unicode-ranges";
import { FontCompressError } from "@suaveplan/font-compress";
// Multi-range merge — Latin + Latin Extended
const ranges = parseUnicodeRanges(["U+0020-007F", "U+00A0-00FF"]);
console.log(ranges.size); // 192
// Catching parse errors
try {
parseUnicodeRanges(["0020-007F"]); // missing U+ prefix
} catch (e) {
if (e instanceof FontCompressError) {
console.error(e.code); // "FONT_COMPRESS.INVALID_OPTIONS"
console.error(e.message); // describes the bad input
}
}
```
## API Reference
### `parseUnicodeRanges(ranges: string[]): Set<number>`
| Parameter | Type | Description |
|---|---|---|
| `ranges` | `string[]` | Array of range strings in `U+XXXX` or `U+XXXX-XXXX` format |
**Returns** a `Set<number>` containing every integer codepoint covered by the union of all input ranges.
**Throws** `FontCompressError` with code `FONT_COMPRESS.INVALID_OPTIONS` when:
- A string does not match the `U+XXXX` or `U+XXXX-XXXX` pattern
- The start codepoint of a range exceeds the end codepoint
## Implementation Notes
The regex `^[Uu]\+([0-9A-Fa-f]{1,6})(?:-([0-9A-Fa-f]{1,6}))?$` validates the entire string in a single pass, avoiding character-by-character parsing. Codepoints up to 6 hex digits are supported (`U+10FFFF` = 1114111), covering the full Unicode range. For very large ranges (e.g. `U+0000-FFFF` = 65536 entries), callers should be aware that `Set` construction is O(n) in the number of codepoints.