前端docx库实现将html页面导出word

发布于:2025-07-15 ⋅ 阅读:(14) ⋅ 点赞:(0)

前言:最近遇到一个需求,需要将页面的html导出为word文档,并且包含横向和竖向页面,并且可以进行混合方向导出。经过一段时间的实验,发现只有docx这个库满足这个要求。在这里记录一下实现思路以及代码。

docx官网

一、效果展示

页面内容:
在这里插入图片描述
导出样式:
在这里插入图片描述

二、解决思路

1、首先是需要在页面上设置哪些部分是需要横向导出,哪些部分是需要竖向导出的。以方便后面进行解析。
2、根据页面样式以及各类html标签进行解析。然后以docx的形式生成,最后导出来。

三、实现代码

1、index.vue

这里 class 中的 section 代表了docx中的一节,也就是一个页面。同时newpage属性控制了是不是要换一个新页,orient属性是页面横向纵向的标识(Z纵向H横向)。也可以根据自己的需求自行添加属性,在后面自己进行对应的解析。

<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形式的拼接组合。可以根据理解自行调整样式以及解析过程。

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 },
				}),
			];
		});
	}
};



网站公告

今日签到

点亮在社区的每一天
去签到