| | |
| | | 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); |
| | |
| | | }); |
| | | }); |
| | | |
| | | return result.join('\n\n'); // 用两个换行符分隔段落 |
| | | return result.join('\n'); // 用两个换行符分隔段落 |
| | | } |
| | | |
| | | function convertTreeToHtml(data) { |
| | |
| | | |
| | | 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; |
| | | } |
| | | |
| | | function getDimensionsFromBase64Sync(base64Str) { |
| | | try { |
| | | // 去除 data:image/...;base64, 前缀 |
| | | const base64Data = base64Str.replace(/^data:image\/\w+;base64,/, ''); |
| | | |
| | | // 将base64转换为二进制 |
| | | const binaryString = atob(base64Data); |
| | | const bytes = new Uint8Array(binaryString.length); |
| | | for (let i = 0; i < binaryString.length; i++) { |
| | | bytes[i] = binaryString.charCodeAt(i); |
| | | } |
| | | |
| | | return parseImageDimensions(bytes); |
| | | } catch (error) { |
| | | console.warn('解析图片尺寸失败:', error); |
| | | return { width: 550, height: 400 }; |
| | | } |
| | | } |
| | | |
| | | function parseImageDimensions(bytes) { |
| | | if (bytes.length < 8) { |
| | | return { width: 550, height: 400 }; |
| | | } |
| | | |
| | | // 检查 PNG 格式 (89 50 4E 47 0D 0A 1A 0A) |
| | | if (bytes[0] === 0x89 && bytes[1] === 0x50 && |
| | | bytes[2] === 0x4E && bytes[3] === 0x47) { |
| | | return parsePNGDimensions(bytes); |
| | | } |
| | | |
| | | // 检查 JPEG 格式 (FF D8) |
| | | if (bytes[0] === 0xFF && bytes[1] === 0xD8) { |
| | | return parseJPEGDimensions(bytes); |
| | | } |
| | | |
| | | return { width: 550, height: 400 }; |
| | | } |
| | | |
| | | // 解析 PNG 尺寸 |
| | | function parsePNGDimensions(bytes) { |
| | | // PNG 的宽高在 IHDR chunk 中(偏移 16-24 字节) |
| | | if (bytes.length >= 24) { |
| | | const view = new DataView(bytes.buffer); |
| | | const width = view.getUint32(16, false); // 大端序 |
| | | const height = view.getUint32(20, false); |
| | | return { width, height }; |
| | | } |
| | | return { width: 550, height: 400 }; |
| | | } |
| | | |
| | | // 解析 JPEG 尺寸 |
| | | function parseJPEGDimensions(bytes) { |
| | | let i = 2; // 跳过 FFD8 |
| | | |
| | | while (i < bytes.length - 1) { |
| | | // JPEG 标记开始 |
| | | if (bytes[i] === 0xFF) { |
| | | const marker = bytes[i + 1]; |
| | | |
| | | // SOF0, SOF1, SOF2 (Start of Frame markers) |
| | | if ((marker >= 0xC0 && marker <= 0xC3) || |
| | | (marker >= 0xC5 && marker <= 0xC7) || |
| | | (marker >= 0xC9 && marker <= 0xCB) || |
| | | (marker >= 0xCD && marker <= 0xCF)) { |
| | | |
| | | if (i + 7 < bytes.length) { |
| | | const height = (bytes[i + 5] << 8) | bytes[i + 6]; |
| | | const width = (bytes[i + 7] << 8) | bytes[i + 8]; |
| | | return { width, height }; |
| | | } |
| | | break; |
| | | } |
| | | |
| | | // 跳过当前段 |
| | | const length = (bytes[i + 2] << 8) | bytes[i + 3]; |
| | | i += length + 2; |
| | | } else { |
| | | i++; |
| | | } |
| | | } |
| | | |
| | | return { width: 550, height: 400 }; |
| | | } |
| | | |
| | | const imageOptions = { |
| | | getImage(tagValue) { |
| | | return base64Parser(tagValue); |
| | | }, |
| | | getSize(img, tagValue, tagName, context) { |
| | | const dimensions = getDimensionsFromBase64Sync(tagValue); |
| | | const { width, height } = dimensions; |
| | | if(tagName == 'sign1' || tagName == 'sign2' || tagName == 'sign3' || tagName == 'sign4'){ |
| | | const targetWidth = 160; |
| | | const scale = targetWidth / width; |
| | | let targetHeight = height * scale; |
| | | targetHeight = Math.max(100, Math.min(400, targetHeight)); |
| | | return [targetWidth, Math.round(targetHeight)]; |
| | | }else{ |
| | | const targetWidth = 550; |
| | | const scale = targetWidth / width; |
| | | let targetHeight = height * scale; |
| | | targetHeight = Math.max(100, Math.min(800, targetHeight)); |
| | | return [targetWidth, Math.round(targetHeight)]; |
| | | } |
| | | }, |
| | | }; |
| | | |
| | | 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); |
| | | // } |
| | | |
| | | if (data.productServiceImages && Array.isArray(data.productServiceImages)) { |
| | | // 确保是纯 base64 字符串数组 |
| | | data.productServiceImageArray = data.productServiceImages.map(item => |
| | | typeof item === 'object' ? item.image : item |
| | | ).filter(img => img && typeof img === 'string'); |
| | | |
| | | // 为前10张图片创建单独的图片变量 |
| | | data.productServiceImageArray.slice(0, 10).forEach((img, index) => { |
| | | data[`productServiceImage${index + 1}`] = img; |
| | | }); |
| | | |
| | | // 创建带元数据的对象数组 |
| | | data.productServiceImageObjects = data.productServiceImageArray.map((img, index) => ({ |
| | | image: img, // 这个字段会作为图片插入 |
| | | index: index + 1, |
| | | description: `产品和服务实现过程图 ${index + 1}` |
| | | })); |
| | | |
| | | data.productServiceCount = data.productServiceImageArray.length; |
| | | data.hasProductServiceImages = data.productServiceImageArray.length > 0; |
| | | } |
| | | // 处理富文本字段(如果有) |
| | | if (data.summaries && typeof data.summaries === 'string') { |
| | | data.summaries = processRichText(data.summaries); |
| | |
| | | if (data.policies && typeof data.policies === 'string') { |
| | | data.policies = processRichText(data.policies); |
| | | } |
| | | if (data.proclaim1 && typeof data.proclaim1 === 'string') { |
| | | data.proclaim1 = processRichText(data.proclaim1); |
| | | |
| | | }if (data.proclaim2 && typeof data.proclaim2 === 'string') { |
| | | data.proclaim2 = processRichText(data.proclaim2); |
| | | |
| | | }if (data.proclaim3 && typeof data.proclaim3 === 'string') { |
| | | data.proclaim3 = processRichText(data.proclaim3); |
| | | |
| | | }if (data.proclaim4 && typeof data.proclaim4 === 'string') { |
| | | data.proclaim4 = processRichText(data.proclaim4); |
| | | } |
| | | // 处理树形结构数据(如果有) |
| | | 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; // 避免传递无效数据 |
| | | } |
| | | if (data.sign1 && typeof data.sign1 !== 'string') { |
| | | console.warn("sign1 不是字符串,可能被意外转换:", data.sign1); |
| | | delete data.sign1; // 避免传递无效数据 |
| | | } |
| | | if (data.sign2 && typeof data.sign2 !== 'string') { |
| | | console.warn("sign1 不是字符串,可能被意外转换:", data.sign2); |
| | | delete data.sign2; // 避免传递无效数据 |
| | | } |
| | | if (data.sign3 && typeof data.sign3 !== 'string') { |
| | | console.warn("sign1 不是字符串,可能被意外转换:", data.sign3); |
| | | delete data.sign3; // 避免传递无效数据 |
| | | } |
| | | if (data.sign4 && typeof data.sign4 !== 'string') { |
| | | console.warn("sign1 不是字符串,可能被意外转换:", data.sign4); |
| | | delete data.sign4; // 避免传递无效数据 |
| | | } |
| | | |
| | | loadFile(templatePath, function (error, content) { |
| | |
| | | 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({ |
| | |
| | | throw error; |
| | | } |
| | | }); |
| | | } |
| | | } |