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.text、pdf.line这些高层接口,做模板“画页面”比较顺手。pdf-lib适合专门负责“读取模板并填值”,这部分它做得很好(也能嵌入字体、更新外观)。但如果要从零搭一张页面,pdf-lib的API就偏底层,得手动拼很多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 并刷新外观),最后把填充完成的文档 save 成 Blob,再交由下载/预览逻辑使用。
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 表单模板并要求“照填”,前端只需按字段名写值就能生成合规文件)等。