Files
markdown-converter/src/wordTemplateExporter.js
T
amitwh d705cfc30b fix(batch): resolve pandoc path handling and include-subfolders option
- Normalize pandoc command parsing with path.basename() to support bundled binary paths
- Use bundled pandoc binary in convertWithPandoc instead of relying on PATH
- Forward includeSubfolders checkbox state from renderer to main process
- Add pandoc availability check before batch conversion
- Re-enable Start button when batch conversion completes
- Clean up obsolete dist build artifact causing test snapshot warning
- Bump version to 4.4.4
2026-06-30 12:56:33 +05:30

807 lines
24 KiB
JavaScript

/**
* Word Template Exporter
* Loads word_template.docx, preserves first 2 pages (cover + TOC),
* and adds markdown content starting from page 3 using template styles
*/
const fs = require('fs');
const path = require('path');
const PizZip = require('pizzip');
class WordTemplateExporter {
constructor(templatePath, startPage = 3, pageSettings = null) {
this.templatePath = templatePath || path.join(__dirname, '../word_template.docx');
this.startPage = startPage; // Which page to start inserting content
this.pageSettings = pageSettings; // Page size and orientation settings
}
/**
* Convert markdown to Word document using template
*/
async convert(markdownContent, outputPath) {
try {
// Load template
const templateBuffer = fs.readFileSync(this.templatePath);
const zip = new PizZip(templateBuffer);
// Extract document.xml
let documentXml = zip.file('word/document.xml').asText();
// Set page size if settings provided
if (this.pageSettings) {
documentXml = this.setPageSize(documentXml);
}
// Parse markdown and generate Word XML
const newContentXml = this.markdownToWordXml(markdownContent);
// Insert new content after the specified start page
const modifiedXml = this.insertContentAfterPage(documentXml, newContentXml, this.startPage);
// Update the zip with modified XML
zip.file('word/document.xml', modifiedXml);
// Generate and save the new document
const newDocBuffer = zip.generate({ type: 'nodebuffer' });
fs.writeFileSync(outputPath, newDocBuffer);
return outputPath;
} catch (error) {
console.error('Error in Word export:', error);
throw error;
}
}
/**
* Set page size in document XML
*/
setPageSize(documentXml) {
// Import PAGE_SIZES from main process (need to pass as parameter)
const PAGE_SIZES = {
a4: { width: 11906, height: 16838 },
a3: { width: 16838, height: 23811 },
a5: { width: 8391, height: 11906 },
b4: { width: 14170, height: 20015 },
b5: { width: 9979, height: 14170 },
letter: { width: 12240, height: 15840 },
legal: { width: 12240, height: 20160 },
tabloid: { width: 15840, height: 24480 },
};
let width, height;
const pageSize = PAGE_SIZES[this.pageSettings.size];
if (pageSize) {
width = pageSize.width;
height = pageSize.height;
} else {
// Default to A4
width = 11906;
height = 16838;
}
// Swap dimensions for landscape
if (this.pageSettings.orientation === 'landscape') {
[width, height] = [height, width];
}
// Update all <w:pgSz> elements in section properties
const pgSzRegex = /<w:pgSz[^>]*\/>/g;
let modifiedXml = documentXml.replace(pgSzRegex, () => {
return `<w:pgSz w:w="${width}" w:h="${height}" w:orient="${this.pageSettings.orientation}"/>`;
});
// If no pgSz found, add it to all sectPr elements
if (!pgSzRegex.test(documentXml)) {
const sectPrRegex = /<w:sectPr[^>]*>/g;
modifiedXml = modifiedXml.replace(sectPrRegex, (match) => {
return `${match}<w:pgSz w:w="${width}" w:h="${height}" w:orient="${this.pageSettings.orientation}"/>`;
});
}
return modifiedXml;
}
/**
* Insert markdown content after the specified page in the document
* @param {string} documentXml - The document XML
* @param {string} newContentXml - The new content to insert
* @param {number} afterPage - Insert content after this page number (1-based)
*/
insertContentAfterPage(documentXml, newContentXml, afterPage) {
// Find section breaks that mark page boundaries
// Look for the section properties tag that marks page breaks
const sectionBreakRegex = /<w:sectPr[^>]*>[\s\S]*?<\/w:sectPr>/g;
const matches = [...documentXml.matchAll(sectionBreakRegex)];
// Calculate which section break to insert after
// Page 1 = before 1st section break
// Page 2 = after 1st section break
// Page 3 = after 2nd section break, etc.
const sectionIndex = afterPage - 1;
if (matches.length >= sectionIndex && sectionIndex > 0) {
// Insert after the specified section break
const insertPoint = matches[sectionIndex - 1].index + matches[sectionIndex - 1][0].length;
return documentXml.slice(0, insertPoint) + newContentXml + documentXml.slice(insertPoint);
} else if (afterPage === 1 || matches.length === 0) {
// Insert at the beginning (after <w:body>) or if no section breaks found
const bodyStart = documentXml.indexOf('<w:body>') + 8;
return documentXml.slice(0, bodyStart) + newContentXml + documentXml.slice(bodyStart);
} else {
// If not enough section breaks, insert before closing body tag
return documentXml.replace('</w:body>', newContentXml + '</w:body>');
}
}
/**
* Convert markdown to Word XML format
*/
markdownToWordXml(markdown) {
const lines = markdown.split('\n');
let xml = '';
let inCodeBlock = false;
let codeLines = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Handle code blocks
if (line.trim().startsWith('```')) {
if (inCodeBlock) {
// End code block
xml += this.createCodeBlockXml(codeLines.join('\n'));
codeLines = [];
inCodeBlock = false;
} else {
inCodeBlock = true;
}
continue;
}
if (inCodeBlock) {
codeLines.push(line);
continue;
}
// Empty lines
if (!line.trim()) {
xml += '<w:p><w:pPr></w:pPr></w:p>';
continue;
}
// Tables - detect table lines
if (line.includes('|') && line.trim().startsWith('|')) {
const tableLines = [line];
i++;
// Collect all consecutive table lines
while (i < lines.length && lines[i].includes('|')) {
tableLines.push(lines[i]);
i++;
}
i--; // Back up one line
xml += this.createTableXml(tableLines);
continue;
}
// ASCII flowcharts/diagrams - detect box drawing characters
if (this.isAsciiArt(line)) {
const asciiLines = [line];
i++;
// Collect all consecutive ASCII art lines
while (i < lines.length && (this.isAsciiArt(lines[i]) || !lines[i].trim())) {
asciiLines.push(lines[i]);
i++;
if (i < lines.length && lines[i].trim() && !this.isAsciiArt(lines[i])) {
break;
}
}
i--; // Back up one line
xml += this.createAsciiArtXml(asciiLines.join('\n'));
continue;
}
// Headings - strip markdown numbering
if (line.trim().startsWith('#')) {
const level = (line.match(/^#+/) || [''])[0].length;
let text = line.replace(/^#+\s*/, '').trim();
// Remove markdown numbering like "1.1 Title" -> "Title"
text = text.replace(/^\d+(\.\d+)*\.?\s+/, '');
xml += this.createHeadingXml(text, level);
continue;
}
// Blockquotes
if (line.trim().startsWith('>')) {
const text = line.replace(/^>\s*/, '').trim();
xml += this.createQuoteXml(text);
continue;
}
// Ordered lists - strip markdown numbering, use template numbering
if (/^\s*\d+\.\s+/.test(line)) {
const text = line.replace(/^\s*\d+\.\s+/, '');
xml += this.createListItemXml(text, true);
continue;
}
// Unordered lists
if (/^\s*[-*+]\s+/.test(line)) {
const text = line.replace(/^\s*[-*+]\s+/, '');
xml += this.createListItemXml(text, false);
continue;
}
// Horizontal rule
if (/^[-*_]{3,}$/.test(line.trim())) {
xml += this.createHorizontalRuleXml();
continue;
}
// Normal paragraph
xml += this.createParagraphXml(line);
}
return xml;
}
/**
* Create heading XML using template styles
*/
createHeadingXml(text, level) {
const styleName = `Heading${level}`;
const runs = this.parseInlineFormatting(text);
return `<w:p>
<w:pPr>
<w:pStyle w:val="${styleName}"/>
</w:pPr>
${runs}
</w:p>`;
}
/**
* Create paragraph XML with Normal style
*/
createParagraphXml(text) {
const runs = this.parseInlineFormatting(text);
return `<w:p>
<w:pPr>
<w:pStyle w:val="Normal"/>
</w:pPr>
${runs}
</w:p>`;
}
/**
* Create quote XML
*/
createQuoteXml(text) {
const runs = this.parseInlineFormatting(text);
return `<w:p>
<w:pPr>
<w:pStyle w:val="Quote"/>
</w:pPr>
${runs}
</w:p>`;
}
/**
* Create list item XML using template numbering
*/
createListItemXml(text, numbered) {
const runs = this.parseInlineFormatting(text);
const numId = numbered ? '1' : '2'; // Template numbering IDs
return `<w:p>
<w:pPr>
<w:pStyle w:val="${numbered ? 'ListNumber' : 'ListBullet'}"/>
<w:numPr>
<w:ilvl w:val="0"/>
<w:numId w:val="${numId}"/>
</w:numPr>
</w:pPr>
${runs}
</w:p>`;
}
/**
* Create code block XML - renders each line as separate paragraph
* to preserve exact formatting like in preview (monospace, no wrapping)
* Background shading applied at paragraph level to extend full width
*/
createCodeBlockXml(code) {
const lines = code.split('\n');
let xml = '';
lines.forEach((line, index) => {
const escapedLine = this.escapeXml(line);
// Each line gets its own paragraph with exact spacing and no wrapping
// Shading (shd) is at paragraph level so background extends full width
xml += `<w:p>
<w:pPr>
<w:pStyle w:val="Normal"/>
<w:shd w:val="clear" w:color="auto" w:fill="F5F5F5"/>
<w:spacing w:before="${index === 0 ? '120' : '0'}" w:after="${index === lines.length - 1 ? '120' : '0'}" w:line="240" w:lineRule="exact"/>
<w:ind w:left="284" w:right="284"/>
<w:jc w:val="left"/>
<w:keepLines/>
<w:wordWrap w:val="0"/>
</w:pPr>
<w:r>
<w:rPr>
<w:rFonts w:ascii="Consolas" w:hAnsi="Consolas" w:cs="Consolas"/>
<w:sz w:val="18"/>
<w:szCs w:val="18"/>
<w:noProof/>
</w:rPr>
<w:t xml:space="preserve">${escapedLine}</w:t>
</w:r>
</w:p>`;
});
return xml;
}
/**
* Create horizontal rule XML
*/
createHorizontalRuleXml() {
return `<w:p>
<w:pPr>
<w:pBdr>
<w:bottom w:val="single" w:sz="6" w:space="1" w:color="auto"/>
</w:pBdr>
</w:pPr>
</w:p>`;
}
/**
* Create table XML from markdown table lines with template styling
* Uses full-width tables with equal column distribution matching template style
*/
createTableXml(tableLines) {
// Parse table
const rows = [];
for (const line of tableLines) {
// Skip separator lines (e.g., |---|---|)
if (/^\s*\|[\s\-:]+\|\s*$/.test(line)) {
continue;
}
// Split by | and trim
const cells = line
.split('|')
.filter((cell) => cell.trim())
.map((cell) => cell.trim());
if (cells.length > 0) {
rows.push(cells);
}
}
if (rows.length === 0) return '';
// Calculate number of columns
const numCols = Math.max(...rows.map((row) => row.length));
// Calculate column width in twips (1440 twips = 1 inch)
// Assume standard page width of 9360 twips (6.5 inches usable)
const totalTableWidth = 9360;
const colWidth = Math.floor(totalTableWidth / numCols);
// Build table XML
let tableXml = '<w:tbl>';
// Table properties - use template table style with full width
tableXml += `<w:tblPr>
<w:tblStyle w:val="TableGrid"/>
<w:tblW w:w="0" w:type="auto"/>
<w:tblLayout w:type="fixed"/>
<w:tblLook w:val="04A0" w:firstRow="1" w:lastRow="0" w:firstColumn="0" w:lastColumn="0" w:noHBand="0" w:noVBand="1"/>
<w:tblBorders>
<w:top w:val="single" w:sz="8" w:space="0" w:color="F58220"/>
<w:left w:val="single" w:sz="8" w:space="0" w:color="F58220"/>
<w:bottom w:val="single" w:sz="8" w:space="0" w:color="F58220"/>
<w:right w:val="single" w:sz="8" w:space="0" w:color="F58220"/>
<w:insideH w:val="single" w:sz="8" w:space="0" w:color="F58220"/>
<w:insideV w:val="single" w:sz="8" w:space="0" w:color="F58220"/>
</w:tblBorders>
</w:tblPr>`;
// Table grid with explicit column widths
tableXml += '<w:tblGrid>';
for (let i = 0; i < numCols; i++) {
tableXml += `<w:gridCol w:w="${colWidth}"/>`;
}
tableXml += '</w:tblGrid>';
// Table rows
rows.forEach((rowCells, rowIndex) => {
const isHeader = rowIndex === 0;
tableXml += '<w:tr>';
// Row properties for consistent height
tableXml += '<w:trPr><w:trHeight w:val="0" w:hRule="atLeast"/></w:trPr>';
// Pad row to have same number of columns
while (rowCells.length < numCols) {
rowCells.push('');
}
rowCells.forEach((cellText, _colIndex) => {
tableXml += '<w:tc>';
tableXml += '<w:tcPr>';
// Cell width
tableXml += `<w:tcW w:w="${colWidth}" w:type="dxa"/>`;
// Cell shading - orange for header, white for data rows
if (isHeader) {
tableXml += '<w:shd w:val="clear" w:color="auto" w:fill="F58220"/>';
} else {
tableXml += '<w:shd w:val="clear" w:color="auto" w:fill="FFFFFF"/>';
}
// Cell borders
tableXml +=
'<w:tcBorders>' +
'<w:top w:val="single" w:sz="8" w:space="0" w:color="F58220"/>' +
'<w:left w:val="single" w:sz="8" w:space="0" w:color="F58220"/>' +
'<w:bottom w:val="single" w:sz="8" w:space="0" w:color="F58220"/>' +
'<w:right w:val="single" w:sz="8" w:space="0" w:color="F58220"/>' +
'</w:tcBorders>';
// Cell margins for proper padding
tableXml +=
'<w:tcMar>' +
'<w:top w:w="80" w:type="dxa"/>' +
'<w:left w:w="120" w:type="dxa"/>' +
'<w:bottom w:w="80" w:type="dxa"/>' +
'<w:right w:w="120" w:type="dxa"/>' +
'</w:tcMar>';
tableXml += '</w:tcPr>';
// Cell content
tableXml += '<w:p>';
tableXml += '<w:pPr>';
tableXml += '<w:spacing w:before="0" w:after="0" w:line="240" w:lineRule="auto"/>';
tableXml += '</w:pPr>';
const runs = this.parseInlineFormatting(cellText);
if (isHeader) {
// Header: bold white text
tableXml += runs.replace(/<w:rPr>/g, '<w:rPr><w:b/><w:color w:val="FFFFFF"/>');
} else {
// Data rows: normal black text
tableXml += runs;
}
tableXml += '</w:p>';
tableXml += '</w:tc>';
});
tableXml += '</w:tr>';
});
tableXml += '</w:tbl>';
// Add spacing after table
tableXml += '<w:p><w:pPr><w:spacing w:before="120" w:after="0"/></w:pPr></w:p>';
return tableXml;
}
/**
* Detect if line contains ASCII art/flowchart characters
*/
isAsciiArt(line) {
// Don't treat markdown tables as ASCII art
if (line.trim().startsWith('|') && line.trim().endsWith('|')) {
// Check if it's a proper markdown table (has multiple cells)
const cells = line.split('|').filter((c) => c.trim());
if (cells.length >= 2) {
return false;
}
}
// Common ASCII art characters (Unicode box drawing and symbols)
const asciiArtChars = [
'─',
'│',
'┌',
'┐',
'└',
'┘',
'├',
'┤',
'┬',
'┴',
'┼', // Box drawing
'═',
'║',
'╔',
'╗',
'╚',
'╝',
'╠',
'╣',
'╦',
'╩',
'╬', // Double box
'╭',
'╮',
'╯',
'╰', // Rounded corners
'▲',
'▼',
'◄',
'►',
'♦',
'●',
'○',
'■',
'□',
'◆',
'◇', // Shapes
'↓',
'→',
'←',
'↑',
'↔',
'↕',
'⇒',
'⇐',
'⇓',
'⇑', // Arrows
'┃',
'━',
'┏',
'┓',
'┗',
'┛',
'┣',
'┫',
'┳',
'┻',
'╋', // Heavy box
'░',
'▒',
'▓',
'█', // Shading
];
// Check for box drawing characters
if (asciiArtChars.some((char) => line.includes(char))) {
return true;
}
// Check for ASCII box patterns with regular characters
const asciiPatterns = [
/^\s*\+[-=_+]+\+/, // +-----+ or +=====+
/^\s*\|[-=_]{3,}\|/, // |-----|
/^\s*[-=_]{5,}$/, // -----
/^\s*\+[-]+\+[-]+\+/, // +---+---+
/^\s*\([A-Z][a-z]+\)\s*\|\|/, // (Deformable) ||
/^\s*\|\s+[A-Z\s]+[-:]\s+[A-Z]/, // | TILE ADHESIVE - TYPE
/^\s*\|\s*\[[^\]]+\]/, // | [BIS Mark / CE Mark]
/^\s*\|\s{2,}\w+.*\|\|/, // || text ||
/^\s*\[[^\]]+\]\s*$/, // [Step in brackets]
/^\s*<[-=]+>/, // <----> or <====>
/^\s*\/[-_\\\/]+\//, // /----/
/^\s*\*[-=\*]+\*/, // *----*
];
return asciiPatterns.some((pattern) => pattern.test(line));
}
/**
* Create ASCII art XML with monospace font
* Background shading applied at paragraph level to extend full width
*/
createAsciiArtXml(asciiContent) {
// Split ASCII art into individual lines and create a paragraph for each
const lines = asciiContent.split('\n');
let xml = '';
lines.forEach((line, index) => {
// Shading (shd) is at paragraph level so background extends full width
xml += `<w:p>
<w:pPr>
<w:pStyle w:val="Normal"/>
<w:shd w:val="clear" w:color="auto" w:fill="F5F5F5"/>
<w:spacing w:before="${index === 0 ? '120' : '0'}" w:after="${index === lines.length - 1 ? '120' : '0'}" w:line="240" w:lineRule="exact"/>
<w:ind w:left="284" w:right="284"/>
<w:jc w:val="left"/>
<w:keepLines/>
<w:wordWrap w:val="0"/>
</w:pPr>`;
// Check if line contains arrow characters and color them red
const arrowChars = ['↓', '→', '←', '↑', '▼', '►', '◄', '▲'];
const hasArrow = arrowChars.some((arrow) => line.includes(arrow));
if (hasArrow) {
// Split line into parts and color arrows red
let remainingLine = line;
while (remainingLine.length > 0) {
let foundArrow = false;
let arrowIndex = -1;
let foundArrowChar = '';
// Find the first arrow in the remaining text
for (const arrow of arrowChars) {
const idx = remainingLine.indexOf(arrow);
if (idx !== -1 && (arrowIndex === -1 || idx < arrowIndex)) {
arrowIndex = idx;
foundArrowChar = arrow;
foundArrow = true;
}
}
if (foundArrow) {
// Add text before arrow (if any)
if (arrowIndex > 0) {
const beforeArrow = this.escapeXml(remainingLine.substring(0, arrowIndex));
xml += `<w:r>
<w:rPr>
<w:rFonts w:ascii="Consolas" w:hAnsi="Consolas" w:cs="Consolas"/>
<w:sz w:val="16"/>
<w:szCs w:val="16"/>
<w:noProof/>
</w:rPr>
<w:t xml:space="preserve">${beforeArrow}</w:t>
</w:r>`;
}
// Add arrow in red
const escapedArrow = this.escapeXml(foundArrowChar);
xml += `<w:r>
<w:rPr>
<w:rFonts w:ascii="Consolas" w:hAnsi="Consolas" w:cs="Consolas"/>
<w:sz w:val="16"/>
<w:szCs w:val="16"/>
<w:color w:val="FF0000"/>
<w:noProof/>
</w:rPr>
<w:t xml:space="preserve">${escapedArrow}</w:t>
</w:r>`;
// Continue with remaining text
remainingLine = remainingLine.substring(arrowIndex + foundArrowChar.length);
} else {
// No more arrows, add remaining text
const escapedRemaining = this.escapeXml(remainingLine);
xml += `<w:r>
<w:rPr>
<w:rFonts w:ascii="Consolas" w:hAnsi="Consolas" w:cs="Consolas"/>
<w:sz w:val="16"/>
<w:szCs w:val="16"/>
<w:noProof/>
</w:rPr>
<w:t xml:space="preserve">${escapedRemaining}</w:t>
</w:r>`;
remainingLine = '';
}
}
} else {
// No arrows, just add the line normally
const escapedLine = this.escapeXml(line);
xml += `<w:r>
<w:rPr>
<w:rFonts w:ascii="Consolas" w:hAnsi="Consolas" w:cs="Consolas"/>
<w:sz w:val="16"/>
<w:szCs w:val="16"/>
<w:noProof/>
</w:rPr>
<w:t xml:space="preserve">${escapedLine}</w:t>
</w:r>`;
}
xml += `</w:p>`;
});
return xml;
}
/**
* Parse inline formatting (bold, italic, code)
*/
parseInlineFormatting(text) {
let xml = '';
// Patterns for inline formatting
const patterns = [
{ regex: /\*\*\*(.+?)\*\*\*/g, bold: true, italic: true },
{ regex: /\*\*(.+?)\*\*/g, bold: true },
{ regex: /\*(.+?)\*/g, italic: true },
{ regex: /`(.+?)`/g, code: true },
];
// Simple approach: process text sequentially
let remaining = text;
while (remaining.length > 0) {
let foundMatch = false;
let earliestPos = remaining.length;
let matchedPattern = null;
let match = null;
// Find earliest match
for (const pattern of patterns) {
pattern.regex.lastIndex = 0;
const m = pattern.regex.exec(remaining);
if (m && m.index < earliestPos) {
earliestPos = m.index;
matchedPattern = pattern;
match = m;
foundMatch = true;
}
}
if (foundMatch) {
// Add text before match
if (earliestPos > 0) {
xml += this.createRunXml(remaining.substring(0, earliestPos));
}
// Add formatted text
xml += this.createRunXml(
match[1],
matchedPattern.bold,
matchedPattern.italic,
matchedPattern.code
);
remaining = remaining.substring(earliestPos + match[0].length);
} else {
// No more matches, add remaining text
xml += this.createRunXml(remaining);
break;
}
}
return xml;
}
/**
* Create a run (text segment) XML
*/
createRunXml(text, bold = false, italic = false, code = false) {
if (!text) return '';
const escapedText = this.escapeXml(text);
let propsXml = '<w:rPr>';
if (bold) propsXml += '<w:b/>';
if (italic) propsXml += '<w:i/>';
if (code) {
propsXml += '<w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/>';
propsXml += '<w:sz w:val="20"/>';
}
propsXml += '</w:rPr>';
return `<w:r>${propsXml}<w:t xml:space="preserve">${escapedText}</w:t></w:r>`;
}
/**
* Escape XML special characters
*/
escapeXml(text) {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
}
module.exports = WordTemplateExporter;