WSの小屋

最近做了一个 Word 模板功能,需求看起来不复杂:

  1. 用户上传一份 Word 模板;
  2. 系统识别模板里的占位符;
  3. 前端用业务数据替换占位符;
  4. 在线预览生成后的文档;
  5. 支持导出 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 执行正则替换并不可靠。

更稳妥的方式是:

  1. 以段落 w:p 为单位;
  2. 找到段落中的全部 w:t;
  3. 将文本临时拼接起来;
  4. 在完整文本中识别占位符;
  5. 再把替换区间映射回原来的文本节点。

这里有一个重要边界:允许占位符跨 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。纯前端场景下,只能选择:

  1. 自己实现 WordprocessingML 排版引擎;
  2. 把 DOCX 渲染为 HTML,再基于 HTML 生成 PDF;
  3. 调用后端 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 结构和样式的情况下完成替换。

几个最关键的经验是:

  1. DOCX 本质上是 ZIP,可以直接处理内部 XML;
  2. 占位符必须按段落合并后识别,不能逐个 w:t 匹配;
  3. 替换结果要映射回原文本节点,尽量保留原有 Run 样式;
  4. 表格必须先扩行,再执行普通字段替换;
  5. 导出 PDF 前必须等待 VueOffice、字体和图片真正渲染完成;
  6. 长文档应该逐页生成 Canvas,避免超长画布和内存峰值;
  7. 纯前端生成的 PDF 更适合视觉还原,不适合要求可搜索文字的场景。

Word 模板不是一个特别新的方向,但只要涉及真实用户制作的模板,就会碰到很多看似偶然、实际由文档结构决定的问题。

把 DOCX 当成一个结构化 XML 容器,而不是一个神秘的二进制文件,整个问题就会清晰很多。

Comments | 0条评论