mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 17:29:29 +05:30
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,15 @@ describe('ImageOperations', () => {
|
||||
.png()
|
||||
.toFile(inputPath);
|
||||
});
|
||||
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
|
||||
// Windows: sharp can hold the file handle briefly after await returns,
|
||||
// so plain rmSync throws EPERM — retry and tolerate stale temp dirs.
|
||||
afterEach(() => {
|
||||
try {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
} catch {
|
||||
/* leftover temp dir is cosmetic on locked-file platforms */
|
||||
}
|
||||
});
|
||||
|
||||
test('imageConvert converts PNG to JPEG', async () => {
|
||||
const outputPath = path.join(tmpDir, 'out.jpg');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
* @jest-environment-options {"testTimeout": 30000}
|
||||
*
|
||||
* PDFBatchOperations.js tests for Task 22's batch PDF operations: the folder
|
||||
* loop that applies one PDFOperations.executeOperation() op to every .pdf in an
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
* @jest-environment-options {"testTimeout": 30000}
|
||||
*
|
||||
* PDFOperations.js tests for Task 15's new operations: extractText, pageNumbers,
|
||||
* crop, extractImages. Uses pdf-lib to build minimal fixture PDFs at test time,
|
||||
@@ -47,13 +48,19 @@ describe('PDFOperations - Task 15 new operations', () => {
|
||||
});
|
||||
|
||||
describe('pdfExtractText', () => {
|
||||
it('extracts text from all pages', async () => {
|
||||
const result = await PDFOperations.pdfExtractText({ inputPath });
|
||||
it(
|
||||
'extracts text from all pages',
|
||||
async () => {
|
||||
const result = await PDFOperations.pdfExtractText({ inputPath });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.text).toContain('Hello Task 15 Page One');
|
||||
expect(result.text).toContain('Second Page Content');
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.text).toContain('Hello Task 15 Page One');
|
||||
expect(result.text).toContain('Second Page Content');
|
||||
},
|
||||
// First pdfjs-dist legacy import can exceed the 5s default on slower
|
||||
// CI runners (observed on windows-latest)
|
||||
30000
|
||||
);
|
||||
|
||||
it('returns failure for a nonexistent file', async () => {
|
||||
const result = await PDFOperations.pdfExtractText({
|
||||
@@ -150,38 +157,42 @@ describe('PDFOperations - Task 15 new operations', () => {
|
||||
});
|
||||
|
||||
describe('pdfExtractImages', () => {
|
||||
it('extracts embedded raster images as PNG files', async () => {
|
||||
const imgPath = path.join(tmpDir, 'red.png');
|
||||
await sharp({
|
||||
create: { width: 20, height: 20, channels: 3, background: { r: 255, g: 0, b: 0 } },
|
||||
})
|
||||
.png()
|
||||
.toFile(imgPath);
|
||||
it(
|
||||
'extracts embedded raster images as PNG files',
|
||||
async () => {
|
||||
const imgPath = path.join(tmpDir, 'red.png');
|
||||
await sharp({
|
||||
create: { width: 20, height: 20, channels: 3, background: { r: 255, g: 0, b: 0 } },
|
||||
})
|
||||
.png()
|
||||
.toFile(imgPath);
|
||||
|
||||
const doc = await PDFDocument.create();
|
||||
const page = doc.addPage([300, 300]);
|
||||
const pngImage = await doc.embedPng(fs.readFileSync(imgPath));
|
||||
page.drawImage(pngImage, { x: 50, y: 50, width: 100, height: 100 });
|
||||
const doc = await PDFDocument.create();
|
||||
const page = doc.addPage([300, 300]);
|
||||
const pngImage = await doc.embedPng(fs.readFileSync(imgPath));
|
||||
page.drawImage(pngImage, { x: 50, y: 50, width: 100, height: 100 });
|
||||
|
||||
const imagePdfPath = path.join(tmpDir, 'with-image.pdf');
|
||||
fs.writeFileSync(imagePdfPath, await doc.save());
|
||||
const imagePdfPath = path.join(tmpDir, 'with-image.pdf');
|
||||
fs.writeFileSync(imagePdfPath, await doc.save());
|
||||
|
||||
const outputDir = path.join(tmpDir, 'extracted');
|
||||
const result = await PDFOperations.pdfExtractImages({
|
||||
inputPath: imagePdfPath,
|
||||
outputDir,
|
||||
});
|
||||
const outputDir = path.join(tmpDir, 'extracted');
|
||||
const result = await PDFOperations.pdfExtractImages({
|
||||
inputPath: imagePdfPath,
|
||||
outputDir,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.count).toBeGreaterThanOrEqual(1);
|
||||
expect(result.files.length).toBe(result.count);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.count).toBeGreaterThanOrEqual(1);
|
||||
expect(result.files.length).toBe(result.count);
|
||||
|
||||
for (const file of result.files) {
|
||||
expect(fs.existsSync(file)).toBe(true);
|
||||
const meta = await sharp(file).metadata();
|
||||
expect(meta.format).toBe('png');
|
||||
}
|
||||
});
|
||||
for (const file of result.files) {
|
||||
expect(fs.existsSync(file)).toBe(true);
|
||||
const meta = await sharp(file).metadata();
|
||||
expect(meta.format).toBe('png');
|
||||
}
|
||||
},
|
||||
30000 // pdfjs import + sharp decode on slower CI runners
|
||||
);
|
||||
|
||||
it('returns zero images for a text-only PDF', async () => {
|
||||
const outputDir = path.join(tmpDir, 'extracted-none');
|
||||
|
||||
@@ -22,7 +22,10 @@ describe('MonospaceFontConfig', () => {
|
||||
(p) => !p.includes('app.asar.unpacked') && p.endsWith('JetBrainsMono-Regular.ttf')
|
||||
);
|
||||
const p = MonospaceFontConfig.getMonoFontTtfPath('jetbrains-mono', 400);
|
||||
expect(p).toMatch(/assets\/fonts\/JetBrainsMono-Regular\.ttf$/);
|
||||
// Normalize separators: Windows paths are backslash-joined
|
||||
expect(p.split(require('path').sep).join('/')).toMatch(
|
||||
/assets\/fonts\/JetBrainsMono-Regular\.ttf$/
|
||||
);
|
||||
});
|
||||
|
||||
test('returns packaged asar.unpacked path when present and file exists', () => {
|
||||
|
||||
Reference in New Issue
Block a user