mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-23 23:10:17 +05:30
feat(monospace): DocxFontEmbedder injects TTF into pandoc DOCX output
Idempotent. Patches fontTable.xml, [Content_Types].xml, .rels, styles.xml.
This commit is contained in:
@@ -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 =
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
|
||||
`<w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/main">\n` +
|
||||
fonts
|
||||
.map((f, i) =>
|
||||
` <w:font w:name="${f.family}"><w:embedRegular r:id="${generatedRId(i)}" xmlns:r="${REL_NS}"/></w:font>`
|
||||
)
|
||||
.join('\n') +
|
||||
`\n</w:fonts>\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(
|
||||
'</Types>',
|
||||
'<Override PartName="/word/fontTable.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml"/></Types>'
|
||||
);
|
||||
}
|
||||
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('</Types>', `<Override PartName="${filePart}" ContentType="${ttfCt}"/></Types>`);
|
||||
}
|
||||
}
|
||||
if (!ct.includes('Default Extension="ttf"')) {
|
||||
ct = ct.replace('</Types>', '<Default Extension="ttf" ContentType="application/x-font-ttf"/></Types>');
|
||||
}
|
||||
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,
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<Relationships xmlns="${REL_NS}"/>`
|
||||
);
|
||||
}
|
||||
let rels = await zip.file(relsPath).async('string');
|
||||
if (!rels.includes('fontTable.xml')) {
|
||||
rels = rels.replace(
|
||||
'</Relationships>',
|
||||
`<Relationship Id="${generatedRId(fonts.length)}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable" Target="fontTable.xml"/></Relationships>`
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < fonts.length; i++) {
|
||||
const fname = path.basename(fonts[i].path);
|
||||
if (!rels.includes(fname)) {
|
||||
rels = rels.replace(
|
||||
'</Relationships>',
|
||||
`<Relationship Id="${generatedRId(i)}" Type="http://schemas.microsoft.com/office/2011/relationships/font" Target="fonts/${fname}"/></Relationships>`
|
||||
);
|
||||
}
|
||||
}
|
||||
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:style[^>]*w:styleId="(?:SourceCode|VerbatimChar)"[^>]*>)/,
|
||||
`$1<w:rPr><w:rFonts w:ascii="${family}" w:hAnsi="${family}" w:cs="${family}"/></w:rPr>`
|
||||
);
|
||||
}
|
||||
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 };
|
||||
@@ -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', '<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"></Types>');
|
||||
zip.file('_rels/.rels', '<?xml version="1.0"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>');
|
||||
zip.file('word/document.xml', '<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/main"><w:body/></w:document>');
|
||||
zip.file('word/styles.xml', '<?xml version="1.0"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/main"><w:style w:type="character" w:styleId="SourceCode"><w:name w:val="Source Code"/></w:style></w:styles>');
|
||||
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:embedRegular/);
|
||||
const styles = await zip.file('word/styles.xml').async('string');
|
||||
expect(styles).toMatch(/<w:rFonts[^>]*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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user