| | |
| | | import Docxtemplater from 'docxtemplater'; |
| | | import JSZipUtils from 'jszip-utils'; |
| | | import { saveAs } from 'file-saver'; |
| | | |
| | | import ImageModule from 'docxtemplater-image-module-free' |
| | | // 加载 .docx 模板文件 |
| | | function loadFile(url, callback) { |
| | | JSZipUtils.getBinaryContent(url, callback); |
| | |
| | | |
| | | function buildList(items) { |
| | | let listHtml = '<ul style="font-family: 宋体; font-size: 12pt; line-height: 1.5;">'; |
| | | |
| | | items.forEach(item => { |
| | | listHtml += `<li style="margin-bottom: 6pt;">${item.label}`; |
| | | listHtml += `<li style="margin-bottom: 6pt;">${item.deptName}`; |
| | | |
| | | if (item.children && item.children.length > 0) { |
| | | listHtml += buildList(item.children); |
| | | } |
| | | |
| | | listHtml += '</li>'; |
| | | }); |
| | | |
| | |
| | | return html; |
| | | } |
| | | |
| | | function generateTableXML(clauses, deptList) { |
| | | const allDeptNames = [...new Set(deptList.map(item => item.deptName))]; |
| | | |
| | | // 构建数据映射 |
| | | const dataMap = {}; |
| | | deptList.forEach(item => { |
| | | if (!dataMap[item.clauseNum]) dataMap[item.clauseNum] = {}; |
| | | dataMap[item.clauseNum][item.deptName] = item.chooseLab; |
| | | }); |
| | | |
| | | return ` |
| | | <w:tbl xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"> |
| | | <w:tblPr> |
| | | <w:tblW w:w="10000" w:type="pct"/> |
| | | <!-- 边框设置 --> |
| | | <w:tblBorders> |
| | | <w:top w:val="single" w:sz="4" w:space="0" w:color="000000"/> |
| | | <w:left w:val="single" w:sz="4" w:space="0" w:color="000000"/> |
| | | <w:bottom w:val="single" w:sz="4" w:space="0" w:color="000000"/> |
| | | <w:right w:val="single" w:sz="4" w:space="0" w:color="000000"/> |
| | | <w:insideH w:val="single" w:sz="4" w:space="0" w:color="000000"/> |
| | | <w:insideV w:val="single" w:sz="4" w:space="0" w:color="000000"/> |
| | | </w:tblBorders> |
| | | <w:tblLook w:val="04A0"/> |
| | | </w:tblPr> |
| | | |
| | | <!-- 列宽定义 --> |
| | | <w:tblGrid> |
| | | <w:gridCol w:w="1500"/> <!-- 条款号列 --> |
| | | <w:gridCol w:w="3500"/> <!-- 内容列 --> |
| | | ${allDeptNames.map(() => '<w:gridCol w:w="2000"/>').join('')} |
| | | </w:tblGrid> |
| | | |
| | | <!-- 表头 --> |
| | | <w:tr> |
| | | <w:tc> |
| | | <w:tcPr><w:tcW w:w="1500" w:type="dxa"/></w:tcPr> |
| | | <w:p><w:r><w:rPr><w:b/></w:rPr><w:t>条款号</w:t></w:r></w:p> |
| | | </w:tc> |
| | | <w:tc> |
| | | <w:tcPr><w:tcW w:w="3500" w:type="dxa"/></w:tcPr> |
| | | <w:p><w:r><w:rPr><w:b/></w:rPr><w:t>内容描述</w:t></w:r></w:p> |
| | | </w:tc> |
| | | ${allDeptNames.map(dept => ` |
| | | <w:tc> |
| | | <w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr> |
| | | <w:p><w:r><w:rPr><w:b/></w:rPr><w:t>${dept}</w:t></w:r></w:p> |
| | | </w:tc> |
| | | `).join('')} |
| | | </w:tr> |
| | | |
| | | <!-- 数据行 --> |
| | | ${clauses.map(clause => ` |
| | | <w:tr> |
| | | <w:tc><w:p><w:r><w:t>${clause.clauseNum}</w:t></w:r></w:p></w:tc> |
| | | <w:tc><w:p><w:r><w:t>${clause.content}</w:t></w:r></w:p></w:tc> |
| | | ${allDeptNames.map(dept => ` |
| | | <w:tc> |
| | | <w:p><w:r><w:t>${dataMap[clause.clauseNum]?.[dept] ?? 0}</w:t></w:r></w:p> |
| | | </w:tc> |
| | | `).join('')} |
| | | </w:tr> |
| | | `).join('')} |
| | | </w:tbl> |
| | | `; |
| | | } |
| | | |
| | | |
| | | function processTableHtml(html) { |
| | | if (!html) return ''; |
| | | |
| | | const parser = new DOMParser(); |
| | | const doc = parser.parseFromString(html, 'text/html'); |
| | | const table = doc.querySelector('table'); |
| | | if (!table) return ''; |
| | | |
| | | // 提取表格结构 |
| | | const rows = table.querySelectorAll('tr'); |
| | | const result = []; |
| | | |
| | | // 处理表头 |
| | | const headers = Array.from(rows[0].querySelectorAll('th')) |
| | | .map(th => `${th.textContent.trim()}`) |
| | | .join('\t'); |
| | | result.push(headers); |
| | | |
| | | // 处理数据行 |
| | | for (let i = 1; i < rows.length; i++) { |
| | | const cells = rows[i].querySelectorAll('td'); |
| | | const rowData = Array.from(cells).map(cell => { |
| | | const indent = cell.style.paddingLeft ? ' '.repeat(parseInt(cell.style.paddingLeft)/4) : ''; |
| | | const content = cell.textContent.trim(); |
| | | return indent + content; |
| | | }).join('\t'); |
| | | result.push(rowData); |
| | | } |
| | | |
| | | return result.join('\n'); |
| | | } |
| | | |
| | | function generateTableHtml(clauses, deptList) { |
| | | const allDeptNames = [...new Set(deptList.map(item => item.deptName))]; |
| | | const dataMap = {}; |
| | | |
| | | // 构建数据映射 |
| | | deptList.forEach(item => { |
| | | if (!dataMap[item.clauseNum]) dataMap[item.clauseNum] = {}; |
| | | dataMap[item.clauseNum][item.deptName] = item.chooseLab? (item.chooseLab==1?'●':'○'):'○' |
| | | }); |
| | | |
| | | return ` |
| | | <table style="width: 100%;border-collapse: collapse;font-family: 'Microsoft YaHei', sans-serif;font-size: 10.5pt;margin-bottom: 12pt;border: 1px solid #ccc"> |
| | | <thead> |
| | | <tr style="background-color: #f5f5f5;width: 100%"> |
| | | <th style="padding: 6pt 8pt;border: 1px solid #ccc;text-align: center;font-weight: bold;min-width: 60pt">条款号</th> |
| | | <th style="padding: 6pt 8pt;border: 1px solid #cccccc;text-align: left;font-weight: bold">内容描述</th> |
| | | ${allDeptNames.map(dept => ` |
| | | <th style="padding: 6pt 8pt;border: 1pt solid #cccccc;text-align: center;font-weight: bold;min-width: 50pt">${dept}</th> |
| | | `).join('')} |
| | | </tr> |
| | | </thead> |
| | | <tbody> |
| | | ${clauses.map(clause => ` |
| | | <tr> |
| | | <td style="padding: 5pt 8pt; |
| | | border: 1pt solid #e0e0e0; |
| | | text-align: center; |
| | | vertical-align: top">${clause.clauseNum}</td> |
| | | <td style=" |
| | | padding: 5pt 8pt; |
| | | border: 1pt solid #e0e0e0; |
| | | text-align: left; |
| | | vertical-align: top">${clause.content}</td> |
| | | ${allDeptNames.map(dept => ` |
| | | <td style=" |
| | | padding: 5pt 8pt; |
| | | border: 1pt solid #e0e0e0; |
| | | text-align: center; |
| | | vertical-align: top"> |
| | | ${dataMap[clause.clauseNum]?.[dept] ?? '○'} |
| | | </td> |
| | | `).join('')} |
| | | </tr> |
| | | `).join('')} |
| | | </tbody> |
| | | </table> |
| | | `; |
| | | } |
| | | const base64Regex = |
| | | /^(?:data:)?image\/(png|jpg|jpeg|svg|svg\+xml);base64,/; |
| | | |
| | | const validBase64 = |
| | | /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; |
| | | function base64Parser(tagValue) { |
| | | if ( |
| | | typeof tagValue !== "string" || |
| | | !base64Regex.test(tagValue) |
| | | ) { |
| | | return false; |
| | | } |
| | | |
| | | const stringBase64 = tagValue.replace(base64Regex, ""); |
| | | |
| | | if (!validBase64.test(stringBase64)) { |
| | | throw new Error( |
| | | "Error parsing base64 data, your data contains invalid characters" |
| | | ); |
| | | } |
| | | |
| | | // For nodejs, return a Buffer |
| | | if (typeof Buffer !== "undefined" && Buffer.from) { |
| | | return Buffer.from(stringBase64, "base64"); |
| | | } |
| | | |
| | | // For browsers, return a string (of binary content) : |
| | | const binaryString = window.atob(stringBase64); |
| | | const len = binaryString.length; |
| | | const bytes = new Uint8Array(len); |
| | | for (let i = 0; i < len; i++) { |
| | | const ascii = binaryString.charCodeAt(i); |
| | | bytes[i] = ascii; |
| | | } |
| | | return bytes.buffer; |
| | | } |
| | | const imageOptions = { |
| | | getImage(tagValue) { |
| | | return base64Parser(tagValue); |
| | | }, |
| | | getSize(img, tagValue, tagName, context) { |
| | | return [600, 600]; |
| | | }, |
| | | }; |
| | | |
| | | const base64DataURLToArrayBuffer = (dataURL) => { |
| | | // 返回包含 ArrayBuffer 和原始 base64 字符串的对象 |
| | | const base64Regex = /^data:image\/(png|jpg|jpeg|svg|svg\+xml);base64,/; |
| | | if (!base64Regex.test(dataURL)) { |
| | | return { buffer: null, base64: dataURL }; |
| | | } |
| | | |
| | | const stringBase64 = dataURL.replace(base64Regex, ""); |
| | | let binaryString = window.atob(stringBase64); |
| | | const len = binaryString.length; |
| | | const bytes = new Uint8Array(len); |
| | | for (let i = 0; i < len; i++) { |
| | | bytes[i] = binaryString.charCodeAt(i); |
| | | } |
| | | |
| | | return { |
| | | buffer: bytes.buffer, // 图片模块需要的 ArrayBuffer |
| | | base64: stringBase64 // 保留原始 base64 字符串(不带前缀) |
| | | }; |
| | | }; |
| | | |
| | | // 生成并下载 Word 文档 |
| | | export function generateWordDocument(templatePath, data, name) { |
| | | // 处理部门表格数据 |
| | | // if (data.clauses && data.duties) { |
| | | // const tableHtml = generateTableHtml(data.clauses, data.duties); |
| | | // data.departmentsTable = processTableHtml(tableHtml); |
| | | // } |
| | | |
| | | // 生成表格XML |
| | | // if (data.clauses && data.duties) { |
| | | // data.tableXML = generateTableXML(data.clauses, data.duties); |
| | | // } |
| | | |
| | | // 处理富文本字段(如果有) |
| | | if (data.summaries && typeof data.summaries === 'string') { |
| | | data.summaries = processRichText(data.summaries); |
| | |
| | | if (data.deptList && Array.isArray(data.deptList)) { |
| | | data.departmentsHtml = processRichText(convertTreeToHtml(data.deptList)); |
| | | } |
| | | if (data.orgChart && typeof data.orgChart !== 'string') { |
| | | console.warn("orgChart 不是字符串,可能被意外转换:", data.orgChart); |
| | | delete data.orgChart; // 避免传递无效数据 |
| | | } |
| | | |
| | | loadFile(templatePath, function (error, content) { |
| | | if (error) { |
| | |
| | | try { |
| | | // 加载模板文件内容到 PizZip |
| | | const zip = new PizZip(content); |
| | | const imageModule = new ImageModule(imageOptions); |
| | | const doc = new Docxtemplater(zip, { |
| | | paragraphLoop: true, |
| | | linebreaks: true, |
| | | modules: [imageModule] |
| | | }); |
| | | |
| | | // 设置模板中的占位符数据 |
| | |
| | | // 渲染文档 |
| | | doc.render(); |
| | | |
| | | // 替换占位符 |
| | | // let xml = zip.files['word/document.xml'].asText(); |
| | | // xml = xml.replace('<!-- TABLE_PLACEHOLDER -->', data.tableXML); |
| | | // zip.file('word/document.xml', xml); |
| | | |
| | | // 生成最终的文档 Blob |
| | | const fileWord = doc.getZip().generate({ |
| | | type: 'blob', |