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

15 KiB

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"