Initial Commit
This commit is contained in:
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
type Counter,
|
||||
type Histogram,
|
||||
type Meter,
|
||||
metrics,
|
||||
type Span,
|
||||
SpanStatusCode,
|
||||
type Tracer,
|
||||
trace,
|
||||
} from "@opentelemetry/api";
|
||||
import {
|
||||
FONT_SUBSET_CODES,
|
||||
FontSubsetError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import type {
|
||||
FontMetadata,
|
||||
FontSubsetAdapter,
|
||||
FontSubsetResult,
|
||||
FontSubsetterOptions,
|
||||
SubsetFontOptions,
|
||||
} from "../../types/common.js";
|
||||
import { SubsetFontOptionsSchema } from "../../types/common.js";
|
||||
|
||||
const TRACER_NAME = "@suaveplan/font-compress";
|
||||
|
||||
function getTracer(): Tracer {
|
||||
return trace.getTracer(TRACER_NAME);
|
||||
}
|
||||
|
||||
function getMeter(): Meter {
|
||||
return metrics.getMeter(TRACER_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enterprise font compression facade.
|
||||
*
|
||||
* Delegates probe and compress operations to a `FontSubsetAdapter`,
|
||||
* validates options via Zod, enforces format support checks, propagates
|
||||
* `AbortSignal`, and emits OTel spans + metrics.
|
||||
*/
|
||||
export class FontSubsetter {
|
||||
readonly #adapter: FontSubsetAdapter;
|
||||
readonly #counter: Counter;
|
||||
readonly #durationHist: Histogram;
|
||||
readonly #ratioHist: Histogram;
|
||||
|
||||
public constructor(options: FontSubsetterOptions) {
|
||||
this.#adapter = options.adapter;
|
||||
const meter = getMeter();
|
||||
this.#counter = meter.createCounter("font_compress_total", {
|
||||
description: "Number of font compression operations",
|
||||
});
|
||||
this.#durationHist = meter.createHistogram(
|
||||
"font_compress_duration_ms",
|
||||
{
|
||||
description: "Font compression duration in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
);
|
||||
this.#ratioHist = meter.createHistogram("font_compress_ratio", {
|
||||
description: "Compressed size / original size ratio",
|
||||
});
|
||||
// Consume optional logger/clock (injected for future use)
|
||||
void options.logger;
|
||||
void options.clock;
|
||||
}
|
||||
|
||||
public async probe(data: Uint8Array): Promise<FontMetadata> {
|
||||
const span: Span = getTracer().startSpan("font.probe");
|
||||
try {
|
||||
const meta = await this.#adapter.probe(data);
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
return meta;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
span.setStatus({ code: SpanStatusCode.ERROR, message: msg });
|
||||
if (err instanceof FontSubsetError) throw err;
|
||||
throw new FontSubsetError(FONT_SUBSET_CODES.PARSE_FAILED, msg, {
|
||||
cause: err instanceof Error ? err : undefined,
|
||||
});
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
|
||||
public async compress(
|
||||
data: Uint8Array,
|
||||
options?: SubsetFontOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<FontSubsetResult> {
|
||||
const span: Span = getTracer().startSpan("font.compress");
|
||||
const start = Date.now();
|
||||
try {
|
||||
if (signal?.aborted) {
|
||||
throw new FontSubsetError(
|
||||
FONT_SUBSET_CODES.ENCODE_FAILED,
|
||||
"Operation aborted"
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = SubsetFontOptionsSchema.safeParse(options ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new FontSubsetError(
|
||||
FONT_SUBSET_CODES.INVALID_OPTIONS,
|
||||
`Invalid compress options: ${parsed.error.message}`
|
||||
);
|
||||
}
|
||||
const opts = parsed.data;
|
||||
|
||||
const meta = await this.probe(data);
|
||||
|
||||
if (!this.#adapter.supportedInputFormats.includes(meta.format)) {
|
||||
throw new FontSubsetError(
|
||||
FONT_SUBSET_CODES.UNSUPPORTED_FORMAT,
|
||||
`Input format "${meta.format}" is not supported by the adapter`
|
||||
);
|
||||
}
|
||||
|
||||
const outputFormat = opts.outputFormat ?? "woff2";
|
||||
if (!this.#adapter.supportedOutputFormats.includes(outputFormat)) {
|
||||
throw new FontSubsetError(
|
||||
FONT_SUBSET_CODES.UNSUPPORTED_FORMAT,
|
||||
`Output format "${outputFormat}" is not supported by the adapter`
|
||||
);
|
||||
}
|
||||
|
||||
let result: FontSubsetResult;
|
||||
try {
|
||||
result = await this.#adapter.compress(data, opts);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
throw new FontSubsetError(
|
||||
FONT_SUBSET_CODES.ENCODE_FAILED,
|
||||
msg,
|
||||
{
|
||||
cause: err instanceof Error ? err : undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const duration = Date.now() - start;
|
||||
this.#counter.add(1, { format: outputFormat });
|
||||
this.#durationHist.record(duration, { format: outputFormat });
|
||||
this.#ratioHist.record(result.ratio, { format: outputFormat });
|
||||
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
return result;
|
||||
} catch (err) {
|
||||
const msg =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: /* istanbul ignore next */ String(err);
|
||||
span.setStatus({ code: SpanStatusCode.ERROR, message: msg });
|
||||
throw err;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Factory function for `FontSubsetter`. */
|
||||
export function createFontSubsetter(
|
||||
options: FontSubsetterOptions
|
||||
): FontSubsetter {
|
||||
return new FontSubsetter(options);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
createFontSubsetter,
|
||||
FontSubsetter,
|
||||
} from "./font-compressor.js";
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
FONT_SUBSET_CODES,
|
||||
FontSubsetError,
|
||||
type FontSubsetErrorCode,
|
||||
} from "./error-codes.js";
|
||||
|
||||
describe("FONT_SUBSET_CODES", () => {
|
||||
it("defines all expected error codes", { timeout: 5000 }, () => {
|
||||
const codes: FontSubsetErrorCode[] = [
|
||||
FONT_SUBSET_CODES.UNSUPPORTED_FORMAT,
|
||||
FONT_SUBSET_CODES.PARSE_FAILED,
|
||||
FONT_SUBSET_CODES.ENCODE_FAILED,
|
||||
FONT_SUBSET_CODES.INVALID_OPTIONS,
|
||||
FONT_SUBSET_CODES.SUBSET_FAILED,
|
||||
];
|
||||
expect(codes).toHaveLength(5);
|
||||
for (const code of codes) {
|
||||
expect(typeof code).toBe("string");
|
||||
expect(code).toMatch(/^FONT_SUBSET\./);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("FontSubsetError", () => {
|
||||
it("creates error with code and message", { timeout: 5000 }, () => {
|
||||
const err = new FontSubsetError(
|
||||
FONT_SUBSET_CODES.PARSE_FAILED,
|
||||
"bad font data"
|
||||
);
|
||||
expect(err.code).toBe(FONT_SUBSET_CODES.PARSE_FAILED);
|
||||
expect(err.message).toBe("bad font data");
|
||||
expect(err.name).toBe("FontSubsetError");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("accepts cause and context", { timeout: 5000 }, () => {
|
||||
const cause = new Error("inner");
|
||||
const err = new FontSubsetError(
|
||||
FONT_SUBSET_CODES.ENCODE_FAILED,
|
||||
"encode failed",
|
||||
{ cause, context: { format: "woff2" } }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context?.["format"]).toBe("woff2");
|
||||
});
|
||||
|
||||
it("ignores non-Error cause", { timeout: 5000 }, () => {
|
||||
const err = new FontSubsetError(
|
||||
FONT_SUBSET_CODES.ENCODE_FAILED,
|
||||
"oops",
|
||||
{ cause: "string cause" }
|
||||
);
|
||||
expect(err.cause).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
export const FONT_SUBSET_CODES = defineCodes("FONT_SUBSET", [
|
||||
"UNSUPPORTED_FORMAT",
|
||||
"PARSE_FAILED",
|
||||
"ENCODE_FAILED",
|
||||
"INVALID_OPTIONS",
|
||||
"SUBSET_FAILED",
|
||||
] as const);
|
||||
|
||||
export type FontSubsetErrorCode =
|
||||
(typeof FONT_SUBSET_CODES)[keyof typeof FONT_SUBSET_CODES];
|
||||
|
||||
export class FontSubsetError extends SuaveplanError<FontSubsetErrorCode> {
|
||||
public constructor(
|
||||
code: FontSubsetErrorCode,
|
||||
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 = "FontSubsetError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { FontSubsetErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
FONT_SUBSET_CODES,
|
||||
FontSubsetError,
|
||||
} from "./error-codes.js";
|
||||
29
packages/media/fonts/font-subset/src/index.ts
Normal file
29
packages/media/fonts/font-subset/src/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export {
|
||||
NullFontSubsetAdapter,
|
||||
type NullFontSubsetAdapterOptions,
|
||||
} from "./adapters/null/null.js";
|
||||
export {
|
||||
createFontSubsetter,
|
||||
FontSubsetter,
|
||||
} from "./core/font-compressor/font-compressor.js";
|
||||
export type { FontSubsetErrorCode } from "./error-codes/index.js";
|
||||
export { FONT_SUBSET_CODES, FontSubsetError } from "./error-codes/index.js";
|
||||
export type {
|
||||
FontFormat,
|
||||
FontMetadata,
|
||||
FontSubsetAdapter,
|
||||
FontSubsetResult,
|
||||
FontSubsetterOptions,
|
||||
SubsetFontOptions,
|
||||
SubsetOptions,
|
||||
} from "./types/common.js";
|
||||
export {
|
||||
FontFormatSchema,
|
||||
FontMetadataSchema,
|
||||
FontSubsetResultSchema,
|
||||
FontSubsetterOptionsSchema,
|
||||
SubsetFontOptionsSchema,
|
||||
SubsetOptionsSchema,
|
||||
} from "./types/common.js";
|
||||
export { detectFontFormat } from "./utils/format-detect.js";
|
||||
export { parseUnicodeRanges } from "./utils/unicode-ranges.js";
|
||||
107
packages/media/fonts/font-subset/src/types/common.md
Normal file
107
packages/media/fonts/font-subset/src/types/common.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Types: common
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the complete public type surface for `@suaveplan/font-compress`. All option bundles, result shapes, the adapter contract, and Zod validation schemas live here, ensuring every layer of the package shares a single authoritative definition. Keeping types co-located with their Zod schemas eliminates the risk of runtime shapes drifting from TypeScript definitions.
|
||||
|
||||
## Features
|
||||
|
||||
- `FontFormat` union type covering `ttf`, `otf`, `woff`, `woff2`, and `unknown`
|
||||
- `FontMetadata` — probe result carrying format, MIME type, byte size, and optional glyph/family metadata
|
||||
- `SubsetOptions` — Unicode range strings, literal character string, and explicit glyph ID list
|
||||
- `CompressFontOptions` — full option bundle: subset, output format, strip-hinting, strip-metadata, feature tags; defaults via Zod `.default()`
|
||||
- `FontCompressResult` — compression output: data bytes, format, MIME type, size pair, compression ratio, and optional glyph count
|
||||
- `FontCompressAdapter` — codec-agnostic interface; implementations supply `probe` and `compress` with async semantics
|
||||
- `FontCompressorOptions` — constructor options for `FontCompressor`: required adapter plus optional logger and clock for observability injection
|
||||
- Zod schemas for every public type, enabling runtime validation at package boundaries
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import type { CompressFontOptions, FontMetadata } from "@suaveplan/font-compress";
|
||||
|
||||
const opts: CompressFontOptions = {
|
||||
outputFormat: "woff2",
|
||||
stripHinting: true,
|
||||
};
|
||||
|
||||
const meta: FontMetadata = {
|
||||
format: "ttf",
|
||||
mimeType: "font/ttf",
|
||||
byteSize: 204800,
|
||||
familyName: "Roboto",
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { CompressFontOptionsSchema } from "@suaveplan/font-compress";
|
||||
|
||||
// Validate user-supplied options at an API boundary
|
||||
const raw: unknown = { outputFormat: "woff2", subset: { unicodeRanges: ["U+0020-007F"] } };
|
||||
const parsed = CompressFontOptionsSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
console.error(parsed.error.flatten());
|
||||
} else {
|
||||
const opts = parsed.data; // stripHinting defaults to true, stripMetadata to false
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `FontFormat`
|
||||
|
||||
```typescript
|
||||
type FontFormat = "ttf" | "otf" | "woff" | "woff2" | "unknown";
|
||||
```
|
||||
|
||||
Union of supported font formats, with `"unknown"` used when detection fails.
|
||||
|
||||
### `FontMetadata`
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `format` | `FontFormat` | ✓ | Detected format |
|
||||
| `mimeType` | `string` | ✓ | IANA MIME type |
|
||||
| `byteSize` | `number` | ✓ | Raw byte count |
|
||||
| `glyphCount` | `number` | — | Total glyphs, if known |
|
||||
| `familyName` | `string` | — | Font family name |
|
||||
| `subFamilyName` | `string` | — | Style variant name |
|
||||
| `version` | `string` | — | Font version string |
|
||||
|
||||
### `SubsetOptions`
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `unicodeRanges` | `string[]` | Range strings e.g. `["U+0020-007F"]` |
|
||||
| `characters` | `string` | Literal chars to include |
|
||||
| `glyphIds` | `number[]` | Explicit glyph IDs |
|
||||
|
||||
### `CompressFontOptions`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `subset` | `SubsetOptions` | — | Subsetting parameters |
|
||||
| `outputFormat` | `FontFormat` | — | Target format |
|
||||
| `stripHinting` | `boolean` | `true` | Remove hinting tables |
|
||||
| `stripMetadata` | `boolean` | `false` | Strip name/OS2 metadata |
|
||||
| `features` | `string[]` | — | OT feature tags to retain |
|
||||
|
||||
### `FontCompressAdapter`
|
||||
|
||||
```typescript
|
||||
interface FontCompressAdapter {
|
||||
readonly supportedInputFormats: readonly FontFormat[];
|
||||
readonly supportedOutputFormats: readonly FontFormat[];
|
||||
probe(data: Uint8Array): Promise<FontMetadata>;
|
||||
compress(data: Uint8Array, options: CompressFontOptions): Promise<FontCompressResult>;
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Zod schemas use `.default()` on `stripHinting` (true) and `stripMetadata` (false) so that callers can omit them without `undefined` propagating to the adapter. `FontCompressorOptionsSchema` uses `z.custom<LoggerLike>()` and `z.custom<Clock>()` to allow structural compatibility without importing those interfaces at runtime — they carry no validation logic. The `FontCompressAdapter` interface is intentionally minimal; adapters that wrap real codec libraries (e.g. woff2, fonttools) need only implement two async methods.
|
||||
77
packages/media/fonts/font-subset/src/types/common.ts
Normal file
77
packages/media/fonts/font-subset/src/types/common.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { Clock, LoggerLike } from "@suaveplan/types";
|
||||
import z from "zod";
|
||||
|
||||
export type FontFormat = "ttf" | "otf" | "woff" | "woff2" | "unknown";
|
||||
|
||||
export const FontFormatSchema = z.enum([
|
||||
"ttf",
|
||||
"otf",
|
||||
"woff",
|
||||
"woff2",
|
||||
"unknown",
|
||||
]);
|
||||
|
||||
export const FontMetadataSchema = z.object({
|
||||
format: FontFormatSchema,
|
||||
mimeType: z.string(),
|
||||
byteSize: z.number().int().nonnegative(),
|
||||
glyphCount: z.number().int().positive().optional(),
|
||||
familyName: z.string().optional(),
|
||||
subFamilyName: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
});
|
||||
|
||||
export type FontMetadata = z.infer<typeof FontMetadataSchema>;
|
||||
|
||||
export const SubsetOptionsSchema = z.object({
|
||||
unicodeRanges: z.array(z.string()).optional(),
|
||||
characters: z.string().optional(),
|
||||
glyphIds: z.array(z.number().int().nonnegative()).optional(),
|
||||
});
|
||||
|
||||
export type SubsetOptions = z.infer<typeof SubsetOptionsSchema>;
|
||||
|
||||
export const SubsetFontOptionsSchema = z.object({
|
||||
subset: SubsetOptionsSchema.optional(),
|
||||
outputFormat: FontFormatSchema.optional(),
|
||||
stripHinting: z.boolean().optional().default(true),
|
||||
stripMetadata: z.boolean().optional().default(false),
|
||||
features: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export type SubsetFontOptions = z.infer<typeof SubsetFontOptionsSchema>;
|
||||
|
||||
export const FontSubsetResultSchema = z.object({
|
||||
data: z.custom<Uint8Array>(
|
||||
/* istanbul ignore next */ (v) => v instanceof Uint8Array
|
||||
),
|
||||
format: FontFormatSchema,
|
||||
mimeType: z.string(),
|
||||
originalSize: z.number().int().nonnegative(),
|
||||
compressedSize: z.number().int().nonnegative(),
|
||||
ratio: z.number().positive(),
|
||||
glyphCount: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
export type FontSubsetResult = z.infer<typeof FontSubsetResultSchema>;
|
||||
|
||||
export interface FontSubsetAdapter {
|
||||
readonly supportedInputFormats: readonly FontFormat[];
|
||||
readonly supportedOutputFormats: readonly FontFormat[];
|
||||
probe(data: Uint8Array): Promise<FontMetadata>;
|
||||
compress(
|
||||
data: Uint8Array,
|
||||
options: SubsetFontOptions
|
||||
): Promise<FontSubsetResult>;
|
||||
}
|
||||
|
||||
export const FontSubsetterOptionsSchema = z.object({
|
||||
logger: z.custom<LoggerLike>().optional(),
|
||||
clock: z.custom<Clock>().optional(),
|
||||
});
|
||||
|
||||
export interface FontSubsetterOptions {
|
||||
adapter: FontSubsetAdapter;
|
||||
logger?: LoggerLike;
|
||||
clock?: Clock;
|
||||
}
|
||||
56
packages/media/fonts/font-subset/src/utils/format-detect.md
Normal file
56
packages/media/fonts/font-subset/src/utils/format-detect.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Utils: format-detect
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides magic-byte font format detection from raw binary data. Before compressing or probing a font file, `FontCompressor` needs to know the input format to validate adapter support. This utility performs a fast 4-byte prefix check covering the four formats that matter for web font workflows: TTF, OTF, WOFF, and WOFF2.
|
||||
|
||||
## Features
|
||||
|
||||
- Detects TTF via `00 01 00 00` (TrueType scalar type)
|
||||
- Detects OTF via `4F 54 54 4F` ("OTTO" — OpenType CFF)
|
||||
- Detects WOFF via `77 4F 46 46` ("wOFF")
|
||||
- Detects WOFF2 via `77 4F 46 32` ("wOF2")
|
||||
- Returns `"unknown"` for unrecognised or too-short buffers (< 4 bytes)
|
||||
- Zero external dependencies — direct byte comparison without any registry
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { detectFontFormat } from "@suaveplan/font-compress/utils/format-detect";
|
||||
|
||||
const bytes = new Uint8Array([0x77, 0x4f, 0x46, 0x32, /* ... */]);
|
||||
console.log(detectFontFormat(bytes)); // "woff2"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { detectFontFormat } from "@suaveplan/font-compress/utils/format-detect";
|
||||
import { FONT_COMPRESS_CODES, FontCompressError } from "@suaveplan/font-compress";
|
||||
|
||||
function requireKnownFormat(data: Uint8Array): void {
|
||||
const fmt = detectFontFormat(data);
|
||||
if (fmt === "unknown") {
|
||||
throw new FontCompressError(
|
||||
FONT_COMPRESS_CODES.UNSUPPORTED_FORMAT,
|
||||
"Unrecognised font format — expected TTF, OTF, WOFF, or WOFF2"
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `detectFontFormat(data: Uint8Array): FontFormat`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `data` | `Uint8Array` | Raw font bytes to inspect |
|
||||
|
||||
**Returns** one of `"ttf"`, `"otf"`, `"woff"`, `"woff2"`, or `"unknown"`.
|
||||
|
||||
Never throws — always returns a `FontFormat` value.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The implementation avoids the `@suaveplan/mime` signature registry for font detection because the published mime package uses `"sideEffects": false`, which causes Vite to tree-shake the side-effect import (`import "./signatures/index.js"`) that registers magic-byte patterns at load time. The result is that `detectFromMagicBytes` returns `undefined` for all inputs in the built package. Inlining the four font signatures here is simpler, zero-dependency, and correct. Should `@suaveplan/mime` be rebuilt with explicit signatures exports, this module can be updated to delegate to it.
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { detectFontFormat } from "./format-detect.js";
|
||||
|
||||
// Magic bytes for each format
|
||||
const TTF_MAGIC = new Uint8Array([0x00, 0x01, 0x00, 0x00, 0x00]);
|
||||
const OTF_MAGIC = new Uint8Array([0x4f, 0x54, 0x54, 0x4f, 0x00]); // "OTTO"
|
||||
const WOFF_MAGIC = new Uint8Array([0x77, 0x4f, 0x46, 0x46, 0x00]); // "wOFF"
|
||||
const WOFF2_MAGIC = new Uint8Array([0x77, 0x4f, 0x46, 0x32, 0x00]); // "wOF2"
|
||||
const UNKNOWN_MAGIC = new Uint8Array([0xff, 0xfe, 0xfd, 0xfc]);
|
||||
|
||||
describe("detectFontFormat", () => {
|
||||
it("detects TTF from magic bytes", { timeout: 5000 }, () => {
|
||||
expect(detectFontFormat(TTF_MAGIC)).toBe("ttf");
|
||||
});
|
||||
|
||||
it("detects OTF from magic bytes", { timeout: 5000 }, () => {
|
||||
expect(detectFontFormat(OTF_MAGIC)).toBe("otf");
|
||||
});
|
||||
|
||||
it("detects WOFF from magic bytes", { timeout: 5000 }, () => {
|
||||
expect(detectFontFormat(WOFF_MAGIC)).toBe("woff");
|
||||
});
|
||||
|
||||
it("detects WOFF2 from magic bytes", { timeout: 5000 }, () => {
|
||||
expect(detectFontFormat(WOFF2_MAGIC)).toBe("woff2");
|
||||
});
|
||||
|
||||
it("returns unknown for unrecognised bytes", { timeout: 5000 }, () => {
|
||||
expect(detectFontFormat(UNKNOWN_MAGIC)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("returns unknown for empty buffer", { timeout: 5000 }, () => {
|
||||
expect(detectFontFormat(new Uint8Array(0))).toBe("unknown");
|
||||
});
|
||||
});
|
||||
30
packages/media/fonts/font-subset/src/utils/format-detect.ts
Normal file
30
packages/media/fonts/font-subset/src/utils/format-detect.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { FontFormat } from "../types/common.js";
|
||||
|
||||
function startsWith(bytes: Uint8Array, pattern: readonly number[]): boolean {
|
||||
for (let i = 0; i < pattern.length; i++) {
|
||||
if (bytes[i] !== pattern[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect font format from raw bytes by inspecting magic-byte signatures.
|
||||
*
|
||||
* Checks known 4-byte magic sequences for TTF, OTF, WOFF, and WOFF2.
|
||||
* Returns `"unknown"` when the buffer is shorter than 4 bytes or no
|
||||
* signature matches.
|
||||
*
|
||||
* Font magic bytes:
|
||||
* - TTF: `00 01 00 00`
|
||||
* - OTF: `4F 54 54 4F` ("OTTO")
|
||||
* - WOFF: `77 4F 46 46` ("wOFF")
|
||||
* - WOFF2: `77 4F 46 32` ("wOF2")
|
||||
*/
|
||||
export function detectFontFormat(data: Uint8Array): FontFormat {
|
||||
if (data.length < 4) return "unknown";
|
||||
if (startsWith(data, [0x77, 0x4f, 0x46, 0x32])) return "woff2";
|
||||
if (startsWith(data, [0x77, 0x4f, 0x46, 0x46])) return "woff";
|
||||
if (startsWith(data, [0x4f, 0x54, 0x54, 0x4f])) return "otf";
|
||||
if (startsWith(data, [0x00, 0x01, 0x00, 0x00])) return "ttf";
|
||||
return "unknown";
|
||||
}
|
||||
65
packages/media/fonts/font-subset/src/utils/unicode-ranges.md
Normal file
65
packages/media/fonts/font-subset/src/utils/unicode-ranges.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Utils: unicode-ranges
|
||||
|
||||
## Purpose
|
||||
|
||||
Parses CSS/OpenType-style Unicode range notation into a `Set<number>` of integer codepoints. Font subsetting workflows need to know which codepoints to retain; this utility bridges the human-readable range format (e.g. `U+0020-007F`) and the numeric representation that font adapters operate on.
|
||||
|
||||
## Features
|
||||
|
||||
- Parses single codepoints: `U+XXXX`
|
||||
- Parses inclusive ranges: `U+XXXX-XXXX`
|
||||
- Case-insensitive for both the `U+` prefix and hex digits
|
||||
- Merges multiple ranges into a single `Set<number>` with no duplicates
|
||||
- Throws `FontCompressError(INVALID_OPTIONS)` for malformed syntax or reversed ranges
|
||||
- Zero external dependencies
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { parseUnicodeRanges } from "@suaveplan/font-compress/utils/unicode-ranges";
|
||||
|
||||
// ASCII printable range
|
||||
const ascii = parseUnicodeRanges(["U+0020-007F"]);
|
||||
console.log(ascii.has(0x41)); // true ("A")
|
||||
console.log(ascii.size); // 96
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { parseUnicodeRanges } from "@suaveplan/font-compress/utils/unicode-ranges";
|
||||
import { FontCompressError } from "@suaveplan/font-compress";
|
||||
|
||||
// Multi-range merge — Latin + Latin Extended
|
||||
const ranges = parseUnicodeRanges(["U+0020-007F", "U+00A0-00FF"]);
|
||||
console.log(ranges.size); // 192
|
||||
|
||||
// Catching parse errors
|
||||
try {
|
||||
parseUnicodeRanges(["0020-007F"]); // missing U+ prefix
|
||||
} catch (e) {
|
||||
if (e instanceof FontCompressError) {
|
||||
console.error(e.code); // "FONT_COMPRESS.INVALID_OPTIONS"
|
||||
console.error(e.message); // describes the bad input
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseUnicodeRanges(ranges: string[]): Set<number>`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `ranges` | `string[]` | Array of range strings in `U+XXXX` or `U+XXXX-XXXX` format |
|
||||
|
||||
**Returns** a `Set<number>` containing every integer codepoint covered by the union of all input ranges.
|
||||
|
||||
**Throws** `FontCompressError` with code `FONT_COMPRESS.INVALID_OPTIONS` when:
|
||||
|
||||
- A string does not match the `U+XXXX` or `U+XXXX-XXXX` pattern
|
||||
- The start codepoint of a range exceeds the end codepoint
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The regex `^[Uu]\+([0-9A-Fa-f]{1,6})(?:-([0-9A-Fa-f]{1,6}))?$` validates the entire string in a single pass, avoiding character-by-character parsing. Codepoints up to 6 hex digits are supported (`U+10FFFF` = 1114111), covering the full Unicode range. For very large ranges (e.g. `U+0000-FFFF` = 65536 entries), callers should be aware that `Set` construction is O(n) in the number of codepoints.
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
FONT_SUBSET_CODES,
|
||||
FontSubsetError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import { parseUnicodeRanges } from "./unicode-ranges.js";
|
||||
|
||||
describe("parseUnicodeRanges", () => {
|
||||
it("parses single codepoint U+XXXX", { timeout: 5000 }, () => {
|
||||
const set = parseUnicodeRanges(["U+0041"]);
|
||||
expect(set.has(0x41)).toBe(true);
|
||||
expect(set.size).toBe(1);
|
||||
});
|
||||
|
||||
it("parses range U+XXXX-XXXX", { timeout: 5000 }, () => {
|
||||
const set = parseUnicodeRanges(["U+0041-0044"]);
|
||||
expect(set.has(0x41)).toBe(true);
|
||||
expect(set.has(0x42)).toBe(true);
|
||||
expect(set.has(0x43)).toBe(true);
|
||||
expect(set.has(0x44)).toBe(true);
|
||||
expect(set.size).toBe(4);
|
||||
});
|
||||
|
||||
it("merges multiple ranges", { timeout: 5000 }, () => {
|
||||
const set = parseUnicodeRanges(["U+0020-007F", "U+00A0"]);
|
||||
expect(set.has(0x20)).toBe(true);
|
||||
expect(set.has(0x7f)).toBe(true);
|
||||
expect(set.has(0xa0)).toBe(true);
|
||||
expect(set.has(0x80)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns empty set for empty input", { timeout: 5000 }, () => {
|
||||
const set = parseUnicodeRanges([]);
|
||||
expect(set.size).toBe(0);
|
||||
});
|
||||
|
||||
it("is case-insensitive for U+ prefix", { timeout: 5000 }, () => {
|
||||
const set = parseUnicodeRanges(["u+0041"]);
|
||||
expect(set.has(0x41)).toBe(true);
|
||||
});
|
||||
|
||||
it("throws INVALID_OPTIONS for malformed range", { timeout: 5000 }, () => {
|
||||
expect(() => parseUnicodeRanges(["INVALID"])).toThrow(FontSubsetError);
|
||||
try {
|
||||
parseUnicodeRanges(["INVALID"]);
|
||||
} catch (e) {
|
||||
expect((e as FontSubsetError).code).toBe(
|
||||
FONT_SUBSET_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws INVALID_OPTIONS for reversed range", { timeout: 5000 }, () => {
|
||||
expect(() => parseUnicodeRanges(["U+0044-0041"])).toThrow(
|
||||
FontSubsetError
|
||||
);
|
||||
try {
|
||||
parseUnicodeRanges(["U+0044-0041"]);
|
||||
} catch (e) {
|
||||
expect((e as FontSubsetError).code).toBe(
|
||||
FONT_SUBSET_CODES.INVALID_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles uppercase hex", { timeout: 5000 }, () => {
|
||||
const set = parseUnicodeRanges(["U+0041-004F"]);
|
||||
expect(set.has(0x41)).toBe(true);
|
||||
expect(set.has(0x4f)).toBe(true);
|
||||
expect(set.size).toBe(15);
|
||||
});
|
||||
});
|
||||
46
packages/media/fonts/font-subset/src/utils/unicode-ranges.ts
Normal file
46
packages/media/fonts/font-subset/src/utils/unicode-ranges.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
FONT_SUBSET_CODES,
|
||||
FontSubsetError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
|
||||
const RANGE_RE = /^[Uu]\+([0-9A-Fa-f]{1,6})(?:-([0-9A-Fa-f]{1,6}))?$/;
|
||||
|
||||
/**
|
||||
* Parse an array of Unicode range strings (e.g. `["U+0020-007F", "U+00A0"]`)
|
||||
* into a `Set<number>` of codepoints.
|
||||
*
|
||||
* Supported notation:
|
||||
* - Single codepoint: `U+XXXX`
|
||||
* - Inclusive range: `U+XXXX-XXXX`
|
||||
*
|
||||
* Case-insensitive for both the `U+` prefix and hex digits.
|
||||
*
|
||||
* @throws {FontSubsetError} INVALID_OPTIONS if a range string is malformed
|
||||
* or if the start codepoint is greater than the end codepoint.
|
||||
*/
|
||||
export function parseUnicodeRanges(ranges: string[]): Set<number> {
|
||||
const result = new Set<number>();
|
||||
for (const range of ranges) {
|
||||
const m = RANGE_RE.exec(range);
|
||||
if (!m) {
|
||||
throw new FontSubsetError(
|
||||
FONT_SUBSET_CODES.INVALID_OPTIONS,
|
||||
`Invalid Unicode range: "${range}". Expected format U+XXXX or U+XXXX-XXXX.`
|
||||
);
|
||||
}
|
||||
const startHex = m[1]!;
|
||||
const endHex = m[2];
|
||||
const start = Number.parseInt(startHex, 16);
|
||||
const end = endHex !== undefined ? Number.parseInt(endHex, 16) : start;
|
||||
if (start > end) {
|
||||
throw new FontSubsetError(
|
||||
FONT_SUBSET_CODES.INVALID_OPTIONS,
|
||||
`Invalid Unicode range: "${range}". Start (U+${startHex.toUpperCase()}) must not exceed end (U+${endHex!.toUpperCase()}).`
|
||||
);
|
||||
}
|
||||
for (let cp = start; cp <= end; cp++) {
|
||||
result.add(cp);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
102
packages/media/fonts/font-subset/tests/integration.test.ts
Normal file
102
packages/media/fonts/font-subset/tests/integration.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
createFontSubsetter,
|
||||
detectFontFormat,
|
||||
FONT_SUBSET_CODES,
|
||||
type FontSubsetError,
|
||||
NullFontSubsetAdapter,
|
||||
parseUnicodeRanges,
|
||||
} from "../src/index.js";
|
||||
|
||||
// Minimal TTF magic bytes + padding
|
||||
const TTF_DATA = new Uint8Array([
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
...new Array(96).fill(0),
|
||||
]);
|
||||
const WOFF2_DATA = new Uint8Array([
|
||||
0x77,
|
||||
0x4f,
|
||||
0x46,
|
||||
0x32,
|
||||
...new Array(96).fill(0),
|
||||
]);
|
||||
|
||||
describe("Full pipeline with NullFontSubsetAdapter", () => {
|
||||
it("probes and compresses TTF → WOFF2", { timeout: 30000 }, async () => {
|
||||
const adapter = new NullFontSubsetAdapter();
|
||||
const compressor = createFontSubsetter({ adapter });
|
||||
|
||||
const meta = await compressor.probe(TTF_DATA);
|
||||
expect(meta.format).toBe("ttf");
|
||||
|
||||
const result = await compressor.compress(TTF_DATA, {
|
||||
outputFormat: "woff2",
|
||||
});
|
||||
expect(result.format).toBe("woff2");
|
||||
expect(result.mimeType).toBe("font/woff2");
|
||||
expect(result.originalSize).toBe(TTF_DATA.byteLength);
|
||||
expect(result.ratio).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("compresses with subset options", { timeout: 30000 }, async () => {
|
||||
const adapter = new NullFontSubsetAdapter();
|
||||
const compressor = createFontSubsetter({ adapter });
|
||||
const result = await compressor.compress(TTF_DATA, {
|
||||
subset: {
|
||||
unicodeRanges: ["U+0020-007F"],
|
||||
characters: "Hello World",
|
||||
},
|
||||
outputFormat: "woff",
|
||||
stripHinting: true,
|
||||
stripMetadata: true,
|
||||
features: ["kern", "liga"],
|
||||
});
|
||||
expect(result.format).toBe("woff");
|
||||
});
|
||||
|
||||
it("detects WOFF2 format from magic bytes", { timeout: 5000 }, () => {
|
||||
expect(detectFontFormat(WOFF2_DATA)).toBe("woff2");
|
||||
});
|
||||
|
||||
it("parseUnicodeRanges returns correct codepoints for ASCII", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const codepoints = parseUnicodeRanges(["U+0020-007F"]);
|
||||
expect(codepoints.size).toBe(96);
|
||||
expect(codepoints.has(0x20)).toBe(true);
|
||||
expect(codepoints.has(0x7f)).toBe(true);
|
||||
});
|
||||
|
||||
it("end-to-end with multiple compress calls", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const adapter = new NullFontSubsetAdapter();
|
||||
const compressor = createFontSubsetter({ adapter });
|
||||
for (const fmt of ["ttf", "otf", "woff", "woff2"] as const) {
|
||||
const result = await compressor.compress(TTF_DATA, {
|
||||
outputFormat: fmt,
|
||||
});
|
||||
expect(result.format).toBe(fmt);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws ENCODE_FAILED when aborted mid-flight", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
const adapter = new NullFontSubsetAdapter();
|
||||
const compressor = createFontSubsetter({ adapter });
|
||||
try {
|
||||
await compressor.compress(TTF_DATA, undefined, ctrl.signal);
|
||||
expect.fail("should have thrown");
|
||||
} catch (e) {
|
||||
expect((e as FontSubsetError).code).toBe(
|
||||
FONT_SUBSET_CODES.ENCODE_FAILED
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
83
packages/media/fonts/font-subset/tests/property.test.ts
Normal file
83
packages/media/fonts/font-subset/tests/property.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, it } from "@suaveplan/testing/runner";
|
||||
import * as fc from "fast-check";
|
||||
import {
|
||||
createFontSubsetter,
|
||||
NullFontSubsetAdapter,
|
||||
parseUnicodeRanges,
|
||||
} from "../src/index.js";
|
||||
|
||||
const TTF_DATA = new Uint8Array([
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
...new Array(96).fill(0),
|
||||
]);
|
||||
|
||||
describe("Property: ratio is positive and bounded", () => {
|
||||
it("ratio is always > 0 for any outputFormat", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const adapter = new NullFontSubsetAdapter();
|
||||
const compressor = createFontSubsetter({ adapter });
|
||||
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.constantFrom("ttf", "otf", "woff", "woff2" as const),
|
||||
async (fmt) => {
|
||||
const result = await compressor.compress(TTF_DATA, {
|
||||
outputFormat: fmt,
|
||||
});
|
||||
return result.ratio > 0;
|
||||
}
|
||||
),
|
||||
{ numRuns: 20 }
|
||||
);
|
||||
});
|
||||
|
||||
it("compressedSize equals originalSize for NullAdapter (no-op)", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const adapter = new NullFontSubsetAdapter();
|
||||
const compressor = createFontSubsetter({ adapter });
|
||||
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.uint8Array({ minLength: 100, maxLength: 1000 }),
|
||||
async (data) => {
|
||||
const patched = new Uint8Array(data);
|
||||
patched[0] = 0x00;
|
||||
patched[1] = 0x01;
|
||||
patched[2] = 0x00;
|
||||
patched[3] = 0x00;
|
||||
const result = await compressor.compress(patched);
|
||||
return (
|
||||
result.originalSize === patched.byteLength &&
|
||||
result.compressedSize === patched.byteLength
|
||||
);
|
||||
}
|
||||
),
|
||||
{ numRuns: 20 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Property: parseUnicodeRanges", () => {
|
||||
it("all codepoints in range U+0000-FFFF are correctly included", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 0, max: 0xfffd }), (start) => {
|
||||
const end = start + 2;
|
||||
const startHex = start
|
||||
.toString(16)
|
||||
.toUpperCase()
|
||||
.padStart(4, "0");
|
||||
const endHex = end.toString(16).toUpperCase().padStart(4, "0");
|
||||
const set = parseUnicodeRanges([`U+${startHex}-${endHex}`]);
|
||||
return set.has(start) && set.has(end) && set.size === 3;
|
||||
}),
|
||||
{ numRuns: 30 }
|
||||
);
|
||||
});
|
||||
});
|
||||
12
packages/media/fonts/font-subset/tsconfig.lib.json
Normal file
12
packages/media/fonts/font-subset/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-subset/vite.config.ts
Normal file
38
packages/media/fonts/font-subset/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-subset",
|
||||
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",
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/mime",
|
||||
"@suaveplan/types",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
31
packages/media/fonts/font-subset/vitest.config.ts
Normal file
31
packages/media/fonts/font-subset/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