/** * 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 elements in section properties const pgSzRegex = /]*\/>/g; let modifiedXml = documentXml.replace(pgSzRegex, () => { return ``; }); // If no pgSz found, add it to all sectPr elements if (!pgSzRegex.test(documentXml)) { const sectPrRegex = /]*>/g; modifiedXml = modifiedXml.replace(sectPrRegex, (match) => { return `${match}`; }); } 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 = /]*>[\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 ) or if no section breaks found const bodyStart = documentXml.indexOf('') + 8; return documentXml.slice(0, bodyStart) + newContentXml + documentXml.slice(bodyStart); } else { // If not enough section breaks, insert before closing body tag return documentXml.replace('', newContentXml + ''); } } /** * 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 += ''; 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 ` ${runs} `; } /** * Create paragraph XML with Normal style */ createParagraphXml(text) { const runs = this.parseInlineFormatting(text); return ` ${runs} `; } /** * Create quote XML */ createQuoteXml(text) { const runs = this.parseInlineFormatting(text); return ` ${runs} `; } /** * Create list item XML using template numbering */ createListItemXml(text, numbered) { const runs = this.parseInlineFormatting(text); const numId = numbered ? '1' : '2'; // Template numbering IDs return ` ${runs} `; } /** * 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 += ` ${escapedLine} `; }); return xml; } /** * Create horizontal rule XML */ createHorizontalRuleXml() { return ` `; } /** * 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 = ''; // Table properties - use template table style with full width tableXml += ` `; // Table grid with explicit column widths tableXml += ''; for (let i = 0; i < numCols; i++) { tableXml += ``; } tableXml += ''; // Table rows rows.forEach((rowCells, rowIndex) => { const isHeader = rowIndex === 0; tableXml += ''; // Row properties for consistent height tableXml += ''; // Pad row to have same number of columns while (rowCells.length < numCols) { rowCells.push(''); } rowCells.forEach((cellText, _colIndex) => { tableXml += ''; tableXml += ''; // Cell width tableXml += ``; // Cell shading - orange for header, white for data rows if (isHeader) { tableXml += ''; } else { tableXml += ''; } // Cell borders tableXml += '' + '' + '' + '' + '' + ''; // Cell margins for proper padding tableXml += '' + '' + '' + '' + '' + ''; tableXml += ''; // Cell content tableXml += ''; tableXml += ''; tableXml += ''; tableXml += ''; const runs = this.parseInlineFormatting(cellText); if (isHeader) { // Header: bold white text tableXml += runs.replace(//g, ''); } else { // Data rows: normal black text tableXml += runs; } tableXml += ''; tableXml += ''; }); tableXml += ''; }); tableXml += ''; // Add spacing after table tableXml += ''; 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 += ` `; // 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 += ` ${beforeArrow} `; } // Add arrow in red const escapedArrow = this.escapeXml(foundArrowChar); xml += ` ${escapedArrow} `; // Continue with remaining text remainingLine = remainingLine.substring(arrowIndex + foundArrowChar.length); } else { // No more arrows, add remaining text const escapedRemaining = this.escapeXml(remainingLine); xml += ` ${escapedRemaining} `; remainingLine = ''; } } } else { // No arrows, just add the line normally const escapedLine = this.escapeXml(line); xml += ` ${escapedLine} `; } xml += ``; }); 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 = ''; if (bold) propsXml += ''; if (italic) propsXml += ''; if (code) { propsXml += ''; propsXml += ''; } propsXml += ''; return `${propsXml}${escapedText}`; } /** * Escape XML special characters */ escapeXml(text) { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } } module.exports = WordTemplateExporter;