从 html2canvas 到矢量 PDF

43 阅读6分钟

1. 背景

在上一篇文章里,我们用 Vue3 + AntD + html2canvas + jsPDF 把业务页面一键截图成 PDF,十分快速、好上手,适合临时场景或模型验证。但缺点也很明显:PDF 里装的其实是一张位图,一旦放大就糊,打印出来也缺少专业质感。本文正是基于这些痛点的延续:我们把“生成 PDF”这件事拆成了两条更加稳健的技术路线。

本方案的核心是“先生成带有表单域的模板,再在浏览器里填充”。

2. 准备页面骨架

要实现这个逻辑,当然需要一个可交互的页面啦。首先我们默认用户已经将需要的数据填入表单并提交至后端,至于具体的业务场景,友友们可根据需求进行调整,基本不会影响后面 PDF 模板的生成与数据填入。这里我们假设是通过 GET 请求获取表单数据,我们点击页面上的按钮去填充数据并下载 PDF。

<div class="p-6 space-y-4">
    <div>当前模板:<code>public/templates/report-template.pdf</code></div>
    <Button type="primary" :loading="loading" @click="handleGenerate"> 填充模板并下载 </Button>
  </div>

3. PDF 模板生成的核心逻辑

jsPDF 自动绘制一份 A4 模板,比如我这里画了标题、客户信息、五行表格、多行文本区域等内容,还内置字段命名和字体注册。生成后放在指定位置,供 PDF 填充的时候使用。

我们这里采用jsPDF库生成 PDF 模板,因为:

  • jsPDF 3.x自带 AcroFormTextField 等类,可以直接 new 出字段、设置默认值、丢进 PDF;布局时用 pdf.textpdf.line 这些高层接口,做模板“画页面”比较顺手。
  • pdf-lib 适合专门负责“读取模板并填值”,这部分它做得很好(也能嵌入字体、更新外观)。但如果要从零搭一张页面,pdf-libAPI 就偏底层,得手动拼很多 drawTextField、坐标和资源,写起来冗长。
/**
 * 生成带 AcroForm 表单域的 A4 模板并写出到本地。
 * @returns {void}
 */
const main = () => {
  if (!fs.existsSync(fontPath)) {
    throw new Error(`Font not found at ${fontPath}`)
  }

  const pdf = new jsPDF({ unit: 'mm', format: 'a4' }) // 新的对象写法
  const fontData = fs.readFileSync(fontPath)
  pdf.addFileToVFS('NotoSansSC-Regular.ttf', toBase64(fontData))
  pdf.addFont('NotoSansSC-Regular.ttf', 'NotoSansSC', 'normal')
  pdf.addFont('NotoSansSC-Regular.ttf', 'NotoSansSC', 'bold')

  pdf.setFont('NotoSansSC', 'bold')
  pdf.setFontSize(20)
  pdf.text('检测报告模板', 105, 25, { align: 'center' })

  pdf.setDrawColor(180) // 横线的颜色
  pdf.setLineWidth(0.2)
  pdf.line(20, 40, 190, 40)

  pdf.setFont('NotoSansSC', 'normal')
  pdf.setFontSize(11)

  drawUnderlineField(pdf, {
    label: '模板编号:',
    name: 'templateNo',
    labelX: 20,
    valueX: 40,
    y: 36,
    lineWidth: 60,
  })

  drawUnderlineField(pdf, {
    label: '生成日期:',
    name: 'generatedAt',
    labelX: 130,
    valueX: 150,
    y: 36,
    lineWidth: 30,
  })

  drawUnderlineField(pdf, {
    label: '客户名称:',
    name: 'clientName',
    labelX: 20,
    valueX: 40,
    y: 50,
    lineWidth: 110,
  })

  drawUnderlineField(pdf, {
    label: '客户编号:',
    name: 'clientId',
    labelX: 20,
    valueX: 40,
    y: 62,
    lineWidth: 110,
  })

  drawUnderlineField(pdf, {
    label: '联系人:',
    name: 'contactPerson',
    labelX: 20,
    valueX: 40,
    y: 74,
    lineWidth: 110,
  })

  drawUnderlineField(pdf, {
    label: '联系电话:',
    name: 'contactPhone',
    labelX: 20,
    valueX: 40,
    y: 86,
    lineWidth: 110,
  })

  // 表格
  pdf.setFont('NotoSansSC', 'bold')
  pdf.setFontSize(14)
  pdf.text('检测项摘要', 20, 102)

  const tableTop = 110
  const tableLeft = 20
  const tableWidth = 170
  const rowHeight = 10
  const columnWidths = [20, 70, 40, 40]
  const columnTitles = ['序号', '检测项目', '结果', '备注']

  pdf.setDrawColor(200)
  pdf.setLineWidth(0.2)

  // 表格横线
  for (let i = 0; i <= 5; i += 1) {
    const y = tableTop + i * rowHeight
    pdf.line(tableLeft, y, tableLeft + tableWidth, y)
  }

  // 表格竖线
  let xCursor = tableLeft
  for (let i = 0; i < columnWidths.length; i += 1) {
    pdf.line(xCursor, tableTop, xCursor, tableTop + 5 * rowHeight)
    xCursor += columnWidths[i]
  }
  pdf.line(tableLeft + tableWidth, tableTop, tableLeft + tableWidth, tableTop + 5 * rowHeight)

  // 表格header标题
  pdf.setFontSize(11)
  xCursor = tableLeft
  columnTitles.forEach((title, index) => {
    pdf.text(title, xCursor + 2, tableTop + 7)
    xCursor += columnWidths[index]
  })

  pdf.setFont('NotoSansSC', 'normal')

  // 表格内容字段表单域
  for (let row = 1; row < 5; row += 1) {
    const rowY = tableTop + (row + 1) * rowHeight - 2
    let cellX = tableLeft
    const fieldHeight = 7

    const cellNames = ['index', 'item', 'result', 'remark']
    columnWidths.forEach((width, colIndex) => {
      const name = `results[${row}].${cellNames[colIndex]}`
      const field = new pdf.AcroFormTextField()
      field.T = name
      field.Rect = [cellX + 2, rowY - fieldHeight, width - 4, fieldHeight]
      field.fontName = 'NotoSansSC'
      field.fontSize = 10
      pdf.addField(field)
      cellX += width
    })
  }

  pdf.setFontSize(7)
  pdf.text('(生成时填充检测项目列表,可按数据条数动态扩展)', tableLeft, tableTop + 5 * rowHeight + 7)

  pdf.setDrawColor(180)
  pdf.setLineWidth(0.2)
  pdf.line(20, tableTop + 5 * rowHeight + 20, 190, tableTop + 5 * rowHeight + 20)

  pdf.setFont('NotoSansSC', 'bold')
  pdf.setFontSize(10)
  pdf.text('结论', 20, tableTop + 5 * rowHeight + 30)

  pdf.setFont('NotoSansSC', 'normal')
  pdf.setFontSize(10)
  pdf.text('结论内容:', 20, tableTop + 5 * rowHeight + 38)
  pdf.text('建议措施:', 20, tableTop + 5 * rowHeight + 50)

  // 多行表单域
  const conclusionField = new pdf.AcroFormTextField()
  conclusionField.T = 'conclusion'
  conclusionField.Rect = [40, tableTop + 5 * rowHeight + 33, 140, 9]
  conclusionField.multiline = true
  conclusionField.fontName = 'NotoSansSC'
  conclusionField.fontSize = 10
  pdf.addField(conclusionField)

  const recommendationField = new pdf.AcroFormTextField()
  recommendationField.T = 'recommendation'
  recommendationField.Rect = [40, tableTop + 5 * rowHeight + 45, 140, 9]
  recommendationField.multiline = true
  recommendationField.fontName = 'NotoSansSC'
  recommendationField.fontSize = 10
  pdf.addField(recommendationField)

  pdf.text('签发人:', 20, tableTop + 5 * rowHeight + 62)
  pdf.text('签发日期:', 110, tableTop + 5 * rowHeight + 62)

  createTextField(pdf, {
    name: 'reviewer',
    x: 35,
    y: tableTop + 5 * rowHeight + 62,
    width: 50,
  })

  createTextField(pdf, {
    name: 'reviewDate',
    x: 130,
    y: tableTop + 5 * rowHeight + 62,
    width: 45,
  })

  pdf.setFontSize(7)
  pdf.text('提示:表格行数与空白内容可在填充时写入真实数据。', 20, tableTop + 5 * rowHeight + 72)

  const buffer = Buffer.from(pdf.output('arraybuffer'))
  fs.mkdirSync(path.dirname(outputPath), { recursive: true })
  fs.writeFileSync(outputPath, buffer)
  console.log(`AcroForm template generated at ${outputPath}`)
}
/**
 * 绘制标签与横线,并在横线位置创建单行文本域。
 * @param {import('jspdf').jsPDF} pdf jsPDF 实例
 * @param {Object} params 配置集合
 * @param {string} params.label 标签文字
 * @param {string} params.name 表单字段名称
 * @param {number} params.labelX 标签 x 坐标(mm)
 * @param {number} params.valueX 输入框起始 x 坐标(mm)
 * @param {number} params.y 基线 y 坐标(mm)
 * @param {number} params.lineWidth 横线长度(mm)
 * @returns {void}
 */
const drawUnderlineField = (pdf, {
  label,
  name,
  labelX,
  valueX,
  y,
  lineWidth,
}) => {
  pdf.text(label, labelX, y - 2) // 是为了让文本基线比后面那条输入横线略高一点,避免文字压住输入线
  pdf.setDrawColor(180)
  pdf.line(valueX, y, valueX + lineWidth, y)
  createTextField(pdf, {
    pdf,
    name,
    x: valueX,
    y,
    width: lineWidth,
  })
}
/**
 * 向文档中添加文本表单域。
 * @param {import('jspdf').jsPDF} pdf jsPDF 实例
 * @param {Object} options 配置对象
 * @param {string} options.name 字段名称
 * @param {number} options.x 字段左上角 X 坐标(mm)
 * @param {number} options.y 字段基线 Y 坐标(mm)
 * @param {number} options.width 字段宽度(mm)
 * @param {number} [options.height=8] 字段高度(mm)
 * @param {boolean} [options.multiline=false] 是否开启多行
 * @param {string} [options.defaultValue=''] 默认值
 * @returns {any} 创建的表单域实例
 */
const createTextField = (pdf, {
  name,
  x,
  y,
  width,
  height = 4,
  multiline = false,
  defaultValue = '',
}) => {
  const field = new pdf.AcroFormTextField()
  field.T = name // 字段名
  field.Rect = [x, y - height, width, height] // 设定输入框在页面上的矩形区域(左下角坐标 + 宽高),这里用 y - height 是因为 PDF 坐标从下往上算。
  field.multiline = multiline // 是否允许多行输入,直接沿用传进来的布尔值。
  field.V = defaultValue // 指定初始显示值,同时把默认值也写进去,方便重置表单时恢复。
  field.defaultValue = defaultValue // 指定初始显示值,同时把默认值也写进去,方便重置表单时恢复。
  field.fontName = 'NotoSansSC' // 字体名称
  field.fontStyle = 'normal' // 字体类型
  field.fontSize = 11 // 文本大小
  pdf.addField(field)
  return field
}

4. PDF 模板数据填入的核心逻辑

这部分功能主要是先调用 fillTemplate 读取预制的 AcroForm 模板和自带的 NotoSans 字体,利用 fontkit 把字体注册进 pdf-lib,然后通过封装好的 setText 方法逐个表单字段写入业务数据(多行字段会自动开启 enableMultiline 并刷新外观),最后把填充完成的文档 saveBlob,再交由下载/预览逻辑使用。

pdf-lib 内置的字体只支持拉丁字符集,如果直接 embedFont 一份包含中文的 TTF,会因为缺少 glyph 编码器报错。把 fontkit 引进来后,通过 pdfDoc.registerFontkit(fontkitModule.default || fontkitModule)pdf-lib 安装一个“字体驱动”,它就能解析 TTF/OTF 里所有字形并顺利嵌入中文字体了。所以没有 fontkit,嵌入思源黑体这类字体会失败。

字体嵌入后,获取表单域并填充相关字段。其中需要注意的是:acroForm.set(PDFName.of('DA'), PDFString.of(/${notoSans.name} 0 Tf 0 g))是在给 AcroForm 设置默认外观 (/DA)。PDFString.of 里写的是标准 PDF 文本操作:/{字体名} 0 Tf 选定我们刚嵌入的 notoSans 字体、字号 0(意味着由字段自身决定),0 g 把填充色设成黑色。这样,当阅读器根据 /NeedAppearances 重绘字段时,会用这套字体和颜色,避免又回退到 Helvetica 或别的西文字体。

当阅读器要重绘表单外观时,它会到 /DR 里的 /Font 子字典去找能用的字体资源。如果我们只嵌入了字体(文件里有对象),但没在这个资源表里登记一条“字体名 → 字体对象引用”的映射,阅读器就不知道怎么引用它:可能退回默认的 Helvetica,可能直接渲染失败。把 NotoSans 加到 /DR/Font,等于告诉阅读器“这个字体可以拿来渲染表单文本”,保证它填充外观时真的用到我们嵌入的中文字体。

/**
 * 使用表单模板填充并生成 PDF。
 * @param {ReturnType<typeof getReportDataApi>} data 报告数据
 * @returns {Promise<Blob>} 生成的 PDF Blob
 */
const fillTemplate = async (data) => {
  // 获取pdf模板文件的二进制数据
  const templateBytes = await fetchArrayBuffer('/templates/report-template.pdf')
  const pdfDoc = await PDFDocument.load(templateBytes)

  // 嵌入字体
  const fontkit = fontkitModule.default || fontkitModule
  pdfDoc.registerFontkit(fontkit)
	// 获取pdf模板文件的二进制数据
  const fontBytes = await fetchArrayBuffer(fontUrl)
  // 返回一个可用于表单外观的字体对象。subset: false 表示按整套字体嵌入,而不是只抽取用到的字符子集;这样生成的外观流就不会因为后续填充包含新汉字而缺字形,保证 Preview/Acrobat 都能正确显示。
  const notoSans = await pdfDoc.embedFont(fontBytes, { subset: false })
	// 获取表单对象
  const form = pdfDoc.getForm()

  /**
  * PDFName.of('AcroForm') 会生成一个表示 /AcroForm 的“名字对象”。
  * PDF 结构里,字典的键都是名字类型
  */
  const acroFormRef = pdfDoc.catalog.get(PDFName.of('AcroForm'))
  if (acroFormRef) {
    // acroForm 是一个可操作的字典对象
    const acroForm = pdfDoc.context.lookup(acroFormRef, PDFDict) // 去底层对象表里查找该引用对应的对象,并做类型校验
    acroForm.set(PDFName.of('NeedAppearances'), PDFBool.True) // PDF 规范规定,如果 /NeedAppearances 为真,阅读器要自行根据表单字段的值临时生成外观 
    acroForm.set(PDFName.of('DA'), PDFString.of(`/${notoSans.name} 0 Tf 0 g`))

    /*
    * PDFDict 是一种类型(一个类),专门表示 PDF 规范中的“字典对象”。PDF 里的很多结构都是字典,比如页面、AcroForm、资源表等,所以 pdf-lib 用 PDFDict 封装它们,方便你用面向对象的方式 get/set/lookup。
    */
    let dr = acroForm.lookupMaybe(PDFName.of('DR'), PDFDict) // 第二个参数是“期望类型”
    if (!dr) {
      dr = pdfDoc.context.obj({})
      acroForm.set(PDFName.of('DR'), dr)
    }

    let fontDict = dr.lookupMaybe(PDFName.of('Font'), PDFDict) // 返回装所有字体引用的字典
    if (!fontDict) {
      fontDict = pdfDoc.context.obj({})
      dr.set(PDFName.of('Font'), fontDict)
    }
    
    fontDict.set(PDFName.of(notoSans.name), notoSans.ref) // 把刚嵌入的字体登记到 /DR 的 /Font 字典里
  }

  /**
   * 写入单个表单字段的值。
   * @param {string} name 字段名称
   * @param {string | undefined} value 对应文本
   * @returns {void}
   */
  const setText = (name, value) => {
    try {
      const field = form.getTextField(name)
      if (name === 'conclusion' || name === 'recommendation') {
        field.enableMultiline()
      }
      field.setText(value ?? '')
    } catch (error) {
      console.warn(`字段 ${name} 填充失败`, error)
    }
  }

  setText('templateNo', data.templateNo)
  setText('generatedAt', data.generatedAt)

  setText('clientName', data.client?.name)
  setText('clientId', data.client?.id)
  setText('contactPerson', data.client?.contactPerson)
  setText('contactPhone', data.client?.contactPhone)

  const results = data.results || []
  for (let i = 0; i < 5; i += 1) {
    const row = results[i] || {}
    const fallbackIndex = results[i] ? String(i + 1) : ''
    setText(`results[${i + 1}].index`, row.index ?? fallbackIndex)
    setText(`results[${i + 1}].item`, row.item)
    setText(`results[${i + 1}].result`, row.result)
    setText(`results[${i + 1}].remark`, row.remark)
  }

  setText('conclusion', data.conclusion)
  setText('recommendation', data.recommendation)
  setText('reviewer', data.reviewer)
  setText('reviewDate', data.reviewDate)

  // 默认 save() 会再次调用 form.updateFieldAppearances(),用内建 Helvetica 重新生成外观,但它不支持中文,如果没有 “{ updateFieldAppearances: false }”,会出现渲染问题。
  // 这里显式关掉自动更新,保留我们手动设置的 /NeedAppearances 和字体资源,让阅读器按这些指令自己渲染,从而避免把外观流写坏。
  const pdfBytes = await pdfDoc.save({ updateFieldAppearances: false })
  return new Blob([pdfBytes], { type: 'application/pdf' })
}

5. 缺陷

这种流程导出的是真正的矢量 PDF,字段仍可编辑,打印无损;又由于模板是提前设计好的,无论多细的排版、样式都能完全还原。但问题在于:PDF 绘制过程较为繁杂,需要一步一步用代码手动绘制得出。这种方式不适用于 PDF 结构多变的场景,比较适用于模板长期固定,只需填充相应值即可的场景,如标准化报告(如检测、检验、体检、评估等行业常见的固定版式文档,尤其需要盖章/签字、交第三方审核的正式输出)、政府或合作方模板(对方提供 PDF 表单模板并要求“照填”,前端只需按字段名写值就能生成合规文件)等。