diff --git a/README.md b/README.md
index de494c5..307f846 100644
--- a/README.md
+++ b/README.md
@@ -47,6 +47,7 @@ A powerful cross-platform Markdown editor and document converter powered by Pand
- **EPUB** - E-book format
- **LaTeX** - Academic document format
- **RTF** - Rich Text Format
+- **Export themes** - Five visual styles (Modern, Classic, Sepia, Minimal, Elegant) for PDF and Word exports — recolored headings, links, and fonts
### Advanced Features
- **Custom headers & footers** - Add headers/footers to exports with dynamic fields
diff --git a/UPDATES.md b/UPDATES.md
index a2d4bc3..e2fdca2 100644
--- a/UPDATES.md
+++ b/UPDATES.md
@@ -1,5 +1,35 @@
# PanConverter - Updates & Changelog
+## Version 4.7.1 (2026-09-05)
+
+### New: Export Themes (Word + PDF)
+- Theme picker in the export dialog (basic and advanced mode) for PDF and DOCX:
+ **Default (Pandoc), Modern, Classic, Sepia, Minimal, Elegant**
+- PDF: LaTeX header recolors/reformats headings + links (xcolor/titlesec,
+ core-TeX packages only); DOCX: styles.xml surgery recolors Heading1-6/Title/
+ Subtitle/Hyperlink and swaps heading/body fonts
+- Themes persist in export presets; unknown ids in old presets fall back to
+ Default instead of failing the export
+
+### Branding
+- New M↓ brand identity: app icons, favicons, tray icon, welcome mark,
+ README wordmark (vector kit in assets/markdown-converter-assets/)
+
+### Fixes
+- **Windows**: pdfjs text/image extraction failed on Windows —
+ standardFontDataUrl is now a proper file:// URL (raw backslash paths
+ failed pdfjs's trailing-slash URL validation)
+- **Windows**: sharp temp-file cleanup (EPERM retry), path-separator test
+ assertions, and pdfjs test timeouts fixed — the Windows CI job is green
+- FiraCode tooling downloads pinned to the immutable 6.2 release;
+ .gitattributes stops CRLF checkout rewriting hash-pinned files
+- macOS pandoc extractor locates the binary in the archive (layout changed)
+- Packaged apps resolve bundled pandoc/markitdown next to the executable
+ (resourcesPath lookup was wrong since 4.5 — packaged builds silently used
+ system pandoc)
+
+---
+
## Version 4.7.0 (2026-09-05)
### Bundling & Legal Compliance
diff --git a/src/index.html b/src/index.html
index c1b01d3..91ed50c 100644
--- a/src/index.html
+++ b/src/index.html
@@ -582,6 +582,13 @@
+
+
+
+
+
{
+ // Export theme: recolor headings/links + swap fonts in styles.xml
+ if (options.theme && options.theme !== 'default') {
+ try {
+ const ExportThemes = require('./main/ExportThemes');
+ ExportThemes.applyDocxTheme(outputFile, options.theme);
+ } catch (themeErr) {
+ if (typeof console !== 'undefined')
+ console.warn('[docx] theme apply failed:', themeErr.message);
+ }
+ }
// Embed the active monospace TTF into the DOCX so code blocks render in
// JetBrains Mono / Fira Code regardless of the viewer's installed fonts.
try {
diff --git a/src/main/ExportThemes.js b/src/main/ExportThemes.js
new file mode 100644
index 0000000..0b77a54
--- /dev/null
+++ b/src/main/ExportThemes.js
@@ -0,0 +1,248 @@
+/**
+ * Export themes — named visual styles for PDF (LaTeX) and Word (DOCX) export.
+ *
+ * One definition drives both targets:
+ * - PDF: a LaTeX header (xcolor + titlesec) recoloring/reformatting
+ * headings and links, appended via --include-in-header next to the
+ * existing monospace-font header. Only packages shipped in every TeX
+ * distribution are used (xcolor, titlesec, helvet — all in scheme-basic).
+ * - DOCX: a styles.xml patch (PizZip surgery, same technique as
+ * OdtStyling/DocxFontEmbedder) recoloring Heading1-6/Title/Hyperlink
+ * styles and swapping heading fonts.
+ *
+ * "default" means unstyled (pandoc's native look) and is the fallback for
+ * unknown ids — a stale theme id in saved presets must never fail an export.
+ *
+ * @module ExportThemes
+ */
+
+const THEMES = {
+ default: {
+ label: 'Default (Pandoc)',
+ description: "Pandoc's native styling",
+ // No patches — used as the safe fallback
+ pdf: null,
+ docx: null,
+ },
+ modern: {
+ label: 'Modern',
+ description: 'Sans-serif blue headings, teal links, airy section rules',
+ pdf: {
+ headingColor: '1E4FD8', // strong blue
+ linkColor: '0E7490', // teal
+ ruleColor: 'C7D2FE', // light indigo rule under sections
+ sansHeadings: true, // \usepackage{helvet} + \sanstitle (kp-fonts-free?)
+ sectionRule: true,
+ },
+ docx: {
+ headingColor: '1E4FD8',
+ linkColor: '0E7490',
+ headingFont: 'Calibri',
+ bodyFont: 'Calibri',
+ },
+ },
+ classic: {
+ label: 'Classic',
+ description: 'Serif type, deep-navy headings, burgundy links',
+ pdf: {
+ headingColor: '1F3A5F',
+ linkColor: '7C2D12',
+ sansHeadings: false,
+ sectionRule: true,
+ ruleColor: '94A3B8',
+ },
+ docx: {
+ headingColor: '1F3A5F',
+ linkColor: '7C2D12',
+ headingFont: 'Cambria',
+ bodyFont: 'Cambria',
+ },
+ },
+ sepia: {
+ label: 'Sepia',
+ description: 'Warm manuscript tones — browns and parchment accents',
+ pdf: {
+ headingColor: '7C4A21',
+ linkColor: 'A16207',
+ sansHeadings: false,
+ sectionRule: true,
+ ruleColor: 'D6BFA6',
+ },
+ docx: {
+ headingColor: '7C4A21',
+ linkColor: 'A16207',
+ headingFont: 'Georgia',
+ bodyFont: 'Georgia',
+ },
+ },
+ minimal: {
+ label: 'Minimal',
+ description: 'Near-black, no rules, understated gray links',
+ pdf: {
+ headingColor: '111827',
+ linkColor: '4B5563',
+ sansHeadings: false,
+ sectionRule: false,
+ },
+ docx: {
+ headingColor: '111827',
+ linkColor: '4B5563',
+ headingFont: 'Aptos',
+ bodyFont: 'Aptos',
+ },
+ },
+ elegant: {
+ label: 'Elegant',
+ description: 'Plum headings with hairline rules, slate links',
+ pdf: {
+ headingColor: '6D28D9',
+ linkColor: '334155',
+ sansHeadings: false,
+ sectionRule: true,
+ ruleColor: 'DDD6FE',
+ },
+ docx: {
+ headingColor: '6D28D9',
+ linkColor: '334155',
+ headingFont: 'Palatino Linotype',
+ bodyFont: 'Palatino Linotype',
+ },
+ },
+};
+
+/** Resolve a theme id safely (unknown → default). */
+function getTheme(id) {
+ return THEMES[id] || THEMES.default;
+}
+
+/** Menu/UI listing: [{id, label, description}] in definition order. */
+function listThemes() {
+ return Object.entries(THEMES).map(([id, t]) => ({
+ id,
+ label: t.label,
+ description: t.description,
+ }));
+}
+
+/**
+ * Build the LaTeX header implementing a theme's PDF look, or null for the
+ * default theme. Colors are inlined as hex literals — no user input reaches
+ * this string, so no LaTeX injection surface exists.
+ *
+ * @param {string} themeId
+ * @returns {string|null} full .tex header content
+ */
+function buildLatexThemeHeader(themeId) {
+ const theme = getTheme(themeId);
+ if (!theme.pdf) return null;
+
+ const p = theme.pdf;
+ const lines = ['% Export theme: ' + theme.label, '\\usepackage{xcolor}'];
+
+ if (p.sansHeadings) {
+ // helvet is in every TeX distribution's core; scale it to match body size
+ lines.push('\\usepackage[scaled=0.92]{helvet}');
+ }
+
+ lines.push(`\\definecolor{mcthemeheading}{HTML}{${p.headingColor}}`);
+ lines.push(`\\definecolor{mcthemelink}{HTML}{${p.linkColor}}`);
+ if (p.ruleColor) lines.push(`\\definecolor{mcthemerule}{HTML}{${p.ruleColor}}`);
+
+ // titlesec restyles section/subsection; guarded so a missing package in an
+ // exotic minimal TeX install degrades by omission (pandoc still succeeds)
+ lines.push('\\usepackage{titlesec}');
+ if (p.sansHeadings) lines.push('\\renewcommand{\\familydefault}{\\sfdefault}');
+ const rule = p.sectionRule && p.ruleColor
+ ? '{\\color{mcthemerule}\\titlerule[0.6pt]}'
+ : '';
+ lines.push(
+ `\\titleformat{\\section}{\\LARGE\\bfseries\\color{mcthemeheading}}{\\thesection}{0.8em}{}${rule}`
+ );
+ lines.push(
+ `\\titleformat{\\subsection}{\\Large\\bfseries\\color{mcthemeheading}}{\\thesubsection}{0.8em}{}`
+ );
+ lines.push(
+ `\\titleformat{\\subsubsection}{\\large\\bfseries\\color{mcthemeheading}}{\\thesubsubsection}{0.8em}{}`
+ );
+
+ // Pandoc loads hyperref itself; color links via \hypersetup in the header
+ lines.push('\\usepackage{etoolbox}');
+ lines.push('\\AfterEndPreamble{\\hypersetup{colorlinks=true,linkcolor=mcthemelink,urlcolor=mcthemelink}}');
+
+ return lines.join('\n') + '\n';
+}
+
+/**
+ * Apply a theme's DOCX styling by patching word/styles.xml in place.
+ * Patches Heading1-6, Title, Subtitle (color + font) and Hyperlink (color);
+ * Normal (body font) is only touched when the theme defines bodyFont.
+ *
+ * @param {string} docxPath .docx to patch (modified in place)
+ * @param {string} themeId
+ * @param {object} [io] injectable { PizZip, fs } for tests
+ */
+function applyDocxTheme(docxPath, themeId, io = {}) {
+ const PizZip = io.PizZip || require('pizzip');
+ const fs = io.fs || require('fs');
+ const theme = getTheme(themeId);
+ if (!theme.docx) return false;
+
+ const d = theme.docx;
+ const zip = new PizZip(fs.readFileSync(docxPath));
+ const stylesFile = zip.file('word/styles.xml');
+ if (!stylesFile) return false;
+ let xml = stylesFile.asText();
+
+ const headingIds = ['Title', 'Subtitle', 'Heading1', 'Heading2', 'Heading3', 'Heading4', 'Heading5', 'Heading6'];
+ for (const styleId of headingIds) {
+ // Match this style's … block (they
+ // never nest), then rewrite its rPr color/rFonts
+ const re = new RegExp(`(]*w:styleId="${styleId}"[^>]*>)([\\s\\S]*?)()`);
+ const match = re.exec(xml);
+ if (!match) continue;
+ let inner = match[2];
+
+ // Drop any existing color/rFonts in the style's rPr, then insert ours
+ inner = inner.replace(//g, '');
+ inner = inner.replace(/]*\/>/g, '');
+
+ const patch =
+ `` +
+ (d.headingFont
+ ? ``
+ : '');
+
+ if (inner.includes('')) {
+ inner = inner.replace('', `${patch}`);
+ } else {
+ // Style without rPr: add one right after the element
+ inner = inner.replace(/(]*\/>)/, `$1${patch}`);
+ }
+ xml = xml.replace(match[0], `${match[1]}${inner}${match[3]}`);
+ }
+
+ // Hyperlink character style: recolor only
+ const hl = /(]*w:styleId="Hyperlink"[^>]*>)([\s\S]*?)(<\/w:style>)/.exec(xml);
+ if (hl) {
+ let inner = hl[2].replace(//g, '');
+ const patch = ``;
+ if (inner.includes('')) inner = inner.replace('', `${patch}`);
+ else inner = inner.replace(/(]*\/>)/, `$1${patch}`);
+ xml = xml.replace(hl[0], `${hl[1]}${inner}${hl[3]}`);
+ }
+
+ // Body font: patch the docDefaults rPrDefault (affects Normal everywhere)
+ if (d.bodyFont) {
+ const patch = ``;
+ xml = xml.replace(
+ /(\s*\s*)/,
+ `$1${patch}`
+ );
+ }
+
+ zip.file('word/styles.xml', xml);
+ fs.writeFileSync(docxPath, zip.generate({ type: 'nodebuffer' }));
+ return true;
+}
+
+module.exports = { THEMES, getTheme, listThemes, buildLatexThemeHeader, applyDocxTheme };
diff --git a/src/renderer.js b/src/renderer.js
index 5d5ae39..f71f293 100644
--- a/src/renderer.js
+++ b/src/renderer.js
@@ -2964,6 +2964,26 @@ function initializeExportForm(format) {
document.getElementById('custom-geometry').style.display = 'none';
}
+ // Export theme picker: shown for the formats that support theming; options
+ // come straight from the shared theme definitions (labels + descriptions)
+ const themeRow = document.getElementById('export-theme-row');
+ const themeSelect = document.getElementById('export-theme');
+ if (themeRow && themeSelect) {
+ const themeable = format === 'pdf' || format === 'docx';
+ themeRow.style.display = themeable ? 'flex' : 'none';
+ if (themeSelect.options.length === 0) {
+ const { listThemes } = require('./main/ExportThemes');
+ for (const theme of listThemes()) {
+ const option = document.createElement('option');
+ option.value = theme.id;
+ option.textContent = theme.label;
+ option.title = theme.description;
+ themeSelect.appendChild(option);
+ }
+ }
+ themeSelect.value = 'default';
+ }
+
// Toggle and reset Reveal.js-specific fields
const revealjsOnly = document.querySelector('.revealjs-only');
if (revealjsOnly) {
@@ -3076,6 +3096,12 @@ function collectExportOptions() {
}
}
+ // Export theme is available in basic mode too (PDF + DOCX)
+ const themeSelect = document.getElementById('export-theme');
+ if (themeSelect && (currentExportFormat === 'pdf' || currentExportFormat === 'docx')) {
+ options.theme = themeSelect.value || 'default';
+ }
+
// Collect page size and orientation (always collected, from basic options)
const pageSize = document.getElementById('page-size').value;
const pageOrientation = document.getElementById('page-orientation').value;
diff --git a/src/renderer/export-presets.js b/src/renderer/export-presets.js
index b73de0c..676b7ac 100644
--- a/src/renderer/export-presets.js
+++ b/src/renderer/export-presets.js
@@ -120,6 +120,12 @@ function captureDialogOptions() {
: geometrySelect;
}
+ // Export theme (PDF + DOCX) — stored with presets like engine/geometry
+ if (format === 'pdf' || format === 'docx') {
+ const theme = valueOf('export-theme');
+ if (theme) options.theme = theme;
+ }
+
if (format === 'revealjs') {
options.revealTheme = valueOf('reveal-theme');
options.revealTransition = valueOf('reveal-transition');
@@ -206,6 +212,15 @@ function applyPresetToDialog(preset) {
setVisible('custom-geometry', true);
}
+ // Export theme (guard: a preset saved with a newer/older theme list still
+ // applies, falling back to default for unknown ids)
+ const themeSelect = elementById('export-theme');
+ if (themeSelect) {
+ const theme = advancedOptions.theme || 'default';
+ const hasTheme = Array.from(themeSelect.options).some((opt) => opt.value === theme);
+ setValue('export-theme', hasTheme ? theme : 'default');
+ }
+
// Reveal.js options
setValue('reveal-theme', advancedOptions.revealTheme || 'black');
setValue('reveal-transition', advancedOptions.revealTransition || 'slide');
diff --git a/tests/main/ExportThemes.test.js b/tests/main/ExportThemes.test.js
new file mode 100644
index 0000000..171369b
--- /dev/null
+++ b/tests/main/ExportThemes.test.js
@@ -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 = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+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);
+ });
+ });
+});
diff --git a/tests/main/ImageOperations.test.js b/tests/main/ImageOperations.test.js
index 635b758..b286dbe 100644
--- a/tests/main/ImageOperations.test.js
+++ b/tests/main/ImageOperations.test.js
@@ -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');
diff --git a/tests/main/PDFBatchOperations.test.js b/tests/main/PDFBatchOperations.test.js
index 8eba832..3cff996 100644
--- a/tests/main/PDFBatchOperations.test.js
+++ b/tests/main/PDFBatchOperations.test.js
@@ -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
diff --git a/tests/main/PDFOperations.test.js b/tests/main/PDFOperations.test.js
index 540c889..ca9f458 100644
--- a/tests/main/PDFOperations.test.js
+++ b/tests/main/PDFOperations.test.js
@@ -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');
diff --git a/tests/monospace-font-config.test.js b/tests/monospace-font-config.test.js
index 398d682..c7a3614 100644
--- a/tests/monospace-font-config.test.js
+++ b/tests/monospace-font-config.test.js
@@ -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', () => {