Spaces:
Sleeping
Sleeping
| 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); | |
| }; | |