import { Document, Packer, Paragraph, TextRun, HeadingLevel, Table, TableRow, TableCell, WidthType, ShadingType, AlignmentType, BorderStyle, Header, ImageRun } from 'docx'; import { saveAs } from 'file-saver'; /** * Exports structured JSON content to a .docx file. * @param {Array} elements - The structured document elements. * @param {string} filename - The name of the file to save. */ /** * Parses markdown-style text (bold/italic) into docx TextRun objects. * @param {string} text * @returns {TextRun[]} */ const parseRichText = (text, options = {}) => { const defaultOptions = { font: "Times New Roman", size: 22, ...options }; if (typeof text !== 'string') return [new TextRun({ text: String(text), ...defaultOptions })]; const parts = []; const regex = /(\*\*.*?\*\*|\*.*?\*)/g; let lastIndex = 0; let match; while ((match = regex.exec(text)) !== null) { if (match.index > lastIndex) { parts.push(new TextRun({ text: text.substring(lastIndex, match.index), ...defaultOptions })); } const content = match[0]; if (content.startsWith('**')) { parts.push(new TextRun({ text: content.slice(2, -2), bold: true, ...defaultOptions })); } else if (content.startsWith('*')) { parts.push(new TextRun({ text: content.slice(1, -1), italics: true, ...defaultOptions })); } lastIndex = regex.lastIndex; } if (lastIndex < text.length) { parts.push(new TextRun({ text: text.substring(lastIndex), ...defaultOptions })); } return parts.length > 0 ? parts : [new TextRun({ text, ...defaultOptions })]; }; const stripPrefix = (text) => { if (typeof text !== 'string') return text; return text.replace(/^(\([a-zA-Z0-9]+\)|[a-zA-Z0-9]+[\.\)]|[\-\•])\s*/, ''); }; export const exportToDocx = async (elements, filename = 'Formatted_Document.docx', docType = 'rfp', logoBase64 = null, contractAddress = '') => { const isRfp = docType === 'rfp'; const children = elements.map(el => { const align = el.alignment === 'center' ? AlignmentType.CENTER : AlignmentType.LEFT; switch (el.type) { case 'heading1': return new Paragraph({ children: parseRichText(el.content, { color: '000000', bold: true, size: 22 }), shading: isRfp ? { fill: 'FFC000', type: ShadingType.CLEAR, color: 'auto', } : undefined, spacing: { before: isRfp ? 240 : 480, after: isRfp ? 200 : 320, }, alignment: align, }); case 'heading2': return new Paragraph({ children: parseRichText(el.content, { size: 22, bold: true, color: '000000' }), spacing: { before: isRfp ? 200 : 400, after: isRfp ? 160 : 240 }, alignment: align, }); case 'heading3': return new Paragraph({ children: parseRichText(el.content, { size: 22, bold: true, italics: true, color: '000000' }), spacing: { before: isRfp ? 160 : 320, after: isRfp ? 120 : 200 }, alignment: align, }); case 'bulletList': return el.content.map(item => new Paragraph({ children: parseRichText(stripPrefix(item)), bullet: { level: 0 }, indent: { left: 720, hanging: 360 }, spacing: { before: isRfp ? 40 : 80, after: isRfp ? 40 : 80 }, alignment: align, })); case 'numberedList': return el.content.map(item => new Paragraph({ children: parseRichText(stripPrefix(item)), numbering: { reference: 'main-numbering', level: el.listType === 'lowerAlpha' ? 1 : 0 }, indent: { left: el.listType === 'lowerAlpha' ? 1440 : 720, hanging: 360 }, spacing: { before: isRfp ? 40 : 80, after: isRfp ? 40 : 80 }, alignment: align, })); case 'table': return new Table({ width: { size: 100, type: WidthType.PERCENTAGE, }, rows: el.content.map(row => new TableRow({ children: row.map(cell => new TableCell({ children: [new Paragraph({ children: parseRichText(cell, { size: 22 }), spacing: { line: 360 } // 1.5 line spacing })], borders: { top: { style: BorderStyle.SINGLE, size: 2 }, bottom: { style: BorderStyle.SINGLE, size: 2 }, left: { style: BorderStyle.SINGLE, size: 2 }, right: { style: BorderStyle.SINGLE, size: 2 }, }, margins: { top: 100, bottom: 100, left: 100, right: 100, }, shading: { fill: 'FFFFFF', type: ShadingType.CLEAR, color: 'auto', } }) ) }) ), }); case 'paragraph': default: return new Paragraph({ children: parseRichText(el.content), spacing: { before: isRfp ? 120 : 240, after: isRfp ? 120 : 240, line: 360 // 1.5 line spacing }, alignment: align, }); } }).flat(); const doc = new Document({ numbering: { config: [ { reference: "main-numbering", levels: [ { level: 0, format: "decimal", text: "%1.", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 720, hanging: 360 } /* 0.5" left, 0.25" hanging */ } } }, { level: 1, format: "lowerLetter", text: "%2.", alignment: AlignmentType.LEFT, style: { paragraph: { indent: { left: 1440, hanging: 360 } /* 1.0" left, 0.25" hanging */ } } }, ], }, ], }, sections: [{ properties: {}, headers: { default: new Header({ children: [ new Table({ width: { size: 100, type: WidthType.PERCENTAGE, }, borders: { top: { style: BorderStyle.NONE }, bottom: { style: BorderStyle.NONE }, left: { style: BorderStyle.NONE }, right: { style: BorderStyle.NONE }, insideVertical: { style: BorderStyle.NONE }, insideHorizontal: { style: BorderStyle.NONE }, }, rows: [ new TableRow({ children: [ new TableCell({ width: { size: 35, type: WidthType.PERCENTAGE }, children: logoBase64 ? [ new Paragraph({ children: [ new ImageRun({ data: Uint8Array.from(atob(logoBase64.split(',')[1]), c => c.charCodeAt(0)), transformation: { width: 150, height: 50, }, }), ], alignment: AlignmentType.LEFT, }) ] : [new Paragraph({})], // Fallback for empty cell }), new TableCell({ width: { size: 65, type: WidthType.PERCENTAGE }, verticalAlign: AlignmentType.CENTER, children: contractAddress ? [ new Paragraph({ children: contractAddress.split('\n').map((line, i) => new TextRun({ text: line, font: "Times New Roman", size: 20, // 10pt color: "000000", break: i > 0 ? 1 : 0 }) ), alignment: AlignmentType.RIGHT, }) ] : [new Paragraph({})], // Fallback for empty cell }), ], }), ], }), ], }), }, children: children, }], }); const blob = await Packer.toBlob(doc); saveAs(blob, filename); };