1. 项目背景与核心需求
在Web开发中处理PDF文档一直是个痛点需求。最近接手了一个电子合同管理系统项目,客户要求在浏览器中实现类似桌面软件般的PDF操作体验:多页展示、拖拽盖章、手写签名、导出图片和PDF。经过技术选型,最终确定基于pdf.js配合Vue3/React实现这套功能。
pdf.js是Mozilla开源的PDF渲染库,能直接在浏览器中解析和渲染PDF文档。相比服务端方案,它有三大优势:
- 完全前端处理,减轻服务器压力
- 支持逐页渲染,适合大文档
- 提供底层API,可深度定制交互
核心功能拆解:
- 多页展示:支持PDF文档的分页加载与导航
- 标注层:在PDF上方叠加透明画布实现交互
- 拖拽盖章:实现印章图片的拖放定位
- 签名板:手写签名捕获与坐标映射
- 导出功能:将标注后的文档导出为图片或PDF
2. 基础环境搭建
2.1 安装pdf.js
推荐使用npm安装稳定版:
npm install pdfjs-dist@2.16.105对于Vue3项目,需要在main.js中配置worker路径:
import { pdfjs } from 'pdfjs-dist' pdfjs.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@2.16.105/build/pdf.worker.min.js'React项目则建议在根组件中配置:
import { useEffect } from 'react' import { pdfjs } from 'pdfjs-dist' function App() { useEffect(() => { pdfjs.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.js', import.meta.url) }, []) }2.2 创建PDF查看器组件
基础结构示例(Vue3单文件组件):
<template> <div class="pdf-container"> <div class="pdf-viewer" ref="viewer"></div> <canvas class="annotation-layer" ref="canvas" @mousedown="startDrawing" @mousemove="draw" @mouseup="stopDrawing"> </canvas> </div> </template> <style scoped> .pdf-container { position: relative; } .annotation-layer { position: absolute; top: 0; left: 0; pointer-events: auto; } </style>关键点说明:
- 采用绝对定位叠加画布实现标注层
- 容器需要设置position: relative
- 画布尺寸需与PDF页面保持同步
3. PDF多页渲染实现
3.1 文档加载与分页处理
// Vue3示例 const loadingTask = pdfjs.getDocument(pdfUrl) loadingTask.promise.then(pdf => { const totalPages = pdf.numPages const pages = [] for (let i = 1; i <= totalPages; i++) { pdf.getPage(i).then(page => { const viewport = page.getViewport({ scale: 1.5 }) pages.push({ pageNum: i, pageObj: page, viewport }) }) } state.pages = pages })3.2 页面渲染优化策略
- 视窗检测:只渲染可视区域内的页面
const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { renderPage(entry.target.dataset.pageNum) } }) }) // 对每个页面容器添加观察 pages.forEach(page => { observer.observe(page.container) })- 分级加载:
- 第一页:立即加载
- 后续页面:延迟500ms分批加载
- 不可见页面:仅加载元数据
- 缓存管理:
const pageCache = new Map() function getPage(pageNum) { if (pageCache.has(pageNum)) { return Promise.resolve(pageCache.get(pageNum)) } return pdf.getPage(pageNum).then(page => { pageCache.set(pageNum, page) return page }) }4. 拖拽盖章功能实现
4.1 印章拖拽交互设计
- 准备印章素材:
<div class="stamp-palette"> <img v-for="stamp in stamps" :src="stamp.image" draggable="true" @dragstart="onStampDragStart($event, stamp)" > </div>- 拖放事件处理:
function onStampDragStart(e, stamp) { e.dataTransfer.setData('stamp', JSON.stringify(stamp)) } function onCanvasDrop(e) { e.preventDefault() const stamp = JSON.parse(e.dataTransfer.getData('stamp')) const rect = e.target.getBoundingClientRect() const x = e.clientX - rect.left const y = e.clientY - rect.top drawStamp(stamp, x, y) }4.2 坐标转换关键算法
PDF坐标系统与Canvas的转换:
function pdfToCanvas(pdfPoint, viewport) { return [ pdfPoint[0] * viewport.width / viewport.viewBox[2], viewport.height - (pdfPoint[1] * viewport.height / viewport.viewBox[3]) ] } function canvasToPdf(canvasPoint, viewport) { return [ canvasPoint[0] * viewport.viewBox[2] / viewport.width, viewport.viewBox[3] - (canvasPoint[1] * viewport.viewBox[3] / viewport.height) ] }注意:pdf.js使用左下角为原点的坐标系,而Canvas使用左上角为原点,需要做Y轴翻转
5. 手写签名实现方案
5.1 签名板核心逻辑
let isDrawing = false let lastX = 0 let lastY = 0 function startDrawing(e) { isDrawing = true ;[lastX, lastY] = [e.offsetX, e.offsetY] } function draw(e) { if (!isDrawing) return const ctx = canvas.getContext('2d') ctx.beginPath() ctx.moveTo(lastX, lastY) ctx.lineTo(e.offsetX, e.offsetY) ctx.stroke() ;[lastX, lastY] = [e.offsetX, e.offsetY] } function stopDrawing() { isDrawing = false }5.2 签名平滑优化
使用贝塞尔曲线优化笔迹:
function drawSmoothLine(ctx, points) { ctx.beginPath() ctx.moveTo(points[0].x, points[0].y) for (let i = 1; i < points.length - 2; i++) { const xc = (points[i].x + points[i + 1].x) / 2 const yc = (points[i].y + points[i + 1].y) / 2 ctx.quadraticCurveTo(points[i].x, points[i].y, xc, yc) } ctx.stroke() }6. 导出功能实现
6.1 导出图片方案
function exportToImage() { const pages = document.querySelectorAll('.page-container') const promises = [] pages.forEach(page => { const canvas = page.querySelector('canvas') promises.push( new Promise(resolve => { canvas.toBlob(blob => { resolve(blob) }, 'image/png') }) ) }) Promise.all(promises).then(blobs => { // 使用JSZip打包多个图片 const zip = new JSZip() blobs.forEach((blob, i) => { zip.file(`page_${i+1}.png`, blob) }) zip.generateAsync({ type: 'blob' }).then(content => { saveAs(content, 'document_images.zip') }) }) }6.2 导出PDF方案
使用pdf-lib库合并修改:
import { PDFDocument } from 'pdf-lib' async function exportToPDF() { const originalPdfBytes = await fetch(pdfUrl).then(res => res.arrayBuffer()) const pdfDoc = await PDFDocument.load(originalPdfBytes) const pages = pdfDoc.getPages() const { width, height } = pages[0].getSize() // 在每页上绘制签名和印章 for (let i = 0; i < pages.length; i++) { const page = pages[i] const annotations = getAnnotationsForPage(i+1) annotations.forEach(anno => { if (anno.type === 'stamp') { const stampImage = await pdfDoc.embedPng(anno.imageData) page.drawImage(stampImage, { x: anno.x, y: height - anno.y - anno.height, width: anno.width, height: anno.height }) } }) } const modifiedPdf = await pdfDoc.save() download(modifiedPdf, 'modified_document.pdf') }7. 性能优化实战经验
7.1 内存管理要点
- 及时释放资源:
function cleanup() { pdfDocument?.destroy() renderedPages.forEach(page => { page.pageObj?.cleanup() }) }- 画布复用策略:
const canvasPool = [] function getCanvas(width, height) { const available = canvasPool.find(c => c.width >= width && c.height >= height ) if (available) return available const canvas = document.createElement('canvas') canvas.width = width canvas.height = height canvasPool.push(canvas) return canvas }7.2 交互响应优化
- 防抖处理高频事件:
const debouncedRender = _.debounce(renderPage, 100) function onScroll() { debouncedRender() }- Web Worker处理复杂计算:
// worker.js self.onmessage = function(e) { const { type, data } = e.data if (type === 'parseAnnotations') { const result = heavyAnnotationParsing(data) self.postMessage({ type, result }) } } // 主线程 const worker = new Worker('worker.js') worker.postMessage({ type: 'parseAnnotations', data: annotationsData })8. 典型问题排查指南
8.1 常见问题与解决方案
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| PDF渲染空白 | 1. Worker路径错误 2. CORS限制 | 1. 检查workerSrc配置 2. 配置服务器CORS头 |
| 印章位置偏移 | 坐标转换错误 | 验证pdfToCanvas/canvasToPdf函数 |
| 内存泄漏 | 未调用cleanup | 在组件卸载时清理资源 |
| 移动端无法签名 | 触摸事件未处理 | 添加touchstart/touchmove事件 |
8.2 调试技巧
- 开启pdf.js调试日志:
pdfjs.verbosity = pdfjs.VerbosityLevel.INFOS- 检查PDF文档结构:
pdf.getMetadata().then(meta => { console.log('PDF metadata:', meta) }) pdf.getOutline().then(outline => { console.log('Document outline:', outline) })- 性能分析:
console.time('renderPage') await renderPage() console.timeEnd('renderPage')9. 扩展功能实现思路
9.1 文本标注与高亮
实现原理:
- 使用pdf.js的getTextContent获取文本位置
- 计算选择区域的文本范围
- 在对应位置绘制半透明矩形
async function highlightText(page, text) { const textContent = await page.getTextContent() const viewport = page.getViewport({ scale: 1.0 }) textContent.items.forEach(item => { if (item.str.includes(text)) { const x = item.transform[4] const y = viewport.height - item.transform[5] ctx.fillStyle = 'rgba(255,255,0,0.5)' ctx.fillRect(x, y, item.width, item.height) } }) }9.2 表单填写与保存
技术方案:
- 解析PDF表单字段:
const formFields = pdfDoc.getForm().getFields()动态创建对应表单控件
保存表单数据:
const formData = pdfDoc.getForm().getFields().map(field => ({ name: field.getName(), value: field.getValue() }))10. 项目部署注意事项
10.1 生产环境配置
- CDN资源加载优化:
<script src="https://cdn.jsdelivr.net/npm/pdfjs-dist@2.16.105/build/pdf.min.js"></script> <script> pdfjs.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@2.16.105/build/pdf.worker.min.js' </script>- 按需加载策略:
const pdfjs = await import('pdfjs-dist/legacy/build/pdf.js') const pdfWorker = await import('pdfjs-dist/legacy/build/pdf.worker.js') pdfjs.GlobalWorkerOptions.workerSrc = pdfWorker10.2 安全防护措施
- 文件校验:
function validatePDF(buffer) { const header = new Uint8Array(buffer, 0, 4) return header[0] === 0x25 && // % header[1] === 0x50 && // P header[2] === 0x44 && // D header[3] === 0x46 // F }- 大小限制:
const MAX_SIZE = 10 * 1024 * 1024 // 10MB if (file.size > MAX_SIZE) { throw new Error('File size exceeds limit') }- 内容扫描:
const dangerousKeywords = ['/JavaScript', '/Launch', '/EmbeddedFile'] const textContent = await pdf.getTextContent() dangerousKeywords.forEach(keyword => { if (textContent.includes(keyword)) { throw new Error('Unsafe content detected') } })在实际项目中,我们通过这套方案成功实现了合同签署系统的核心功能。有几个特别值得分享的经验点:
对于大文档(100页+),采用虚拟滚动技术可以显著提升性能。我们实现了按需渲染,当用户滚动到某页时才加载该页内容,同时保留前后3页的缓存。
签名笔迹的平滑处理是个细节活。除了贝塞尔曲线,我们还加入了笔压模拟算法,根据绘制速度动态调整线条粗细,使电子签名更接近真实笔迹。
在移动端适配时,发现触摸事件和鼠标事件的坐标处理有差异。最终我们抽象出了统一的坐标处理层,屏蔽了底层事件差异。
导出PDF时遇到中文乱码问题,解决方案是预先在pdf-lib中注册中文字体:
const fontBytes = await fetch('/fonts/SourceHanSansCN-Regular.ttf') const customFont = await pdfDoc.embedFont(fontBytes)这套方案目前已在生产环境稳定运行,支持日均5000+份合同的在线签署。对于需要更高安全级别的场景,建议结合数字证书实现真正的电子签名,而不仅仅是视觉上的签名效果。