Files
convert/docs/superpowers/specs/2026-07-31-font-conversion-design.md
T
anthony eb211d5672 Fix fontkit import in font conversion spec
Verified empirically: fontkit has no default ESM export in this project's
"type": "module" setup (import fontkit from 'fontkit' throws). The working
form is import * as fontkit from 'fontkit'.
2026-07-31 10:53:07 +02:00

13 KiB

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):

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):

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).