Initial Commit
This commit is contained in:
56
packages/media/fonts/font-convert/README.md
Normal file
56
packages/media/fonts/font-convert/README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# @suaveplan/font-convert - Convert TTF/OTF/WOFF/WOFF2 font files to typeface.js JSON format. Defines canonical TypefaceFont, TypefaceGlyph, and PathCommand types used by font-compress and font-decompress.
|
||||
|
||||
## Summary
|
||||
|
||||
Converts TTF/OTF/WOFF/WOFF2 font files to the [typeface.js](https://gero3.github.io/facetype.js/) JSON format used by [Three.js `FontLoader`](https://threejs.org/docs/#examples/en/loaders/FontLoader). Also defines the canonical `TypefaceFont`, `TypefaceGlyph`, and `PathCommand` types consumed by `@suaveplan/font-compress`.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [batch-converter](./src/core/batch-converter/batch-converter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [batch-converter](./src/core/batch-converter/batch-converter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [types](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [typeface](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [outline-to-string](./src/utils/outline-to-string.md) — Serialises an array of `PathCommand` objects to the space-separated token string stored in a `TypefaceGlyph`'s `o` fiel…
|
||||
- [reverse-winding](./src/utils/reverse-winding.md) — Reverses the winding order of a typeface.js path command sequence. Three.js `FontLoader` expects counter-clockwise wind…
|
||||
<!-- end:auto-docs --> - [batch-converter](./src/core/batch-converter/batch-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_- [typeface](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_- [outline-to-string](./src/utils/outline-to-string.md) — Serialises an array of `PathCommand` objects to the space-separated token string stored in a `TypefaceGlyph`'s `o` fiel…
|
||||
- [reverse-winding](./src/utils/reverse-winding.md) — Reverses the winding order of a typeface.js path command sequence. Three.js `FontLoader` expects counter-clockwise wind…
|
||||
- [batch-converter](./src/core/batch-converter/batch-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_- [typeface](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_- [outline-to-string](./src/utils/outline-to-string.md) — Serialises an array of `PathCommand` objects to the space-separated token string stored in a `TypefaceGlyph`'s `o` fiel…
|
||||
- [reverse-winding](./src/utils/reverse-winding.md) — Reverses the winding order of a typeface.js path command sequence. Three.js `FontLoader` expects counter-clockwise wind…
|
||||
- [batch-converter](./src/core/batch-converter/batch-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_- [typeface](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_- [outline-to-string](./src/utils/outline-to-string.md) — Serialises an array of `PathCommand` objects to the space-separated token string stored in a `TypefaceGlyph`'s `o` fiel…
|
||||
- [reverse-winding](./src/utils/reverse-winding.md) — Reverses the winding order of a typeface.js path command sequence. Three.js `FontLoader` expects counter-clockwise wind…
|
||||
- [batch-converter](./src/core/batch-converter/batch-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_ - [font-converter](./src/core/font-converter/font-converter.md) — <!-- begin:auto-docs --> _(no submodules)_- [types](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_ - [typeface](./src/types/typeface.md) — <!-- begin:auto-docs --> _(no submodules)_- [outline-to-string](./src/utils/outline-to-string.md) — Serialises an array of `PathCommand` objects to the space-separated token string stored in a `TypefaceGlyph`'s `o` fiel…
|
||||
- [reverse-winding](./src/utils/reverse-winding.md) — Reverses the winding order of a typeface.js path command sequence. Three.js `FontLoader` expects counter-clockwise wind…
|
||||
|
||||
## Packages in the font family
|
||||
|
||||
| Package | Purpose |
|
||||
|---|---|
|
||||
| `@suaveplan/font-subset` | Unicode subset, WOFF2/WOFF encode, hinting strip |
|
||||
| **`@suaveplan/font-convert`** | **TTF/OTF → typeface.js JSON (this package)** |
|
||||
| `@suaveplan/font-compress` | typeface.js JSON → BTF binary pack/unpack |
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { createFontConverter } from "@suaveplan/font-convert";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const converter = createFontConverter();
|
||||
const font = converter.convert(readFileSync("MyFont.ttf"));
|
||||
|
||||
console.log(font.familyName); // "MyFont"
|
||||
console.log(font.cssFontWeight); // "normal" | "bold"
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Description |
|
||||
|---|---|
|
||||
| [FontConverter](src/core/font-converter/font-converter.md) | Parse font binary → TypefaceFont |
|
||||
| [BatchConverter](src/core/batch-converter/batch-converter.md) | Convert multiple fonts in one call |
|
||||
| [reverseWinding](src/utils/reverse-winding.md) | Reverse glyph winding order |
|
||||
| [outlineToString](src/utils/outline-to-string.md) | Serialise PathCommand[] to outline string |
|
||||
| [TypefaceFont types](src/types/typeface.md) | Canonical shared types |
|
||||
96
packages/media/fonts/font-convert/package.json
Normal file
96
packages/media/fonts/font-convert/package.json
Normal file
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"name": "@suaveplan/font-convert",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Convert TTF/OTF/WOFF/WOFF2 font files to typeface.js JSON format. Defines canonical TypefaceFont, TypefaceGlyph, and PathCommand types used by font-compress and font-decompress.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./types/typeface": {
|
||||
"import": {
|
||||
"types": "./dist/types/typeface.d.ts",
|
||||
"default": "./dist/types/typeface.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/types/typeface.d.ts",
|
||||
"default": "./dist/types/typeface.js"
|
||||
}
|
||||
},
|
||||
"./core/font-converter": {
|
||||
"import": {
|
||||
"types": "./dist/core/font-converter/index.d.ts",
|
||||
"default": "./dist/core/font-converter/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/font-converter/index.d.ts",
|
||||
"default": "./dist/core/font-converter/index.js"
|
||||
}
|
||||
},
|
||||
"./core/batch-converter": {
|
||||
"import": {
|
||||
"types": "./dist/core/batch-converter/index.d.ts",
|
||||
"default": "./dist/core/batch-converter/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/core/batch-converter/index.d.ts",
|
||||
"default": "./dist/core/batch-converter/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"opentype.js": "catalog:fonts",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@types/opentype.js": "catalog:fonts",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"mitata": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
4
packages/media/fonts/font-convert/src/constants/index.ts
Normal file
4
packages/media/fonts/font-convert/src/constants/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const RESOLUTION = 1000;
|
||||
export const DEFAULT_UNITS_PER_EM = 2048;
|
||||
export const SCALE_NUMERATOR = RESOLUTION * 100;
|
||||
export const SCALE_DENOMINATOR_FACTOR = 72;
|
||||
@@ -0,0 +1,102 @@
|
||||
# BatchConverter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Converts an array of font buffers to `TypefaceFont` JSON objects in a single call. It wraps `FontConverter` and applies the same conversion options to every item in the batch, returning an array of `{ name, font }` pairs. Useful for build pipelines that process multiple font files in a single pass before packing them to BTF binary.
|
||||
|
||||
## Features
|
||||
|
||||
- Accepts an array of `{ name: string; data: Uint8Array }` batch items
|
||||
- Returns `{ name: string; font: TypefaceFont }[]` in the same order as the input
|
||||
- Shares a single `FontConverter` instance across all items
|
||||
- Passes `ConversionOptions` uniformly to every conversion
|
||||
- Propagates `FontConvertError` immediately on the first failing item
|
||||
- Returns an empty array when given no items
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { BatchConverter } from "@suaveplan/font-convert";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const batch = new BatchConverter();
|
||||
|
||||
const results = batch.convertAll([
|
||||
{ name: "Roboto-Regular", data: readFileSync("Roboto-Regular.ttf") },
|
||||
{ name: "Roboto-Bold", data: readFileSync("Roboto-Bold.ttf") },
|
||||
]);
|
||||
|
||||
for (const { name, font } of results) {
|
||||
console.log(name, font.cssFontWeight);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { BatchConverter } from "@suaveplan/font-convert";
|
||||
|
||||
const batch = new BatchConverter({ logger: myLogger });
|
||||
|
||||
const results = batch.convertAll(
|
||||
[
|
||||
{ name: "Helvetica-Regular", data: helData },
|
||||
{ name: "Helvetica-Bold", data: boldData },
|
||||
],
|
||||
{
|
||||
reverseTypeface: true,
|
||||
restrictCharacters: true,
|
||||
restrictCharacterSet: "32-126", // printable ASCII only
|
||||
},
|
||||
);
|
||||
|
||||
results.forEach(({ name, font }) => {
|
||||
console.log(name, Object.keys(font.glyphs).length);
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class BatchConverter`
|
||||
|
||||
#### `constructor(options?: FontConverterOptions)`
|
||||
|
||||
Accepts the same options as `FontConverter`.
|
||||
|
||||
#### `convertAll(items: BatchItem[], options?: ConversionOptions): BatchResult[]`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `items` | `BatchItem[]` | Array of `{ name, data }` font inputs |
|
||||
| `options` | `ConversionOptions?` | Conversion options applied to all items |
|
||||
|
||||
**Returns**: `BatchResult[]` — `{ name: string; font: TypefaceFont }[]` in input order.
|
||||
|
||||
**Throws**: `FontConvertError` on the first item that fails to convert.
|
||||
|
||||
### `BatchItem`
|
||||
|
||||
```ts
|
||||
interface BatchItem {
|
||||
name: string;
|
||||
data: Uint8Array;
|
||||
}
|
||||
```
|
||||
|
||||
### `BatchResult`
|
||||
|
||||
```ts
|
||||
interface BatchResult {
|
||||
name: string;
|
||||
font: TypefaceFont;
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`BatchConverter` processes items sequentially with `Array.map`. There is no parallelism or streaming — fonts are converted one at a time in the order provided. This keeps error attribution simple (the first failure surfaces immediately) and avoids concurrency complications in build pipelines where ordering matters.
|
||||
|
||||
For very large batches (100+ fonts), consider chunking the items and processing chunks in separate worker threads if total wall-clock conversion time becomes a concern.
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
|
||||
import { FontConvertError } from "../../error-codes.js";
|
||||
import { FontConverter } from "../font-converter/font-converter.js";
|
||||
import { BatchConverter } from "./batch-converter.js";
|
||||
|
||||
describe("BatchConverter", () => {
|
||||
it("returns empty array for empty input", { timeout: 5000 }, () => {
|
||||
const batch = new BatchConverter();
|
||||
expect(batch.convertAll([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("calls converter.convert for each item and returns results", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const mockFont = {
|
||||
glyphs: {},
|
||||
familyName: "Test",
|
||||
ascender: 0,
|
||||
descender: 0,
|
||||
underlinePosition: 0,
|
||||
underlineThickness: 0,
|
||||
boundingBox: { yMin: 0, xMin: 0, yMax: 0, xMax: 0 },
|
||||
resolution: 1000,
|
||||
original_font_information: {},
|
||||
cssFontWeight: "normal",
|
||||
cssFontStyle: "normal",
|
||||
reversed: false,
|
||||
};
|
||||
|
||||
const converterSpy = vi
|
||||
.spyOn(FontConverter.prototype, "convert")
|
||||
.mockReturnValue(mockFont);
|
||||
|
||||
const batch = new BatchConverter();
|
||||
const data = new Uint8Array([0, 1, 2, 3]);
|
||||
const results = batch.convertAll([
|
||||
{ name: "font-a", data },
|
||||
{ name: "font-b", data },
|
||||
]);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0]?.name).toBe("font-a");
|
||||
expect(results[1]?.name).toBe("font-b");
|
||||
expect(results[0]?.font).toBe(mockFont);
|
||||
converterSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("propagates FontConvertError from individual item", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const convertError = new FontConvertError(
|
||||
"FONT_CONVERT.PARSE_FAILED" as any,
|
||||
"bad font"
|
||||
);
|
||||
vi.spyOn(FontConverter.prototype, "convert").mockImplementation(() => {
|
||||
throw convertError;
|
||||
});
|
||||
|
||||
const batch = new BatchConverter();
|
||||
expect(() =>
|
||||
batch.convertAll([{ name: "bad", data: new Uint8Array([0]) }])
|
||||
).toThrow(FontConvertError);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("accepts ConversionOptions and passes them to converter", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const mockFont = {
|
||||
glyphs: {},
|
||||
familyName: "Test",
|
||||
ascender: 0,
|
||||
descender: 0,
|
||||
underlinePosition: 0,
|
||||
underlineThickness: 0,
|
||||
boundingBox: { yMin: 0, xMin: 0, yMax: 0, xMax: 0 },
|
||||
resolution: 1000,
|
||||
original_font_information: {},
|
||||
cssFontWeight: "normal",
|
||||
cssFontStyle: "normal",
|
||||
reversed: true,
|
||||
};
|
||||
|
||||
const convertSpy = vi
|
||||
.spyOn(FontConverter.prototype, "convert")
|
||||
.mockReturnValue(mockFont);
|
||||
|
||||
const batch = new BatchConverter();
|
||||
batch.convertAll([{ name: "x", data: new Uint8Array([0]) }], {
|
||||
reverseTypeface: true,
|
||||
});
|
||||
|
||||
expect(convertSpy).toHaveBeenCalledWith(expect.any(Uint8Array), {
|
||||
reverseTypeface: true,
|
||||
});
|
||||
convertSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { TypefaceFont } from "../../types/typeface.js";
|
||||
import { FontConverter } from "../font-converter/font-converter.js";
|
||||
import type {
|
||||
ConversionOptions,
|
||||
FontConverterOptions,
|
||||
} from "../font-converter/font-converter.types.js";
|
||||
|
||||
export interface BatchItem {
|
||||
name: string;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
export interface BatchResult {
|
||||
name: string;
|
||||
font: TypefaceFont;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an array of font buffers to typeface.js JSON in one call.
|
||||
* Each item is processed synchronously via the underlying FontConverter.
|
||||
*/
|
||||
export class BatchConverter {
|
||||
readonly #converter: FontConverter;
|
||||
|
||||
constructor(options?: FontConverterOptions) {
|
||||
this.#converter = new FontConverter(options);
|
||||
}
|
||||
|
||||
convertAll(items: BatchItem[], options?: ConversionOptions): BatchResult[] {
|
||||
return items.map((item) => ({
|
||||
name: item.name,
|
||||
font: this.#converter.convert(item.data, options),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { BatchItem, BatchResult } from "./batch-converter.js";
|
||||
export { BatchConverter } from "./batch-converter.js";
|
||||
@@ -0,0 +1,97 @@
|
||||
# FontConverter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Converts a TTF, OTF, WOFF, or WOFF2 font binary to the `TypefaceFont` JSON format used by Three.js `FontLoader`. It parses the binary via `opentype.js`, scales all coordinates to the 1 000-unit typeface.js resolution, and constructs a complete `TypefaceFont` object ready for BTF binary packing by `@suaveplan/font-compress`. `FontConverter` is the primary consumer-facing class; `createFontConverter` is the factory function.
|
||||
|
||||
## Features
|
||||
|
||||
- Accepts raw font data as `Uint8Array` or `ArrayBuffer`
|
||||
- Parses TTF, OTF, WOFF, and WOFF2 formats via `opentype.js`
|
||||
- Applies the typeface.js normalisation scale: `(1000 × 100) / (unitsPerEm × 72)`
|
||||
- Converts all command types including cubic bezier (`C` → `b` in typeface.js notation)
|
||||
- Optional winding reversal via `reverseTypeface` option (delegates to `reverseWinding`)
|
||||
- Optional character restriction by set string or numeric codepoint range (`"65-90"`)
|
||||
- Falls back to `font.familyName` and `"Unknown"` when name table fields are absent
|
||||
- Detects `bold`/`italic` from the font subfamily name (case-insensitive)
|
||||
- Accepts a pre-parsed `opentype.Font` via `convertFromParsed` for batch or pipeline use
|
||||
- Wraps all errors in `FontConvertError` with stable error codes
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createFontConverter } from "@suaveplan/font-convert";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const data = readFileSync("Roboto-Regular.ttf");
|
||||
const converter = createFontConverter();
|
||||
const font = converter.convert(data);
|
||||
|
||||
console.log(font.familyName); // "Roboto"
|
||||
console.log(font.cssFontWeight); // "normal"
|
||||
console.log(Object.keys(font.glyphs).length); // glyph count
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createFontConverter } from "@suaveplan/font-convert";
|
||||
import * as opentype from "opentype.js";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const buffer = readFileSync("MyFont-Bold.ttf").buffer;
|
||||
const parsed = opentype.parse(buffer);
|
||||
|
||||
const converter = createFontConverter();
|
||||
|
||||
// Convert from already-parsed font (avoids double-parse in pipelines)
|
||||
const font = converter.convertFromParsed(parsed, {
|
||||
reverseTypeface: true, // flip winding for TrueType CFF fonts
|
||||
restrictCharacters: true,
|
||||
restrictCharacterSet: "65-122", // only A–Z and a–z
|
||||
});
|
||||
|
||||
console.log(font.cssFontWeight); // "bold" — detected from subfamily
|
||||
console.log(font.reversed); // true
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class FontConverter`
|
||||
|
||||
#### `constructor(options?: FontConverterOptions)`
|
||||
|
||||
| Option | Type | Description |
|
||||
|---|---|---|
|
||||
| `logger` | `LoggerLike?` | Optional structured logger |
|
||||
|
||||
#### `convert(data: Uint8Array \| ArrayBuffer, options?: ConversionOptions): TypefaceFont`
|
||||
|
||||
Parses and converts a font binary. Throws `FontConvertError` with code `FONT_CONVERT.PARSE_FAILED` if the binary cannot be parsed.
|
||||
|
||||
#### `convertFromParsed(font: opentype.Font, options?: ConversionOptions): TypefaceFont`
|
||||
|
||||
Converts an already-parsed `opentype.Font` object. Throws `FontConvertError` with code `FONT_CONVERT.ENCODE_FAILED` on failure.
|
||||
|
||||
### `ConversionOptions`
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `reverseTypeface` | `boolean?` | `false` | Reverse glyph winding order |
|
||||
| `restrictCharacters` | `boolean?` | `false` | Enable character filtering |
|
||||
| `restrictCharacterSet` | `string?` | — | Characters or `"start-end"` range |
|
||||
|
||||
### `createFontConverter(options?: FontConverterOptions): FontConverter`
|
||||
|
||||
Factory function. Equivalent to `new FontConverter(options)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The scale factor `(1000 × 100) / (unitsPerEm × 72)` is the canonical typeface.js normalisation used by the original `facetype.js` CLI and `three.js` font generation scripts. The `× 100` and `÷ 72` factors preserve the original script's DPI-to-point conversion. A fallback of `unitsPerEm = 2048` (the TrueType default) is used when the parsed font reports `unitsPerEm = 0`.
|
||||
|
||||
Cubic bezier commands are renamed from SVG's `C`/`c` to typeface.js's `b` during command mapping. All other command types (`m`, `l`, `q`, `z`) are lowercased but otherwise unchanged.
|
||||
|
||||
Character restriction supports two modes: a literal character-set string (e.g. `"ABC"`) and a numeric codepoint range in the form `"start-end"` (e.g. `"65-90"` for ASCII A–Z). The range form is detected when the string splits on `-` into exactly two valid integers; anything else is treated as a literal character set.
|
||||
@@ -0,0 +1,407 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { FontConvertError } from "../../error-codes.js";
|
||||
import { createFontConverter, FontConverter } from "./font-converter.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal opentype.js font mock factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeGlyph(
|
||||
unicode: number | undefined,
|
||||
unicodes: number[],
|
||||
advanceWidth: number,
|
||||
commands: Array<{
|
||||
type: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
x1?: number;
|
||||
y1?: number;
|
||||
x2?: number;
|
||||
y2?: number;
|
||||
}>
|
||||
) {
|
||||
return {
|
||||
unicode,
|
||||
unicodes,
|
||||
advanceWidth,
|
||||
xMin: 0,
|
||||
xMax: advanceWidth,
|
||||
path: { commands },
|
||||
};
|
||||
}
|
||||
|
||||
function makeFont(glyphs: ReturnType<typeof makeGlyph>[] = []) {
|
||||
return {
|
||||
unitsPerEm: 1000,
|
||||
ascender: 800,
|
||||
descender: -200,
|
||||
familyName: "TestFont",
|
||||
styleName: "Regular",
|
||||
names: {
|
||||
fontFamily: { en: "TestFont" },
|
||||
fontSubfamily: { en: "Regular" },
|
||||
name: {},
|
||||
},
|
||||
tables: {
|
||||
name: {},
|
||||
post: { underlinePosition: -100, underlineThickness: 50 },
|
||||
head: { yMin: -200, xMin: 0, yMax: 800, xMax: 1000 },
|
||||
},
|
||||
glyphs: {
|
||||
length: glyphs.length,
|
||||
get: (i: number) => glyphs[i],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("FontConverter", () => {
|
||||
describe("createFontConverter", () => {
|
||||
it("returns a FontConverter instance", { timeout: 5000 }, () => {
|
||||
const converter = createFontConverter();
|
||||
expect(converter).toBeInstanceOf(FontConverter);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertFromParsed", () => {
|
||||
it("returns a TypefaceFont with correct familyName", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const font = makeFont();
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(result.familyName).toBe("TestFont");
|
||||
});
|
||||
|
||||
it("sets resolution to 1000", { timeout: 5000 }, () => {
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(makeFont() as any);
|
||||
expect(result.resolution).toBe(1000);
|
||||
});
|
||||
|
||||
it("computes ascender and descender using scale factor", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const font = makeFont();
|
||||
// scale = (1000*100) / (1000*72) = 100000/72000 ≈ 1.3889
|
||||
const scale = (1000 * 100) / (1000 * 72);
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(result.ascender).toBe(Math.round(800 * scale));
|
||||
expect(result.descender).toBe(Math.round(-200 * scale));
|
||||
});
|
||||
|
||||
it("includes glyphs with unicode codepoints", { timeout: 5000 }, () => {
|
||||
const glyphs = [
|
||||
makeGlyph(65, [65], 600, [
|
||||
{ type: "M", x: 0, y: 0 },
|
||||
{ type: "L", x: 100, y: 0 },
|
||||
{ type: "Z" },
|
||||
]),
|
||||
];
|
||||
const font = makeFont(glyphs);
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(result.glyphs["A"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("maps cubic bezier type C/c to 'b' in outline string", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const glyphs = [
|
||||
makeGlyph(65, [65], 600, [
|
||||
{ type: "M", x: 0, y: 0 },
|
||||
{ type: "C", x1: 10, y1: 20, x2: 30, y2: 40, x: 100, y: 0 },
|
||||
{ type: "Z" },
|
||||
]),
|
||||
];
|
||||
const font = makeFont(glyphs);
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(result.glyphs["A"]?.o).toContain("b ");
|
||||
});
|
||||
|
||||
it("applies reverseTypeface option when true", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const glyphs = [
|
||||
makeGlyph(65, [65], 600, [
|
||||
{ type: "M", x: 0, y: 0 },
|
||||
{ type: "L", x: 100, y: 0 },
|
||||
]),
|
||||
];
|
||||
const font = makeFont(glyphs);
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any, {
|
||||
reverseTypeface: true,
|
||||
});
|
||||
expect(result.reversed).toBe(true);
|
||||
});
|
||||
|
||||
it("sets reversed=false when not reversing", { timeout: 5000 }, () => {
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(makeFont() as any);
|
||||
expect(result.reversed).toBe(false);
|
||||
});
|
||||
|
||||
it("detects bold style from fontSubfamily name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const font = makeFont();
|
||||
(font.names.fontSubfamily as any) = { en: "Bold" };
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(result.cssFontWeight).toBe("bold");
|
||||
});
|
||||
|
||||
it("detects italic style from fontSubfamily name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const font = makeFont();
|
||||
(font.names.fontSubfamily as any) = { en: "Italic" };
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(result.cssFontStyle).toBe("italic");
|
||||
});
|
||||
|
||||
it("sets cssFontWeight/Style to normal for Regular", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(makeFont() as any);
|
||||
expect(result.cssFontWeight).toBe("normal");
|
||||
expect(result.cssFontStyle).toBe("normal");
|
||||
});
|
||||
|
||||
it("restricts output to character set when restrictCharacters=true", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const glyphs = [
|
||||
makeGlyph(65, [65], 600, [{ type: "M", x: 0, y: 0 }]), // A
|
||||
makeGlyph(66, [66], 600, [{ type: "M", x: 0, y: 0 }]), // B
|
||||
];
|
||||
const font = makeFont(glyphs);
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any, {
|
||||
restrictCharacters: true,
|
||||
restrictCharacterSet: "A",
|
||||
});
|
||||
expect(result.glyphs["A"]).toBeDefined();
|
||||
expect(result.glyphs["B"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("restricts output to numeric range when restrictCharacterSet is 'start-end'", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const glyphs = [
|
||||
makeGlyph(65, [65], 600, [{ type: "M", x: 0, y: 0 }]), // A (65)
|
||||
makeGlyph(90, [90], 600, [{ type: "M", x: 0, y: 0 }]), // Z (90)
|
||||
makeGlyph(97, [97], 600, [{ type: "M", x: 0, y: 0 }]), // a (97)
|
||||
];
|
||||
const font = makeFont(glyphs);
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any, {
|
||||
restrictCharacters: true,
|
||||
restrictCharacterSet: "65-90",
|
||||
});
|
||||
expect(result.glyphs["A"]).toBeDefined();
|
||||
expect(result.glyphs["Z"]).toBeDefined();
|
||||
expect(result.glyphs["a"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips glyphs with no unicode codepoint", { timeout: 5000 }, () => {
|
||||
const glyphs = [
|
||||
makeGlyph(undefined, [], 600, [{ type: "M", x: 0, y: 0 }]),
|
||||
];
|
||||
const font = makeFont(glyphs);
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(Object.keys(result.glyphs)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("deduplicates unicode codepoints in unicodes array", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// glyph.unicode = 65, glyph.unicodes = [65, 65] — should only produce one entry
|
||||
const glyphs = [makeGlyph(65, [65, 65], 600, [])];
|
||||
const font = makeFont(glyphs);
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(Object.keys(result.glyphs)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses DEFAULT_UNITS_PER_EM (2048) when unitsPerEm is 0", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const font = makeFont();
|
||||
(font as any).unitsPerEm = 0;
|
||||
const scale = (1000 * 100) / (2048 * 72);
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(result.ascender).toBe(Math.round(800 * scale));
|
||||
});
|
||||
|
||||
it("falls back to font.familyName when names.fontFamily.en is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const font = makeFont();
|
||||
(font.names.fontFamily as any) = {};
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(result.familyName).toBe("TestFont");
|
||||
});
|
||||
|
||||
it("falls back to 'Unknown' when both fontFamily fields are absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const font = makeFont();
|
||||
(font.names.fontFamily as any) = {};
|
||||
(font as any).familyName = undefined;
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(result.familyName).toBe("Unknown");
|
||||
});
|
||||
|
||||
it("wraps thrown errors from opentype in FontConvertError", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const badFont = {
|
||||
...makeFont(),
|
||||
get ascender() {
|
||||
throw new Error("corrupt font data");
|
||||
},
|
||||
};
|
||||
const converter = createFontConverter();
|
||||
expect(() => converter.convertFromParsed(badFont as any)).toThrow(
|
||||
FontConvertError
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to font.styleName when fontSubfamily.en is absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const font = makeFont();
|
||||
(font.names.fontSubfamily as any) = {};
|
||||
(font as any).styleName = "Bold";
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(result.cssFontWeight).toBe("bold");
|
||||
});
|
||||
|
||||
it("falls back to 'Regular' when both fontSubfamily.en and styleName are absent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const font = makeFont();
|
||||
(font.names.fontSubfamily as any) = {};
|
||||
(font as any).styleName = undefined;
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(result.cssFontWeight).toBe("normal");
|
||||
expect(result.cssFontStyle).toBe("normal");
|
||||
});
|
||||
|
||||
it("collects codepoints from glyph with no unicodes array", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Glyph has unicode but no unicodes property at all
|
||||
const glyphNoUnicodes = {
|
||||
unicode: 66,
|
||||
advanceWidth: 600,
|
||||
xMin: 0,
|
||||
xMax: 600,
|
||||
path: { commands: [] },
|
||||
};
|
||||
const font = makeFont([glyphNoUnicodes as any]);
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convertFromParsed(font as any);
|
||||
expect(result.glyphs["B"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("convert", () => {
|
||||
it("converts a real TTF buffer to TypefaceFont", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ttf = readFileSync(
|
||||
new URL("../../../tests/fixtures/test.ttf", import.meta.url)
|
||||
);
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convert(ttf);
|
||||
expect(result.familyName).toBe("Comfortaa");
|
||||
expect(Object.keys(result.glyphs).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("converts a real OTF buffer to TypefaceFont", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const otf = readFileSync(
|
||||
new URL("../../../tests/fixtures/test.otf", import.meta.url)
|
||||
);
|
||||
const converter = createFontConverter();
|
||||
const result = converter.convert(otf);
|
||||
expect(result.familyName).toBeDefined();
|
||||
expect(Object.keys(result.glyphs).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("throws FontConvertError on invalid binary data", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const converter = createFontConverter();
|
||||
const bad = new Uint8Array([0x00, 0x01, 0x02]);
|
||||
expect(() => converter.convert(bad)).toThrow(FontConvertError);
|
||||
});
|
||||
|
||||
it("re-throws FontConvertError from parse unchanged", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const originalErr = new FontConvertError(
|
||||
"FONT_CONVERT.PARSE_FAILED" as any,
|
||||
"inner error"
|
||||
);
|
||||
const converter = createFontConverter({
|
||||
parse: () => {
|
||||
throw originalErr;
|
||||
},
|
||||
});
|
||||
let caught: unknown;
|
||||
try {
|
||||
converter.convert(new Uint8Array([0, 1, 0, 0, 0, 0]));
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBe(originalErr);
|
||||
});
|
||||
|
||||
it("wraps parser Error in FontConvertError", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const converter = createFontConverter({
|
||||
parse: () => {
|
||||
throw new Error("parse error");
|
||||
},
|
||||
});
|
||||
expect(() =>
|
||||
converter.convert(
|
||||
new Uint8Array([0, 1, 0, 0, 0, 0]).buffer as ArrayBuffer
|
||||
)
|
||||
).toThrow(FontConvertError);
|
||||
});
|
||||
|
||||
it("wraps non-Error thrown value in FontConvertError", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const converter = createFontConverter({
|
||||
parse: () => {
|
||||
throw "string error";
|
||||
},
|
||||
});
|
||||
expect(() =>
|
||||
converter.convert(new Uint8Array([0, 1, 0, 0, 0, 0]))
|
||||
).toThrow(FontConvertError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
import * as opentype from "opentype.js";
|
||||
import {
|
||||
DEFAULT_UNITS_PER_EM,
|
||||
RESOLUTION,
|
||||
SCALE_DENOMINATOR_FACTOR,
|
||||
SCALE_NUMERATOR,
|
||||
} from "../../constants/index.js";
|
||||
import { FONT_CONVERT_CODES, FontConvertError } from "../../error-codes.js";
|
||||
import type {
|
||||
PathCommand,
|
||||
TypefaceFont,
|
||||
TypefaceGlyph,
|
||||
} from "../../types/typeface.js";
|
||||
import { reverseWinding } from "../../utils/reverse-winding.js";
|
||||
import type {
|
||||
ConversionOptions,
|
||||
FontConverterOptions,
|
||||
} from "./font-converter.types.js";
|
||||
|
||||
type RawCommand = {
|
||||
type: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
x1?: number;
|
||||
y1?: number;
|
||||
x2?: number;
|
||||
y2?: number;
|
||||
};
|
||||
|
||||
type FontTables = {
|
||||
post: { underlinePosition: number; underlineThickness: number };
|
||||
head: { yMin: number; xMin: number; yMax: number; xMax: number };
|
||||
name: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export class FontConverter {
|
||||
readonly #logger: LoggerLike | undefined;
|
||||
readonly #parse: (buffer: ArrayBuffer) => opentype.Font;
|
||||
|
||||
constructor(options: FontConverterOptions = {}) {
|
||||
this.#logger = options.logger;
|
||||
this.#parse = options.parse ?? opentype.parse;
|
||||
}
|
||||
|
||||
convert(
|
||||
data: Uint8Array | ArrayBuffer,
|
||||
options?: ConversionOptions
|
||||
): TypefaceFont {
|
||||
try {
|
||||
const buffer =
|
||||
data instanceof Uint8Array
|
||||
? data.buffer.slice(
|
||||
data.byteOffset,
|
||||
data.byteOffset + data.byteLength
|
||||
)
|
||||
: (data as ArrayBuffer);
|
||||
const font = this.#parse(buffer as ArrayBuffer);
|
||||
return this.convertFromParsed(font, options);
|
||||
} catch (err) {
|
||||
if (err instanceof FontConvertError) throw err;
|
||||
this.#logger?.error("font parse failed", { cause: err });
|
||||
throw new FontConvertError(
|
||||
FONT_CONVERT_CODES["PARSE_FAILED"],
|
||||
err instanceof Error ? err.message : String(err),
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
convertFromParsed(
|
||||
font: opentype.Font,
|
||||
options: ConversionOptions = {}
|
||||
): TypefaceFont {
|
||||
try {
|
||||
const upm = font.unitsPerEm || DEFAULT_UNITS_PER_EM;
|
||||
const scale = SCALE_NUMERATOR / (upm * SCALE_DENOMINATOR_FACTOR);
|
||||
|
||||
const familyName =
|
||||
(font.names.fontFamily as Record<string, string> | undefined)?.[
|
||||
"en"
|
||||
] ??
|
||||
(font as unknown as { familyName?: string }).familyName ??
|
||||
"Unknown";
|
||||
|
||||
const tables = font.tables as unknown as FontTables;
|
||||
|
||||
const result: TypefaceFont = {
|
||||
glyphs: {},
|
||||
familyName,
|
||||
ascender: Math.round(font.ascender * scale),
|
||||
descender: Math.round(font.descender * scale),
|
||||
underlinePosition: Math.round(
|
||||
tables["post"].underlinePosition * scale
|
||||
),
|
||||
underlineThickness: Math.round(
|
||||
tables["post"].underlineThickness * scale
|
||||
),
|
||||
boundingBox: {
|
||||
yMin: Math.round(tables["head"].yMin * scale),
|
||||
xMin: Math.round(tables["head"].xMin * scale),
|
||||
yMax: Math.round(tables["head"].yMax * scale),
|
||||
xMax: Math.round(tables["head"].xMax * scale),
|
||||
},
|
||||
resolution: RESOLUTION,
|
||||
original_font_information: tables["name"],
|
||||
cssFontWeight: "normal",
|
||||
cssFontStyle: "normal",
|
||||
};
|
||||
|
||||
const restriction = this.#parseRestriction(options);
|
||||
|
||||
const glyphSet = font.glyphs;
|
||||
for (let i = 0; i < glyphSet.length; i++) {
|
||||
const glyph = glyphSet.get(i) as opentype.Glyph & {
|
||||
unicodes?: number[];
|
||||
};
|
||||
const unicodes = this.#collectUnicodes(glyph);
|
||||
|
||||
for (const cp of unicodes) {
|
||||
if (!this.#shouldExport(cp, restriction)) continue;
|
||||
|
||||
const glyphData = this.#buildGlyph(glyph, scale, options);
|
||||
result.glyphs[String.fromCharCode(cp)] = glyphData;
|
||||
}
|
||||
}
|
||||
|
||||
const subfamily =
|
||||
(
|
||||
font.names.fontSubfamily as
|
||||
| Record<string, string>
|
||||
| undefined
|
||||
)?.["en"] ??
|
||||
(font as unknown as { styleName?: string }).styleName ??
|
||||
"Regular";
|
||||
|
||||
result.cssFontWeight = subfamily.toLowerCase().includes("bold")
|
||||
? "bold"
|
||||
: "normal";
|
||||
result.cssFontStyle = subfamily.toLowerCase().includes("italic")
|
||||
? "italic"
|
||||
: "normal";
|
||||
result.reversed = options.reverseTypeface ?? false;
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.#logger?.error("font encode failed", { cause: err });
|
||||
throw new FontConvertError(
|
||||
FONT_CONVERT_CODES["ENCODE_FAILED"],
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: /* istanbul ignore next */ String(err),
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#collectUnicodes(
|
||||
glyph: opentype.Glyph & { unicodes?: number[] }
|
||||
): number[] {
|
||||
const seen = new Set<number>();
|
||||
if (glyph.unicode !== undefined) seen.add(glyph.unicode);
|
||||
for (const cp of glyph.unicodes ?? []) seen.add(cp);
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
#parseRestriction(options: ConversionOptions): {
|
||||
range: [number, number] | null;
|
||||
set: string | null;
|
||||
} {
|
||||
const restriction: {
|
||||
range: [number, number] | null;
|
||||
set: string | null;
|
||||
} = { range: null, set: null };
|
||||
|
||||
if (!options.restrictCharacters || !options.restrictCharacterSet) {
|
||||
return restriction;
|
||||
}
|
||||
|
||||
const content = options.restrictCharacterSet;
|
||||
const parts = content.split("-");
|
||||
if (
|
||||
parts.length === 2 &&
|
||||
!Number.isNaN(Number.parseInt(parts[0]!, 10)) &&
|
||||
!Number.isNaN(Number.parseInt(parts[1]!, 10))
|
||||
) {
|
||||
restriction.range = [
|
||||
Number.parseInt(parts[0]!, 10),
|
||||
Number.parseInt(parts[1]!, 10),
|
||||
];
|
||||
} else {
|
||||
restriction.set = content;
|
||||
}
|
||||
|
||||
return restriction;
|
||||
}
|
||||
|
||||
#shouldExport(
|
||||
cp: number,
|
||||
restriction: { range: [number, number] | null; set: string | null }
|
||||
): boolean {
|
||||
if (restriction.range !== null) {
|
||||
return cp >= restriction.range[0] && cp <= restriction.range[1];
|
||||
}
|
||||
if (restriction.set !== null) {
|
||||
return restriction.set.includes(String.fromCharCode(cp));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#buildGlyph(
|
||||
glyph: opentype.Glyph,
|
||||
scale: number,
|
||||
options: ConversionOptions
|
||||
): TypefaceGlyph {
|
||||
const rawCommands = (
|
||||
glyph.path as unknown as { commands: RawCommand[] }
|
||||
).commands;
|
||||
|
||||
const pathCmds = rawCommands.map<PathCommand>((c) => {
|
||||
const rawType =
|
||||
c.type.toLowerCase() === "c" ? "b" : c.type.toLowerCase();
|
||||
const cmd: PathCommand = {
|
||||
type: rawType as PathCommand["type"],
|
||||
};
|
||||
if (c.x !== undefined) cmd.x = c.x;
|
||||
if (c.y !== undefined) cmd.y = c.y;
|
||||
if (c.x1 !== undefined) cmd.x1 = c.x1;
|
||||
if (c.y1 !== undefined) cmd.y1 = c.y1;
|
||||
if (c.x2 !== undefined) cmd.x2 = c.x2;
|
||||
if (c.y2 !== undefined) cmd.y2 = c.y2;
|
||||
return cmd;
|
||||
});
|
||||
|
||||
const finalCmds = options.reverseTypeface
|
||||
? reverseWinding(pathCmds)
|
||||
: pathCmds;
|
||||
|
||||
let o = "";
|
||||
for (const cmd of finalCmds) {
|
||||
o += `${cmd.type} `;
|
||||
if (cmd.x !== undefined && cmd.y !== undefined) {
|
||||
o += `${Math.round(cmd.x * scale)} ${Math.round(cmd.y * scale)} `;
|
||||
}
|
||||
if (cmd.x1 !== undefined && cmd.y1 !== undefined) {
|
||||
o += `${Math.round(cmd.x1 * scale)} ${Math.round(cmd.y1 * scale)} `;
|
||||
}
|
||||
if (cmd.x2 !== undefined && cmd.y2 !== undefined) {
|
||||
o += `${Math.round(cmd.x2 * scale)} ${Math.round(cmd.y2 * scale)} `;
|
||||
}
|
||||
}
|
||||
|
||||
const rawGlyph = glyph as unknown as {
|
||||
advanceWidth: number;
|
||||
xMin: number;
|
||||
xMax: number;
|
||||
};
|
||||
|
||||
return {
|
||||
ha: Math.round(rawGlyph.advanceWidth * scale),
|
||||
x_min: Math.round(rawGlyph.xMin * scale),
|
||||
x_max: Math.round(rawGlyph.xMax * scale),
|
||||
o,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createFontConverter(
|
||||
options?: FontConverterOptions
|
||||
): FontConverter {
|
||||
return new FontConverter(options);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { LoggerLike } from "@suaveplan/types";
|
||||
|
||||
export interface ConversionOptions {
|
||||
/** Reverse glyph winding order for TrueType CFF compat. Default: false */
|
||||
reverseTypeface?: boolean;
|
||||
/** Limit output to a specific character set. Default: false */
|
||||
restrictCharacters?: boolean;
|
||||
/** Characters to include when restrictCharacters=true */
|
||||
restrictCharacterSet?: string;
|
||||
}
|
||||
|
||||
export interface FontConverterOptions {
|
||||
logger?: LoggerLike;
|
||||
/** Optional parser override for testing or custom pipelines. */
|
||||
parse?: (buffer: ArrayBuffer) => import("opentype.js").Font;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { createFontConverter, FontConverter } from "./font-converter.js";
|
||||
export type {
|
||||
ConversionOptions,
|
||||
FontConverterOptions,
|
||||
} from "./font-converter.types.js";
|
||||
32
packages/media/fonts/font-convert/src/error-codes.ts
Normal file
32
packages/media/fonts/font-convert/src/error-codes.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
export const FONT_CONVERT_CODES = defineCodes("FONT_CONVERT", [
|
||||
"PARSE_FAILED",
|
||||
"ENCODE_FAILED",
|
||||
"INVALID_OPTIONS",
|
||||
"UNSUPPORTED_FORMAT",
|
||||
] as const);
|
||||
|
||||
export type FontConvertErrorCode =
|
||||
(typeof FONT_CONVERT_CODES)[keyof typeof FONT_CONVERT_CODES];
|
||||
|
||||
export class FontConvertError extends SuaveplanError<FontConvertErrorCode> {
|
||||
public constructor(
|
||||
code: FontConvertErrorCode,
|
||||
message: string,
|
||||
options?: { cause?: unknown; context?: Record<string, unknown> }
|
||||
) {
|
||||
const superOptions: {
|
||||
cause?: Error;
|
||||
context?: Record<string, unknown>;
|
||||
} = {};
|
||||
if (options?.cause instanceof Error) {
|
||||
superOptions.cause = options.cause;
|
||||
}
|
||||
if (options?.context !== undefined) {
|
||||
superOptions.context = options.context;
|
||||
}
|
||||
super(code, message, superOptions);
|
||||
this.name = "FontConvertError";
|
||||
}
|
||||
}
|
||||
22
packages/media/fonts/font-convert/src/index.ts
Normal file
22
packages/media/fonts/font-convert/src/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export type {
|
||||
BatchItem,
|
||||
BatchResult,
|
||||
} from "./core/batch-converter/index.js";
|
||||
export { BatchConverter } from "./core/batch-converter/index.js";
|
||||
export type {
|
||||
ConversionOptions,
|
||||
FontConverterOptions,
|
||||
} from "./core/font-converter/index.js";
|
||||
|
||||
export {
|
||||
createFontConverter,
|
||||
FontConverter,
|
||||
} from "./core/font-converter/index.js";
|
||||
export type { FontConvertErrorCode } from "./error-codes.js";
|
||||
export { FONT_CONVERT_CODES, FontConvertError } from "./error-codes.js";
|
||||
export type {
|
||||
PathCommand,
|
||||
PathCommandType,
|
||||
TypefaceFont,
|
||||
TypefaceGlyph,
|
||||
} from "./types/typeface.js";
|
||||
125
packages/media/fonts/font-convert/src/types/typeface.md
Normal file
125
packages/media/fonts/font-convert/src/types/typeface.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# TypefaceFont Types
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the canonical shared types for the typeface.js font format used throughout the `@suaveplan/font-*` package family. `TypefaceFont` is the output of `FontConverter` and the input to `BinaryFontPacker`; `TypefaceGlyph` describes a single glyph's outline; `PathCommand` encodes a single vector drawing operation in the outline string.
|
||||
|
||||
These types are the authoritative source — `@suaveplan/font-compress` and `@suaveplan/font-decompress` both import from here rather than defining their own.
|
||||
|
||||
## Features
|
||||
|
||||
- `PathCommandType` — union of the five typeface.js command tokens: `m` (moveto), `l` (lineto), `q` (quadratic bezier), `b` (cubic bezier), `z` (close path)
|
||||
- `PathCommand` — structured representation of a single outline command with optional coordinate fields
|
||||
- `TypefaceGlyph` — horizontal advance, bounding box extremes, and the serialised outline string
|
||||
- `TypefaceFont` — complete typeface.js document: glyph dictionary, font metadata, bounding box, CSS weight/style, and an optional `reversed` flag for winding-order correction
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { TypefaceFont, TypefaceGlyph, PathCommand } from "@suaveplan/font-convert/types/typeface";
|
||||
|
||||
const glyph: TypefaceGlyph = {
|
||||
ha: 1234,
|
||||
x_min: 10,
|
||||
x_max: 800,
|
||||
o: "m 100 0 l 100 700 l 500 700 z ",
|
||||
};
|
||||
|
||||
const font: TypefaceFont = {
|
||||
glyphs: { A: glyph },
|
||||
familyName: "MyFont",
|
||||
ascender: 800,
|
||||
descender: -200,
|
||||
underlinePosition: -100,
|
||||
underlineThickness: 50,
|
||||
boundingBox: { yMin: -200, xMin: 0, yMax: 800, xMax: 1000 },
|
||||
resolution: 1000,
|
||||
original_font_information: {},
|
||||
cssFontWeight: "normal",
|
||||
cssFontStyle: "normal",
|
||||
};
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { PathCommand, PathCommandType } from "@suaveplan/font-convert/types/typeface";
|
||||
|
||||
function parseOutlineString(outline: string): PathCommand[] {
|
||||
const tokens = outline.trim().split(/\s+/);
|
||||
const commands: PathCommand[] = [];
|
||||
let i = 0;
|
||||
while (i < tokens.length) {
|
||||
const type = tokens[i++] as PathCommandType;
|
||||
const cmd: PathCommand = { type };
|
||||
if (type === "m" || type === "l") {
|
||||
cmd.x = Number(tokens[i++]);
|
||||
cmd.y = Number(tokens[i++]);
|
||||
} else if (type === "q") {
|
||||
cmd.x1 = Number(tokens[i++]);
|
||||
cmd.y1 = Number(tokens[i++]);
|
||||
cmd.x = Number(tokens[i++]);
|
||||
cmd.y = Number(tokens[i++]);
|
||||
} else if (type === "b") {
|
||||
cmd.x1 = Number(tokens[i++]);
|
||||
cmd.y1 = Number(tokens[i++]);
|
||||
cmd.x2 = Number(tokens[i++]);
|
||||
cmd.y2 = Number(tokens[i++]);
|
||||
cmd.x = Number(tokens[i++]);
|
||||
cmd.y = Number(tokens[i++]);
|
||||
}
|
||||
commands.push(cmd);
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `PathCommandType`
|
||||
|
||||
String union: `"m" | "l" | "q" | "b" | "z"`. Maps to typeface.js outline token types (note: cubic bezier uses `"b"`, not SVG's `"c"`).
|
||||
|
||||
### `PathCommand`
|
||||
|
||||
| Field | Type | Present for |
|
||||
|---|---|---|
|
||||
| `type` | `PathCommandType` | all |
|
||||
| `x`, `y` | `number` | `m`, `l`, `q`, `b` |
|
||||
| `x1`, `y1` | `number` | `q`, `b` |
|
||||
| `x2`, `y2` | `number` | `b` only |
|
||||
|
||||
### `TypefaceGlyph`
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `ha` | `number` | Horizontal advance (scaled units) |
|
||||
| `x_min` | `number` | Left extent of the glyph bounding box |
|
||||
| `x_max` | `number` | Right extent of the glyph bounding box |
|
||||
| `o` | `string` | Space-separated outline command tokens |
|
||||
|
||||
### `TypefaceFont`
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `glyphs` | `Record<string, TypefaceGlyph>` | Map from character to glyph |
|
||||
| `familyName` | `string` | Font family name |
|
||||
| `ascender` | `number` | Ascender height (scaled) |
|
||||
| `descender` | `number` | Descender depth (scaled, negative) |
|
||||
| `underlinePosition` | `number` | Underline Y position |
|
||||
| `underlineThickness` | `number` | Underline stroke width |
|
||||
| `boundingBox` | `{ yMin, xMin, yMax, xMax }` | Overall font bounding box |
|
||||
| `resolution` | `number` | Always `1000` — typeface.js coordinate scale |
|
||||
| `original_font_information` | `unknown` | Raw name table from the source font |
|
||||
| `cssFontWeight` | `string` | `"normal"` or `"bold"` |
|
||||
| `cssFontStyle` | `string` | `"normal"` or `"italic"` |
|
||||
| `reversed` | `boolean?` | `true` when winding was reversed for TrueType CFF compat |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All coordinate values stored in a `TypefaceFont` are pre-scaled by `(1000 × 100) / (unitsPerEm × 72)`. This scale factor normalises any source font's internal coordinate system to the 1000-unit resolution expected by typeface.js and Three.js `FontLoader`. The `resolution: 1000` field documents this contract.
|
||||
|
||||
The `reversed` flag is informational only; it tells downstream consumers (e.g. Three.js path renderers) that the winding correction has already been applied so they should not apply their own.
|
||||
37
packages/media/fonts/font-convert/src/types/typeface.ts
Normal file
37
packages/media/fonts/font-convert/src/types/typeface.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/** A single path command in typeface.js outline format */
|
||||
export type PathCommandType = "m" | "l" | "q" | "b" | "z";
|
||||
|
||||
export interface PathCommand {
|
||||
type: PathCommandType;
|
||||
x?: number;
|
||||
y?: number;
|
||||
x1?: number;
|
||||
y1?: number;
|
||||
x2?: number;
|
||||
y2?: number;
|
||||
}
|
||||
|
||||
/** Typeface.js glyph outline */
|
||||
export interface TypefaceGlyph {
|
||||
ha: number;
|
||||
x_min: number;
|
||||
x_max: number;
|
||||
/** Outline string: space-separated "type x y ..." tokens */
|
||||
o: string;
|
||||
}
|
||||
|
||||
/** Typeface.js font format — output of FontConverter, input to BinaryFontPacker */
|
||||
export interface TypefaceFont {
|
||||
glyphs: Record<string, TypefaceGlyph>;
|
||||
familyName: string;
|
||||
ascender: number;
|
||||
descender: number;
|
||||
underlinePosition: number;
|
||||
underlineThickness: number;
|
||||
boundingBox: { yMin: number; xMin: number; yMax: number; xMax: number };
|
||||
resolution: number;
|
||||
original_font_information: unknown;
|
||||
cssFontWeight: string;
|
||||
cssFontStyle: string;
|
||||
reversed?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
# outlineToString
|
||||
|
||||
## Purpose
|
||||
|
||||
Serialises an array of `PathCommand` objects to the space-separated token string stored in a `TypefaceGlyph`'s `o` field. The typeface.js format requires each command to be written as `"type [x y] [x1 y1] [x2 y2]"` with a trailing space after each coordinate group. `outlineToString` encapsulates this serialisation contract and applies an optional scale factor so that internal font-unit coordinates are normalised to the 1 000-unit resolution expected by Three.js `FontLoader`.
|
||||
|
||||
## Features
|
||||
|
||||
- Handles all five typeface.js command types: `m`, `l`, `q`, `b`, `z`
|
||||
- Emits only the coordinate fields present on each command
|
||||
- Applies a floating-point scale multiplier and rounds each coordinate to the nearest integer
|
||||
- Returns an empty string for an empty command array
|
||||
- Does not mutate the input array
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { outlineToString } from "@suaveplan/font-convert";
|
||||
import type { PathCommand } from "@suaveplan/font-convert";
|
||||
|
||||
const commands: PathCommand[] = [
|
||||
{ type: "m", x: 0, y: 0 },
|
||||
{ type: "l", x: 100, y: 0 },
|
||||
{ type: "z" },
|
||||
];
|
||||
|
||||
const outline = outlineToString(commands);
|
||||
// → "m 0 0 l 100 0 z "
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { outlineToString } from "@suaveplan/font-convert";
|
||||
import type { PathCommand } from "@suaveplan/font-convert";
|
||||
|
||||
// Apply a scale factor when coordinates are in raw font units
|
||||
const unitsPerEm = 1000;
|
||||
const scale = (1000 * 100) / (unitsPerEm * 72); // ≈ 1.389
|
||||
|
||||
const commands: PathCommand[] = [
|
||||
{ type: "m", x: 0, y: 0 },
|
||||
{ type: "q", x1: 360, y1: 720, x: 720, y: 0 },
|
||||
{ type: "b", x1: 100, y1: 200, x2: 300, y2: 400, x: 1000, y: 0 },
|
||||
];
|
||||
|
||||
const outline = outlineToString(commands, scale);
|
||||
// coordinates are Math.round(value * scale)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `outlineToString(commands: PathCommand[], scale?: number): string`
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `commands` | `PathCommand[]` | — | Path commands to serialise |
|
||||
| `scale` | `number` | `1` | Multiplier applied to all coordinate values before rounding |
|
||||
|
||||
**Returns**: The typeface.js outline string. Each command contributes one token group ending with a trailing space. Returns `""` when `commands` is empty.
|
||||
|
||||
**Coordinate order per command type:**
|
||||
|
||||
| Type | Token sequence |
|
||||
|---|---|
|
||||
| `m` | `m x y` |
|
||||
| `l` | `l x y` |
|
||||
| `z` | `z` |
|
||||
| `q` | `q x y x1 y1` |
|
||||
| `b` | `b x y x1 y1 x2 y2` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The coordinate emission order matches what `FontConverter` and the reference `typeface.js` CLI produce: the endpoint `x/y` is written before control points `x1/y1` and `x2/y2`. This is the order required by the Three.js `FontLoader` parser. Changing this order would silently corrupt all outlines that use bezier curves.
|
||||
|
||||
The scale factor default of `1` makes `outlineToString` directly testable with integer coordinates without requiring the caller to know the actual font resolution.
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { PathCommand } from "../types/typeface.js";
|
||||
import { outlineToString } from "./outline-to-string.js";
|
||||
|
||||
describe("outlineToString", () => {
|
||||
it("returns empty string for no commands", { timeout: 5000 }, () => {
|
||||
expect(outlineToString([])).toBe("");
|
||||
});
|
||||
|
||||
it("encodes a moveto command", { timeout: 5000 }, () => {
|
||||
const cmds: PathCommand[] = [{ type: "m", x: 10, y: 20 }];
|
||||
expect(outlineToString(cmds)).toBe("m 10 20 ");
|
||||
});
|
||||
|
||||
it("encodes a lineto command", { timeout: 5000 }, () => {
|
||||
const cmds: PathCommand[] = [{ type: "l", x: 100, y: 200 }];
|
||||
expect(outlineToString(cmds)).toBe("l 100 200 ");
|
||||
});
|
||||
|
||||
it("encodes a close-path command (no coords)", { timeout: 5000 }, () => {
|
||||
const cmds: PathCommand[] = [{ type: "z" }];
|
||||
expect(outlineToString(cmds)).toBe("z ");
|
||||
});
|
||||
|
||||
it("encodes a quadratic bezier command", { timeout: 5000 }, () => {
|
||||
const cmds: PathCommand[] = [{ type: "q", x1: 5, y1: 10, x: 50, y: 0 }];
|
||||
expect(outlineToString(cmds)).toBe("q 50 0 5 10 ");
|
||||
});
|
||||
|
||||
it("encodes a cubic bezier command", { timeout: 5000 }, () => {
|
||||
const cmds: PathCommand[] = [
|
||||
{ type: "b", x1: 1, y1: 2, x2: 3, y2: 4, x: 10, y: 20 },
|
||||
];
|
||||
expect(outlineToString(cmds)).toBe("b 10 20 1 2 3 4 ");
|
||||
});
|
||||
|
||||
it("encodes a sequence of mixed commands", { timeout: 5000 }, () => {
|
||||
const cmds: PathCommand[] = [
|
||||
{ type: "m", x: 0, y: 0 },
|
||||
{ type: "l", x: 100, y: 0 },
|
||||
{ type: "z" },
|
||||
];
|
||||
expect(outlineToString(cmds)).toBe("m 0 0 l 100 0 z ");
|
||||
});
|
||||
|
||||
it("applies scale factor to coordinates", { timeout: 5000 }, () => {
|
||||
const cmds: PathCommand[] = [{ type: "m", x: 200, y: 400 }];
|
||||
// scale = 0.5: Math.round(200*0.5) = 100, Math.round(400*0.5) = 200
|
||||
expect(outlineToString(cmds, 0.5)).toBe("m 100 200 ");
|
||||
});
|
||||
|
||||
it("rounds scaled coordinates to nearest integer", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cmds: PathCommand[] = [{ type: "l", x: 100, y: 100 }];
|
||||
// scale = 0.333: Math.round(100*0.333) = 33
|
||||
expect(outlineToString(cmds, 0.333)).toBe("l 33 33 ");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { PathCommand } from "../types/typeface.js";
|
||||
|
||||
/**
|
||||
* Serialises an array of PathCommand objects to the typeface.js outline string format.
|
||||
* The outline string is space-separated tokens: "type [x y] [x1 y1] [x2 y2]".
|
||||
* An optional scale factor is applied and coordinates are rounded to integers.
|
||||
*/
|
||||
export function outlineToString(commands: PathCommand[], scale = 1): string {
|
||||
let result = "";
|
||||
|
||||
for (const cmd of commands) {
|
||||
result += `${cmd.type} `;
|
||||
|
||||
if (cmd.x !== undefined && cmd.y !== undefined) {
|
||||
result += `${Math.round(cmd.x * scale)} `;
|
||||
result += `${Math.round(cmd.y * scale)} `;
|
||||
}
|
||||
|
||||
if (cmd.x1 !== undefined && cmd.y1 !== undefined) {
|
||||
result += `${Math.round(cmd.x1 * scale)} `;
|
||||
result += `${Math.round(cmd.y1 * scale)} `;
|
||||
}
|
||||
|
||||
if (cmd.x2 !== undefined && cmd.y2 !== undefined) {
|
||||
result += `${Math.round(cmd.x2 * scale)} `;
|
||||
result += `${Math.round(cmd.y2 * scale)} `;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# reverseWinding
|
||||
|
||||
## Purpose
|
||||
|
||||
Reverses the winding order of a typeface.js path command sequence. Three.js `FontLoader` expects counter-clockwise winding for outer contours, while some TrueType and CFF fonts ship with clockwise winding. Calling `reverseWinding` before serialising glyphs ensures the outline direction is corrected without reprocessing the source binary.
|
||||
|
||||
## Features
|
||||
|
||||
- Splits commands at each `M`/`m` moveto to identify individual subpaths
|
||||
- Drops `Z`/`z` close-path commands (they are implicit in typeface.js rendering)
|
||||
- Reverses command order within each subpath
|
||||
- Swaps cubic bezier control point pairs (`x1/y1` ↔ `x2/y2`) to maintain curve shape
|
||||
- Preserves quadratic bezier single control point after destination flip
|
||||
- Works with zero commands (returns empty array, no error)
|
||||
- Operates entirely on the `PathCommand` type from `@suaveplan/font-convert`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { reverseWinding } from "@suaveplan/font-convert";
|
||||
import type { PathCommand } from "@suaveplan/font-convert";
|
||||
|
||||
const commands: PathCommand[] = [
|
||||
{ type: "m", x: 0, y: 0 },
|
||||
{ type: "l", x: 100, y: 0 },
|
||||
{ type: "l", x: 50, y: 100 },
|
||||
{ type: "z" },
|
||||
];
|
||||
|
||||
const reversed = reverseWinding(commands);
|
||||
// reversed[0] = { type: "m", x: 50, y: 100 } — last point becomes new start
|
||||
// reversed[1] = { type: "l", x: 100, y: 0 }
|
||||
// reversed[2] = { type: "l", x: 0, y: 0 }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { reverseWinding } from "@suaveplan/font-convert";
|
||||
import type { PathCommand } from "@suaveplan/font-convert";
|
||||
|
||||
// Cubic bezier: reversal swaps x1/y1 and x2/y2
|
||||
const cubicCommands: PathCommand[] = [
|
||||
{ type: "m", x: 0, y: 0 },
|
||||
{ type: "b", x1: 10, y1: 20, x2: 30, y2: 40, x: 100, y: 0 },
|
||||
];
|
||||
|
||||
const rev = reverseWinding(cubicCommands);
|
||||
// rev[0] = { type: "m", x: 100, y: 0 }
|
||||
// rev[1] = { type: "b", x1: 30, y1: 40, x2: 10, y2: 20, x: 0, y: 0 }
|
||||
// ^^^ x2/y2 and x1/y1 are swapped relative to the original
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `reverseWinding(commands: PathCommand[]): PathCommand[]`
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `commands` | `PathCommand[]` | Array of path commands as produced by `FontConverter` |
|
||||
|
||||
**Returns**: A new array of `PathCommand` objects in reversed winding order. The input array is not mutated.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The function mirrors the winding-reversal algorithm used in `typeface.js`'s own CLI tooling and the reference `FontConverter.ts`. Subpath splitting is done by scanning for `m`/`M` commands; `z`/`Z` close-path commands are discarded because typeface.js renderers treat each subpath as implicitly closed. The reversed list starts with a new `m` command pointing at the last non-close command of the original path, then walks the original path backwards, placing each point at the previous command's destination.
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { PathCommand } from "../types/typeface.js";
|
||||
import { reverseWinding } from "./reverse-winding.js";
|
||||
|
||||
describe("reverseWinding", () => {
|
||||
it("returns empty array for empty input", { timeout: 5000 }, () => {
|
||||
expect(reverseWinding([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("reverses a simple triangular moveto+lineto path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const commands: PathCommand[] = [
|
||||
{ type: "m", x: 0, y: 0 },
|
||||
{ type: "l", x: 100, y: 0 },
|
||||
{ type: "l", x: 50, y: 100 },
|
||||
{ type: "z" },
|
||||
];
|
||||
const reversed = reverseWinding(commands);
|
||||
// First point of reversed path is last moveable point
|
||||
expect(reversed[0]).toMatchObject({ type: "m", x: 50, y: 100 });
|
||||
// Second command moves to previous point
|
||||
expect(reversed[1]).toMatchObject({ type: "l", x: 100, y: 0 });
|
||||
expect(reversed[2]).toMatchObject({ type: "l", x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it("reverses quadratic bezier (q) control points", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const commands: PathCommand[] = [
|
||||
{ type: "m", x: 0, y: 0 },
|
||||
{ type: "q", x1: 50, y1: 100, x: 100, y: 0 },
|
||||
];
|
||||
const reversed = reverseWinding(commands);
|
||||
expect(reversed[0]).toMatchObject({ type: "m", x: 100, y: 0 });
|
||||
const q = reversed[1] as PathCommand;
|
||||
expect(q.type).toBe("q");
|
||||
// Control point preserved (single cp quadratic)
|
||||
expect(q.x1).toBe(50);
|
||||
expect(q.y1).toBe(100);
|
||||
// Destination becomes original start
|
||||
expect(q.x).toBe(0);
|
||||
expect(q.y).toBe(0);
|
||||
});
|
||||
|
||||
it("reverses cubic bezier (b) and swaps control point pairs", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const commands: PathCommand[] = [
|
||||
{ type: "m", x: 0, y: 0 },
|
||||
{ type: "b", x1: 10, y1: 20, x2: 30, y2: 40, x: 100, y: 0 },
|
||||
];
|
||||
const reversed = reverseWinding(commands);
|
||||
expect(reversed[0]).toMatchObject({ type: "m", x: 100, y: 0 });
|
||||
const b = reversed[1] as PathCommand;
|
||||
expect(b.type).toBe("b");
|
||||
// cp1 and cp2 swap
|
||||
expect(b.x1).toBe(30);
|
||||
expect(b.y1).toBe(40);
|
||||
expect(b.x2).toBe(10);
|
||||
expect(b.y2).toBe(20);
|
||||
// Destination is original start
|
||||
expect(b.x).toBe(0);
|
||||
expect(b.y).toBe(0);
|
||||
});
|
||||
|
||||
it("handles multiple subpaths independently", { timeout: 5000 }, () => {
|
||||
const commands: PathCommand[] = [
|
||||
{ type: "m", x: 0, y: 0 },
|
||||
{ type: "l", x: 10, y: 0 },
|
||||
{ type: "z" },
|
||||
{ type: "m", x: 50, y: 50 },
|
||||
{ type: "l", x: 60, y: 50 },
|
||||
];
|
||||
const reversed = reverseWinding(commands);
|
||||
// First subpath reversed
|
||||
expect(reversed[0]).toMatchObject({ type: "m", x: 10, y: 0 });
|
||||
// Second subpath reversed
|
||||
const secondStart = reversed.findIndex(
|
||||
(c) => c.type === "m" && c.x === 60
|
||||
);
|
||||
expect(secondStart).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("skips z commands (close-path) in paths array", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const commands: PathCommand[] = [
|
||||
{ type: "m", x: 0, y: 0 },
|
||||
{ type: "l", x: 5, y: 5 },
|
||||
{ type: "z" },
|
||||
];
|
||||
const reversed = reverseWinding(commands);
|
||||
// z is not in the output (reference impl drops z)
|
||||
expect(reversed.some((c) => c.type === "z")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles path starting with moveto that has no subsequent commands", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const commands: PathCommand[] = [{ type: "m", x: 0, y: 0 }];
|
||||
const reversed = reverseWinding(commands);
|
||||
// Single m with no following commands: reversed start is the same point
|
||||
expect(reversed[0]).toMatchObject({ type: "m", x: 0, y: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { PathCommand } from "../types/typeface.js";
|
||||
|
||||
type XYCommand = PathCommand & { x: number; y: number };
|
||||
|
||||
/**
|
||||
* Reverses the winding order of path commands for TrueType/CFF compatibility.
|
||||
* Three.js FontLoader requires counter-clockwise winding; some fonts ship clockwise.
|
||||
*/
|
||||
export function reverseWinding(commands: PathCommand[]): PathCommand[] {
|
||||
const subpaths: PathCommand[][] = [];
|
||||
let current: PathCommand[] = [];
|
||||
|
||||
for (const cmd of commands) {
|
||||
if (cmd.type === "m") {
|
||||
current = [cmd];
|
||||
subpaths.push(current);
|
||||
} else if (cmd.type !== "z") {
|
||||
current.push(cmd);
|
||||
}
|
||||
}
|
||||
|
||||
const reversed: PathCommand[] = [];
|
||||
|
||||
for (const path of subpaths) {
|
||||
// All non-z path commands carry x/y; cast is safe here
|
||||
const last = path[path.length - 1] as XYCommand;
|
||||
reversed.push({ type: "m", x: last.x, y: last.y });
|
||||
|
||||
for (let i = path.length - 1; i > 0; i--) {
|
||||
const cmd = path[i] as PathCommand;
|
||||
const dest = path[i - 1] as XYCommand;
|
||||
const out: PathCommand = { type: cmd.type, x: dest.x, y: dest.y };
|
||||
|
||||
if (cmd.x2 !== undefined && cmd.y2 !== undefined) {
|
||||
// Swap control point pairs for cubic bezier reversal.
|
||||
// x1/y1 are always defined when x2/y2 are present in a valid cubic bezier.
|
||||
const cubic = cmd as PathCommand & {
|
||||
x1: number;
|
||||
y1: number;
|
||||
};
|
||||
out.x1 = cmd.x2;
|
||||
out.y1 = cmd.y2;
|
||||
out.x2 = cubic.x1;
|
||||
out.y2 = cubic.y1;
|
||||
} else if (cmd.x1 !== undefined && cmd.y1 !== undefined) {
|
||||
out.x1 = cmd.x1;
|
||||
out.y1 = cmd.y1;
|
||||
}
|
||||
|
||||
reversed.push(out);
|
||||
}
|
||||
}
|
||||
|
||||
return reversed;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
[ZoneTransfer]
|
||||
ZoneId=3
|
||||
BIN
packages/media/fonts/font-convert/tests/fixtures/test.otf
vendored
Normal file
BIN
packages/media/fonts/font-convert/tests/fixtures/test.otf
vendored
Normal file
Binary file not shown.
BIN
packages/media/fonts/font-convert/tests/fixtures/test.ttf
vendored
Normal file
BIN
packages/media/fonts/font-convert/tests/fixtures/test.ttf
vendored
Normal file
Binary file not shown.
45
packages/media/fonts/font-convert/tests/integration.test.ts
Normal file
45
packages/media/fonts/font-convert/tests/integration.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createFontConverter, FontConvertError } from "../src/index.js";
|
||||
|
||||
/**
|
||||
* Integration tests that require a real font binary.
|
||||
* Full real-font round-trip tests live in font-compress roundtrip.test.ts.
|
||||
*/
|
||||
describe("font-convert integration", () => {
|
||||
it("throws FontConvertError on obviously corrupt binary", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const converter = createFontConverter();
|
||||
const garbage = new Uint8Array(64).fill(0xff);
|
||||
expect(() => converter.convert(garbage)).toThrow(FontConvertError);
|
||||
});
|
||||
|
||||
it("createFontConverter returns converter with convert method", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const converter = createFontConverter();
|
||||
expect(typeof converter.convert).toBe("function");
|
||||
expect(typeof converter.convertFromParsed).toBe("function");
|
||||
});
|
||||
|
||||
it("FontConvertError preserves Error cause", { timeout: 5000 }, () => {
|
||||
const cause = new Error("root cause");
|
||||
const err = new FontConvertError(
|
||||
"FONT_CONVERT.PARSE_FAILED" as any,
|
||||
"wrapped",
|
||||
{ cause }
|
||||
);
|
||||
expect(err.cause).toBe(cause);
|
||||
});
|
||||
|
||||
it("FontConvertError preserves context when provided", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const err = new FontConvertError(
|
||||
"FONT_CONVERT.INVALID_OPTIONS" as any,
|
||||
"bad options",
|
||||
{ context: { field: "value" } }
|
||||
);
|
||||
expect(err.context).toEqual({ field: "value" });
|
||||
});
|
||||
});
|
||||
12
packages/media/fonts/font-convert/tsconfig.lib.json
Normal file
12
packages/media/fonts/font-convert/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
38
packages/media/fonts/font-convert/vite.config.ts
Normal file
38
packages/media/fonts/font-convert/vite.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/font-convert",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"tslib",
|
||||
"zod",
|
||||
"opentype.js",
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
31
packages/media/fonts/font-convert/vitest.config.ts
Normal file
31
packages/media/fonts/font-convert/vitest.config.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["src/__benchmarks__/**", "dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user