diff --git a/src/converters/font.js b/src/converters/font.js new file mode 100644 index 0000000..d716670 --- /dev/null +++ b/src/converters/font.js @@ -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); + } + }, + }); + } + } +} diff --git a/test/converters/font.test.js b/test/converters/font.test.js index 184b6fb..83b8d11 100644 --- a/test/converters/font.test.js +++ b/test/converters/font.test.js @@ -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 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'; async function parseWithOpentype(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); }); }); + +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'); + }); +});