简介:这是一份基于Vue.js实现PDF上传、盖章与签名功能的完整项目源码包,面向需要为合同、审批等文档流程引入在线签章能力的开发者,适合具备一定Vue基础、希望快速落地的前端学习者。压缩包共19个文件,含5个Vue组件、5个JavaScript脚本、3个JSON配置文件及2张效果预览图等,整体仅216KB,结构清晰,便于直接导入工程运行。资源演示了借助pdfjs-dist加载与渲染PDF,通过vue-pdf-signature交互添加签名和印章,并配合axios完成文件上传、生成链接触发下载的完整链路,覆盖从文件展示到导出的主要实现思路。已有1457人学习下载,可作为理解相关插件用法、排查常见集成问题的参考样本。
1. 给 PDF 盖章签名再下载:Vue 前端能独立做完,但坑在细节里
后台管理系统里经常出现这样的需求:用户上传一份 PDF 合同,审核通过后需要在指定位置盖一个红章、签一个名字,然后下载回去当正式文件。很多人第一反应是找后端帮忙,但其实 Vue 前端有一套成熟的做法,可以独立完成“读取 PDF → 盖章 → 签名 → 下载”这条完整链路。核心思路是用 pdf-lib 这类 PDF 操作库直接读写 PDF 字节流,前端不只是能“显示”PDF,还能生成合法、可被阅读器正常打开的 PDF 文件。整个流程听起来不复杂,但坐标换算、透明通道、字体、内存边界,每一个都会让生产环境翻车。这篇文章把这条链路完整拆开,让你照着就能跑通,也把最容易踩的坑提前标出来。
2. 技术选型与坐标体系:为什么是 pdf-lib,以及 PDF 坐标系里 Y 轴朝上这件事
2.1 pdf-lib、jspdf、canvas 渲染三选一:取舍依据
给已有 PDF 加内容,业内常见的方案有三个:jspdf、pdf-lib、pdf.js 配合 canvas 渲染。它们的定位差异很大,选错方向后面全是返工。
| 目标 | jspdf | pdf-lib | pdf.js + canvas |
|---|---|---|---|
| 从零生成新 PDF | 强 | 中 | 弱 |
| 加载已有 PDF 并盖章 | 弱,需把原文件当底图重新插 | 原生支持 | 不支持写文件 |
| 保留原 PDF 的文本和样式 | 可能丢失 | 完整保留 | 保留渲染效果但不产出 PDF |
| 嵌入图片、字体 | 支持 | 支持 | 不支持 |
| 合并、拆分、多页操作 | 有限 | 完善 | 不支持 |
我一般这样选:凡是“给已有 PDF 加内容、合并页面、拆分文件”这类编辑场景,直接上 pdf-lib;凡是“从数据库生成报表 PDF、完全由前端排版出文件”的场景,才用 jspdf。pdf.js 在这条链路里只承担一个职责——渲染预览和校验,不参与最终文件生成。
pdf-lib 在浏览器环境里能直接加载 PDF 字节数组,读取每一页,然后在页面上绘制图片、文字、矩形,最后保存为新字节流。它不会像 canvas 渲染方案那样把 PDF 变成位图再导出,所以原文件里的文字仍然可选可复制,文件体积也不会因为像素化而膨胀。
2.2 PDF 页面坐标换算:前端 Y 轴朝下,PDF Y 轴朝上
这是一个非常容易忽略的点。前端页面和 canvas 的坐标系原点在左上角,Y 轴向下;而 PDF 页面坐标系原点在左下角,Y 轴向上,单位是 point(1/72 英寸)。一张 A4 纸在 PDF 里的尺寸是 595.28 × 841.89 point。
如果你把前端页面上拿到的鼠标坐标直接传给 pdf-lib 的 drawImage,章的位置会完全错乱——看起来像是盖到了镜像位置。换算公式不复杂:
// frontY 是前端坐标系里的 Y 值,pageHeight 是 PDF 页面高度 const pdfX = frontX const pdfY = pageHeight - frontY如果前端预览区对 PDF 做了缩放,还需要先把前端坐标除以缩放比例,还原成 PDF 原始坐标。比如预览容器宽度是 800px,PDF 渲染宽度也是 800px,那 scale 就是 1;如果容器是 400px,PDF 实际渲染比例是 0.5,那么坐标也要除以 0.5 才能对上。
2.3 盖章素材准备:透明 PNG、尺寸与旋转规范
电子章和签名图片的素材质量,直接决定盖章效果。红章建议用透明背景的 PNG,尺寸在 500 到 1000 像素之间,形状居中对齐,中央文字与边缘保留一定留白。如果是圆形章,四周不要裁太紧,否则旋转后边缘会被页面裁掉。
签名则建议用白色背景画布手写导出,导出格式必须是 PNG,不能是 JPEG,否则透明区域会变成黑色或白色块。素材准备好之后,在代码里统一约定:印章宽度按 PDF 页面宽度的 15% 到 20% 计算,签名宽度按页面宽度的 30% 左右计算。这样在不同尺寸的 PDF 里盖出来比例协调,而不是固定像素导致大页面显小、小页面溢出。
3. 在 Vue 里跑通盖章签名下载:从上传到落盘的最小实现
3.1 Vue 组件读取 PDF:从 file 对象到 Uint8Array
Vue 3 的组件里,上传 PDF 最直接的方式是用 input 元素绑定 change 事件。拿到 file 对象后,把它转成 Uint8Array,pdf-lib 才能识别。现代浏览器可以直接用 file.arrayBuffer(),兼容性要求高再用 FileReader。
// upload-pdf.vue <script setup> import { ref } from 'vue' import { PDFDocument } from 'pdf-lib' const pdfBytesRef = ref(null) async function handleFileChange(event) { const file = event.target.files[0] if (!file) return // 读取文件字节流,arrayBuffer() 返回 ArrayBuffer,转成 Uint8Array const pdfBytes = new Uint8Array(await file.arrayBuffer()) pdfBytesRef.value = pdfBytes }这里有两个容易忽略的点。第一,file.arrayBuffer() 返回的是 Promise,必须 await;第二,pdf-lib 的 load 方法只接受 Uint8Array 或 ArrayBuffer,不要直接传 Blob。如果项目需要兼容旧版浏览器,可以用 FileReader 的 readAsArrayBuffer 替代,回调里同样转成 Uint8Array。
加载 PDF 文档时,建议把 ignoreEncryption 选项打开。很多业务系统导出的 PDF 带有空密码保护,不打开这个选项会直接抛错。如果 PDF 确实有内容加密且需要密码,pdf-lib 会要求传入 password,这块要单独做交互处理。
3.2 第一页盖第一个章:pdf-lib 最小盖章代码
这是整条链路的核心操作。先把印章图片嵌入 PDF 文档,再从文档中取出目标页面,最后调用 drawImage 把图片画上去。
async function stampFirstPage(stampFile) { const pdfBytes = pdfBytesRef.value if (!pdfBytes) return // 加载 PDF 文档,ignoreEncryption 避免被空密码拦截 const pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true }) // 嵌入印章图片,必须是 PNG 字节流 const stampBytes = new Uint8Array(await stampFile.arrayBuffer()) const stampImage = await pdfDoc.embedPng(stampBytes) // 取第一页,索引从 0 开始 const page = pdfDoc.getPage(0) // 按页面宽度 18% 计算章的大小 const stampWidth = page.getWidth() * 0.18 const stampHeight = stampWidth // 圆形章宽高一致 // 盖在右上角:X 靠右留 40pt 边距,Y 靠上留 40pt 边距 page.drawImage(stampImage, { x: page.getWidth() - stampWidth - 40, y: page.getHeight() - stampHeight - 40, width: stampWidth, height: stampHeight, opacity: 0.95, }) // 保存为新的字节流 const savedBytes = await pdfDoc.save() return savedBytes }drawImage 的参数里,x 和 y 是图片左下角在 PDF 页面上的坐标,不是中心点。width 和 height 控制实际显示尺寸,单位是 point。opacity 控制透明度,红章一般用 0.9 到 0.95,太透明会发虚,完全不透明会盖住正文太生硬。如果印章图片需要旋转,加 rotate 参数,单位是角度,比如 rotate: 15 表示顺时针旋转 15 度。
注意:getPage(0) 是第一页,不是第零页。pdf-lib 沿用了数组索引习惯,第一页的 index 是 0。多页文档里如果要盖第三页,用 getPage(2)。
3.3 手写签名画布:canvas 导出与 PDF 嵌入
签名功能通常用一个 canvas 让用户手写,然后导出 PNG 字节嵌入 PDF。这块的关键点是导出格式和数据转换。
<canvas ref="signCanvas" width="400" height="200" style="border:1px solid #ccc;"></canvas>// 签名画布导出 function exportSignature() { const canvas = signCanvas.value // 导出 PNG 格式 dataURL,黑色笔迹保留透明背景 const dataUrl = canvas.toDataURL('image/png') const pngBytes = dataUrlToUint8Array(dataUrl) return pngBytes } // dataURL 转 Uint8Array function dataUrlToUint8Array(dataUrl) { const base64 = dataUrl.split(',')[1] const binary = atob(base64) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i) } return bytes }签名图片嵌入 PDF 的方式与盖章完全一样,用 pdfDoc.embedPng 把 pngBytes 嵌入。签名不像印章要求圆形,宽高可以根据笔迹实际内容调整,或者按宽 30%、高 15% 的比例设定。建议给画布加鼠标事件监听,并在 mousedown/mousemove 中实现画笔轨迹,避免手写时出现线条断裂。
3.4 多页盖章与多次盖章:按页码数组循环铺章
实际业务里很少只盖一页。合同可能每页右下角都要盖骑缝章,或者只在指定几页盖公章。常见做法是把要盖章的页码放进数组,循环处理。
async function stampMultiPages(stampFile, pageNumbers) { const pdfDoc = await PDFDocument.load(pdfBytesRef.value, { ignoreEncryption: true }) const stampImage = await pdfDoc.embedPng(new Uint8Array(await stampFile.arrayBuffer())) const stampWidth = pdfDoc.getPage(0).getWidth() * 0.18 const margin = 40 // pageNumbers 是页码数组,比如 [1, 3, 5],内部转换为索引 for (const pageNum of pageNumbers) { const page = pdfDoc.getPage(pageNum - 1) const x = page.getWidth() - stampWidth - margin const y = margin // 右下角 page.drawImage(stampImage, { x, y, width: stampWidth, height: stampWidth, opacity: 0.9, }) } return pdfDoc.save() }这里把章盖在每一页的右下角,x 靠右、y 靠底。多个章叠加时,只需对同一页面多次调用 drawImage,后画的会覆盖先画的,可以用 opacity 做出骑缝章的层次感。要注意的是,页面尺寸不一定相同,比如 PDF 里同时存在 A4 和 A3 页面,此时不能在循环外写死 x 和 y,必须在循环内用每页的 getWidth() 和 getHeight() 重新计算。
3.5 保存与下载:PDF 字节转 Blob,中文文件名保留
保存下载是整个流程的最后一跳。pdf-lib 的 save() 返回 Uint8Array,需要包装成 Blob,再通过 URL.createObjectURL 生成下载链接。中文文件名在这个环节经常出问题。
async function downloadPdf(pdfBytes, originalName) { const blob = new Blob([pdfBytes], { type: 'application/pdf' }) const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url // 保留原文件名,追加“盖章”后缀,注意 .pdf 后缀不能重复 const baseName = originalName.replace(/\.pdf$/i, '') link.download = `${baseName}_盖章.pdf` link.click() // 及时释放对象 URL,避免内存泄漏 URL.revokeObjectURL(url) }Blob 类型必须写 application/pdf,否则部分 Windows 机器下载后后缀异常。a.download 属性对中文文件名支持良好,但如果文件名里带斜杠、冒号等非法字符,保存时会失败,需要在拼接前过滤。文件较大时,建议下载完成后调用 revokeObjectURL 释放内存,否则连续操作多个大文件,页面会越来越卡。
4. 盖章下载的翻车点与排查清单:坐标、白底、字体、内存、模糊
4.1 章盖到了坐标系“镜像位”:现象、定位与换算修复
现象:预览层里章的位置完全正确,但下载后的 PDF 里章跑到页面底部或者上下颠倒的位置。原因很直接:预览层用的是前端坐标系,原点在左上角,Y 向下增长;PDF 坐标系原点在左下角,Y 向上增长。直接把前端 Y 坐标塞进 drawImage 的 y 参数,等于把章放到了对称位置。
解决:所有传进 drawImage 的 y 都必须经过翻转换算。
function toPdfPoint(frontX, frontY, pageHeight, scale) { return { x: frontX / scale, y: pageHeight - frontY / scale, } }scale 是前端预览的缩放比例。如果预览区域做了缩放,必须先除以 scale 再翻转。我在项目里习惯封装一个 getStampPosition 函数,统一处理缩放、翻转和页面边距,业务代码里不再出现裸坐标,避免每个开发者各写一套换算逻辑。
4.2 印章周围出现白块:源图有白底
现象:红章盖上去之后,图片周围多了一个白色矩形块,正文被挡住一块。原因:印章源图不是透明 PNG,而是带白底的 JPG 或者导出时没去掉背景。pdf-lib 的 embedPng 只能识别 PNG 的 alpha 通道,白底会原样保留。
解决:如果手头只有白底图片,前端可以做一个简单的去底处理。用 canvas 读取像素,把接近白色的像素 alpha 置 0。
function removeWhiteBackground(canvas, threshold = 240) { const ctx = canvas.getContext('2d') const { width, height } = canvas const imageData = ctx.getImageData(0, 0, width, height) const data = imageData.data for (let i = 0; i < data.length; i += 4) { const r = data[i] const g = data[i + 1] const b = data[i + 2] // RGB 都大于阈值视为白底 if (r > threshold && g > threshold && b > threshold) { data[i + 3] = 0 } } ctx.putImageData(imageData, 0, 0) return canvas.toDataURL('image/png') }要注意阈值别设太低,否则红章边缘会被削掉一层;也别太高,否则阴影残留发灰。这个方案只适合纯色白底,如果印章里有白色文字或细线,会被一起变透明,这种素材只能重新制作透明 PNG。
4.3 中文文件名乱码与标准字体不支持中文
现象:下载下来的文件名变成一串随机字符,或者用 setFont 添加文字时中文全部变成乱码。文件名乱码的原因通常是直接用了 Blob URL 的默认名称,或者 a.download 里没有编码处理;中文乱码的深层原因是 pdf-lib 内置标准字体只有 Helvetica、Times 等西方字体,不支持中文字符。
解决:文件名问题按 3.5 的方式用 a.download 显式指定即可,注意把文件名过滤一遍非法字符。字体问题要正视:如果盖章时需要添加“审核通过”“2025-06-01”这类中文文字,不能依赖标准字体,常见做法有两种。第一种是在 canvas 上把文字画成透明 PNG,再嵌入 PDF,简单可靠,不引入字体文件;第二种是用 fontkit 嵌入中文字体文件,效果更专业,但字体文件体积大,加载慢,还要处理 fontkit 的初始化。
我的习惯是优先用 canvas 画字转 PNG,除非业务要求 PDF 内文字可选中可复制,才走字体嵌入路线。
4.4 大 PDF 加载占用过多内存:上传与图片压缩边界
现象:用户在系统里上传了一个几百 MB 的 PDF,点击盖章后浏览器直接白屏或崩溃。原因很明确,pdf-lib 的 load 会把整个 PDF 文件读进内存,浏览器 tab 的内存上限有限,大文件加上图片嵌入,内存瞬间爆掉。
解决:第一道防线是上传限制,设置 30MB 或 50MB 的文件大小校验,超出直接提示。第二道防线是图片压缩,印章和签名图片在嵌入前先用 canvas 缩放到合理尺寸,比如 200px 宽就够用,不要直接嵌入 4000px 的扫描件。第三道防线是加载期间的 UI 反馈,写入 loading 状态,避免用户重复点击触发多个并发任务。如果业务确实需要处理超大 PDF,建议拆页处理或者交后端转换,前端不适合硬扛。
4.5 签名导出文字发虚:画布分辨率与格式选择
现象:手写签名嵌入 PDF 后,笔迹边缘发虚,放大后有明显锯齿。原因:canvas 的 CSS 尺寸和实际像素尺寸不一致,默认情况下画布只有 400×200 的物理像素,在 PDF 里拉伸后被插值模糊。
解决:把 canvas 的物理尺寸按设备像素比放大,再通过 CSS 控制显示大小。代码里固定设置:
const canvas = signCanvas.value const dpr = window.devicePixelRatio || 1 canvas.width = cssWidth * dpr canvas.height = cssHeight * dpr const ctx = canvas.getContext('2d') ctx.scale(dpr, dpr)这样绘制时的坐标还是按 CSS 像素写,但物理像素密度提升了一倍,导出 PNG 的清晰度明显改善。导出格式一定用 image/png,JPEG 会把透明背景变成黑色,而且有损压缩会让笔迹边缘出现色斑。
5. 让盖章结果可核对、可存证:文件的二次校验与元数据写入
5.1 下载前先校验:从字节到页面的闭环检查
生产环境里经常遇到“下载后打不开”或者“页数变少”的问题。我习惯在保存下载前加一道校验逻辑,把生成好的字节流重新 load 一遍,先确认文件结构没被破坏。
async function verifyPdf(generatedBytes, originalPageCount) { const doc = await PDFDocument.load(generatedBytes, { ignoreEncryption: true }) // 页数是否一致 if (doc.getPageCount() !== originalPageCount) { throw new Error(`页数不一致:原文件 ${originalPageCount},生成文件 ${doc.getPageCount()}`) } // 文件体积是否异常,小于 100 字节基本说明生成失败 if (generatedBytes.byteLength < 1024) { throw new Error('生成文件体积异常,请检查源文件') } return true }这只验证了文件层面的完整性。位置是否正确,还需要把生成文件渲染成 canvas 目视检查。可以用 pdf.js 把每一页渲染成图片,和前端预览层对比盖章区域。这个步骤不用全自动,能通过布尔值判断页数和体积是否异常,加上人工抽查位置,就已经能拦截绝大多数问题。
| 检查项 | 方法 |
|---|---|
| 页数无增减 | PDFDocument.load 后 getPageCount 对比原文件 |
| 文件能正常解析 | load 不抛错且 byteLength 不为 0 |
| 盖章位置准确 | pdf.js 渲染页面到 canvas,与预览层人工比对 |
| 文件体积异常 | 生成文件与原文件体积对比,超出合理范围则排查 |
5.2 元数据写入与业务边界:不能忽视的一件事
pdf-lib 支持写入 PDF 元数据,这在一份要归档的盖章合同里很有用。在保存前把标题、作者、创建时间写进文档,方便后续检索。
pdfDoc.setTitle(`合同-${originalFileBaseName}`) pdfDoc.setAuthor('业务系统签章模块') pdfDoc.setCreationDate(new Date()) pdfDoc.setModificationDate(new Date())通过这种方式,生成的文件自带操作时间和业务来源标识。但要诚实面对一个边界:前端盖章是“视觉上的套印”,做出来是一张带有印章图像的普通 PDF,不等于法律意义上的电子签章。真正的电子签章需要对接 CA 体系、可信时间戳和防篡改机制,那是另一套系统,不是 pdf-lib 能解决的。做业务开发时,我会把这个边界提前和需求方说清楚,避免上线后产生合同效力纠纷。
我自己常年的一个习惯是“先出预览,再写文件”。在模拟项目 X 里,第一次做盖章功能时直接按鼠标坐标传给 pdf-lib,预览看着是对的,下载下来章全跑到了左侧边缘,就是因为没按 PDF 坐标系翻转。后来我强制自己每次改动都先跑一遍校验函数,确认页数和文件结构没问题,再点击下载。前端做 PDF 编辑不复杂,但每一步都要能验证,希望帮到你。
本文还有配套的精品资源,点击获取