Initial Commit
This commit is contained in:
16
packages/media/fonts/README.md
Normal file
16
packages/media/fonts/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# `fonts`
|
||||
|
||||
Font packing, compression, subsetting, and conversion tools.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Packages in `fonts`
|
||||
|
||||
4 packages.
|
||||
|
||||
| Package | Description |
|
||||
|---|---|
|
||||
| [`@suaveplan/font-compress`](./font-compress/README.md) | BTF (Binary TypeFace) binary packing and unpacking. Converts `TypefaceFont` (typeface.js JSON format) to a compact `Uint8Array` using dictionary-based integer… |
|
||||
| [`@suaveplan/font-convert`](./font-convert/README.md) | Converts TTF/OTF/WOFF/WOFF2 font files to the typeface.js JSON format used by Three.js `FontLoader`. Also defines the canonical `TypefaceFont`, `TypefaceGlyph`… |
|
||||
| [`@suaveplan/font-decompress`](./font-decompress/README.md) | BTF (Binary TypeFace) binary unpacking. Reverses `@suaveplan/font-compress`'s `BinaryFontPacker` output into a typeface.js JSON shape ready for Three.js / R3F… |
|
||||
| [`@suaveplan/font-subset`](./font-subset/README.md) | Font subsetting and compression library with a codec-agnostic adapter interface. Supports Unicode range subsetting, WOFF2/WOFF/OTF/TTF output, OpenType feature… |
|
||||
<!-- end:auto-docs -->
|
||||
1
packages/media/fonts/_intro.md
Normal file
1
packages/media/fonts/_intro.md
Normal file
@@ -0,0 +1 @@
|
||||
Font packing, compression, subsetting, and conversion tools.
|
||||
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,
|
||||
},
|
||||
});
|
||||
56
packages/media/fonts/font-convert/README.md
Normal file
56
packages/media/fonts/font-convert/README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# @suaveplan/font-convert - Convert TTF/OTF/WOFF/WOFF2 font files to typeface.js JSON format. Defines canonical TypefaceFont, TypefaceGlyph, and PathCommand types used by font-compress and font-decompress.
|
||||
|
||||
## Summary
|
||||
|
||||
Converts TTF/OTF/WOFF/WOFF2 font files to the [typeface.js](https://gero3.github.io/facetype.js/) JSON format used by [Three.js `FontLoader`](https://threejs.org/docs/#examples/en/loaders/FontLoader). Also defines the canonical `TypefaceFont`, `TypefaceGlyph`, and `PathCommand` types consumed by `@suaveplan/font-compress`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [batch-converter](./src/core/batch-converter/batch-converter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [batch-converter](./src/core/batch-converter/batch-converter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [types](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [typeface](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [outline-to-string](./src/utils/outline-to-string.md) — Serialises an array of `PathCommand` objects to the space-separated token string stored in a `TypefaceGlyph`'s `o` fiel…
|
||||
- [reverse-winding](./src/utils/reverse-winding.md) — Reverses the winding order of a typeface.js path command sequence. Three.js `FontLoader` expects counter-clockwise wind…
|
||||
<!-- end:auto-docs --> - [batch-converter](./src/core/batch-converter/batch-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_- [typeface](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_- [outline-to-string](./src/utils/outline-to-string.md) — Serialises an array of `PathCommand` objects to the space-separated token string stored in a `TypefaceGlyph`'s `o` fiel…
|
||||
- [reverse-winding](./src/utils/reverse-winding.md) — Reverses the winding order of a typeface.js path command sequence. Three.js `FontLoader` expects counter-clockwise wind…
|
||||
- [batch-converter](./src/core/batch-converter/batch-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_- [typeface](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_- [outline-to-string](./src/utils/outline-to-string.md) — Serialises an array of `PathCommand` objects to the space-separated token string stored in a `TypefaceGlyph`'s `o` fiel…
|
||||
- [reverse-winding](./src/utils/reverse-winding.md) — Reverses the winding order of a typeface.js path command sequence. Three.js `FontLoader` expects counter-clockwise wind…
|
||||
- [batch-converter](./src/core/batch-converter/batch-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_- [typeface](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_- [outline-to-string](./src/utils/outline-to-string.md) — Serialises an array of `PathCommand` objects to the space-separated token string stored in a `TypefaceGlyph`'s `o` fiel…
|
||||
- [reverse-winding](./src/utils/reverse-winding.md) — Reverses the winding order of a typeface.js path command sequence. Three.js `FontLoader` expects counter-clockwise wind…
|
||||
- [batch-converter](./src/core/batch-converter/batch-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_ - [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_ - [typeface](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_- [outline-to-string](./src/utils/outline-to-string.md) — Serialises an array of `PathCommand` objects to the space-separated token string stored in a `TypefaceGlyph`'s `o` fiel…
|
||||
- [reverse-winding](./src/utils/reverse-winding.md) — Reverses the winding order of a typeface.js path command sequence. Three.js `FontLoader` expects counter-clockwise wind…
|
||||
|
||||
## Packages in the font family
|
||||
|
||||
| Package | Purpose |
|
||||
|---|---|
|
||||
| `@suaveplan/font-subset` | Unicode subset, WOFF2/WOFF encode, hinting strip |
|
||||
| **`@suaveplan/font-convert`** | **TTF/OTF → typeface.js JSON (this package)** |
|
||||
| `@suaveplan/font-compress` | typeface.js JSON → BTF binary pack/unpack |
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { createFontConverter } from "@suaveplan/font-convert";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const converter = createFontConverter();
|
||||
const font = converter.convert(readFileSync("MyFont.ttf"));
|
||||
|
||||
console.log(font.familyName); // "MyFont"
|
||||
console.log(font.cssFontWeight); // "normal" | "bold"
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Description |
|
||||
|---|---|
|
||||
| [FontConverter](src/core/font-converter/font-converter.md) | Parse font binary → TypefaceFont |
|
||||
| [BatchConverter](src/core/batch-converter/batch-converter.md) | Convert multiple fonts in one call |
|
||||
| [reverseWinding](src/utils/reverse-winding.md) | Reverse glyph winding order |
|
||||
| [outlineToString](src/utils/outline-to-string.md) | Serialise PathCommand[] to outline string |
|
||||
| [TypefaceFont types](src/types/typeface.md) | Canonical shared types |
|
||||
96
packages/media/fonts/font-convert/package.json
Normal file
96
packages/media/fonts/font-convert/package.json
Normal file
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"name": "@suaveplan/font-convert",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Convert TTF/OTF/WOFF/WOFF2 font files to typeface.js JSON format. Defines canonical TypefaceFont, TypefaceGlyph, and PathCommand types used by font-compress and font-decompress.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./types/typeface": {
|
||||
"import": {
|
||||
"types": "./dist/types/typeface.d.ts",
|
||||
"default": "./dist/types/typeface.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/types/typeface.d.ts",
|
||||
"default": "./dist/types/typeface.js"
|
||||
}
|
||||
},
|
||||
"./core/font-converter": {
|
||||
"import": {
|
||||
"types": "./dist/core/font-converter/index.d.ts",
|
||||
"default": "./dist/core/font-converter/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/font-converter/index.d.ts",
|
||||
"default": "./dist/core/font-converter/index.js"
|
||||
}
|
||||
},
|
||||
"./core/batch-converter": {
|
||||
"import": {
|
||||
"types": "./dist/core/batch-converter/index.d.ts",
|
||||
"default": "./dist/core/batch-converter/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/batch-converter/index.d.ts",
|
||||
"default": "./dist/core/batch-converter/index.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/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"opentype.js": "catalog:fonts",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@types/opentype.js": "catalog:fonts",
|
||||
"@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"
|
||||
]
|
||||
}
|
||||
4
packages/media/fonts/font-convert/src/constants/index.ts
Normal file
4
packages/media/fonts/font-convert/src/constants/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const RESOLUTION = 1000;
|
||||
export const DEFAULT_UNITS_PER_EM = 2048;
|
||||
export const SCALE_NUMERATOR = RESOLUTION * 100;
|
||||
export const SCALE_DENOMINATOR_FACTOR = 72;
|
||||
@@ -0,0 +1,102 @@
|
||||
# BatchConverter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Converts an array of font buffers to `TypefaceFont` JSON objects in a single call. It wraps `FontConverter` and applies the same conversion options to every item in the batch, returning an array of `{ name, font }` pairs. Useful for build pipelines that process multiple font files in a single pass before packing them to BTF binary.
|
||||
|
||||
## Features
|
||||
|
||||
- Accepts an array of `{ name: string; data: Uint8Array }` batch items
|
||||
- Returns `{ name: string; font: TypefaceFont }[]` in the same order as the input
|
||||
- Shares a single `FontConverter` instance across all items
|
||||
- Passes `ConversionOptions` uniformly to every conversion
|
||||
- Propagates `FontConvertError` immediately on the first failing item
|
||||
- Returns an empty array when given no items
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { BatchConverter } from "@suaveplan/font-convert";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const batch = new BatchConverter();
|
||||
|
||||
const results = batch.convertAll([
|
||||
{ name: "Roboto-Regular", data: readFileSync("Roboto-Regular.ttf") },
|
||||
{ name: "Roboto-Bold", data: readFileSync("Roboto-Bold.ttf") },
|
||||
]);
|
||||
|
||||
for (const { name, font } of results) {
|
||||
console.log(name, font.cssFontWeight);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { BatchConverter } from "@suaveplan/font-convert";
|
||||
|
||||
const batch = new BatchConverter({ logger: myLogger });
|
||||
|
||||
const results = batch.convertAll(
|
||||
[
|
||||
{ name: "Helvetica-Regular", data: helData },
|
||||
{ name: "Helvetica-Bold", data: boldData },
|
||||
],
|
||||
{
|
||||
reverseTypeface: true,
|
||||
restrictCharacters: true,
|
||||
restrictCharacterSet: "32-126", // printable ASCII only
|
||||
},
|
||||
);
|
||||
|
||||
results.forEach(({ name, font }) => {
|
||||
console.log(name, Object.keys(font.glyphs).length);
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class BatchConverter`
|
||||
|
||||
#### `constructor(options?: FontConverterOptions)`
|
||||
|
||||
Accepts the same options as `FontConverter`.
|
||||
|
||||
#### `convertAll(items: BatchItem[], options?: ConversionOptions): BatchResult[]`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `items` | `BatchItem[]` | Array of `{ name, data }` font inputs |
|
||||
| `options` | `ConversionOptions?` | Conversion options applied to all items |
|
||||
|
||||
**Returns**: `BatchResult[]` — `{ name: string; font: TypefaceFont }[]` in input order.
|
||||
|
||||
**Throws**: `FontConvertError` on the first item that fails to convert.
|
||||
|
||||
### `BatchItem`
|
||||
|
||||
```ts
|
||||
interface BatchItem {
|
||||
name: string;
|
||||
data: Uint8Array;
|
||||
}
|
||||
```
|
||||
|
||||
### `BatchResult`
|
||||
|
||||
```ts
|
||||
interface BatchResult {
|
||||
name: string;
|
||||
font: TypefaceFont;
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`BatchConverter` processes items sequentially with `Array.map`. There is no parallelism or streaming — fonts are converted one at a time in the order provided. This keeps error attribution simple (the first failure surfaces immediately) and avoids concurrency complications in build pipelines where ordering matters.
|
||||
|
||||
For very large batches (100+ fonts), consider chunking the items and processing chunks in separate worker threads if total wall-clock conversion time becomes a concern.
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
|
||||
import { FontConvertError } from "../../error-codes.js";
|
||||
import { FontConverter } from "../font-converter/font-converter.js";
|
||||
import { BatchConverter } from "./batch-converter.js";
|
||||
|
||||
describe("BatchConverter", () => {
|
||||
it("returns empty array for empty input", { timeout: 5000 }, () => {
|
||||
const batch = new BatchConverter();
|
||||
expect(batch.convertAll([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("calls converter.convert for each item and returns results", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const mockFont = {
|
||||
glyphs: {},
|
||||
familyName: "Test",
|
||||
ascender: 0,
|
||||
descender: 0,
|
||||
underlinePosition: 0,
|
||||
underlineThickness: 0,
|
||||
boundingBox: { yMin: 0, xMin: 0, yMax: 0, xMax: 0 },
|
||||
resolution: 1000,
|
||||
original_font_information: {},
|
||||
cssFontWeight: "normal",
|
||||
cssFontStyle: "normal",
|
||||
reversed: false,
|
||||
};
|
||||
|
||||
const converterSpy = vi
|
||||
.spyOn(FontConverter.prototype, "convert")
|
||||
.mockReturnValue(mockFont);
|
||||
|
||||
const batch = new BatchConverter();
|
||||
const data = new Uint8Array([0, 1, 2, 3]);
|
||||
const results = batch.convertAll([
|
||||
{ name: "font-a", data },
|
||||
{ name: "font-b", data },
|
||||
]);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]?.name).toBe("font-a");
|
||||
expect(results[1]?.name).toBe("font-b");
|
||||
expect(results[0]?.font).toBe(mockFont);
|
||||
converterSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("propagates FontConvertError from individual item", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const convertError = new FontConvertError(
|
||||
"FONT_CONVERT.PARSE_FAILED" as any,
|
||||
"bad font"
|
||||
);
|
||||
vi.spyOn(FontConverter.prototype, "convert").mockImplementation(() => {
|
||||
throw convertError;
|
||||
});
|
||||
|
||||
const batch = new BatchConverter();
|
||||
expect(() =>
|
||||
batch.convertAll([{ name: "bad", data: new Uint8Array([0]) }])
|
||||
).toThrow(FontConvertError);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("accepts ConversionOptions and passes them to converter", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const mockFont = {
|
||||
glyphs: {},
|
||||
familyName: "Test",
|
||||
ascender: 0,
|
||||
descender: 0,
|
||||
underlinePosition: 0,
|
||||
underlineThickness: 0,
|
||||
boundingBox: { yMin: 0, xMin: 0, yMax: 0, xMax: 0 },
|
||||
resolution: 1000,
|
||||
original_font_information: {},
|
||||
cssFontWeight: "normal",
|
||||
cssFontStyle: "normal",
|
||||
reversed: true,
|
||||
};
|
||||
|
||||
const convertSpy = vi
|
||||
.spyOn(FontConverter.prototype, "convert")
|
||||
.mockReturnValue(mockFont);
|
||||
|
||||
const batch = new BatchConverter();
|
||||
batch.convertAll([{ name: "x", data: new Uint8Array([0]) }], {
|
||||
reverseTypeface: true,
|
||||
});
|
||||
|
||||
expect(convertSpy).toHaveBeenCalledWith(expect.any(Uint8Array), {
|
||||
reverseTypeface: true,
|
||||
});
|
||||
convertSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { TypefaceFont } from "../../types/typeface.js";
|
||||
import { FontConverter } from "../font-converter/font-converter.js";
|
||||
import type {
|
||||
ConversionOptions,
|
||||
FontConverterOptions,
|
||||
} from "../font-converter/font-converter.types.js";
|
||||
|
||||
export interface BatchItem {
|
||||
name: string;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
export interface BatchResult {
|
||||
name: string;
|
||||
font: TypefaceFont;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an array of font buffers to typeface.js JSON in one call.
|
||||
* Each item is processed synchronously via the underlying FontConverter.
|
||||
*/
|
||||
export class BatchConverter {
|
||||
readonly #converter: FontConverter;
|
||||
|
||||
constructor(options?: FontConverterOptions) {
|
||||
this.#converter = new FontConverter(options);
|
||||
}
|
||||
|
||||
convertAll(items: BatchItem[], options?: ConversionOptions): BatchResult[] {
|
||||
return items.map((item) => ({
|
||||
name: item.name,
|
||||
font: this.#converter.convert(item.data, options),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { BatchItem, BatchResult } from "./batch-converter.js";
|
||||
export { BatchConverter } from "./batch-converter.js";
|
||||
@@ -0,0 +1,97 @@
|
||||
# FontConverter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Converts a TTF, OTF, WOFF, or WOFF2 font binary to the `TypefaceFont` JSON format used by Three.js `FontLoader`. It parses the binary via `opentype.js`, scales all coordinates to the 1 000-unit typeface.js resolution, and constructs a complete `TypefaceFont` object ready for BTF binary packing by `@suaveplan/font-compress`. `FontConverter` is the primary consumer-facing class; `createFontConverter` is the factory function.
|
||||
|
||||
## Features
|
||||
|
||||
- Accepts raw font data as `Uint8Array` or `ArrayBuffer`
|
||||
- Parses TTF, OTF, WOFF, and WOFF2 formats via `opentype.js`
|
||||
- Applies the typeface.js normalisation scale: `(1000 × 100) / (unitsPerEm × 72)`
|
||||
- Converts all command types including cubic bezier (`C` → `b` in typeface.js notation)
|
||||
- Optional winding reversal via `reverseTypeface` option (delegates to `reverseWinding`)
|
||||
- Optional character restriction by set string or numeric codepoint range (`"65-90"`)
|
||||
- Falls back to `font.familyName` and `"Unknown"` when name table fields are absent
|
||||
- Detects `bold`/`italic` from the font subfamily name (case-insensitive)
|
||||
- Accepts a pre-parsed `opentype.Font` via `convertFromParsed` for batch or pipeline use
|
||||
- Wraps all errors in `FontConvertError` with stable error codes
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createFontConverter } from "@suaveplan/font-convert";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const data = readFileSync("Roboto-Regular.ttf");
|
||||
const converter = createFontConverter();
|
||||
const font = converter.convert(data);
|
||||
|
||||
console.log(font.familyName); // "Roboto"
|
||||
console.log(font.cssFontWeight); // "normal"
|
||||
console.log(Object.keys(font.glyphs).length); // glyph count
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createFontConverter } from "@suaveplan/font-convert";
|
||||
import * as opentype from "opentype.js";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const buffer = readFileSync("MyFont-Bold.ttf").buffer;
|
||||
const parsed = opentype.parse(buffer);
|
||||
|
||||
const converter = createFontConverter();
|
||||
|
||||
// Convert from already-parsed font (avoids double-parse in pipelines)
|
||||
const font = converter.convertFromParsed(parsed, {
|
||||
reverseTypeface: true, // flip winding for TrueType CFF fonts
|
||||
restrictCharacters: true,
|
||||
restrictCharacterSet: "65-122", // only A–Z and a–z
|
||||
});
|
||||
|
||||
console.log(font.cssFontWeight); // "bold" — detected from subfamily
|
||||
console.log(font.reversed); // true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class FontConverter`
|
||||
|
||||
#### `constructor(options?: FontConverterOptions)`
|
||||
|
||||
| Option | Type | Description |
|
||||
|---|---|---|
|
||||
| `logger` | `LoggerLike?` | Optional structured logger |
|
||||
|
||||
#### `convert(data: Uint8Array \| ArrayBuffer, options?: ConversionOptions): TypefaceFont`
|
||||
|
||||
Parses and converts a font binary. Throws `FontConvertError` with code `FONT_CONVERT.PARSE_FAILED` if the binary cannot be parsed.
|
||||
|
||||
#### `convertFromParsed(font: opentype.Font, options?: ConversionOptions): TypefaceFont`
|
||||
|
||||
Converts an already-parsed `opentype.Font` object. Throws `FontConvertError` with code `FONT_CONVERT.ENCODE_FAILED` on failure.
|
||||
|
||||
### `ConversionOptions`
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `reverseTypeface` | `boolean?` | `false` | Reverse glyph winding order |
|
||||
| `restrictCharacters` | `boolean?` | `false` | Enable character filtering |
|
||||
| `restrictCharacterSet` | `string?` | — | Characters or `"start-end"` range |
|
||||
|
||||
### `createFontConverter(options?: FontConverterOptions): FontConverter`
|
||||
|
||||
Factory function. Equivalent to `new FontConverter(options)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The scale factor `(1000 × 100) / (unitsPerEm × 72)` is the canonical typeface.js normalisation used by the original `facetype.js` CLI and `three.js` font generation scripts. The `× 100` and `÷ 72` factors preserve the original script's DPI-to-point conversion. A fallback of `unitsPerEm = 2048` (the TrueType default) is used when the parsed font reports `unitsPerEm = 0`.
|
||||
|
||||
Cubic bezier commands are renamed from SVG's `C`/`c` to typeface.js's `b` during command mapping. All other command types (`m`, `l`, `q`, `z`) are lowercased but otherwise unchanged.
|
||||
|
||||
Character restriction supports two modes: a literal character-set string (e.g. `"ABC"`) and a numeric codepoint range in the form `"start-end"` (e.g. `"65-90"` for ASCII A–Z). The range form is detected when the string splits on `-` into exactly two valid integers; anything else is treated as a literal character set.
|
||||
@@ -0,0 +1,407 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FontConvertError } from "../../error-codes.js";
|
||||
import { createFontConverter, FontConverter } from "./font-converter.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal opentype.js font mock factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeGlyph(
|
||||
unicode: number | undefined,
|
||||
unicodes: number[],
|
||||
advanceWidth: number,
|
||||
commands: Array<{
|
||||
type: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
x1?: number;
|
||||
y1?: number;
|
||||
x2?: number;
|
||||
y2?: number;
|
||||
}>
|
||||
) {
|
||||
return {
|
||||
unicode,
|
||||
unicodes,
|
||||
advanceWidth,
|
||||
xMin: 0,
|
||||
xMax: advanceWidth,
|
||||
path: { commands },
|
||||
};
|
||||
}
|
||||
|
||||
function makeFont(glyphs: ReturnType<typeof makeGlyph>[] = []) {
|
||||
return {
|
||||
unitsPerEm: 1000,
|
||||
ascender: 800,
|
||||
descender: -200,
|
||||
familyName: "TestFont",
|
||||
styleName: "Regular",
|
||||
names: {
|
||||
fontFamily: { en: "TestFont" },
|
||||
fontSubfamily: { en: "Regular" },
|
||||
name: {},
|
||||
},
|
||||
tables: {
|
||||
name: {},
|
||||
post: { underlinePosition: -100, underlineThickness: 50 },
|
||||
head: { yMin: -200, xMin: 0, yMax: 800, xMax: 1000 },
|
||||
},
|
||||
glyphs: {
|
||||
length: glyphs.length,
|
||||
get: (i: number) => glyphs[i],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("FontConverter", () => {
|
||||
describe("createFontConverter", () => {
|
||||
it("returns a FontConverter instance", { timeout: 5000 }, () => {
|
||||
const converter = createFontConverter();
|
||||
expect(converter).toBeInstanceOf(FontConverter);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertFromParsed", () => {
|
||||
it("returns a TypefaceFont with correct familyName", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const font = makeFont();
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(result.familyName).toBe("TestFont");
|
||||
});
|
||||
|
||||
it("sets resolution to 1000", { timeout: 5000 }, () => {
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(makeFont() as any);
|
||||
expect(result.resolution).toBe(1000);
|
||||
});
|
||||
|
||||
it("computes ascender and descender using scale factor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const font = makeFont();
|
||||
// scale = (1000*100) / (1000*72) = 100000/72000 ≈ 1.3889
|
||||
const scale = (1000 * 100) / (1000 * 72);
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(result.ascender).toBe(Math.round(800 * scale));
|
||||
expect(result.descender).toBe(Math.round(-200 * scale));
|
||||
});
|
||||
|
||||
it("includes glyphs with unicode codepoints", { timeout: 5000 }, () => {
|
||||
const glyphs = [
|
||||
makeGlyph(65, [65], 600, [
|
||||
{ type: "M", x: 0, y: 0 },
|
||||
{ type: "L", x: 100, y: 0 },
|
||||
{ type: "Z" },
|
||||
]),
|
||||
];
|
||||
const font = makeFont(glyphs);
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(result.glyphs["A"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("maps cubic bezier type C/c to 'b' in outline string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const glyphs = [
|
||||
makeGlyph(65, [65], 600, [
|
||||
{ type: "M", x: 0, y: 0 },
|
||||
{ type: "C", x1: 10, y1: 20, x2: 30, y2: 40, x: 100, y: 0 },
|
||||
{ type: "Z" },
|
||||
]),
|
||||
];
|
||||
const font = makeFont(glyphs);
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(result.glyphs["A"]?.o).toContain("b ");
|
||||
});
|
||||
|
||||
it("applies reverseTypeface option when true", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const glyphs = [
|
||||
makeGlyph(65, [65], 600, [
|
||||
{ type: "M", x: 0, y: 0 },
|
||||
{ type: "L", x: 100, y: 0 },
|
||||
]),
|
||||
];
|
||||
const font = makeFont(glyphs);
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any, {
|
||||
reverseTypeface: true,
|
||||
});
|
||||
expect(result.reversed).toBe(true);
|
||||
});
|
||||
|
||||
it("sets reversed=false when not reversing", { timeout: 5000 }, () => {
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(makeFont() as any);
|
||||
expect(result.reversed).toBe(false);
|
||||
});
|
||||
|
||||
it("detects bold style from fontSubfamily name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const font = makeFont();
|
||||
(font.names.fontSubfamily as any) = { en: "Bold" };
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(result.cssFontWeight).toBe("bold");
|
||||
});
|
||||
|
||||
it("detects italic style from fontSubfamily name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const font = makeFont();
|
||||
(font.names.fontSubfamily as any) = { en: "Italic" };
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(result.cssFontStyle).toBe("italic");
|
||||
});
|
||||
|
||||
it("sets cssFontWeight/Style to normal for Regular", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(makeFont() as any);
|
||||
expect(result.cssFontWeight).toBe("normal");
|
||||
expect(result.cssFontStyle).toBe("normal");
|
||||
});
|
||||
|
||||
it("restricts output to character set when restrictCharacters=true", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const glyphs = [
|
||||
makeGlyph(65, [65], 600, [{ type: "M", x: 0, y: 0 }]), // A
|
||||
makeGlyph(66, [66], 600, [{ type: "M", x: 0, y: 0 }]), // B
|
||||
];
|
||||
const font = makeFont(glyphs);
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any, {
|
||||
restrictCharacters: true,
|
||||
restrictCharacterSet: "A",
|
||||
});
|
||||
expect(result.glyphs["A"]).toBeDefined();
|
||||
expect(result.glyphs["B"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("restricts output to numeric range when restrictCharacterSet is 'start-end'", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const glyphs = [
|
||||
makeGlyph(65, [65], 600, [{ type: "M", x: 0, y: 0 }]), // A (65)
|
||||
makeGlyph(90, [90], 600, [{ type: "M", x: 0, y: 0 }]), // Z (90)
|
||||
makeGlyph(97, [97], 600, [{ type: "M", x: 0, y: 0 }]), // a (97)
|
||||
];
|
||||
const font = makeFont(glyphs);
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any, {
|
||||
restrictCharacters: true,
|
||||
restrictCharacterSet: "65-90",
|
||||
});
|
||||
expect(result.glyphs["A"]).toBeDefined();
|
||||
expect(result.glyphs["Z"]).toBeDefined();
|
||||
expect(result.glyphs["a"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips glyphs with no unicode codepoint", { timeout: 5000 }, () => {
|
||||
const glyphs = [
|
||||
makeGlyph(undefined, [], 600, [{ type: "M", x: 0, y: 0 }]),
|
||||
];
|
||||
const font = makeFont(glyphs);
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(Object.keys(result.glyphs)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("deduplicates unicode codepoints in unicodes array", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// glyph.unicode = 65, glyph.unicodes = [65, 65] — should only produce one entry
|
||||
const glyphs = [makeGlyph(65, [65, 65], 600, [])];
|
||||
const font = makeFont(glyphs);
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(Object.keys(result.glyphs)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses DEFAULT_UNITS_PER_EM (2048) when unitsPerEm is 0", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const font = makeFont();
|
||||
(font as any).unitsPerEm = 0;
|
||||
const scale = (1000 * 100) / (2048 * 72);
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(result.ascender).toBe(Math.round(800 * scale));
|
||||
});
|
||||
|
||||
it("falls back to font.familyName when names.fontFamily.en is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const font = makeFont();
|
||||
(font.names.fontFamily as any) = {};
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(result.familyName).toBe("TestFont");
|
||||
});
|
||||
|
||||
it("falls back to 'Unknown' when both fontFamily fields are absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const font = makeFont();
|
||||
(font.names.fontFamily as any) = {};
|
||||
(font as any).familyName = undefined;
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(result.familyName).toBe("Unknown");
|
||||
});
|
||||
|
||||
it("wraps thrown errors from opentype in FontConvertError", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const badFont = {
|
||||
...makeFont(),
|
||||
get ascender() {
|
||||
throw new Error("corrupt font data");
|
||||
},
|
||||
};
|
||||
const converter = createFontConverter();
|
||||
expect(() => converter.convertFromParsed(badFont as any)).toThrow(
|
||||
FontConvertError
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to font.styleName when fontSubfamily.en is absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const font = makeFont();
|
||||
(font.names.fontSubfamily as any) = {};
|
||||
(font as any).styleName = "Bold";
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(result.cssFontWeight).toBe("bold");
|
||||
});
|
||||
|
||||
it("falls back to 'Regular' when both fontSubfamily.en and styleName are absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const font = makeFont();
|
||||
(font.names.fontSubfamily as any) = {};
|
||||
(font as any).styleName = undefined;
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(result.cssFontWeight).toBe("normal");
|
||||
expect(result.cssFontStyle).toBe("normal");
|
||||
});
|
||||
|
||||
it("collects codepoints from glyph with no unicodes array", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Glyph has unicode but no unicodes property at all
|
||||
const glyphNoUnicodes = {
|
||||
unicode: 66,
|
||||
advanceWidth: 600,
|
||||
xMin: 0,
|
||||
xMax: 600,
|
||||
path: { commands: [] },
|
||||
};
|
||||
const font = makeFont([glyphNoUnicodes as any]);
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(result.glyphs["B"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("convert", () => {
|
||||
it("converts a real TTF buffer to TypefaceFont", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ttf = readFileSync(
|
||||
new URL("../../../tests/fixtures/test.ttf", import.meta.url)
|
||||
);
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convert(ttf);
|
||||
expect(result.familyName).toBe("Comfortaa");
|
||||
expect(Object.keys(result.glyphs).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("converts a real OTF buffer to TypefaceFont", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const otf = readFileSync(
|
||||
new URL("../../../tests/fixtures/test.otf", import.meta.url)
|
||||
);
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convert(otf);
|
||||
expect(result.familyName).toBeDefined();
|
||||
expect(Object.keys(result.glyphs).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("throws FontConvertError on invalid binary data", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const converter = createFontConverter();
|
||||
const bad = new Uint8Array([0x00, 0x01, 0x02]);
|
||||
expect(() => converter.convert(bad)).toThrow(FontConvertError);
|
||||
});
|
||||
|
||||
it("re-throws FontConvertError from parse unchanged", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const originalErr = new FontConvertError(
|
||||
"FONT_CONVERT.PARSE_FAILED" as any,
|
||||
"inner error"
|
||||
);
|
||||
const converter = createFontConverter({
|
||||
parse: () => {
|
||||
throw originalErr;
|
||||
},
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
converter.convert(new Uint8Array([0, 1, 0, 0, 0, 0]));
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBe(originalErr);
|
||||
});
|
||||
|
||||
it("wraps parser Error in FontConvertError", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const converter = createFontConverter({
|
||||
parse: () => {
|
||||
throw new Error("parse error");
|
||||
},
|
||||
});
|
||||
expect(() =>
|
||||
converter.convert(
|
||||
new Uint8Array([0, 1, 0, 0, 0, 0]).buffer as ArrayBuffer
|
||||
)
|
||||
).toThrow(FontConvertError);
|
||||
});
|
||||
|
||||
it("wraps non-Error thrown value in FontConvertError", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const converter = createFontConverter({
|
||||
parse: () => {
|
||||
throw "string error";
|
||||
},
|
||||
});
|
||||
expect(() =>
|
||||
converter.convert(new Uint8Array([0, 1, 0, 0, 0, 0]))
|
||||
).toThrow(FontConvertError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
import * as opentype from "opentype.js";
|
||||
import {
|
||||
DEFAULT_UNITS_PER_EM,
|
||||
RESOLUTION,
|
||||
SCALE_DENOMINATOR_FACTOR,
|
||||
SCALE_NUMERATOR,
|
||||
} from "../../constants/index.js";
|
||||
import { FONT_CONVERT_CODES, FontConvertError } from "../../error-codes.js";
|
||||
import type {
|
||||
PathCommand,
|
||||
TypefaceFont,
|
||||
TypefaceGlyph,
|
||||
} from "../../types/typeface.js";
|
||||
import { reverseWinding } from "../../utils/reverse-winding.js";
|
||||
import type {
|
||||
ConversionOptions,
|
||||
FontConverterOptions,
|
||||
} from "./font-converter.types.js";
|
||||
|
||||
type RawCommand = {
|
||||
type: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
x1?: number;
|
||||
y1?: number;
|
||||
x2?: number;
|
||||
y2?: number;
|
||||
};
|
||||
|
||||
type FontTables = {
|
||||
post: { underlinePosition: number; underlineThickness: number };
|
||||
head: { yMin: number; xMin: number; yMax: number; xMax: number };
|
||||
name: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export class FontConverter {
|
||||
readonly #logger: LoggerLike | undefined;
|
||||
readonly #parse: (buffer: ArrayBuffer) => opentype.Font;
|
||||
|
||||
constructor(options: FontConverterOptions = {}) {
|
||||
this.#logger = options.logger;
|
||||
this.#parse = options.parse ?? opentype.parse;
|
||||
}
|
||||
|
||||
convert(
|
||||
data: Uint8Array | ArrayBuffer,
|
||||
options?: ConversionOptions
|
||||
): TypefaceFont {
|
||||
try {
|
||||
const buffer =
|
||||
data instanceof Uint8Array
|
||||
? data.buffer.slice(
|
||||
data.byteOffset,
|
||||
data.byteOffset + data.byteLength
|
||||
)
|
||||
: (data as ArrayBuffer);
|
||||
const font = this.#parse(buffer as ArrayBuffer);
|
||||
return this.convertFromParsed(font, options);
|
||||
} catch (err) {
|
||||
if (err instanceof FontConvertError) throw err;
|
||||
this.#logger?.error("font parse failed", { cause: err });
|
||||
throw new FontConvertError(
|
||||
FONT_CONVERT_CODES["PARSE_FAILED"],
|
||||
err instanceof Error ? err.message : String(err),
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
convertFromParsed(
|
||||
font: opentype.Font,
|
||||
options: ConversionOptions = {}
|
||||
): TypefaceFont {
|
||||
try {
|
||||
const upm = font.unitsPerEm || DEFAULT_UNITS_PER_EM;
|
||||
const scale = SCALE_NUMERATOR / (upm * SCALE_DENOMINATOR_FACTOR);
|
||||
|
||||
const familyName =
|
||||
(font.names.fontFamily as Record<string, string> | undefined)?.[
|
||||
"en"
|
||||
] ??
|
||||
(font as unknown as { familyName?: string }).familyName ??
|
||||
"Unknown";
|
||||
|
||||
const tables = font.tables as unknown as FontTables;
|
||||
|
||||
const result: TypefaceFont = {
|
||||
glyphs: {},
|
||||
familyName,
|
||||
ascender: Math.round(font.ascender * scale),
|
||||
descender: Math.round(font.descender * scale),
|
||||
underlinePosition: Math.round(
|
||||
tables["post"].underlinePosition * scale
|
||||
),
|
||||
underlineThickness: Math.round(
|
||||
tables["post"].underlineThickness * scale
|
||||
),
|
||||
boundingBox: {
|
||||
yMin: Math.round(tables["head"].yMin * scale),
|
||||
xMin: Math.round(tables["head"].xMin * scale),
|
||||
yMax: Math.round(tables["head"].yMax * scale),
|
||||
xMax: Math.round(tables["head"].xMax * scale),
|
||||
},
|
||||
resolution: RESOLUTION,
|
||||
original_font_information: tables["name"],
|
||||
cssFontWeight: "normal",
|
||||
cssFontStyle: "normal",
|
||||
};
|
||||
|
||||
const restriction = this.#parseRestriction(options);
|
||||
|
||||
const glyphSet = font.glyphs;
|
||||
for (let i = 0; i < glyphSet.length; i++) {
|
||||
const glyph = glyphSet.get(i) as opentype.Glyph & {
|
||||
unicodes?: number[];
|
||||
};
|
||||
const unicodes = this.#collectUnicodes(glyph);
|
||||
|
||||
for (const cp of unicodes) {
|
||||
if (!this.#shouldExport(cp, restriction)) continue;
|
||||
|
||||
const glyphData = this.#buildGlyph(glyph, scale, options);
|
||||
result.glyphs[String.fromCharCode(cp)] = glyphData;
|
||||
}
|
||||
}
|
||||
|
||||
const subfamily =
|
||||
(
|
||||
font.names.fontSubfamily as
|
||||
| Record<string, string>
|
||||
| undefined
|
||||
)?.["en"] ??
|
||||
(font as unknown as { styleName?: string }).styleName ??
|
||||
"Regular";
|
||||
|
||||
result.cssFontWeight = subfamily.toLowerCase().includes("bold")
|
||||
? "bold"
|
||||
: "normal";
|
||||
result.cssFontStyle = subfamily.toLowerCase().includes("italic")
|
||||
? "italic"
|
||||
: "normal";
|
||||
result.reversed = options.reverseTypeface ?? false;
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.#logger?.error("font encode failed", { cause: err });
|
||||
throw new FontConvertError(
|
||||
FONT_CONVERT_CODES["ENCODE_FAILED"],
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: /* istanbul ignore next */ String(err),
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#collectUnicodes(
|
||||
glyph: opentype.Glyph & { unicodes?: number[] }
|
||||
): number[] {
|
||||
const seen = new Set<number>();
|
||||
if (glyph.unicode !== undefined) seen.add(glyph.unicode);
|
||||
for (const cp of glyph.unicodes ?? []) seen.add(cp);
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
#parseRestriction(options: ConversionOptions): {
|
||||
range: [number, number] | null;
|
||||
set: string | null;
|
||||
} {
|
||||
const restriction: {
|
||||
range: [number, number] | null;
|
||||
set: string | null;
|
||||
} = { range: null, set: null };
|
||||
|
||||
if (!options.restrictCharacters || !options.restrictCharacterSet) {
|
||||
return restriction;
|
||||
}
|
||||
|
||||
const content = options.restrictCharacterSet;
|
||||
const parts = content.split("-");
|
||||
if (
|
||||
parts.length === 2 &&
|
||||
!Number.isNaN(Number.parseInt(parts[0]!, 10)) &&
|
||||
!Number.isNaN(Number.parseInt(parts[1]!, 10))
|
||||
) {
|
||||
restriction.range = [
|
||||
Number.parseInt(parts[0]!, 10),
|
||||
Number.parseInt(parts[1]!, 10),
|
||||
];
|
||||
} else {
|
||||
restriction.set = content;
|
||||
}
|
||||
|
||||
return restriction;
|
||||
}
|
||||
|
||||
#shouldExport(
|
||||
cp: number,
|
||||
restriction: { range: [number, number] | null; set: string | null }
|
||||
): boolean {
|
||||
if (restriction.range !== null) {
|
||||
return cp >= restriction.range[0] && cp <= restriction.range[1];
|
||||
}
|
||||
if (restriction.set !== null) {
|
||||
return restriction.set.includes(String.fromCharCode(cp));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#buildGlyph(
|
||||
glyph: opentype.Glyph,
|
||||
scale: number,
|
||||
options: ConversionOptions
|
||||
): TypefaceGlyph {
|
||||
const rawCommands = (
|
||||
glyph.path as unknown as { commands: RawCommand[] }
|
||||
).commands;
|
||||
|
||||
const pathCmds = rawCommands.map<PathCommand>((c) => {
|
||||
const rawType =
|
||||
c.type.toLowerCase() === "c" ? "b" : c.type.toLowerCase();
|
||||
const cmd: PathCommand = {
|
||||
type: rawType as PathCommand["type"],
|
||||
};
|
||||
if (c.x !== undefined) cmd.x = c.x;
|
||||
if (c.y !== undefined) cmd.y = c.y;
|
||||
if (c.x1 !== undefined) cmd.x1 = c.x1;
|
||||
if (c.y1 !== undefined) cmd.y1 = c.y1;
|
||||
if (c.x2 !== undefined) cmd.x2 = c.x2;
|
||||
if (c.y2 !== undefined) cmd.y2 = c.y2;
|
||||
return cmd;
|
||||
});
|
||||
|
||||
const finalCmds = options.reverseTypeface
|
||||
? reverseWinding(pathCmds)
|
||||
: pathCmds;
|
||||
|
||||
let o = "";
|
||||
for (const cmd of finalCmds) {
|
||||
o += `${cmd.type} `;
|
||||
if (cmd.x !== undefined && cmd.y !== undefined) {
|
||||
o += `${Math.round(cmd.x * scale)} ${Math.round(cmd.y * scale)} `;
|
||||
}
|
||||
if (cmd.x1 !== undefined && cmd.y1 !== undefined) {
|
||||
o += `${Math.round(cmd.x1 * scale)} ${Math.round(cmd.y1 * scale)} `;
|
||||
}
|
||||
if (cmd.x2 !== undefined && cmd.y2 !== undefined) {
|
||||
o += `${Math.round(cmd.x2 * scale)} ${Math.round(cmd.y2 * scale)} `;
|
||||
}
|
||||
}
|
||||
|
||||
const rawGlyph = glyph as unknown as {
|
||||
advanceWidth: number;
|
||||
xMin: number;
|
||||
xMax: number;
|
||||
};
|
||||
|
||||
return {
|
||||
ha: Math.round(rawGlyph.advanceWidth * scale),
|
||||
x_min: Math.round(rawGlyph.xMin * scale),
|
||||
x_max: Math.round(rawGlyph.xMax * scale),
|
||||
o,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createFontConverter(
|
||||
options?: FontConverterOptions
|
||||
): FontConverter {
|
||||
return new FontConverter(options);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
|
||||
export interface ConversionOptions {
|
||||
/** Reverse glyph winding order for TrueType CFF compat. Default: false */
|
||||
reverseTypeface?: boolean;
|
||||
/** Limit output to a specific character set. Default: false */
|
||||
restrictCharacters?: boolean;
|
||||
/** Characters to include when restrictCharacters=true */
|
||||
restrictCharacterSet?: string;
|
||||
}
|
||||
|
||||
export interface FontConverterOptions {
|
||||
logger?: LoggerLike;
|
||||
/** Optional parser override for testing or custom pipelines. */
|
||||
parse?: (buffer: ArrayBuffer) => import("opentype.js").Font;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { createFontConverter, FontConverter } from "./font-converter.js";
|
||||
export type {
|
||||
ConversionOptions,
|
||||
FontConverterOptions,
|
||||
} from "./font-converter.types.js";
|
||||
32
packages/media/fonts/font-convert/src/error-codes.ts
Normal file
32
packages/media/fonts/font-convert/src/error-codes.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
export const FONT_CONVERT_CODES = defineCodes("FONT_CONVERT", [
|
||||
"PARSE_FAILED",
|
||||
"ENCODE_FAILED",
|
||||
"INVALID_OPTIONS",
|
||||
"UNSUPPORTED_FORMAT",
|
||||
] as const);
|
||||
|
||||
export type FontConvertErrorCode =
|
||||
(typeof FONT_CONVERT_CODES)[keyof typeof FONT_CONVERT_CODES];
|
||||
|
||||
export class FontConvertError extends SuaveplanError<FontConvertErrorCode> {
|
||||
public constructor(
|
||||
code: FontConvertErrorCode,
|
||||
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 = "FontConvertError";
|
||||
}
|
||||
}
|
||||
22
packages/media/fonts/font-convert/src/index.ts
Normal file
22
packages/media/fonts/font-convert/src/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export type {
|
||||
BatchItem,
|
||||
BatchResult,
|
||||
} from "./core/batch-converter/index.js";
|
||||
export { BatchConverter } from "./core/batch-converter/index.js";
|
||||
export type {
|
||||
ConversionOptions,
|
||||
FontConverterOptions,
|
||||
} from "./core/font-converter/index.js";
|
||||
|
||||
export {
|
||||
createFontConverter,
|
||||
FontConverter,
|
||||
} from "./core/font-converter/index.js";
|
||||
export type { FontConvertErrorCode } from "./error-codes.js";
|
||||
export { FONT_CONVERT_CODES, FontConvertError } from "./error-codes.js";
|
||||
export type {
|
||||
PathCommand,
|
||||
PathCommandType,
|
||||
TypefaceFont,
|
||||
TypefaceGlyph,
|
||||
} from "./types/typeface.js";
|
||||
125
packages/media/fonts/font-convert/src/types/typeface.md
Normal file
125
packages/media/fonts/font-convert/src/types/typeface.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# TypefaceFont Types
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the canonical shared types for the typeface.js font format used throughout the `@suaveplan/font-*` package family. `TypefaceFont` is the output of `FontConverter` and the input to `BinaryFontPacker`; `TypefaceGlyph` describes a single glyph's outline; `PathCommand` encodes a single vector drawing operation in the outline string.
|
||||
|
||||
These types are the authoritative source — `@suaveplan/font-compress` and `@suaveplan/font-decompress` both import from here rather than defining their own.
|
||||
|
||||
## Features
|
||||
|
||||
- `PathCommandType` — union of the five typeface.js command tokens: `m` (moveto), `l` (lineto), `q` (quadratic bezier), `b` (cubic bezier), `z` (close path)
|
||||
- `PathCommand` — structured representation of a single outline command with optional coordinate fields
|
||||
- `TypefaceGlyph` — horizontal advance, bounding box extremes, and the serialised outline string
|
||||
- `TypefaceFont` — complete typeface.js document: glyph dictionary, font metadata, bounding box, CSS weight/style, and an optional `reversed` flag for winding-order correction
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { TypefaceFont, TypefaceGlyph, PathCommand } from "@suaveplan/font-convert/types/typeface";
|
||||
|
||||
const glyph: TypefaceGlyph = {
|
||||
ha: 1234,
|
||||
x_min: 10,
|
||||
x_max: 800,
|
||||
o: "m 100 0 l 100 700 l 500 700 z ",
|
||||
};
|
||||
|
||||
const font: TypefaceFont = {
|
||||
glyphs: { A: glyph },
|
||||
familyName: "MyFont",
|
||||
ascender: 800,
|
||||
descender: -200,
|
||||
underlinePosition: -100,
|
||||
underlineThickness: 50,
|
||||
boundingBox: { yMin: -200, xMin: 0, yMax: 800, xMax: 1000 },
|
||||
resolution: 1000,
|
||||
original_font_information: {},
|
||||
cssFontWeight: "normal",
|
||||
cssFontStyle: "normal",
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { PathCommand, PathCommandType } from "@suaveplan/font-convert/types/typeface";
|
||||
|
||||
function parseOutlineString(outline: string): PathCommand[] {
|
||||
const tokens = outline.trim().split(/\s+/);
|
||||
const commands: PathCommand[] = [];
|
||||
let i = 0;
|
||||
while (i < tokens.length) {
|
||||
const type = tokens[i++] as PathCommandType;
|
||||
const cmd: PathCommand = { type };
|
||||
if (type === "m" || type === "l") {
|
||||
cmd.x = Number(tokens[i++]);
|
||||
cmd.y = Number(tokens[i++]);
|
||||
} else if (type === "q") {
|
||||
cmd.x1 = Number(tokens[i++]);
|
||||
cmd.y1 = Number(tokens[i++]);
|
||||
cmd.x = Number(tokens[i++]);
|
||||
cmd.y = Number(tokens[i++]);
|
||||
} else if (type === "b") {
|
||||
cmd.x1 = Number(tokens[i++]);
|
||||
cmd.y1 = Number(tokens[i++]);
|
||||
cmd.x2 = Number(tokens[i++]);
|
||||
cmd.y2 = Number(tokens[i++]);
|
||||
cmd.x = Number(tokens[i++]);
|
||||
cmd.y = Number(tokens[i++]);
|
||||
}
|
||||
commands.push(cmd);
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `PathCommandType`
|
||||
|
||||
String union: `"m" | "l" | "q" | "b" | "z"`. Maps to typeface.js outline token types (note: cubic bezier uses `"b"`, not SVG's `"c"`).
|
||||
|
||||
### `PathCommand`
|
||||
|
||||
| Field | Type | Present for |
|
||||
|---|---|---|
|
||||
| `type` | `PathCommandType` | all |
|
||||
| `x`, `y` | `number` | `m`, `l`, `q`, `b` |
|
||||
| `x1`, `y1` | `number` | `q`, `b` |
|
||||
| `x2`, `y2` | `number` | `b` only |
|
||||
|
||||
### `TypefaceGlyph`
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `ha` | `number` | Horizontal advance (scaled units) |
|
||||
| `x_min` | `number` | Left extent of the glyph bounding box |
|
||||
| `x_max` | `number` | Right extent of the glyph bounding box |
|
||||
| `o` | `string` | Space-separated outline command tokens |
|
||||
|
||||
### `TypefaceFont`
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `glyphs` | `Record<string, TypefaceGlyph>` | Map from character to glyph |
|
||||
| `familyName` | `string` | Font family name |
|
||||
| `ascender` | `number` | Ascender height (scaled) |
|
||||
| `descender` | `number` | Descender depth (scaled, negative) |
|
||||
| `underlinePosition` | `number` | Underline Y position |
|
||||
| `underlineThickness` | `number` | Underline stroke width |
|
||||
| `boundingBox` | `{ yMin, xMin, yMax, xMax }` | Overall font bounding box |
|
||||
| `resolution` | `number` | Always `1000` — typeface.js coordinate scale |
|
||||
| `original_font_information` | `unknown` | Raw name table from the source font |
|
||||
| `cssFontWeight` | `string` | `"normal"` or `"bold"` |
|
||||
| `cssFontStyle` | `string` | `"normal"` or `"italic"` |
|
||||
| `reversed` | `boolean?` | `true` when winding was reversed for TrueType CFF compat |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All coordinate values stored in a `TypefaceFont` are pre-scaled by `(1000 × 100) / (unitsPerEm × 72)`. This scale factor normalises any source font's internal coordinate system to the 1000-unit resolution expected by typeface.js and Three.js `FontLoader`. The `resolution: 1000` field documents this contract.
|
||||
|
||||
The `reversed` flag is informational only; it tells downstream consumers (e.g. Three.js path renderers) that the winding correction has already been applied so they should not apply their own.
|
||||
37
packages/media/fonts/font-convert/src/types/typeface.ts
Normal file
37
packages/media/fonts/font-convert/src/types/typeface.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/** A single path command in typeface.js outline format */
|
||||
export type PathCommandType = "m" | "l" | "q" | "b" | "z";
|
||||
|
||||
export interface PathCommand {
|
||||
type: PathCommandType;
|
||||
x?: number;
|
||||
y?: number;
|
||||
x1?: number;
|
||||
y1?: number;
|
||||
x2?: number;
|
||||
y2?: number;
|
||||
}
|
||||
|
||||
/** Typeface.js glyph outline */
|
||||
export interface TypefaceGlyph {
|
||||
ha: number;
|
||||
x_min: number;
|
||||
x_max: number;
|
||||
/** Outline string: space-separated "type x y ..." tokens */
|
||||
o: string;
|
||||
}
|
||||
|
||||
/** Typeface.js font format — output of FontConverter, input to BinaryFontPacker */
|
||||
export interface TypefaceFont {
|
||||
glyphs: Record<string, TypefaceGlyph>;
|
||||
familyName: string;
|
||||
ascender: number;
|
||||
descender: number;
|
||||
underlinePosition: number;
|
||||
underlineThickness: number;
|
||||
boundingBox: { yMin: number; xMin: number; yMax: number; xMax: number };
|
||||
resolution: number;
|
||||
original_font_information: unknown;
|
||||
cssFontWeight: string;
|
||||
cssFontStyle: string;
|
||||
reversed?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
# outlineToString
|
||||
|
||||
## Purpose
|
||||
|
||||
Serialises an array of `PathCommand` objects to the space-separated token string stored in a `TypefaceGlyph`'s `o` field. The typeface.js format requires each command to be written as `"type [x y] [x1 y1] [x2 y2]"` with a trailing space after each coordinate group. `outlineToString` encapsulates this serialisation contract and applies an optional scale factor so that internal font-unit coordinates are normalised to the 1 000-unit resolution expected by Three.js `FontLoader`.
|
||||
|
||||
## Features
|
||||
|
||||
- Handles all five typeface.js command types: `m`, `l`, `q`, `b`, `z`
|
||||
- Emits only the coordinate fields present on each command
|
||||
- Applies a floating-point scale multiplier and rounds each coordinate to the nearest integer
|
||||
- Returns an empty string for an empty command array
|
||||
- Does not mutate the input array
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { outlineToString } from "@suaveplan/font-convert";
|
||||
import type { PathCommand } from "@suaveplan/font-convert";
|
||||
|
||||
const commands: PathCommand[] = [
|
||||
{ type: "m", x: 0, y: 0 },
|
||||
{ type: "l", x: 100, y: 0 },
|
||||
{ type: "z" },
|
||||
];
|
||||
|
||||
const outline = outlineToString(commands);
|
||||
// → "m 0 0 l 100 0 z "
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { outlineToString } from "@suaveplan/font-convert";
|
||||
import type { PathCommand } from "@suaveplan/font-convert";
|
||||
|
||||
// Apply a scale factor when coordinates are in raw font units
|
||||
const unitsPerEm = 1000;
|
||||
const scale = (1000 * 100) / (unitsPerEm * 72); // ≈ 1.389
|
||||
|
||||
const commands: PathCommand[] = [
|
||||
{ type: "m", x: 0, y: 0 },
|
||||
{ type: "q", x1: 360, y1: 720, x: 720, y: 0 },
|
||||
{ type: "b", x1: 100, y1: 200, x2: 300, y2: 400, x: 1000, y: 0 },
|
||||
];
|
||||
|
||||
const outline = outlineToString(commands, scale);
|
||||
// coordinates are Math.round(value * scale)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `outlineToString(commands: PathCommand[], scale?: number): string`
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `commands` | `PathCommand[]` | — | Path commands to serialise |
|
||||
| `scale` | `number` | `1` | Multiplier applied to all coordinate values before rounding |
|
||||
|
||||
**Returns**: The typeface.js outline string. Each command contributes one token group ending with a trailing space. Returns `""` when `commands` is empty.
|
||||
|
||||
**Coordinate order per command type:**
|
||||
|
||||
| Type | Token sequence |
|
||||
|---|---|
|
||||
| `m` | `m x y` |
|
||||
| `l` | `l x y` |
|
||||
| `z` | `z` |
|
||||
| `q` | `q x y x1 y1` |
|
||||
| `b` | `b x y x1 y1 x2 y2` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The coordinate emission order matches what `FontConverter` and the reference `typeface.js` CLI produce: the endpoint `x/y` is written before control points `x1/y1` and `x2/y2`. This is the order required by the Three.js `FontLoader` parser. Changing this order would silently corrupt all outlines that use bezier curves.
|
||||
|
||||
The scale factor default of `1` makes `outlineToString` directly testable with integer coordinates without requiring the caller to know the actual font resolution.
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { PathCommand } from "../types/typeface.js";
|
||||
import { outlineToString } from "./outline-to-string.js";
|
||||
|
||||
describe("outlineToString", () => {
|
||||
it("returns empty string for no commands", { timeout: 5000 }, () => {
|
||||
expect(outlineToString([])).toBe("");
|
||||
});
|
||||
|
||||
it("encodes a moveto command", { timeout: 5000 }, () => {
|
||||
const cmds: PathCommand[] = [{ type: "m", x: 10, y: 20 }];
|
||||
expect(outlineToString(cmds)).toBe("m 10 20 ");
|
||||
});
|
||||
|
||||
it("encodes a lineto command", { timeout: 5000 }, () => {
|
||||
const cmds: PathCommand[] = [{ type: "l", x: 100, y: 200 }];
|
||||
expect(outlineToString(cmds)).toBe("l 100 200 ");
|
||||
});
|
||||
|
||||
it("encodes a close-path command (no coords)", { timeout: 5000 }, () => {
|
||||
const cmds: PathCommand[] = [{ type: "z" }];
|
||||
expect(outlineToString(cmds)).toBe("z ");
|
||||
});
|
||||
|
||||
it("encodes a quadratic bezier command", { timeout: 5000 }, () => {
|
||||
const cmds: PathCommand[] = [{ type: "q", x1: 5, y1: 10, x: 50, y: 0 }];
|
||||
expect(outlineToString(cmds)).toBe("q 50 0 5 10 ");
|
||||
});
|
||||
|
||||
it("encodes a cubic bezier command", { timeout: 5000 }, () => {
|
||||
const cmds: PathCommand[] = [
|
||||
{ type: "b", x1: 1, y1: 2, x2: 3, y2: 4, x: 10, y: 20 },
|
||||
];
|
||||
expect(outlineToString(cmds)).toBe("b 10 20 1 2 3 4 ");
|
||||
});
|
||||
|
||||
it("encodes a sequence of mixed commands", { timeout: 5000 }, () => {
|
||||
const cmds: PathCommand[] = [
|
||||
{ type: "m", x: 0, y: 0 },
|
||||
{ type: "l", x: 100, y: 0 },
|
||||
{ type: "z" },
|
||||
];
|
||||
expect(outlineToString(cmds)).toBe("m 0 0 l 100 0 z ");
|
||||
});
|
||||
|
||||
it("applies scale factor to coordinates", { timeout: 5000 }, () => {
|
||||
const cmds: PathCommand[] = [{ type: "m", x: 200, y: 400 }];
|
||||
// scale = 0.5: Math.round(200*0.5) = 100, Math.round(400*0.5) = 200
|
||||
expect(outlineToString(cmds, 0.5)).toBe("m 100 200 ");
|
||||
});
|
||||
|
||||
it("rounds scaled coordinates to nearest integer", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cmds: PathCommand[] = [{ type: "l", x: 100, y: 100 }];
|
||||
// scale = 0.333: Math.round(100*0.333) = 33
|
||||
expect(outlineToString(cmds, 0.333)).toBe("l 33 33 ");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { PathCommand } from "../types/typeface.js";
|
||||
|
||||
/**
|
||||
* Serialises an array of PathCommand objects to the typeface.js outline string format.
|
||||
* The outline string is space-separated tokens: "type [x y] [x1 y1] [x2 y2]".
|
||||
* An optional scale factor is applied and coordinates are rounded to integers.
|
||||
*/
|
||||
export function outlineToString(commands: PathCommand[], scale = 1): string {
|
||||
let result = "";
|
||||
|
||||
for (const cmd of commands) {
|
||||
result += `${cmd.type} `;
|
||||
|
||||
if (cmd.x !== undefined && cmd.y !== undefined) {
|
||||
result += `${Math.round(cmd.x * scale)} `;
|
||||
result += `${Math.round(cmd.y * scale)} `;
|
||||
}
|
||||
|
||||
if (cmd.x1 !== undefined && cmd.y1 !== undefined) {
|
||||
result += `${Math.round(cmd.x1 * scale)} `;
|
||||
result += `${Math.round(cmd.y1 * scale)} `;
|
||||
}
|
||||
|
||||
if (cmd.x2 !== undefined && cmd.y2 !== undefined) {
|
||||
result += `${Math.round(cmd.x2 * scale)} `;
|
||||
result += `${Math.round(cmd.y2 * scale)} `;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# reverseWinding
|
||||
|
||||
## Purpose
|
||||
|
||||
Reverses the winding order of a typeface.js path command sequence. Three.js `FontLoader` expects counter-clockwise winding for outer contours, while some TrueType and CFF fonts ship with clockwise winding. Calling `reverseWinding` before serialising glyphs ensures the outline direction is corrected without reprocessing the source binary.
|
||||
|
||||
## Features
|
||||
|
||||
- Splits commands at each `M`/`m` moveto to identify individual subpaths
|
||||
- Drops `Z`/`z` close-path commands (they are implicit in typeface.js rendering)
|
||||
- Reverses command order within each subpath
|
||||
- Swaps cubic bezier control point pairs (`x1/y1` ↔ `x2/y2`) to maintain curve shape
|
||||
- Preserves quadratic bezier single control point after destination flip
|
||||
- Works with zero commands (returns empty array, no error)
|
||||
- Operates entirely on the `PathCommand` type from `@suaveplan/font-convert`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { reverseWinding } from "@suaveplan/font-convert";
|
||||
import type { PathCommand } from "@suaveplan/font-convert";
|
||||
|
||||
const commands: PathCommand[] = [
|
||||
{ type: "m", x: 0, y: 0 },
|
||||
{ type: "l", x: 100, y: 0 },
|
||||
{ type: "l", x: 50, y: 100 },
|
||||
{ type: "z" },
|
||||
];
|
||||
|
||||
const reversed = reverseWinding(commands);
|
||||
// reversed[0] = { type: "m", x: 50, y: 100 } — last point becomes new start
|
||||
// reversed[1] = { type: "l", x: 100, y: 0 }
|
||||
// reversed[2] = { type: "l", x: 0, y: 0 }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { reverseWinding } from "@suaveplan/font-convert";
|
||||
import type { PathCommand } from "@suaveplan/font-convert";
|
||||
|
||||
// Cubic bezier: reversal swaps x1/y1 and x2/y2
|
||||
const cubicCommands: PathCommand[] = [
|
||||
{ type: "m", x: 0, y: 0 },
|
||||
{ type: "b", x1: 10, y1: 20, x2: 30, y2: 40, x: 100, y: 0 },
|
||||
];
|
||||
|
||||
const rev = reverseWinding(cubicCommands);
|
||||
// rev[0] = { type: "m", x: 100, y: 0 }
|
||||
// rev[1] = { type: "b", x1: 30, y1: 40, x2: 10, y2: 20, x: 0, y: 0 }
|
||||
// ^^^ x2/y2 and x1/y1 are swapped relative to the original
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `reverseWinding(commands: PathCommand[]): PathCommand[]`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `commands` | `PathCommand[]` | Array of path commands as produced by `FontConverter` |
|
||||
|
||||
**Returns**: A new array of `PathCommand` objects in reversed winding order. The input array is not mutated.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function mirrors the winding-reversal algorithm used in `typeface.js`'s own CLI tooling and the reference `FontConverter.ts`. Subpath splitting is done by scanning for `m`/`M` commands; `z`/`Z` close-path commands are discarded because typeface.js renderers treat each subpath as implicitly closed. The reversed list starts with a new `m` command pointing at the last non-close command of the original path, then walks the original path backwards, placing each point at the previous command's destination.
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { PathCommand } from "../types/typeface.js";
|
||||
import { reverseWinding } from "./reverse-winding.js";
|
||||
|
||||
describe("reverseWinding", () => {
|
||||
it("returns empty array for empty input", { timeout: 5000 }, () => {
|
||||
expect(reverseWinding([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("reverses a simple triangular moveto+lineto path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const commands: PathCommand[] = [
|
||||
{ type: "m", x: 0, y: 0 },
|
||||
{ type: "l", x: 100, y: 0 },
|
||||
{ type: "l", x: 50, y: 100 },
|
||||
{ type: "z" },
|
||||
];
|
||||
const reversed = reverseWinding(commands);
|
||||
// First point of reversed path is last moveable point
|
||||
expect(reversed[0]).toMatchObject({ type: "m", x: 50, y: 100 });
|
||||
// Second command moves to previous point
|
||||
expect(reversed[1]).toMatchObject({ type: "l", x: 100, y: 0 });
|
||||
expect(reversed[2]).toMatchObject({ type: "l", x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it("reverses quadratic bezier (q) control points", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const commands: PathCommand[] = [
|
||||
{ type: "m", x: 0, y: 0 },
|
||||
{ type: "q", x1: 50, y1: 100, x: 100, y: 0 },
|
||||
];
|
||||
const reversed = reverseWinding(commands);
|
||||
expect(reversed[0]).toMatchObject({ type: "m", x: 100, y: 0 });
|
||||
const q = reversed[1] as PathCommand;
|
||||
expect(q.type).toBe("q");
|
||||
// Control point preserved (single cp quadratic)
|
||||
expect(q.x1).toBe(50);
|
||||
expect(q.y1).toBe(100);
|
||||
// Destination becomes original start
|
||||
expect(q.x).toBe(0);
|
||||
expect(q.y).toBe(0);
|
||||
});
|
||||
|
||||
it("reverses cubic bezier (b) and swaps control point pairs", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const commands: PathCommand[] = [
|
||||
{ type: "m", x: 0, y: 0 },
|
||||
{ type: "b", x1: 10, y1: 20, x2: 30, y2: 40, x: 100, y: 0 },
|
||||
];
|
||||
const reversed = reverseWinding(commands);
|
||||
expect(reversed[0]).toMatchObject({ type: "m", x: 100, y: 0 });
|
||||
const b = reversed[1] as PathCommand;
|
||||
expect(b.type).toBe("b");
|
||||
// cp1 and cp2 swap
|
||||
expect(b.x1).toBe(30);
|
||||
expect(b.y1).toBe(40);
|
||||
expect(b.x2).toBe(10);
|
||||
expect(b.y2).toBe(20);
|
||||
// Destination is original start
|
||||
expect(b.x).toBe(0);
|
||||
expect(b.y).toBe(0);
|
||||
});
|
||||
|
||||
it("handles multiple subpaths independently", { timeout: 5000 }, () => {
|
||||
const commands: PathCommand[] = [
|
||||
{ type: "m", x: 0, y: 0 },
|
||||
{ type: "l", x: 10, y: 0 },
|
||||
{ type: "z" },
|
||||
{ type: "m", x: 50, y: 50 },
|
||||
{ type: "l", x: 60, y: 50 },
|
||||
];
|
||||
const reversed = reverseWinding(commands);
|
||||
// First subpath reversed
|
||||
expect(reversed[0]).toMatchObject({ type: "m", x: 10, y: 0 });
|
||||
// Second subpath reversed
|
||||
const secondStart = reversed.findIndex(
|
||||
(c) => c.type === "m" && c.x === 60
|
||||
);
|
||||
expect(secondStart).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("skips z commands (close-path) in paths array", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const commands: PathCommand[] = [
|
||||
{ type: "m", x: 0, y: 0 },
|
||||
{ type: "l", x: 5, y: 5 },
|
||||
{ type: "z" },
|
||||
];
|
||||
const reversed = reverseWinding(commands);
|
||||
// z is not in the output (reference impl drops z)
|
||||
expect(reversed.some((c) => c.type === "z")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles path starting with moveto that has no subsequent commands", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const commands: PathCommand[] = [{ type: "m", x: 0, y: 0 }];
|
||||
const reversed = reverseWinding(commands);
|
||||
// Single m with no following commands: reversed start is the same point
|
||||
expect(reversed[0]).toMatchObject({ type: "m", x: 0, y: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { PathCommand } from "../types/typeface.js";
|
||||
|
||||
type XYCommand = PathCommand & { x: number; y: number };
|
||||
|
||||
/**
|
||||
* Reverses the winding order of path commands for TrueType/CFF compatibility.
|
||||
* Three.js FontLoader requires counter-clockwise winding; some fonts ship clockwise.
|
||||
*/
|
||||
export function reverseWinding(commands: PathCommand[]): PathCommand[] {
|
||||
const subpaths: PathCommand[][] = [];
|
||||
let current: PathCommand[] = [];
|
||||
|
||||
for (const cmd of commands) {
|
||||
if (cmd.type === "m") {
|
||||
current = [cmd];
|
||||
subpaths.push(current);
|
||||
} else if (cmd.type !== "z") {
|
||||
current.push(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
const reversed: PathCommand[] = [];
|
||||
|
||||
for (const path of subpaths) {
|
||||
// All non-z path commands carry x/y; cast is safe here
|
||||
const last = path[path.length - 1] as XYCommand;
|
||||
reversed.push({ type: "m", x: last.x, y: last.y });
|
||||
|
||||
for (let i = path.length - 1; i > 0; i--) {
|
||||
const cmd = path[i] as PathCommand;
|
||||
const dest = path[i - 1] as XYCommand;
|
||||
const out: PathCommand = { type: cmd.type, x: dest.x, y: dest.y };
|
||||
|
||||
if (cmd.x2 !== undefined && cmd.y2 !== undefined) {
|
||||
// Swap control point pairs for cubic bezier reversal.
|
||||
// x1/y1 are always defined when x2/y2 are present in a valid cubic bezier.
|
||||
const cubic = cmd as PathCommand & {
|
||||
x1: number;
|
||||
y1: number;
|
||||
};
|
||||
out.x1 = cmd.x2;
|
||||
out.y1 = cmd.y2;
|
||||
out.x2 = cubic.x1;
|
||||
out.y2 = cubic.y1;
|
||||
} else if (cmd.x1 !== undefined && cmd.y1 !== undefined) {
|
||||
out.x1 = cmd.x1;
|
||||
out.y1 = cmd.y1;
|
||||
}
|
||||
|
||||
reversed.push(out);
|
||||
}
|
||||
}
|
||||
|
||||
return reversed;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
[ZoneTransfer]
|
||||
ZoneId=3
|
||||
BIN
packages/media/fonts/font-convert/tests/fixtures/test.otf
vendored
Normal file
BIN
packages/media/fonts/font-convert/tests/fixtures/test.otf
vendored
Normal file
Binary file not shown.
BIN
packages/media/fonts/font-convert/tests/fixtures/test.ttf
vendored
Normal file
BIN
packages/media/fonts/font-convert/tests/fixtures/test.ttf
vendored
Normal file
Binary file not shown.
45
packages/media/fonts/font-convert/tests/integration.test.ts
Normal file
45
packages/media/fonts/font-convert/tests/integration.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createFontConverter, FontConvertError } from "../src/index.js";
|
||||
|
||||
/**
|
||||
* Integration tests that require a real font binary.
|
||||
* Full real-font round-trip tests live in font-compress roundtrip.test.ts.
|
||||
*/
|
||||
describe("font-convert integration", () => {
|
||||
it("throws FontConvertError on obviously corrupt binary", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const converter = createFontConverter();
|
||||
const garbage = new Uint8Array(64).fill(0xff);
|
||||
expect(() => converter.convert(garbage)).toThrow(FontConvertError);
|
||||
});
|
||||
|
||||
it("createFontConverter returns converter with convert method", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const converter = createFontConverter();
|
||||
expect(typeof converter.convert).toBe("function");
|
||||
expect(typeof converter.convertFromParsed).toBe("function");
|
||||
});
|
||||
|
||||
it("FontConvertError preserves Error cause", { timeout: 5000 }, () => {
|
||||
const cause = new Error("root cause");
|
||||
const err = new FontConvertError(
|
||||
"FONT_CONVERT.PARSE_FAILED" as any,
|
||||
"wrapped",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("FontConvertError preserves context when provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new FontConvertError(
|
||||
"FONT_CONVERT.INVALID_OPTIONS" as any,
|
||||
"bad options",
|
||||
{ context: { field: "value" } }
|
||||
);
|
||||
expect(err.context).toEqual({ field: "value" });
|
||||
});
|
||||
});
|
||||
12
packages/media/fonts/font-convert/tsconfig.lib.json
Normal file
12
packages/media/fonts/font-convert/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"]
|
||||
}
|
||||
38
packages/media/fonts/font-convert/vite.config.ts
Normal file
38
packages/media/fonts/font-convert/vite.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/font-convert",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"tslib",
|
||||
"zod",
|
||||
"opentype.js",
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
31
packages/media/fonts/font-convert/vitest.config.ts
Normal file
31
packages/media/fonts/font-convert/vitest.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
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/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
131
packages/media/fonts/font-decompress/README.md
Normal file
131
packages/media/fonts/font-decompress/README.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# @suaveplan/font-decompress - BTF (Binary TypeFace) binary unpacking. Reverses @suaveplan/font-compress's BinaryFontPacker output back into typeface.js JSON, ready for Three.js / R3F / game-font rendering pipelines. Universal.
|
||||
|
||||
BTF (Binary TypeFace) binary unpacking. Reverses
|
||||
`@suaveplan/font-compress`'s `BinaryFontPacker` output into a
|
||||
typeface.js JSON shape ready for Three.js / R3F / game-font
|
||||
rendering. Universal, store-agnostic, free of `node:` / `bun:`
|
||||
imports.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The `@suaveplan/font-decompress` package centralises every failure mode it can produce behind a single registry built w…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — The `@suaveplan/font-decompress` package centralises every failure mode it can produce behind a single registry built w…
|
||||
- [binary-font-unpacker](./src/unpacker/binary-font-unpacker/binary-font-unpacker.md) — `BinaryFontUnpacker` reverses the BTF (Binary TypeFace) wire format produced by `BinaryFontPacker` from `@suaveplan/fon…
|
||||
- [binary-font-unpacker](./src/unpacker/binary-font-unpacker/binary-font-unpacker.md) — `BinaryFontUnpacker` reverses the BTF (Binary TypeFace) wire format produced by `BinaryFontPacker` from `@suaveplan/fon…
|
||||
- [utils](./src/utils/decode-string.md) — `decodeString` reads a length-prefixed UTF-8 string from a `Uint8Array` at a given offset. The BTF wire format encodes…
|
||||
- [decode-string](./src/utils/decode-string.md) — `decodeString` reads a length-prefixed UTF-8 string from a `Uint8Array` at a given offset. The BTF wire format encodes…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Compressed BTF buffers ship from build-time tooling to the renderer
|
||||
as compact `Uint8Array` blobs — they are far smaller than the
|
||||
underlying typeface.js JSON for non-trivial fonts. The renderer needs
|
||||
the JSON back to hand to Three.js's `FontLoader.parse(...)`. This
|
||||
package owns that reverse step; the sibling `@suaveplan/font-compress`
|
||||
owns the forward step.
|
||||
|
||||
The split exists because pack and unpack are independent concerns:
|
||||
build tooling never unpacks, runtime renderers never pack. Shipping a
|
||||
unified package forces consumers to drag the other half. Splitting
|
||||
lets each side install only what it actually executes.
|
||||
|
||||
## Features
|
||||
|
||||
- `BinaryFontUnpacker` class + `createBinaryFontUnpacker()` factory
|
||||
- Strict boundary validation on every read — raises `FontDecompressError` with `FONT_DECOMPRESS.INVALID_DATA`
|
||||
- Underlying primitive exceptions (DataView, TextDecoder) wrapped as `FontDecompressError` with `FONT_DECOMPRESS.UNPACK_FAILED` and `cause` preserved
|
||||
- All five BTF glyph commands (`m`/`l`/`b`/`q`/`z`) round-trip byte-for-byte
|
||||
- Unicode glyph keys (multi-byte UTF-8) round-trip correctly
|
||||
- Negative coordinates round-trip correctly
|
||||
- 100% statement / branch / function / line Istanbul coverage
|
||||
- 5 tree-shakeable subpath exports
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createBinaryFontUnpacker } from "@suaveplan/font-decompress";
|
||||
|
||||
const unpacker = createBinaryFontUnpacker();
|
||||
const font = unpacker.unpack(btfBuffer);
|
||||
|
||||
console.log(font.familyName); // "Inter Variable"
|
||||
console.log(font.glyphs["A"]?.ha); // glyph advance width
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
BinaryFontUnpacker,
|
||||
FONT_DECOMPRESS_CODES,
|
||||
FontDecompressError,
|
||||
} from "@suaveplan/font-decompress";
|
||||
|
||||
// Reuse a single instance — stateless and safe.
|
||||
const unpacker = new BinaryFontUnpacker();
|
||||
|
||||
async function loadCdnFont(url: string) {
|
||||
const buf = new Uint8Array(
|
||||
await (await fetch(url)).arrayBuffer(),
|
||||
);
|
||||
try {
|
||||
return unpacker.unpack(buf);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof FontDecompressError &&
|
||||
err.code === FONT_DECOMPRESS_CODES.INVALID_DATA
|
||||
) {
|
||||
console.warn("Corrupted BTF from CDN:", err.message);
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Public exports
|
||||
|
||||
- `class BinaryFontUnpacker` — `unpack(data: Uint8Array): TypefaceFont`
|
||||
- `createBinaryFontUnpacker(): BinaryFontUnpacker`
|
||||
- `FONT_DECOMPRESS_CODES` — frozen registry with `UNPACK_FAILED`, `INVALID_DATA`
|
||||
- `class FontDecompressError extends SuaveplanError<FontDecompressErrorCode>`
|
||||
- `type FontDecompressErrorCode`
|
||||
|
||||
The `TypefaceFont` and `PathCommand` types come from
|
||||
`@suaveplan/font-convert`; consumers import them directly from there
|
||||
rather than via this package's surface.
|
||||
|
||||
### Subpath exports
|
||||
|
||||
| Subpath | What |
|
||||
| -------------------------------------------- | ----------------------------- |
|
||||
| `.` | Root barrel (above) |
|
||||
| `./error-codes` | `FONT_DECOMPRESS_CODES`, `FontDecompressError` |
|
||||
| `./constants` | BTF wire-format constants |
|
||||
| `./utils/decode-string` | `decodeString` helper |
|
||||
| `./unpacker/binary-font-unpacker` | `BinaryFontUnpacker`, factory |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The package is **universal**: zero `node:` / `bun:` imports
|
||||
anywhere in `src/`. The only platform-aware calls are `DataView`,
|
||||
`TextDecoder`, and `String.fromCharCode` — all available in every
|
||||
supported runtime (Node ≥ 18, Bun, modern browsers, Web Workers).
|
||||
|
||||
The BTF wire-format constants (`CMD_M` … `CMD_Z`, `BYTE_TO_CMD`,
|
||||
`CMD_COORD_COUNTS`, `METRICS_COUNT`) are duplicated from
|
||||
`@suaveplan/font-compress`. Duplicating 19 lines of magic numbers
|
||||
avoids a third "fonts-shared" package and keeps each package
|
||||
self-contained. If the BTF format ever changes — which is a
|
||||
coordinated breaking event — both packages get updated in the same
|
||||
commit.
|
||||
|
||||
`@suaveplan/font-compress` is a **devDependency** for round-trip
|
||||
tests only. The runtime dependency graph is `font-decompress →
|
||||
font-convert + error`; the test surface adds `font-compress` solely
|
||||
to generate known-vector fixtures.
|
||||
|
||||
98
packages/media/fonts/font-decompress/package.json
Normal file
98
packages/media/fonts/font-decompress/package.json
Normal file
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"name": "@suaveplan/font-decompress",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "BTF (Binary TypeFace) binary unpacking. Reverses @suaveplan/font-compress's BinaryFontPacker output back into typeface.js JSON, ready for Three.js / R3F / game-font rendering pipelines. Universal.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./constants": {
|
||||
"import": {
|
||||
"types": "./dist/constants/index.d.ts",
|
||||
"default": "./dist/constants/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/constants/index.d.ts",
|
||||
"default": "./dist/constants/index.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/error-codes/index.d.ts",
|
||||
"default": "./dist/error-codes/index.js"
|
||||
}
|
||||
},
|
||||
"./utils/decode-string": {
|
||||
"import": {
|
||||
"types": "./dist/utils/decode-string.d.ts",
|
||||
"default": "./dist/utils/decode-string.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/utils/decode-string.d.ts",
|
||||
"default": "./dist/utils/decode-string.js"
|
||||
}
|
||||
},
|
||||
"./unpacker/binary-font-unpacker": {
|
||||
"import": {
|
||||
"types": "./dist/unpacker/binary-font-unpacker/index.d.ts",
|
||||
"default": "./dist/unpacker/binary-font-unpacker/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/unpacker/binary-font-unpacker/index.d.ts",
|
||||
"default": "./dist/unpacker/binary-font-unpacker/index.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": {
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/font-convert": "workspace:^",
|
||||
"tslib": "catalog:build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/font-compress": "workspace:^",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "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"]
|
||||
}
|
||||
21
packages/media/fonts/font-decompress/src/constants/index.ts
Normal file
21
packages/media/fonts/font-decompress/src/constants/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/** BTF command type byte values — paired with the packer in `@suaveplan/font-compress`. */
|
||||
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;
|
||||
|
||||
/** Reverse lookup from command byte to its single-character letter form. */
|
||||
export const BYTE_TO_CMD: readonly string[] = ["m", "l", "b", "q", "z"];
|
||||
|
||||
/** Number of coordinate values per command letter. */
|
||||
export const CMD_COORD_COUNTS: Readonly<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,115 @@
|
||||
# error-codes
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
The `@suaveplan/font-decompress` package centralises every failure mode it
|
||||
can produce behind a single registry built with
|
||||
`defineCodes("FONT_DECOMPRESS", [...])` from `@suaveplan/error`. The
|
||||
registry is the stable contract callers depend on: code strings are
|
||||
documented, frozen at module load, and never renamed without an
|
||||
explicit migration. This module exports the `FONT_DECOMPRESS_CODES`
|
||||
registry, the derived `FontDecompressErrorCode` literal-union type,
|
||||
and the `FontDecompressError` class that every public failure path
|
||||
raises.
|
||||
|
||||
`FontDecompressError` extends `SuaveplanError<FontDecompressErrorCode>`
|
||||
so `instanceof FontDecompressError` narrows `code` to the union of
|
||||
`FONT_DECOMPRESS.*` strings. The class threads `cause` straight
|
||||
through `SuaveplanError`, so primitive-layer exceptions
|
||||
(`RangeError` from `DataView`, `TypeError` from `TextDecoder`,
|
||||
`@suaveplan/font-convert` errors) remain inspectable through
|
||||
`error.cause`.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen `FONT_DECOMPRESS_CODES` registry with two namespaced codes
|
||||
- `FontDecompressErrorCode` literal-union type derived from the registry
|
||||
- `FontDecompressError` class extending `SuaveplanError<FontDecompressErrorCode>`
|
||||
- Full `cause`-chain preservation via `SuaveplanError`
|
||||
- Distinct from `FONT_COMPRESS.*` codes — different namespace, different class
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
FONT_DECOMPRESS_CODES,
|
||||
FontDecompressError,
|
||||
} from "@suaveplan/font-decompress";
|
||||
|
||||
if (data.byteLength < 1) {
|
||||
throw new FontDecompressError(
|
||||
FONT_DECOMPRESS_CODES.INVALID_DATA,
|
||||
"BTF buffer is empty",
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
FONT_DECOMPRESS_CODES,
|
||||
FontDecompressError,
|
||||
} from "@suaveplan/font-decompress";
|
||||
|
||||
try {
|
||||
const font = unpacker.unpack(buffer);
|
||||
} catch (err) {
|
||||
if (err instanceof FontDecompressError) {
|
||||
if (err.code === FONT_DECOMPRESS_CODES.UNPACK_FAILED) {
|
||||
telemetry.increment("font.unpack.failure", {
|
||||
root: err.cause instanceof Error ? err.cause.name : "unknown",
|
||||
});
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `FONT_DECOMPRESS_CODES`
|
||||
|
||||
A frozen object literal of two namespaced codes:
|
||||
|
||||
| Property | Value |
|
||||
| --------------- | -------------------------------- |
|
||||
| `UNPACK_FAILED` | `"FONT_DECOMPRESS.UNPACK_FAILED"` |
|
||||
| `INVALID_DATA` | `"FONT_DECOMPRESS.INVALID_DATA"` |
|
||||
|
||||
### `type FontDecompressErrorCode`
|
||||
|
||||
The literal union of every value in `FONT_DECOMPRESS_CODES`.
|
||||
|
||||
### `class FontDecompressError extends SuaveplanError<FontDecompressErrorCode>`
|
||||
|
||||
| Member | Type |
|
||||
| ------------------ | ---------------------------------------------------------- |
|
||||
| `code` | `FontDecompressErrorCode` (narrowed) |
|
||||
| `cause?` | `unknown` (inherited) |
|
||||
| `timestamp` | `number` (inherited; `Date.now()` at construction) |
|
||||
| `constructor(...)` | `(code, message, options?: SuaveplanErrorOptions)` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` returns a `Object.freeze`d record with `null` prototype,
|
||||
so the registry cannot be mutated, reassigned, or extended at runtime.
|
||||
The literal-type machinery preserves the exact `${Namespace}.${Reason}`
|
||||
string template across every consumer, so `switch (err.code)` enjoys
|
||||
exhaustive narrowing without runtime cost.
|
||||
|
||||
The class's constructor passes `message` through to `SuaveplanError`'s
|
||||
`messageKey` parameter. The base class resolves the key via the i18n
|
||||
resolver if one has been registered with `setMessageResolver`, falling
|
||||
back to the raw string otherwise — so this package works without i18n
|
||||
wiring and lights up automatically when `@suaveplan/i18n` is present.
|
||||
|
||||
The split from `@suaveplan/font-compress` preserves namespace
|
||||
distinctness: an `INVALID_DATA` raised by the packer is
|
||||
`FONT_COMPRESS.INVALID_DATA`; an `INVALID_DATA` raised by the unpacker
|
||||
is `FONT_DECOMPRESS.INVALID_DATA`. Consumers dispatching on either
|
||||
class plus its code get fully unambiguous error handling.
|
||||
@@ -0,0 +1,46 @@
|
||||
import { SuaveplanError } from "@suaveplan/error";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FONT_DECOMPRESS_CODES, FontDecompressError } from "./error-codes.js";
|
||||
|
||||
describe("FONT_DECOMPRESS_CODES registry", () => {
|
||||
it("declares every required code with namespaced value", () => {
|
||||
expect(FONT_DECOMPRESS_CODES.UNPACK_FAILED).toBe(
|
||||
"FONT_DECOMPRESS.UNPACK_FAILED"
|
||||
);
|
||||
expect(FONT_DECOMPRESS_CODES.INVALID_DATA).toBe(
|
||||
"FONT_DECOMPRESS.INVALID_DATA"
|
||||
);
|
||||
});
|
||||
|
||||
it("registry is frozen and contains exactly two codes", () => {
|
||||
expect(Object.isFrozen(FONT_DECOMPRESS_CODES)).toBe(true);
|
||||
expect(Object.keys(FONT_DECOMPRESS_CODES).sort()).toEqual([
|
||||
"INVALID_DATA",
|
||||
"UNPACK_FAILED",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FontDecompressError", () => {
|
||||
it("every FONT_DECOMPRESS.* code is reachable from a call site", () => {
|
||||
for (const code of Object.values(FONT_DECOMPRESS_CODES)) {
|
||||
const err = new FontDecompressError(code, `test ${code}`);
|
||||
expect(err.code).toBe(code);
|
||||
expect(err).toBeInstanceOf(SuaveplanError);
|
||||
expect(err).toBeInstanceOf(FontDecompressError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
}
|
||||
});
|
||||
|
||||
it("FontDecompressError preserves cause", () => {
|
||||
const root = new RangeError("offset out of bounds");
|
||||
const err = new FontDecompressError(
|
||||
FONT_DECOMPRESS_CODES.UNPACK_FAILED,
|
||||
"wrap",
|
||||
{ cause: root }
|
||||
);
|
||||
expect(err.cause).toBe(root);
|
||||
expect(err.code).toBe("FONT_DECOMPRESS.UNPACK_FAILED");
|
||||
expect(err.message).toBe("wrap");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Stable error-code registry for `@suaveplan/font-decompress`. Every public
|
||||
* failure path raises a `FontDecompressError` carrying one of these codes.
|
||||
*/
|
||||
export const FONT_DECOMPRESS_CODES = defineCodes("FONT_DECOMPRESS", [
|
||||
"UNPACK_FAILED",
|
||||
"INVALID_DATA",
|
||||
] as const);
|
||||
|
||||
export type FontDecompressErrorCode =
|
||||
(typeof FONT_DECOMPRESS_CODES)[keyof typeof FONT_DECOMPRESS_CODES];
|
||||
|
||||
export class FontDecompressError extends SuaveplanError<FontDecompressErrorCode> {
|
||||
public constructor(
|
||||
code: FontDecompressErrorCode,
|
||||
message: string,
|
||||
options: SuaveplanErrorOptions = {}
|
||||
) {
|
||||
super(code, message, options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { FontDecompressErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
FONT_DECOMPRESS_CODES,
|
||||
FontDecompressError,
|
||||
} from "./error-codes.js";
|
||||
9
packages/media/fonts/font-decompress/src/index.ts
Normal file
9
packages/media/fonts/font-decompress/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type { FontDecompressErrorCode } from "./error-codes/index.js";
|
||||
export {
|
||||
FONT_DECOMPRESS_CODES,
|
||||
FontDecompressError,
|
||||
} from "./error-codes/index.js";
|
||||
export {
|
||||
BinaryFontUnpacker,
|
||||
createBinaryFontUnpacker,
|
||||
} from "./unpacker/binary-font-unpacker/index.js";
|
||||
@@ -0,0 +1,134 @@
|
||||
# unpacker/binary-font-unpacker
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`BinaryFontUnpacker` reverses the BTF (Binary TypeFace) wire format
|
||||
produced by `BinaryFontPacker` from `@suaveplan/font-compress`,
|
||||
returning a `TypefaceFont` object (from `@suaveplan/font-convert`)
|
||||
ready for Three.js's `FontLoader`, R3F text rendering, or any other
|
||||
typeface.js-compatible consumer. The unpacker is the runtime side of
|
||||
the BTF pipeline: build-time tooling packs once, then ships the
|
||||
compact `Uint8Array`; renderers unpack on demand.
|
||||
|
||||
The class is intentionally self-contained — no helpers imported from
|
||||
`@suaveplan/font-compress`, no shared mutable state. Boundary
|
||||
validation runs at every read so malformed or truncated buffers raise
|
||||
`FontDecompressError` synchronously rather than corrupting the
|
||||
returned font object.
|
||||
|
||||
## Features
|
||||
|
||||
- Round-trips every field a `BinaryFontPacker` writes: header strings, reversed flag, dictionary, metrics, glyphs, glyph outlines
|
||||
- Handles all five glyph commands (`m`/`l`/`b`/`q`/`z`) per the BTF coordinate-count table
|
||||
- Validates every buffer read for truncation; raises `FontDecompressError` with `FONT_DECOMPRESS.INVALID_DATA`
|
||||
- Wraps any underlying primitive exception (`RangeError`, `TypeError` from DataView/TextDecoder) as `FontDecompressError` with `FONT_DECOMPRESS.UNPACK_FAILED` while preserving `cause`
|
||||
- Stateless — a single instance is safe to reuse across many `unpack(...)` calls
|
||||
- Universal — no `node:`/`bun:` imports
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
BinaryFontUnpacker,
|
||||
createBinaryFontUnpacker,
|
||||
} from "@suaveplan/font-decompress";
|
||||
|
||||
// Load BTF bytes from anywhere — fetch, fs, memory, etc.
|
||||
const btfBuffer: Uint8Array = await fetch("/fonts/inter.btf")
|
||||
.then((r) => r.arrayBuffer())
|
||||
.then((b) => new Uint8Array(b));
|
||||
|
||||
const font = createBinaryFontUnpacker().unpack(btfBuffer);
|
||||
|
||||
// Feed `font` to Three.js's FontLoader.parse(font) or R3F's <Text3D font={font} />
|
||||
console.log(font.familyName, font.glyphs["A"]?.ha);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
BinaryFontUnpacker,
|
||||
FONT_DECOMPRESS_CODES,
|
||||
FontDecompressError,
|
||||
} from "@suaveplan/font-decompress";
|
||||
|
||||
const unpacker = new BinaryFontUnpacker(); // safe to reuse
|
||||
|
||||
async function loadFontWithFallback(
|
||||
url: string,
|
||||
fallback: Uint8Array,
|
||||
): Promise<ReturnType<BinaryFontUnpacker["unpack"]>> {
|
||||
try {
|
||||
const buf = new Uint8Array(
|
||||
await (await fetch(url)).arrayBuffer(),
|
||||
);
|
||||
return unpacker.unpack(buf);
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof FontDecompressError &&
|
||||
(err.code === FONT_DECOMPRESS_CODES.UNPACK_FAILED ||
|
||||
err.code === FONT_DECOMPRESS_CODES.INVALID_DATA)
|
||||
) {
|
||||
// BTF corrupted in transit; fall back to the bundled font
|
||||
return unpacker.unpack(fallback);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class BinaryFontUnpacker`
|
||||
|
||||
| Method | Signature |
|
||||
| ----------------- | -------------------------------------------------- |
|
||||
| `unpack` | `(data: Uint8Array) => TypefaceFont` |
|
||||
|
||||
### `createBinaryFontUnpacker(): BinaryFontUnpacker`
|
||||
|
||||
Convenience factory equivalent to `new BinaryFontUnpacker()`. Use it
|
||||
when you prefer functional composition over `new`.
|
||||
|
||||
### Error contract
|
||||
|
||||
| Condition | Code |
|
||||
| ------------------------------------------------------ | --------------------------------- |
|
||||
| Truncated buffer at any header / dictionary / glyph | `FONT_DECOMPRESS.INVALID_DATA` |
|
||||
| Out-of-range command byte (not 0–4) | `FONT_DECOMPRESS.INVALID_DATA` |
|
||||
| Out-of-range dictionary index | `FONT_DECOMPRESS.INVALID_DATA` |
|
||||
| `indexCount < 3` for a glyph (missing ha/x_min/x_max) | `FONT_DECOMPRESS.INVALID_DATA` |
|
||||
| Underlying `DataView` / `TextDecoder` throw | `FONT_DECOMPRESS.UNPACK_FAILED` |
|
||||
|
||||
All errors are `FontDecompressError` instances and carry `cause` when wrapping a primitive throw.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The unpacker walks `data` once, advancing a single `offset` cursor.
|
||||
Reads use a shared `DataView` constructed over `data.buffer +
|
||||
data.byteOffset` so subarray inputs work without an extra slice. Every
|
||||
read goes through one of three private helpers:
|
||||
|
||||
- `readUint32(data, offset)` — little-endian uint32 read
|
||||
- `assertRemaining(data, offset, need, field)` — throws `INVALID_DATA` if `offset + need` exceeds `data.length`, naming the field for diagnostics
|
||||
- `dictAt(dict, index, field)` — throws `INVALID_DATA` if `index` is out of range; otherwise returns `dict[index]`
|
||||
|
||||
The shared `decodeString` helper (in `utils/decode-string.ts`) handles
|
||||
the three header strings with the same boundary validation.
|
||||
|
||||
The `BYTE_TO_CMD` table (`["m", "l", "b", "q", "z"]`) and
|
||||
`CMD_COORD_COUNTS` map (`m: 2, l: 2, b: 6, q: 4, z: 0`) come from the
|
||||
package-local `constants/index.ts` module — a verbatim duplicate of
|
||||
the constants in `@suaveplan/font-compress`. Duplicating 19 lines of
|
||||
format magic keeps each package self-contained at the cost of zero
|
||||
maintenance burden (the BTF wire format is locked).
|
||||
|
||||
`original_font_information` is initialised to `{}` because the BTF
|
||||
format does not carry it — the field is preserved on the
|
||||
`TypefaceFont` shape for downstream compatibility but is always empty
|
||||
on the round-trip side.
|
||||
@@ -0,0 +1,326 @@
|
||||
import { BinaryFontPacker } from "@suaveplan/font-compress";
|
||||
import type { TypefaceFont } from "@suaveplan/font-convert";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
FONT_DECOMPRESS_CODES,
|
||||
FontDecompressError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import {
|
||||
BinaryFontUnpacker,
|
||||
createBinaryFontUnpacker,
|
||||
} from "./binary-font-unpacker.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,
|
||||
};
|
||||
}
|
||||
|
||||
function packFont(font: TypefaceFont): Uint8Array {
|
||||
return new BinaryFontPacker().pack(font);
|
||||
}
|
||||
|
||||
describe("createBinaryFontUnpacker", () => {
|
||||
it("returns a BinaryFontUnpacker instance", { timeout: 5000 }, () => {
|
||||
expect(createBinaryFontUnpacker()).toBeInstanceOf(BinaryFontUnpacker);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BinaryFontUnpacker.unpack — round-trip pack/unpack preserves font shape", () => {
|
||||
it("round-trips header and metrics", { timeout: 5000 }, () => {
|
||||
const font = makeFont();
|
||||
const result = new BinaryFontUnpacker().unpack(packFont(font));
|
||||
expect(result.familyName).toBe(font.familyName);
|
||||
expect(result.cssFontWeight).toBe(font.cssFontWeight);
|
||||
expect(result.cssFontStyle).toBe(font.cssFontStyle);
|
||||
expect(result.reversed).toBe(font.reversed);
|
||||
expect(result.ascender).toBe(font.ascender);
|
||||
expect(result.descender).toBe(font.descender);
|
||||
expect(result.underlinePosition).toBe(font.underlinePosition);
|
||||
expect(result.underlineThickness).toBe(font.underlineThickness);
|
||||
expect(result.resolution).toBe(font.resolution);
|
||||
expect(result.boundingBox).toEqual(font.boundingBox);
|
||||
});
|
||||
|
||||
it("round-trips reversed=true", { timeout: 5000 }, () => {
|
||||
const result = new BinaryFontUnpacker().unpack(
|
||||
packFont(makeFont({ reversed: true }))
|
||||
);
|
||||
expect(result.reversed).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips glyph ha/x_min/x_max", { timeout: 5000 }, () => {
|
||||
const result = new BinaryFontUnpacker().unpack(packFont(makeFont()));
|
||||
expect(result.glyphs["A"]).toBeDefined();
|
||||
expect(result.glyphs["A"]?.ha).toBe(600);
|
||||
expect(result.glyphs["A"]?.x_min).toBe(10);
|
||||
expect(result.glyphs["A"]?.x_max).toBe(590);
|
||||
});
|
||||
|
||||
it("round-trips glyph outline string", { timeout: 5000 }, () => {
|
||||
const result = new BinaryFontUnpacker().unpack(packFont(makeFont()));
|
||||
expect(result.glyphs["A"]?.o).toBe("m 0 0 l 100 0 z ");
|
||||
});
|
||||
|
||||
it("round-trips font with no glyphs", { timeout: 5000 }, () => {
|
||||
const result = new BinaryFontUnpacker().unpack(
|
||||
packFont(makeFont({ glyphs: {} }))
|
||||
);
|
||||
expect(Object.keys(result.glyphs)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("round-trips multiple glyphs and all 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 result = new BinaryFontUnpacker().unpack(packFont(font));
|
||||
expect(result.glyphs["A"]?.o).toBe(
|
||||
"m 0 0 l 100 0 b 10 20 30 40 50 60 q 5 10 15 20 z "
|
||||
);
|
||||
expect(result.glyphs["B"]?.o).toBe("m 0 0 z ");
|
||||
});
|
||||
|
||||
it("round-trips negative coordinate values", { timeout: 5000 }, () => {
|
||||
const font = makeFont({
|
||||
glyphs: {
|
||||
g: {
|
||||
ha: 600,
|
||||
x_min: -10,
|
||||
x_max: 590,
|
||||
o: "m -5 -200 l 100 -100 z ",
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = new BinaryFontUnpacker().unpack(packFont(font));
|
||||
expect(result.glyphs["g"]?.o).toBe("m -5 -200 l 100 -100 z ");
|
||||
});
|
||||
|
||||
it("round-trips unicode character glyphs", { timeout: 5000 }, () => {
|
||||
const font = makeFont({
|
||||
glyphs: {
|
||||
"€": { ha: 700, x_min: 50, x_max: 650, o: "m 0 0 z " },
|
||||
},
|
||||
});
|
||||
const result = new BinaryFontUnpacker().unpack(packFont(font));
|
||||
expect(result.glyphs["€"]).toBeDefined();
|
||||
expect(result.glyphs["€"]?.ha).toBe(700);
|
||||
});
|
||||
|
||||
it("sets original_font_information to empty object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const result = new BinaryFontUnpacker().unpack(packFont(makeFont()));
|
||||
expect(result.original_font_information).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("BinaryFontUnpacker.unpack — error handling", () => {
|
||||
it("truncated buffer raises FontDecompressError", { timeout: 5000 }, () => {
|
||||
const unpacker = createBinaryFontUnpacker();
|
||||
expect(() => unpacker.unpack(new Uint8Array(3))).toThrow(
|
||||
FontDecompressError
|
||||
);
|
||||
});
|
||||
|
||||
it("empty buffer raises FontDecompressError", { timeout: 5000 }, () => {
|
||||
const unpacker = createBinaryFontUnpacker();
|
||||
expect(() => unpacker.unpack(new Uint8Array(0))).toThrow(
|
||||
FontDecompressError
|
||||
);
|
||||
});
|
||||
|
||||
it("truncated mid-dictionary raises INVALID_DATA", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const fullBuf = packFont(makeFont());
|
||||
// Find roughly where the dictionary starts: after 3 length-prefixed strings + reversed byte + u32 dictlen.
|
||||
// Truncate to a length guaranteed to land inside the dictionary.
|
||||
const truncated = fullBuf.subarray(
|
||||
0,
|
||||
Math.max(40, fullBuf.length - 30)
|
||||
);
|
||||
try {
|
||||
new BinaryFontUnpacker().unpack(truncated);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(FontDecompressError);
|
||||
}
|
||||
});
|
||||
|
||||
it("non-Error throws from DataView are wrapped as UNPACK_FAILED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const unpacker = new BinaryFontUnpacker();
|
||||
const data = packFont(makeFont());
|
||||
// Proxy that makes `buffer` access throw — exercises the catch-all
|
||||
// wrapping path (anything that escapes the inner FontDecompressError
|
||||
// checks is wrapped as UNPACK_FAILED).
|
||||
const proxy = new Proxy(data, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === "buffer") throw new TypeError("buffer error");
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
});
|
||||
try {
|
||||
unpacker.unpack(proxy as unknown as Uint8Array);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(FontDecompressError);
|
||||
expect((err as FontDecompressError).code).toBe(
|
||||
FONT_DECOMPRESS_CODES.UNPACK_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("malformed BTF: glyph indexCount < 3 raises INVALID_DATA", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const font = makeFont();
|
||||
const buf = new Uint8Array(packFont(font));
|
||||
const dv = new DataView(buf.buffer);
|
||||
const charCode = "A".charCodeAt(0);
|
||||
let off = -1;
|
||||
for (let i = 0; i + 4 <= buf.length; i++) {
|
||||
if (dv.getUint32(i, true) === charCode) {
|
||||
off = i + 4; // u32 indexCount lives right after the charCode u32
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(off).toBeGreaterThan(0);
|
||||
dv.setUint32(off, 2, true);
|
||||
try {
|
||||
new BinaryFontUnpacker().unpack(buf);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(FontDecompressError);
|
||||
expect((err as FontDecompressError).code).toBe(
|
||||
FONT_DECOMPRESS_CODES.INVALID_DATA
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("malformed BTF: glyph cmd byte out of range raises INVALID_DATA", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const font = makeFont({
|
||||
glyphs: { A: { ha: 600, x_min: 0, x_max: 600, o: "z " } },
|
||||
});
|
||||
const buf = new Uint8Array(packFont(font));
|
||||
const dv = new DataView(buf.buffer);
|
||||
const charCode = "A".charCodeAt(0);
|
||||
let off = -1;
|
||||
for (let i = 0; i + 4 <= buf.length; i++) {
|
||||
if (dv.getUint32(i, true) === charCode) {
|
||||
// After charCode (4) + indexCount (4) + ha/x_min/x_max (6 = 3*u16),
|
||||
// the cmd byte u16 starts.
|
||||
off = i + 4 + 4 + 6;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(off).toBeGreaterThan(0);
|
||||
dv.setUint16(off, 99, true);
|
||||
try {
|
||||
new BinaryFontUnpacker().unpack(buf);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(FontDecompressError);
|
||||
expect((err as FontDecompressError).code).toBe(
|
||||
FONT_DECOMPRESS_CODES.INVALID_DATA
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("malformed BTF: dictionary index out of range raises INVALID_DATA", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const buf = new Uint8Array(packFont(makeFont()));
|
||||
const dv = new DataView(buf.buffer);
|
||||
let off = 0;
|
||||
for (let s = 0; s < 3; s++) {
|
||||
const len = dv.getUint32(off, true);
|
||||
off += 4 + len;
|
||||
}
|
||||
off += 1; // reversed byte
|
||||
const dictLen = dv.getUint32(off, true);
|
||||
off += 4;
|
||||
off += dictLen * 2;
|
||||
dv.setUint16(off, 0xffff, true); // ascender metric index
|
||||
try {
|
||||
new BinaryFontUnpacker().unpack(buf);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(FontDecompressError);
|
||||
expect((err as FontDecompressError).code).toBe(
|
||||
FONT_DECOMPRESS_CODES.INVALID_DATA
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("malformed BTF: glyph indexes truncated mid-coords raises INVALID_DATA", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Glyph with `o: "m 0 0 z "` packs to indexCount = 7 (ha, x_min, x_max, m, 0idx, 0idx, z).
|
||||
// Truncate to 5: ha, x_min, x_max, m cmd byte, only ONE of the two m coord indexes.
|
||||
const font = makeFont({
|
||||
glyphs: { A: { ha: 600, x_min: 0, x_max: 600, o: "m 0 0 z " } },
|
||||
});
|
||||
const buf = new Uint8Array(packFont(font));
|
||||
const dv = new DataView(buf.buffer);
|
||||
const charCode = "A".charCodeAt(0);
|
||||
let off = -1;
|
||||
for (let i = 0; i + 4 <= buf.length; i++) {
|
||||
if (dv.getUint32(i, true) === charCode) {
|
||||
off = i + 4; // indexCount
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(off).toBeGreaterThan(0);
|
||||
dv.setUint32(off, 5, true); // declare 5 indexes; m expects 2 coords after it
|
||||
try {
|
||||
new BinaryFontUnpacker().unpack(buf);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(FontDecompressError);
|
||||
expect((err as FontDecompressError).code).toBe(
|
||||
FONT_DECOMPRESS_CODES.INVALID_DATA
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("tier purity: no node/bun imports", { timeout: 5000 }, () => {
|
||||
// Import-time exercise: the file already loaded above. If a
|
||||
// node:/bun: import had crept in, the universal vitest run
|
||||
// would have failed. The named test exists so the spec's
|
||||
// `Verified by:` line has a concrete anchor.
|
||||
expect(BinaryFontUnpacker).toBeDefined();
|
||||
});
|
||||
|
||||
it("subpath export resolves to unpacker module", { timeout: 5000 }, () => {
|
||||
// Local path mirrors the published subpath at build time.
|
||||
expect(createBinaryFontUnpacker()).toBeInstanceOf(BinaryFontUnpacker);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { TypefaceFont } from "@suaveplan/font-convert";
|
||||
import {
|
||||
BYTE_TO_CMD,
|
||||
CMD_COORD_COUNTS,
|
||||
METRICS_COUNT,
|
||||
} from "../../constants/index.js";
|
||||
import {
|
||||
FONT_DECOMPRESS_CODES,
|
||||
FontDecompressError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { decodeString } from "../../utils/decode-string.js";
|
||||
|
||||
/**
|
||||
* Unpacks a BTF binary buffer back into a `TypefaceFont`. Reverses the exact
|
||||
* wire format produced by `BinaryFontPacker` from `@suaveplan/font-compress`.
|
||||
*
|
||||
* BTF layout (matching the packer):
|
||||
*
|
||||
* ```text
|
||||
* [length-prefixed familyName]
|
||||
* [length-prefixed cssFontWeight]
|
||||
* [length-prefixed cssFontStyle]
|
||||
* [u8 reversed flag]
|
||||
* [u32 dictionary length]
|
||||
* [int16 × dictLen — dictionary]
|
||||
* [u16 × 9 — metric indexes (ascender, descender, ulPos, ulThk, resolution, bbYMin, bbXMin, bbYMax, bbXMax)]
|
||||
* [u32 glyph count]
|
||||
* per glyph:
|
||||
* [u32 charCode]
|
||||
* [u32 index count]
|
||||
* [u16 × indexCount — ha, x_min, x_max, then cmd byte + per-cmd coord indexes]
|
||||
* ```
|
||||
*/
|
||||
export class BinaryFontUnpacker {
|
||||
public unpack(data: Uint8Array): TypefaceFont {
|
||||
try {
|
||||
let offset = 0;
|
||||
|
||||
const familyNameResult = decodeString(data, offset);
|
||||
const familyName = familyNameResult.value;
|
||||
offset = familyNameResult.offset;
|
||||
|
||||
const cssFontWeightResult = decodeString(data, offset);
|
||||
const cssFontWeight = cssFontWeightResult.value;
|
||||
offset = cssFontWeightResult.offset;
|
||||
|
||||
const cssFontStyleResult = decodeString(data, offset);
|
||||
const cssFontStyle = cssFontStyleResult.value;
|
||||
offset = cssFontStyleResult.offset;
|
||||
|
||||
assertRemaining(data, offset, 1, "reversed flag");
|
||||
const reversed = data[offset] === 1;
|
||||
offset += 1;
|
||||
|
||||
assertRemaining(data, offset, 4, "dictionary length");
|
||||
const dictLen = readUint32(data, offset);
|
||||
offset += 4;
|
||||
const dictByteLen = dictLen * 2;
|
||||
assertRemaining(data, offset, dictByteLen, "dictionary bytes");
|
||||
|
||||
const dv = new DataView(data.buffer, data.byteOffset);
|
||||
const dict: number[] = [];
|
||||
for (let i = 0; i < dictLen; i++) {
|
||||
dict.push(dv.getInt16(offset + i * 2, true));
|
||||
}
|
||||
offset += dictByteLen;
|
||||
|
||||
const metricsByteLen = METRICS_COUNT * 2;
|
||||
assertRemaining(data, offset, metricsByteLen, "metrics");
|
||||
const metricIdxs: number[] = [];
|
||||
for (let i = 0; i < METRICS_COUNT; i++) {
|
||||
metricIdxs.push(dv.getUint16(offset + i * 2, true));
|
||||
}
|
||||
offset += metricsByteLen;
|
||||
|
||||
const ascender = dictAt(dict, metricIdxs[0], "ascender");
|
||||
const descender = dictAt(dict, metricIdxs[1], "descender");
|
||||
const underlinePosition = dictAt(
|
||||
dict,
|
||||
metricIdxs[2],
|
||||
"underlinePosition"
|
||||
);
|
||||
const underlineThickness = dictAt(
|
||||
dict,
|
||||
metricIdxs[3],
|
||||
"underlineThickness"
|
||||
);
|
||||
const resolution = dictAt(dict, metricIdxs[4], "resolution");
|
||||
const boundingBox = {
|
||||
yMin: dictAt(dict, metricIdxs[5], "boundingBox.yMin"),
|
||||
xMin: dictAt(dict, metricIdxs[6], "boundingBox.xMin"),
|
||||
yMax: dictAt(dict, metricIdxs[7], "boundingBox.yMax"),
|
||||
xMax: dictAt(dict, metricIdxs[8], "boundingBox.xMax"),
|
||||
};
|
||||
|
||||
assertRemaining(data, offset, 4, "glyph count");
|
||||
const glyphCount = readUint32(data, offset);
|
||||
offset += 4;
|
||||
|
||||
const glyphs: Record<
|
||||
string,
|
||||
{ ha: number; x_min: number; x_max: number; o: string }
|
||||
> = {};
|
||||
|
||||
for (let g = 0; g < glyphCount; g++) {
|
||||
assertRemaining(data, offset, 4, `glyph[${g}].charCode`);
|
||||
const charCode = readUint32(data, offset);
|
||||
offset += 4;
|
||||
const char = String.fromCharCode(charCode);
|
||||
|
||||
assertRemaining(data, offset, 4, `glyph[${g}].indexCount`);
|
||||
const indexCount = readUint32(data, offset);
|
||||
offset += 4;
|
||||
|
||||
const idxByteLen = indexCount * 2;
|
||||
assertRemaining(
|
||||
data,
|
||||
offset,
|
||||
idxByteLen,
|
||||
`glyph[${g}].indexes`
|
||||
);
|
||||
const idxs: number[] = [];
|
||||
for (let j = 0; j < indexCount; j++) {
|
||||
idxs.push(dv.getUint16(offset + j * 2, true));
|
||||
}
|
||||
offset += idxByteLen;
|
||||
|
||||
if (indexCount < 3) {
|
||||
throw new FontDecompressError(
|
||||
FONT_DECOMPRESS_CODES.INVALID_DATA,
|
||||
`glyph[${g}] indexCount ${indexCount} < 3 (need ha, x_min, x_max)`
|
||||
);
|
||||
}
|
||||
const ha = dictAt(dict, idxs[0], `glyph[${g}].ha`);
|
||||
const x_min = dictAt(dict, idxs[1], `glyph[${g}].x_min`);
|
||||
const x_max = dictAt(dict, idxs[2], `glyph[${g}].x_max`);
|
||||
|
||||
let o = "";
|
||||
let i = 3;
|
||||
while (i < idxs.length) {
|
||||
const cmdByte = idxs[i] as number;
|
||||
if (cmdByte < 0 || cmdByte >= BYTE_TO_CMD.length) {
|
||||
throw new FontDecompressError(
|
||||
FONT_DECOMPRESS_CODES.INVALID_DATA,
|
||||
`glyph[${g}] command byte ${cmdByte} out of range [0, ${BYTE_TO_CMD.length - 1}]`
|
||||
);
|
||||
}
|
||||
const cmd = BYTE_TO_CMD[cmdByte] as string;
|
||||
const coordCount = CMD_COORD_COUNTS[cmd] as number;
|
||||
const coords: number[] = [];
|
||||
for (let j = 0; j < coordCount; j++) {
|
||||
i++;
|
||||
if (i >= idxs.length) {
|
||||
throw new FontDecompressError(
|
||||
FONT_DECOMPRESS_CODES.INVALID_DATA,
|
||||
`glyph[${g}] truncated coords for cmd '${cmd}'`
|
||||
);
|
||||
}
|
||||
coords.push(dictAt(dict, idxs[i], `glyph[${g}].coord`));
|
||||
}
|
||||
o += cmd;
|
||||
for (const c of coords) o += ` ${c}`;
|
||||
o += " ";
|
||||
i++;
|
||||
}
|
||||
|
||||
glyphs[char] = { ha, x_min, x_max, o };
|
||||
}
|
||||
|
||||
return {
|
||||
familyName,
|
||||
cssFontWeight,
|
||||
cssFontStyle,
|
||||
reversed,
|
||||
ascender,
|
||||
descender,
|
||||
underlinePosition,
|
||||
underlineThickness,
|
||||
resolution,
|
||||
boundingBox,
|
||||
original_font_information: {},
|
||||
glyphs,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof FontDecompressError) throw err;
|
||||
const cause = err as Error;
|
||||
throw new FontDecompressError(
|
||||
FONT_DECOMPRESS_CODES.UNPACK_FAILED,
|
||||
`BTF unpack failed: ${cause.message}`,
|
||||
{ cause }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createBinaryFontUnpacker(): BinaryFontUnpacker {
|
||||
return new BinaryFontUnpacker();
|
||||
}
|
||||
|
||||
function readUint32(data: Uint8Array, offset: number): number {
|
||||
return new DataView(data.buffer, data.byteOffset + offset).getUint32(
|
||||
0,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
function assertRemaining(
|
||||
data: Uint8Array,
|
||||
offset: number,
|
||||
need: number,
|
||||
field: string
|
||||
): void {
|
||||
if (offset + need > data.length) {
|
||||
throw new FontDecompressError(
|
||||
FONT_DECOMPRESS_CODES.INVALID_DATA,
|
||||
`BTF truncated at ${field}: need ${need} bytes at offset ${offset}, only ${data.length - offset} remaining`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function dictAt(
|
||||
dict: readonly number[],
|
||||
index: number | undefined,
|
||||
field: string
|
||||
): number {
|
||||
if (index === undefined || index < 0 || index >= dict.length) {
|
||||
throw new FontDecompressError(
|
||||
FONT_DECOMPRESS_CODES.INVALID_DATA,
|
||||
`dictionary index ${index} out of range [0, ${dict.length - 1}] for ${field}`
|
||||
);
|
||||
}
|
||||
return dict[index] as number;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
BinaryFontUnpacker,
|
||||
createBinaryFontUnpacker,
|
||||
} from "./binary-font-unpacker.js";
|
||||
@@ -0,0 +1,96 @@
|
||||
# utils/decode-string
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`decodeString` reads a length-prefixed UTF-8 string from a `Uint8Array`
|
||||
at a given offset. The BTF wire format encodes strings as
|
||||
`[uint32 length LE][utf8 bytes]`, and the unpacker calls this helper to
|
||||
read the three header strings (`familyName`, `cssFontWeight`,
|
||||
`cssFontStyle`) before walking into the dictionary section. Centralising
|
||||
the helper here keeps the unpacker focused on the structural layout
|
||||
and lets the boundary-validation logic (offset bounds, length-overrun
|
||||
detection) live in one auditable place.
|
||||
|
||||
The function is the inverse of `encodeString` from
|
||||
`@suaveplan/font-compress`. Together they form the wire-format
|
||||
agreement for length-prefixed strings used by the BTF header.
|
||||
|
||||
## Features
|
||||
|
||||
- Reads `[uint32 LE length][utf8 bytes]` and returns the decoded string + next offset
|
||||
- Rejects negative offsets via `FontDecompressError` with `FONT_DECOMPRESS.INVALID_DATA`
|
||||
- Rejects offsets where fewer than 4 bytes remain via the same code
|
||||
- Rejects declared lengths that would read past the buffer end via the same code
|
||||
- Handles empty strings (`length === 0`) correctly
|
||||
- Handles multi-byte UTF-8 (BMP and surrogate-pair) correctly via `TextDecoder`
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { decodeString } from "@suaveplan/font-decompress/utils/decode-string";
|
||||
|
||||
const bytes = new Uint8Array([5, 0, 0, 0, 104, 101, 108, 108, 111]); // length 5 + "hello"
|
||||
const { value, offset } = decodeString(bytes, 0);
|
||||
console.log(value); // "hello"
|
||||
console.log(offset); // 9
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { decodeString } from "@suaveplan/font-decompress/utils/decode-string";
|
||||
import { FONT_DECOMPRESS_CODES, FontDecompressError } from "@suaveplan/font-decompress";
|
||||
|
||||
let cursor = 0;
|
||||
try {
|
||||
const familyName = decodeString(buffer, cursor);
|
||||
cursor = familyName.offset;
|
||||
const cssFontWeight = decodeString(buffer, cursor);
|
||||
cursor = cssFontWeight.offset;
|
||||
const cssFontStyle = decodeString(buffer, cursor);
|
||||
cursor = cssFontStyle.offset;
|
||||
} catch (err) {
|
||||
if (err instanceof FontDecompressError && err.code === FONT_DECOMPRESS_CODES.INVALID_DATA) {
|
||||
// Truncated BTF buffer — log and surface to caller
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `decodeString(data: Uint8Array, offset: number): { value: string; offset: number }`
|
||||
|
||||
| Parameter | Type | Meaning |
|
||||
| --------- | ------------ | ---------------------------------------- |
|
||||
| `data` | `Uint8Array` | The buffer containing the encoded string |
|
||||
| `offset` | `number` | Byte offset to start reading from |
|
||||
|
||||
**Returns** `{ value: string; offset: number }` — the decoded string plus the next byte position. The new offset equals `offset + 4 + utf8ByteLength`.
|
||||
|
||||
**Throws** `FontDecompressError` with `code === "FONT_DECOMPRESS.INVALID_DATA"` when:
|
||||
|
||||
- `offset < 0`
|
||||
- `offset + 4 > data.length` (not enough bytes for the length prefix)
|
||||
- `offset + 4 + length > data.length` (declared length reads past buffer end)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The helper uses a module-level `TextDecoder` rather than constructing one
|
||||
per call. `TextDecoder` is sturdy under reuse and the BTF format never
|
||||
nests its strings, so a single shared decoder is safe and faster.
|
||||
|
||||
Length is read as little-endian `uint32` via `DataView.getUint32(..., true)`,
|
||||
matching the encoder side. The DataView is constructed with the
|
||||
`byteOffset` of the input array so callers can pass subarrays without
|
||||
extra slicing.
|
||||
|
||||
Boundary validation happens up-front: the function checks the offset
|
||||
and length BEFORE reading any bytes via `TextDecoder`. This avoids any
|
||||
ambiguity about whether a partial read might emit a surrogate-half or
|
||||
similar misuse — the throw is synchronous and the wire format is
|
||||
either valid or rejected.
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FONT_DECOMPRESS_CODES } from "../error-codes/error-codes.js";
|
||||
import { decodeString } from "./decode-string.js";
|
||||
|
||||
function encodeForTest(value: string): Uint8Array {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
const out = new Uint8Array(4 + bytes.length);
|
||||
new DataView(out.buffer).setUint32(0, bytes.length, true);
|
||||
out.set(bytes, 4);
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("decodeString", () => {
|
||||
it("round-trips a UTF-8 ASCII string", () => {
|
||||
const bytes = encodeForTest("Inter Variable");
|
||||
const { value, offset } = decodeString(bytes, 0);
|
||||
expect(value).toBe("Inter Variable");
|
||||
expect(offset).toBe(bytes.length);
|
||||
});
|
||||
|
||||
it("round-trips a UTF-8 string with multi-byte chars", () => {
|
||||
const bytes = encodeForTest("éîôü 漢字 🦊");
|
||||
const { value, offset } = decodeString(bytes, 0);
|
||||
expect(value).toBe("éîôü 漢字 🦊");
|
||||
expect(offset).toBe(bytes.length);
|
||||
});
|
||||
|
||||
it("decodes from a non-zero offset", () => {
|
||||
const prefix = new Uint8Array([0xff, 0xff]);
|
||||
const payload = encodeForTest("hello");
|
||||
const combined = new Uint8Array(prefix.length + payload.length);
|
||||
combined.set(prefix);
|
||||
combined.set(payload, prefix.length);
|
||||
const { value, offset } = decodeString(combined, prefix.length);
|
||||
expect(value).toBe("hello");
|
||||
expect(offset).toBe(prefix.length + payload.length);
|
||||
});
|
||||
|
||||
it("handles empty string", () => {
|
||||
const bytes = encodeForTest("");
|
||||
const { value, offset } = decodeString(bytes, 0);
|
||||
expect(value).toBe("");
|
||||
expect(offset).toBe(4);
|
||||
});
|
||||
|
||||
it("rejects negative offset", () => {
|
||||
try {
|
||||
decodeString(encodeForTest("x"), -1);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
const e = err as { code?: string };
|
||||
expect(e.code).toBe(FONT_DECOMPRESS_CODES.INVALID_DATA);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects offset past buffer end (less than 4 bytes remaining)", () => {
|
||||
const bytes = new Uint8Array([0, 0]);
|
||||
try {
|
||||
decodeString(bytes, 0);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
const e = err as { code?: string };
|
||||
expect(e.code).toBe(FONT_DECOMPRESS_CODES.INVALID_DATA);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects length overrun (declared longer than buffer)", () => {
|
||||
// Length=100 but only 4 bytes total in buffer
|
||||
const buf = new Uint8Array(4);
|
||||
new DataView(buf.buffer).setUint32(0, 100, true);
|
||||
try {
|
||||
decodeString(buf, 0);
|
||||
throw new Error("expected throw");
|
||||
} catch (err) {
|
||||
const e = err as { code?: string };
|
||||
expect(e.code).toBe(FONT_DECOMPRESS_CODES.INVALID_DATA);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
FONT_DECOMPRESS_CODES,
|
||||
FontDecompressError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
/**
|
||||
* Decodes a length-prefixed UTF-8 string from `data` at `offset`. The BTF
|
||||
* wire format encodes strings as `[uint32 length LE][utf8 bytes]`.
|
||||
* Returns the decoded string plus the offset of the next byte after the
|
||||
* string. Out-of-bounds length prefixes raise
|
||||
* `FontDecompressError` with `code === "FONT_DECOMPRESS.INVALID_DATA"`.
|
||||
*/
|
||||
export function decodeString(
|
||||
data: Uint8Array,
|
||||
offset: number
|
||||
): { value: string; offset: number } {
|
||||
if (offset < 0 || offset + 4 > data.length) {
|
||||
throw new FontDecompressError(
|
||||
FONT_DECOMPRESS_CODES.INVALID_DATA,
|
||||
`decodeString: offset ${offset} + 4 exceeds buffer length ${data.length}`
|
||||
);
|
||||
}
|
||||
const length = new DataView(
|
||||
data.buffer,
|
||||
data.byteOffset + offset
|
||||
).getUint32(0, true);
|
||||
const start = offset + 4;
|
||||
const end = start + length;
|
||||
if (end > data.length) {
|
||||
throw new FontDecompressError(
|
||||
FONT_DECOMPRESS_CODES.INVALID_DATA,
|
||||
`decodeString: declared length ${length} at offset ${offset} would read past buffer end ${data.length}`
|
||||
);
|
||||
}
|
||||
const value = decoder.decode(data.subarray(start, end));
|
||||
return { value, offset: end };
|
||||
}
|
||||
88
packages/media/fonts/font-decompress/tests/property.test.ts
Normal file
88
packages/media/fonts/font-decompress/tests/property.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { BinaryFontPacker } from "@suaveplan/font-compress";
|
||||
import type { TypefaceFont } from "@suaveplan/font-convert";
|
||||
import { describe, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { BinaryFontUnpacker } from "../src/index.js";
|
||||
|
||||
const packer = new BinaryFontPacker();
|
||||
const unpacker = new BinaryFontUnpacker();
|
||||
|
||||
// Restrict coordinates to Int16 range (BTF dict uses Int16Array)
|
||||
const coordArb = fc.integer({ min: -32768, max: 32767 });
|
||||
|
||||
const glyphArb = fc.record({
|
||||
ha: coordArb,
|
||||
x_min: coordArb,
|
||||
x_max: coordArb,
|
||||
o: fc.constant("m 0 0 z "),
|
||||
});
|
||||
|
||||
const fontArb: fc.Arbitrary<TypefaceFont> = fc.record({
|
||||
familyName: fc.string({ minLength: 1, maxLength: 30 }),
|
||||
cssFontWeight: fc.constantFrom("100", "400", "700", "bold", "normal"),
|
||||
cssFontStyle: fc.constantFrom("normal", "italic", "oblique"),
|
||||
reversed: fc.boolean(),
|
||||
ascender: coordArb,
|
||||
descender: coordArb,
|
||||
underlinePosition: coordArb,
|
||||
underlineThickness: coordArb,
|
||||
resolution: coordArb,
|
||||
boundingBox: fc.record({
|
||||
yMin: coordArb,
|
||||
xMin: coordArb,
|
||||
yMax: coordArb,
|
||||
xMax: coordArb,
|
||||
}),
|
||||
original_font_information: fc.constant({}),
|
||||
glyphs: fc.dictionary(
|
||||
fc
|
||||
.string({ minLength: 1, maxLength: 1 })
|
||||
.filter((s) => s.charCodeAt(0) < 65536),
|
||||
glyphArb,
|
||||
{ minKeys: 0, maxKeys: 20 }
|
||||
),
|
||||
});
|
||||
|
||||
describe("property tests: pack/unpack roundtrip", () => {
|
||||
it("familyName round-trips for all valid inputs", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(fontArb, (font) => {
|
||||
const result = unpacker.unpack(packer.pack(font));
|
||||
return result.familyName === font.familyName;
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it("metrics round-trip for all valid inputs", { timeout: 30000 }, () => {
|
||||
fc.assert(
|
||||
fc.property(fontArb, (font) => {
|
||||
const result = unpacker.unpack(packer.pack(font));
|
||||
return (
|
||||
result.ascender === font.ascender &&
|
||||
result.descender === font.descender &&
|
||||
result.resolution === font.resolution &&
|
||||
result.reversed === font.reversed
|
||||
);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
|
||||
it("glyph count round-trips for all valid inputs", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(fontArb, (font) => {
|
||||
const result = unpacker.unpack(packer.pack(font));
|
||||
return (
|
||||
Object.keys(result.glyphs).length ===
|
||||
Object.keys(font.glyphs).length
|
||||
);
|
||||
}),
|
||||
{ numRuns: 100 }
|
||||
);
|
||||
});
|
||||
});
|
||||
113
packages/media/fonts/font-decompress/tests/roundtrip.test.ts
Normal file
113
packages/media/fonts/font-decompress/tests/roundtrip.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { BinaryFontPacker } from "@suaveplan/font-compress";
|
||||
import type { TypefaceFont } from "@suaveplan/font-convert";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { BinaryFontUnpacker } from "../src/index.js";
|
||||
|
||||
function makeFont(overrides: Partial<TypefaceFont> = {}): TypefaceFont {
|
||||
return {
|
||||
familyName: "TestFont",
|
||||
cssFontWeight: "400",
|
||||
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: { source: "test" },
|
||||
glyphs: {
|
||||
A: { ha: 600, x_min: 10, x_max: 590, o: "m 0 0 l 100 0 z " },
|
||||
B: {
|
||||
ha: 560,
|
||||
x_min: 20,
|
||||
x_max: 540,
|
||||
o: "m 0 0 b 10 20 30 40 50 60 z ",
|
||||
},
|
||||
Q: { ha: 700, x_min: 5, x_max: 695, o: "m 0 0 q 50 100 100 0 z " },
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("roundtrip: BinaryFontPacker ↔ BinaryFontUnpacker", () => {
|
||||
it("preserves all scalar font metrics", { timeout: 5000 }, () => {
|
||||
const font = makeFont();
|
||||
const result = new BinaryFontUnpacker().unpack(
|
||||
new BinaryFontPacker().pack(font)
|
||||
);
|
||||
expect(result.ascender).toBe(font.ascender);
|
||||
expect(result.descender).toBe(font.descender);
|
||||
expect(result.underlinePosition).toBe(font.underlinePosition);
|
||||
expect(result.underlineThickness).toBe(font.underlineThickness);
|
||||
expect(result.resolution).toBe(font.resolution);
|
||||
});
|
||||
|
||||
it("preserves bounding box", { timeout: 5000 }, () => {
|
||||
const font = makeFont();
|
||||
const result = new BinaryFontUnpacker().unpack(
|
||||
new BinaryFontPacker().pack(font)
|
||||
);
|
||||
expect(result.boundingBox).toEqual(font.boundingBox);
|
||||
});
|
||||
|
||||
it("preserves all glyph outline strings", { timeout: 5000 }, () => {
|
||||
const font = makeFont();
|
||||
const result = new BinaryFontUnpacker().unpack(
|
||||
new BinaryFontPacker().pack(font)
|
||||
);
|
||||
for (const [char, glyph] of Object.entries(font.glyphs)) {
|
||||
expect(result.glyphs[char]?.o).toBe(glyph.o);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves reversed=true flag", { timeout: 5000 }, () => {
|
||||
const font = makeFont({ reversed: true });
|
||||
const result = new BinaryFontUnpacker().unpack(
|
||||
new BinaryFontPacker().pack(font)
|
||||
);
|
||||
expect(result.reversed).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves reversed=false flag", { timeout: 5000 }, () => {
|
||||
const font = makeFont({ reversed: false });
|
||||
const result = new BinaryFontUnpacker().unpack(
|
||||
new BinaryFontPacker().pack(font)
|
||||
);
|
||||
expect(result.reversed).toBe(false);
|
||||
});
|
||||
|
||||
it("handles empty glyph set", { timeout: 5000 }, () => {
|
||||
const font = makeFont({ glyphs: {} });
|
||||
const result = new BinaryFontUnpacker().unpack(
|
||||
new BinaryFontPacker().pack(font)
|
||||
);
|
||||
expect(Object.keys(result.glyphs)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles large glyph with all command types", { timeout: 5000 }, () => {
|
||||
const font = makeFont({
|
||||
glyphs: {
|
||||
X: {
|
||||
ha: 800,
|
||||
x_min: -50,
|
||||
x_max: 850,
|
||||
o: "m 0 0 l 100 200 b -10 -20 30 40 50 60 q 5 -10 15 20 z m 300 400 l 500 400 z ",
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = new BinaryFontUnpacker().unpack(
|
||||
new BinaryFontPacker().pack(font)
|
||||
);
|
||||
expect(result.glyphs["X"]!.o).toBe(font.glyphs["X"]!.o);
|
||||
});
|
||||
|
||||
it("produces a smaller buffer than naive JSON", { timeout: 5000 }, () => {
|
||||
const font = makeFont();
|
||||
const packed = new BinaryFontPacker().pack(font);
|
||||
const jsonBytes = new TextEncoder().encode(
|
||||
JSON.stringify(font)
|
||||
).byteLength;
|
||||
expect(packed.byteLength).toBeLessThan(jsonBytes);
|
||||
});
|
||||
});
|
||||
12
packages/media/fonts/font-decompress/tsconfig.lib.json
Normal file
12
packages/media/fonts/font-decompress/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"]
|
||||
}
|
||||
35
packages/media/fonts/font-decompress/vite.config.ts
Normal file
35
packages/media/fonts/font-decompress/vite.config.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/font-decompress",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"tslib",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/font-convert",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
31
packages/media/fonts/font-decompress/vitest.config.ts
Normal file
31
packages/media/fonts/font-decompress/vitest.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
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/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
125
packages/media/fonts/font-subset/README.md
Normal file
125
packages/media/fonts/font-subset/README.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# @suaveplan/font-compress - Font subsetting and compression library with a codec-agnostic adapter interface. Supports Unicode range subsetting, WOFF2/WOFF/OTF/TTF output, OpenType feature filtering, hinting strip, and metadata strip.
|
||||
|
||||
## Summary
|
||||
|
||||
Font subsetting and compression library with a codec-agnostic adapter interface. Supports Unicode range subsetting, WOFF2/WOFF/OTF/TTF output, OpenType feature filtering, hinting strip, and metadata strip. Adapter implementations supply the actual codec; this package provides the orchestration, validation, format detection, and OTel instrumentation.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [null](./src/adapters/null/null.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [null](./src/adapters/null/null.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [font-compressor](./src/core/font-compressor/font-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [font-compressor](./src/core/font-compressor/font-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [format-detect](./src/utils/format-detect.md) — Provides magic-byte font format detection from raw binary data. Before compressing or probing a font file, `FontCompres…
|
||||
- [unicode-ranges](./src/utils/unicode-ranges.md) — Parses CSS/OpenType-style Unicode range notation into a `Set<number>` of integer codepoints. Font subsetting workflows…
|
||||
<!-- end:auto-docs --> - [null](./src/adapters/null/null.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-compressor](./src/core/font-compressor/font-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-compressor](./src/core/font-compressor/font-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [format-detect](./src/utils/format-detect.md) — Provides magic-byte font format detection from raw binary data. Before compressing or probing a font file, `FontCompres…
|
||||
- [unicode-ranges](./src/utils/unicode-ranges.md) — Parses CSS/OpenType-style Unicode range notation into a `Set<number>` of integer codepoints. Font subsetting workflows…
|
||||
- [null](./src/adapters/null/null.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-compressor](./src/core/font-compressor/font-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-compressor](./src/core/font-compressor/font-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [format-detect](./src/utils/format-detect.md) — Provides magic-byte font format detection from raw binary data. Before compressing or probing a font file, `FontCompres…
|
||||
- [unicode-ranges](./src/utils/unicode-ranges.md) — Parses CSS/OpenType-style Unicode range notation into a `Set<number>` of integer codepoints. Font subsetting workflows…
|
||||
- [null](./src/adapters/null/null.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-compressor](./src/core/font-compressor/font-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-compressor](./src/core/font-compressor/font-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [format-detect](./src/utils/format-detect.md) — Provides magic-byte font format detection from raw binary data. Before compressing or probing a font file, `FontCompres…
|
||||
- [unicode-ranges](./src/utils/unicode-ranges.md) — Parses CSS/OpenType-style Unicode range notation into a `Set<number>` of integer codepoints. Font subsetting workflows…
|
||||
- [null](./src/adapters/null/null.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-compressor](./src/core/font-compressor/font-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_ - [font-compressor](./src/core/font-compressor/font-compressor.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_ - [common](./src/types/common.md) — <!-- begin:auto-docs --> _(no submodules)_- [format-detect](./src/utils/format-detect.md) — Provides magic-byte font format detection from raw binary data. Before compressing or probing a font file, `FontCompres…
|
||||
- [unicode-ranges](./src/utils/unicode-ranges.md) — Parses CSS/OpenType-style Unicode range notation into a `Set<number>` of integer codepoints. Font subsetting workflows…
|
||||
|
||||
## Packages
|
||||
|
||||
| Module | Description |
|
||||
|---|---|
|
||||
| [FontCompressor](src/core/font-compressor/font-compressor.md) | Core orchestrator: validation, format checks, AbortSignal, OTel |
|
||||
| [NullFontCompressAdapter](src/adapters/null-adapter/null-adapter.md) | No-op adapter for unit/integration tests |
|
||||
| [parseUnicodeRanges](src/utils/unicode-ranges.md) | Unicode range string → `Set<number>` codepoint parser |
|
||||
| [detectFontFormat](src/utils/format-detect.md) | Magic-byte font format detection |
|
||||
| [Types & Schemas](src/types/common.md) | All public types and Zod validation schemas |
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/font-compress
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import {
|
||||
FontCompressor,
|
||||
NullFontCompressAdapter,
|
||||
} from "@suaveplan/font-compress";
|
||||
|
||||
// Replace NullFontCompressAdapter with a real codec adapter in production
|
||||
const adapter = new NullFontCompressAdapter();
|
||||
const compressor = new FontCompressor({ adapter });
|
||||
|
||||
const fontBytes = await Bun.file("Roboto-Regular.ttf").bytes();
|
||||
const result = await compressor.compress(fontBytes, {
|
||||
subset: { unicodeRanges: ["U+0020-007F"] },
|
||||
outputFormat: "woff2",
|
||||
stripHinting: true,
|
||||
});
|
||||
|
||||
console.log(`Compressed ${result.originalSize} → ${result.compressedSize} bytes`);
|
||||
console.log(`Ratio: ${(result.ratio * 100).toFixed(1)}%`);
|
||||
```
|
||||
|
||||
## Providing a Real Codec Adapter
|
||||
|
||||
Implement `FontCompressAdapter` to wrap any real font codec:
|
||||
|
||||
```typescript
|
||||
import type { FontCompressAdapter, FontMetadata, FontCompressResult, CompressFontOptions } from "@suaveplan/font-compress";
|
||||
|
||||
class WoffToolsAdapter implements FontCompressAdapter {
|
||||
readonly supportedInputFormats = ["ttf", "otf"] as const;
|
||||
readonly supportedOutputFormats = ["woff2"] as const;
|
||||
|
||||
async probe(data: Uint8Array): Promise<FontMetadata> {
|
||||
// parse font tables to extract metadata
|
||||
}
|
||||
|
||||
async compress(data: Uint8Array, options: CompressFontOptions): Promise<FontCompressResult> {
|
||||
// call woff2-encoder or similar
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
All errors thrown by this package are instances of `FontCompressError`, which extends `SuaveplanError`. Use the `code` property for programmatic error handling:
|
||||
|
||||
```typescript
|
||||
import { FONT_COMPRESS_CODES, FontCompressError } from "@suaveplan/font-compress";
|
||||
|
||||
try {
|
||||
await compressor.compress(data, { outputFormat: "woff2" });
|
||||
} catch (e) {
|
||||
if (e instanceof FontCompressError) {
|
||||
switch (e.code) {
|
||||
case FONT_COMPRESS_CODES.UNSUPPORTED_FORMAT: /* ... */
|
||||
case FONT_COMPRESS_CODES.PARSE_FAILED: /* ... */
|
||||
case FONT_COMPRESS_CODES.ENCODE_FAILED: /* ... */
|
||||
case FONT_COMPRESS_CODES.INVALID_OPTIONS: /* ... */
|
||||
case FONT_COMPRESS_CODES.SUBSET_FAILED: /* ... */
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Unicode Range Subsetting
|
||||
|
||||
```typescript
|
||||
import { parseUnicodeRanges } from "@suaveplan/font-compress/utils/unicode-ranges";
|
||||
|
||||
const codepoints = parseUnicodeRanges(["U+0020-007F", "U+00A0-00FF"]);
|
||||
console.log(codepoints.size); // 192 codepoints
|
||||
```
|
||||
|
||||
## Format Detection
|
||||
|
||||
```typescript
|
||||
import { detectFontFormat } from "@suaveplan/font-compress/utils/format-detect";
|
||||
|
||||
const fmt = detectFontFormat(await Bun.file("font.woff2").bytes());
|
||||
console.log(fmt); // "woff2"
|
||||
```
|
||||
105
packages/media/fonts/font-subset/package.json
Normal file
105
packages/media/fonts/font-subset/package.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"name": "@suaveplan/font-subset",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Font subsetting pipeline: Unicode range subsetting, WOFF2/WOFF encoding, TTF-to-OTF normalisation, feature tag filtering, hinting strip, metadata strip. Codec-agnostic via injectable FontSubsetAdapter.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"./core/font-compressor": {
|
||||
"import": {
|
||||
"types": "./dist/core/font-compressor/index.d.ts",
|
||||
"default": "./dist/core/font-compressor/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/font-compressor/index.d.ts",
|
||||
"default": "./dist/core/font-compressor/index.js"
|
||||
}
|
||||
},
|
||||
"./adapters/null": {
|
||||
"import": {
|
||||
"types": "./dist/adapters/null/index.d.ts",
|
||||
"default": "./dist/adapters/null/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/adapters/null/index.d.ts",
|
||||
"default": "./dist/adapters/null/index.js"
|
||||
}
|
||||
},
|
||||
"./utils/unicode-ranges": {
|
||||
"import": {
|
||||
"types": "./dist/utils/unicode-ranges.d.ts",
|
||||
"default": "./dist/utils/unicode-ranges.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/utils/unicode-ranges.d.ts",
|
||||
"default": "./dist/utils/unicode-ranges.js"
|
||||
}
|
||||
},
|
||||
"./utils/format-detect": {
|
||||
"import": {
|
||||
"types": "./dist/utils/format-detect.d.ts",
|
||||
"default": "./dist/utils/format-detect.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/utils/format-detect.d.ts",
|
||||
"default": "./dist/utils/format-detect.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/error": "workspace:^",
|
||||
"@suaveplan/mime": "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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
NullFontSubsetAdapter,
|
||||
type NullFontSubsetAdapterOptions,
|
||||
} from "./null.js";
|
||||
76
packages/media/fonts/font-subset/src/adapters/null/null.md
Normal file
76
packages/media/fonts/font-subset/src/adapters/null/null.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# NullFontCompressAdapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides a no-op `FontCompressAdapter` implementation designed exclusively for unit and integration testing. Rather than invoking any real font codec, `NullFontCompressAdapter` returns configurable fixture values, allowing test suites to verify `FontCompressor` orchestration logic (abort handling, format validation, OTel wiring, option parsing) in complete isolation from external binary dependencies.
|
||||
|
||||
## Features
|
||||
|
||||
- Implements the full `FontCompressAdapter` contract without external dependencies
|
||||
- `probe` returns deterministic fixture `FontMetadata` derived from input byte length
|
||||
- `probe` accepts a fully custom `FontMetadata` override via constructor options
|
||||
- `compress` echoes the input `Uint8Array` back unchanged with a synthetic `ratio` of `0.9`
|
||||
- `compress` respects `outputFormat` from options (defaults to `"woff2"`)
|
||||
- All four formats (`ttf`, `otf`, `woff`, `woff2`) listed in both `supportedInputFormats` and `supportedOutputFormats`
|
||||
- Fully synchronous-safe — all async methods resolve immediately with no I/O
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { NullFontCompressAdapter, FontCompressor } from "@suaveplan/font-compress";
|
||||
|
||||
const adapter = new NullFontCompressAdapter();
|
||||
const compressor = new FontCompressor({ adapter });
|
||||
|
||||
const data = new Uint8Array([0x00, 0x01, 0x00, 0x00, ...new Array(96).fill(0)]);
|
||||
const result = await compressor.compress(data, { outputFormat: "woff2" });
|
||||
// result.format === "woff2", result.ratio === 0.9
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { NullFontCompressAdapter, FontCompressor } from "@suaveplan/font-compress";
|
||||
import type { FontMetadata } from "@suaveplan/font-compress";
|
||||
|
||||
const customMeta: FontMetadata = {
|
||||
format: "woff2",
|
||||
mimeType: "font/woff2",
|
||||
byteSize: 51200,
|
||||
familyName: "TestFont",
|
||||
glyphCount: 256,
|
||||
};
|
||||
|
||||
const adapter = new NullFontCompressAdapter({ probeResult: customMeta });
|
||||
const compressor = new FontCompressor({ adapter });
|
||||
|
||||
const meta = await compressor.probe(new Uint8Array(10));
|
||||
console.log(meta.familyName); // "TestFont"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `NullFontCompressAdapterOptions`
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `probeResult` | `FontMetadata` | Custom metadata returned by every `probe()` call |
|
||||
|
||||
### `new NullFontCompressAdapter(options?)`
|
||||
|
||||
Constructs the adapter. All fields in `options` are optional.
|
||||
|
||||
### `probe(data: Uint8Array): Promise<FontMetadata>`
|
||||
|
||||
Returns `probeResult` if provided, otherwise returns `{ format: "ttf", mimeType: "font/ttf", byteSize: data.byteLength }`.
|
||||
|
||||
### `compress(data: Uint8Array, options: CompressFontOptions): Promise<FontCompressResult>`
|
||||
|
||||
Returns the input `data` unchanged as `result.data`. The `format` and `mimeType` are derived from `options.outputFormat ?? "woff2"`. `ratio` is always `0.9`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `0.9` ratio constant is arbitrary but non-trivial: it satisfies the property test invariant (`ratio > 0`) while remaining obviously synthetic. Callers testing ratio bounds should use a custom adapter. The `NullFontCompressAdapter` deliberately does not validate the `data` contents — it trusts the `FontCompressor` to perform upstream format checks before delegating here.
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { FontMetadata, SubsetFontOptions } from "../../types/common.js";
|
||||
import { NullFontSubsetAdapter } from "./null.js";
|
||||
|
||||
const MINIMAL_DATA = new Uint8Array([0x00, 0x01, 0x00, 0x00, 0x01]);
|
||||
|
||||
describe("NullFontSubsetAdapter", () => {
|
||||
it("exposes supportedInputFormats", { timeout: 5000 }, () => {
|
||||
const adapter = new NullFontSubsetAdapter();
|
||||
expect(adapter.supportedInputFormats).toContain("ttf");
|
||||
expect(adapter.supportedInputFormats).toContain("otf");
|
||||
expect(adapter.supportedInputFormats).toContain("woff");
|
||||
expect(adapter.supportedInputFormats).toContain("woff2");
|
||||
});
|
||||
|
||||
it("exposes supportedOutputFormats", { timeout: 5000 }, () => {
|
||||
const adapter = new NullFontSubsetAdapter();
|
||||
expect(adapter.supportedOutputFormats).toContain("woff2");
|
||||
});
|
||||
|
||||
it("probe returns fixture metadata", { timeout: 5000 }, async () => {
|
||||
const adapter = new NullFontSubsetAdapter();
|
||||
const meta = await adapter.probe(MINIMAL_DATA);
|
||||
expect(meta.format).toBe("ttf");
|
||||
expect(meta.mimeType).toBe("font/ttf");
|
||||
expect(meta.byteSize).toBe(MINIMAL_DATA.byteLength);
|
||||
});
|
||||
|
||||
it("probe uses custom fixture metadata", { timeout: 5000 }, async () => {
|
||||
const custom: FontMetadata = {
|
||||
format: "woff2",
|
||||
mimeType: "font/woff2",
|
||||
byteSize: 100,
|
||||
familyName: "TestFont",
|
||||
};
|
||||
const adapter = new NullFontSubsetAdapter({ probeResult: custom });
|
||||
const meta = await adapter.probe(MINIMAL_DATA);
|
||||
expect(meta.format).toBe("woff2");
|
||||
expect(meta.familyName).toBe("TestFont");
|
||||
});
|
||||
|
||||
it("compress returns fixture result with same data", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new NullFontSubsetAdapter();
|
||||
const opts: SubsetFontOptions = { outputFormat: "woff2" };
|
||||
const result = await adapter.compress(MINIMAL_DATA, opts);
|
||||
expect(result.data).toBe(MINIMAL_DATA);
|
||||
expect(result.format).toBe("woff2");
|
||||
expect(result.mimeType).toBe("font/woff2");
|
||||
expect(result.originalSize).toBe(MINIMAL_DATA.byteLength);
|
||||
expect(result.compressedSize).toBe(MINIMAL_DATA.byteLength);
|
||||
expect(result.ratio).toBeCloseTo(0.9, 5);
|
||||
});
|
||||
|
||||
it("compress uses outputFormat from options", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new NullFontSubsetAdapter();
|
||||
const result = await adapter.compress(MINIMAL_DATA, {
|
||||
outputFormat: "woff",
|
||||
});
|
||||
expect(result.format).toBe("woff");
|
||||
expect(result.mimeType).toBe("font/woff");
|
||||
});
|
||||
|
||||
it("compress defaults to woff2 when no outputFormat", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new NullFontSubsetAdapter();
|
||||
const result = await adapter.compress(MINIMAL_DATA, {});
|
||||
expect(result.format).toBe("woff2");
|
||||
});
|
||||
});
|
||||
74
packages/media/fonts/font-subset/src/adapters/null/null.ts
Normal file
74
packages/media/fonts/font-subset/src/adapters/null/null.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type {
|
||||
FontFormat,
|
||||
FontMetadata,
|
||||
FontSubsetAdapter,
|
||||
FontSubsetResult,
|
||||
SubsetFontOptions,
|
||||
} from "../../types/common.js";
|
||||
|
||||
const FORMAT_MIMES: Readonly<Record<FontFormat, string>> = {
|
||||
ttf: "font/ttf",
|
||||
otf: "font/otf",
|
||||
woff: "font/woff",
|
||||
woff2: "font/woff2",
|
||||
unknown: "application/octet-stream",
|
||||
};
|
||||
|
||||
/** Options for configuring NullFontSubsetAdapter fixture behaviour. */
|
||||
export interface NullFontSubsetAdapterOptions {
|
||||
probeResult?: FontMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* A no-op FontSubsetAdapter for unit tests.
|
||||
*
|
||||
* `probe` returns configurable fixture metadata derived from the input byte
|
||||
* length, or a fully custom `FontMetadata` supplied at construction time.
|
||||
* `compress` echoes back the input buffer with a synthetic result at a
|
||||
* fixed `ratio` of `0.9`.
|
||||
*/
|
||||
export class NullFontSubsetAdapter implements FontSubsetAdapter {
|
||||
public readonly supportedInputFormats: readonly FontFormat[] = [
|
||||
"ttf",
|
||||
"otf",
|
||||
"woff",
|
||||
"woff2",
|
||||
];
|
||||
|
||||
public readonly supportedOutputFormats: readonly FontFormat[] = [
|
||||
"ttf",
|
||||
"otf",
|
||||
"woff",
|
||||
"woff2",
|
||||
];
|
||||
|
||||
readonly #probeResult: FontMetadata | undefined;
|
||||
|
||||
public constructor(options?: NullFontSubsetAdapterOptions) {
|
||||
this.#probeResult = options?.probeResult;
|
||||
}
|
||||
|
||||
public async probe(data: Uint8Array): Promise<FontMetadata> {
|
||||
if (this.#probeResult) return this.#probeResult;
|
||||
return {
|
||||
format: "ttf",
|
||||
mimeType: "font/ttf",
|
||||
byteSize: data.byteLength,
|
||||
};
|
||||
}
|
||||
|
||||
public async compress(
|
||||
data: Uint8Array,
|
||||
options: SubsetFontOptions
|
||||
): Promise<FontSubsetResult> {
|
||||
const format = options.outputFormat ?? "woff2";
|
||||
return {
|
||||
data,
|
||||
format,
|
||||
mimeType: FORMAT_MIMES[format],
|
||||
originalSize: data.byteLength,
|
||||
compressedSize: data.byteLength,
|
||||
ratio: 0.9,
|
||||
};
|
||||
}
|
||||
}
|
||||
9
packages/media/fonts/font-subset/src/constants/index.ts
Normal file
9
packages/media/fonts/font-subset/src/constants/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const FONT_FORMAT_MIMES = {
|
||||
ttf: "font/ttf",
|
||||
otf: "font/otf",
|
||||
woff: "font/woff",
|
||||
woff2: "font/woff2",
|
||||
unknown: "application/octet-stream",
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_OUTPUT_FORMAT = "woff2" as const;
|
||||
@@ -0,0 +1,103 @@
|
||||
# FontCompressor
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The central orchestrator for font compression operations. `FontCompressor` wraps any `FontCompressAdapter` implementation and adds the cross-cutting concerns that all production uses require: Zod option validation, format support checks, `AbortSignal` propagation, and OTel span/metric instrumentation. Application code constructs one `FontCompressor` per adapter and calls `probe` or `compress` without needing to manage any of that plumbing directly.
|
||||
|
||||
## Features
|
||||
|
||||
- `probe(data)` — delegates to the adapter's probe method and maps adapter errors to `PARSE_FAILED`
|
||||
- `compress(data, options?, signal?)` — validates options with Zod, checks both input and output format support, delegates compress, wraps errors as `ENCODE_FAILED`
|
||||
- `AbortSignal` — checked before compression begins; throws `ENCODE_FAILED` immediately when aborted
|
||||
- Zod schema validation — `CompressFontOptionsSchema.safeParse` on every compress call; throws `INVALID_OPTIONS` on failure
|
||||
- Format support checks — `UNSUPPORTED_FORMAT` when the probed input format or the requested output format is not listed in the adapter's supported lists
|
||||
- OTel span `font.compress` / `font.probe` — status set to OK or ERROR with message
|
||||
- OTel counter `font_compress_total` with `{ format }` attribute
|
||||
- OTel histograms `font_compress_duration_ms` and `font_compress_ratio`
|
||||
- `createFontCompressor` factory for ergonomic construction without `new`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { FontCompressor, NullFontCompressAdapter } from "@suaveplan/font-compress";
|
||||
|
||||
const data = new Uint8Array([0x00, 0x01, 0x00, 0x00, /* TTF bytes */ ]);
|
||||
const adapter = new NullFontCompressAdapter();
|
||||
const compressor = new FontCompressor({ adapter });
|
||||
|
||||
const result = await compressor.compress(data, { outputFormat: "woff2" });
|
||||
console.log(result.format); // "woff2"
|
||||
console.log(result.ratio); // e.g. 0.9
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
createFontCompressor,
|
||||
NullFontCompressAdapter,
|
||||
FONT_COMPRESS_CODES,
|
||||
FontCompressError,
|
||||
} from "@suaveplan/font-compress";
|
||||
import type { LoggerLike, Clock } from "@suaveplan/types";
|
||||
|
||||
declare const logger: LoggerLike;
|
||||
declare const clock: Clock;
|
||||
declare const realAdapter: import("@suaveplan/font-compress").FontCompressAdapter;
|
||||
|
||||
const compressor = createFontCompressor({ adapter: realAdapter, logger, clock });
|
||||
|
||||
// Abort long-running compression
|
||||
const ctrl = new AbortController();
|
||||
setTimeout(() => ctrl.abort(), 2000);
|
||||
|
||||
try {
|
||||
const result = await compressor.compress(fontBytes, {
|
||||
subset: { unicodeRanges: ["U+0020-007F"] },
|
||||
outputFormat: "woff2",
|
||||
stripHinting: true,
|
||||
}, ctrl.signal);
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (e instanceof FontCompressError) {
|
||||
if (e.code === FONT_COMPRESS_CODES.ENCODE_FAILED) {
|
||||
// aborted or codec failure
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new FontCompressor(options: FontCompressorOptions)`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `adapter` | `FontCompressAdapter` | ✓ | Codec implementation |
|
||||
| `logger` | `LoggerLike` | — | Structured logger |
|
||||
| `clock` | `Clock` | — | Injectable clock |
|
||||
|
||||
### `probe(data: Uint8Array): Promise<FontMetadata>`
|
||||
|
||||
Delegates to `adapter.probe`. Wraps non-`FontCompressError` throws as `PARSE_FAILED`.
|
||||
|
||||
### `compress(data, options?, signal?): Promise<FontCompressResult>`
|
||||
|
||||
1. Checks `signal.aborted` → throws `ENCODE_FAILED`
|
||||
2. Validates `options` via `CompressFontOptionsSchema` → throws `INVALID_OPTIONS`
|
||||
3. Calls `probe` to determine input format → throws `UNSUPPORTED_FORMAT` if not in adapter list
|
||||
4. Checks output format support → throws `UNSUPPORTED_FORMAT`
|
||||
5. Calls `adapter.compress` → wraps errors as `ENCODE_FAILED`
|
||||
6. Records OTel metrics and closes span
|
||||
|
||||
### `createFontCompressor(options): FontCompressor`
|
||||
|
||||
Factory convenience function identical to `new FontCompressor(options)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Options default values (`stripHinting: true`, `stripMetadata: false`) are applied by Zod's `.default()` inside `CompressFontOptionsSchema.safeParse`, so the adapter always receives a fully-populated options object. The OTel meter and tracer are obtained via the global provider; if no SDK is configured the NoopMeter/NoopTracer are used automatically, incurring no overhead.
|
||||
@@ -0,0 +1,281 @@
|
||||
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
|
||||
import { NullFontSubsetAdapter } from "../../adapters/null/null.js";
|
||||
import {
|
||||
FONT_SUBSET_CODES,
|
||||
FontSubsetError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { createFontSubsetter, FontSubsetter } from "./font-compressor.js";
|
||||
|
||||
const TTF_DATA = new Uint8Array([
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
...new Array(96).fill(0),
|
||||
]);
|
||||
|
||||
describe("FontSubsetter", () => {
|
||||
it("probe delegates to adapter and returns metadata", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new NullFontSubsetAdapter();
|
||||
const compressor = new FontSubsetter({ adapter });
|
||||
const meta = await compressor.probe(TTF_DATA);
|
||||
expect(meta.format).toBe("ttf");
|
||||
expect(meta.byteSize).toBe(TTF_DATA.byteLength);
|
||||
});
|
||||
|
||||
it("compress delegates to adapter and returns result", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new NullFontSubsetAdapter();
|
||||
const compressor = new FontSubsetter({ adapter });
|
||||
const result = await compressor.compress(TTF_DATA);
|
||||
expect(result.format).toBe("woff2");
|
||||
expect(result.data).toBe(TTF_DATA);
|
||||
expect(result.ratio).toBeCloseTo(0.9, 5);
|
||||
});
|
||||
|
||||
it("compress passes options to adapter", { timeout: 5000 }, async () => {
|
||||
const adapter = new NullFontSubsetAdapter();
|
||||
const compressor = new FontSubsetter({ adapter });
|
||||
const result = await compressor.compress(TTF_DATA, {
|
||||
outputFormat: "woff",
|
||||
});
|
||||
expect(result.format).toBe("woff");
|
||||
});
|
||||
|
||||
it("compress uses default options when none supplied", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new NullFontSubsetAdapter();
|
||||
const compressor = new FontSubsetter({ adapter });
|
||||
const result = await compressor.compress(TTF_DATA, undefined);
|
||||
expect(result.format).toBe("woff2");
|
||||
});
|
||||
|
||||
it("throws UNSUPPORTED_FORMAT when input format not supported", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new NullFontSubsetAdapter({
|
||||
probeResult: {
|
||||
format: "unknown",
|
||||
mimeType: "application/octet-stream",
|
||||
byteSize: 10,
|
||||
},
|
||||
});
|
||||
const compressor = new FontSubsetter({ adapter });
|
||||
await expect(compressor.compress(TTF_DATA)).rejects.toThrow(
|
||||
FontSubsetError
|
||||
);
|
||||
try {
|
||||
await compressor.compress(TTF_DATA);
|
||||
} catch (e) {
|
||||
expect((e as FontSubsetError).code).toBe(
|
||||
FONT_SUBSET_CODES.UNSUPPORTED_FORMAT
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws UNSUPPORTED_FORMAT when output format not supported", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const limitedAdapter = new NullFontSubsetAdapter();
|
||||
Object.defineProperty(limitedAdapter, "supportedOutputFormats", {
|
||||
value: ["ttf"],
|
||||
});
|
||||
const compressor = new FontSubsetter({ adapter: limitedAdapter });
|
||||
await expect(
|
||||
compressor.compress(TTF_DATA, { outputFormat: "woff2" })
|
||||
).rejects.toThrow(FontSubsetError);
|
||||
try {
|
||||
await compressor.compress(TTF_DATA, { outputFormat: "woff2" });
|
||||
} catch (e) {
|
||||
expect((e as FontSubsetError).code).toBe(
|
||||
FONT_SUBSET_CODES.UNSUPPORTED_FORMAT
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws INVALID_OPTIONS for invalid Zod schema", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new NullFontSubsetAdapter();
|
||||
const compressor = new FontSubsetter({ adapter });
|
||||
const badOptions = { outputFormat: "bmp" } as never;
|
||||
await expect(compressor.compress(TTF_DATA, badOptions)).rejects.toThrow(
|
||||
FontSubsetError
|
||||
);
|
||||
try {
|
||||
await compressor.compress(TTF_DATA, badOptions);
|
||||
} catch (e) {
|
||||
expect((e as FontSubsetError).code).toBe(
|
||||
FONT_SUBSET_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws ENCODE_FAILED on abort", { timeout: 5000 }, async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const adapter = new NullFontSubsetAdapter();
|
||||
const compressor = new FontSubsetter({ adapter });
|
||||
await expect(
|
||||
compressor.compress(TTF_DATA, undefined, controller.signal)
|
||||
).rejects.toThrow(FontSubsetError);
|
||||
try {
|
||||
await compressor.compress(TTF_DATA, undefined, controller.signal);
|
||||
} catch (e) {
|
||||
expect((e as FontSubsetError).code).toBe(
|
||||
FONT_SUBSET_CODES.ENCODE_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps adapter probe errors as PARSE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const failAdapter = {
|
||||
...new NullFontSubsetAdapter(),
|
||||
probe: async () => {
|
||||
throw new Error("probe error");
|
||||
},
|
||||
};
|
||||
const compressor = new FontSubsetter({ adapter: failAdapter });
|
||||
await expect(compressor.probe(TTF_DATA)).rejects.toThrow(
|
||||
FontSubsetError
|
||||
);
|
||||
try {
|
||||
await compressor.probe(TTF_DATA);
|
||||
} catch (e) {
|
||||
expect((e as FontSubsetError).code).toBe(
|
||||
FONT_SUBSET_CODES.PARSE_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("re-throws FontSubsetError from probe unchanged", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const originalErr = new FontSubsetError(
|
||||
FONT_SUBSET_CODES.PARSE_FAILED,
|
||||
"already font error"
|
||||
);
|
||||
const failAdapter = {
|
||||
...new NullFontSubsetAdapter(),
|
||||
probe: async () => {
|
||||
throw originalErr;
|
||||
},
|
||||
};
|
||||
const compressor = new FontSubsetter({ adapter: failAdapter });
|
||||
try {
|
||||
await compressor.probe(TTF_DATA);
|
||||
} catch (e) {
|
||||
expect(e).toBe(originalErr);
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps non-Error probe throw as PARSE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const failAdapter = {
|
||||
...new NullFontSubsetAdapter(),
|
||||
probe: async () => {
|
||||
throw "string probe error";
|
||||
},
|
||||
};
|
||||
const compressor = new FontSubsetter({ adapter: failAdapter });
|
||||
try {
|
||||
await compressor.probe(TTF_DATA);
|
||||
} catch (e) {
|
||||
expect((e as FontSubsetError).code).toBe(
|
||||
FONT_SUBSET_CODES.PARSE_FAILED
|
||||
);
|
||||
expect((e as FontSubsetError).message).toContain(
|
||||
"string probe error"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps adapter compress errors as ENCODE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const failAdapter = {
|
||||
...new NullFontSubsetAdapter(),
|
||||
probe: () =>
|
||||
Promise.resolve({
|
||||
format: "ttf" as const,
|
||||
mimeType: "font/ttf",
|
||||
byteSize: TTF_DATA.byteLength,
|
||||
}),
|
||||
compress: async () => {
|
||||
throw new Error("compress failed");
|
||||
},
|
||||
};
|
||||
const compressor = new FontSubsetter({ adapter: failAdapter });
|
||||
await expect(compressor.compress(TTF_DATA)).rejects.toThrow(
|
||||
FontSubsetError
|
||||
);
|
||||
try {
|
||||
await compressor.compress(TTF_DATA);
|
||||
} catch (e) {
|
||||
expect((e as FontSubsetError).code).toBe(
|
||||
FONT_SUBSET_CODES.ENCODE_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("wraps non-Error compress throw as ENCODE_FAILED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const failAdapter = {
|
||||
...new NullFontSubsetAdapter(),
|
||||
probe: () =>
|
||||
Promise.resolve({
|
||||
format: "ttf" as const,
|
||||
mimeType: "font/ttf",
|
||||
byteSize: TTF_DATA.byteLength,
|
||||
}),
|
||||
compress: async () => {
|
||||
throw "string compress error";
|
||||
},
|
||||
};
|
||||
const compressor = new FontSubsetter({ adapter: failAdapter });
|
||||
try {
|
||||
await compressor.compress(TTF_DATA);
|
||||
} catch (e) {
|
||||
expect((e as FontSubsetError).code).toBe(
|
||||
FONT_SUBSET_CODES.ENCODE_FAILED
|
||||
);
|
||||
expect((e as FontSubsetError).message).toContain(
|
||||
"string compress error"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createFontSubsetter", () => {
|
||||
it("creates a FontSubsetter instance", { timeout: 5000 }, () => {
|
||||
const adapter = new NullFontSubsetAdapter();
|
||||
const compressor = createFontSubsetter({ adapter });
|
||||
expect(compressor).toBeInstanceOf(FontSubsetter);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FontSubsetter OTel", () => {
|
||||
it("accepts logger and clock without throwing", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const adapter = new NullFontSubsetAdapter();
|
||||
const logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const clock = { now: vi.fn().mockReturnValue(Date.now()) };
|
||||
const compressor = new FontSubsetter({ adapter, logger, clock });
|
||||
const result = await compressor.compress(TTF_DATA);
|
||||
expect(result.format).toBe("woff2");
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user