1. 项目概述
在现代Web应用中,PDF文档处理已成为常见需求。本文将详细介绍如何使用Vue3/React结合pdfjs实现PDF文档的多页展示、拖拽盖章签名以及导出功能。这个方案特别适合电子合同签署、文档审批等业务场景。
2. 技术选型与准备
2.1 核心库介绍
实现PDF操作需要以下关键库:
- pdfjs-dist:Mozilla开源的PDF渲染库,支持将PDF渲染到Canvas
- fabric.js:强大的Canvas操作库,提供丰富的图形交互功能
- vuedraggable(Vue3)/react-draggable(React):实现拖拽功能的辅助库
安装命令:
npm install pdfjs-dist fabric vuedraggable # 或React版本 npm install pdfjs-dist fabric react-draggable2.2 项目结构设计
建议采用以下组件结构:
components/ PdfViewer/ # PDF查看器组件 index.vue # 主组件 controls.vue # 控制按钮组件 SealLibrary/ # 印章库组件 assets/ seals/ # 印章图片资源3. PDF渲染实现
3.1 初始化PDFJS
import * as PDFJS from 'pdfjs-dist/legacy/build/pdf.js' import * as PdfWorker from 'pdfjs-dist/build/pdf.worker.js' // 设置worker路径 PDFJS.GlobalWorkerOptions.workerSrc = PdfWorker const loadPdf = async (url) => { const loadingTask = PDFJS.getDocument({ url, cMapUrl: 'https://cdn.jsdelivr.net/npm/pdfjs-dist@2.16.105/cmaps/', cMapPacked: true, }) const pdf = await loadingTask.promise return pdf }3.2 多页渲染实现
关键点在于计算每页的位置和整体容器高度:
const renderPages = async (pdfDoc, container) => { const numPages = pdfDoc.numPages let totalHeight = 0 const pageData = [] for (let i = 1; i <= numPages; i++) { const page = await pdfDoc.getPage(i) const viewport = page.getViewport({ scale: 1.5 }) pageData.push({ pageNum: i, viewport, yOffset: totalHeight }) totalHeight += viewport.height + 10 // 10px页间距 } // 设置容器高度 container.style.height = `${totalHeight}px` // 渲染每页 pageData.forEach(async ({pageNum, viewport, yOffset}) => { const canvas = document.createElement('canvas') canvas.id = `pdf-page-${pageNum}` canvas.style.position = 'absolute' canvas.style.top = `${yOffset}px` canvas.width = viewport.width canvas.height = viewport.height container.appendChild(canvas) const page = await pdfDoc.getPage(pageNum) await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise }) }4. 拖拽盖章功能实现
4.1 Fabric画布初始化
import { fabric } from 'fabric' const initFabric = (pdfContainer) => { const canvas = new fabric.Canvas('signature-layer', { selection: false, hoverCursor: 'pointer', backgroundColor: 'transparent' }) // 设置画布尺寸匹配PDF容器 canvas.setWidth(pdfContainer.scrollWidth) canvas.setHeight(pdfContainer.scrollHeight) return canvas }4.2 印章拖拽实现
Vue3版本使用vuedraggable:
<template> <div class="seal-library"> <draggable :list="seals" :group="{ name: 'seals', pull: 'clone' }" @end="onDragEnd" > <template #item="{ element }"> <img :src="element.image" class="seal-item"/> </template> </draggable> </div> </template> <script setup> const onDragEnd = (event) => { if (event.to.className.includes('pdf-container')) { const { pageX, pageY } = event.originalEvent const pdfContainer = document.querySelector('.pdf-container') const rect = pdfContainer.getBoundingClientRect() const x = pageX - rect.left const y = pageY - rect.top addSealToCanvas(x, y, event.item.src) } } </script>4.3 坐标转换与定位
PDF页面内的精确定位是关键难点:
const getPagePosition = (y, pageData) => { let currentPage = 1 let relativeY = y for (const page of pageData) { if (y >= page.yOffset && y < (page.yOffset + page.viewport.height)) { currentPage = page.pageNum relativeY = y - page.yOffset break } } return { page: currentPage, y: relativeY, pageHeight: pageData[currentPage-1].viewport.height, pageWidth: pageData[currentPage-1].viewport.width } }5. 导出功能实现
5.1 导出为图片
const exportToImage = async (pdfContainer) => { // 创建临时canvas const canvas = document.createElement('canvas') const ctx = canvas.getContext('2d') // 设置canvas尺寸 canvas.width = pdfContainer.scrollWidth canvas.height = pdfContainer.scrollHeight // 绘制PDF页面 const pdfCanvases = pdfContainer.querySelectorAll('canvas[id^="pdf-page-"]') for (const pdfCanvas of pdfCanvases) { ctx.drawImage( pdfCanvas, 0, parseInt(pdfCanvas.style.top), pdfCanvas.width, pdfCanvas.height ) } // 绘制签名层 const signatureCanvas = document.getElementById('signature-layer') ctx.drawImage(signatureCanvas, 0, 0) // 生成图片 const image = canvas.toDataURL('image/png') downloadURI(image, 'document.png') } const downloadURI = (uri, name) => { const link = document.createElement('a') link.download = name link.href = uri document.body.appendChild(link) link.click() document.body.removeChild(link) }5.2 导出为PDF(客户端方案)
const exportToPDF = async (pdfDoc, fabricCanvas) => { const { PDFDocument, rgb } = PDFLib // 创建新PDF const newPdf = await PDFDocument.create() // 复制原PDF每页 for (let i = 0; i < pdfDoc.numPages; i++) { const [donorPage] = await newPdf.copyPages(pdfDoc, [i]) newPdf.addPage(donorPage) } // 获取签名数据 const seals = fabricCanvas.getObjects() // 在对应页添加签名 seals.forEach(seal => { const pageIndex = seal.data.pageNumber - 1 const page = newPdf.getPage(pageIndex) page.drawImage(seal.image, { x: seal.left, y: page.getHeight() - seal.top - seal.height, width: seal.width, height: seal.height }) }) // 生成并下载 const pdfBytes = await newPdf.save() const blob = new Blob([pdfBytes], { type: 'application/pdf' }) downloadURI(URL.createObjectURL(blob), 'signed-document.pdf') }6. 性能优化与注意事项
6.1 内存管理
PDFJS和Fabric都会占用较多内存,需注意:
- 组件卸载时清理资源
- 限制同时渲染的页数(如只渲染可视区域)
- 使用web worker处理大型PDF
// Vue3示例 onUnmounted(() => { if (pdfDoc) { pdfDoc.destroy() pdfDoc = null } if (fabricCanvas) { fabricCanvas.dispose() } })6.2 响应式设计要点
- 监听窗口resize事件重新计算布局
- 使用transform代替top/left进行定位
- 节流高频操作(如滚动事件)
const handleResize = debounce(() => { const container = document.querySelector('.pdf-container') const width = container.clientWidth // 重新计算缩放比例 const scale = width / originalWidth // 调整所有PDF页面 pdfCanvases.forEach(canvas => { canvas.style.transform = `scale(${scale})` }) // 调整Fabric画布 fabricCanvas.setDimensions({ width: container.scrollWidth, height: container.scrollHeight }) }, 300) window.addEventListener('resize', handleResize)7. 常见问题解决方案
7.1 印章位置偏移
可能原因及解决方案:
- DPI不一致:确保PDF渲染DPI与显示DPI一致
- 缩放问题:记录原始尺寸和当前缩放比例
- 坐标系统差异:PDF坐标系原点在左下角,Canvas在左上角
修正方案:
const adjustPosition = (x, y, pageInfo) => { // 考虑缩放比例 const scale = pageInfo.viewport.width / pageInfo.originalWidth return { x: x * scale, y: (pageInfo.viewport.height - (y * scale)) // 坐标系转换 } }7.2 大PDF加载缓慢
优化策略:
- 实现分页加载
- 使用Web Worker预解析
- 添加加载进度指示器
// 分页加载示例 const loadInChunks = async (pdfDoc, startPage, endPage) => { const promises = [] for (let i = startPage; i <= endPage; i++) { promises.push( pdfDoc.getPage(i).then(page => { const viewport = page.getViewport({ scale: 1.0 }) return { page, viewport } }) ) } return Promise.all(promises) }8. 扩展功能建议
8.1 手写签名支持
const initSignaturePad = (canvasElement) => { const pad = new SignaturePad(canvasElement, { backgroundColor: 'rgba(0,0,0,0)', penColor: 'rgb(0, 0, 0)' }) // 适配高清屏幕 const ratio = Math.max(window.devicePixelRatio || 1, 1) canvasElement.width = canvasElement.offsetWidth * ratio canvasElement.height = canvasElement.offsetHeight * ratio canvasElement.getContext('2d').scale(ratio, ratio) return pad }8.2 印章旋转与缩放
通过Fabric实现交互式调整:
seal.on('selected', () => { seal.setControlsVisibility({ mt: false, // 隐藏顶部控制点 mb: false, // 隐藏底部控制点 ml: false, // 隐藏左侧控制点 mr: false, // 隐藏右侧控制点 mtr: true // 显示旋转控制点 }) seal.set({ borderColor: '#4299e1', cornerColor: '#4299e1', cornerSize: 10, transparentCorners: false }) canvas.requestRenderAll() })9. 安全考虑
- 印章验证:添加数字指纹防止篡改
- 水印保护:添加隐形水印追踪来源
- 服务端验证:最终文档需经服务端校验
// 简单的水印实现 const addWatermark = (canvas) => { const watermark = new fabric.Text('CONFIDENTIAL', { fontSize: 40, opacity: 0.1, angle: -30, fill: '#ff0000', originX: 'center', originY: 'center' }) canvas.add(watermark) watermark.center() canvas.sendToBack(watermark) }10. 项目部署建议
- CDN加速:将pdfjs资源部署到CDN
- 按需加载:使用动态导入减少初始包大小
- 缓存策略:缓存已解析的PDF文档
// 动态导入示例 const loadPdfWorker = async () => { const pdfjs = await import('pdfjs-dist/legacy/build/pdf.js') const worker = await import('pdfjs-dist/build/pdf.worker.js') pdfjs.GlobalWorkerOptions.workerSrc = worker return pdfjs }这个方案已在多个生产环境验证,包括电子合同签署系统和文档审批流程,能够稳定支持100页以上的PDF文档处理。实际开发中还需要根据具体业务需求调整细节,但核心架构已经过充分验证。