前端PDF在线签章实战:pdfjs渲染Canvas与DOM拖拽合成详解 这阵子接了一个需要在网页上处理PDF合同的需求用户要能在线查看一份多页PDF在里面拖动“公司合同章”和“负责人签名”到指定位置最后把带章带签的效果导出成图片或者PDF。先说结论这套东西用 Vue3 或 React 都能做核心引擎是 pdfjs 渲染出来的 Canvas叠加层用 DOM 做拖拽导出阶段再基于渲染结果合成图片。整个过程踩了不少坑我把能直接落地的做法整理成一篇分享给遇到同类型需求的朋友做个参考。这个功能现在很常见银行、政务、电子合同、审批系统里都会碰到。只要你的项目里有“预览一份文件并加上自己的标记”的需求基本上就会落到这条技术路径上。适合已经会用 Vue 或 React、想了解 pdfjs 集成方式的前端开发者也适合团队里负责技术选型的同学。下面我会从原理讲起再给一套可运行的主流程代码最后把高频问题集中梳理一遍。1. 项目背景与技术选型1.1 需求拆解我刚接到需求时业务方给得比较粗就是把 PDF 在网页里打开能盖章、能签名可以翻页最后导出一份完整文件。真正动手以后会发现这里藏着几个非常关键的点多页 PDF 要正常展示且不能被浏览器默认的 PDF 插件顶下去。印章和签名要能拖拽位置要精确到页面上的某个合同位置。拖拽之后的最终效果必须和预览看到的完全一致不能导出后偏移。导出格式既要图片也要 PDF。这里面最麻烦的不是 PDF 解析本身而是“拖拽元素”和“PDF 底层渲染”这两套坐标系怎么统一。PDF 有自己的坐标系浏览器页面有屏幕坐标系导出合成时又要回到 PDF 的物理尺寸坐标三条线不打通导出一定错位。1.2 选型为什么是pdfjs canvas覆盖层我先对比过几种实现方案直接用浏览器内置 PDF Viewer用 iframe 嵌入优点是零开发缺点是你完全控制不了内部内容想在页面上叠图章、加拖拽非常不现实基本只能做个“查看”功能。把 PDF 转成图片再用 canvas 展示看起来简单但多页场景下图片体积大、加载慢而且无法保留 PDF 矢量清晰度放大容易糊。用 pdfjs 把每一页渲染成 canvas然后在 canvas 上覆盖一个 DOM 层所有印章、签名都是 DOM 元素通过绝对定位放在对应位置。这种方式既保留了清晰度又能用 DOM 天然的事件机制处理拖拽是当前性价比最高的方案。最后我选了 pdfjs-dist Canvas 绝对定位覆盖层。React 和 Vue 只是组件写法不同底层这套模型完全一致。一个核心点不要把图章画死到 canvas 里。一旦你直接画到 canvas 上拖拽时就要不断重绘整个页面性能会很差。DOM 覆盖层的好处是拖动只改变元素的 left/top浏览器自己处理重排而且后续要导出的合成逻辑也很灵活。2. 核心实现原理拆解2.1 pdfjs的渲染流程与worker配置pdfjs 的核心概念是PDFDocumentProxy。拿到文档对象后用getPage(pageNumber)获取某一页再通过page.getViewport({ scale })得到这一页在指定缩放倍数下的尺寸最后把页面渲染到 canvas 上。这里有几个容易踩坑的点GlobalWorkerOptions.workerSrc必须设置。pdfjs 的解析工作跑在 Web Worker 里如果不设置页面会直接报Failed to fetch或者No GlobalWorkerOptions.workerSrc specified。worker 文件路径不要写死相对路径尤其是最终打包部署到 CDN 或子路径场景下相对路径非常容易失效。最稳妥的方式是让构建工具帮你处理new URL(pdfjs-dist/build/pdf.worker.min.mjs, import.meta.url)。如果你的 PDF 超过几 MB建议在业务类型选择getDocument({ data })时取消isEvalSupported: false可以规避部分服务端环境下的动态执行限制。渲染一页的基本过程import * as pdfjsLib from pdfjs-dist; pdfjsLib.GlobalWorkerOptions.workerSrc new URL( pdfjs-dist/build/pdf.worker.min.mjs, import.meta.url ).toString(); const pdf await pdfjsLib.getDocument({ url: /api/file/demo.pdf }).promise; const page await pdf.getPage(1); const viewport page.getViewport({ scale: 1.5 }); const canvas document.createElement(canvas); canvas.width viewport.width; canvas.height viewport.height; const ctx canvas.getContext(2d); await page.render({ canvasContext: ctx, viewport, }).promise;这里有个容易忽略的细节viewport.width和viewport.height的单位是 CSS 像素但 PDF 内部用的是点单位默认 Scale 为 1 时1 页 A4 纸的宽约 595.28 点。如果你在页面上展示这点需要根据容器宽度计算实际比例。2.2 坐标系统PDF坐标、屏幕坐标与拖拽PDF 的坐标原点在页面左下角而浏览器 CSS 坐标原点在左上角。如果走page.getViewport默认参数pdfjs 已经帮你做转换页面里的坐标可以直接理解为左上角为原点这和我们浏览器是统一的。但你拖拽时拿到的鼠标事件坐标是相对浏览器视口的要转成页面内的坐标必须去掉容器的偏移。比如const rect container.getBoundingClientRect(); const x e.clientX - rect.left; const y e.clientY - rect.top;这个x和y就是覆盖层内的坐标也是之后用来设置元素left和top的数据。导出的时候因为底图 canvas 的 CSS 尺寸和覆盖层容器是同一个坐标系所以直接沿用这套坐标基本不会错位。如果画布渲染时使用了devicePixelRatio做高清适配那你还需要把坐标乘上dpr因为在绘制到底层 canvas 时canvas 的实际像素数是CSS 宽高 * dpr而拖拽坐标用的是 CSS 宽高。坐标统一的原则展示层统一使用 CSS 像素容器长宽和 canvas 的 style 宽高保持一致。导出层统一使用原始 canvas 像素所有 DOM 坐标乘以导出倍率。2.3 拖拽盖章与签名叠加层设计我这里的做法是维护一个数组描述每个页面上的图章或签名const elements [ { id: stamp_1, pageNumber: 1, type: stamp, src: /seal.png, x: 120, y: 260, width: 150, height: 150, rotation: 0, }, ];渲染时根据pageNumber把元素渲染到对应页面的 overlay 容器中。拖拽时监听 pointer 事件更新数组里对应项的x和y。用 DOM 做叠加层的好处是你可以自由调整大小、旋转、透明度甚至以后要支持元素缩放也只需要改widthheight触发 React/Vue 重新渲染即可。如果后续想支持旋转推荐用 CSStransform: rotate(angle)因为这样不会影响布局尺寸拖拽的坐标也很好算。注意角度单位CSS 里是度数导出时需要用弧度。2.4 导出图片与PDF的思路导出图片的核心逻辑是把当前页面底层 canvas 和一个或多个 DOM 图章合成到一张新 canvas 上。DOM 元素没办法直接画到 canvas所以要把覆盖层里的图片、文字依次drawImage过去。这个过程相当于“拍照”预览长什么样导出就长什么样。导出 PDF 我使用的是jspdf它的用法是把页面图片添加到 PDF 中每页对应一张图片。生成的 PDF 是基于图片的文字不可选中但优点是简单可靠适合用于盖章签名的结果文件也方便和业务方确认。如果合同必须保留原始文本层建议前端只负责“预览 标注”最终文件由后端基于原始 PDF 叠加图层生成两种思路不冲突。这里有一个取舍前端全量导出 PDF 时如果页数很多内存压力会非常大。所以导出前最好把所有页面渲染成固定倍率的图片再逐页写入 PDF。后文会给出具体代码。3. 实操开发过程从零搭建一个可用的PDF签章组件这里我以 Vue3 为例写一个主流程版本React 的写法差异并不大无非是useRef配合state管理页数组和元素数组事件回调注意传参与清理即可。3.1 基础布局与页面结构先建一个组件默认是类似合同的竖版布局。页面的大结构是顶部工具栏上传按钮、导出图片按钮、导出 PDF 按钮。中间内容区滚动容器里面按 PDF 页顺序渲染多个.page-wrapper。每个.page-wrapper是一个相对定位的盒子包含底层 canvas 和覆盖层.page-overlay。template div classpdf-signer div classtoolbar button clickloadDemo加载PDF/button button clickexportImage导出图片/button button clickexportPdf导出PDF/button /div div refscrollContainer classscroll-container div classpdf-pages div v-forpage in pageList :keypage.pageNumber classpage-wrapper :data-page-numberpage.pageNumber :style{ width: page.width px, height: page.height px } canvas refcanvasRefs :data-page-numberpage.pageNumber/canvas div classpage-overlay div v-forel in elementsOfPage(page.pageNumber) :keyel.id classstamp-item :style{ left: el.x px, top: el.y px, transform: rotate(${el.rotation}deg) } pointerdownstartDrag($event, el) img :srcel.src :style{ width: el.width px, height: el.height px } / /div /div /div /div /div /div /template注意这里的canvasRefs在 v-for 中拿到的是数组渲染后可以通过pageNumber找到对应的 canvas。3.2 加载PDF并渲染多页加载流程我分成四步用 fetch 或直接传给getDocument。解析出 PDF 总页数。遍历每一页计算适合容器宽度的缩放比例。渲染 canvas 并保存每页的宽高数据。计算公式比较简单。如果容器宽度是 800而 PDF 第 1 页原始宽度为 595.28 点理想比例就是800 / 595.28。为了保持每页一致我用第一页算出的比例给所有页统一缩放。const loadingTask pdfjsLib.getDocument({ url: /demo.pdf }); this.pdf await loadingTask.promise; const firstPage await this.pdf.getPage(1); const baseViewport firstPage.getViewport({ scale: 1 }); const fitScale (containerWidth - 48) / baseViewport.width; for (let i 1; i this.pdf.numPages; i) { const page await this.pdf.getPage(i); const viewport page.getViewport({ scale: fitScale }); this.pageList.push({ pageNumber: i, width: viewport.width, height: viewport.height, scale: fitScale, }); this.renderPageToCanvas(page, i, viewport); }渲染到 canvas 时建议做高清适配不然在 Retina 屏幕上会明显发虚function renderPageToCanvas(page, pageNumber, viewport) { const canvas getCanvasByPageNumber(pageNumber); const dpr window.devicePixelRatio || 1; canvas.width viewport.width * dpr; canvas.height viewport.height * dpr; canvas.style.width viewport.width px; canvas.style.height viewport.height px; const ctx canvas.getContext(2d); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); await page.render({ canvasContext: ctx, viewport }).promise; }这里先把 canvas 画布尺寸乘上 dpr再用setTransform归一化坐标系之后 pdfjs 渲染出来的图形仍然按 viewport 尺寸绘制但清晰度会随着 dpr 提升。3.3 实现拖拽盖章操作实现拖拽我推荐用pointerdown、pointermove、pointerup这组事件可以同时覆盖鼠标和触屏。在pointerdown时记录元素初始位置和鼠标起始位置在pointermove时计算差值并更新元素坐标function startDrag(event, element) { event.preventDefault(); const startX event.clientX; const startY event.clientY; const originX element.x; const originY element.y; const onMove (moveEvent) { const dx moveEvent.clientX - startX; const dy moveEvent.clientY - startY; element.x originX dx; element.y originY dy; }; const onUp () { document.removeEventListener(pointermove, onMove); document.removeEventListener(pointerup, onUp); }; document.addEventListener(pointermove, onMove); document.addEventListener(pointerup, onUp); }这样做的好处是即使鼠标移动速度很快、跑到图章元素外面也能持续跟踪。拖拽时还应该判断边界避免把章拖出页面区域。更新数组时如果是 Vue3 的reactive数组直接修改对象的属性即可触发响应式更新React 则需要不可变更新把数组替换成新数组。3.4 导出单页图片与合成PDF导出图片前我建议在内存里创建一张临时 canvas尺寸等于底层 canvas 的像素尺寸然后把底层 canvas 和所有覆盖层的图片元素都绘制上去。这样才能保证导出内容和预览一致。function exportPageAsImage(pageNumber) { const wrapper document.querySelector(.page-wrapper[data-page-number${pageNumber}]); const baseCanvas wrapper.querySelector(canvas); const overlay wrapper.querySelector(.page-overlay); const dpr window.devicePixelRatio || 1; const output document.createElement(canvas); output.width baseCanvas.width; output.height baseCanvas.height; const ctx output.getContext(2d); ctx.drawImage(baseCanvas, 0, 0, output.width, output.height); overlay.querySelectorAll(.stamp-item).forEach((node) { const img node.querySelector(img); const left parseFloat(node.style.left); const top parseFloat(node.style.top); const width parseFloat(node.style.width || img.style.width); const height parseFloat(node.style.height || img.style.height); const rotation parseFloat(node.style.transform.replace(/[^0-9-]/g, )) || 0; ctx.save(); ctx.translate(left * dpr (width * dpr) / 2, top * dpr (height * dpr) / 2); ctx.rotate((rotation * Math.PI) / 180); ctx.drawImage(img, -(width * dpr) / 2, -(height * dpr) / 2, width * dpr, height * dpr); ctx.restore(); }); return output.toDataURL(image/png); }这段代码里parseFloat(node.style.transform.replace(...))只适合角度为纯数字的场景实际项目如果遇到负角度或者其他 transform 组合建议把旋转角度单独存到数据对象里不要从 CSS 字符串里去解析。导出 PDF 的流程也很直观先拿到 A4 页面尺寸从第一页 viewport 得到单位是 pt再遍历每一页导出图片依次写入 jsPDF 实例。import { jsPDF } from jspdf; async function exportPdf() { const firstWrapper document.querySelector(.page-wrapper); const canvas firstWrapper.querySelector(canvas); const viewport this.pageList[0]; const pdf new jsPDF({ orientation: viewport.width viewport.height ? landscape : portrait, unit: pt, format: [viewport.width * 2, viewport.height * 2], }); for (let pageNumber 1; pageNumber this.pageList.length; pageNumber) { const imgData exportPageAsImage(pageNumber); if (pageNumber 1) { pdf.addPage([viewport.width * 2, viewport.height * 2], pdf.orientation); } pdf.addImage(imgData, PNG, 0, 0, viewport.width * 2, viewport.height * 2); } pdf.save(signed.pdf); }这里我把页面尺寸乘以 2是为了导出更清晰的图片版 PDF。如果 PDF 页数很多建议降低这个倍率否则 jsPDF 合成时内存会明显上涨。4. 常见问题与避坑实录4.1 问题速查表现象原因解决方案控制台报No GlobalWorkerOptions.workerSrc specifiedWorker 路径未设置使用new URL或 CDN 地址设置 workerSrcPDF 文件跨域导致加载失败目标服务器未返回 CORS 头将文件下载为 Blob 再传给getDocument导出图片比预览模糊canvas 没有做 DPR 适配渲染时乘上devicePixelRatio拖拽后图章偏移鼠标坐标用的是clientX没有减容器偏移使用getBoundingClientRect()计算相对坐标多页懒加载时页面高度闪动canvas 渲染是异步的初始化时没有占位高度先根据 viewport 宽度设置 wrapper 高度再渲染 canvas导出 PDF 很慢或卡死页面过多或导出倍率过高用 1.5~2 倍率并分页导出释放内存4.2 三个高频坑的详细排查第一个坑worker 部署后失效。本地开发正常发布到服务器后发现 PDF 加载不出来打开控制台大概率是 worker 文件 404。原因是使用相对路径pdf.worker.min.js时浏览器会基于当前页面 URL 去拼接一旦你的路由是router hash还好如果是 history 模式或者项目部署在子目录路径就会跑偏。我用new URL(..., import.meta.url)可以让打包工具生成绝对资源地址基本可以一劳永逸。第二个坑PDF 跨域。业务方给的 PDF 文件通常放在文件服务如果这个服务没有配置跨域头前端直接传入 URL 会被浏览器拦截。我的经验是先fetchPDF 文件转成 ArrayBuffer 或 Blob再传给getDocument。这样可以绕开 CORS 对数据加载的拦截文件中转的场景也能用这个方法。第三个坑导出偏移。很多时候预览看起来位置没问题导出图片后印章偏到了右下角。最典型的原因是覆盖层容器没有和 canvas 完全对齐比如 wrapper 有 padding或者 canvas 设置了border、margin。我建议 canvas 和 overlay 在同一个 wrapper 内wrapper 本身不设 paddingoverlay 的绝对定位用top: 0; left: 0同时确保 wrapper 宽度和 canvas 的 style 宽度一致。5. 一些实用经验与优化建议5.1 签名与印章素材的处理签名功能可以做成一个小画板用户按下鼠标时画线松开后生成 PNG 图片。比较适合业务的做法是将画板输出为透明背景图片然后把它当作一个“可拖拽元素”放入页面而不是直接把画板嵌在 PDF 页面上。印章素材建议使用透明背景的 PNG尺寸不要超过必要范围。我常用的图章大小为 200x200 像素CSS 展示宽度约 140~160 像素这样在导出倍率为 2 时仍能保持清晰。太小的图标如 50x50在高分屏下拖大后会出现马赛克尽量避免。如果你要在代码里生成文字章动态图片也可以用 canvas 绘制圆、边线、横向文字再导出 PNG。这样不需要后端提供素材。5.2 多页场景下的性能优化如果 PDF 超过 20 页一次性渲染完所有页面首屏时间会比较长滚动时也没有必要渲染那些完全不可见的页面。我常用的做法是 IntersectionObserver 懒渲染只有 wrapper 进入视口范围内才创建 canvas离开视口时如果允许也可以主动销毁。实现起来并不复杂const io new IntersectionObserver((entries) { entries.forEach((entry) { const pageNumber Number(entry.target.dataset.pageNumber); if (entry.isIntersecting !this.renderedPages.has(pageNumber)) { this.renderPage(pageNumber); this.renderedPages.add(pageNumber); } }); });懒渲染带来一个新的问题如果页面还没渲染完wrapper 的高度可能为 0导致滚动条突然跳动。解决方法是先根据 viewport 设置 wrapper 的宽高canvas 渲染完成后只修改 canvas 的内容不影响布局。5.3 产品层面的体验细节除了核心功能有两个产品细节特别影响实际使用感受。一是撤销/清空功能。用户拖完章、签完名很可能想撤销最后一笔或者一键清空所有标注。我建议维护一个操作记录栈每次新增元素、移动元素、删除元素都推入栈中撤销时恢复上一个状态。这个小功能在测试阶段可能看不出来但上线后使用频率极高。二是多页复制。业务方经常说“这个章要盖到每一页的同一个位置上”如果让用户一页一页拖过去体验会很差。我在实现时增加了一个“应用到所有页面”的按钮把当前页面的元素数据结构复制一份给其他页面坐标保持不变即可。这个功能成本很低但用户满意度提升非常明显。前端做 PDF 签章本质上不是“从零画一个 PDF 编辑器”而是把预览、交互、导出三个环节串起来。掌握了 pdfjs 渲染和坐标换算后面加缩放、旋转、字体、撤回都只是迭代量的问题。我遇到比较头疼的反而是兼容性测试Edge 和 Chrome 对 canvas 绘制差异不大但移动端 Safari 的 devicePixelRatio 处理偶尔会有奇怪表现建议上线前至少覆盖 iPhone 真机场景。如果项目最终要求导出的 PDF 必须保留原始文字层而不是图片型 PDF那前端方案会面临很大压力。更稳妥的路径是前端负责预览和标注数据生成 JSON 传给后端后端使用服务端渲染引擎把同样坐标的印章叠加到原始 PDF 上。两种方案各有适用场景我一般会先问业务方一句这个导出文件后续还需要被别人编辑文字吗答案是否定的那采用纯前端的图片型 PDF 方案效率最高也足够稳定。