/**
* @jest-environment node
*
* ExportThemes tests: LaTeX header generation (content + injection safety)
* and DOCX styles.xml patching against a minimal pandoc-style fixture.
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const PizZip = require('pizzip');
const {
THEMES,
getTheme,
listThemes,
buildLatexThemeHeader,
applyDocxTheme,
} = require('../../src/main/ExportThemes');
// Minimal pandoc-like word/styles.xml: heading + hyperlink styles + docDefaults
const STYLES_XML = `
`;
function writeDocxFixture(filePath) {
const zip = new PizZip();
zip.file(
'[Content_Types].xml',
''
);
zip.file('word/styles.xml', STYLES_XML);
zip.file('word/document.xml', '');
fs.writeFileSync(filePath, zip.generate({ type: 'nodebuffer' }));
}
function readStyles(filePath) {
return new PizZip(fs.readFileSync(filePath)).file('word/styles.xml').asText();
}
describe('ExportThemes', () => {
describe('definitions', () => {
it('lists themes with labels in a stable order, default first', () => {
const list = listThemes();
expect(list[0].id).toBe('default');
expect(list.map((t) => t.id)).toEqual(Object.keys(THEMES));
expect(list.every((t) => t.label && t.description)).toBe(true);
});
it('falls back to default for unknown ids', () => {
expect(getTheme('nope').id).toBeUndefined(); // default theme has no id field
expect(getTheme('nope').label).toBe('Default (Pandoc)');
expect(getTheme('modern').label).toBe('Modern');
});
});
describe('buildLatexThemeHeader', () => {
it('returns null for the default theme', () => {
expect(buildLatexThemeHeader('default')).toBeNull();
expect(buildLatexThemeHeader('unknown-id')).toBeNull();
});
it('emits xcolor, heading color, and titlesec formatting', () => {
const tex = buildLatexThemeHeader('modern');
expect(tex).toContain('\\usepackage{xcolor}');
// \definecolor{mcthemeheading}{HTML}{1E4FD8}
expect(tex).toContain('\\definecolor{mcthemeheading}{HTML}{1E4FD8}');
expect(tex).toContain('\\definecolor{mcthemelink}{HTML}{0E7490}');
expect(tex).toContain('\\titleformat{\\section}');
// modern = sans headings
expect(tex).toContain('helvet');
});
it('omits section rules and helvet for themes that decline them', () => {
const tex = buildLatexThemeHeader('minimal');
expect(tex).not.toContain('helvet');
expect(tex).not.toContain('titlerule');
});
it('only inlines hex literals (no user input reaches LaTeX)', () => {
for (const id of Object.keys(THEMES)) {
const tex = buildLatexThemeHeader(id);
if (!tex) continue;
// Every color is the {HTML}{HEXHEXHEX} literal form
const colors = tex.match(/\{HTML\}\{([^}]*)\}/g) || [];
expect(colors.length).toBeGreaterThan(0);
for (const c of colors) {
expect(c).toMatch(/^\{HTML\}\{[A-F0-9]{6}\}$/);
}
}
});
});
describe('applyDocxTheme', () => {
let tmpDir, docxPath;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'theme_'));
docxPath = path.join(tmpDir, 'doc.docx');
writeDocxFixture(docxPath);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
it('is a no-op returning false for the default theme', () => {
expect(applyDocxTheme(docxPath, 'default')).toBe(false);
expect(readStyles(docxPath)).toBe(STYLES_XML);
});
it('recolors headings, swaps heading font, and recolors hyperlinks', () => {
const applied = applyDocxTheme(docxPath, 'modern');
expect(applied).toBe(true);
const xml = readStyles(docxPath);
// Heading color + font injected
expect(xml).toContain('');
expect(xml).toContain('w:ascii="Calibri"');
// Heading2's old color is replaced, not duplicated
expect(xml).not.toContain('2E74B5');
const h1 = /w:styleId="Heading1"[\s\S]*?<\/w:style>/.exec(xml)[0];
expect(h1.match(/');
// Body font lands in docDefaults
expect(xml).toMatch(/\s* {
applyDocxTheme(docxPath, 'classic');
const xml = readStyles(docxPath);
expect(xml).toContain('');
expect(xml).toContain('');
expect(xml).toContain('');
});
it('tolerates styles.xml without an rPr block in a style', () => {
// Title style in the fixture has rPr; craft one without it
const bare = STYLES_XML.replace(
/(\s*)\s*<\/w:rPr>/,
'$1'
);
const zip = new PizZip();
zip.file('[Content_Types].xml', '');
zip.file('word/styles.xml', bare);
fs.writeFileSync(docxPath, zip.generate({ type: 'nodebuffer' }));
expect(applyDocxTheme(docxPath, 'sepia')).toBe(true);
const xml = readStyles(docxPath);
const title = /w:styleId="Title"[\s\S]*?<\/w:style>/.exec(xml)[0];
expect(title).toContain('');
});
it('returns false when styles.xml is missing', () => {
const zip = new PizZip();
zip.file('[Content_Types].xml', '');
fs.writeFileSync(docxPath, zip.generate({ type: 'nodebuffer' }));
expect(applyDocxTheme(docxPath, 'modern')).toBe(false);
});
});
});