# Capability Spec: font-compress **Package**: `@suaveplan/font-compress` **Status**: SHIPPED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. ## Purpose `@suaveplan/font-compress` packs `TypefaceFont` JSON objects from `@suaveplan/font-convert` into the BTF (Binary TypeFace) compact wire format. It is the build-time side of the BTF pipeline; the sibling `@suaveplan/font-decompress` reverses the operation at runtime. Post-split the package's scope is **packer-only** — every unpacker, decoder, and unpacker-side helper now lives in `@suaveplan/font-decompress`, so renderer-tier consumers (which never pack) can install only what they execute. ## Requirements ### Requirement: Packer-only public surface The package SHALL expose `BinaryFontPacker` class + `createBinaryFontPacker()` factory + `FONT_COMPRESS_CODES` registry + `FontCompressError` class, and SHALL NOT expose any unpacker functionality. `BinaryFontUnpacker` and `createBinaryFontUnpacker` are NOT exported from this package — they live in the sibling `@suaveplan/font-decompress`. #### Scenario: root barrel exposes only the packer - **WHEN** a consumer imports `@suaveplan/font-compress` - **THEN** `BinaryFontPacker`, `createBinaryFontPacker`, `FONT_COMPRESS_CODES`, and `FontCompressError` are present; `BinaryFontUnpacker` is undefined **Verified by:** `src/packer/binary-font-packer/binary-font-packer.test.ts::"root barrel exposes packer only"` --- ### Requirement: FONT_COMPRESS_CODES trimmed registry The package SHALL declare an error-code registry via `defineCodes("FONT_COMPRESS", [...])` containing exactly `PACK_FAILED` and `INVALID_DATA`. `UNPACK_FAILED` is NOT in this registry — it lives in `@suaveplan/font-decompress` as `FONT_DECOMPRESS.UNPACK_FAILED`. Every pack-side error path SHALL throw a `FontCompressError` carrying one of the two retained codes. #### Scenario: every kept code is reachable - **WHEN** the test suite exercises a successful pack and a malformed-input pack (e.g. missing `familyName`) - **THEN** the malformed path raises `FontCompressError` whose `code` matches `FONT_COMPRESS.INVALID_DATA` or `FONT_COMPRESS.PACK_FAILED` #### Scenario: UNPACK_FAILED removed from registry - **WHEN** the test suite asserts on `Object.keys(FONT_COMPRESS_CODES).sort()` - **THEN** the array is exactly `["INVALID_DATA", "PACK_FAILED"]` (sorted lexicographically) **Verified by:** `src/error-codes.test.ts::"every kept FONT_COMPRESS.* code is reachable"` AND `src/error-codes.test.ts::"UNPACK_FAILED removed from registry post-split"` --- ### Requirement: BinaryFontPacker public API The package SHALL provide `class BinaryFontPacker` with method `pack(font: TypefaceFont): Uint8Array` and a `createBinaryFontPacker(): BinaryFontPacker` factory function. `TypefaceFont` is imported from `@suaveplan/font-convert`. The class SHALL emit the BTF wire format consumed by `@suaveplan/font-decompress::BinaryFontUnpacker`. #### Scenario: round-trip TypefaceFont → BTF → TypefaceFont - **WHEN** a known-vector `TypefaceFont` is passed to `BinaryFontPacker.pack(...)` and the resulting bytes are then unpacked via `BinaryFontUnpacker.unpack(...)` (from `@suaveplan/font-decompress`) - **THEN** the round-trip preserves `familyName`, `cssFontWeight`, `cssFontStyle`, `boundingBox`, metrics, and every glyph entry (within the dictionary-encoding tolerance documented in the implementation) **Verified by:** `src/packer/binary-font-packer/binary-font-packer.test.ts::"round-trip preserves TypefaceFont shape via font-decompress unpacker"` --- ### Requirement: encodeString utility The package SHALL provide an `encodeString(value: string): Uint8Array` helper used by the packer to emit length-prefixed UTF-8 strings to the BTF header. The function SHALL emit the bytes in the format `[uint32 length][utf8 bytes]`. `decodeString` is NOT in this package — it lives in `@suaveplan/font-decompress`. #### Scenario: emits length-prefixed UTF-8 - **WHEN** `encodeString("Inter Variable")` is called - **THEN** the returned `Uint8Array` is `14` (uint32 length, little-endian) followed by the UTF-8 bytes of `"Inter Variable"` #### Scenario: decodeString export removed - **WHEN** `import { decodeString } from "@suaveplan/font-compress"` is attempted at type-check time - **THEN** TypeScript reports `decodeString` is not exported (the test asserts the runtime absence via `import * as ns from ...; expect(ns).not.toHaveProperty("decodeString")`) **Verified by:** `src/utils/encode-string.test.ts::"emits length-prefixed UTF-8"` AND `src/utils/encode-string.test.ts::"decodeString export removed post-split"` --- ### Requirement: Universal-tier purity The package SHALL ship as a universal-tier package — no imports from `node:*`, `bun:*`, or the bare specifiers `fs`/`path`/`stream`/`net`/`http`/`crypto` in any file under `src/`. `package.json` SHALL NOT declare a `"browser"` field, SHALL NOT pin `engines.node` or `engines.bun`, and SHALL declare `"sideEffects": false`. #### Scenario: no platform imports in src/ - **WHEN** the CI step `grep -rnE "from ['\"](node:|bun:|fs|path|stream|net|http)['\"]" packages/fonts/font-compress/src/` runs - **THEN** the command produces zero matches **Verified by:** `src/packer/binary-font-packer/binary-font-packer.test.ts::"tier purity: no node/bun imports"` --- ### Requirement: Subpath exports trimmed `package.json` SHALL declare subpath exports for `.` (root barrel), `./error-codes`, `./constants`, `./utils/encode-string`, `./utils/parse-path`, `./packer/binary-font-packer`. The `./unpacker/binary-font-unpacker` subpath is removed (no longer present in dist). #### Scenario: unpacker subpath removed - **WHEN** a consumer attempts `import { BinaryFontUnpacker } from "@suaveplan/font-compress/unpacker/binary-font-unpacker"` - **THEN** the import fails to resolve (the subpath is no longer in `package.json` exports and the source files are no longer present) **Verified by:** `src/packer/binary-font-packer/binary-font-packer.test.ts::"unpacker subpath export removed"` --- ### Requirement: Banned patterns absent from src/ The package SHALL ship with zero matches of `@deprecated`, `TODO`, `FIXME`, `ts-ignore`, `ts-expect-error`, `it.skip`, `test.skip`, `describe.skip`, `.only`, raw `any`, or `console.log` in any `.ts` or `.tsx` file under `src/`. Zero `// biome-ignore` suppression comments. All test files import runner primitives from `@suaveplan/testing/runner` (rule §7). #### Scenario: clean static-analysis grep - **WHEN** the CI step `grep -rn "@deprecated\|TODO\|FIXME\|ts-ignore\|ts-expect-error\|\\.skip\\|\\.only\\|\\bany\\b\\|console\\.log" packages/fonts/font-compress/src/ --include="*.ts"` runs - **THEN** the command produces zero matches **Verified by:** repo-wide CI grep gate + `bun scripts/lint/check-test-runner-imports.ts` --- ### Requirement: suaveplan-font-compress bin entry The package SHALL register a bin entry named `suaveplan-font-compress` mapping to `./dist/bin/suaveplan-font-compress.js`. The compiled bin script SHALL start with `#!/usr/bin/env node` and SHALL be executable. Invoking it from a consumer's `node_modules/.bin/` SHALL run the CLI's `main` function with `process.argv.slice(2)`. #### Scenario: bin field declared in package.json - **WHEN** the package's `package.json` is parsed - **THEN** `bin["suaveplan-font-compress"] === "./dist/bin/suaveplan-font-compress.js"` #### Scenario: shebang preserved in compiled output - **WHEN** `dist/bin/suaveplan-font-compress.js` is read after `bunx turbo build --filter=@suaveplan/font-compress` - **THEN** the first line is exactly `#!/usr/bin/env node` **Verified by:** `src/bin/suaveplan-font-compress.test.ts::"package.json bin field maps suaveplan-font-compress to dist/bin entry"` AND `src/bin/suaveplan-font-compress.spec-conformance.test.ts::"compiled bin starts with node shebang"` --- ### Requirement: Argument parser with help and version The package SHALL accept the following flags: positional ``; `--input ` / `-i `; `--output ` / `-o `; `--format=btf|json`; `--force` / `-f`; `--recursive` / `-r`; `--no-recursive`; `--preserve-structure`; `--no-preserve-structure`; `--help` / `-h`; `--version` / `-v`. `--help` SHALL print usage to stdout and exit `0`. `--version` SHALL print the package version (read from `package.json`) and exit `0`. Unknown flags SHALL print an error to stderr and exit `2`. Conflicting flags (e.g. `--recursive` and `--no-recursive` together) SHALL print an error to stderr and exit `2`. #### Scenario: --help short-circuits without doing FS work - **WHEN** the bin is invoked with `["--help"]` - **THEN** stdout contains the string `"USAGE"` AND the exit code is `0` #### Scenario: --version reads from package.json - **WHEN** the bin is invoked with `["--version"]` - **THEN** stdout contains the same version string as `JSON.parse(readFileSync('package.json')).version` AND the exit code is `0` #### Scenario: unknown flag exits 2 - **WHEN** the bin is invoked with `["--no-such-flag"]` - **THEN** stderr contains the string `"Unknown option"` AND the exit code is `2` #### Scenario: conflicting recursive flags exit 2 - **WHEN** the bin is invoked with `["./fonts", "--recursive", "--no-recursive"]` - **THEN** the exit code is `2` AND stderr describes the conflict **Verified by:** `src/bin/suaveplan-font-compress.test.ts::"--help short-circuits with exit 0"` AND `src/bin/suaveplan-font-compress.test.ts::"--version prints package version with exit 0"` AND `src/bin/suaveplan-font-compress.test.ts::"unknown flag exits 2"` AND `src/bin/suaveplan-font-compress.test.ts::"conflicting recursive flags exit 2"` --- ### Requirement: Input dispatcher selects convert + pack vs pack-only The package SHALL inspect the input path's extension. For `.ttf`, `.otf`, `.woff`, `.woff2` (and directories containing them when `--recursive`) the bin SHALL call `@suaveplan/font-convert::FontConverter` (single file) or `BatchConverter` (directory) to produce a `TypefaceFont`, then call `BinaryFontPacker.pack(typefaceFont)` to emit a `Uint8Array`, then write the `.btf` file. For `.json` inputs (a previously-converted typeface JSON) the bin SHALL skip conversion and pack directly after schema validation. #### Scenario: TTF input runs convert then pack - **WHEN** the bin is invoked with a single `.ttf` file path - **THEN** `FontConverter.convert` is called once AND `BinaryFontPacker.pack` is called once with the converter's output AND a `.btf` file is written #### Scenario: JSON input runs pack only - **WHEN** the bin is invoked with a single `.json` file path containing a valid `TypefaceFont` - **THEN** `FontConverter.convert` is not called AND `BinaryFontPacker.pack` is called once with the parsed JSON AND a `.btf` file is written #### Scenario: directory input uses BatchConverter - **WHEN** the bin is invoked with a directory path containing two `.ttf` files at `--recursive` - **THEN** `BatchConverter` is constructed AND `BinaryFontPacker.pack` is called twice AND two `.btf` files are written under `--output` **Verified by:** `src/bin/suaveplan-font-compress.test.ts::"TTF input runs convert then pack"` AND `src/bin/suaveplan-font-compress.test.ts::"JSON input runs pack only"` AND `src/bin/integration/cli-roundtrip.test.ts::"directory of TTFs produces matching BTF outputs"` --- ### Requirement: --format=json emits typeface JSON instead of BTF The package SHALL accept `--format=json` to emit the converter's `TypefaceFont` as JSON instead of running it through `BinaryFontPacker`. This mode SHALL write `.json` files. JSON-input + `--format=json` SHALL be a no-op pass-through (read JSON, validate, write to output path). #### Scenario: --format=json writes typeface JSON - **WHEN** the bin is invoked with a `.ttf` input and `--format=json` - **THEN** a `.json` file is written AND `BinaryFontPacker.pack` is not called AND the JSON parses as a valid `TypefaceFont` #### Scenario: default format is btf - **WHEN** the bin is invoked without `--format` - **THEN** `--format=btf` is the effective default AND `.btf` files are written **Verified by:** `src/bin/suaveplan-font-compress.test.ts::"--format=json writes typeface JSON instead of BTF"` AND `src/bin/suaveplan-font-compress.test.ts::"default format is btf"` --- ### Requirement: Structured exit codes The package SHALL return distinct exit codes: `0` all inputs succeeded; `1` one or more inputs failed during conversion or packing (per-file errors isolated; batch continues); `2` invalid CLI arguments (unknown flag, missing required positional, conflicting flags); `3` the input path does not exist on disk. #### Scenario: missing input path exits 3 - **WHEN** the bin is invoked with a positional input that does not exist - **THEN** the exit code is `3` AND stderr contains the string `"does not exist"` #### Scenario: per-file failure exits 1, not 2 - **WHEN** the bin is invoked over a directory where one TTF file is corrupt and three are valid - **THEN** the exit code is `1` AND three `.btf` files are written AND the corrupt file's error is captured to stderr **Verified by:** `src/bin/suaveplan-font-compress.test.ts::"missing input path exits 3"` AND `src/bin/integration/cli-roundtrip.test.ts::"corrupt TTF in directory yields exit 1 with siblings still written"` --- ### Requirement: FONT_COMPRESS.CLI_INVALID_ARGS error code The package SHALL register `FONT_COMPRESS.CLI_INVALID_ARGS` in `FONT_COMPRESS_CODES`. Argument-parsing failures SHALL throw `FontCompressError` with this code (caught by `main`'s top-level handler, mapped to exit `2`). Plain `Error` SHALL NOT be thrown from the CLI argument-parsing path. #### Scenario: code is registered - **WHEN** `FONT_COMPRESS_CODES` is enumerated - **THEN** the array includes `"FONT_COMPRESS.CLI_INVALID_ARGS"` #### Scenario: bad args throw the coded error - **WHEN** the bin is invoked with an unknown flag and the throw is intercepted (in-process driver, not subprocess) - **THEN** a `FontCompressError` is thrown with `code === "FONT_COMPRESS.CLI_INVALID_ARGS"` AND `context.flag === "--no-such-flag"` **Verified by:** `src/error-codes.test.ts::"FONT_COMPRESS_CODES includes CLI_INVALID_ARGS"` AND `src/bin/suaveplan-font-compress.spec-conformance.test.ts::"argument-parser throws FontCompressError with CLI_INVALID_ARGS"` --- ### Requirement: Progress reporting and summary The package SHALL print per-file progress lines while processing a batch (one line per converted file with the family name and compression ratio) and a summary block at the end (total files, succeeded, failed, total original bytes, total compressed bytes, compression ratio, output path). Progress lines SHALL go to stdout. Errors SHALL go to stderr. #### Scenario: per-file progress lines printed - **WHEN** the bin is invoked over a directory with three valid TTFs - **THEN** stdout contains three lines starting with the per-file marker (`✓` or equivalent) AND each line names the family #### Scenario: summary block printed at end - **WHEN** the same invocation completes - **THEN** stdout ends with a summary block containing `Total fonts: 3`, `Converted: 3`, `Errors: 0` AND a compression-ratio line **Verified by:** `src/bin/integration/cli-roundtrip.test.ts::"per-file progress lines and summary block printed"`