Initial Commit
This commit is contained in:
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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user