1. 项目概述
在现代Web应用中,PDF处理已成为常见需求。基于Vue3/React框架结合pdfjs实现PDF多页展示、拖拽盖章签名及导出功能,能够满足电子合同、在线审批等业务场景的需求。这个方案的核心在于利用pdfjs-dist库解析PDF文件,通过Canvas渲染页面,再借助Fabric.js实现交互式绘图层。
我曾在一个金融风控系统中实现过类似功能,用户需要在贷款审批流程中对PDF合同进行电子签章。最初尝试了几种方案后,发现pdfjs+Fabric的组合在性能和兼容性上表现最佳。下面将详细介绍这个方案的具体实现。
2. 技术选型与准备
2.1 核心库介绍
pdfjs-dist:Mozilla开源的PDF解析库,能够将PDF文档渲染到Canvas上。最新版本支持:
- 文本选择和高亮
- 多页渲染
- 搜索功能
- 自定义缩放
Fabric.js:强大的Canvas操作库,提供:
- 对象模型(支持图像、文本、路径等)
- 交互式操作(拖拽、缩放、旋转)
- 序列化/反序列化
- 事件系统
2.2 环境配置
安装所需依赖:
npm install pdfjs-dist@2.16.105 fabric@5.3.1 lodash-es对于Vue3项目,还需要安装拖拽组件:
npm install vuedraggable@4.1.0注意:pdfjs-dist的worker文件需要单独配置,建议使用CDN引入以减小打包体积。
3. 核心实现步骤
3.1 PDF加载与渲染
首先实现PDF文件加载和页面渲染:
// PDF加载函数 const loadPDF = async (url) => { // 设置worker路径(重要!) PDFJS.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@2.16.105/build/pdf.worker.min.js' const loadingTask = PDFJS.getDocument({ url, cMapUrl: 'https://cdn.jsdelivr.net/npm/pdfjs-dist@2.16.105/cmaps/', cMapPacked: true }) try { const pdf = await loadingTask.promise const numPages = pdf.numPages const pageRatios = [] // 预计算所有页面尺寸 for (let i = 1; i <= numPages; i++) { const page = await pdf.getPage(i) const viewport = page.getViewport({ scale: 1 }) pageRatios.push(viewport.width / viewport.height) } return { pdf, numPages, pageRatios } } catch (error) { console.error('PDF加载失败:', error) throw error } }3.2 多页Canvas渲染
实现PDF多页渲染到Canvas:
// 渲染PDF页面到Canvas const renderPages = async (pdfDoc, containerEl) => { const canvases = [] for (let i = 1; i <= pdfDoc.numPages; i++) { const page = await pdfDoc.getPage(i) const viewport = page.getViewport({ scale: 1.5 }) // 适当放大 const canvas = document.createElement('canvas') canvas.id = `pdf-page-${i}` canvas.width = viewport.width canvas.height = viewport.height containerEl.appendChild(canvas) await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise canvases.push({ canvas, pageNum: i, width: viewport.width, height: viewport.height }) } return canvases }3.3 Fabric画布叠加
创建交互层覆盖PDF:
// 初始化Fabric画布 const initFabricCanvas = (width, height) => { const fabricCanvas = new fabric.Canvas('fabric-canvas', { width, height, selection: false, backgroundColor: 'transparent' }) // 防止对象超出画布 fabricCanvas.on('object:moving', (e) => { const obj = e.target obj.setCoords() // 边界检查 if (obj.left < 0) obj.left = 0 if (obj.top < 0) obj.top = 0 if (obj.left + obj.width > width) obj.left = width - obj.width if (obj.top + obj.height > height) obj.top = height - obj.height }) return fabricCanvas }4. 拖拽盖章实现
4.1 印章拖拽交互
实现从侧边栏拖拽印章到PDF上的功能:
<template> <div class="container"> <!-- 印章列表 --> <div class="stamp-palette"> <div v-for="(stamp, index) in stamps" :key="index" class="stamp-item" draggable="true" @dragstart="onDragStart($event, stamp)" > <img :src="stamp.image" :alt="stamp.name"> </div> </div> <!-- PDF查看区域 --> <div class="pdf-viewer" @drop="onDrop" @dragover.prevent> <div v-for="page in pages" :key="page.id" class="pdf-page"> <canvas :id="`pdf-${page.id}`"></canvas> </div> <canvas id="fabric-canvas"></canvas> </div> </div> </template> <script> export default { methods: { onDragStart(e, stamp) { e.dataTransfer.setData('stamp', JSON.stringify(stamp)) }, onDrop(e) { const stamp = JSON.parse(e.dataTransfer.getData('stamp')) const rect = e.currentTarget.getBoundingClientRect() const x = e.clientX - rect.left const y = e.clientY - rect.top this.addStampToCanvas(stamp, x, y) } } } </script>4.2 印章定位计算
精准计算印章在PDF上的位置:
// 添加印章到画布 function addStampToCanvas(stamp, clientX, clientY) { // 计算相对位置 const pdfContainer = document.querySelector('.pdf-viewer') const scrollTop = pdfContainer.scrollTop const scrollLeft = pdfContainer.scrollLeft // 计算落在哪个PDF页面上 let pageIndex = 0 let accumulatedHeight = 0 for (let i = 0; i < this.pages.length; i++) { accumulatedHeight += this.pages[i].height if (clientY + scrollTop <= accumulatedHeight) { pageIndex = i break } } // 计算在该页面内的相对位置 const pageOffset = pageIndex > 0 ? accumulatedHeight - this.pages[pageIndex].height : 0 const relativeY = clientY + scrollTop - pageOffset // 创建Fabric图像对象 fabric.Image.fromURL(stamp.image, (img) => { img.set({ left: clientX + scrollLeft, top: relativeY, scaleX: 0.5, scaleY: 0.5, data: { type: 'stamp', page: pageIndex + 1, stampId: stamp.id } }) this.fabricCanvas.add(img) this.fabricCanvas.renderAll() }) }5. 导出功能实现
5.1 导出图片
将带有印章的PDF导出为图片:
// 导出为图片 function exportToImage() { // 创建临时Canvas合并所有内容 const tempCanvas = document.createElement('canvas') const tempCtx = tempCanvas.getContext('2d') // 计算总高度 let totalHeight = 0 let maxWidth = 0 this.pages.forEach(page => { totalHeight += page.height maxWidth = Math.max(maxWidth, page.width) }) tempCanvas.width = maxWidth tempCanvas.height = totalHeight // 绘制所有PDF页面 let currentY = 0 this.pages.forEach(page => { const pageCanvas = document.getElementById(`pdf-${page.id}`) tempCtx.drawImage(pageCanvas, 0, currentY) currentY += page.height }) // 绘制印章层 const fabricCanvasEl = this.fabricCanvas.getElement() tempCtx.drawImage(fabricCanvasEl, 0, 0) // 生成图片 const imgData = tempCanvas.toDataURL('image/png') const link = document.createElement('a') link.href = imgData link.download = 'signed-document.png' link.click() }5.2 导出PDF
将修改后的内容导出为PDF(需要pdf-lib库):
npm install pdf-lib@1.17.1实现代码:
async function exportToPDF() { const { PDFDocument, rgb } = PDFLib // 创建新PDF文档 const pdfDoc = await PDFDocument.create() // 处理每一页 for (const page of this.pages) { // 添加新页 const newPage = pdfDoc.addPage([page.width, page.height]) // 获取原始PDF页面图像 const canvas = document.getElementById(`pdf-${page.id}`) const pngImage = await pdfDoc.embedPng(canvas.toDataURL('image/png')) // 绘制原始页面 newPage.drawImage(pngImage, { x: 0, y: 0, width: page.width, height: page.height }) // 绘制该页的印章 const stamps = this.fabricCanvas.getObjects().filter(obj => { return obj.data?.page === page.number }) for (const stamp of stamps) { const stampImg = await pdfDoc.embedPng(stamp.toDataURL({ format: 'png', quality: 1 })) newPage.drawImage(stampImg, { x: stamp.left, y: page.height - stamp.top - stamp.height * stamp.scaleY, width: stamp.width * stamp.scaleX, height: stamp.height * stamp.scaleY }) } } // 生成并下载 const pdfBytes = await pdfDoc.save() const blob = new Blob([pdfBytes], { type: 'application/pdf' }) const link = document.createElement('a') link.href = URL.createObjectURL(blob) link.download = 'signed-document.pdf' link.click() }6. 性能优化与常见问题
6.1 大PDF处理优化
处理大型PDF时的性能建议:
- 分页加载:不要一次性渲染所有页面,实现懒加载
- Canvas回收:离开视口的页面释放Canvas内存
- 分辨率控制:根据设备DPI调整渲染比例
// 视口检测实现懒加载 const lazyLoadPages = () => { const viewer = document.querySelector('.pdf-viewer') const visibleHeight = viewer.clientHeight const scrollTop = viewer.scrollTop this.pages.forEach(async (page) => { const pageEl = document.getElementById(`pdf-page-${page.number}`) if (!pageEl) return const rect = pageEl.getBoundingClientRect() const isVisible = rect.top < visibleHeight && rect.bottom > 0 if (isVisible && !page.rendered) { await renderPage(page.number) page.rendered = true } }) } // 监听滚动事件 viewer.addEventListener('scroll', _.throttle(lazyLoadPages, 200))6.2 常见问题排查
问题1:印章位置偏移
- 原因:PDF渲染缩放与Canvas尺寸不匹配
- 解决:确保Fabric画布与PDF Canvas使用相同的尺寸比例
// 正确的尺寸同步 function syncCanvasSizes() { const pdfCanvas = document.getElementById('pdf-page-1') const { width, height } = pdfCanvas this.fabricCanvas.setDimensions({ width, height }) this.fabricCanvas.setZoom(1) // 重置缩放 this.fabricCanvas.renderAll() }问题2:移动端触摸支持
- 原因:原生拖拽事件在移动端不工作
- 解决:添加触摸事件支持
// 添加触摸支持 function initTouchEvents() { const container = document.querySelector('.pdf-viewer') container.addEventListener('touchstart', (e) => { if (e.target.classList.contains('stamp-item')) { this.draggingStamp = this.stamps.find( s => s.id == e.target.dataset.stampId ) } }) container.addEventListener('touchend', (e) => { if (this.draggingStamp) { const touch = e.changedTouches[0] const { clientX, clientY } = touch this.addStampToCanvas(this.draggingStamp, clientX, clientY) this.draggingStamp = null } }) }问题3:导出的PDF文字不可选
- 原因:Canvas渲染的是图像而非原始文本
- 解决:使用pdf-lib合并原始PDF文本层
async function exportWithTextLayer(originalPdfBytes) { const originalPdf = await PDFDocument.load(originalPdfBytes) const newPdf = await PDFDocument.create() // 复制每一页内容和注释 const pages = await newPdf.copyPages(originalPdf, originalPdf.getPageIndices() ) pages.forEach(page => newPdf.addPage(page)) // 添加印章作为注释 const stamps = this.fabricCanvas.getObjects() for (const stamp of stamps) { const page = newPdf.getPage(stamp.data.page - 1) const stampImg = await newPdf.embedPng(stamp.toDataURL()) page.drawImage(stampImg, { x: stamp.left, y: page.getHeight() - stamp.top - stamp.height * stamp.scaleY, width: stamp.width * stamp.scaleX, height: stamp.height * stamp.scaleY }) } // 导出 const bytes = await newPdf.save() return bytes }7. 安全与合规考虑
- 签名验证:所有印章应包含数字签名防止篡改
- 操作审计:记录所有盖章操作的时间戳和用户信息
- 水印保护:导出的PDF应添加隐形水印
- 权限控制:限制印章使用权限和下载权限
实现示例:
// 添加数字水印 function addWatermark(ctx, text, width, height) { ctx.save() ctx.globalAlpha = 0.1 ctx.font = '20px Arial' ctx.fillStyle = '#000000' ctx.rotate(-0.26) for (let y = 50; y < height; y += 100) { for (let x = 20; x < width; x += 200) { ctx.fillText(text, x, y) } } ctx.restore() } // 在导出时调用 const canvas = document.createElement('canvas') // ...绘制内容... addWatermark(canvas.getContext('2d'), `Confidential ${new Date().toISOString()}`, canvas.width, canvas.height )8. 项目扩展方向
- 多人协作签名:使用WebSocket实现多用户同时签署文档
- 手写签名:集成签名板支持手写签名
- 模板系统:预设常用盖章位置模板
- OCR识别:自动识别PDF中的签名区域
- 区块链存证:将签名哈希上链存证
手写签名实现示例:
// 初始化签名板 function initSignaturePad() { const canvas = document.getElementById('signature-pad') const pad = new SignaturePad(canvas, { backgroundColor: 'rgba(255, 255, 255, 0)', penColor: 'rgb(0, 0, 0)' }) // 导出签名为图像 document.getElementById('save-sig').addEventListener('click', () => { if (pad.isEmpty()) { alert('请先提供签名') } else { const dataURL = pad.toDataURL('image/png') this.addStampToCanvas({ id: 'user-sig', image: dataURL }, 100, 100) pad.clear() } }) }在实际项目中,这种PDF交互方案已经帮助我们将合同签署流程从原来的3-5天缩短到30分钟以内。关键在于处理好细节问题,比如:
- 高DPI设备的显示适配
- 不同PDF页面的尺寸统一处理
- 印章位置的持久化存储
- 移动端的手势操作支持
通过合理的架构设计和性能优化,即使处理100页以上的PDF文档也能保持流畅的交互体验。