feat: add bidirectional ttf/otf/woff font converter
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,39 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
import opentype from 'opentype.js';
|
import opentype from 'opentype.js';
|
||||||
import * as fontkit from 'fontkit';
|
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';
|
||||||
|
|
||||||
async function parseWithOpentype(fixtureName) {
|
async function parseWithOpentype(fixtureName) {
|
||||||
const buf = await fs.readFile(path.join(import.meta.dirname, '..', 'fixtures', fixtureName));
|
const buf = await fs.readFile(path.join(import.meta.dirname, '..', 'fixtures', fixtureName));
|
||||||
@@ -30,3 +34,83 @@ describe('font fixtures', () => {
|
|||||||
expect(sfntType.refList).toHaveLength(1);
|
expect(sfntType.refList).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user