赞
踩
最近接触到一个有点意思的开发需求, 需要在前端富文本编写后, 能在后端导出原样的 Excel 文本, 当然这个 Excel 包含富文本和图片. 前端减少造轮子的过程用了 wangEditor5, 后端里导出 Excel 用了 exceljs
流程
这里前端 wangEditor5 就不在这里写了, 不是重点, 直接给出我的 toolbarKeys
js
复制代码
toolbarKeys: [ // 一些常用的菜单 key "blockquote", "bold", // 加粗 "italic", // 斜体 "through", // 删除线 "underline", // 下划线 "bulletedList", // 无序列表 "numberedList", // 有序列表 "color", // 文字颜色 "fontSize", // 字体大小 "uploadImage", // 上传图片 "deleteImage", //删除图片 "divider", // 分割线 "code", // 行内代码 "codeBlock", // 代码块 "undo", // 撤销 "redo", // 重做 ],
这个是重点了, exceljs 是支持输出富文本到 Excel 文档的, 但是格式要按它的来, 所以要做格式的转换才行. 详情可以看看官方文档 github.com/exceljs/exc…
Enum: Excel.ValueType.RichText
样式丰富的文本。
例如:
js
复制代码
worksheet.getCell('A1').value = { richText: [ { text: 'This is '}, {font: {italic: true}, text: 'italic'}, ] };
但是官方文档也没有说明要怎么转换, 后面我实验所得, 可以去看格式, 字休那里, 按它的格式来设置参数. 当然还有对齐方式等等, 我没有去搞.
下面是我写的一个转换的类, 各位可以参考一下, 去修改成自己相应的格式, 这里面并不全, 思路在这里了, 另外, 我这里转换的格式, 是把图片单独抽到一个单元格去计算宽高, 把文字分隔开来. 各位也可以按实际需求去调整.
思路是先解释出 DOM, 然后转成 JSON, 在把 JSON 转成 exceljs 的富文本格式.
js
复制代码
import { JSDOM } from 'jsdom'; import { DOMWindow } from 'jsdom'; import * as ExcelJS from 'exceljs'; export default class HtmlToExcelJS { // 定义一个 Node 类型 private Node: DOMWindow['Node']; /** * HTML 转 ExcelJS 富文本格式数组 * @param html HTML 字符串 * @returns ExcelJS 富文本格式数组 */ public toExcelJS(html): ExcelJS.RichText[] { const dom = new JSDOM(html); this.Node = dom.window.Node; const json = this.htmlToJson(dom.window.document.body); const richTexts = this.jsonToRichText(json); // richTexts 数组里, 只要 img, 分割出数组, 前面一组, 图片一组, 后面一样 const richTextsArr = []; let richTextsTemp = []; for (let i = 0; i < richTexts.length; i++) { if (richTexts[i].img) { richTextsArr.push(richTextsTemp); richTextsTemp = []; richTextsArr.push([richTexts[i]]); } else { richTextsTemp.push(richTexts[i]); if (i === richTexts.length - 1) { richTextsArr.push(richTextsTemp); } } } // richTextsArr 数组, 删除 [ { font: {}, text: '\n' } ] 元素 for (let i = 0; i < richTextsArr.length; i++) { // 最后一个元素为换行符, 删除 if (richTextsArr[i].length === 1 && richTextsArr[i][0].text === '\n') { richTextsArr.splice(i, 1); } } return richTextsArr; } /** * 将 RGB 颜色值转换为 ARGB 颜色值 * @param rgb rgb * @returns ARGB */ private rgbToArgb(rgb) { // 移除 "rgb(" 和 ")",然后将结果分割为一个数组 const parts = rgb.replace(/rgba?\(/, '').replace(/\)/, '').split(','); // 将 RGB 颜色值转换为十六进制 const r = parseInt(parts[0]).toString(16).padStart(2, '0'); const g = parseInt(parts[1]).toString(16).padStart(2, '0'); const b = parseInt(parts[2]).toString(16).padStart(2, '0'); // 返回 ARGB 颜色值 return 'ff' + r + g + b; } /** * HTML 转 JSON * @param element * @returns JSON */ private htmlToJson(element) { const json = { tagName: element.tagName, font: {}, img: {}, children: [], }; for (let i = 0; i < element.attributes.length; i++) { const attr = element.attributes[i]; if (attr.name === 'style' && attr.value.startsWith('font-size')) { // 字体大小 json.font['size'] = attr.value.match(/\d+/)[0]; } else if (attr.name === 'style' && attr.value.startsWith('color')) { // 字体颜色 const rgp = attr.value.replace('color: ', ''); json.font['color'] = { argb: this.rgbToArgb(rgp) }; } else { json.font[attr.name] = attr.value; } // 代码块处理 if (json.tagName === 'CODE' || json.tagName === 'BLOCKQUOTE') { json.font['color'] = { argb: '6A9B59' }; // 绿色 #6A9B59 json.font['code'] = true; } } for (let i = 0; i < element.childNodes.length; i++) { const childNode = element.childNodes[i]; if (childNode.nodeType === this.Node.ELEMENT_NODE) { json.children.push(this.htmlToJson(childNode)); } else if (childNode.nodeType === this.Node.TEXT_NODE) { json.children.push({ text: childNode.textContent }); } } return json; } /** * 处理 JSON 数据 * @param json */ private handleJson(json) { // HR 水平线, 添加分割线 if (json?.tagName === 'HR') { json.children.push({ text: '----------------------------------------' }); } // markdown BLOCKQUOTE 前面添加 > if (json?.tagName === 'BLOCKQUOTE') { json.children.unshift({ text: '> ' }); } // 如果是块级元素,添加换行符, 注意:如果输出到单个单元格,换行符需要添加 if (['P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HR', 'CODE', 'BLOCKQUOTE', 'LI'].includes(json.tagName)) { json.children.push({ text: '\n' }); } // 如果是 CODE 标签,添加代码块标记, 和语言标记 if (json?.tagName === 'CODE') { const lang = json?.font?.class?.split('-')[1]; json.children.unshift({ text: '```' + lang + '\n'}); json.children.push({ text: '```' + '\n' }); } // 处理子元素 json?.children?.forEach((child) => { // 如果是列表元素 UL,子类 LI 添加 • 号 if (json?.tagName === 'UL' && child?.tagName === 'LI') { child?.children?.unshift({ text: '• ' }); } // 如果是列表元素 OL,子类 LI 添加序号 if (json?.tagName === 'OL' && child?.tagName === 'LI') { child?.children?.unshift({ text: `${json?.children?.indexOf(child) + 1}. ` }); } this.handleJson(child); }) return json; } /** * 将 JSON 转换为 ExcelJS 富文本 * @param json * @returns ExcelJS 富文本 */ private jsonToRichText(json) { json = this.handleJson(json) const richText = []; // 标签对应的字体样式 const tagToFont = { 'STRONG': { bold: true }, // 字体 粗细 'EM': { italic: true }, // 字体 斜体 'U': { underline: true }, // 字体 下划线 'S': { strike: true }, // 字体 删除线 'BLOCKQUOTE': { size: 14, color: { argb: '6A9B59' } }, // markdown blockquote 'H1': { size: 28 }, // h1 标签 字体大小 'H2': { size: 24 }, // h2 标签 字体大小 'H3': { size: 20 }, // h3 标签 字体大小 'H4': { size: 16 }, // h4 标签 字体大小 'H5': { size: 14 }, // h5 标签 字体大小 'H6': { size: 12 }, // h6 标签 字体大小 }; // 字体属性, 用于提取 font 中的属性, 可以自定义添加去做特别处理, 如 ‘code’ 代码块 const fontProperties = ['size', 'color', 'bold', 'italic', 'underline', 'strike', 'outline', 'name', 'family', 'scheme', 'vertAlign', 'charset', 'code']; function traverse(node, parentFont, parentTagName) { let currentFont = parentFont ? { ...parentFont } : {}; // 如果当前节点有字体样式,将其添加到 currentFont 中 if (node.font) { // 从 node.font 中提取 fontProperties 中的属性 const newFontProperties = fontProperties.reduce((acc, prop) => { if (prop in node.font) { acc[prop] = node.font[prop]; } return acc; }, {}); // 将新的属性合并到 currentFont 中 Object.assign(currentFont, newFontProperties); // 如果父标签有字体样式,继承父标签的字体样式 if (tagToFont[parentTagName]) { Object.assign(currentFont, tagToFont[parentTagName]); } } // 如果当前节点是文本节点,将其添加到 richText 数组中 if (node.children) { for (let child of node.children) { if (child.text) { const textObj = { font: currentFont, text: child.text }; richText.push(textObj); } if (child.children) { traverse(child, currentFont, child.tagName); } } } // 图片处理 if (node.tagName === 'IMG') { const width = node?.font?.style?.match(/width: (\d+)/)?.[1]; const height = node?.font?.style?.match(/height: (\d+)/)?.[1]; const src = node?.font?.src; const textObj = { img: { width, height, src, alt: node?.font?.alt }, text: node?.font?.alt }; richText.push(textObj); } } traverse(json, null, json.tagName); return richText; } }
exceljs 用法, 可以直接去官方啊, 这里就不写了, 不是重点, 这里有点要注意的, exceljs 在计算高度时, 有个 bug, 在 pc windows 和 mac 之间, 设置的像素点是不一样的, 比如设置的每一列的宽度或高度, 在二个系统都不一致. 这里在 issues 有解决办法了. 就是对创建的 worksheet 的 views 做一个初始化
js
复制代码
const worksheet = workbook.addWorksheet('worksheet', { views: [{}] });
或者
js
复制代码
const worksheet = workbook.addWorksheet('worksheet', { properties: { defaultRowHeight: rowHeight }, pageSetup: { paperSize: 9, orientation: 'portrait', fitToPage: true, fitToWidth: 1, fitToHeight: 0 }, views: [{ zoomScale: 100, zoomScaleNormal: 100 }] });
直接上部分代码吧, 参数着吧, 每个项目的实际需求都不一样.
js
复制代码
// Html 转 ExcelJS 富文本格式数组 const richTexts = new HtmlToExcelJS().toExcelJS(content?.content) // 遍历富文本数组 for (const index in richTexts) { const richText: any = richTexts[index]; // contentRow2 const contentRow2 = worksheet.getRow(currentRow); // 合并 B 到 J worksheet.mergeCells(`B${currentRow}:J${currentRow}`); if (index === '0') { contentRow2.getCell(1).value = '內容 ' + (Number(key) + 1) + '\n' + 'Meeting Minutes ' + (Number(key) + 1); contentRow2.getCell(1).style = titleCellStyle; // 合并 A currentRow 到 A (currentRow + richTexts.length - 1) worksheet.mergeCells(`A${currentRow}:A${currentRow + richTexts.length - 1}`); } if (richText?.[0]?.img) { // 图片 // 计算图片宽高 async function getImageDimensions(imgBuffer) { try { const metadata = await sharp(imgBuffer).metadata(); let imgWidth = metadata.width; let imgHeight = metadata.height; // 计算图片宽度 字符宽度 * 8 = 像素宽度(8是推算出来的, 1个字符大约是8像素) const rowWidth = (bWidth + cWidth + dWidth + eWidth + fWidth + gWidth + hWidth + iWidth + jWidth) * 8; if (imgWidth > rowWidth) { imgHeight = Math.floor(imgHeight * (rowWidth / imgWidth)); imgWidth = rowWidth; } return { imgWidth, imgHeight }; } catch (err) { console.error('获取图片元数据失败:', err); } } // 写入图片到单元格 async function addImageToWorksheet(imgBuffer, imgWidth, imgHeight) { const imageId = workbook.addImage({ buffer: imgBuffer, extension: 'png', }); worksheet.addImage(imageId, { tl: { col: 1.1, row: currentRow - 1 + 0.01 }, ext: { width: imgWidth, height: imgHeight }, editAs: 'oneCell' }); } // src 去掉域名 const src = richText?.[0]?.img?.src.replace(/https?:\/\/[^/]+/, ''); // 取得当前目录路径 const currentPath = process.cwd(); // 本地图片文件路径 const imgPath = path.join(currentPath, 'public', src); // 图片宽度 let imgageWidth = richText?.[0]?.img?.width // 图片高度 let imgageHeight = richText?.[0]?.img?.height // 图片缓存 let imgBuffer // 本地图片文件不存在, 尝试下载网络图片 if (!fs.existsSync(imgPath)) { const srcHttp = richText?.[0]?.img?.src; try { const response = await axios.get(srcHttp, { responseType: 'arraybuffer' }); if (response) { imgBuffer = Buffer.from(response.data); } } catch (err) { console.error('获取网络图片文件失败:', err); } } else { imgBuffer = fs.readFileSync(imgPath); } if (imgBuffer) { // 如果没有设置图片宽高, 获取图片原宽高 if (!richText?.[0]?.img?.height) { const { imgWidth, imgHeight } = await getImageDimensions(imgBuffer); imgageWidth = imgWidth; imgageHeight = imgHeight; } contentRow2.height = imgageHeight / 1.3; await addImageToWorksheet(imgBuffer, imgageWidth, imgageHeight); } } else { // 文本 contentRow2.getCell(2).value = { richText }; contentRow2.getCell(2).alignment = { vertical: 'top', horizontal: 'left', wrapText: true }; // 计算文本内容的高度 function calculateNumLines(text, font, lineWidth, charWidth, rowHeight) { // 换行符的数量 const matches = text.match(/\n/g); // 行数 let numLines = matches ? matches.length : 0; // 字体高度补偿 if (font && font.size) { const lines = font.size * 1.3 / rowHeight - 1; numLines += lines; } // 计算超出的行数补偿, 不计算代码块code if (!font.code) { // 计算超出的行数补偿, 计算每行的字符数, 一行约 97 字符 const charsPerLine = Math.floor(lineWidth / charWidth); // 超出行数 const outNumLines = Math.ceil(text.length / charsPerLine); if (outNumLines > 1) { numLines += outNumLines - 1; } } return numLines; } const lineWidth = bWidth + cWidth + dWidth + eWidth + fWidth + gWidth + hWidth + iWidth + jWidth; const charWidth = 1.76; // 每个字符的宽度是1.76 // 计算每个富文本的行数 const numLinesArray = richText.map(rt => calculateNumLines(rt.text, rt.font, lineWidth, charWidth, rowHeight)); // 计算总高度 const cellHeight = numLinesArray.reduce((acc, numLines) => acc + numLines * rowHeight, 0); // 设置特定的行高 worksheet.getRow(currentRow).height = cellHeight < (rowHeight * 2) ? (rowHeight * 2) : cellHeight; } currentRow++; }
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。