/** * @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); }); }); });