ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于pdf.js实现Web端PDF标注与电子签名功能

基于pdf.js实现Web端PDF标注与电子签名功能

1. 项目背景与核心需求

在Web开发中处理PDF文档一直是个痛点需求。最近接手了一个电子合同管理系统项目,客户要求在浏览器中实现类似桌面软件般的PDF操作体验:多页展示、拖拽盖章、手写签名、导出图片和PDF。经过技术选型,最终确定基于pdf.js配合Vue3/React实现这套功能。

pdf.js是Mozilla开源的PDF渲染库,能直接在浏览器中解析和渲染PDF文档。相比服务端方案,它有三大优势:

  • 完全前端处理,减轻服务器压力
  • 支持逐页渲染,适合大文档
  • 提供底层API,可深度定制交互

核心功能拆解:

  1. 多页展示:支持PDF文档的分页加载与导航
  2. 标注层:在PDF上方叠加透明画布实现交互
  3. 拖拽盖章:实现印章图片的拖放定位
  4. 签名板:手写签名捕获与坐标映射
  5. 导出功能:将标注后的文档导出为图片或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 页面渲染优化策略

  1. 视窗检测:只渲染可视区域内的页面
const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { renderPage(entry.target.dataset.pageNum) } }) }) // 对每个页面容器添加观察 pages.forEach(page => { observer.observe(page.container) })
  1. 分级加载:
  • 第一页:立即加载
  • 后续页面:延迟500ms分批加载
  • 不可见页面:仅加载元数据
  1. 缓存管理:
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 印章拖拽交互设计

  1. 准备印章素材:
<div class="stamp-palette"> <img v-for="stamp in stamps" :src="stamp.image" draggable="true" @dragstart="onStampDragStart($event, stamp)" > </div>
  1. 拖放事件处理:
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 内存管理要点

  1. 及时释放资源:
function cleanup() { pdfDocument?.destroy() renderedPages.forEach(page => { page.pageObj?.cleanup() }) }
  1. 画布复用策略:
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 交互响应优化

  1. 防抖处理高频事件:
const debouncedRender = _.debounce(renderPage, 100) function onScroll() { debouncedRender() }
  1. 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 调试技巧

  1. 开启pdf.js调试日志:
pdfjs.verbosity = pdfjs.VerbosityLevel.INFOS
  1. 检查PDF文档结构:
pdf.getMetadata().then(meta => { console.log('PDF metadata:', meta) }) pdf.getOutline().then(outline => { console.log('Document outline:', outline) })
  1. 性能分析:
console.time('renderPage') await renderPage() console.timeEnd('renderPage')

9. 扩展功能实现思路

9.1 文本标注与高亮

实现原理:

  1. 使用pdf.js的getTextContent获取文本位置
  2. 计算选择区域的文本范围
  3. 在对应位置绘制半透明矩形
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 表单填写与保存

技术方案:

  1. 解析PDF表单字段:
const formFields = pdfDoc.getForm().getFields()
  1. 动态创建对应表单控件

  2. 保存表单数据:

const formData = pdfDoc.getForm().getFields().map(field => ({ name: field.getName(), value: field.getValue() }))

10. 项目部署注意事项

10.1 生产环境配置

  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>
  1. 按需加载策略:
const pdfjs = await import('pdfjs-dist/legacy/build/pdf.js') const pdfWorker = await import('pdfjs-dist/legacy/build/pdf.worker.js') pdfjs.GlobalWorkerOptions.workerSrc = pdfWorker

10.2 安全防护措施

  1. 文件校验:
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 }
  1. 大小限制:
const MAX_SIZE = 10 * 1024 * 1024 // 10MB if (file.size > MAX_SIZE) { throw new Error('File size exceeds limit') }
  1. 内容扫描:
const dangerousKeywords = ['/JavaScript', '/Launch', '/EmbeddedFile'] const textContent = await pdf.getTextContent() dangerousKeywords.forEach(keyword => { if (textContent.includes(keyword)) { throw new Error('Unsafe content detected') } })

在实际项目中,我们通过这套方案成功实现了合同签署系统的核心功能。有几个特别值得分享的经验点:

  1. 对于大文档(100页+),采用虚拟滚动技术可以显著提升性能。我们实现了按需渲染,当用户滚动到某页时才加载该页内容,同时保留前后3页的缓存。

  2. 签名笔迹的平滑处理是个细节活。除了贝塞尔曲线,我们还加入了笔压模拟算法,根据绘制速度动态调整线条粗细,使电子签名更接近真实笔迹。

  3. 在移动端适配时,发现触摸事件和鼠标事件的坐标处理有差异。最终我们抽象出了统一的坐标处理层,屏蔽了底层事件差异。

  4. 导出PDF时遇到中文乱码问题,解决方案是预先在pdf-lib中注册中文字体:

const fontBytes = await fetch('/fonts/SourceHanSansCN-Regular.ttf') const customFont = await pdfDoc.embedFont(fontBytes)

这套方案目前已在生产环境稳定运行,支持日均5000+份合同的在线签署。对于需要更高安全级别的场景,建议结合数字证书实现真正的电子签名,而不仅仅是视觉上的签名效果。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进