diff --git a/src/main/EpubFontEmbedder.js b/src/main/EpubFontEmbedder.js new file mode 100644 index 0000000..deb2b29 --- /dev/null +++ b/src/main/EpubFontEmbedder.js @@ -0,0 +1,37 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const JSZip = require('jszip'); + +function withEpubEmbedFontArgs(pandocArgs, ttfPath, _fontFamily) { + return [`--epub-embed-font=${ttfPath}`, ...pandocArgs]; +} + +async function embedEpubFont(epubPath, ttfPath, _fontFamily) { + if (!ttfPath || !fs.existsSync(ttfPath)) { + throw new Error(`EpubFontEmbedder: TTF not found at ${ttfPath}`); + } + const bytes = fs.readFileSync(epubPath); + const zip = await JSZip.loadAsync(bytes); + const ttfBytes = fs.readFileSync(ttfPath); + const fontName = path.basename(ttfPath); + zip.file(`OEBPS/${fontName}`, ttfBytes); + const opfPath = 'OEBPS/content.opf'; + const opfFile = zip.file(opfPath); + if (!opfFile) { + fs.writeFileSync(epubPath, await zip.generateAsync({ type: 'nodebuffer' })); + return; + } + let opf = await opfFile.async('string'); + const manifestEntry = ``; + if (!opf.includes('manifest')) { + opf = opf.replace('', `${manifestEntry}`); + } else if (!opf.includes(fontName)) { + opf = opf.replace('', `${manifestEntry}`); + } + zip.file(opfPath, opf); + fs.writeFileSync(epubPath, await zip.generateAsync({ type: 'nodebuffer' })); +} + +module.exports = { withEpubEmbedFontArgs, embedEpubFont }; diff --git a/tests/unit/main/monospace/EpubFontEmbedder.test.js b/tests/unit/main/monospace/EpubFontEmbedder.test.js new file mode 100644 index 0000000..b5ee766 --- /dev/null +++ b/tests/unit/main/monospace/EpubFontEmbedder.test.js @@ -0,0 +1,53 @@ +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const JSZip = require('jszip'); + +const { withEpubEmbedFontArgs, embedEpubFont } = require('../../../../src/main/EpubFontEmbedder'); + +async function makeMinimalEpub(outPath) { + const zip = new JSZip(); + zip.file( + 'META-INF/container.xml', + '' + ); + zip.file('OEBPS/content.opf', ''); + fs.writeFileSync(outPath, await zip.generateAsync({ type: 'nodebuffer' })); +} + +describe('EpubFontEmbedder', () => { + let tmpDir; + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mono-epub-')); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('withEpubEmbedFontArgs prepends --epub-embed-font', () => { + const args = withEpubEmbedFontArgs(['-o', 'out.epub', 'in.md'], '/tmp/font.ttf', 'JetBrains Mono'); + expect(args).toContain('--epub-embed-font=/tmp/font.ttf'); + expect(args.indexOf('--epub-embed-font=/tmp/font.ttf')).toBeLessThan(args.indexOf('-o')); + }); + + test('embedEpubFont adds font to OEBPS and updates manifest', async () => { + const epub = path.join(tmpDir, 'book.epub'); + const ttf = path.join(tmpDir, 'JetBrainsMono-Regular.ttf'); + await makeMinimalEpub(epub); + fs.writeFileSync(ttf, Buffer.from([0x01, 0x00])); + + await embedEpubFont(epub, ttf, 'JetBrains Mono'); + + const zip = await JSZip.loadAsync(fs.readFileSync(epub)); + const fontFile = zip.file('OEBPS/JetBrainsMono-Regular.ttf'); + expect(fontFile).toBeTruthy(); + const opf = await zip.file('OEBPS/content.opf').async('string'); + expect(opf).toContain('JetBrainsMono-Regular.ttf'); + }); + + test('embedEpubFont rejects missing TTF', async () => { + const epub = path.join(tmpDir, 'book.epub'); + await makeMinimalEpub(epub); + await expect(embedEpubFont(epub, '/nope.ttf', 'JetBrains Mono')).rejects.toThrow(/TTF/); + }); +});