☰
H5 PDF预览与电子签名实现:基于PDF.js的完整方案与移动端避坑指南
2026/10/6 3:42:01 网站建设 项目流程

简介:面向需为合同签署、表单填写等场景增加PDF在线预览与电子签名功能的前端开发者,这是一份基于H5技术的完整实现代码包。方案借助PDF.js完成浏览器端PDF渲染,通过canvas捕捉手写签名,并利用PDFKit将签名图像合并进原PDF,同时给出Web Worker避免主线程阻塞的优化思路。资源包共15个文件,以10个js文件为主体,涵盖PDF渲染、移动端适配、二维码生成等核心库,另有2个css用于样式调整,附带1个html示例页面和1个pdf测试文档,压缩包大小仅711KB,轻量易部署。已有419人学习浏览,适合具备一定JavaScript基础、希望快速集成在线签章功能的开发者参考。通过直接运行示例页面即可观察完整交互,并进一步理解PDF渲染、笔触捕获、图像合成与HTTPS安全传输等关键环节的实现细节。

1. 为什么 H5 里的 PDF 预览总在签名这步翻车

做过移动端办公场景的都知道,H5 里打开 PDF 本来就不省心,iOS 的 iframe 勉强能看,Android 各厂商 WebView 对 PDF 的默认支持完全看心情;一旦需求从「看看」变成「在合同上签个字再传回去」,问题就从渲染层烧到了交互层。这个标题拆开看是两件事:第一,把 PDF 稳定渲染进 H5 页面;第二,在渲染结果上叠加可落地的签名能力。前后端要做的活完全不同,前端管 canvas 渲染和触摸事件,后端管签名图的合成与回写。本文按一套常见的实现路径走下来,覆盖 PDF.js 渲染、签名弹层、图像合成、移动端坑点,能让你在业务里少填几个洞。

2. PDF.js 渲染进 H5:选型与最小可运行方案

2.1 三种渲染方案的取舍:iframe、pdf.js、Native 混合

H5 里预览 PDF,业界主流就三条路。第一种是 iframe 或 embed 直接嵌浏览器原生预览器,PC 端 Chrome、Edge 都没问题,移动端 iOS Safari 也能渲染,但 Android 的微信内置 X5 内核和部分厂商 WebView 会直接甩一个「无法打开文件」的下载提示。第二种是服务端先转图片再逐张加载,前端拿到的是一堆 PNG,兼容性最好,但分页加载和缩放逻辑全要自己写,签名更是只能签在静态图上,回写时坐标换算全靠猜。第三种就是 PDF.js,把渲染工作放到前端 canvas 上,兼容性、可定制性、与签名交互的耦合度都是最优解。

我的选择是 PDF.js。原因很直接:签名功能需要在渲染结果上叠加一层透明 canvas,iframe 方案根本没有机会插入这一层;转图片方案的重绘和缩放开销大,在低端 Android 机上翻页有明显的白屏感。PDF.js 虽然初始加载体积不小,但 worker 机制能把解析和渲染放在独立线程,页面交互不会卡死。后面所有实现都基于 PDF.js 的 2.x 稳定版本,这个版本的 API 与 vue/react 脚手架配合是社区里踩坑最少、文档最全的。

2.2 最小初始化代码:把 PDF 第一页画上 canvas

先落地一段最小代码,把 PDF 的第一页渲染出来,目标是跑通「加载 → 解析 → 渲染」整条链路。

import * as pdfjsLib from 'pdfjs-dist'; import workerSrc from 'pdfjs-dist/build/pdf.worker.min.js?url'; pdfjsLib.GlobalWorkerOptions.workerSrc = workerSrc; const loadingTask = pdfjsLib.getDocument({ url: pdfUrl }); const pdf = await loadingTask.promise; const page = await pdf.getPage(1); const viewport = page.getViewport({ scale: 1 }); const canvas = document.getElementById('pdf-canvas'); const ctx = canvas.getContext('2d'); canvas.width = viewport.width; canvas.height = viewport.height; await page.render({ canvasContext: ctx, viewport }).promise;

这段代码里,workerSrc必须单独指定,否则 PDF.js 会在全局找 worker 文件路径,脚手架打包时经常因为 worker 加载路径不对导致解析失败。用?url方式导入是 Vite 项目里比较稳的做法,Webpack 项目则可以用pdfjs-dist/build/pdf.worker.min.js直接配copy-webpack-plugin。getDocument({ url })支持传入文件路径或 ArrayBuffer,移动端场景建议先把 PDF 下载成 ArrayBuffer 再传给解析器,避免url在某些 WebView 里因为跨域或缓存策略被拦截。

2.3 多页浏览与缩放:用 CSS 层叠替代 iframe 的滚动

单页渲染跑通之后,多页浏览是下一步。PDF.js 官方自带 viewer,但那个是为桌面鼠标交互设计的,拖到移动端体验很别扭。常见的做法是自己维护一个容器,把每一页按顺序渲染成 canvas,垂直排列在滚动容器里,用手势滚动替代翻页按钮。

async function renderAllPages(pdf) { const container = document.getElementById('pdf-container'); for (let i = 1; i <= pdf.numPages; i++) { const page = await pdf.getPage(i); const viewport = page.getViewport({ scale: 1.5 }); const canvas = document.createElement('canvas'); canvas.className = 'pdf-page'; canvas.width = viewport.width; canvas.height = viewport.height; container.appendChild(canvas); await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise; } }

逐个 append 渲染后再挂载到 DOM,页面滚动是流畅的,但内存占用会随页数线性上涨。30 页以内的合同文档问题不大,超过 50 页的说明书建议改成懒加载,只渲染当前可视区域前后各两页,滚动时动态回收远端 canvas。缩放方面不需要自己写手势识别,给整个容器设置transform: scale()就能整体放大缩小,但要注意 canvas 的位图分辨率在放大时会模糊,常规做法是放大前重新计算 viewport scale 并重渲染,而不是直接缩放 canvas 的 CSS 尺寸。

3. 签名交互层:从手势采集到图像合成

3.1 签名画板的最小实现:pointer 事件统一处理触控与鼠标

签名功能的核心是一个可绘制的透明画板,叠加在 PDF 渲染层之上。这里不能再用touchstart/touchmove那套老写法,一个pointerdown/pointermove/pointerup就能同时覆盖触屏和桌面鼠标,省掉大半兼容代码。

const signCanvas = document.getElementById('sign-layer'); const signCtx = signCanvas.getContext('2d'); let drawing = false; signCanvas.addEventListener('pointerdown', (e) => { drawing = true; signCtx.beginPath(); signCtx.moveTo(e.offsetX, e.offsetY); }); signCanvas.addEventListener('pointermove', (e) => { if (!drawing) return; signCtx.lineTo(e.offsetX, e.offsetY); signCtx.stroke(); }); signCanvas.addEventListener('pointerup', () => { drawing = false; });

offsetX和offsetY在 canvas 自身坐标系里取坐标,不需要额外换算。但要注意pointermove事件的触发频率远高于触摸移动,画出来的线条会密集堆积,笔迹越画越粗。解决办法是把每次 stroke 的lineWidth固定,并在pointerdown时重置lineTo起始点。如果想要笔锋效果,可以根据相邻两个事件的时间间隔动态调整lineWidth,间隔越短线越粗,这是模拟真实手写感的基础逻辑。

3.2 签名位置与 PDF 页面坐标的换算

用户签名时,通常会先滚动到一个页面,再把签名拖到指定位置。签名画板是覆盖在整个 PDF 容器上的绝对定位层,而每个 PDF 页面 canvas 有自己的偏移位置。把签名坐标换算成 PDF 页面本身的坐标,是签名能正确回写的关键一步。

function getSignaturePositionInPage(pageElement, signCanvas, clientX, clientY) { const pageRect = pageElement.getBoundingClientRect(); const signRect = signCanvas.getBoundingClientRect(); return { pageX: clientX - pageRect.left, pageY: clientY - pageRect.top, }; }

这里的pageX/pageY是基于该页 canvas 左上角的偏移,配合viewport.scale就能映射回 PDF 原始坐标系。注意不要在容器滚动后直接用clientX和pageElement.offsetTop做加法,getBoundingClientRect已经扣掉了滚动偏移,数值是视口内的真实位置。签名完成后,把这个坐标随签名图片一起存储,后续回写或服务端盖章都靠它定位。

3.3 签名图的合成:把透明画板内容合并到 PDF 页面上

签名画板是一整块透明 canvas,用户完成签名后,需要把这块画板上的非透明区域裁剪出来,再贴回到对应的 PDF 页面 canvas 上。

function mergeSignatureToPage(signCanvas, pageCanvas, signBox) { const pageCtx = pageCanvas.getContext('2d'); const merged = document.createElement('canvas'); merged.width = pageCanvas.width; merged.height = pageCanvas.height; const mctx = merged.getContext('2d'); mctx.drawImage(pageCanvas, 0, 0); mctx.drawImage( signCanvas, signBox.x, signBox.y, signBox.width, signBox.height, signBox.pdfX, signBox.pdfY, signBox.width, signBox.height ); return merged.toDataURL('image/png'); }

这里有两个坑。第一,签名画板的分辨率和 PDF 页面的渲染分辨率很可能不一致,直接把 canvas 贴上去会尺寸失真。我一般会在mergeSignatureToPage里把宽度按两边的比例缩放,用drawImage的九个参数精确控制目标矩形。第二,toDataURL生成的 PNG 在 Android 低端机上偶尔会带透明通道的杂色边,导出前先调mctx.globalCompositeOperation = 'destination-over'铺一层白色底,后续传给后端生成图片 PDF 时底色更干净。

4. 移动端渲染避坑:白屏、模糊、内存与字体问题排查

这一章是血泪经验的集中区。H5 的 PDF 预览和签名,功能代码不难,难的是在不同机型、不同 WebView 里保持一致的呈现效果。以下四类问题是我在实际落地中遇到最多的,每条都按现象、原因、解决来写。

第一个常见问题是首屏白屏,页面加载完成但 canvas 区域一直空白。原因通常是 PDF.js 的 worker 文件加载失败,尤其在有 Content-Security-Policy 的页面里,workerSrc的路径被拦截。解决方法是把 worker 文件放到同域静态资源目录,并在初始化时加上pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/pdf.worker.min.js',不要依赖 CDN 或跨域地址。还有一个玄学现象:某些机型在弱网下点击预览会白屏,getDocument的 Promise 一直 pending。解决思路是加个timeout机制,超过 15 秒主动销毁 loadingTask 并提示用户,不要一直等。

第二个问题是渲染模糊,尤其放大后文字边缘发虚。原因在 2.3 节提过,Canvas 位图的物理分辨率是固定的,CSS 缩放放大的是像素点。解决方法是监听缩放结束事件,重新调用page.getViewport({ scale: newScale })并重渲染画布。注意重渲染时如果有签名图层,要先隐藏签名层再重绘底层,否则两层的toDataURL合成位置会错位。另一个容易忽略的是设备像素比,canvas.width = viewport.width * devicePixelRatio再加ctx.scale(devicePixelRatio, devicePixelRatio),清晰度能明显提升。

第三个问题是『签完名后导出,整个页面卡死』。原因定位在toDataURL这个操作上,合成大图时导出 PNG 编码本身非常耗时。解决方法是先计算签名区域的实际像素矩形,只对这个子区域调用getImageData和toDataURL,不要导出整个页面大图。另外把内存中的 canvas 对象在页面切换时显式置空,canvas.width = canvas.height = 0可以释放 GPU 内存,这是个不起眼但很有效的优化。

第四个问题是 PDF 内的字体渲染异常,预览时某些字符变成豆腐块或乱码。原因大多是 PDF 里嵌入了非标准字体子集,PDF.js 在部分 Android WebView 上解析这些字体时走了 fallback。解决方法是让后端在生成 PDF 时统一转成 PDF/A 标准,或者使用嵌入字体而非引用系统字体。如果文件名含中文,还要注意encodeURIComponent处理,否则getDocument({ url })在部分 WebView 会 404。

以上问题排查完,H5 预览的稳定性基本能保证。剩下的是一类容易被忽略的事:签名数据的持久化与回写,这也是真正交付时决定能不能用的关键。

5. 签名落地与回写:导出格式、坐标回传与服务端合成

5.1 三种导出方案对比及选择建议

  • 方案一:导出合成 PNG,前端把带签名的页导出为一张图片,回传业务系统。最大优势是流程简单、前端可控,适合合同截图或审核留档,失真的 PDF 排版信息其实已经不需要了。
  • 方案二:导出图片 PDF。前端生成完整带签名的 PDF 文件,jsPDF加canvas合成,回传给后端保存。这种做法兼容性最好,但jsPDF的图片压缩质量需要调,生成的 PDF 文件体积比原始文件大不少。
  • 方案三:签名坐标和签名图回传,后端在服务端把签名盖到原始 PDF 的指定位置。这是最干净的一种——前端不碰 PDF 文件本身,只把pageX/pageY坐标、签名 PNG、当前页码传给后端,后端用 PDF 库(比如 PDFBox 或 iText)完成盖章。我的建议是优先走方案三,签名受法律效力约束时,前端合成的图片容易被认为是伪造的,后端基于原始文件盖章可信度更高。

5.2 坐标精度的三个隐藏参数

服务端合成签名时,前端传过去的坐标不能直接用屏幕像素,必须换算回 PDF 原始坐标系。换算公式很简单:pdfY = pageY / viewport.scale / devicePixelRatio。但这里还有三个隐藏参数经常让人踩坑。第一个是页面的rotate属性,PDF 页面可能自带旋转角度,getViewport({ scale })返回的 viewport 已经包含了旋转,但后端无感知,要在传坐标时把页码对应的旋转信息一起带上。第二个是缩放基准,用户预览时可能改过scale,如果前端在签名后重新渲染过 canvas,pageY的参照系就变了,严谨的做法是在签名完成锚定坐标后立即读取当时的viewport.scale并随文件一起传送。第三个是签名图片的分辨率,建议按devicePixelRatio放大绘制,传到后端后按比例缩小。

5.3 服务端合成的一份伪代码参考

服务端合成部分不涉及前端,但为了对接顺畅给一段 Java 伪代码理清数据流:

public void signPdf(byte[] originPdf, SignRequest req) throws Exception { PDDocument doc = PDDocument.load(originPdf); PDPage page = doc.getPage(req.getPageIndex()); PDRectangle pageSize = page.getMediaBox(); float x = req.getPdfX(); // 前端传回的缩放后坐标 float y = pageSize.getHeight() - req.getPdfY(); // PDF 原点左下,Y 轴需翻转 BufferedImage signImg = ImageIO.read(new ByteArrayInputStream(req.getSignImage())); PDPageContentStream stream = new PDPageContentStream(doc, page, AppendMode.APPEND, true, true); stream.drawImage(PDImageXObject.createFromByteArray(doc, req.getSignImage(), "png"), x, y, signImg.getWidth() * 0.5f, signImg.getHeight() * 0.5f); stream.close(); doc.save(outputFile); doc.close(); }

这里最容易掉坑的是 Y 轴方向,PDF 的坐标原点在左下角,前端页面的原点在左上角,不做翻转签出来的名字绝对倒挂。另外AppendMode.APPEND表示在现有页面内容上叠加图层,不破坏原始 PDF 的文本层,这个参数要刻意保留。合成完以后,再导出时还有一个常见问题——部分 PDF 加密签过名的文件,PDDocument.load时会抛异常,需要后端预先把密码传到签名服务里,和原始需求是两码事,但联调时十次有八次卡在这。

6. 性能调优与验证:让预览滚动不卡、签名导出不变形

最后一章落到实际验证手段。预览性能的验证不要只看真机操作顺滑与否,打开 WebView 的调试面板直接看 GPU 内存曲线更靠谱。连续翻页 50 次后内存回收不掉,说明 canvas 实例在闭包里没释放;连续缩放 20 次后白屏,说明viewport.scale的缓存没有清理。前端排查用 Chrome DevTools 的 Performance 面板录制滚动事件,如果 long task 超过 200ms,优先检查当前页渲染是否被主线程阻塞。对于 PDF.js,2.x 版本的page.render任务分散度不错,但getDocument的解析阶段容易把主线程卡 1 秒以上,体验差的场景建议加个 loading 态遮罩,不要让用户觉得页面死了。

签名相关的验证要覆盖一个最容易出错的场景:用户在横向滚动浏览宽版 PDF(比如表格类文档)时签名,坐标是否还正确。我的习惯是写一个自检脚本,在签名完成后自动执行:把签名图重新加到原始 PDF 坐标对应的位置,再和前端预览渲染出来的页面对比,偏差超过 2px 就告警。这个工具代码很简单,但对规避「手机上看没问题,打印出来位置不对」这类事故非常有效。另外多端联调时,iPad 的 Safari 和 Android 的 Chrome 对 canvas 的toDataURL压缩策略不同,导出 PNG 文件大小差异能到几倍,可以统一在后端转码时归一化。

签名功能的技术方向做不做得深,其实取决于你的业务是否涉及合规。如果只是做个内部流转工具,前端合成 PNG 足够;如果拿给客户签字走履约流程,那服务端合成、审计日志、签名原文与签名人绑定这些都是硬性需求,前端代码要留出坐标与签名图上报的接口。我自己第二次做类似功能时,把坐标换算和合成逻辑全挪到后端,前端只当作采集终端,从那次以后因为签名位置不对的返工基本清零了。这篇按渲染、签名、合成、排错的顺序把整个链路捋了一遍,参数和代码都是可以直接搬进项目调试的,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询