最近做了一个 Word 模板功能,需求看起来不复杂:
- 用户上传一份 Word 模板;
- 系统识别模板里的占位符;
- 前端用业务数据替换占位符;
- 在线预览生成后的文档;
- 支持导出 Word 和 PDF。
真正开始做之后才发现,难点并不在文件上传,而在 DOCX 的内部结构。
Word 可能会把一个完整的占位符拆成多个 XML 节点;表格里的数组数据还需要自动扩行;导出 PDF 时,又要处理异步渲染、分页、图片加载和 Canvas 尺寸限制。
这篇文章把整个实现链路拆开讲清楚。方案采用 Vue 3 + TypeScript,所有处理都在浏览器完成,不依赖后端 Office 服务。
最终实现效果
模板里可以这样写:
甲方:{{客户名称}}
合同编号:{{合同编号}}
签订日期:{{签订日期}}
表格中也可以使用占位符:
| 产品名称 | 数量 | 单价 |
|---|---|---|
| {{产品名称}} | {{数量}} | {{单价}} |
用户上传模板后,前端扫描出对应字段。普通字段直接替换;表格中的数组字段则按照最长数组自动复制模板行。
最后可以得到:
- 可继续编辑的 DOCX 文件;
- 浏览器中的 Word 预览;
- 与当前预览一致的图片型 PDF。
用到的库
pnpm add pizzip @vue-office/docx html2canvas jspdf
| 库 | 用途 |
|---|---|
| pizzip | 解压、读取和重新打包 DOCX |
| @vue-office/docx | 在浏览器中渲染 DOCX |
| html2canvas | 将渲染后的每一页转成 Canvas |
| jspdf | 将页面图片写入 PDF 并下载 |
这里没有使用 docxtemplater。不是因为它不好用,而是这次需要更细地控制占位符扫描、跨 XML 节点替换、表格扩行以及未匹配字段的处理,所以直接操作 Word XML 更合适。
DOCX 到底是什么
DOCX 并不是一个不可解析的二进制文件,它本质上是一个 ZIP 压缩包。
把一个 .docx 文件改名为 .zip 并解压,可以看到类似的目录:
word/
├── document.xml
├── header1.xml
├── footer1.xml
├── footnotes.xml
├── endnotes.xml
├── styles.xml
├── media/
└── _rels/
正文主要存放在 word/document.xml,页眉和页脚则可能存放在 header1.xml 和 footer1.xml。
Word 文本通常由这样的 XML 表示:
<w:p>
<w:r>
<w:t>合同编号:</w:t>
</w:r>
<w:r>
<w:t>{{合同编号}}</w:t>
</w:r>
</w:p>
其中 w:p 表示段落,w:r 表示一段具有相同格式的 Run,w:t 才是真正的文本节点。
最大的坑:占位符会被 Word 拆开
模板里看起来是 {{合同编号}},保存后,内部结构可能变成:
<w:r>
<w:t>{{合同</w:t>
</w:r>
<w:r>
<w:t>编号}}</w:t>
</w:r>
只要用户修改过部分字体、复制粘贴过内容,或者 Word 自己重新整理了格式,文本就可能被拆到多个 Run 中。因此逐个 w:t 执行正则替换并不可靠。
更稳妥的方式是:
- 以段落 w:p 为单位;
- 找到段落中的全部 w:t;
- 将文本临时拼接起来;
- 在完整文本中识别占位符;
- 再把替换区间映射回原来的文本节点。
这里有一个重要边界:允许占位符跨 Run,但不允许跨段落。这个限制需要提前写进模板使用说明,否则实现复杂度会迅速失控。
文件校验与加载
先定义占位符格式、DOCX MIME 类型和需要处理的 XML:
import PizZip from 'pizzip'
const PLACEHOLDER_PATTERN = /\{\{\s*([^{}]+?)\s*\}\}/g
const DOCX_MIME_TYPE =
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
const WORD_XML_PATTERN =
/^word\/(?:document|header\d+|footer\d+|footnotes|endnotes)\.xml$/
文件选择框可以使用 accept=".docx",但 accept 只能改善用户体验,不能代替真正的校验:
function assertDocxFile(file: File): void {
const isDocxName = file.name.toLowerCase().endsWith('.docx')
if (!isDocxName && file.type !== DOCX_MIME_TYPE) {
throw new Error('请选择 .docx 格式的 Word 文件,不支持旧版 .doc 文件')
}
}
async function loadDocx(file: File): Promise<PizZip> {
try {
const zip = new PizZip(await file.arrayBuffer())
if (!zip.file('word/document.xml')) {
throw new Error('缺少 word/document.xml')
}
return zip
} catch (error) {
throw new Error(
'无法读取 DOCX 文件,文件可能已损坏或不是有效的 Word 文档',
{ cause: error },
)
}
}
一个文件即使叫 template.docx,内部也可能不是有效的 Word 文档。检查 word/document.xml,可以挡住大部分扩展名伪装和损坏文件。
扫描正文、页眉和页脚中的占位符
先找到需要处理的 XML 文件:
function getWordXmlPartNames(zip: PizZip): string[] {
return Object.keys(zip.files)
.filter(name => WORD_XML_PATTERN.test(name))
.sort((a, b) => {
if (a === 'word/document.xml') return -1
if (b === 'word/document.xml') return 1
return a.localeCompare(b)
})
}
function readXmlPart(zip: PizZip, partName: string): string {
const content = zip.file(partName)?.asText()
if (content === undefined) {
throw new Error('DOCX 缺少必要内容:' + partName)
}
return content
}
function parseXml(xml: string, partName: string): XMLDocument {
const document = new DOMParser().parseFromString(xml, 'application/xml')
if (document.querySelector('parsererror')) {
throw new Error('无法解析 DOCX 内容:' + partName)
}
return document
}
查询元素时不建议依赖 w: 前缀。不同 Word 版本生成的命名空间前缀不一定完全一致,使用 localName 更稳妥:
function findElements(root: ParentNode, localName: string): Element[] {
return [...root.querySelectorAll('*')]
.filter(element => element.localName === localName)
}
function findPlaceholderKeys(root: ParentNode): string[] {
return findElements(root, 'p').flatMap((paragraph) => {
const text = findElements(paragraph, 't')
.map(element => element.textContent ?? '')
.join('')
return [...text.matchAll(PLACEHOLDER_PATTERN)]
.map(match => match[1]?.trim() ?? '')
})
}
最终的扫描方法:
export async function extractWordTemplateFields(file: File): Promise<string[]> {
assertDocxFile(file)
const zip = await loadDocx(file)
const fields: string[] = []
for (const partName of getWordXmlPartNames(zip)) {
const document = parseXml(readXmlPart(zip, partName), partName)
fields.push(...findPlaceholderKeys(document))
}
return fields
}
业务层一般还要去重,并为每个字段生成默认配置:
const fields = [...new Set(await extractWordTemplateFields(file))]
const config = Object.fromEntries(
fields.map(field => [
field,
{
inputType: 'manual',
autoFillFields: [],
remark: '',
},
]),
)
把替换区间映射回 XML 节点
不能把整个段落重写到一个 w:t 节点中,否则原来的字体、字号和颜色等 Run 样式可能丢失。
这里采用的规则是:
- 替换结果写入占位符起始节点;
- 中间节点清空;
- 结束节点保留占位符之后的内容;
- 其他文本和 XML 结构保持不变。
function replaceParagraphPlaceholders(
paragraph: Element,
resolve: (key: string) => string | undefined,
): void {
const textElements = findElements(paragraph, 't')
const text = textElements.map(element => element.textContent ?? '').join('')
const matches = [...text.matchAll(PLACEHOLDER_PATTERN)]
// 从后向前替换,避免文字长度变化影响前面匹配的下标
for (let index = matches.length - 1; index >= 0; index -= 1) {
const match = matches[index]
const replacement = resolve(match?.[1]?.trim() ?? '')
if (replacement === undefined || match?.index === undefined) continue
replaceTextRange(
textElements,
match.index,
match.index + match[0].length,
replacement,
)
}
}
function replaceTextRange(
elements: Element[],
start: number,
end: number,
replacement: string,
): void {
let offset = 0
let startIndex = -1
let endIndex = -1
let startOffset = 0
let endOffset = 0
elements.forEach((element, index) => {
const length = (element.textContent ?? '').length
if (startIndex < 0 && start >= offset && start < offset + length) {
startIndex = index
startOffset = start - offset
}
if (endIndex < 0 && end > offset && end <= offset + length) {
endIndex = index
endOffset = end - offset
}
offset += length
})
if (startIndex < 0 || endIndex < 0) return
const startElement = elements[startIndex]
const endElement = elements[endIndex]
if (!startElement || !endElement) return
const startText = startElement.textContent ?? ''
const endText = endElement.textContent ?? ''
if (startIndex === endIndex) {
setTextContent(
startElement,
startText.slice(0, startOffset) + replacement + startText.slice(endOffset),
)
return
}
setTextContent(startElement, startText.slice(0, startOffset) + replacement)
for (let index = startIndex + 1; index < endIndex; index += 1) {
if (elements[index]) setTextContent(elements[index], '')
}
setTextContent(endElement, endText.slice(endOffset))
}
function setTextContent(element: Element, value: string): void {
element.textContent = value
if (/^\s|\s$/.test(value)) {
element.setAttribute('xml:space', 'preserve')
} else {
element.removeAttribute('xml:space')
}
}
通过 textContent 写入,再交给 XMLSerializer 序列化,尖括号和 & 等特殊字符会自动进行 XML 转义,不需要手动拼接转义字符串。
表格中的数组自动扩行
假设模板中有一行产品名称、数量、单价,占位符对应的数据都是数组。期望结果应该是自动生成多行,而不是把数组拼成一段字符串。
这里以同一模板行中的最长数组决定复制行数:
type WordTemplatePrimitive =
| string
| number
| boolean
| null
| undefined
interface WordTextValue {
type: 'text'
value: WordTemplatePrimitive | WordTemplatePrimitive[]
}
type WordTemplateValues = Record<string, WordTextValue>
function expandTableRows(
document: XMLDocument,
values: WordTemplateValues,
): void {
const rows = findElements(document, 'tr')
for (const row of rows) {
const keys = findPlaceholderKeysInRow(row)
const rowCount = Math.max(
1,
...keys.map((key) => {
const value = values[key]?.value
return Array.isArray(value) ? value.length : 1
}),
)
if (rowCount <= 1 || !row.parentNode) continue
const fragment = document.createDocumentFragment()
for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) {
const clonedRow = row.cloneNode(true) as Element
replacePlaceholders(
clonedRow,
key => getTableReplacement(values[key], rowIndex),
)
fragment.append(clonedRow)
}
row.parentNode.replaceChild(fragment, row)
}
}
function findPlaceholderKeysInRow(row: Element): string[] {
return findElements(row, 'p')
.filter(paragraph => findClosestAncestor(paragraph, 'tr') === row)
.flatMap((paragraph) => {
const text = findElements(paragraph, 't')
.map(element => element.textContent ?? '')
.join('')
return [...text.matchAll(PLACEHOLDER_PATTERN)]
.map(match => match[1]?.trim() ?? '')
})
}
function findClosestAncestor(
element: Element,
localName: string,
): Element | undefined {
let current = element.parentElement
while (current) {
if (current.localName === localName) return current
current = current.parentElement
}
}
function getTableReplacement(
config: WordTextValue | undefined,
rowIndex: number,
): string | undefined {
if (!config) return undefined
if (Array.isArray(config.value)) {
return stringifyPrimitive(config.value[rowIndex])
}
return rowIndex === 0 ? stringifyPrimitive(config.value) : ''
}
function stringifyPrimitive(value: WordTemplatePrimitive): string {
return value === null || value === undefined ? '' : String(value)
}
本次采用的产品规则是:
- 最长数组决定行数;
- 较短数组缺少的部分补空;
- 单值只出现在第一行;
- 其他行填空。
克隆整个 w:tr,而不是手动创建单元格,可以保留列宽、边框、底色和文字样式。
完成 DOCX 填充
function replacePlaceholders(
root: ParentNode,
resolve: (key: string) => string | undefined,
): void {
for (const paragraph of findElements(root, 'p')) {
replaceParagraphPlaceholders(paragraph, resolve)
}
}
function getNormalReplacement(
config: WordTextValue | undefined,
): string | undefined {
if (!config) return undefined
return Array.isArray(config.value)
? config.value.map(stringifyPrimitive).join(', ')
: stringifyPrimitive(config.value)
}
interface FillWordTemplateOptions {
clearUnresolved?: boolean
}
export async function fillWordTemplate(
file: File,
values: WordTemplateValues,
options: FillWordTemplateOptions = {},
): Promise<Blob> {
assertDocxFile(file)
const zip = await loadDocx(file)
for (const partName of getWordXmlPartNames(zip)) {
const document = parseXml(readXmlPart(zip, partName), partName)
// 顺序不能反:先扩展表格,再处理普通替换
expandTableRows(document, values)
replacePlaceholders(document, (key) => {
return getNormalReplacement(values[key])
?? (options.clearUnresolved ? '' : undefined)
})
zip.file(partName, new XMLSerializer().serializeToString(document))
}
return zip.generate({
type: 'blob',
mimeType: DOCX_MIME_TYPE,
})
}
如果先执行普通替换,表格里的数组可能已经被拼成字符串,后续就无法判断应该复制多少行。
clearUnresolved 为 false 时保留未赋值占位符,适合查看原始模板;为 true 时清空未赋值占位符,适合生成预览和导出文件。
在 Vue 中预览 DOCX
<script setup lang="ts">
import { shallowRef } from 'vue'
import VueOfficeDocx from '@vue-office/docx/lib/v3/index.js'
import '@vue-office/docx/lib/v3/index.css'
const previewDocument = shallowRef<Blob>()
async function generatePreview(): Promise<void> {
previewDocument.value = await fillWordTemplate(
templateFile.value,
createTemplateValues(),
{ clearUnresolved: true },
)
}
</script>
<template>
<VueOfficeDocx
v-if="previewDocument"
:src="previewDocument"
@rendered="handleRendered"
@error="handleRenderError"
/>
</template>
File 和 Blob 属于外部对象,没有必要让 Vue 深度代理它们的内部属性,因此适合用 shallowRef 保存。
浏览器预览并不等于 Microsoft Word。复杂字体、分页、文本框、特殊域和浮动图片,在 VueOffice、Word 和 WPS 中可能存在差异。预览适合确认内容和大体版式,严谨排版仍应以 Word/WPS 为准。
导出可编辑的 Word
async function exportWord(): Promise<void> {
const output = await fillWordTemplate(
templateFile.value,
createTemplateValues(),
{ clearUnresolved: true },
)
const url = URL.createObjectURL(output)
const link = document.createElement('a')
link.href = url
link.download = '合同.docx'
link.click()
URL.revokeObjectURL(url)
}
这份文件仍然是标准 DOCX,用户可以继续使用 Word 或 WPS 编辑。
PDF 为什么要从预览页面生成
浏览器没有原生的 DOCX 转 PDF API。纯前端场景下,只能选择:
- 自己实现 WordprocessingML 排版引擎;
- 把 DOCX 渲染为 HTML,再基于 HTML 生成 PDF;
- 调用后端 LibreOffice、OnlyOffice 或商业转换服务。
第一种几乎不可行,第三种不符合纯前端要求,所以这里采用:
填充 DOCX
↓
VueOffice 渲染为分页 HTML
↓
html2canvas 逐页截图
↓
jsPDF 逐页写入 PDF
这种方案实现成本低,而且 PDF 和浏览器预览基本一致。缺点也很明确:生成的是图片型 PDF,文字不能搜索、选择或复制。
如果业务要求文字可搜索、电子签章或严格印刷质量,应该使用服务端 Office 转换方案。
等待 Word 真正渲染完成
nextTick 只能保证 Vue 完成一次 DOM 更新,无法保证 VueOffice 已经完成文档解析、分页、字体加载和图片加载。应监听 VueOffice 的 rendered 事件:
let resolveRendered: (() => void) | undefined
let rejectRendered: ((reason?: unknown) => void) | undefined
function renderPreviewDocument(document: Blob): Promise<void> {
return new Promise((resolve, reject) => {
const timeoutId = window.setTimeout(() => {
resolveRendered = undefined
rejectRendered = undefined
reject(new Error('文档预览渲染超时'))
}, 30_000)
resolveRendered = () => {
window.clearTimeout(timeoutId)
resolveRendered = undefined
rejectRendered = undefined
requestAnimationFrame(() => {
requestAnimationFrame(resolve)
})
}
rejectRendered = (reason?: unknown) => {
window.clearTimeout(timeoutId)
resolveRendered = undefined
rejectRendered = undefined
reject(reason)
}
previewDocument.value = document
})
}
function handleRendered(): void {
resolveRendered?.()
}
function handleRenderError(error: unknown): void {
rejectRendered?.(error)
}
连续等待两次 requestAnimationFrame,是为了给浏览器留下完成布局和绘制的时间。30 秒超时则用于防止第三方组件异常时 Promise 永远不结束。
使用 html2canvas 和 jsPDF 逐页导出
VueOffice 渲染后的每个 section 对应一页。不要把整份长文档一次性转成 Canvas,否则很容易碰到浏览器 Canvas 最大尺寸限制。
import type { jsPDF as JsPdf } from 'jspdf'
const PDF_RENDER_SCALE = 2
const PDF_IMAGE_QUALITY = 0.92
export async function exportHtmlPagesToPdf(
pages: HTMLElement[],
fileName: string,
): Promise<void> {
if (pages.length === 0) throw new Error('未找到可导出的文档页面')
const [{ default: html2canvas }, { jsPDF }] = await Promise.all([
import('html2canvas'),
import('jspdf'),
])
await waitForFonts()
let pdf: JsPdf | undefined
for (const page of pages) {
await waitForImages(page)
const canvas = await html2canvas(page, {
backgroundColor: '#ffffff',
logging: false,
scale: PDF_RENDER_SCALE,
useCORS: true,
width: page.scrollWidth,
height: page.scrollHeight,
windowWidth: Math.max(
document.documentElement.clientWidth,
page.scrollWidth,
),
windowHeight: Math.max(
document.documentElement.clientHeight,
page.scrollHeight,
),
})
const pageWidth = page.scrollWidth
const pageHeight = page.scrollHeight
if (!pageWidth || !pageHeight) throw new Error('文档页面尺寸无效')
const orientation = pageWidth > pageHeight ? 'landscape' : 'portrait'
if (!pdf) {
pdf = new jsPDF({
unit: 'px',
format: [pageWidth, pageHeight],
orientation,
hotfixes: ['px_scaling'],
})
} else {
pdf.addPage([pageWidth, pageHeight], orientation)
}
const imageData = canvas.toDataURL('image/jpeg', PDF_IMAGE_QUALITY)
pdf.addImage(
imageData,
'JPEG',
0,
0,
pdf.internal.pageSize.getWidth(),
pdf.internal.pageSize.getHeight(),
undefined,
'FAST',
)
// 及时释放长文档产生的 Canvas 内存
canvas.width = 0
canvas.height = 0
}
pdf?.save(fileName)
}
async function waitForFonts(): Promise<void> {
if ('fonts' in document) await document.fonts.ready
}
async function waitForImages(root: HTMLElement): Promise<void> {
const images = [...root.querySelectorAll('img')]
await Promise.all(images.map(async (image) => {
if (image.complete) {
await image.decode().catch(() => undefined)
return
}
await new Promise<void>((resolve) => {
image.addEventListener('load', () => resolve(), { once: true })
image.addEventListener('error', () => resolve(), { once: true })
})
}))
}
最终导出:
async function exportPdf(): Promise<void> {
const output = await fillWordTemplate(
templateFile.value,
createTemplateValues(),
{ clearUnresolved: true },
)
await renderPreviewDocument(output)
const pages = [
...(previewContainer.value
?.querySelectorAll<HTMLElement>('.docx-wrapper > section') ?? []),
]
await exportHtmlPagesToPdf(pages, '合同.pdf')
}
html2canvas 和 jspdf 使用动态导入,是因为它们只在用户点击导出 PDF 时才需要,避免无意义地增加初始加载体积。
清晰度、体积和性能
影响 PDF 的两个主要参数是:
const PDF_RENDER_SCALE = 2
const PDF_IMAGE_QUALITY = 0.92
scale 越高,Canvas 分辨率越高,但内存占用按面积增长。从 2 调到 3,像素数量会增长到原来的 2.25 倍左右。
| 场景 | scale | 图片格式 |
|---|---|---|
| 普通合同和报价单 | 2 | JPEG 0.9~0.92 |
| 细小文字较多 | 2~2.5 | JPEG 0.95 |
| 含大量截图或照片 | 2 | JPEG |
| 要求透明背景或无损 | 2 | PNG |
页数较多时,建议增加导出 Loading、禁止重复点击、进度提示,并在每页处理完后主动清空 Canvas。
跨域图片问题
如果 Word 中包含网络图片,VueOffice 渲染后可能出现跨域资源。即使 html2canvas 配置了 useCORS: true,图片服务器仍然需要返回正确的 CORS 响应头,否则 Canvas 可能被污染,toDataURL 会抛出安全错误。
可选处理方式包括:
- 图片服务器开启 CORS;
- 模板图片存储在同源服务;
- 通过后端代理图片;
- 导出前将图片转换成 Data URL。
扫描失败怎么处理
文件处理最好拆成几个明确阶段:
文件格式校验
↓
读取 DOCX
↓
扫描占位符
↓
上传原文件
↓
保存模板配置
扫描失败不一定意味着文件无法上传。在模板配置只是辅助数据的业务中,可以提示用户后降级为空配置:
async function scanUploadedFile(file: File): Promise<Record<string, unknown>> {
assertDocxFile(file)
try {
const fields = [...new Set(await extractWordTemplateFields(file))]
if (fields.length === 0) {
console.warn('模板中未扫描到占位符')
}
return Object.fromEntries(
fields.map(field => [
field,
{
inputType: 'manual',
autoFillFields: [],
},
]),
)
} catch (error) {
console.error('模板占位符扫描失败', error)
return {}
}
}
格式错误则应该直接阻止上传,因为 .doc、PDF 或图片文件无法进入后续处理链路。扫描失败和格式错误不是同一种错误,不应使用相同的处理方式。
方案边界
这套实现适合合同、报价单、采购单、收发货单以及其他固定格式的业务文档。
目前只处理 {{字段名}} 形式的文本占位符,暂不处理:
- 条件语句;
- 循环语法;
- 动态图片替换;
- 富文本;
- 合并单元格的复杂扩行;
- Word 域和目录更新;
- 宏;
- 旧版 .doc;
- 可搜索文字型 PDF。
如果模板规则继续扩张,不建议不断往正则表达式中添加语法,而应该把模板能力设计成明确的 AST,或者引入成熟模板引擎。
最后复盘
整个流程可以归纳为:
上传 DOCX
↓
校验扩展名和文件结构
↓
PizZip 解压
↓
DOMParser 解析 WordprocessingML
↓
按段落合并文本节点
↓
识别 {{占位符}}
↓
表格数组扩行
↓
将替换区间映射回原 XML 节点
↓
重新打包 DOCX
├─ 下载可编辑 Word
└─ VueOffice 浏览器预览
↓
html2canvas 逐页截图
↓
jsPDF 生成图片型 PDF
真正值得注意的不是“如何用正则替换两个花括号”,而是如何在不破坏 Word 原有 XML 结构和样式的情况下完成替换。
几个最关键的经验是:
- DOCX 本质上是 ZIP,可以直接处理内部 XML;
- 占位符必须按段落合并后识别,不能逐个 w:t 匹配;
- 替换结果要映射回原文本节点,尽量保留原有 Run 样式;
- 表格必须先扩行,再执行普通字段替换;
- 导出 PDF 前必须等待 VueOffice、字体和图片真正渲染完成;
- 长文档应该逐页生成 Canvas,避免超长画布和内存峰值;
- 纯前端生成的 PDF 更适合视觉还原,不适合要求可搜索文字的场景。
Word 模板不是一个特别新的方向,但只要涉及真实用户制作的模板,就会碰到很多看似偶然、实际由文档结构决定的问题。
把 DOCX 当成一个结构化 XML 容器,而不是一个神秘的二进制文件,整个问题就会清晰很多。
Comments | 0条评论