Files
convert/docs/superpowers/plans/2026-07-31-font-conversion.md
anthony 1eb391242f Add implementation plan for font conversion (ttf/otf/woff/dfont)
Five TDD tasks: dependencies+fixtures, ttf/otf/woff converter,
dfont extraction converter, mime.js fixes, and wiring+API tests.
2026-07-31 10:58:53 +02:00

30 KiB

Font Conversion (ttf/otf/woff/dfont) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add font format conversion — ttf/otf/woff bidirectional, plus dfont (Mac OS X font suitcase) as a source-only format.

Architecture: Two new converter modules (src/converters/font.js, src/converters/dfont.js) register into the existing generic sourceFormat -> targetFormat registry, following the exact pattern src/converters/ico.js/heic.js already use. fonteditor-core is the primary read/write engine (robust against real-world fonts, but cannot write .otf). opentype.js fills the one gap — writing .otf — but is best-effort: it throws for fonts using certain OpenType layout tables, and that throw is left to the existing generic worker error handling, not specially caught. fontkit is used only to parse a dfont's resource-fork-style container and locate the raw bytes of its embedded font(s), which are then run back through the same font.js logic used for a normal upload.

Tech Stack: Node.js (ESM), Express, Prisma/MySQL, vitest+supertest.

Global Constraints

  • No dependency with native/compiled bindings — fonteditor-core, opentype.js, and fontkit are all pure JS/WASM with no compile step (verified: none ship a binding.gyp), satisfying o2switch's shared-hosting/no-toolchain requirement.
  • cff and cid are explicitly out of scope — dropped during design, no viable library either direction. Never add a converter, MIME entry, or fixture for them.
  • dfont is source-only — never register anything with dfont as a targetFormat.
  • .otf as a target is best-effort. It will throw for some real fonts (confirmed: "Unable to write GSUB lookup type 7 tables." for Arial/Times New Roman/Candara). Do not add try/catch or fallback logic around this — the existing processJob catch-all in src/worker.js already turns any thrown error into a failed job with a generic message.
  • fontkit has no default ESM export in this project's "type": "module" setup — import fontkit from 'fontkit' throws SyntaxError: The requested module 'fontkit' does not provide an export named 'default'. Always use import * as fontkit from 'fontkit'.
  • Any new converter registration must be added to both src/app.js and src/worker.js — they independently duplicate the registration call list.
  • Local test runs must use .env.local-equivalent inline env vars, never load real .env prod credentials (see CLAUDE.md). Run tests with:
    DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run <path>
    
  • Font fixtures cannot be synthesized by the toolchain — they must be real, openly-licensed files committed to test/fixtures/, with an attribution file documenting source and license (see test/fixtures/HEIC_ATTRIBUTION.md for the existing convention).

Task 1: Dependencies and font fixtures

Files:

  • Modify: package.json (add fonteditor-core, opentype.js, fontkit)
  • Create: test/fixtures/sample.otf, test/fixtures/sample.ttf, test/fixtures/sample.dfont
  • Create: test/fixtures/FONT_ATTRIBUTION.md
  • Create: test/converters/font.test.js (fixture smoke test only, extended in Task 2)

Interfaces:

  • Consumes: nothing.

  • Produces: test/fixtures/sample.otf (Adobe Source Code Pro Regular, CFF outlines, 1568 glyphs), test/fixtures/sample.ttf (same font family's TTF build, glyf outlines, same 1568 glyphs — deliberately matched so later tasks can assert glyph-count fidelity across conversions), test/fixtures/sample.dfont (fonttools' own tiny test fixture, one embedded TrueType sfnt resource, 7 glyphs).

  • Step 1: Install the font libraries

npm install fonteditor-core opentype.js fontkit
  • Step 2: Download the font fixtures
curl -sL -o test/fixtures/sample.otf "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/OTF/SourceCodePro-Regular.otf"
curl -sL -o test/fixtures/sample.ttf "https://raw.githubusercontent.com/adobe-fonts/source-code-pro/release/TTF/SourceCodePro-Regular.ttf"
curl -sL -o test/fixtures/sample.dfont "https://raw.githubusercontent.com/fonttools/fonttools/main/Tests/ttx/data/TestDFONT.dfont"

Verify each downloaded correctly (should print recognizable font file descriptions, not HTML/error pages):

file test/fixtures/sample.otf test/fixtures/sample.ttf test/fixtures/sample.dfont

Expected: sample.otf: OpenType font data, sample.ttf: TrueType Font data..., sample.dfont: Mac OSX datafork font, TrueType....

  • Step 3: Write the attribution file

Create test/fixtures/FONT_ATTRIBUTION.md:

# Font fixture attribution

`sample.otf` and `sample.ttf` are the Regular weight of
[Source Code Pro](https://github.com/adobe-fonts/source-code-pro) by Adobe,
licensed under the SIL Open Font License 1.1 (see that repo's
`LICENSE.md`). Both are the same font family deliberately, so tests can
assert glyph-count fidelity (1568 glyphs) across otf/ttf/woff conversions
of "the same font" rather than comparing unrelated files.

`sample.dfont` is `Tests/ttx/data/TestDFONT.dfont` from
[fonttools/fonttools](https://github.com/fonttools/fonttools), MIT
licensed. It is a small (3.5KB) fixture containing one real embedded
`sfnt` TrueType resource (7 glyphs) — used to exercise this project's
dfont-container extraction code without needing a large real-world
font suitcase.
  • Step 4: Write the fixture smoke test

Create test/converters/font.test.js:

import { describe, it, expect } from 'vitest';
import fs from 'node:fs/promises';
import path from 'node:path';
import opentype from 'opentype.js';
import * as fontkit from 'fontkit';

async function parseWithOpentype(fixtureName) {
  const buf = await fs.readFile(path.join(import.meta.dirname, '..', 'fixtures', fixtureName));
  const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
  return opentype.parse(ab);
}

describe('font fixtures', () => {
  it('sample.otf parses as a CFF-flavored OpenType font', async () => {
    const font = await parseWithOpentype('sample.otf');
    expect(font.outlinesFormat).toBe('cff');
    expect(font.glyphs.length).toBe(1568);
  });

  it('sample.ttf parses as a TrueType font with the same glyph count as sample.otf', async () => {
    const font = await parseWithOpentype('sample.ttf');
    expect(font.outlinesFormat).toBe('truetype');
    expect(font.glyphs.length).toBe(1568);
  });

  it('sample.dfont is a valid dfont container with one embedded sfnt font', async () => {
    const buffer = await fs.readFile(path.join(import.meta.dirname, '..', 'fixtures', 'sample.dfont'));
    const dfont = fontkit.create(buffer);
    const sfntType = dfont.header.map.typeList.types.find((t) => t.name === 'sfnt');
    expect(sfntType.refList).toHaveLength(1);
  });
});
  • Step 5: Run the test to verify it passes

Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/font.test.js Expected: PASS (3 tests) — this confirms the dependencies installed correctly and the fixtures are what they're claimed to be, before any project code is written against them.

  • Step 6: Commit
git add package.json package-lock.json test/fixtures/sample.otf test/fixtures/sample.ttf test/fixtures/sample.dfont test/fixtures/FONT_ATTRIBUTION.md test/converters/font.test.js
git commit -m "feat: add font conversion dependencies and test fixtures"

Task 2: ttf/otf/woff bidirectional converter (src/converters/font.js)

Files:

  • Create: src/converters/font.js
  • Modify: test/converters/font.test.js (extend from Task 1)

Interfaces:

  • Consumes: register from src/converters/registry.js (register({ family, sourceFormat, targetFormat, convert }), already existing).

  • Produces: registerFontConverter() — registers every non-identity pair among ttf/otf/woff with family: 'font'. convertBufferToOtf(buffer, outputPath) — exported so dfont.js (Task 3) can reuse the otf-writing logic instead of duplicating it.

  • Step 1: Write the failing tests

Update the import block at the top of test/converters/font.test.js to match this (adds os, registerFontConverter, resolve/listTargetFormats, detectInputMime, and beforeAll/afterAll):

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import opentype from 'opentype.js';
import * as fontkit from 'fontkit';
import { registerFontConverter } from '../../src/converters/font.js';
import { resolve, listTargetFormats } from '../../src/converters/registry.js';
import { detectInputMime } from '../../src/mime.js';

Keep the existing describe('font fixtures', ...) block and its parseWithOpentype helper from Task 1 unchanged. Append the following to the end of the file:

let tmpDir;

beforeAll(async () => {
  registerFontConverter();
  tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'converter-font-'));
});

afterAll(async () => {
  await fs.rm(tmpDir, { recursive: true, force: true });
});

async function glyphCountOf(filePath) {
  const buf = await fs.readFile(filePath);
  const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
  return opentype.parse(ab).glyphs.length;
}

describe('font converter registration', () => {
  it('registers ttf, otf, and woff as sources and targets of each other', () => {
    expect(listTargetFormats('ttf')).toEqual(expect.arrayContaining(['otf', 'woff']));
    expect(listTargetFormats('otf')).toEqual(expect.arrayContaining(['ttf', 'woff']));
    expect(listTargetFormats('woff')).toEqual(expect.arrayContaining(['ttf', 'otf']));
  });

  it('does not register a format as its own target', () => {
    expect(listTargetFormats('ttf')).not.toContain('ttf');
  });

  it('has no registered converter for cff or cid in either direction', () => {
    expect(listTargetFormats('cff')).toEqual([]);
    expect(listTargetFormats('cid')).toEqual([]);
    expect(resolve('cff', 'ttf')).toBeNull();
    expect(resolve('ttf', 'cff')).toBeNull();
    expect(resolve('cid', 'ttf')).toBeNull();
  });
});

describe('font format conversion', () => {
  it('converts otf to ttf, preserving glyph count', async () => {
    const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.otf');
    const outputPath = path.join(tmpDir, 'from-otf.ttf');

    await resolve('otf', 'ttf').convert(inputPath, outputPath);

    const detected = await detectInputMime(outputPath);
    expect(detected.mime).toBe('font/ttf');
    expect(await glyphCountOf(outputPath)).toBe(1568);
  });

  it('converts ttf to woff', async () => {
    const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.ttf');
    const outputPath = path.join(tmpDir, 'from-ttf.woff');

    await resolve('ttf', 'woff').convert(inputPath, outputPath);

    const detected = await detectInputMime(outputPath);
    expect(detected.mime).toBe('font/woff');
  });

  it('round-trips ttf -> woff -> ttf, preserving glyph count', async () => {
    const woffPath = path.join(tmpDir, 'roundtrip.woff');
    const ttfPath = path.join(tmpDir, 'roundtrip.ttf');

    await resolve('ttf', 'woff').convert(path.join(import.meta.dirname, '..', 'fixtures', 'sample.ttf'), woffPath);
    await resolve('woff', 'ttf').convert(woffPath, ttfPath);

    expect(await glyphCountOf(ttfPath)).toBe(1568);
  });

  it('converts ttf to otf', async () => {
    const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.ttf');
    const outputPath = path.join(tmpDir, 'from-ttf.otf');

    await resolve('ttf', 'otf').convert(inputPath, outputPath);

    const detected = await detectInputMime(outputPath);
    expect(detected.mime).toBe('font/otf');
  });
});
  • Step 2: Run the tests to verify they fail

Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/font.test.js Expected: FAIL — Cannot find module '../../src/converters/font.js'.

  • Step 3: Implement src/converters/font.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'];

export async function convertBufferToOtf(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()));
}

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));
}

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 convertBufferToOtf(await fs.readFile(inputPath), outputPath);
          } else {
            await convertViaFontEditor(inputPath, outputPath, sourceFormat, targetFormat);
          }
        },
      });
    }
  }
}
  • Step 4: Run the tests to verify they pass

Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/font.test.js Expected: PASS (all tests in the file).

  • Step 5: Commit
git add src/converters/font.js test/converters/font.test.js
git commit -m "feat: add bidirectional ttf/otf/woff font converter"

Task 3: dfont source-only converter (src/converters/dfont.js)

Files:

  • Create: src/converters/dfont.js
  • Create: test/converters/dfont.test.js (separate file from font.test.js since it has its own beforeAll/tmpDir setup)

Interfaces:

  • Consumes: convertBufferToOtf from src/converters/font.js (Task 2). register from src/converters/registry.js.

  • Produces: probeDfont(buffer) — returns true/false, used by src/mime.js (Task 4). extractSfntBuffer(buffer) — returns a Buffer containing a byte-exact standalone sfnt font. registerDfontConverter() — registers dfont -> ttf/otf/woff.

  • Step 1: Write the failing tests

Create test/converters/dfont.test.js:

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import opentype from 'opentype.js';
import { probeDfont, extractSfntBuffer, registerDfontConverter } from '../../src/converters/dfont.js';
import { resolve, listTargetFormats } from '../../src/converters/registry.js';
import { detectInputMime } from '../../src/mime.js';

let tmpDir;

beforeAll(async () => {
  registerDfontConverter();
  tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'converter-dfont-'));
});

afterAll(async () => {
  await fs.rm(tmpDir, { recursive: true, force: true });
});

describe('probeDfont', () => {
  it('returns true for a real dfont fixture', async () => {
    const buffer = await fs.readFile(path.join(import.meta.dirname, '..', 'fixtures', 'sample.dfont'));
    expect(probeDfont(buffer)).toBe(true);
  });

  it('returns false for content starting with the ICO-colliding magic bytes but no real dfont structure', () => {
    const buffer = Buffer.from([0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x10, 0x10]);
    expect(probeDfont(buffer)).toBe(false);
  });
});

describe('extractSfntBuffer', () => {
  it('extracts a valid, byte-exact standalone sfnt font from the dfont fixture', async () => {
    const buffer = await fs.readFile(path.join(import.meta.dirname, '..', 'fixtures', 'sample.dfont'));
    const sfnt = extractSfntBuffer(buffer);

    const ab = sfnt.buffer.slice(sfnt.byteOffset, sfnt.byteOffset + sfnt.byteLength);
    const font = opentype.parse(ab);
    expect(font.outlinesFormat).toBe('truetype');
    expect(font.glyphs.length).toBe(7);
  });
});

describe('dfont converter registration', () => {
  it('registers dfont as a source for ttf, otf, and woff', () => {
    expect(listTargetFormats('dfont')).toEqual(expect.arrayContaining(['ttf', 'otf', 'woff']));
  });

  it('never registers dfont as a target format', () => {
    expect(listTargetFormats('ttf')).not.toContain('dfont');
    expect(listTargetFormats('otf')).not.toContain('dfont');
    expect(listTargetFormats('woff')).not.toContain('dfont');
  });
});

describe('dfont -> font conversion', () => {
  it('converts the dfont fixture to ttf', async () => {
    const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.dfont');
    const outputPath = path.join(tmpDir, 'from-dfont.ttf');

    await resolve('dfont', 'ttf').convert(inputPath, outputPath);

    const detected = await detectInputMime(outputPath);
    expect(detected.mime).toBe('font/ttf');
  });

  it('converts the dfont fixture to woff', async () => {
    const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.dfont');
    const outputPath = path.join(tmpDir, 'from-dfont.woff');

    await resolve('dfont', 'woff').convert(inputPath, outputPath);

    const detected = await detectInputMime(outputPath);
    expect(detected.mime).toBe('font/woff');
  });

  it('converts the dfont fixture to otf', async () => {
    const inputPath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.dfont');
    const outputPath = path.join(tmpDir, 'from-dfont.otf');

    await resolve('dfont', 'otf').convert(inputPath, outputPath);

    const detected = await detectInputMime(outputPath);
    expect(detected.mime).toBe('font/otf');
  });
});
  • Step 2: Run the tests to verify they fail

Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/dfont.test.js Expected: FAIL — Cannot find module '../../src/converters/dfont.js'.

  • Step 3: Implement src/converters/dfont.js
import fs from 'node:fs/promises';
import * as fontkit from 'fontkit';
import { Font } from 'fonteditor-core';
import { register } from './registry.js';
import { convertBufferToOtf } from './font.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);

        if (targetFormat === 'otf') {
          await convertBufferToOtf(sfntBuffer, outputPath);
        } else {
          const sourceFormat = sniffSfntType(sfntBuffer);
          const font = Font.create(sfntBuffer, { type: sourceFormat });
          const out = font.write({ type: targetFormat });
          await fs.writeFile(outputPath, Buffer.from(out));
        }
      },
    });
  }
}
  • Step 4: Run the tests to verify they pass

Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/converters/dfont.test.js Expected: PASS (all tests in the file).

  • Step 5: Commit
git add src/converters/dfont.js test/converters/dfont.test.js
git commit -m "feat: add source-only dfont font converter"

Task 4: mime.js — font output MIME types and the dfont/ico detection collision fix

Files:

  • Modify: src/mime.js
  • Test: test/mime.test.js

Interfaces:

  • Consumes: probeDfont from src/converters/dfont.js (Task 3).

  • Produces: outputMimeType('ttf'/'otf'/'woff') returns 'font/ttf'/'font/otf'/'font/woff'. resolveInputFormat(path, 'dfont') correctly validates real dfont content even though file-type misreports it as ico, returning { mime: 'application/x-dfont', valid: true } for real dfonts and { mime: null, valid: false } otherwise.

  • Step 1: Write the failing tests

Add to test/mime.test.js:

describe('outputMimeType — fonts', () => {
  it('returns the correct MIME type for each font target format', () => {
    expect(outputMimeType('ttf')).toBe('font/ttf');
    expect(outputMimeType('otf')).toBe('font/otf');
    expect(outputMimeType('woff')).toBe('font/woff');
  });
});

describe('resolveInputFormat — dfont', () => {
  it('confirms file-type alone misidentifies the dfont fixture as ico (documents the bug this fix works around)', async () => {
    const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.dfont');
    const detected = await detectInputMime(fixturePath);
    expect(detected.ext).toBe('ico');
  });

  it('accepts a real dfont file despite that misidentification', async () => {
    const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample.dfont');
    const result = await resolveInputFormat(fixturePath, 'dfont');
    expect(result).toEqual({ mime: 'application/x-dfont', valid: true });
  });

  it('rejects content starting with the colliding magic bytes that is not a real dfont', async () => {
    const fixturePath = path.join(import.meta.dirname, 'fixtures', 'sample-fake.dfont');
    await fs.writeFile(fixturePath, Buffer.from([0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x10, 0x10]));

    const result = await resolveInputFormat(fixturePath, 'dfont');
    expect(result).toEqual({ mime: null, valid: false });

    await fs.unlink(fixturePath);
  });
});
  • Step 2: Run the tests to verify they fail

Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/mime.test.js Expected: the outputMimeType font cases throw No known MIME type for target format "ttf" (etc); the "misidentifies... as ico" case passes already (it's asserting current, pre-fix behavior); the "accepts a real dfont file" case fails because resolveInputFormat currently reports valid: false for it.

  • Step 3: Implement

In src/mime.js, add the import and the three font MIME entries:

import { fileTypeFromFile } from 'file-type';
import fs from 'node:fs/promises';
import { probeDfont } from './converters/dfont.js';

const OUTPUT_MIME_TYPES = {
  jpg: 'image/jpeg',
  jpeg: 'image/jpeg',
  png: 'image/png',
  webp: 'image/webp',
  gif: 'image/gif',
  tiff: 'image/tiff',
  avif: 'image/avif',
  bmp: 'image/bmp',
  ico: 'image/x-icon',
  pdf: 'application/pdf',
  html: 'text/html',
  txt: 'text/plain',
  docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  md: 'text/markdown',
  csv: 'text/csv',
  xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  ttf: 'font/ttf',
  otf: 'font/otf',
  woff: 'font/woff',
};

Update resolveInputFormat to special-case dfont before the generic file-type path:

export async function resolveInputFormat(filePath, declaredFormat) {
  if (declaredFormat === 'dfont') {
    const buffer = await fs.readFile(filePath);
    return probeDfont(buffer)
      ? { mime: 'application/x-dfont', valid: true }
      : { mime: null, valid: false };
  }

  const detected = await detectInputMime(filePath);

  if (!detected) {
    const fallbackMime = UNDETECTABLE_TEXT_FORMATS[declaredFormat];
    if (fallbackMime) {
      return { mime: fallbackMime, valid: true };
    }
    return { mime: null, valid: false };
  }

  const valid = normalizeFormat(detected.ext) === normalizeFormat(declaredFormat);
  return { mime: detected.mime, valid };
}
  • Step 4: Run the tests to verify they pass

Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/mime.test.js Expected: PASS (all tests in the file).

  • Step 5: Commit
git add src/mime.js test/mime.test.js
git commit -m "feat: add font MIME types and fix dfont/ico detection collision"

Task 5: Wire converters into the app/worker and end-to-end API tests

Files:

  • Modify: src/app.js
  • Modify: src/worker.js
  • Test: test/api/jobs.test.js

Interfaces:

  • Consumes: registerFontConverter (Task 2), registerDfontConverter (Task 3).

  • Produces: nothing new for later tasks — this is the last task in the plan.

  • Step 1: Write the failing tests

Add to test/api/jobs.test.js:

describe('POST /api/jobs — fonts', () => {
  it('creates a pending job converting an OTF upload to ttf', async () => {
    const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.otf');

    const response = await request(app)
      .post('/api/jobs')
      .field('targetFormats', JSON.stringify(['ttf']))
      .attach('files', fixturePath, 'font.otf');

    expect(response.status).toBe(201);
    expect(response.body.jobs[0].status).toBe('pending');

    const job = await getJobByUuid(prisma, response.body.jobs[0].id);
    expect(job.sourceFormat).toBe('otf');
    expect(job.targetFormat).toBe('ttf');
    expect(job.family).toBe('font');
    expect(job.inputMimeType).toBe('font/otf');
  });

  it('creates a pending job converting a dfont upload despite file-type misidentifying its content as ico', async () => {
    const fixturePath = path.join(import.meta.dirname, '..', 'fixtures', 'sample.dfont');

    const response = await request(app)
      .post('/api/jobs')
      .field('targetFormats', JSON.stringify(['ttf']))
      .attach('files', fixturePath, 'font.dfont');

    expect(response.status).toBe(201);
    expect(response.body.jobs[0].status).toBe('pending');

    const job = await getJobByUuid(prisma, response.body.jobs[0].id);
    expect(job.sourceFormat).toBe('dfont');
    expect(job.inputMimeType).toBe('application/x-dfont');
  });

  it('lists ttf/otf/woff as targets for each other via GET /api/formats, and never lists dfont as a target', async () => {
    const ttfTargets = await request(app).get('/api/formats').query({ source: 'ttf' });
    expect(ttfTargets.body.targets).toEqual(expect.arrayContaining(['otf', 'woff']));

    const otfTargets = await request(app).get('/api/formats').query({ source: 'otf' });
    expect(otfTargets.body.targets).not.toContain('dfont');
  });
});
  • Step 2: Run the tests to verify they fail

Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/api/jobs.test.js Expected: FAIL — both new POST cases get response.body.jobs[0].error matching /Unsupported conversion/ instead of a pending job, since registerFontConverter/registerDfontConverter are never called by src/app.js.

  • Step 3: Wire the converters into src/app.js

Add the imports alongside the existing converter imports:

import { registerFontConverter } from './converters/font.js';
import { registerDfontConverter } from './converters/dfont.js';

Add both calls inside registerAllConverters():

function registerAllConverters() {
  if (convertersRegistered) return;
  registerImageConverters();
  registerImageToPdfConverter();
  registerDocumentConverters();
  registerIcoConverter();
  registerHeicConverter();
  registerFontConverter();
  registerDfontConverter();
  convertersRegistered = true;
}
  • Step 4: Wire the converters into src/worker.js

Add the same imports, and add both calls inside main():

import { registerFontConverter } from './converters/font.js';
import { registerDfontConverter } from './converters/dfont.js';
  registerImageConverters();
  registerImageToPdfConverter();
  registerDocumentConverters();
  registerIcoConverter();
  registerHeicConverter();
  registerFontConverter();
  registerDfontConverter();
  • Step 5: Run the tests to verify they pass

Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run test/api/jobs.test.js Expected: PASS (all tests in the file).

  • Step 6: Run the full test suite

Run: DB_HOST=127.0.0.1 DB_USER=convert_user DB_PASSWORD=change_me DB_NAME=file_converter STORAGE_DIR=./storage PORT=3000 npx vitest run Expected: PASS, except the two pre-existing unrelated failures documented in CLAUDE.md (test/cleanup.test.js and test/jobs/jobRepository.test.js expired-job clock/timezone issue) — verify against main first if any other failure appears.

  • Step 7: Commit
git add src/app.js src/worker.js test/api/jobs.test.js
git commit -m "feat: register font/dfont converters in the app and worker"

Not in this plan

  • Frontend changes: none needed. frontend/src/App.jsx's target-format dropdown and per-format controls (QUALITY_FORMATS, 'ico', 'png', 'pdf' checks) are all driven by GET /api/formats, which reflects the registry — font formats work automatically once Task 5 is done. Do a manual browser check (upload a .otf/.ttf/.woff/.dfont file, confirm the target dropdown and conversion work) since there is no frontend test framework, consistent with how quality/iconSize were verified.
  • Database changes: none. family is a free-text VARCHAR(32) column (no enum), and fonts have no quality/size-style knob.