diff --git a/src/main/DocxFontEmbedder.js b/src/main/DocxFontEmbedder.js new file mode 100644 index 0000000..8ff701a --- /dev/null +++ b/src/main/DocxFontEmbedder.js @@ -0,0 +1,46 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const JSZip = require('jszip'); + +const FONT_TABLE_PATH = 'word/fontTable.xml'; +const FONT_TABLE_RELS_PATH = 'word/_rels/fontTable.xml.rels'; + +function fontTableXml(family) { + return ` + + + + + + + + +`; +} + +function fontTableRels(fontFilename) { + return ` + + +`; +} + +async function embedDocxFont(inputDocxPath, outputDocxPath, ttfPath, fontFamily) { + if (!ttfPath || !fs.existsSync(ttfPath)) { + throw new Error(`DocxFontEmbedder: TTF not found at ${ttfPath}`); + } + const inputBytes = fs.readFileSync(inputDocxPath); + const zip = await JSZip.loadAsync(inputBytes); + const safeFamily = String(fontFamily).replace(/[^A-Za-z0-9]/g, ''); + const fontFilename = `${safeFamily}.ttf`; + const fontBytes = fs.readFileSync(ttfPath); + zip.file(`word/fonts/${fontFilename}`, fontBytes); + zip.file(FONT_TABLE_PATH, fontTableXml(safeFamily)); + zip.file(FONT_TABLE_RELS_PATH, fontTableRels(fontFilename)); + const out = await zip.generateAsync({ type: 'nodebuffer' }); + fs.writeFileSync(outputDocxPath, out); +} + +module.exports = { embedDocxFont }; diff --git a/tests/unit/main/monospace/DocxFontEmbedder.test.js b/tests/unit/main/monospace/DocxFontEmbedder.test.js new file mode 100644 index 0000000..c52b93c --- /dev/null +++ b/tests/unit/main/monospace/DocxFontEmbedder.test.js @@ -0,0 +1,65 @@ +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const JSZip = require('jszip'); + +const { embedDocxFont } = require('../../../../src/main/DocxFontEmbedder'); + +async function makeMinimalDocx(outPath) { + const zip = new JSZip(); + zip.file( + '[Content_Types].xml', + '' + ); + zip.file( + '_rels/.rels', + '' + ); + zip.file('word/document.xml', ''); + const buf = await zip.generateAsync({ type: 'nodebuffer' }); + fs.writeFileSync(outPath, buf); +} + +describe('DocxFontEmbedder', () => { + let tmpDir; + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mono-docx-')); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('embeds TTF into word/fonts and updates rels', async () => { + const input = path.join(tmpDir, 'in.docx'); + const output = path.join(tmpDir, 'out.docx'); + const ttf = path.join(tmpDir, 'JetBrainsMono-Regular.ttf'); + await makeMinimalDocx(input); + fs.writeFileSync(ttf, Buffer.from([0x01, 0x00, 0x00, 0x00, 0x00])); + + await embedDocxFont(input, output, ttf, 'JetBrainsMono'); + + const result = await JSZip.loadAsync(fs.readFileSync(output)); + const fontFiles = Object.keys(result.files).filter((n) => n.startsWith('word/fonts/')); + expect(fontFiles.length).toBeGreaterThan(0); + const rels = result.file('word/_rels/fontTable.xml.rels'); + expect(rels).toBeTruthy(); + const relsContent = await rels.async('string'); + expect(relsContent).toContain('.ttf'); + }); + + test('returns rejected promise for invalid docx input', async () => { + const input = path.join(tmpDir, 'invalid.docx'); + const output = path.join(tmpDir, 'out.docx'); + const ttf = path.join(tmpDir, 'JetBrainsMono-Regular.ttf'); + fs.writeFileSync(input, Buffer.from('not a docx')); + fs.writeFileSync(ttf, Buffer.from([0x01])); + await expect(embedDocxFont(input, output, ttf, 'JetBrainsMono')).rejects.toThrow(); + }); + + test('throws if ttf missing', async () => { + const input = path.join(tmpDir, 'in.docx'); + const output = path.join(tmpDir, 'out.docx'); + await makeMinimalDocx(input); + await expect(embedDocxFont(input, output, '/nope.ttf', 'JetBrainsMono')).rejects.toThrow(/TTF/); + }); +});