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