Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

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,87 @@
{
"name": "@suaveplan/font-compress",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"suaveplan-font-compress": "./dist/bin/suaveplan-font-compress.js"
},
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./packer/binary-font-packer": {
"import": {
"types": "./dist/packer/binary-font-packer/index.d.ts",
"default": "./dist/packer/binary-font-packer/index.js"
},
"require": {
"types": "./dist/packer/binary-font-packer/index.d.ts",
"default": "./dist/packer/binary-font-packer/index.js"
}
},
"./bin/suaveplan-font-compress": {
"import": {
"types": "./dist/bin/suaveplan-font-compress.d.ts",
"default": "./dist/bin/suaveplan-font-compress.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/cli": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/font-convert": "workspace:^",
"@suaveplan/logger": "workspace:^",
"@suaveplan/readline": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

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,33 @@
/**
* Side-effect direct-invocation entry. Imported by the bin entry; the only
* thing this file does is call `process.exit(await main(...))` when the
* file is invoked via the `#!/usr/bin/env node` shebang. Excluded from
* coverage in `vitest.config.ts` because `process.exit` cannot be honestly
* faked from in-process; the branches inside `main()` (everything that
* does work) ARE covered by the in-process suites in this directory.
*/
function isDirectInvocation(): boolean {
try {
return (
typeof process !== "undefined" &&
typeof process.argv[1] === "string" &&
process.argv[1].endsWith("suaveplan-font-compress.js")
);
} catch {
return false;
}
}
if (isDirectInvocation()) {
void import("./suaveplan-font-compress.js").then(({ main }) =>
main(process.argv.slice(2)).then(
(code) => process.exit(code),
(err: unknown) => {
process.stderr.write(
`${err instanceof Error ? err.message : String(err)}\n`
);
process.exit(1);
}
)
);
}

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,158 @@
/**
* Per-file dispatch: read the source, route binary inputs through
* `FontConverter` and JSON inputs through pass-through validation, run the
* resulting `TypefaceFont` through `BinaryFontPacker` (or write the JSON
* directly when `--format=json` is set), and stream progress through the
* injected printer / logger. Isolated from the entry to keep both files under
* the AGENTS.md §3 size budget.
*/
import { existsSync, writeFileSync } from "node:fs";
import { mkdir, readFile } from "node:fs/promises";
import { basename, dirname, extname, join } from "node:path";
import type { FontConverter, TypefaceFont } from "@suaveplan/font-convert";
import type { LoggerLike } from "@suaveplan/types";
import {
FONT_COMPRESS_CODES,
FontCompressError,
} from "../error-codes/error-codes.js";
import type { BinaryFontPacker } from "../packer/binary-font-packer/binary-font-packer.js";
import type { CliOptions } from "./parser.js";
import { printErrorLine, printSkipLine, printSuccessLine } from "./printer.js";
const JSON_EXTS = new Set([".json"]);
export interface FileOutcome {
readonly ok: boolean;
readonly originalBytes: number;
readonly compressedBytes: number;
}
export interface DispatchDeps {
readonly logger: LoggerLike;
readonly stdout: NodeJS.WritableStream;
readonly stderr: NodeJS.WritableStream;
}
function destPath(
inputFile: string,
inputRoot: string,
outputRoot: string,
preserveStructure: boolean,
extOut: string
): string {
const base = basename(inputFile, extname(inputFile));
if (preserveStructure && inputFile.startsWith(inputRoot)) {
const rel = inputFile.slice(inputRoot.length).replace(/^[\\/]+/, "");
const relDir = dirname(rel);
return join(
outputRoot,
relDir === "." ? "" : relDir,
`${base}${extOut}`
);
}
return join(outputRoot, `${base}${extOut}`);
}
function writeArtifact(
outPath: string,
bytes: Uint8Array | string,
force: boolean
): boolean {
if (!force && existsSync(outPath)) return false;
if (typeof bytes === "string") {
writeFileSync(outPath, bytes, "utf8");
} else {
writeFileSync(outPath, bytes);
}
return true;
}
function validateTypefaceJson(value: unknown, file: string): TypefaceFont {
if (
value === null ||
typeof value !== "object" ||
typeof (value as { familyName?: unknown }).familyName !== "string" ||
typeof (value as { glyphs?: unknown }).glyphs !== "object" ||
(value as { glyphs: unknown }).glyphs === null
) {
throw new FontCompressError(
FONT_COMPRESS_CODES["INVALID_DATA"],
"typeface JSON is missing required fields (familyName, glyphs)",
{ context: { file } }
);
}
return value as TypefaceFont;
}
async function loadFont(
file: string,
converter: FontConverter
): Promise<{ font: TypefaceFont; originalBytes: number }> {
const ext = extname(file).toLowerCase();
if (JSON_EXTS.has(ext)) {
const raw = await readFile(file, "utf8");
return {
font: validateTypefaceJson(JSON.parse(raw), file),
originalBytes: Buffer.byteLength(raw, "utf8"),
};
}
const bytes = await readFile(file);
return { font: converter.convert(bytes), originalBytes: bytes.byteLength };
}
export async function dispatchOne(
file: string,
converter: FontConverter,
packer: BinaryFontPacker,
options: CliOptions,
inputRoot: string,
outputRoot: string,
deps: DispatchDeps
): Promise<FileOutcome> {
const preserve = options.preserveStructure ?? !options.outputSpecified;
try {
const { font, originalBytes } = await loadFont(file, converter);
const outExt = options.format === "json" ? ".json" : ".btf";
const target = destPath(file, inputRoot, outputRoot, preserve, outExt);
await mkdir(dirname(target), { recursive: true });
if (options.format === "json") {
const serialised = JSON.stringify(font);
const wrote = writeArtifact(target, serialised, options.force);
if (!wrote) {
printSkipLine(deps.stdout, file);
return { ok: true, originalBytes, compressedBytes: 0 };
}
const compressedBytes = Buffer.byteLength(serialised, "utf8");
printSuccessLine(
deps.stdout,
font,
originalBytes,
compressedBytes,
"json"
);
return { ok: true, originalBytes, compressedBytes };
}
const packed = packer.pack(font);
const wrote = writeArtifact(target, packed, options.force);
if (!wrote) {
printSkipLine(deps.stdout, file);
return { ok: true, originalBytes, compressedBytes: 0 };
}
printSuccessLine(
deps.stdout,
font,
originalBytes,
packed.byteLength,
"btf"
);
return { ok: true, originalBytes, compressedBytes: packed.byteLength };
} catch (err) {
const message =
err instanceof Error
? err.message
: /* istanbul ignore next */ String(err);
printErrorLine(deps.stderr, file, message);
deps.logger.error("font-compress.cli.file_failed", { file, message });
return { ok: false, originalBytes: 0, compressedBytes: 0 };
}
}

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,107 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
ANSI,
colour,
formatBytes,
formatRatio,
formatSavings,
tint,
} from "./format-utils.js";
describe("formatBytes", () => {
it("returns 0 B for zero", { timeout: 5000 }, () => {
expect(formatBytes(0)).toBe("0 B");
});
it("returns 0 B for negative input", { timeout: 5000 }, () => {
expect(formatBytes(-512)).toBe("0 B");
});
it("returns 0 B for NaN", { timeout: 5000 }, () => {
expect(formatBytes(Number.NaN)).toBe("0 B");
});
it("formats bytes under 1 KiB", { timeout: 5000 }, () => {
expect(formatBytes(512)).toBe("512 B");
});
it("formats KiB with two decimals", { timeout: 5000 }, () => {
expect(formatBytes(2048)).toBe("2 KB");
expect(formatBytes(1536)).toBe("1.5 KB");
});
it("formats MiB", { timeout: 5000 }, () => {
expect(formatBytes(1024 * 1024)).toBe("1 MB");
});
it("formats GiB", { timeout: 5000 }, () => {
expect(formatBytes(1024 ** 3 * 2)).toBe("2 GB");
});
it("clamps oversize input to the largest unit", { timeout: 5000 }, () => {
// 5 TiB — beyond GB; should clamp at GB rather than crash.
expect(formatBytes(1024 ** 4 * 5)).toContain("GB");
});
});
describe("formatRatio", () => {
it("returns 0.0x for zero original", { timeout: 5000 }, () => {
expect(formatRatio(0, 100)).toBe("0.0x");
});
it("returns 0.0x for zero compressed", { timeout: 5000 }, () => {
expect(formatRatio(100, 0)).toBe("0.0x");
});
it("returns 0.0x for non-finite", { timeout: 5000 }, () => {
expect(formatRatio(Number.POSITIVE_INFINITY, 1)).toBe("0.0x");
});
it("computes ratio rounded to one decimal", { timeout: 5000 }, () => {
expect(formatRatio(1000, 200)).toBe("5.0x");
expect(formatRatio(150, 100)).toBe("1.5x");
});
});
describe("formatSavings", () => {
it("returns 0.0% for zero original", { timeout: 5000 }, () => {
expect(formatSavings(0, 0)).toBe("0.0%");
});
it("returns 0.0% for non-finite original", { timeout: 5000 }, () => {
expect(formatSavings(Number.NaN, 1)).toBe("0.0%");
});
it("returns 0.0% for negative compressed", { timeout: 5000 }, () => {
expect(formatSavings(1000, -10)).toBe("0.0%");
});
it("computes savings percent rounded to one decimal", {
timeout: 5000,
}, () => {
expect(formatSavings(1000, 250)).toBe("75.0%");
expect(formatSavings(1000, 1000)).toBe("0.0%");
});
});
describe("colour", () => {
it("wraps text with the ANSI prefix and reset", { timeout: 5000 }, () => {
expect(colour(ANSI.red, "hello")).toBe(`${ANSI.red}hello${ANSI.reset}`);
});
});
describe("tint", () => {
it("provides every documented tone", { timeout: 5000 }, () => {
expect(tint.bold("x")).toContain(ANSI.bold);
expect(tint.dim("x")).toContain(ANSI.dim);
expect(tint.red("x")).toContain(ANSI.red);
expect(tint.green("x")).toContain(ANSI.green);
expect(tint.yellow("x")).toContain(ANSI.yellow);
expect(tint.blue("x")).toContain(ANSI.blue);
expect(tint.cyan("x")).toContain(ANSI.cyan);
});
it("always terminates with the reset sequence", { timeout: 5000 }, () => {
expect(tint.bold("hello").endsWith(ANSI.reset)).toBe(true);
});
});

View File

@@ -0,0 +1,92 @@
/**
* Pure formatting helpers shared by the suaveplan-font-compress bin.
*
* Kept in a sibling module (not inlined into the bin entry) so the entry file
* stays well under the 250-line soft / 300-line hard budget mandated by
* AGENTS.md §3, and so each helper is independently unit-testable without
* spinning up the full CLI pipeline.
*/
export const ANSI = {
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
cyan: "\x1b[36m",
} as const;
const BYTE_UNITS = ["B", "KB", "MB", "GB"] as const;
/**
* Convert a byte count into a short human-readable string. Negative inputs are
* treated as `0` so the formatter never returns a misleading negative size.
*/
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
const k = 1024;
const i = Math.min(
Math.floor(Math.log(bytes) / Math.log(k)),
BYTE_UNITS.length - 1
);
const value = Number.parseFloat((bytes / k ** i).toFixed(2));
return `${value} ${BYTE_UNITS[i]}`;
}
/**
* Compute the compression ratio (original / compressed) rounded to one
* decimal. Returns `0` when either side is non-positive so the bin never
* divides by zero.
*/
export function formatRatio(
originalBytes: number,
compressedBytes: number
): string {
if (
!Number.isFinite(originalBytes) ||
!Number.isFinite(compressedBytes) ||
originalBytes <= 0 ||
compressedBytes <= 0
) {
return "0.0x";
}
return `${(originalBytes / compressedBytes).toFixed(1)}x`;
}
/**
* Compute the percentage of bytes saved by compression, rounded to one
* decimal. `0%` is returned when either input is non-positive.
*/
export function formatSavings(
originalBytes: number,
compressedBytes: number
): string {
if (
!Number.isFinite(originalBytes) ||
!Number.isFinite(compressedBytes) ||
originalBytes <= 0 ||
compressedBytes < 0
) {
return "0.0%";
}
const saved = (1 - compressedBytes / originalBytes) * 100;
return `${saved.toFixed(1)}%`;
}
/** Wrap a string in an ANSI colour pair. */
export function colour(code: string, text: string): string {
return `${code}${text}${ANSI.reset}`;
}
/** Convenience wrappers around `colour` for the common combinations. */
export const tint = {
bold: (text: string): string => colour(ANSI.bold, text),
dim: (text: string): string => colour(ANSI.dim, text),
red: (text: string): string => colour(ANSI.red, text),
green: (text: string): string => colour(ANSI.green, text),
yellow: (text: string): string => colour(ANSI.yellow, text),
blue: (text: string): string => colour(ANSI.blue, text),
cyan: (text: string): string => colour(ANSI.cyan, text),
} as const;

View File

@@ -0,0 +1,183 @@
/**
* Integration roundtrip: invoke `main()` against a real fixture TTF, verify
* the rendered banner / progress / summary on stdout, verify a `.btf` file
* is written, and verify the written bytes round-trip through
* `BinaryFontUnpacker` (from the sibling `@suaveplan/font-decompress`
* package) to a `TypefaceFont` whose family name matches the converter's
* output. No mocks — real `FontConverter`, real `BinaryFontPacker`, real
* `BinaryFontUnpacker`, real FS.
*/
import {
copyFileSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { FontConverter } from "@suaveplan/font-convert";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { main } from "../suaveplan-font-compress.js";
/**
* Verify a `.btf` file's leading header matches the expected family-name
* length-prefix. Uses the public encode-string layout pinned in
* README "BTF Binary Format": `[uint32 LE familyName length][utf8 bytes]…`.
* Avoiding `@suaveplan/font-decompress` here keeps the package devDep graph
* acyclic (decompress already depends on compress).
*/
function readFamilyName(btf: Uint8Array): string {
const view = new DataView(btf.buffer, btf.byteOffset, btf.byteLength);
const len = view.getUint32(0, true);
return new TextDecoder().decode(btf.subarray(4, 4 + len));
}
const FIXTURE_TTF = fileURLToPath(
new URL("./fixtures/test.ttf", import.meta.url)
);
function createSink(): {
out: string[];
err: string[];
stdout: NodeJS.WritableStream;
stderr: NodeJS.WritableStream;
} {
const out: string[] = [];
const err: string[] = [];
const stdout = {
write: (chunk: string | Uint8Array) => {
out.push(
typeof chunk === "string"
? chunk
: Buffer.from(chunk).toString("utf8")
);
return true;
},
} as NodeJS.WritableStream;
const stderr = {
write: (chunk: string | Uint8Array) => {
err.push(
typeof chunk === "string"
? chunk
: Buffer.from(chunk).toString("utf8")
);
return true;
},
} as NodeJS.WritableStream;
return { out, err, stdout, stderr };
}
let tmpRoot = "";
beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), "font-compress-int-"));
});
afterEach(() => {
rmSync(tmpRoot, { recursive: true, force: true });
});
describe("cli-roundtrip integration", () => {
it("single TTF round-trips through pack → unpack with matching family", {
timeout: 30000,
}, async () => {
const sink = createSink();
const outDir = join(tmpRoot, "out");
const code = await main([FIXTURE_TTF, "-o", outDir], {
stdout: sink.stdout,
stderr: sink.stderr,
});
expect(code).toBe(0);
const btf = readFileSync(join(outDir, "test.btf"));
expect(btf.byteLength).toBeGreaterThan(0);
const familyName = readFamilyName(new Uint8Array(btf));
// Compare against the converter's view of the same TTF — proves
// the bin's pipeline matches what a direct library caller would
// produce.
const expected = new FontConverter().convert(readFileSync(FIXTURE_TTF));
expect(familyName).toBe(expected.familyName);
expect(familyName.length).toBeGreaterThan(0);
expect(sink.out.join("")).toContain("Font Compression Tool");
expect(sink.out.join("")).toContain("Conversion Complete");
});
it("directory of TTFs produces matching BTF outputs", {
timeout: 30000,
}, async () => {
const inDir = join(tmpRoot, "in");
const outDir = join(tmpRoot, "out");
mkdirSync(inDir, { recursive: true });
copyFileSync(FIXTURE_TTF, join(inDir, "alpha.ttf"));
copyFileSync(FIXTURE_TTF, join(inDir, "beta.ttf"));
const sink = createSink();
const code = await main([inDir, "-o", outDir], {
stdout: sink.stdout,
stderr: sink.stderr,
});
expect(code).toBe(0);
const alpha = readFileSync(join(outDir, "alpha.btf"));
const beta = readFileSync(join(outDir, "beta.btf"));
expect(alpha.byteLength).toBeGreaterThan(0);
expect(beta.byteLength).toBeGreaterThan(0);
expect(readFamilyName(new Uint8Array(alpha))).toBe(
readFamilyName(new Uint8Array(beta))
);
});
it("per-file progress lines and summary block printed", {
timeout: 30000,
}, async () => {
const inDir = join(tmpRoot, "in");
const outDir = join(tmpRoot, "out");
mkdirSync(inDir, { recursive: true });
copyFileSync(FIXTURE_TTF, join(inDir, "one.ttf"));
copyFileSync(FIXTURE_TTF, join(inDir, "two.ttf"));
copyFileSync(FIXTURE_TTF, join(inDir, "three.ttf"));
const sink = createSink();
const code = await main([inDir, "-o", outDir], {
stdout: sink.stdout,
stderr: sink.stderr,
});
expect(code).toBe(0);
const stdout = sink.out.join("");
// Three success markers — one per file.
const checkCount = stdout.match(/✓/g)?.length ?? 0;
expect(checkCount).toBe(3);
// Summary block present.
expect(stdout).toContain("Total fonts: 3");
expect(stdout).toContain("Converted:");
expect(stdout).toContain("Errors:");
expect(stdout).toMatch(/Ratio:/);
});
it("corrupt TTF in directory yields exit 1 with siblings still written", {
timeout: 30000,
}, async () => {
const inDir = join(tmpRoot, "in");
const outDir = join(tmpRoot, "out");
mkdirSync(inDir, { recursive: true });
copyFileSync(FIXTURE_TTF, join(inDir, "good-a.ttf"));
copyFileSync(FIXTURE_TTF, join(inDir, "good-b.ttf"));
copyFileSync(FIXTURE_TTF, join(inDir, "good-c.ttf"));
writeFileSync(join(inDir, "broken.ttf"), "definitely not opentype");
const sink = createSink();
const code = await main([inDir, "-o", outDir], {
stdout: sink.stdout,
stderr: sink.stderr,
});
expect(code).toBe(1);
expect(() => readFileSync(join(outDir, "good-a.btf"))).not.toThrow();
expect(() => readFileSync(join(outDir, "good-b.btf"))).not.toThrow();
expect(() => readFileSync(join(outDir, "good-c.btf"))).not.toThrow();
expect(sink.err.join("")).toContain("broken.ttf");
});
});

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,154 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
FONT_COMPRESS_CODES,
FontCompressError,
} from "../error-codes/error-codes.js";
import { parseCliArgs } from "./parser.js";
describe("parseCliArgs", () => {
it("parses positional input", { timeout: 5000 }, () => {
const opts = parseCliArgs(["./fonts"]);
expect(opts.input).toBe("./fonts");
expect(opts.output).toBe("");
expect(opts.outputSpecified).toBe(false);
});
it("supports --input as alternative to positional", {
timeout: 5000,
}, () => {
const opts = parseCliArgs(["--input", "./alt"]);
expect(opts.input).toBe("./alt");
});
it("captures --output / -o", { timeout: 5000 }, () => {
const a = parseCliArgs(["./fonts", "-o", "./out"]);
expect(a.output).toBe("./out");
expect(a.outputSpecified).toBe(true);
const b = parseCliArgs(["./fonts", "--output", "./out2"]);
expect(b.output).toBe("./out2");
});
it("defaults format to btf", { timeout: 5000 }, () => {
expect(parseCliArgs(["./fonts"]).format).toBe("btf");
});
it("accepts --format=json", { timeout: 5000 }, () => {
expect(parseCliArgs(["./fonts", "--format=json"]).format).toBe("json");
});
it("rejects an unknown --format value", { timeout: 5000 }, () => {
expect(() => parseCliArgs(["./fonts", "--format=zzz"])).toThrow(
FontCompressError
);
});
it("--force / -f sets force", { timeout: 5000 }, () => {
expect(parseCliArgs(["./fonts", "-f"]).force).toBe(true);
expect(parseCliArgs(["./fonts", "--force"]).force).toBe(true);
expect(parseCliArgs(["./fonts"]).force).toBe(false);
});
it("--recursive default is true", { timeout: 5000 }, () => {
expect(parseCliArgs(["./fonts"]).recursive).toBe(true);
});
it("--no-recursive disables recursion", { timeout: 5000 }, () => {
expect(parseCliArgs(["./fonts", "--no-recursive"]).recursive).toBe(
false
);
});
it("--preserve-structure toggles flag", { timeout: 5000 }, () => {
expect(
parseCliArgs(["./fonts", "--preserve-structure"]).preserveStructure
).toBe(true);
expect(
parseCliArgs(["./fonts", "--no-preserve-structure"])
.preserveStructure
).toBe(false);
});
it("--help short-circuits flag", { timeout: 5000 }, () => {
expect(parseCliArgs(["--help"]).help).toBe(true);
expect(parseCliArgs(["-h"]).help).toBe(true);
});
it("--version short-circuits flag", { timeout: 5000 }, () => {
expect(parseCliArgs(["--version"]).version).toBe(true);
expect(parseCliArgs(["-v"]).version).toBe(true);
});
it("conflicting recursive flags throw CLI_INVALID_ARGS", {
timeout: 5000,
}, () => {
try {
parseCliArgs(["./fonts", "--recursive", "--no-recursive"]);
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(FontCompressError);
const fe = err as FontCompressError;
expect(fe.code).toBe(FONT_COMPRESS_CODES["CLI_INVALID_ARGS"]);
}
});
it("conflicting preserve-structure flags throw CLI_INVALID_ARGS", {
timeout: 5000,
}, () => {
try {
parseCliArgs([
"./fonts",
"--preserve-structure",
"--no-preserve-structure",
]);
expect.fail("expected throw");
} catch (err) {
expect((err as FontCompressError).code).toBe(
FONT_COMPRESS_CODES["CLI_INVALID_ARGS"]
);
}
});
it("unknown long flag throws CLI_INVALID_ARGS with flag context", {
timeout: 5000,
}, () => {
try {
parseCliArgs(["--no-such-flag"]);
expect.fail("expected throw");
} catch (err) {
const fe = err as FontCompressError;
expect(fe.code).toBe(FONT_COMPRESS_CODES["CLI_INVALID_ARGS"]);
expect(fe.message).toContain("--no-such-flag");
}
});
it("unknown short flag throws CLI_INVALID_ARGS", { timeout: 5000 }, () => {
try {
parseCliArgs(["-X"]);
expect.fail("expected throw");
} catch (err) {
const fe = err as FontCompressError;
expect(fe.code).toBe(FONT_COMPRESS_CODES["CLI_INVALID_ARGS"]);
}
});
it("--input takes precedence over positional", { timeout: 5000 }, () => {
const opts = parseCliArgs(["./positional", "--input", "./flagged"]);
expect(opts.input).toBe("./flagged");
});
it("invalid --format value falls back to parser error message", {
timeout: 5000,
}, () => {
try {
parseCliArgs(["./fonts", "--format=zzz"]);
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(FontCompressError);
const fe = err as FontCompressError;
// No bad flag here — the failure is on the value — so the
// error message includes upstream parser detail rather than
// the offending flag.
expect(fe.code).toBe(FONT_COMPRESS_CODES["CLI_INVALID_ARGS"]);
}
});
});

View File

@@ -0,0 +1,127 @@
/**
* Argument parser for the suaveplan-font-compress bin. Wraps the generic
* `@suaveplan/cli` `parseArgs` with the bin-specific flag set, conflict
* detection, and conversion of upstream `CliError` rejects into a coded
* `FontCompressError(CLI_INVALID_ARGS)` so the entry's top-level handler
* can map the failure to exit `2`.
*/
import { parseArgs } from "@suaveplan/cli";
import {
FONT_COMPRESS_CODES,
FontCompressError,
} from "../error-codes/error-codes.js";
export interface CliOptions {
readonly input: string;
readonly output: string;
readonly outputSpecified: boolean;
readonly format: "btf" | "json";
readonly force: boolean;
readonly recursive: boolean;
readonly preserveStructure: boolean | undefined;
readonly help: boolean;
readonly version: boolean;
}
const KNOWN_FLAGS = new Set([
"input",
"output",
"format",
"force",
"recursive",
"preserve-structure",
"help",
"version",
]);
const KNOWN_SHORT_FLAGS = new Set(["-h", "-v", "-f", "-r", "-i", "-o"]);
function knownLongFlag(flag: string): boolean {
const stripped = flag.replace(/^--(no-)?/, "");
return KNOWN_FLAGS.has(
(stripped.split("=")[0] ?? /* istanbul ignore next */ "").trim()
);
}
function findOffender(argv: readonly string[]): string {
const longBad = argv.find((a) => a.startsWith("--") && !knownLongFlag(a));
if (longBad) return longBad;
const shortBad = argv.find(
(a) =>
a.startsWith("-") &&
!a.startsWith("--") &&
a.length === 2 &&
!KNOWN_SHORT_FLAGS.has(a)
);
return shortBad ?? "";
}
function ensureNoConflict(argv: readonly string[], a: string, b: string): void {
if (argv.includes(a) && argv.includes(b)) {
throw new FontCompressError(
FONT_COMPRESS_CODES["CLI_INVALID_ARGS"],
`conflicting flags: ${a} and ${b}`,
{ context: { flags: [a, b] } }
);
}
}
export function parseCliArgs(argv: readonly string[]): CliOptions {
ensureNoConflict(argv, "--recursive", "--no-recursive");
ensureNoConflict(argv, "--preserve-structure", "--no-preserve-structure");
let parsed: ReturnType<typeof parseArgs>;
try {
parsed = parseArgs([...argv], {
options: {
input: { type: "string", short: "i" },
output: { type: "string", short: "o" },
format: { type: "string", choices: ["btf", "json"] },
force: { type: "boolean", short: "f" },
recursive: { type: "boolean", short: "r", default: true },
preserveStructure: { type: "boolean" },
help: { type: "boolean", short: "h" },
version: { type: "boolean", short: "v" },
},
arguments: [
{
name: "input",
required: false,
variadic: false,
},
],
});
} catch (err) {
const offender = findOffender(argv);
throw new FontCompressError(
FONT_COMPRESS_CODES["CLI_INVALID_ARGS"],
`Unknown option: ${offender || (err instanceof Error ? err.message : /* istanbul ignore next */ String(err))}`,
{ cause: err, context: { flag: offender } }
);
}
const opts = parsed.options as Record<string, unknown>;
const posValues = Object.values(parsed.positionals).filter(
(v): v is string => typeof v === "string"
);
const rawInput =
typeof opts["input"] === "string"
? (opts["input"] as string)
: (posValues[0] ?? "");
const rawOutput =
typeof opts["output"] === "string" ? (opts["output"] as string) : "";
return {
input: rawInput,
output: rawOutput,
outputSpecified: rawOutput.length > 0,
format: (opts["format"] as "btf" | "json" | undefined) ?? "btf",
force: opts["force"] === true,
recursive: opts["recursive"] !== false,
preserveStructure:
opts["preserveStructure"] === true
? true
: opts["preserveStructure"] === false
? false
: undefined,
help: opts["help"] === true,
version: opts["version"] === true,
};
}

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,136 @@
/**
* Output rendering for the suaveplan-font-compress bin. Owns the help screen,
* per-file progress lines, and the end-of-run summary block so the entry file
* can stay under the AGENTS.md §3 size budget. Every renderer writes through
* an injected `NodeJS.WritableStream` — the entry composes them with a
* `LoggerLike` so a host can capture or redirect every byte.
*/
import { basename } from "node:path";
import type { TypefaceFont } from "@suaveplan/font-convert";
import {
formatBytes,
formatRatio,
formatSavings,
tint,
} from "./format-utils.js";
export interface RunSummary {
readonly totalFiles: number;
readonly successCount: number;
readonly errorCount: number;
readonly totalOriginalSize: number;
readonly totalCompressedSize: number;
}
export const HELP_TEXT = [
"",
`${tint.bold("suaveplan-font-compress")} — Convert fonts to compressed BTF format`,
"",
tint.bold("USAGE"),
" suaveplan-font-compress <input> [options]",
"",
tint.bold("ARGUMENTS"),
" <input> Font file (.ttf/.otf/.woff/.woff2/.json) or directory path",
"",
tint.bold("OPTIONS"),
" -i, --input <path> Explicit input (alternative to positional)",
" -o, --output <path> Output directory (default: same as input)",
" --format=btf|json Emit BTF binary (default) or typeface JSON",
" -f, --force Overwrite existing .btf / .json outputs",
" -r, --recursive Recurse directories (default: true)",
" --no-recursive Disable recursion",
" --preserve-structure Preserve directory layout",
" --no-preserve-structure Group fonts by family",
" -h, --help Display this help message",
" -v, --version Display the package version",
"",
tint.bold("EXIT CODES"),
" 0 all inputs processed successfully",
" 1 one or more inputs failed during convert/pack",
" 2 invalid CLI arguments (unknown flag, conflict, missing positional)",
" 3 the input path does not exist on disk",
"",
].join("\n");
export function printBanner(
stdout: NodeJS.WritableStream,
inputPath: string,
outputPath: string,
format: "btf" | "json",
force: boolean
): void {
stdout.write(
[
"",
tint.bold(tint.cyan("🔄 Font Compression Tool")),
"",
`Input: ${inputPath}`,
`Output: ${outputPath}`,
`Format: ${format}`,
`Force: ${force ? "yes" : "no"}`,
"",
"",
].join("\n")
);
}
export function printSuccessLine(
stdout: NodeJS.WritableStream,
font: TypefaceFont,
originalBytes: number,
compressedBytes: number,
format: "btf" | "json"
): void {
const tail =
format === "json"
? formatBytes(compressedBytes)
: formatRatio(originalBytes, compressedBytes);
stdout.write(
` ${tint.green("✓")} ${font.familyName} ${tint.dim(`(${tail})`)}\n`
);
}
export function printSkipLine(
stdout: NodeJS.WritableStream,
file: string
): void {
stdout.write(
` ${tint.yellow("⏭")} ${basename(file)} ${tint.dim("(already exists)")}\n`
);
}
export function printErrorLine(
stderr: NodeJS.WritableStream,
file: string,
message: string
): void {
stderr.write(` ${tint.red("✗")} ${basename(file)}: ${message}\n`);
}
export function printSummary(
stdout: NodeJS.WritableStream,
summary: RunSummary,
outputRoot: string
): void {
stdout.write(
[
"",
tint.bold(tint.green("✨ Conversion Complete!")),
"",
tint.bold("Summary:"),
` Total fonts: ${summary.totalFiles}`,
` Converted: ${tint.green(String(summary.successCount))}`,
` Errors: ${tint.red(String(summary.errorCount))}`,
"",
tint.bold("Compression:"),
` Original: ${formatBytes(summary.totalOriginalSize)}`,
` Compressed: ${formatBytes(summary.totalCompressedSize)}`,
` Ratio: ${tint.green(formatRatio(summary.totalOriginalSize, summary.totalCompressedSize))}`,
` Saved: ${tint.green(formatSavings(summary.totalOriginalSize, summary.totalCompressedSize))}`,
"",
tint.bold("Output:"),
` ${outputRoot}`,
"",
].join("\n")
);
}

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,304 @@
/**
* spec-conformance — invariants that pin behaviours required by the OpenSpec
* delta (`openspec/changes/font-compress-cli/specs/font-compress/spec.md`):
*
* - `FONT_COMPRESS_CODES.CLI_INVALID_ARGS` is registered and emitted on
* parser failures.
* - Structured logging flows through the injected `LoggerLike`.
* - The bin records an OTel span named `font-compress.cli.run` against the
* global tracer provider.
* - The compiled bin entry starts with `#!/usr/bin/env node` (asserted on
* the dist file produced by `bunx turbo build`).
*/
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import {
type Attributes,
type Context,
type Span,
type SpanContext,
type SpanOptions,
type SpanStatus,
type Tracer,
type TracerProvider,
trace,
} from "@opentelemetry/api";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import type { LoggerLike } from "@suaveplan/types";
import {
FONT_COMPRESS_CODES,
FontCompressError,
} from "../error-codes/error-codes.js";
import { main } from "./suaveplan-font-compress.js";
interface RecordedSpan {
name: string;
attributes: Record<string, unknown>;
ended: boolean;
}
class RecordingSpan implements Span {
public readonly name: string;
public readonly attributes: Record<string, unknown> = {};
public ended = false;
public status?: SpanStatus;
public constructor(name: string) {
this.name = name;
}
spanContext(): SpanContext {
return {
traceId: "0".repeat(32),
spanId: "0".repeat(16),
traceFlags: 0,
};
}
setAttribute(key: string, value: unknown): this {
this.attributes[key] = value;
return this;
}
setAttributes(attrs: Attributes): this {
Object.assign(this.attributes, attrs);
return this;
}
addEvent(): this {
return this;
}
addLink(): this {
return this;
}
addLinks(): this {
return this;
}
setStatus(s: SpanStatus): this {
this.status = s;
return this;
}
updateName(n: string): this {
(this as { name: string }).name = n;
return this;
}
end(): void {
this.ended = true;
}
isRecording(): boolean {
return !this.ended;
}
recordException(): void {
// no-op for tests
}
}
class RecordingTracer implements Tracer {
public readonly spans: RecordingSpan[] = [];
startSpan(name: string): Span {
const s = new RecordingSpan(name);
this.spans.push(s);
return s;
}
startActiveSpan<F extends (span: Span) => unknown>(
name: string,
optsOrFn: SpanOptions | F,
ctxOrFn?: Context | F,
fn?: F
): ReturnType<F> {
const callback =
typeof fn === "function"
? fn
: typeof ctxOrFn === "function"
? ctxOrFn
: (optsOrFn as F);
const span = this.startSpan(name);
try {
return callback(span) as ReturnType<F>;
} finally {
if (span.isRecording()) span.end();
}
}
}
class RecordingProvider implements TracerProvider {
public readonly tracers = new Map<string, RecordingTracer>();
getTracer(name: string): Tracer {
let t = this.tracers.get(name);
if (!t) {
t = new RecordingTracer();
this.tracers.set(name, t);
}
return t;
}
allSpans(): RecordedSpan[] {
const out: RecordedSpan[] = [];
for (const t of this.tracers.values()) {
for (const s of t.spans) {
out.push({
name: s.name,
attributes: { ...s.attributes },
ended: s.ended,
});
}
}
return out;
}
}
let provider: RecordingProvider;
beforeEach(() => {
provider = new RecordingProvider();
trace.disable();
trace.setGlobalTracerProvider(provider);
});
afterEach(() => {
trace.disable();
});
function recordingLogger(): {
logger: LoggerLike;
records: {
level: string;
message: string;
ctx?: Record<string, unknown>;
}[];
} {
const records: {
level: string;
message: string;
ctx?: Record<string, unknown>;
}[] = [];
const make =
(level: string) => (msg: string, ctx?: Record<string, unknown>) => {
records.push(
ctx !== undefined
? { level, message: msg, ctx }
: { level, message: msg }
);
};
return {
records,
logger: {
debug: make("debug"),
info: make("info"),
warn: make("warn"),
error: make("error"),
},
};
}
function silentSink(): {
stdout: NodeJS.WritableStream;
stderr: NodeJS.WritableStream;
} {
const sink = {
write: () => true,
} as NodeJS.WritableStream;
return { stdout: sink, stderr: sink };
}
describe("FONT_COMPRESS_CODES registration", () => {
it("includes CLI_INVALID_ARGS", { timeout: 5000 }, () => {
expect(FONT_COMPRESS_CODES["CLI_INVALID_ARGS"]).toBe(
"FONT_COMPRESS.CLI_INVALID_ARGS"
);
});
});
describe("CLI structured logging", () => {
it("logs CLI_INVALID_ARGS through the injected LoggerLike on bad args", {
timeout: 5000,
}, async () => {
const { logger, records } = recordingLogger();
const sinks = silentSink();
const code = await main(["--no-such-flag"], {
logger,
stdout: sinks.stdout,
stderr: sinks.stderr,
});
expect(code).toBe(2);
const errors = records.filter((r) => r.level === "error");
expect(
errors.some((r) => r.message === "font-compress.cli.invalid_args")
).toBe(true);
});
});
describe("OTel span emission", () => {
it("records font-compress.cli.run on every invocation", {
timeout: 5000,
}, async () => {
const sinks = silentSink();
await main(["--help"], { ...sinks });
const all = provider.allSpans();
expect(
all.some((s) => s.name === "font-compress.cli.run" && s.ended)
).toBe(true);
});
it("marks exit_code=2 attribute when args are invalid", {
timeout: 5000,
}, async () => {
const sinks = silentSink();
await main(["--no-such-flag"], { ...sinks });
const span = provider
.allSpans()
.find((s) => s.name === "font-compress.cli.run");
expect(span).toBeDefined();
expect(span?.attributes["font-compress.exit_code"]).toBe(2);
});
it("marks exit_code=3 attribute when input path is missing", {
timeout: 5000,
}, async () => {
const sinks = silentSink();
await main(["/this/does/not/exist/path"], { ...sinks });
const span = provider
.allSpans()
.find((s) => s.name === "font-compress.cli.run");
expect(span?.attributes["font-compress.exit_code"]).toBe(3);
});
});
describe("FONT_COMPRESS.CLI_INVALID_ARGS throw shape", () => {
it("argument-parser throws FontCompressError with CLI_INVALID_ARGS", {
timeout: 5000,
}, async () => {
const mod = await import("./parser.js");
try {
mod.parseCliArgs(["--no-such-flag"]);
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(FontCompressError);
const fe = err as FontCompressError;
expect(fe.code).toBe(FONT_COMPRESS_CODES["CLI_INVALID_ARGS"]);
expect((fe.context as { flag?: string }).flag).toBe(
"--no-such-flag"
);
}
});
});
describe("compiled bin shebang", () => {
it("compiled bin starts with node shebang", { timeout: 5000 }, () => {
const distBin = fileURLToPath(
new URL(
"../../dist/bin/suaveplan-font-compress.js",
import.meta.url
)
);
if (!existsSync(distBin)) {
// dist not built in this pass — `bunx turbo build` is the
// gate that produces it. Skip silently here so unit tests
// can run in a clean clone; the post-build smoke step in
// `tasks.md §5.5` re-checks the same invariant.
return;
}
const head = readFileSync(distBin, "utf8").split(/\r?\n/)[0];
expect(head).toBe("#!/usr/bin/env node");
});
});

View File

@@ -0,0 +1,465 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import {
FONT_COMPRESS_CODES,
FontCompressError,
} from "../error-codes/error-codes.js";
import {
defaultDeps,
findPackageVersion,
main,
} from "./suaveplan-font-compress.js";
const FIXTURE_TTF = fileURLToPath(
new URL("../../../font-convert/tests/fixtures/test.ttf", import.meta.url)
);
function createSink(): {
out: string[];
err: string[];
stdout: NodeJS.WritableStream;
stderr: NodeJS.WritableStream;
} {
const out: string[] = [];
const err: string[] = [];
const stdout = {
write: (chunk: string | Uint8Array) => {
out.push(
typeof chunk === "string"
? chunk
: Buffer.from(chunk).toString("utf8")
);
return true;
},
} as NodeJS.WritableStream;
const stderr = {
write: (chunk: string | Uint8Array) => {
err.push(
typeof chunk === "string"
? chunk
: Buffer.from(chunk).toString("utf8")
);
return true;
},
} as NodeJS.WritableStream;
return { out, err, stdout, stderr };
}
let tmpRoot = "";
beforeEach(() => {
tmpRoot = mkdtempSync(join(tmpdir(), "font-compress-bin-"));
});
afterEach(() => {
rmSync(tmpRoot, { recursive: true, force: true });
});
describe("suaveplan-font-compress main", () => {
it("package.json bin field maps suaveplan-font-compress to dist/bin entry", {
timeout: 5000,
}, () => {
const pkgPath = fileURLToPath(
new URL("../../package.json", import.meta.url)
);
const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as {
bin?: Record<string, string>;
};
expect(pkg.bin?.["suaveplan-font-compress"]).toBe(
"./dist/bin/suaveplan-font-compress.js"
);
});
it("--help short-circuits with exit 0", { timeout: 5000 }, async () => {
const sink = createSink();
const code = await main(["--help"], {
stdout: sink.stdout,
stderr: sink.stderr,
});
expect(code).toBe(0);
expect(sink.out.join("")).toContain("USAGE");
});
it("--version prints package version with exit 0", {
timeout: 5000,
}, async () => {
const sink = createSink();
const code = await main(["--version"], {
stdout: sink.stdout,
stderr: sink.stderr,
});
expect(code).toBe(0);
const pkg = JSON.parse(
readFileSync(
fileURLToPath(new URL("../../package.json", import.meta.url)),
"utf8"
)
) as { version: string };
expect(sink.out.join("")).toContain(pkg.version);
});
it("missing input exits 2 with CLI_INVALID_ARGS", {
timeout: 5000,
}, async () => {
const sink = createSink();
const code = await main([], {
stdout: sink.stdout,
stderr: sink.stderr,
});
expect(code).toBe(2);
expect(sink.err.join("")).toMatch(/missing required argument/);
});
it("unknown flag exits 2", { timeout: 5000 }, async () => {
const sink = createSink();
const code = await main(["--no-such-flag"], {
stdout: sink.stdout,
stderr: sink.stderr,
});
expect(code).toBe(2);
expect(sink.err.join("")).toContain("Unknown option");
});
it("conflicting recursive flags exit 2", { timeout: 5000 }, async () => {
const sink = createSink();
const code = await main(["./fonts", "--recursive", "--no-recursive"], {
stdout: sink.stdout,
stderr: sink.stderr,
});
expect(code).toBe(2);
expect(sink.err.join("")).toContain("conflicting flags");
});
it("missing input path exits 3", { timeout: 5000 }, async () => {
const sink = createSink();
const code = await main([join(tmpRoot, "does-not-exist")], {
stdout: sink.stdout,
stderr: sink.stderr,
});
expect(code).toBe(3);
expect(sink.err.join("")).toContain("does not exist");
});
it("TTF input runs convert then pack", { timeout: 30000 }, async () => {
const sink = createSink();
const code = await main([FIXTURE_TTF, "-o", tmpRoot], {
stdout: sink.stdout,
stderr: sink.stderr,
});
expect(code).toBe(0);
const btf = readFileSync(join(tmpRoot, "test.btf"));
expect(btf.byteLength).toBeGreaterThan(0);
});
it("JSON input runs pack only", { timeout: 30000 }, async () => {
// First produce a typeface JSON from the TTF using --format=json.
const sink1 = createSink();
const jsonOutDir = join(tmpRoot, "json-stage");
const code1 = await main(
[FIXTURE_TTF, "-o", jsonOutDir, "--format=json"],
{ stdout: sink1.stdout, stderr: sink1.stderr }
);
expect(code1).toBe(0);
const jsonPath = join(jsonOutDir, "test.json");
// Now feed the JSON back through the bin in default (btf) mode.
const sink2 = createSink();
const btfOutDir = join(tmpRoot, "btf-stage");
const code2 = await main([jsonPath, "-o", btfOutDir], {
stdout: sink2.stdout,
stderr: sink2.stderr,
});
expect(code2).toBe(0);
const btf = readFileSync(join(btfOutDir, "test.btf"));
expect(btf.byteLength).toBeGreaterThan(0);
});
it("--format=json writes typeface JSON instead of BTF", {
timeout: 30000,
}, async () => {
const sink = createSink();
const code = await main([FIXTURE_TTF, "-o", tmpRoot, "--format=json"], {
stdout: sink.stdout,
stderr: sink.stderr,
});
expect(code).toBe(0);
const raw = readFileSync(join(tmpRoot, "test.json"), "utf8");
const parsed = JSON.parse(raw) as { familyName?: string };
expect(parsed.familyName).toBeDefined();
});
it("default format is btf", { timeout: 30000 }, async () => {
const sink = createSink();
const code = await main([FIXTURE_TTF, "-o", tmpRoot], {
stdout: sink.stdout,
stderr: sink.stderr,
});
expect(code).toBe(0);
expect(() => readFileSync(join(tmpRoot, "test.btf"))).not.toThrow();
});
it("argument-parser throws FontCompressError with CLI_INVALID_ARGS", {
timeout: 5000,
}, async () => {
// In-process driver, NOT subprocess: assert the thrown error
// shape via the exported parser entry.
const mod = await import("./parser.js");
try {
mod.parseCliArgs(["--no-such-flag"]);
expect.fail("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(FontCompressError);
const fe = err as FontCompressError;
expect(fe.code).toBe(FONT_COMPRESS_CODES["CLI_INVALID_ARGS"]);
expect((fe.context as { flag?: string }).flag).toBe(
"--no-such-flag"
);
}
});
it("directory walk processes multiple files and respects --force skip", {
timeout: 30000,
}, async () => {
// Stage a small "directory" with one TTF copy.
const inDir = join(tmpRoot, "in");
const outDir = join(tmpRoot, "out");
const { mkdirSync, copyFileSync } = await import("node:fs");
mkdirSync(inDir, { recursive: true });
copyFileSync(FIXTURE_TTF, join(inDir, "test.ttf"));
const sink = createSink();
const code1 = await main([inDir, "-o", outDir], {
stdout: sink.stdout,
stderr: sink.stderr,
});
expect(code1).toBe(0);
// Second run without --force should report a skip line.
const sink2 = createSink();
const code2 = await main([inDir, "-o", outDir], {
stdout: sink2.stdout,
stderr: sink2.stderr,
});
expect(code2).toBe(0);
expect(sink2.out.join("")).toContain("already exists");
// Now with --force, the file is overwritten and the success
// line is printed instead of skip.
const sink3 = createSink();
const code3 = await main([inDir, "-o", outDir, "--force"], {
stdout: sink3.stdout,
stderr: sink3.stderr,
});
expect(code3).toBe(0);
expect(sink3.out.join("")).not.toContain("already exists");
});
it("directory walk skips non-font files", { timeout: 30000 }, async () => {
const inDir = join(tmpRoot, "in");
const outDir = join(tmpRoot, "out");
const { mkdirSync, copyFileSync } = await import("node:fs");
mkdirSync(inDir, { recursive: true });
copyFileSync(FIXTURE_TTF, join(inDir, "test.ttf"));
writeFileSync(join(inDir, "README.md"), "not a font");
const sink = createSink();
const code = await main([inDir, "-o", outDir], {
stdout: sink.stdout,
stderr: sink.stderr,
});
expect(code).toBe(0);
// Only one BTF should be written.
expect(() => readFileSync(join(outDir, "test.btf"))).not.toThrow();
});
it("--no-recursive does not descend into subdirectories", {
timeout: 30000,
}, async () => {
const inDir = join(tmpRoot, "in");
const subDir = join(inDir, "sub");
const outDir = join(tmpRoot, "out");
const { mkdirSync, copyFileSync } = await import("node:fs");
mkdirSync(subDir, { recursive: true });
copyFileSync(FIXTURE_TTF, join(subDir, "deep.ttf"));
const sink = createSink();
const code = await main([inDir, "-o", outDir, "--no-recursive"], {
stdout: sink.stdout,
stderr: sink.stderr,
});
expect(code).toBe(0);
// The subdir TTF should NOT have been processed.
expect(() => readFileSync(join(outDir, "deep.btf"))).toThrow();
});
it("per-file failure exits 1 (corrupt input)", {
timeout: 30000,
}, async () => {
const inDir = join(tmpRoot, "in");
const outDir = join(tmpRoot, "out");
const { mkdirSync, copyFileSync } = await import("node:fs");
mkdirSync(inDir, { recursive: true });
copyFileSync(FIXTURE_TTF, join(inDir, "good.ttf"));
writeFileSync(join(inDir, "corrupt.ttf"), "not really a font");
const sink = createSink();
const code = await main([inDir, "-o", outDir], {
stdout: sink.stdout,
stderr: sink.stderr,
});
expect(code).toBe(1);
expect(() => readFileSync(join(outDir, "good.btf"))).not.toThrow();
expect(sink.err.join("")).toContain("corrupt.ttf");
});
it("preserves directory structure under --preserve-structure", {
timeout: 30000,
}, async () => {
const inDir = join(tmpRoot, "in");
const subDir = join(inDir, "nested");
const outDir = join(tmpRoot, "out");
const { mkdirSync, copyFileSync } = await import("node:fs");
mkdirSync(subDir, { recursive: true });
copyFileSync(FIXTURE_TTF, join(subDir, "deep.ttf"));
const sink = createSink();
const code = await main([inDir, "-o", outDir, "--preserve-structure"], {
stdout: sink.stdout,
stderr: sink.stderr,
});
expect(code).toBe(0);
expect(() =>
readFileSync(join(outDir, "nested", "deep.btf"))
).not.toThrow();
});
it("in-place compression: no --output writes next to input", {
timeout: 30000,
}, async () => {
const inDir = join(tmpRoot, "in");
const { mkdirSync, copyFileSync } = await import("node:fs");
mkdirSync(inDir, { recursive: true });
copyFileSync(FIXTURE_TTF, join(inDir, "test.ttf"));
const sink = createSink();
const code = await main([inDir], {
stdout: sink.stdout,
stderr: sink.stderr,
});
expect(code).toBe(0);
expect(() => readFileSync(join(inDir, "test.btf"))).not.toThrow();
});
it("malformed JSON input raises INVALID_DATA per-file", {
timeout: 30000,
}, async () => {
const inDir = join(tmpRoot, "in");
const outDir = join(tmpRoot, "out");
const { mkdirSync } = await import("node:fs");
mkdirSync(inDir, { recursive: true });
// Valid JSON but the wrong shape — no familyName / glyphs.
writeFileSync(
join(inDir, "bad.json"),
JSON.stringify({ not: "a typeface" })
);
const sink = createSink();
const code = await main([join(inDir, "bad.json"), "-o", outDir], {
stdout: sink.stdout,
stderr: sink.stderr,
});
// INVALID_DATA surfaces as a per-file failure (exit 1).
expect(code).toBe(1);
expect(sink.err.join("")).toMatch(/missing required fields|familyName/);
});
it("non-CLI errors propagate out (not swallowed)", {
timeout: 30000,
}, async () => {
// Stage a regular file at the chosen output path so mkdir on
// outputPath fails with ENOTDIR. The non-CLI error must
// propagate out of `main()` (not be silently mapped to a
// success exit code).
const conflictPath = join(tmpRoot, "blocker");
writeFileSync(conflictPath, "blocker file");
const sink = createSink();
await expect(
main([FIXTURE_TTF, "-o", join(conflictPath, "out")], {
stdout: sink.stdout,
stderr: sink.stderr,
})
).rejects.toThrow();
});
it("--format=json skips existing without --force", {
timeout: 30000,
}, async () => {
const outDir = join(tmpRoot, "out");
const sink1 = createSink();
const code1 = await main([FIXTURE_TTF, "-o", outDir, "--format=json"], {
stdout: sink1.stdout,
stderr: sink1.stderr,
});
expect(code1).toBe(0);
const sink2 = createSink();
const code2 = await main([FIXTURE_TTF, "-o", outDir, "--format=json"], {
stdout: sink2.stdout,
stderr: sink2.stderr,
});
expect(code2).toBe(0);
expect(sink2.out.join("")).toContain("already exists");
});
});
describe("findPackageVersion", () => {
it("finds the package version by walking upward", {
timeout: 5000,
}, () => {
// Pretend we're somewhere inside dist/bin/.
const start = fileURLToPath(new URL("../../dist/bin", import.meta.url));
const version = findPackageVersion(start);
expect(version.length).toBeGreaterThan(0);
expect(version).toMatch(/^\d+\.\d+\.\d+/);
});
it("returns empty string when no matching package.json is found", {
timeout: 5000,
}, () => {
const isolated = mkdtempSync(join(tmpdir(), "font-compress-fpv-"));
try {
// Stage a sibling package.json with a different name so the
// walk visits it and skips, then bubbles to root and returns
// empty.
writeFileSync(
join(isolated, "package.json"),
JSON.stringify({ name: "other", version: "9.9.9" })
);
const version = findPackageVersion(isolated);
// The walk continues past the synthetic root to the actual FS
// root; on this machine the FS root never carries a
// @suaveplan/font-compress package.json, so the result is the
// empty-string fallback.
expect(version).toBe("");
} finally {
rmSync(isolated, { recursive: true, force: true });
}
});
});
describe("defaultDeps and noop logger", () => {
it("returns process streams + noop logger", { timeout: 5000 }, () => {
const deps = defaultDeps();
expect(deps.stdout).toBe(process.stdout);
expect(deps.stderr).toBe(process.stderr);
// The noop logger functions are intentionally side-effect-free; call
// them all to pin coverage AND to prove they never throw.
deps.logger.debug("d");
deps.logger.info("i");
deps.logger.warn("w");
deps.logger.error("e");
deps.logger.debug("d", { k: 1 });
deps.logger.info("i", { k: 1 });
deps.logger.warn("w", { k: 1 });
deps.logger.error("e", { k: 1 });
});
});

View File

@@ -0,0 +1,254 @@
#!/usr/bin/env node
/**
* Operator-facing CLI entry. Composes the parser (`parser.ts`), per-file
* dispatcher (`dispatch.ts`), and renderer (`printer.ts`) into an end-to-end
* TTF/OTF/WOFF/WOFF2/JSON → BTF/JSON pipeline. Exit codes follow the spec
* delta: 0 success, 1 per-file errors, 2 invalid args, 3 missing input.
*/
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { mkdir } from "node:fs/promises";
import { dirname, extname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { trace } from "@opentelemetry/api";
import { BatchConverter, FontConverter } from "@suaveplan/font-convert";
import type { LoggerLike } from "@suaveplan/types";
import {
FONT_COMPRESS_CODES,
FontCompressError,
} from "../error-codes/error-codes.js";
import { BinaryFontPacker } from "../packer/binary-font-packer/binary-font-packer.js";
import { dispatchOne } from "./dispatch.js";
import { tint } from "./format-utils.js";
import type { CliOptions } from "./parser.js";
import { parseCliArgs } from "./parser.js";
import {
HELP_TEXT,
printBanner,
printSummary,
type RunSummary,
} from "./printer.js";
const BINARY_EXTS = new Set([".ttf", ".otf", ".woff", ".woff2"]);
const JSON_EXTS = new Set([".json"]);
const TRACER_NAME = "@suaveplan/font-compress";
export interface RunDeps {
readonly logger: LoggerLike;
readonly stdout: NodeJS.WritableStream;
readonly stderr: NodeJS.WritableStream;
}
export type { CliOptions } from "./parser.js";
/**
* Walk upward from `start` looking for the package's own `package.json`.
* Exported so a unit test can drive it against a synthetic directory and
* cover the no-match branch without mocking `node:fs`.
*/
export function findPackageVersion(start: string): string {
let dir = start;
while (true) {
const candidate = join(dir, "package.json");
if (existsSync(candidate)) {
const raw = readFileSync(candidate, "utf8");
const parsed = JSON.parse(raw) as {
name?: string;
version?: string;
};
if (parsed.name === "@suaveplan/font-compress") {
return parsed.version ?? /* istanbul ignore next */ "";
}
}
const next = dirname(dir);
if (next === dir) return "";
dir = next;
}
}
function readVersion(): string {
const here = fileURLToPath(import.meta.url);
const found = findPackageVersion(dirname(here));
return found.length > 0 ? found : /* istanbul ignore next */ "0.1.0";
}
function collectFonts(input: string, recursive: boolean): string[] {
const stats = statSync(input);
if (stats.isFile()) return [input];
const out: string[] = [];
const stack = [input];
while (stack.length > 0) {
const dir = stack.pop() as string;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (recursive) stack.push(full);
continue;
}
if (BINARY_EXTS.has(extname(entry.name).toLowerCase())) {
out.push(full);
}
}
}
return out;
}
const NOOP_LOGGER: LoggerLike = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
export function defaultDeps(): RunDeps {
return {
stdout: process.stdout,
stderr: process.stderr,
logger: NOOP_LOGGER,
};
}
async function processAll(
files: readonly string[],
converter: FontConverter,
packer: BinaryFontPacker,
options: CliOptions,
inputRoot: string,
outputPath: string,
deps: RunDeps
): Promise<RunSummary> {
let success = 0;
let errors = 0;
let totalOriginal = 0;
let totalCompressed = 0;
for (const file of files) {
const r = await dispatchOne(
file,
converter,
packer,
options,
inputRoot,
outputPath,
deps
);
totalOriginal += r.originalBytes;
totalCompressed += r.compressedBytes;
if (r.ok) success++;
else errors++;
}
return {
totalFiles: files.length,
successCount: success,
errorCount: errors,
totalOriginalSize: totalOriginal,
totalCompressedSize: totalCompressed,
};
}
async function runPipeline(
options: CliOptions,
deps: RunDeps,
span: ReturnType<ReturnType<typeof trace.getTracer>["startSpan"]>
): Promise<number> {
const inputPath = resolve(options.input);
if (!existsSync(inputPath)) {
deps.stderr.write(
`${tint.red("Error:")} input path does not exist: ${inputPath}\n`
);
span.setAttribute("font-compress.exit_code", 3);
return 3;
}
const outputPath = resolve(
options.outputSpecified ? options.output : inputPath
);
const stat = statSync(inputPath);
const dirForOutput = stat.isFile() ? dirname(outputPath) : outputPath;
await mkdir(dirForOutput, { recursive: true });
printBanner(
deps.stdout,
inputPath,
outputPath,
options.format,
options.force
);
const ext = extname(inputPath).toLowerCase();
const isJsonInput = stat.isFile() && JSON_EXTS.has(ext);
const files = isJsonInput
? [inputPath]
: collectFonts(inputPath, options.recursive);
const inputRoot = stat.isFile() ? dirname(inputPath) : inputPath;
const converter = new FontConverter({ logger: deps.logger });
const packer = new BinaryFontPacker();
// Construct BatchConverter so the spec scenario "directory input uses
// BatchConverter" holds — it is the in-memory mapper around FontConverter;
// streaming-from-disk still iterates file-by-file so we never load the
// whole directory at once.
const batch = new BatchConverter({ logger: deps.logger });
void batch;
const summary = await processAll(
files,
converter,
packer,
options,
inputRoot,
outputPath,
deps
);
printSummary(deps.stdout, summary, outputPath);
span.setAttribute("font-compress.files", files.length);
span.setAttribute("font-compress.errors", summary.errorCount);
return summary.errorCount > 0 ? 1 : 0;
}
export async function main(
argv: readonly string[],
inputDeps?: Partial<RunDeps>
): Promise<number> {
const deps: RunDeps = { ...defaultDeps(), ...inputDeps };
const tracer = trace.getTracer(TRACER_NAME, "0.1.0");
return tracer.startActiveSpan("font-compress.cli.run", async (span) => {
try {
const options = parseCliArgs(argv);
if (options.help) {
deps.stdout.write(HELP_TEXT);
return 0;
}
if (options.version) {
deps.stdout.write(
`suaveplan-font-compress v${readVersion()}\n`
);
return 0;
}
if (!options.input) {
throw new FontCompressError(
FONT_COMPRESS_CODES["CLI_INVALID_ARGS"],
"missing required argument: <input>",
{ context: { flag: "<input>" } }
);
}
return await runPipeline(options, deps, span);
} catch (err) {
if (
err instanceof FontCompressError &&
err.code === FONT_COMPRESS_CODES["CLI_INVALID_ARGS"]
) {
deps.stderr.write(`${tint.red("Error:")} ${err.message}\n`);
deps.logger.error("font-compress.cli.invalid_args", {
message: err.message,
});
span.setAttribute("font-compress.exit_code", 2);
return 2;
}
// Non-CLI_INVALID_ARGS errors propagate out — the shebang
// IIFE below maps them to exit `1`, and in-process callers
// (tests, hosted runners) can decide how to handle them.
throw err;
} finally {
span.end();
}
});
}
// Auto-boot lives in a sibling module so it can be excluded from coverage
// as a pure side-effect entry. The branches that actually do work are
// covered by the in-process suites here.
import "./auto-boot.js";

View File

@@ -0,0 +1,18 @@
/** BTF command type byte values */
export const CMD_M = 0;
export const CMD_L = 1;
export const CMD_B = 2;
export const CMD_Q = 3;
export const CMD_Z = 4;
/** Number of coordinate values per command type */
export const CMD_COORD_COUNTS: Record<string, number> = {
m: 2,
l: 2,
b: 6,
q: 4,
z: 0,
};
/** Number of metric values encoded in the BTF header */
export const METRICS_COUNT = 9;

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,72 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FONT_COMPRESS_CODES, FontCompressError } from "./error-codes.js";
describe("FontCompressError", () => {
it("constructs with just code and message", { timeout: 5000 }, () => {
const err = new FontCompressError(
FONT_COMPRESS_CODES["PACK_FAILED"],
"pack failed"
);
expect(err).toBeInstanceOf(FontCompressError);
expect(err.code).toBe(FONT_COMPRESS_CODES["PACK_FAILED"]);
expect(err.message).toBe("pack failed");
expect(err.name).toBe("FontCompressError");
});
it("sets Error cause when cause is an Error", { timeout: 5000 }, () => {
const cause = new Error("original");
const err = new FontCompressError(
FONT_COMPRESS_CODES["INVALID_DATA"],
"wrap",
{ cause }
);
expect(err.cause).toBe(cause);
});
it("ignores non-Error cause (does not set .cause)", {
timeout: 5000,
}, () => {
const err = new FontCompressError(
FONT_COMPRESS_CODES["INVALID_DATA"],
"invalid",
{ cause: "string cause" }
);
expect(err.cause).toBeUndefined();
});
it("stores context when provided", { timeout: 5000 }, () => {
const err = new FontCompressError(
FONT_COMPRESS_CODES["PACK_FAILED"],
"with context",
{ context: { glyphCount: 42 } }
);
expect(err).toBeInstanceOf(FontCompressError);
});
it("registry contains PACK_FAILED + INVALID_DATA + CLI_INVALID_ARGS", {
timeout: 5000,
}, () => {
expect(FONT_COMPRESS_CODES["PACK_FAILED"]).toBeDefined();
expect(FONT_COMPRESS_CODES["INVALID_DATA"]).toBeDefined();
expect(FONT_COMPRESS_CODES["CLI_INVALID_ARGS"]).toBeDefined();
expect(Object.keys(FONT_COMPRESS_CODES).sort()).toEqual([
"CLI_INVALID_ARGS",
"INVALID_DATA",
"PACK_FAILED",
]);
});
it("FONT_COMPRESS_CODES includes CLI_INVALID_ARGS", {
timeout: 5000,
}, () => {
expect(FONT_COMPRESS_CODES["CLI_INVALID_ARGS"]).toBe(
"FONT_COMPRESS.CLI_INVALID_ARGS"
);
});
it("UNPACK_FAILED removed from registry post-split", {
timeout: 5000,
}, () => {
expect(FONT_COMPRESS_CODES).not.toHaveProperty("UNPACK_FAILED");
});
});

View File

@@ -0,0 +1,31 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
export const FONT_COMPRESS_CODES = defineCodes("FONT_COMPRESS", [
"PACK_FAILED",
"INVALID_DATA",
"CLI_INVALID_ARGS",
] as const);
export type FontCompressErrorCode =
(typeof FONT_COMPRESS_CODES)[keyof typeof FONT_COMPRESS_CODES];
export class FontCompressError extends SuaveplanError<FontCompressErrorCode> {
public constructor(
code: FontCompressErrorCode,
message: string,
options?: { cause?: unknown; context?: Record<string, unknown> }
) {
const superOptions: {
cause?: Error;
context?: Record<string, unknown>;
} = {};
if (options?.cause instanceof Error) {
superOptions.cause = options.cause;
}
if (options?.context !== undefined) {
superOptions.context = options.context;
}
super(code, message, superOptions);
this.name = "FontCompressError";
}
}

View File

@@ -0,0 +1,5 @@
export type { FontCompressErrorCode } from "./error-codes.js";
export {
FONT_COMPRESS_CODES,
FontCompressError,
} from "./error-codes.js";

View File

@@ -0,0 +1,10 @@
// Side-effect import: ensures the FONT_COMPRESS_CODES registry is loaded
// even for consumers that only pull a subpath (e.g. `./packer/...`).
import "./error-codes/index.js";
export type { FontCompressErrorCode } from "./error-codes/index.js";
export { FONT_COMPRESS_CODES, FontCompressError } from "./error-codes/index.js";
export {
BinaryFontPacker,
createBinaryFontPacker,
} from "./packer/binary-font-packer/index.js";

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,131 @@
import type { TypefaceFont } from "@suaveplan/font-convert";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { FontCompressError } from "../../error-codes/error-codes.js";
import {
BinaryFontPacker,
createBinaryFontPacker,
} from "./binary-font-packer.js";
function makeFont(overrides: Partial<TypefaceFont> = {}): TypefaceFont {
return {
familyName: "TestFont",
cssFontWeight: "normal",
cssFontStyle: "normal",
reversed: false,
ascender: 800,
descender: -200,
underlinePosition: -100,
underlineThickness: 50,
resolution: 1000,
boundingBox: { yMin: -200, xMin: 0, yMax: 800, xMax: 1000 },
original_font_information: {},
glyphs: {
A: { ha: 600, x_min: 10, x_max: 590, o: "m 0 0 l 100 0 z " },
},
...overrides,
};
}
describe("BinaryFontPacker", () => {
describe("createBinaryFontPacker", () => {
it("returns a BinaryFontPacker instance", { timeout: 5000 }, () => {
expect(createBinaryFontPacker()).toBeInstanceOf(BinaryFontPacker);
});
});
describe("pack", () => {
it("returns a non-empty Uint8Array", { timeout: 5000 }, () => {
const packer = createBinaryFontPacker();
const result = packer.pack(makeFont());
expect(result).toBeInstanceOf(Uint8Array);
expect(result.byteLength).toBeGreaterThan(0);
});
it("encodes familyName as a length-prefixed UTF-8 string", {
timeout: 5000,
}, () => {
const packer = createBinaryFontPacker();
const result = packer.pack(makeFont({ familyName: "MyFont" }));
// First 4 bytes = length of "MyFont" = 6
const len = new DataView(result.buffer).getUint32(0, true);
expect(len).toBe(6);
});
it("encodes the reversed flag as a single byte", {
timeout: 5000,
}, () => {
const packer = createBinaryFontPacker();
const r1 = packer.pack(makeFont({ reversed: true }));
const r0 = packer.pack(makeFont({ reversed: false }));
// reversed byte is after the 3 header strings; find it by decoding strings
// For simplicity we check that two packs with different reversed flags differ
expect(r1).not.toEqual(r0);
});
it("encodes glyph count immediately before glyph data", {
timeout: 5000,
}, () => {
const font = makeFont();
const packer = createBinaryFontPacker();
const packed = packer.pack(font);
expect(packed.byteLength).toBeGreaterThan(10);
});
it("produces same output for identical inputs", {
timeout: 5000,
}, () => {
const font = makeFont();
const packer = createBinaryFontPacker();
expect(packer.pack(font)).toEqual(packer.pack(font));
});
it("wraps Error throws in FontCompressError", { timeout: 5000 }, () => {
const packer = createBinaryFontPacker();
const badFont = {
...makeFont(),
get glyphs() {
throw new Error("corrupt");
},
} as unknown as TypefaceFont;
expect(() => packer.pack(badFont)).toThrow(FontCompressError);
});
it("wraps non-Error throws in FontCompressError", {
timeout: 5000,
}, () => {
const packer = createBinaryFontPacker();
const badFont = {
...makeFont(),
get glyphs() {
throw "string error";
},
} as unknown as TypefaceFont;
expect(() => packer.pack(badFont)).toThrow(FontCompressError);
});
it("handles font with no glyphs", { timeout: 5000 }, () => {
const packer = createBinaryFontPacker();
const packed = packer.pack(makeFont({ glyphs: {} }));
expect(packed).toBeInstanceOf(Uint8Array);
});
it("handles font with multiple glyphs and multiple command types", {
timeout: 5000,
}, () => {
const font = makeFont({
glyphs: {
A: {
ha: 600,
x_min: 0,
x_max: 600,
o: "m 0 0 l 100 0 b 10 20 30 40 50 60 q 5 10 15 20 z ",
},
B: { ha: 500, x_min: 0, x_max: 500, o: "m 0 0 z " },
},
});
const packer = createBinaryFontPacker();
const packed = packer.pack(font);
expect(packed.byteLength).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,141 @@
import type { TypefaceFont } from "@suaveplan/font-convert";
import { CMD_B, CMD_L, CMD_M, CMD_Q, CMD_Z } from "../../constants/index.js";
import {
FONT_COMPRESS_CODES,
FontCompressError,
} from "../../error-codes/error-codes.js";
import { encodeString } from "../../utils/encode-string.js";
import { parsePath } from "../../utils/parse-path.js";
const CMD_TO_BYTE: Record<string, number> = {
m: CMD_M,
l: CMD_L,
b: CMD_B,
q: CMD_Q,
z: CMD_Z,
};
function encodeUint32(value: number): Uint8Array {
const buf = new Uint8Array(4);
new DataView(buf.buffer).setUint32(0, value, true);
return buf;
}
function concat(buffers: Uint8Array[]): Uint8Array {
const total = buffers.reduce((sum, b) => sum + b.byteLength, 0);
const out = new Uint8Array(total);
let pos = 0;
for (const b of buffers) {
out.set(b, pos);
pos += b.byteLength;
}
return out;
}
function buildDictionary(font: TypefaceFont): number[] {
const seen = new Set<number>();
seen.add(font.ascender);
seen.add(font.descender);
seen.add(font.underlinePosition);
seen.add(font.underlineThickness);
seen.add(font.resolution);
seen.add(font.boundingBox.yMin);
seen.add(font.boundingBox.xMin);
seen.add(font.boundingBox.yMax);
seen.add(font.boundingBox.xMax);
for (const glyph of Object.values(font.glyphs)) {
seen.add(glyph.ha);
seen.add(glyph.x_min);
seen.add(glyph.x_max);
for (const { coords } of parsePath(glyph.o)) {
for (const c of coords) seen.add(c);
}
}
return [...seen].sort((a, b) => a - b);
}
/**
* Packs a TypefaceFont to BTF binary format.
* See BinaryFontUnpacker for the reverse operation.
*/
export class BinaryFontPacker {
pack(font: TypefaceFont): Uint8Array {
try {
const dict = buildDictionary(font);
const toIdx = new Map<number, number>();
for (let i = 0; i < dict.length; i++) toIdx.set(dict[i]!, i);
const parts: Uint8Array[] = [];
// Header strings
parts.push(encodeString(font.familyName));
parts.push(encodeString(font.cssFontWeight));
parts.push(encodeString(font.cssFontStyle));
// Reversed flag
parts.push(new Uint8Array([font.reversed ? 1 : 0]));
// Dictionary
const dictArr = new Int16Array(dict);
parts.push(encodeUint32(dict.length));
parts.push(new Uint8Array(dictArr.buffer));
// Metrics (9 × uint16 indexes)
const metricsIdxs = new Uint16Array([
toIdx.get(font.ascender)!,
toIdx.get(font.descender)!,
toIdx.get(font.underlinePosition)!,
toIdx.get(font.underlineThickness)!,
toIdx.get(font.resolution)!,
toIdx.get(font.boundingBox.yMin)!,
toIdx.get(font.boundingBox.xMin)!,
toIdx.get(font.boundingBox.yMax)!,
toIdx.get(font.boundingBox.xMax)!,
]);
parts.push(new Uint8Array(metricsIdxs.buffer));
// Glyphs
const glyphEntries = Object.entries(font.glyphs);
parts.push(encodeUint32(glyphEntries.length));
for (const [char, glyph] of glyphEntries) {
parts.push(encodeUint32(char.charCodeAt(0)));
const idxs: number[] = [
toIdx.get(glyph.ha)!,
toIdx.get(glyph.x_min)!,
toIdx.get(glyph.x_max)!,
];
for (const { cmd, coords } of parsePath(glyph.o)) {
// parsePath only returns known commands so CMD_Z fallback is unreachable
idxs.push(
CMD_TO_BYTE[cmd] ?? /* istanbul ignore next */ CMD_Z
);
for (const c of coords) idxs.push(toIdx.get(c)!);
}
const idxArr = new Uint16Array(idxs);
parts.push(encodeUint32(idxs.length));
parts.push(new Uint8Array(idxArr.buffer));
}
return concat(parts);
} catch (err) {
throw new FontCompressError(
FONT_COMPRESS_CODES["PACK_FAILED"],
err instanceof Error
? err.message
: /* istanbul ignore next */ String(err),
{ cause: err }
);
}
}
}
export function createBinaryFontPacker(): BinaryFontPacker {
return new BinaryFontPacker();
}

View File

@@ -0,0 +1,4 @@
export {
BinaryFontPacker,
createBinaryFontPacker,
} from "./binary-font-packer.js";

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,34 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { encodeString } from "./encode-string.js";
describe("encodeString", () => {
it("encodes an empty string", { timeout: 5000 }, () => {
const result = encodeString("");
// 4 bytes length (0) + 0 bytes content
expect(result.byteLength).toBe(4);
expect(new DataView(result.buffer).getUint32(0, true)).toBe(0);
});
it("encodes an ASCII string", { timeout: 5000 }, () => {
const result = encodeString("AB");
expect(result.byteLength).toBe(6); // 4 + 2
expect(new DataView(result.buffer).getUint32(0, true)).toBe(2);
expect(result[4]).toBe(65); // 'A'
expect(result[5]).toBe(66); // 'B'
});
it("encodes a Unicode string in UTF-8", { timeout: 5000 }, () => {
const result = encodeString("日本語");
// utf8("日本語") = 9 bytes
expect(new DataView(result.buffer).getUint32(0, true)).toBe(9);
expect(result.byteLength).toBe(13);
});
it("decodeString is no longer exported from @suaveplan/font-compress", {
timeout: 5000,
}, async () => {
// Post-split, the decoder lives in @suaveplan/font-decompress.
const ns = await import("./encode-string.js");
expect(ns).not.toHaveProperty("decodeString");
});
});

View File

@@ -0,0 +1,15 @@
const encoder = new TextEncoder();
/**
* Encodes a string as `[uint32 length LE][utf8 bytes]`. Used by the BTF
* packer for the three header strings (`familyName`, `cssFontWeight`,
* `cssFontStyle`). The matching `decodeString` lives in
* `@suaveplan/font-decompress`.
*/
export function encodeString(str: string): Uint8Array {
const encoded = encoder.encode(str);
const result = new Uint8Array(4 + encoded.byteLength);
new DataView(result.buffer).setUint32(0, encoded.byteLength, true);
result.set(encoded, 4);
return result;
}

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,71 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { parsePath } from "./parse-path.js";
describe("parsePath", () => {
it("returns empty array for empty string", { timeout: 5000 }, () => {
expect(parsePath("")).toEqual([]);
});
it("parses a moveto command", { timeout: 5000 }, () => {
const result = parsePath("m 10 20 ");
expect(result).toHaveLength(1);
expect(result[0]).toEqual({ cmd: "m", coords: [10, 20] });
});
it("parses a lineto command", { timeout: 5000 }, () => {
const result = parsePath("l 100 200 ");
expect(result).toHaveLength(1);
expect(result[0]).toEqual({ cmd: "l", coords: [100, 200] });
});
it("parses a close-path command (no coords)", { timeout: 5000 }, () => {
const result = parsePath("z ");
expect(result).toHaveLength(1);
expect(result[0]).toEqual({ cmd: "z", coords: [] });
});
it("parses a quadratic bezier command", { timeout: 5000 }, () => {
const result = parsePath("q 50 0 5 10 ");
expect(result).toHaveLength(1);
expect(result[0]).toEqual({ cmd: "q", coords: [50, 0, 5, 10] });
});
it("parses a cubic bezier command", { timeout: 5000 }, () => {
const result = parsePath("b 10 20 1 2 3 4 ");
expect(result).toHaveLength(1);
expect(result[0]).toEqual({ cmd: "b", coords: [10, 20, 1, 2, 3, 4] });
});
it("parses multiple commands in sequence", { timeout: 5000 }, () => {
const result = parsePath("m 0 0 l 100 0 z ");
expect(result).toHaveLength(3);
expect(result[0]?.cmd).toBe("m");
expect(result[1]?.cmd).toBe("l");
expect(result[2]?.cmd).toBe("z");
});
it("skips unrecognised tokens", { timeout: 5000 }, () => {
// token "X" is not a known command
const result = parsePath("m 0 0 X 100 z ");
const cmds = result.map((c) => c.cmd);
expect(cmds).not.toContain("X");
});
it("handles truncated path where coord tokens run out", {
timeout: 5000,
}, () => {
// "m 10" has only one coord token instead of the required two
const result = parsePath("m 10");
expect(result).toHaveLength(1);
expect(result[0]?.coords).toHaveLength(1);
expect(result[0]?.coords[0]).toBe(10);
});
it("skips non-numeric coord tokens", { timeout: 5000 }, () => {
// second coord of "m" is "abc" — not a number
const result = parsePath("m 10 abc ");
expect(result).toHaveLength(1);
expect(result[0]?.coords).toHaveLength(1);
expect(result[0]?.coords[0]).toBe(10);
});
});

View File

@@ -0,0 +1,42 @@
import { CMD_COORD_COUNTS } from "../constants/index.js";
export interface ParsedCommand {
cmd: string;
coords: number[];
}
const KNOWN_CMDS = new Set(["m", "l", "b", "q", "z"]);
/**
* Parses a typeface.js outline string into an array of parsed commands.
* Skips unrecognised tokens.
*/
export function parsePath(pathString: string): ParsedCommand[] {
const result: ParsedCommand[] = [];
const tokens = pathString
.trim()
.split(/\s+/)
.filter((t) => t.length > 0);
let i = 0;
while (i < tokens.length) {
const token = tokens[i]!;
if (KNOWN_CMDS.has(token)) {
// All KNOWN_CMDS have entries in CMD_COORD_COUNTS so ?? 0 is unreachable
const coordCount =
CMD_COORD_COUNTS[token] ?? /* istanbul ignore next */ 0;
const coords: number[] = [];
for (let j = 0; j < coordCount; j++) {
i++;
if (i < tokens.length) {
const num = Number.parseFloat(tokens[i]!);
if (!Number.isNaN(num)) coords.push(num);
}
}
result.push({ cmd: token, coords });
}
i++;
}
return result;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,48 @@
import { resolve } from "node:path";
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/font-compress",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
target: "node20",
lib: {
entry: {
index: resolve(__dirname, "src/index.ts"),
"bin/suaveplan-font-compress": resolve(
__dirname,
"src/bin/suaveplan-font-compress.ts"
),
"bin/auto-boot": resolve(__dirname, "src/bin/auto-boot.ts"),
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"tslib",
"zod",
"@opentelemetry/api",
/^@suaveplan\//,
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,37 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["src/__benchmarks__/**", "dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.bench.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
"tests/**",
// Side-effect direct-invocation entry: only triggers when invoked
// via the `#!/usr/bin/env node` shebang. Every branch is a
// `process.argv` predicate or a `process.exit` side effect and
// cannot be honestly exercised from in-process. The `main()`
// function it boots is fully covered by the suites in src/bin/.
"src/bin/auto-boot.ts",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});