From e5e14c88ce830995334b5f5b5e654c8268971317 Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Tue, 30 Jun 2026 23:36:41 +0530 Subject: [PATCH] feat(monospace): DocxFontEmbedder injects TTF into pandoc DOCX output Idempotent. Patches fontTable.xml, [Content_Types].xml, .rels, styles.xml. --- src/main/DocxFontEmbedder.js | 119 +++++++++++++++++++++++++++++++ tests/docx-font-embedder.test.js | 44 ++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 src/main/DocxFontEmbedder.js create mode 100644 tests/docx-font-embedder.test.js diff --git a/src/main/DocxFontEmbedder.js b/src/main/DocxFontEmbedder.js new file mode 100644 index 0000000..9458c08 --- /dev/null +++ b/src/main/DocxFontEmbedder.js @@ -0,0 +1,119 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const JSZip = require('jszip'); + +const REL_NS = 'http://schemas.openxmlformats.org/package/2006/relationships'; + +function generatedRId(idx) { + return `rIdFont${idx}`; +} + +async function patchZipWithFonts(inputPath, fonts) { + const buf = fs.readFileSync(inputPath); + const zip = await JSZip.loadAsync(buf); + const existingFontNames = new Set(); + + // Detect prior embeds (idempotency: skip TTF files already present). + for (const f of Object.keys(zip.files)) { + if (zip.files[f].name && /^word\/fonts\//.test(zip.files[f].name)) { + existingFontNames.add(path.basename(f)); + } + } + + for (let i = 0; i < fonts.length; i++) { + const { path: fontPath } = fonts[i]; + const fname = path.basename(fontPath); + const wordPath = `word/fonts/${fname}`; + if (!existingFontNames.has(fname)) { + zip.file(wordPath, fs.readFileSync(fontPath)); + existingFontNames.add(fname); + } + } + + // Build/replace word/fontTable.xml so Word knows the family and where to + // fetch the embedded TTF data. + const fontTableXml = + `\n` + + `\n` + + fonts + .map((f, i) => + ` ` + ) + .join('\n') + + `\n\n`; + + zip.file('word/fontTable.xml', fontTableXml); + + // Patch [Content_Types].xml — add Override for /word/fontTable.xml and each TTF. + const ctPath = '[Content_Types].xml'; + let ct = await zip.file(ctPath).async('string'); + if (!ct.includes('PartName="/word/fontTable.xml"')) { + ct = ct.replace( + '', + '' + ); + } + for (const f of fonts) { + const ttfCt = 'application/x-font-ttf'; + const filePart = `/word/fonts/${path.basename(f.path)}`; + if (!ct.includes(`PartName="${filePart}"`)) { + ct = ct.replace('', ``); + } + } + if (!ct.includes('Default Extension="ttf"')) { + ct = ct.replace('', ''); + } + zip.file(ctPath, ct); + + // Patch word/_rels/document.xml.rels — relationships for fontTable + each font. + const relsPath = 'word/_rels/document.xml.rels'; + if (!zip.files[relsPath]) { + zip.file( + relsPath, + `\n` + ); + } + let rels = await zip.file(relsPath).async('string'); + if (!rels.includes('fontTable.xml')) { + rels = rels.replace( + '', + `` + ); + } + for (let i = 0; i < fonts.length; i++) { + const fname = path.basename(fonts[i].path); + if (!rels.includes(fname)) { + rels = rels.replace( + '', + `` + ); + } + } + zip.file(relsPath, rels); + + // Patch word/styles.xml — bind SourceCode/VerbatimChar styles to the family. + const stylesPath = 'word/styles.xml'; + if (zip.files[stylesPath]) { + let styles = await zip.file(stylesPath).async('string'); + const family = fonts[0].family; + if (!styles.includes(`w:ascii="${family}"`)) { + styles = styles.replace( + /(]*w:styleId="(?:SourceCode|VerbatimChar)"[^>]*>)/, + `$1` + ); + } + zip.file(stylesPath, styles); + } + + const outBuf = await zip.generateAsync({ type: 'nodebuffer' }); + fs.writeFileSync(inputPath, outBuf); + return inputPath; +} + +async function embed(docxPath, fonts) { + return patchZipWithFonts(docxPath, fonts); +} + +module.exports = { embed }; \ No newline at end of file diff --git a/tests/docx-font-embedder.test.js b/tests/docx-font-embedder.test.js new file mode 100644 index 0000000..308511f --- /dev/null +++ b/tests/docx-font-embedder.test.js @@ -0,0 +1,44 @@ +const fs = require('fs'); +const path = require('path'); +const JSZip = require('jszip'); +const DocxFontEmbedder = require('../src/main/DocxFontEmbedder'); + +describe('DocxFontEmbedder.embed', () => { + const dir = path.join(__dirname, 'fixtures-docx'); + const docxPath = path.join(dir, 'in.docx'); + const fontPath = path.join(dir, 'fake.ttf'); + + beforeAll(async () => { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(fontPath, 'fake-ttf-data'); + const zip = new JSZip(); + zip.file('[Content_Types].xml', ''); + zip.file('_rels/.rels', ''); + zip.file('word/document.xml', ''); + zip.file('word/styles.xml', ''); + fs.writeFileSync(docxPath, await zip.generateAsync({ type: 'nodebuffer' })); + }); + + afterAll(() => fs.rmSync(dir, { recursive: true, force: true })); + + test('embeds TTF and patches fontTable.xml + styles.xml', async () => { + const out = await DocxFontEmbedder.embed(docxPath, [ + { path: fontPath, family: 'JetBrains Mono', weight: 400 }, + ]); + const zip = await JSZip.loadAsync(fs.readFileSync(out)); + expect(Object.keys(zip.files).some((f) => f.startsWith('word/fonts/'))).toBe(true); + const fontTable = zip.file('word/fontTable.xml') ? await zip.file('word/fontTable.xml').async('string') : ''; + expect(fontTable).toContain('JetBrains Mono'); + expect(fontTable).toMatch(/]*w:ascii="JetBrains Mono"/); + }); + + test('is idempotent: running twice does not double-embed', async () => { + const once = await DocxFontEmbedder.embed(docxPath, [{ path: fontPath, family: 'JetBrains Mono', weight: 400 }]); + const twice = await DocxFontEmbedder.embed(once, [{ path: fontPath, family: 'JetBrains Mono', weight: 400 }]); + const zip = await JSZip.loadAsync(fs.readFileSync(twice)); + const fontEntries = Object.keys(zip.files).filter((f) => /^word\/fonts\/[^/]+\.ttf$/.test(f)); + expect(fontEntries.length).toBe(1); + }); +}); \ No newline at end of file