diff --git a/src/main.js b/src/main.js index 1acac86..cb028a0 100644 --- a/src/main.js +++ b/src/main.js @@ -9,6 +9,7 @@ const GitOperations = require('./main/GitOperations'); const PdfFontHeader = require('./main/PdfFontHeader'); const MonospaceFontConfig = require('./main/MonospaceFontConfig'); const ExportCss = require('./main/ExportCss'); +const EpubFontEmbedder = require('./main/EpubFontEmbedder'); // Add MiKTeX to PATH for LaTeX support if (process.platform === 'win32') { @@ -2838,6 +2839,45 @@ function performExportWithOptions(format, options) { } else if (format === 'confluence' || format === 'jira') { pandocCmd = `${getPandocPath()} "${currentFile}" -t jira -o "${outputFile}"`; exportWithPandoc(pandocCmd, outputFile, format); + } else if (format === 'epub') { + // Embed the active monospace TTF into EPUB so code blocks render in + // JetBrains Mono / Fira Code regardless of the reader's installed fonts. + if (pandocSupportsEpubEmbedFont()) { + const familyKey = readSettingsJsonCached().monospaceFont || 'jetbrains-mono'; + const regular = MonospaceFontConfig.getMonoFontTtfPath(familyKey, 400); + const bold = MonospaceFontConfig.getMonoFontTtfPath(familyKey, 700); + [regular, bold].filter(Boolean).forEach((p) => { + pandocCmd += ` --epub-embed-font="${p}"`; + }); + } + runPandocCmd(pandocCmd, async (error) => { + if (error) { + dialog.showErrorBox( + 'Export Error', + sanitizeErrorMessage(`Failed to export EPUB: ${error.message}`) + ); + return; + } + // Patch the OPF manifest so readers can locate the embedded font. + // Skip silently if the file is missing or pandoc didn't embed. + try { + const familyKey = readSettingsJsonCached().monospaceFont || 'jetbrains-mono'; + const regular = MonospaceFontConfig.getMonoFontTtfPath(familyKey, 400); + const fonts = []; + if (regular) fonts.push({ path: regular, family: familyKey === 'fira-code' ? 'Fira Code' : 'JetBrains Mono', weight: 400 }); + if (fonts.length) { + const patched = await EpubFontEmbedder.patchManifest(outputFile, fonts); + try { + fs.renameSync(patched, outputFile); + } catch (renameErr) { + if (typeof console !== 'undefined') console.warn('[epub] could not overwrite with patched EPUB:', renameErr.message); + } + } + } catch (patchErr) { + if (typeof console !== 'undefined') console.warn('[epub] manifest patch failed:', patchErr.message); + } + showExportSuccess(outputFile); + }); } else if (format === 'mobi') { // First export to EPUB, then try ebook-convert if available const epubFile = outputFile.replace(/\.mobi$/i, '.epub'); diff --git a/src/main/EpubFontEmbedder.js b/src/main/EpubFontEmbedder.js new file mode 100644 index 0000000..8895420 --- /dev/null +++ b/src/main/EpubFontEmbedder.js @@ -0,0 +1,44 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const JSZip = require('jszip'); + +// Patches an EPUB produced by pandoc so it embeds the supplied TTF font files +// inside the OEBPS and registers them in the OPF manifest. Used after +// `pandoc --epub-embed-font=...` runs (which embeds the font data) but does +// not always add the manifest item we need for readers to discover the font. +// +// Writes a new file at `${epubPath}.patched.epub` and returns the patched path. +// Caller should overwrite the original after a successful export. +async function patchManifest(epubPath, fonts) { + const zip = await JSZip.loadAsync(fs.readFileSync(epubPath)); + const opfPath = Object.keys(zip.files).find((f) => f.endsWith('content.opf')); + if (!opfPath) throw new Error('EPUB has no content.opf'); + let opf = await zip.file(opfPath).async('string'); + + for (const { path: fontPath, family, weight } of fonts) { + const filename = path.basename(fontPath); + const inFontDir = `OEBPS/fonts/${filename}`; + zip.file(inFontDir, fs.readFileSync(fontPath)); + if (!opf.includes(filename)) { + const safeFamily = String(family || 'Font').replace(/\s+/g, '-'); + const item = ``; + if (opf.includes('')) { + opf = opf.replace('', `${item}`); + } else { + // OPF without a manifest element (unusual but tolerated): inject one + // just before so the font item is still discoverable. + opf = opf.replace('', `${item}`); + } + } + } + + zip.file(opfPath, opf); + const buf = await zip.generateAsync({ type: 'nodebuffer' }); + const tmp = `${epubPath}.patched.epub`; + fs.writeFileSync(tmp, buf); + return tmp; +} + +module.exports = { patchManifest }; \ No newline at end of file diff --git a/tests/epub-font-embedder.test.js b/tests/epub-font-embedder.test.js new file mode 100644 index 0000000..b02d078 --- /dev/null +++ b/tests/epub-font-embedder.test.js @@ -0,0 +1,31 @@ +const fs = require('fs'); +const path = require('path'); +const JSZip = require('jszip'); +const EpubFontEmbedder = require('../src/main/EpubFontEmbedder'); + +describe('EpubFontEmbedder.patchManifest', () => { + const fixturesDir = path.join(__dirname, 'fixtures'); + const epubPath = path.join(fixturesDir, 'fake.epub'); + const fontPath = path.join(fixturesDir, 'fake.ttf'); + + beforeAll(async () => { + fs.mkdirSync(fixturesDir, { recursive: true }); + fs.writeFileSync(fontPath, 'fake-ttf-binary'); + const zip = new JSZip(); + zip.file('OEBPS/content.opf', ''); + fs.writeFileSync(epubPath, await zip.generateAsync({ type: 'nodebuffer' })); + }); + + afterAll(() => fs.rmSync(fixturesDir, { recursive: true, force: true })); + + test('adds a manifest entry referencing the TTF when missing', async () => { + const patched = await EpubFontEmbedder.patchManifest(epubPath, [ + { path: fontPath, family: 'JetBrains Mono', weight: 400 }, + ]); + const out = fs.readFileSync(patched); + const zip = await JSZip.loadAsync(out); + const opf = await zip.file('OEBPS/content.opf').async('string'); + expect(opf).toMatch(/]*href="OEBPS\/fonts\/fake\.ttf"/); + expect(opf).toMatch(/]*media-type="application\/x-font-ttf"/); + }); +}); \ No newline at end of file