From bd13bc1c9aacfe6cb736b6e483e5d9d3bb840d81 Mon Sep 17 00:00:00 2001 From: Anthony GAEREMYNCK <1@anthony.sh> Date: Fri, 31 Jul 2026 10:50:44 +0200 Subject: [PATCH] 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. --- .../2026-07-31-font-conversion-design.md | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-31-font-conversion-design.md diff --git a/docs/superpowers/specs/2026-07-31-font-conversion-design.md b/docs/superpowers/specs/2026-07-31-font-conversion-design.md new file mode 100644 index 0000000..283b013 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-font-conversion-design.md @@ -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).