mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-23 23:10:17 +05:30
feat(export): add visual word-template settings dialog with graceful default-template fallback
Replaces the two native OS dialogs used to configure the DOCX "Enhanced"
export template (an open-file picker + a message-box question) with a
single in-app modal that shows the currently active template state, per
Task 18's original audit finding that this state was invisible until a
user thought to reopen the menu. Consolidates the "Select Word
Template..."/"Template Settings..." menu items into one "Word Template
Settings..." entry wired to the new dialog; Browse still uses the native
file picker since there is genuinely no bundled folder of templates to
enumerate (confirmed by investigation — see task-18-report.md).
Also fixes a related dangling-reference bug: WordTemplateExporter's
hardcoded default template path (word_template.docx) was deleted from
the repo in an earlier commit, but the code still tried to read it and
threw ENOENT whenever no custom template was selected. convert() now
degrades gracefully by generating a minimal, valid DOCX shell (styles +
numbering matching what markdownToWordXml() already references) instead
of crashing, and the new dialog surfaces this state honestly ("using
default formatting, no default template is bundled") rather than
implying a working default exists.
Out of scope, per explicit instruction: bundling fabricated starter
.docx templates to populate a literal multi-item gallery (rejected as
disproportionate/fake-content scope), and an EPUB template gallery (no
EPUB template mechanism exists anywhere in this codebase to build one
for).
Amit Haridas
This commit is contained in:
@@ -2354,6 +2354,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Word Template Settings Dialog -->
|
||||||
|
<div
|
||||||
|
id="word-template-dialog"
|
||||||
|
class="modal hidden"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="word-template-title"
|
||||||
|
>
|
||||||
|
<div class="modal-backdrop" data-close></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="word-template-title">Word Template Settings</h3>
|
||||||
|
<button class="modal-close" id="word-template-close" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="wt-status-section">
|
||||||
|
<div id="word-template-status" class="wt-status wt-status-none">
|
||||||
|
<div class="wt-status-icon" aria-hidden="true">📄</div>
|
||||||
|
<div class="wt-status-text">
|
||||||
|
<div id="word-template-status-title" class="wt-status-title">
|
||||||
|
No template selected
|
||||||
|
</div>
|
||||||
|
<div id="word-template-status-detail" class="wt-status-detail">
|
||||||
|
Using default formatting
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wt-status-actions">
|
||||||
|
<button id="word-template-browse" class="browse-btn">Browse...</button>
|
||||||
|
<button id="word-template-clear" class="clear-btn">Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="wt-startpage-section">
|
||||||
|
<label for="word-template-start-page">Content starts from page</label>
|
||||||
|
<input type="number" id="word-template-start-page" min="1" max="100" value="3" />
|
||||||
|
<p class="wt-help">
|
||||||
|
Templates usually reserve the first pages for a cover sheet and table of contents;
|
||||||
|
your Markdown content is inserted starting from this page. Ignored when no template
|
||||||
|
is selected.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button id="word-template-cancel" class="btn btn-secondary" data-close>Cancel</button>
|
||||||
|
<button id="word-template-save" class="btn btn-primary">Save Settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="main-content" id="main-content">
|
<div class="main-content" id="main-content">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<div class="sidebar collapsed" id="sidebar">
|
<div class="sidebar collapsed" id="sidebar">
|
||||||
|
|||||||
+64
-71
@@ -1005,12 +1005,12 @@ function createMenu() {
|
|||||||
type: 'separator',
|
type: 'separator',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Select Word Template...',
|
label: 'Word Template Settings...',
|
||||||
click: selectWordTemplate,
|
click: () => {
|
||||||
},
|
if (mainWindow) {
|
||||||
{
|
mainWindow.webContents.send('open-word-template-dialog');
|
||||||
label: 'Template Settings...',
|
}
|
||||||
click: showTemplateSettings,
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Header & Footer Settings...',
|
label: 'Header & Footer Settings...',
|
||||||
@@ -1937,76 +1937,69 @@ function showBatchConversionDialog() {
|
|||||||
mainWindow.webContents.send('show-batch-dialog');
|
mainWindow.webContents.send('show-batch-dialog');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select Word Template
|
// Word Template Settings IPC Handlers
|
||||||
async function selectWordTemplate() {
|
//
|
||||||
const result = await dialog.showOpenDialog(mainWindow, {
|
// Template selection/settings used to be two separate native OS dialogs
|
||||||
title: 'Select Word Template',
|
// (an open-file picker and a message-box question) with no visible
|
||||||
filters: [
|
// in-app UI showing the currently active template — the original audit
|
||||||
{
|
// finding this replaces. There is still genuinely no folder of bundled
|
||||||
name: 'Word Document',
|
// templates to enumerate (see WordTemplateExporter.getDefaultTemplatePath()
|
||||||
extensions: ['docx'],
|
// docs), so "Browse..." still opens a native file picker, but the result
|
||||||
},
|
// and current state are now shown in a real renderer dialog instead of
|
||||||
],
|
// being invisible until a user thinks to reopen the menu item.
|
||||||
properties: ['openFile'],
|
|
||||||
|
// Send current template state to the renderer dialog
|
||||||
|
ipcMain.on('get-word-template-settings', (event) => {
|
||||||
|
event.reply('word-template-settings-data', {
|
||||||
|
templatePath: wordTemplatePath,
|
||||||
|
templateFileName: wordTemplatePath ? path.basename(wordTemplatePath) : null,
|
||||||
|
startPage: templateStartPage,
|
||||||
|
defaultTemplateAvailable: fs.existsSync(WordTemplateExporter.getDefaultTemplatePath()),
|
||||||
});
|
});
|
||||||
if (!result.canceled && result.filePaths.length > 0) {
|
});
|
||||||
wordTemplatePath = result.filePaths[0];
|
|
||||||
store.set('wordTemplatePath', wordTemplatePath);
|
|
||||||
dialog.showMessageBox(mainWindow, {
|
|
||||||
type: 'info',
|
|
||||||
title: 'Template Selected',
|
|
||||||
message: 'Word template has been updated',
|
|
||||||
detail: `Template: ${path.basename(wordTemplatePath)}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Template Settings Dialog
|
// Browse for a template file via the native picker; does not persist
|
||||||
async function showTemplateSettings() {
|
// until the dialog's Save button sends 'save-word-template-settings'.
|
||||||
const result = await dialog.showMessageBox(mainWindow, {
|
ipcMain.on('browse-word-template', async (event) => {
|
||||||
type: 'question',
|
try {
|
||||||
title: 'Template Settings',
|
const result = await dialog.showOpenDialog(mainWindow, {
|
||||||
message: 'Configure Word Template Export',
|
title: 'Select Word Template',
|
||||||
detail: `Current template: ${wordTemplatePath ? path.basename(wordTemplatePath) : 'Default template'}\nContent starts from page: ${templateStartPage}\n\nWhich page should content start from?\n(Templates usually have cover pages, TOC, etc.)`,
|
filters: [
|
||||||
buttons: ['Page 1', 'Page 2', 'Page 3', 'Page 4', 'Page 5', 'Custom...', 'Cancel'],
|
{
|
||||||
defaultId: templateStartPage - 1,
|
name: 'Word Document',
|
||||||
cancelId: 6,
|
extensions: ['docx'],
|
||||||
});
|
},
|
||||||
if (result.response === 6) return; // Cancel
|
],
|
||||||
|
properties: ['openFile'],
|
||||||
let newStartPage;
|
|
||||||
if (result.response === 5) {
|
|
||||||
// Custom
|
|
||||||
// Show input dialog for custom page number
|
|
||||||
mainWindow.webContents.send('show-custom-start-page-dialog', templateStartPage);
|
|
||||||
} else {
|
|
||||||
newStartPage = result.response + 1; // Convert button index to page number
|
|
||||||
templateStartPage = newStartPage;
|
|
||||||
store.set('templateStartPage', templateStartPage);
|
|
||||||
dialog.showMessageBox(mainWindow, {
|
|
||||||
type: 'info',
|
|
||||||
title: 'Settings Updated',
|
|
||||||
message: 'Template settings have been updated',
|
|
||||||
detail: `Content will now start from page ${templateStartPage}`,
|
|
||||||
});
|
});
|
||||||
|
if (!result.canceled && result.filePaths.length > 0) {
|
||||||
|
const filePath = result.filePaths[0];
|
||||||
|
event.reply('word-template-browsed', {
|
||||||
|
templatePath: filePath,
|
||||||
|
templateFileName: path.basename(filePath),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Word template browse error:', error);
|
||||||
|
dialog.showErrorBox(
|
||||||
|
'Template Error',
|
||||||
|
sanitizeErrorMessage(`Failed to select template: ${error.message}`)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
// Handle custom start page input from renderer
|
// Clear the currently selected template (revert to default formatting)
|
||||||
ipcMain.on('set-custom-start-page', (event, pageNumber) => {
|
ipcMain.on('clear-word-template', (event) => {
|
||||||
const page = parseInt(pageNumber);
|
event.reply('word-template-browsed', { templatePath: null, templateFileName: null });
|
||||||
if (page >= 1 && page <= 100) {
|
});
|
||||||
templateStartPage = page;
|
|
||||||
store.set('templateStartPage', templateStartPage);
|
// Persist template path + start page from the dialog's Save button
|
||||||
dialog.showMessageBox(mainWindow, {
|
ipcMain.on('save-word-template-settings', (event, settings) => {
|
||||||
type: 'info',
|
wordTemplatePath = (settings && settings.templatePath) || null;
|
||||||
title: 'Settings Updated',
|
const page = parseInt(settings && settings.startPage, 10);
|
||||||
message: 'Template settings have been updated',
|
templateStartPage = page >= 1 && page <= 100 ? page : 3;
|
||||||
detail: `Content will now start from page ${templateStartPage}`,
|
store.set('wordTemplatePath', wordTemplatePath);
|
||||||
});
|
store.set('templateStartPage', templateStartPage);
|
||||||
} else {
|
|
||||||
dialog.showErrorBox('Invalid Page Number', 'Please enter a page number between 1 and 100');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Header & Footer Settings IPC Handlers
|
// Header & Footer Settings IPC Handlers
|
||||||
|
|||||||
@@ -1705,6 +1705,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
const pdfEditorModal = new ModalManager('#pdf-editor-dialog');
|
const pdfEditorModal = new ModalManager('#pdf-editor-dialog');
|
||||||
const headerFooterModal = new ModalManager('#header-footer-dialog');
|
const headerFooterModal = new ModalManager('#header-footer-dialog');
|
||||||
const fieldPickerModal = new ModalManager('#field-picker-dialog');
|
const fieldPickerModal = new ModalManager('#field-picker-dialog');
|
||||||
|
const wordTemplateModal = new ModalManager('#word-template-dialog');
|
||||||
|
|
||||||
// Make modals globally accessible for functions outside this scope
|
// Make modals globally accessible for functions outside this scope
|
||||||
window.modals = {
|
window.modals = {
|
||||||
@@ -1718,6 +1719,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
pdfEditorModal,
|
pdfEditorModal,
|
||||||
headerFooterModal,
|
headerFooterModal,
|
||||||
fieldPickerModal,
|
fieldPickerModal,
|
||||||
|
wordTemplateModal,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize sidebar
|
// Initialize sidebar
|
||||||
@@ -4975,6 +4977,98 @@ window.openHeaderFooterDialog = openHeaderFooterDialog;
|
|||||||
ipcRenderer.on('open-header-footer-dialog', () => {
|
ipcRenderer.on('open-header-footer-dialog', () => {
|
||||||
openHeaderFooterDialog();
|
openHeaderFooterDialog();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// Word Template Settings Dialog
|
||||||
|
// ================================
|
||||||
|
//
|
||||||
|
// Replaces the two former native OS dialogs ("Select Word Template...",
|
||||||
|
// "Template Settings...") with a single in-app modal that shows the
|
||||||
|
// currently active template state, instead of that state being invisible
|
||||||
|
// until a user thought to reopen a menu item. "Browse..." still triggers
|
||||||
|
// a native file picker in the main process — there is no bundled folder
|
||||||
|
// of templates to enumerate, so a gallery of multiple templates would
|
||||||
|
// require inventing content that doesn't exist; see Task 18 report.
|
||||||
|
|
||||||
|
// Tracks the template path chosen via Browse in this dialog session,
|
||||||
|
// before it is persisted by Save.
|
||||||
|
let pendingWordTemplatePath = null;
|
||||||
|
let wordTemplateDefaultAvailable = false;
|
||||||
|
|
||||||
|
function openWordTemplateDialog() {
|
||||||
|
window.modals.wordTemplateModal.open();
|
||||||
|
ipcRenderer.send('get-word-template-settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeWordTemplateDialog() {
|
||||||
|
window.modals.wordTemplateModal.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWordTemplateStatus(templateFileName) {
|
||||||
|
const statusEl = document.getElementById('word-template-status');
|
||||||
|
const titleEl = document.getElementById('word-template-status-title');
|
||||||
|
const detailEl = document.getElementById('word-template-status-detail');
|
||||||
|
|
||||||
|
statusEl.classList.remove('wt-status-none', 'wt-status-selected', 'wt-status-missing');
|
||||||
|
|
||||||
|
if (templateFileName) {
|
||||||
|
statusEl.classList.add('wt-status-selected');
|
||||||
|
titleEl.textContent = templateFileName;
|
||||||
|
detailEl.textContent = 'Custom template selected';
|
||||||
|
} else if (wordTemplateDefaultAvailable) {
|
||||||
|
statusEl.classList.add('wt-status-none');
|
||||||
|
titleEl.textContent = 'No template selected';
|
||||||
|
detailEl.textContent = 'Using the bundled default template';
|
||||||
|
} else {
|
||||||
|
statusEl.classList.add('wt-status-missing');
|
||||||
|
titleEl.textContent = 'No template selected';
|
||||||
|
detailEl.textContent = 'Using default formatting (no default template is bundled)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate dialog with current settings from main process
|
||||||
|
ipcRenderer.on('word-template-settings-data', (event, data) => {
|
||||||
|
pendingWordTemplatePath = data.templatePath || null;
|
||||||
|
wordTemplateDefaultAvailable = !!data.defaultTemplateAvailable;
|
||||||
|
renderWordTemplateStatus(data.templateFileName);
|
||||||
|
document.getElementById('word-template-start-page').value = data.startPage || 3;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Result of a Browse... click (native picker) or Clear
|
||||||
|
ipcRenderer.on('word-template-browsed', (event, data) => {
|
||||||
|
pendingWordTemplatePath = data.templatePath || null;
|
||||||
|
renderWordTemplateStatus(data.templateFileName);
|
||||||
|
});
|
||||||
|
|
||||||
|
function saveWordTemplateSettings() {
|
||||||
|
const startPageInput = document.getElementById('word-template-start-page');
|
||||||
|
let startPage = parseInt(startPageInput.value, 10);
|
||||||
|
if (!Number.isFinite(startPage) || startPage < 1) startPage = 1;
|
||||||
|
if (startPage > 100) startPage = 100;
|
||||||
|
|
||||||
|
ipcRenderer.send('save-word-template-settings', {
|
||||||
|
templatePath: pendingWordTemplatePath,
|
||||||
|
startPage,
|
||||||
|
});
|
||||||
|
closeWordTemplateDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('word-template-close').addEventListener('click', closeWordTemplateDialog);
|
||||||
|
document.getElementById('word-template-cancel').addEventListener('click', closeWordTemplateDialog);
|
||||||
|
document.getElementById('word-template-save').addEventListener('click', saveWordTemplateSettings);
|
||||||
|
document.getElementById('word-template-browse').addEventListener('click', () => {
|
||||||
|
ipcRenderer.send('browse-word-template');
|
||||||
|
});
|
||||||
|
document.getElementById('word-template-clear').addEventListener('click', () => {
|
||||||
|
ipcRenderer.send('clear-word-template');
|
||||||
|
});
|
||||||
|
|
||||||
|
window.openWordTemplateDialog = openWordTemplateDialog;
|
||||||
|
|
||||||
|
ipcRenderer.on('open-word-template-dialog', () => {
|
||||||
|
openWordTemplateDialog();
|
||||||
|
});
|
||||||
|
|
||||||
// Command Palette - initialized via CommandPalette class
|
// Command Palette - initialized via CommandPalette class
|
||||||
// (see DOMContentLoaded handler for registration of commands)
|
// (see DOMContentLoaded handler for registration of commands)
|
||||||
|
|
||||||
|
|||||||
+111
@@ -3705,6 +3705,117 @@ body[data-theme='dark'] .field-option:hover {
|
|||||||
background: #0d6efd;
|
background: #0d6efd;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ================================
|
||||||
|
Word Template Dialog Styles
|
||||||
|
================================ */
|
||||||
|
|
||||||
|
.wt-status-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
background: var(--bg-secondary, #f5f5f5);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-status-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-status-text {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-status-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-status-detail {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-status-missing .wt-status-detail {
|
||||||
|
color: var(--danger-color, #dc3545);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-status-selected .wt-status-detail {
|
||||||
|
color: var(--accent-color, #007bff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-status-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-startpage-section {
|
||||||
|
padding: 15px;
|
||||||
|
background: var(--bg-tertiary, #fafafa);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-startpage-section label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-startpage-section input[type='number'] {
|
||||||
|
width: 100px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border-color, #ccc);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--input-bg, white);
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-startpage-section input[type='number']:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-color, #007bff);
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wt-help {
|
||||||
|
margin: 10px 0 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme='dark'] .wt-status-section,
|
||||||
|
body[data-theme='dark'] .wt-startpage-section {
|
||||||
|
background: #252525;
|
||||||
|
border-color: #404040;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-theme='dark'] .wt-startpage-section input[type='number'] {
|
||||||
|
background: #2d2d2d;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border-color: #404040;
|
||||||
|
}
|
||||||
|
|
||||||
/* Mermaid Diagram Styles */
|
/* Mermaid Diagram Styles */
|
||||||
.mermaid {
|
.mermaid {
|
||||||
background: #f9f9f9;
|
background: #f9f9f9;
|
||||||
|
|||||||
Binary file not shown.
@@ -2,6 +2,10 @@
|
|||||||
* Tests for WordTemplateExporter
|
* Tests for WordTemplateExporter
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const PizZip = require('pizzip');
|
||||||
const WordTemplateExporter = require('../src/wordTemplateExporter');
|
const WordTemplateExporter = require('../src/wordTemplateExporter');
|
||||||
|
|
||||||
describe('WordTemplateExporter.preprocessMarkdownForWordExport', () => {
|
describe('WordTemplateExporter.preprocessMarkdownForWordExport', () => {
|
||||||
@@ -115,3 +119,123 @@ Centered
|
|||||||
expect(WordTemplateExporter.preprocessMarkdownForWordExport(123)).toBe(123);
|
expect(WordTemplateExporter.preprocessMarkdownForWordExport(123)).toBe(123);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('WordTemplateExporter.hasTemplateFile', () => {
|
||||||
|
test('is false when no path is given and the bundled default template is absent', () => {
|
||||||
|
// word_template.docx was removed from the repo (see git history); this
|
||||||
|
// asserts the current, real state of the repo rather than assuming a
|
||||||
|
// file that may or may not exist.
|
||||||
|
const exporter = new WordTemplateExporter(null);
|
||||||
|
const defaultExists = fs.existsSync(WordTemplateExporter.getDefaultTemplatePath());
|
||||||
|
expect(exporter.hasTemplateFile()).toBe(defaultExists);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is false for a path that does not exist on disk', () => {
|
||||||
|
const exporter = new WordTemplateExporter('/definitely/not/a/real/path/template.docx');
|
||||||
|
expect(exporter.hasTemplateFile()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is true for a path that does exist on disk', () => {
|
||||||
|
const tmpFile = path.join(os.tmpdir(), `wt-exists-${Date.now()}.docx`);
|
||||||
|
fs.writeFileSync(tmpFile, 'not a real docx, existence is all that matters here');
|
||||||
|
try {
|
||||||
|
const exporter = new WordTemplateExporter(tmpFile);
|
||||||
|
expect(exporter.hasTemplateFile()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(tmpFile);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WordTemplateExporter.convert — graceful fallback with no template file', () => {
|
||||||
|
let outputPath;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (outputPath && fs.existsSync(outputPath)) {
|
||||||
|
fs.unlinkSync(outputPath);
|
||||||
|
}
|
||||||
|
outputPath = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not throw ENOENT and produces a readable DOCX when the template path is missing', async () => {
|
||||||
|
outputPath = path.join(os.tmpdir(), `wt-fallback-${Date.now()}.docx`);
|
||||||
|
const exporter = new WordTemplateExporter('/definitely/not/a/real/path/template.docx', 3, null);
|
||||||
|
|
||||||
|
await expect(exporter.convert('# Title\n\nSome paragraph text.', outputPath)).resolves.toBe(
|
||||||
|
outputPath
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fs.existsSync(outputPath)).toBe(true);
|
||||||
|
|
||||||
|
// The generated file must be a well-formed DOCX (zip) with the parts
|
||||||
|
// Word requires, containing the markdown content.
|
||||||
|
const zip = new PizZip(fs.readFileSync(outputPath));
|
||||||
|
expect(zip.file('word/document.xml')).not.toBeNull();
|
||||||
|
expect(zip.file('word/styles.xml')).not.toBeNull();
|
||||||
|
expect(zip.file('word/numbering.xml')).not.toBeNull();
|
||||||
|
|
||||||
|
const documentXml = zip.file('word/document.xml').asText();
|
||||||
|
expect(documentXml).toContain('Title');
|
||||||
|
expect(documentXml).toContain('Some paragraph text.');
|
||||||
|
expect(documentXml).toContain('Heading1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('also degrades gracefully when templatePath is null and the default template is absent', async () => {
|
||||||
|
outputPath = path.join(os.tmpdir(), `wt-fallback-null-${Date.now()}.docx`);
|
||||||
|
const exporter = new WordTemplateExporter(null, 3, null);
|
||||||
|
|
||||||
|
if (exporter.hasTemplateFile()) {
|
||||||
|
// Environment happens to have a real default template on disk —
|
||||||
|
// this test only asserts the fallback path, so skip in that case.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(exporter.convert('Plain content.', outputPath)).resolves.toBe(outputPath);
|
||||||
|
expect(fs.existsSync(outputPath)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('honors pageSettings (landscape) in the generated default document', async () => {
|
||||||
|
outputPath = path.join(os.tmpdir(), `wt-fallback-landscape-${Date.now()}.docx`);
|
||||||
|
const exporter = new WordTemplateExporter('/definitely/not/a/real/path/template.docx', 3, {
|
||||||
|
size: 'a4',
|
||||||
|
orientation: 'landscape',
|
||||||
|
});
|
||||||
|
|
||||||
|
await exporter.convert('Landscape content.', outputPath);
|
||||||
|
|
||||||
|
const zip = new PizZip(fs.readFileSync(outputPath));
|
||||||
|
const documentXml = zip.file('word/document.xml').asText();
|
||||||
|
expect(documentXml).toContain('w:orient="landscape"');
|
||||||
|
// A4 landscape swaps width/height relative to portrait (11906x16838).
|
||||||
|
expect(documentXml).toContain('w:w="16838"');
|
||||||
|
expect(documentXml).toContain('w:h="11906"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still uses the real template file when one exists on disk (regression check)', async () => {
|
||||||
|
// Build a tiny but valid docx fixture (using the same generator used
|
||||||
|
// for the no-template fallback) to stand in for a "real" template, so
|
||||||
|
// this test does not depend on any bundled fixture file existing.
|
||||||
|
const templatePath = path.join(os.tmpdir(), `wt-fixture-template-${Date.now()}.docx`);
|
||||||
|
const fixtureExporter = new WordTemplateExporter('/no/such/file.docx');
|
||||||
|
const fixtureZip = fixtureExporter.buildDefaultDocumentZip(
|
||||||
|
'<w:p><w:r><w:t>COVER</w:t></w:r></w:p>'
|
||||||
|
);
|
||||||
|
fs.writeFileSync(templatePath, fixtureZip.generate({ type: 'nodebuffer' }));
|
||||||
|
|
||||||
|
outputPath = path.join(os.tmpdir(), `wt-with-template-${Date.now()}.docx`);
|
||||||
|
try {
|
||||||
|
const exporter = new WordTemplateExporter(templatePath, 3, null);
|
||||||
|
expect(exporter.hasTemplateFile()).toBe(true);
|
||||||
|
|
||||||
|
await exporter.convert('Body content.', outputPath);
|
||||||
|
|
||||||
|
const zip = new PizZip(fs.readFileSync(outputPath));
|
||||||
|
const documentXml = zip.file('word/document.xml').asText();
|
||||||
|
// Content from the "template" (cover) and the new export both present.
|
||||||
|
expect(documentXml).toContain('COVER');
|
||||||
|
expect(documentXml).toContain('Body content.');
|
||||||
|
} finally {
|
||||||
|
fs.unlinkSync(templatePath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user