# 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 * as 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).