前言:最近遇到一个需求,需要将页面的html导出为word文档,并且包含横向和竖向页面,并且可以进行混合方向导出。经过一段时间的实验,发现只有docx这个库满足这个要求。在这里记录一下实现思路以及代码。
一、效果展示
页面内容:
导出样式:
二、解决思路
1、首先是需要在页面上设置哪些部分是需要横向导出,哪些部分是需要竖向导出的。以方便后面进行解析。
2、根据页面样式以及各类html标签进行解析。然后以docx的形式生成,最后导出来。
三、实现代码
1、index.vue
这里 class 中的 section 代表了docx中的一节,也就是一个页面。同时newpage属性控制了是不是要换一个新页,orient属性是页面横向纵向的标识(Z纵向H横向)。也可以根据自己的需求自行添加属性,在后面自己进行对应的解析。
javascript
<template>
<div>
<el-row>
<el-col :span="24">
<div>
<el-button type="primary" @click="exportToWord" style="float: right">导出</el-button>
</div>
<div style="overflow-y: auto; height: calc(85vh)" id="export">
<div class="section" orient="Z">
<h1 style="text-align: center">这里是标题1</h1>
</div>
<div class="section" orient="Z" newpage="true">
<h2 style="text-align: center">这里是标题2</h2>
<h3 style="text-align: center">这里是标题3</h3>
</div>
<div class="section" orient="Z">
<p>这里是一段文字内容</p>
</div>
<div class="section" orient="Z">
<el-table :data="tableData" :span-method="arraySpanMethod" border style="width: 100%">
<el-table-column prop="id" label="ID" width="180" header-align="center" align="left"/>
<el-table-column prop="name" label="姓名" width="" header-align="center" align="left"/>
<el-table-column prop="amount1" label="列 1" width="" header-align="center" align="center"/>
<el-table-column prop="amount2" label="列 2" width="" header-align="center" align="right"/>
<el-table-column prop="amount3" label="列 3" width="" header-align="center" align="left"/>
</el-table>
</div>
<div class="section" orient="H">
<p>这里是横向页面内容</p>
</div>
<div class="section" orient="Z">
<p>这里是纵向页面内容</p>
</div>
</div>
</el-col>
</el-row>
</div>
</template>
<script lang="ts" setup="" name="">
//导出用
import * as htmlDocx from 'html-docx-js-typescript';
import { saveAs } from 'file-saver';
import { exportDocxFromHTML } from '@/utils/exportWord';
//导出word
const exportToWord = async () => {
let contentElement = document.getElementById('export') as HTMLElement;
// 克隆元素 操作新元素
let newDiv = contentElement.cloneNode(true) as HTMLElement;
// 这里可以对newDiv进行一些操作...
exportDocxFromHTML(newDiv, `test.docx`);
};
import type { TableColumnCtx } from 'element-plus'
interface User {
id: string
name: string
amount1: string
amount2: string
amount3: number
}
interface SpanMethodProps {
row: User
column: TableColumnCtx<User>
rowIndex: number
columnIndex: number
}
const tableData:User[] = [
{
id: '12987122',
name: 'Tom',
amount1: '234',
amount2: '3.2',
amount3: 10,
},
{
id: '12987123',
name: 'Tom',
amount1: '165',
amount2: '4.43',
amount3: 12,
},
{
id: '12987124',
name: 'Tom',
amount1: '324',
amount2: '1.9',
amount3: 9,
},
{
id: '12987125',
name: 'Tom',
amount1: '621',
amount2: '2.2',
amount3: 17,
},
{
id: '12987126',
name: 'Tom',
amount1: '539',
amount2: '4.1',
amount3: 15,
},
];
const arraySpanMethod = ({
row,
column,
rowIndex,
columnIndex,
}: SpanMethodProps) => {
if (rowIndex % 2 === 0) {
if (columnIndex === 0) {
return [1, 2]
} else if (columnIndex === 1) {
return [0, 0]
}
}
}
onMounted(async () => {});
</script>
<style lang="scss" scoped></style>
2、exportWord.ts
这个部分是进行了html转换成docx形式的拼接组合。可以根据理解自行调整样式以及解析过程。
javascript
import {
Document,
Packer,
Paragraph,
TextRun,
ImageRun,
ExternalHyperlink,
WidthType,
VerticalAlign,
AlignmentType,
PageOrientation,
HeadingLevel,
Table,
TableRow,
TableCell,
BorderStyle,
} from 'docx';
import { saveAs } from 'file-saver';
/**
* 字符串是否为空
* @param {*} obj
* @returns
*/
export function isEmpty(obj:any) {
if (typeof obj == 'undefined' || obj == null || obj === '') {
return true
} else {
return false
}
};
import { ElMessageBox, ElMessage } from 'element-plus';
// 定义类型
type DocxElement = Paragraph | Table | TextRun | ImageRun | ExternalHyperlink;
//保存图片,表格,列表
type ExportOptions = {
includeImages: boolean;
includeTables: boolean;
includeLists: boolean;
};
const includeImages = ref(true);
const includeTables = ref(true);
const includeLists = ref(true);
//保存样式对象
type StyleOptions = {
bold: boolean; //是否加粗
font: Object; //字体样式
size: number; //字体大小
id: String | null; //样式id
};
//横向A4
export const H_properties_A4 = {
page: {
size: {
width: 15840, // A4 横向宽度 (11英寸)
height: 12240, // A4 横向高度 (8.5英寸)
},
},
};
//纵向A4
export const Z_properties_A4 = {
page: {
size: {
width: 12240, // A4 纵向宽度 (8.5英寸 * 1440 twip/inch)
height: 15840, // A4 纵向高度 (11英寸 * 1440)
},
orientation: PageOrientation.LANDSCAPE,
},
};
//根据html生成word文档
export const exportDocxFromHTML = async (htmlDom: any, filename: any) => {
let sections = [] as any; //页面数据
let doms = htmlDom.querySelectorAll('.section');
try {
const options: ExportOptions = {
includeImages: includeImages.value,
includeTables: includeTables.value,
includeLists: includeLists.value,
};
let preorient = 'Z';
for (let i = 0; i < doms.length; i++) {
let dom = doms[i];
let orient = dom.getAttribute('orient');
let newpage = dom.getAttribute('newpage');
if (orient == preorient && newpage != 'true' && sections.length > 0) {
//方向一致且不分页,继续从上一个section节添加
// 获取子节点
let childNodes = dom.childNodes;
// 递归处理所有节点
let children = [];
for (let i = 0; i < childNodes.length; i++) {
const node = childNodes[i];
const result = await parseNode(node, options, null);
children.push(...result);
}
if (sections[sections.length - 1].children && children.length > 0) {
for (let c = 0; c < children.length; c++) {
let one = children[c];
sections[sections.length - 1].children.push(one);
}
}
} else {
//否则则新开一个section节
// 获取子节点
let childNodes = dom.childNodes;
// 递归处理所有节点
let children = [];
for (let i = 0; i < childNodes.length; i++) {
const node = childNodes[i];
const result = await parseNode(node, options, null);
children.push(...result);
}
let section = {
properties: orient == 'H' ? H_properties_A4 : Z_properties_A4,
children: children,
};
sections.push(section);
preorient = orient;
}
}
if (sections.length > 0) {
// 创建Word文档
const doc = new Document({
styles: {
default: {
heading1: {
//宋体 二号
run: {
size: 44,
bold: true,
italics: true,
color: '000000',
font: '宋体',
},
paragraph: {
spacing: {
after: 120,
},
},
},
heading2: {
//宋体 小二
run: {
size: 36,
bold: true,
color: '000000',
font: '宋体',
},
paragraph: {
spacing: {
before: 240,
after: 120,
},
},
},
heading3: {
//宋体 四号
run: {
size: 28,
bold: true,
color: '000000',
font: '宋体',
},
paragraph: {
spacing: {
before: 240,
after: 120,
},
},
},
heading4: {
//宋体
run: {
size: 24,
bold: true,
color: '000000',
font: '宋体',
},
paragraph: {
spacing: {
before: 240,
after: 120,
},
},
},
heading5: {
run: {
size: 20,
bold: true,
color: '000000',
font: '宋体',
},
paragraph: {
spacing: {
before: 240,
after: 120,
},
},
},
},
paragraphStyles: [
{
id: 'STx4Style', // 样式ID
name: '宋体小四号样式', // 可读名称
run: {
font: '宋体', // 字体
size: 24, // 字号
},
paragraph: {
spacing: { line: 360 }, // 1.5倍行距(240*1.5=360)
indent: { firstLine: 400 }, // 首行缩进400twips(约2字符)
},
},
{
id: 'THStyle', // 样式ID
name: '表头样式', // 可读名称
run: {
font: '等线', // 字体
size: 20.5, // 字号
},
paragraph: {
spacing: {
before: 240,
after: 120,
},
},
},
{
id: 'TDStyle', // 样式ID
name: '单元格样式', // 可读名称
run: {
font: '等线', // 字体
size: 20.5, // 字号
},
// paragraph: {
// spacing: {
// before: 240,
// after: 120,
// },
// },
},
],
},
sections: sections, //.filter(Boolean) as (Paragraph | Table)[],
});
// 生成并下载文档
await Packer.toBlob(doc).then((blob) => {
saveAs(blob, filename);
});
} else {
ElMessage.error('导出失败,该页面没有要导出的信息!');
}
} catch (error) {
console.error('导出失败:', error);
ElMessage.error('导出失败,请联系管理人员!'); //查看控制台获取详细信息!');
} finally {
}
};
// 递归转换 DOM 节点为 docx 元素
export const parseNode = async (node: Node, options: ExportOptions, style: any): Promise<DocxElement[]> => {
const elements: DocxElement[] = [];
// 1、处理文本节点
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent?.trim();
if (!isEmpty(text)) {
const parent = node.parentElement;
if (style == null) {
let child = new TextRun({
text: text,
});
elements.push(child);
} else {
const isBold = style.bold ? true : parent?.tagName === 'STRONG' || parent?.tagName === 'B';
// const isItalic = parent?.tagName === 'EM' || parent?.tagName === 'I';
// const isUnderline = parent?.tagName === 'U';
const Font = style.font ? style.font : '宋体';
const Size = style.size ? style.size : 24;
if (!isEmpty(style.id)) {
let child = new TextRun({
text: text,
style: style.id,
});
elements.push(child);
} else {
let child = new TextRun({
text: text,
bold: isBold,
font: Font,
size: Size,
});
elements.push(child);
}
}
}
return elements;
}
// 2、处理元素节点
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as HTMLElement;
const tagName = element.tagName.toUpperCase();
const childNodes = element.childNodes;
// 递归处理子节点
let childElements: DocxElement[] = [];
for (let i = 0; i < childNodes.length; i++) {
const child = childNodes[i];
if (tagName == 'A') {
if (style == null) {
style = {
id: 'Hyperlink',
};
} else {
style.id = 'Hyperlink';
}
}
const childResult = await parseNode(child, options, style);
childElements = childElements.concat(childResult);
}
// 根据标签类型创建不同的docx元素
switch (tagName) {
case 'H1':
return [
new Paragraph({
heading: HeadingLevel.HEADING_1,
alignment: AlignmentType.CENTER,
children: childElements.filter((e) => e instanceof TextRun) as TextRun[],
}),
];
case 'H2':
return [
new Paragraph({
heading: HeadingLevel.HEADING_2,
alignment: AlignmentType.CENTER,
children: childElements.filter((e) => e instanceof TextRun) as TextRun[],
}),
];
case 'H3':
return [
new Paragraph({
heading: HeadingLevel.HEADING_3,
alignment: AlignmentType.LEFT,
children: childElements.filter((e) => e instanceof TextRun) as TextRun[],
}),
];
case 'H4':
return [
new Paragraph({
heading: HeadingLevel.HEADING_4,
alignment: AlignmentType.LEFT,
children: childElements.filter((e) => e instanceof TextRun) as TextRun[],
}),
];
case 'H5':
return [
new Paragraph({
heading: HeadingLevel.HEADING_5,
alignment: AlignmentType.LEFT,
children: childElements.filter((e) => e instanceof TextRun) as TextRun[],
}),
];
case 'P':
return [
new Paragraph({
children: childElements.filter((e) => e instanceof TextRun) as TextRun[],
style: 'STx4Style', // 应用样式ID
}),
];
case 'BR':
return [new TextRun({ text: '', break: 1 })];
case 'A':
const href = element.getAttribute('href');
if (href) {
return [
new Paragraph({
children: [
new ExternalHyperlink({
children: childElements.filter((e) => e instanceof TextRun) as TextRun[],
link: href,
}),
],
}),
];
} else {
return childElements.filter((e) => e instanceof TextRun) as TextRun[];
}
case 'TABLE':
return getTable(element, options);
// case 'IMG':
// if (!options.includeImages) {
// return [];
// } else {
// const src = element.getAttribute('src');
// if (src) {
// try {
// const response = await fetch(src);
// const arrayBuffer = await response.arrayBuffer();
// // return [
// // new ImageRun({
// // data: arrayBuffer,
// // transformation: {
// // width: 400,
// // height: 300,
// // },
// // }),
// // ];
// return [];
// } catch (e) {
// console.error('图片加载失败:', e);
// return [
// new TextRun({
// text: '[图片加载失败]',
// color: 'FF0000',
// }),
// ];
// }
// } else {
// return [];
// }
// }
// case 'I':
// return childElements.map((e) => {
// if (e instanceof TextRun) {
// return new TextRun({
// ...e.options,
// italics: true,
// });
// }
// return e;
// });
// case 'U':
// return childElements.map((e) => {
// if (e instanceof TextRun) {
// return new TextRun({
// ...e.options,
// underline: {},
// });
// }
// return e;
// });
default:
return childElements;
}
}
return elements;
};
//获取一个表格
export const getTable = async (element: any, options: ExportOptions) => {
if (!options.includeTables) {
return [];
} else {
const rows = Array.from(element.rows);
const tableRows = rows.map((row: any) => {
const cells = Array.from(row.cells);
const tableCells = cells.map(async (cell: any, index: any) => {
let textAlign = cell.style.textAlign; //居中/居左
let width = (cell.style.width + '').replace('%', ''); //宽度
let classlist = Array.from(cell.classList);
if (classlist && classlist.length > 0) {
if (classlist.indexOf('is-left') > -1) {
textAlign = 'left';
} else if (classlist.indexOf('is-center') > -1) {
textAlign = 'center';
} else if (classlist.indexOf('is-right') > -1) {
textAlign = 'right';
}
}
const cellChildren = [];
for (let i = 0; i < cell.childNodes.length; i++) {
let childNode = cell.childNodes[i];
if (cell.tagName == 'TH') {
const styleoption: StyleOptions = {
bold: true,
font: '等线',
size: 21,
id: null,
};
const result = await parseNode(childNode, options, styleoption);
cellChildren.push(
new Paragraph({
alignment: textAlign == 'center' ? AlignmentType.CENTER : textAlign == 'right' ? AlignmentType.RIGHT : AlignmentType.LEFT, // 水平居中/居右/居左
children: result,
style: 'THStyle',
})
);
} else {
const styleoption: StyleOptions = {
bold: false,
font: '等线',
size: 21,
id: null,
};
const result = await parseNode(childNode, options, styleoption);
cellChildren.push(
new Paragraph({
alignment: textAlign == 'center' ? AlignmentType.CENTER : textAlign == 'right' ? AlignmentType.RIGHT : AlignmentType.LEFT, // 水平居中/居右/居左
children: result,
style: 'TDStyle',
})
);
}
}
// 动态判断是否合并
//const isMergedStart = cell.rowSpan > 1 || cell.colSpan > 1;
return new TableCell({
rowSpan: cell.rowSpan,
columnSpan: cell.colSpan,
verticalAlign: VerticalAlign.CENTER,
verticalMerge: cell.rowSpan > 1 ? 'restart' : undefined,
width: {
size: parseFloat(width), // 设置第一列宽度为250
type: WidthType.PERCENTAGE, //WidthType.DXA, // 单位为twip (1/20 of a point)
},
children: cellChildren.filter((e) => e instanceof Paragraph) as Paragraph[],
});
// return new TableCell({
// children: cellChildren.filter((e) => e instanceof Paragraph) as Paragraph[],
// });
});
return Promise.all(tableCells).then((cells) => {
return new TableRow({
children: cells,
});
});
});
return Promise.all(tableRows).then((rows) => {
return [
new Table({
rows: rows,
width: { size: 100, type: WidthType.PERCENTAGE },
}),
];
});
}
};