Initial Commit
This commit is contained in:
111
packages/media/fonts/font-compress/README.md
Normal file
111
packages/media/fonts/font-compress/README.md
Normal 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.
|
||||
|
||||
87
packages/media/fonts/font-compress/package.json
Normal file
87
packages/media/fonts/font-compress/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
69
packages/media/fonts/font-compress/src/bin/auto-boot.md
Normal file
69
packages/media/fonts/font-compress/src/bin/auto-boot.md
Normal 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.
|
||||
33
packages/media/fonts/font-compress/src/bin/auto-boot.ts
Normal file
33
packages/media/fonts/font-compress/src/bin/auto-boot.ts
Normal 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);
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
77
packages/media/fonts/font-compress/src/bin/dispatch.md
Normal file
77
packages/media/fonts/font-compress/src/bin/dispatch.md
Normal 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.
|
||||
158
packages/media/fonts/font-compress/src/bin/dispatch.ts
Normal file
158
packages/media/fonts/font-compress/src/bin/dispatch.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
86
packages/media/fonts/font-compress/src/bin/format-utils.md
Normal file
86
packages/media/fonts/font-compress/src/bin/format-utils.md
Normal 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.
|
||||
107
packages/media/fonts/font-compress/src/bin/format-utils.test.ts
Normal file
107
packages/media/fonts/font-compress/src/bin/format-utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
92
packages/media/fonts/font-compress/src/bin/format-utils.ts
Normal file
92
packages/media/fonts/font-compress/src/bin/format-utils.ts
Normal 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;
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Binary file not shown.
74
packages/media/fonts/font-compress/src/bin/parser.md
Normal file
74
packages/media/fonts/font-compress/src/bin/parser.md
Normal 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).
|
||||
154
packages/media/fonts/font-compress/src/bin/parser.test.ts
Normal file
154
packages/media/fonts/font-compress/src/bin/parser.test.ts
Normal 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"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
127
packages/media/fonts/font-compress/src/bin/parser.ts
Normal file
127
packages/media/fonts/font-compress/src/bin/parser.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
79
packages/media/fonts/font-compress/src/bin/printer.md
Normal file
79
packages/media/fonts/font-compress/src/bin/printer.md
Normal 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.
|
||||
136
packages/media/fonts/font-compress/src/bin/printer.ts
Normal file
136
packages/media/fonts/font-compress/src/bin/printer.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
18
packages/media/fonts/font-compress/src/constants/index.ts
Normal file
18
packages/media/fonts/font-compress/src/constants/index.ts
Normal 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;
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { FontCompressErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
FONT_COMPRESS_CODES,
|
||||
FontCompressError,
|
||||
} from "./error-codes.js";
|
||||
10
packages/media/fonts/font-compress/src/index.ts
Normal file
10
packages/media/fonts/font-compress/src/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
BinaryFontPacker,
|
||||
createBinaryFontPacker,
|
||||
} from "./binary-font-packer.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
67
packages/media/fonts/font-compress/src/utils/parse-path.md
Normal file
67
packages/media/fonts/font-compress/src/utils/parse-path.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
42
packages/media/fonts/font-compress/src/utils/parse-path.ts
Normal file
42
packages/media/fonts/font-compress/src/utils/parse-path.ts
Normal 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;
|
||||
}
|
||||
12
packages/media/fonts/font-compress/tsconfig.lib.json
Normal file
12
packages/media/fonts/font-compress/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
48
packages/media/fonts/font-compress/vite.config.ts
Normal file
48
packages/media/fonts/font-compress/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
37
packages/media/fonts/font-compress/vitest.config.ts
Normal file
37
packages/media/fonts/font-compress/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user