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.
This commit is contained in:
@@ -0,0 +1,746 @@
|
|||||||
|
# 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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install fonteditor-core opentype.js fontkit
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Download the font fixtures**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 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`:
|
||||||
|
|
||||||
|
```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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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`):
|
||||||
|
|
||||||
|
```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 * 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:
|
||||||
|
|
||||||
|
```js
|
||||||
|
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`**
|
||||||
|
|
||||||
|
```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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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`:
|
||||||
|
|
||||||
|
```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`**
|
||||||
|
|
||||||
|
```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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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`:
|
||||||
|
|
||||||
|
```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:
|
||||||
|
|
||||||
|
```js
|
||||||
|
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:
|
||||||
|
|
||||||
|
```js
|
||||||
|
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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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`:
|
||||||
|
|
||||||
|
```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:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { registerFontConverter } from './converters/font.js';
|
||||||
|
import { registerDfontConverter } from './converters/dfont.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
Add both calls inside `registerAllConverters()`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
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()`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { registerFontConverter } from './converters/font.js';
|
||||||
|
import { registerDfontConverter } from './converters/dfont.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
```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**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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.
|
||||||
Reference in New Issue
Block a user