Add design spec for font conversion (ttf/otf/woff/dfont; cff/cid dropped)
Empirically verified library choices (fonteditor-core, opentype.js, fontkit) against real fonts before committing to the scope, and root-caused a file-type/dfont MIME collision that would otherwise silently break uploads.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
# Font conversion (ttf/otf/woff/dfont; cff/cid dropped)
|
||||
|
||||
Date: 2026-07-31
|
||||
|
||||
## Goal
|
||||
|
||||
Add font format conversion. Requested formats: `cff`, `cid`, `dfont`, `otf`, `ttf`, `woff`.
|
||||
|
||||
## Scope decision
|
||||
|
||||
- New bidirectional formats: `ttf`, `otf`, `woff` (every pair, both directions). Writing `.otf` specifically is **best-effort**: it fails for a real (empirically confirmed) subset of fonts, see "Known limitation" below.
|
||||
- New source-only format: `dfont` (Mac OS X datafork font suitcase) → `ttf`/`otf`/`woff`. Nothing writes a new `.dfont`, mirroring the existing HEIC/HEIF source-only pattern.
|
||||
- Dropped entirely: `cff`, `cid` — bare/CID-keyed PostScript font programs (not wrapped in an sfnt/OpenType container). No pure-JS or WASM library was found anywhere that parses or writes these standalone; the only tools that do (FontForge, Adobe tooling, commercial SDKs behind sites like Convertio/Vertopal) require a real compiler/toolchain, which o2switch's shared hosting does not allow. Same situation as HDR being dropped in the HEIC/HEIF/ICO spec.
|
||||
|
||||
## Why this split (empirical findings)
|
||||
|
||||
All three candidate libraries were installed and run against real font files (Adobe Source Code Pro OTF/TTF, Windows Arial/Times New Roman/Candara/Consolas TTF, and fonttools' own `TestDFONT.dfont` fixture) before committing to this design — not inferred from documentation alone.
|
||||
|
||||
- **`fonteditor-core`**: reads `ttf`/`otf`/`woff`, writes `ttf`/`woff`. Verified robust — successfully converted otf→ttf, otf→woff, ttf→woff, and round-tripped woff→ttf for every real-world font tested, including fonts with complex ligature/substitution tables (Arial, Times New Roman). Cannot write `.otf` at all: `font.write({ type: 'otf' })` throws `"not support font type otf"` for every input, confirmed empirically, not just per docs.
|
||||
- **`opentype.js`**: the only pure-JS library found that can write `.otf`. Verified fragile: writing (via `toArrayBuffer()`) failed for 3 of 4 real TTF fonts tested (Arial, Times New Roman, Candara) with `"Unable to write GSUB lookup type 7 tables."` — GSUB extension substitution is common in professional fonts with ligatures/contextual alternates. It succeeded for a simpler font (Consolas) and for OTF→OTF passthrough. No WOFF2 support; also confirmed (by inspecting the bundled source) that it has **no WOFF write path at all** — only decompresses WOFF on read, via a bundled `tinf_uncompress`.
|
||||
- **`fontkit`**: read-only/parsing library (its subsetting write path is explicitly documented as producing PDF-embedding-only output, missing tables needed for standalone files — not usable as a general converter). Used here solely to parse dfont's resource-fork-style container and locate the embedded `sfnt` font(s).
|
||||
|
||||
## dfont extraction detail
|
||||
|
||||
`fontkit`'s own `DFont.getFont()`/`.fonts` accessors slice the underlying stream from the resource's start offset to the **end of the whole dfont buffer** (confirmed by reading `fontkit`'s `DFont.js` source), not to the resource's actual end — so reusing that buffer directly to write out a standalone font file would append garbage (the rest of the dfont) after the real font data.
|
||||
|
||||
Fix: locate the target resource the same way `fontkit` does (via its parsed `header.map.typeList` for `type.name === 'sfnt'`, giving each `ref.dataOffset`), then read the file ourselves — a resource in a `dfont`'s data fork is prefixed by a 4-byte big-endian length. Read that length at `header.dataOffset + ref.dataOffset`, then slice `[start, start + len)` where `start = header.dataOffset + ref.dataOffset + 4`. This gives a byte-exact standalone sfnt buffer, verified against fonttools' `TestDFONT.dfont` fixture (a real font with `outlinesFormat: 'truetype'`, 7 glyphs, parsed correctly by both `opentype.js` and `fonteditor-core` after extraction).
|
||||
|
||||
If a dfont contains multiple embedded fonts (common for weight families, e.g. Regular/Bold/Italic/BoldItalic each as a separate `sfnt` resource), the **first** resource in the list is used — same "pick one, least surprising default" reasoning as HEIC's "decode the main image" and ICO's "largest embedded image."
|
||||
|
||||
## MIME detection collision (dfont vs ico)
|
||||
|
||||
Empirically found and root-caused: `file-type` (already used for content-sniffing validation in `src/mime.js`) misidentifies **every** dfont file tested as `{ ext: 'ico', mime: 'image/x-icon' }`. Cause: a dfont's data fork begins with a big-endian `dataOffset` field, conventionally `0x00000100`, which is byte-identical to ICO's 4-byte magic number. Confirmed against three different real dfont samples (two classic-Mac NFNT-resource suitcases, one modern sfnt-resource suitcase) — deterministic, not a fluke.
|
||||
|
||||
Left unfixed, this breaks `resolveInputFormat`'s content-matches-extension check for every legitimate dfont upload (declared `dfont` vs. detected `ico` → rejected as invalid).
|
||||
|
||||
Fix in `src/mime.js`: give `dfont` its own validation path, parallel to (but stronger than) the existing `UNDETECTABLE_TEXT_FORMATS` fallback for txt/html/md/csv. Rather than trusting the declared extension blindly, use `fontkit`'s own structural probe (decode the resource map header, confirm a `sfnt`-type entry exists — the same check `fontkit`'s internal `DFont.probe()` performs) to positively verify the file really is a dfont, bypassing the generic `file-type` result for this one extension only.
|
||||
|
||||
## Backend flow
|
||||
|
||||
**`src/converters/font.js`** (new):
|
||||
|
||||
```js
|
||||
import fs from 'node:fs/promises';
|
||||
import { Font } from 'fonteditor-core';
|
||||
import opentype from 'opentype.js';
|
||||
import { register } from './registry.js';
|
||||
|
||||
const FONT_FORMATS = ['ttf', 'otf', 'woff'];
|
||||
|
||||
async function convertViaFontEditor(inputPath, outputPath, sourceFormat, targetFormat) {
|
||||
const buffer = await fs.readFile(inputPath);
|
||||
const font = Font.create(buffer, { type: sourceFormat });
|
||||
const out = font.write({ type: targetFormat });
|
||||
await fs.writeFile(outputPath, Buffer.from(out));
|
||||
}
|
||||
|
||||
async function convertToOtf(buffer, outputPath) {
|
||||
const ab = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
|
||||
const font = opentype.parse(ab);
|
||||
await fs.writeFile(outputPath, Buffer.from(font.toArrayBuffer()));
|
||||
}
|
||||
|
||||
export function registerFontConverter() {
|
||||
for (const sourceFormat of FONT_FORMATS) {
|
||||
for (const targetFormat of FONT_FORMATS) {
|
||||
if (sourceFormat === targetFormat) continue;
|
||||
register({
|
||||
family: 'font',
|
||||
sourceFormat,
|
||||
targetFormat,
|
||||
convert: async (inputPath, outputPath) => {
|
||||
if (targetFormat === 'otf') {
|
||||
await convertToOtf(await fs.readFile(inputPath), outputPath);
|
||||
} else {
|
||||
await convertViaFontEditor(inputPath, outputPath, sourceFormat, targetFormat);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`src/converters/dfont.js`** (new):
|
||||
|
||||
```js
|
||||
import fs from 'node:fs/promises';
|
||||
import fontkit from 'fontkit';
|
||||
import { Font } from 'fonteditor-core';
|
||||
import opentype from 'opentype.js';
|
||||
import { register } from './registry.js';
|
||||
|
||||
export function probeDfont(buffer) {
|
||||
try {
|
||||
const dfont = fontkit.create(buffer);
|
||||
const types = dfont.header?.map?.typeList?.types ?? [];
|
||||
return types.some((t) => t.name === 'sfnt');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractSfntBuffer(buffer) {
|
||||
const dfont = fontkit.create(buffer);
|
||||
const sfntType = dfont.header.map.typeList.types.find((t) => t.name === 'sfnt');
|
||||
const ref = sfntType.refList[0];
|
||||
const lenPos = dfont.header.dataOffset + ref.dataOffset;
|
||||
const len = buffer.readUInt32BE(lenPos);
|
||||
const start = lenPos + 4;
|
||||
return buffer.subarray(start, start + len);
|
||||
}
|
||||
|
||||
function sniffSfntType(buffer) {
|
||||
return buffer.slice(0, 4).toString('ascii') === 'OTTO' ? 'otf' : 'ttf';
|
||||
}
|
||||
|
||||
export function registerDfontConverter() {
|
||||
for (const targetFormat of ['ttf', 'otf', 'woff']) {
|
||||
register({
|
||||
family: 'font',
|
||||
sourceFormat: 'dfont',
|
||||
targetFormat,
|
||||
convert: async (inputPath, outputPath) => {
|
||||
const dfontBuffer = await fs.readFile(inputPath);
|
||||
const sfntBuffer = extractSfntBuffer(dfontBuffer);
|
||||
const sourceFormat = sniffSfntType(sfntBuffer);
|
||||
if (targetFormat === 'otf') {
|
||||
const ab = sfntBuffer.buffer.slice(sfntBuffer.byteOffset, sfntBuffer.byteOffset + sfntBuffer.byteLength);
|
||||
const font = opentype.parse(ab);
|
||||
await fs.writeFile(outputPath, Buffer.from(font.toArrayBuffer()));
|
||||
} else {
|
||||
const font = Font.create(Buffer.from(sfntBuffer), { type: sourceFormat });
|
||||
await fs.writeFile(outputPath, Buffer.from(font.write({ type: targetFormat })));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`probeDfont` is also imported by `src/mime.js` for the content-sniffing carve-out described above. Note `extractSfntBuffer` takes a `Buffer` directly (not a path) so `probeDfont`/`mime.js` and the converter can share one read of the file.
|
||||
|
||||
The otf-writing logic (`opentype.parse` + `toArrayBuffer()`) is identical between `font.js` and `dfont.js` above; the implementation should export it once from `font.js` (e.g. `convertBufferToOtf(buffer, outputPath)`) and import it into `dfont.js` rather than duplicating it, the same way `ico.js`/`heic.js` share `sharpFormatName`/`buildFormatOptions` from `image.js` today.
|
||||
|
||||
**`src/mime.js`**:
|
||||
- `OUTPUT_MIME_TYPES`: add `ttf: 'font/ttf'`, `otf: 'font/otf'`, `woff: 'font/woff'` (matching `file-type`'s own reported mime strings for these formats, verified empirically — same convention as every existing entry). No entry for `dfont` (never a target, same as `heic`/`heif` today).
|
||||
- `resolveInputFormat`: before the generic `file-type` check, special-case `declaredFormat === 'dfont'` — read the file and run the dfont structural probe; valid if it passes, regardless of what `file-type` reports.
|
||||
|
||||
**`src/app.js` / `src/worker.js`**: add `registerFontConverter()` and `registerDfontConverter()` calls to `registerAllConverters()` (app.js) and the equivalent block in `worker.js`'s `main()`, alongside the existing five calls.
|
||||
|
||||
**Database / frontend**: no changes. `family` is a free-text `VARCHAR(32)` (no enum constraint, confirmed by reading `prisma/schema.prisma`) — `'font'` is a new value like `'image'`/`'document'`. Fonts have no quality/iconSize-style knob, so no new job column. The frontend's target-format dropdown and per-format controls (`QUALITY_FORMATS`, `'ico'`, `'png'`, `'pdf'` checks in `App.jsx`) are all format-string-driven off `GET /api/formats`, which itself reflects the registry — no font-specific UI code needed, exactly like HEIC/HEIF required none.
|
||||
|
||||
## Known limitation
|
||||
|
||||
Converting **to** `.otf` will fail for fonts using certain complex OpenType layout tables (confirmed: GSUB extension substitution, lookup type 7 — present in Arial, Times New Roman, and other common professional fonts). This is a real, not theoretical, gap: 3 of 4 arbitrary real-world fonts tested failed. The failure is a normal thrown `Error` (`"Unable to write GSUB lookup type 7 tables."`) and is handled by the existing generic worker error path (`processJob`'s try/catch → `markFailed`, surfacing `"Conversion failed, please try again."` to the user) — no special handling is added or needed. `.otf` remains fully reliable as a **source** format regardless (reading is unaffected; only opentype.js's writer is fragile).
|
||||
|
||||
## Testing
|
||||
|
||||
Fixtures (all under `test/fixtures/`, all openly licensed since nothing in the toolchain can synthesize real fonts):
|
||||
- `sample.otf` — Adobe Source Code Pro Regular, OFL-licensed, CFF outlines, 1568 glyphs.
|
||||
- `sample.ttf` — Adobe Source Code Pro Regular TTF build, OFL-licensed, glyf outlines, same 1568 glyphs as `sample.otf` — chosen specifically so tests can assert glyph-count fidelity across otf/ttf/woff conversions of "the same font." Verified to convert cleanly through both `fonteditor-core` (ttf→woff) and `opentype.js` (ttf→otf, i.e. it does *not* hit the GSUB lookup type 7 failure).
|
||||
- `sample.dfont` — fonttools' own `Tests/ttx/data/TestDFONT.dfont` (MIT-licensed project), a small (3.5KB) fixture confirmed to contain one real `sfnt` TrueType resource.
|
||||
|
||||
`test/converters/font.test.js`:
|
||||
- Round-trip conversions across the full `ttf`/`otf`/`woff` matrix using `sample.otf`/`sample.ttf`, asserting the output re-parses successfully with a glyph count matching the source.
|
||||
- A case using a font expected to fail `.otf` writing (would need a fixture exhibiting GSUB lookup type 7, or this can be asserted structurally rather than fixture-based) confirming the thrown error surfaces as a normal rejected promise, not a corrupted/partial output file.
|
||||
- dfont extraction: assert `extractSfntBuffer` returns a buffer whose sfnt table directory is internally consistent (not truncated or padded with trailing garbage), then confirm conversion to each target format succeeds.
|
||||
|
||||
`test/mime.test.js`: add a case asserting a `*.dfont` file with real dfont content passes `resolveInputFormat` despite `file-type` reporting it as `ico` — this is a regression guard for the collision described above.
|
||||
|
||||
`test/api/jobs.test.js`: extend to cover the new source/target pairs, plus a case confirming `cff`/`cid` extensions correctly resolve to "unsupported conversion" rather than silently matching some other registered pair.
|
||||
|
||||
Frontend: manual browser check only (no frontend test framework exists today, consistent with `quality`/`iconSize`/`targetFormat` controls).
|
||||
Reference in New Issue
Block a user