feat(export): themable PDF/Word exports; Windows CI green (v4.7.1)

Export themes:
- Six presets in the export dialog (basic + advanced modes) for PDF/DOCX:
  Default, Modern, Classic, Sepia, Minimal, Elegant
- PDF: LaTeX header (xcolor/titlesec) recolors headings, adds section
  rules and colored links — core-TeX packages only, hex-literal only
  (no injection surface)
- DOCX: styles.xml surgery recolors Heading1-6/Title/Subtitle/Hyperlink
  and swaps heading/body fonts; verified end-to-end against a real
  pandoc-produced docx
- Themes ride along in export presets (unknown ids fall back to Default)

Windows CI fixes:
- pdfjs standardFontDataUrl now a file:// URL (backslash paths failed
  pdfjs's trailing-slash validation, breaking extractText/extractImages)
- sharp temp cleanup EPERM retries; path-separator assertions; pdfjs
  test timeouts raised; batch suite testTimeout 30s

648/648 tests green; 4.7.1 linux+win artifacts rebuilt.
This commit is contained in:
2026-09-05 23:59:53 +05:30
parent 1b2ab7b55c
commit a2455c3f8a
12 changed files with 590 additions and 35 deletions
+181
View File
@@ -0,0 +1,181 @@
/**
* @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 = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:docDefaults>
<w:rPrDefault><w:rPr><w:sz w:val="22"/></w:rPr></w:rPrDefault>
</w:docDefaults>
<w:style w:type="paragraph" w:styleId="Heading1">
<w:name w:val="heading 1"/>
<w:pPr><w:outlineLvl w:val="0"/></w:pPr>
<w:rPr><w:b/><w:sz w:val="32"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Heading2">
<w:name w:val="heading 2"/>
<w:rPr><w:b/><w:color w:val="2E74B5"/></w:rPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Title">
<w:name w:val="Title"/>
<w:rPr><w:b/></w:rPr>
</w:style>
<w:style w:type="character" w:styleId="Hyperlink">
<w:name w:val="Hyperlink"/>
<w:rPr><w:color w:val="0563C1"/><w:u w:val="single"/></w:rPr>
</w:style>
</w:styles>`;
function writeDocxFixture(filePath) {
const zip = new PizZip();
zip.file(
'[Content_Types].xml',
'<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"/>'
);
zip.file('word/styles.xml', STYLES_XML);
zip.file('word/document.xml', '<w:document/>');
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('<w:color w:val="1E4FD8"/>');
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(/<w:color/g)).toHaveLength(1);
// Hyperlink recolored
expect(xml).not.toContain('0563C1');
expect(xml).toContain('<w:color w:val="0E7490"/>');
// Body font lands in docDefaults
expect(xml).toMatch(/<w:rPrDefault>\s*<w:rPr><w:rFonts w:ascii="Calibri"/);
});
it('preserves unrelated style content', () => {
applyDocxTheme(docxPath, 'classic');
const xml = readStyles(docxPath);
expect(xml).toContain('<w:sz w:val="32"/>');
expect(xml).toContain('<w:u w:val="single"/>');
expect(xml).toContain('<w:outlineLvl w:val="0"/>');
});
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(
/(<w:style w:type="paragraph" w:styleId="Title">\s*<w:name w:val="Title"\/>)\s*<w:rPr><w:b\/><\/w:rPr>/,
'$1'
);
const zip = new PizZip();
zip.file('[Content_Types].xml', '<Types/>');
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('<w:color w:val="7C4A21"/>');
});
it('returns false when styles.xml is missing', () => {
const zip = new PizZip();
zip.file('[Content_Types].xml', '<Types/>');
fs.writeFileSync(docxPath, zip.generate({ type: 'nodebuffer' }));
expect(applyDocxTheme(docxPath, 'modern')).toBe(false);
});
});
});