Files
2026-05-27 23:29:53 +01:00

255 lines
15 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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>`; `--input <path>` / `-i <path>`; `--output <path>` / `-o <path>`; `--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 `<basename>.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"`