一句话:预览用 pdf.js,改 PDF 用 pdf-lib,签字用 smooth-signature——三库分工,全在浏览器完成。
写在前面
业务里常见 H5 合同场景:用户阅读 PDF → 手写签名 → 合成带签名/印章的文件 → 上传后端或对接 e 签宝。
本博客把预览 + 签名 + 下载做成工具页 /tool/pdf,核心逻辑在组件 components/xia/signature/index.vue,页面壳在 pages/tool/pdf.vue。
读完你能:说清三库职责;理解缩放与 ArrayBuffer 缓存;知道中文嵌入 pdf-lib 的坑。
三库分工
| 库 | 职责 | 本项目中 |
|---|---|---|
| pdf.js | 解析 PDF、渲染到 Canvas | 多页预览、滚动读页 |
| pdf-lib | 创建/修改 PDF 二进制 | 最后一页叠加 PNG 签名、印章、日期文字 |
| smooth-signature | 手写板采集笔迹 | Canvas 签名 → toDataURL('image/png') |
脚本通过 utils/script-loader.ts 按需 CDN 加载(pdf.js worker、pdf-lib 1.17.1),避免 SSR 阶段引用 window。
用户流程
pages/tool/pdf.vue 支持三种 PDF 来源:
- 默认示例:站点静态
/static/uploads/...保证书.pdf - 本地上传:
URL.createObjectURL(file) - URL 参数:
?file=/static/...或远程地址
预览:pdf.js 渲染
javascript
const data = await fetchPdfBuffer(pdfSrc); // 统一 ArrayBuffer
const pdfDocument = await pdfjsLib.getDocument({ data }).promise;
for (let i = 1; i <= pdfDocument.numPages; i++) {
const page = await pdfDocument.getPage(i);
const viewport = page.getViewport({ scale: 2 });
const canvas = document.createElement('canvas');
await page.render({ canvasContext: ctx, viewport }).promise;
}
要点:
- 远程 PDF 先
fetch → arrayBuffer,避免 pdf.js 跨域 worker 问题(需静态资源 CORS 正确) - 多页 PDF 每页一个 canvas,纵向排列
pdfSourceBuffer缓存原始字节,签名后更新缓存,支持「预览已签名版」而不重复请求
缩放
Letter 尺寸按 612×792 pt 估算,容器 CSS 变量驱动:
css
.pdf-wrap {
width: calc(var(--scale-factor) * 612px);
min-height: calc(var(--scale-factor) * 792px * var(--page-count, 1));
}
minScale = 容器宽 / 612,放大上限约 minScale × 4。滚动时根据 scrollTop 更新「当前页」指示。
签名:smooth-signature
倒计时结束(默认 10s)后才显示「开始签名」——降低未读就签的合规风险。
javascript
signature.value = new SmoothSignature(canvas, {
width: Math.min(containerWidth - 24, 480),
height: 160,
minWidth: 3,
maxWidth: 10,
color: '#333',
bgColor: 'transparent',
});
signaturePng.value = signature.value.toDataURL(); // PNG base64
支持清除、撤销;空签名拦截并 toast 提示。
合成:pdf-lib 写入最后一页
javascript
const pdfDoc = await PDFDocument.load(pdfSourceBuffer.value.slice(0));
const lastPage = pdfDoc.getPages().at(-1);
const { width, height } = lastPage.getSize();
// 手写签名 PNG
const sigImg = await pdfDoc.embedPng(await fetch(signaturePng.value).then(r => r.arrayBuffer()));
lastPage.drawImage(sigImg, { x: width - 260, y: height / 2 - 100, width: 160, height: 60 });
// 印章图(本地 assets)
const sealImg = await pdfDoc.embedPng(await fetch(sealLogo).then(r => r.arrayBuffer()));
lastPage.drawImage(sealImg, { x: width - 260, y: height / 2 - 140, width: 140, height: 140 });
// 日期(默认字体,英文数字)
lastPage.drawText(dayjs().format('YYYY MM DD'), { x: width - 240, y: height / 2 - 120, size: 18 });
const pdfBytes = await pdfDoc.save();
emits('success', new Blob([pdfBytes], { type: 'application/pdf' }));
中文与字体
drawText 写中文需 embed 中文字体(如 Noto Sans SC),否则乱码或报错。本工具页日期用数字格式规避;生产合同若需中文条款编辑,建议:
- 预置 TTF/OTF,
pdfDoc.embedFont(fontBytes) - 或后端用 e 签宝 / 服务端 PDF 引擎盖章
与后端 e 签宝的关系
本篇是 纯前端 POC:下载的 PDF 可由用户自行上传,或由业务后端再调第三方 CA/电子签 API。
典型生产链路:
- H5 完成本文流程 → 上传已签 PDF
- 服务端调 e 签宝「企业盖章 / 司法存证」
- 返回具备法律效力的签署链接
浏览器端签名适合 用户意愿确认 + 视觉留痕;法律效力通常还需 CA 与时间戳。
踩坑与注意
- SSR:pdf.js / pdf-lib 仅客户端加载,
onMounted里loadPdfScripts() - Blob URL 泄漏:
onBeforeUnmount里URL.revokeObjectURL - 二次签名:用
pdfSourceBuffer缓存,避免editPdf再次 fetch 覆盖已签内容 - 大 PDF:多页 render 占内存,可考虑懒加载可见页(当前为全量渲染,适合合同页数较少)
- 移动端:签名板高度用
min/max限制,避免键盘顶起布局错乱
小结
- pdf.js 只读渲染;pdf-lib 改二进制;smooth-signature 采集笔迹——职责清晰
- 缩放用 CSS
--scale-factor+ Letter 宽高比 - 中文 pdf-lib 要 embed 字体;简单场景可用 PNG 图代替文字
- 在线试用:https://jiang-xia.top/tool/pdf


全部评论(1)