简介:这份资源面向需要在网页端实现PDF在线预览与电子签名的前端开发者,尤其适合合同签署、表单填写等业务场景。它整合了HTML5 canvas、PDF.js渲染、Web Worker异步处理等关键技术,帮助读者理解从加载PDF到捕获手写笔迹、再将签名图像合并回文档的完整链路,并涉及HTTPS传输与身份验证等安全考量。压缩包共15个文件,约711KB,以10个JavaScript脚本为核心,配合2个CSS样式、1个HTML入口页面和1份示例PDF文档,另含少量辅助文件,结构紧凑便于快速运行与二次开发。目前已有419人学习下载。通过这份资源,读者可获得可运行的在线预览与签名示例,掌握PDF.js与canvas协同的实践思路,并了解前端渲染与后端处理两种签名合并方案的取舍,为搭建轻量级电子签署功能提供参考。
1. 从一次合同回传的翻车说起:h5 pdf在线预览 并支持签名到底解决什么问题
去年帮一个做房屋租赁的朋友看他们的签约流程,用户要在手机上打开合同 PDF,看完直接手写签名回传。他们最初的做法是让用户下载 PDF,用系统自带的看图工具打开,签完再截图上传。结果就是各种玄学问题:安卓机下载完找不到文件、iOS 截图签名糊成一团、后台收到的图片方向还是歪的。后来换成在 H5 页面里直接内嵌预览加签名,整个链路才顺下来。
这个项目要解决的就是这件事:在浏览器里把 PDF 渲染出来,翻页、缩放,然后在上面叠加一层画布让用户手写签名,最后把签名合成回 PDF 或者导出成图片回传。它适合做电子合同、审批单、报名表这类需要「看文档 + 签字确认」场景的前端和全栈开发者。核心关键词就是 pdf签名在线预览,难点不在预览本身,而在预览和签名两层怎么对齐坐标、怎么导出。
2. 技术选型拆解:pdf.js 渲染层与 canvas 签名层怎么配合
2.1 为什么是 pdf.js 而不是直接 iframe 塞 PDF
很多人第一反应是<iframe src="xxx.pdf">或者<embed>,这在桌面浏览器上确实能显示,但一到移动端就原形毕露:iOS Safari 对 iframe 内 PDF 的支持时好时坏,安卓各家 WebView 行为不一致,更关键的是你拿不到渲染后的 DOM,没法在上面精确定位签名区域。所以这个项目的渲染层用的是 pdf.js,它把 PDF 每一页解析成 canvas,页面结构完全由我们控制。
pdf.js 有两种引入方式,一种是官方提供的 viewer.html 整套界面,另一种是只用核心库自己画。这个项目走的是后者,因为要自定义签名层,viewer 那套 UI 反而碍事。常见做法是通过 CDN 或者 npm 引入pdfjs-dist,然后拿到getDocument方法。
// 引入 pdf.js 核心库,注意 worker 路径要配对,否则会报 fake worker 警告 import * as pdfjsLib from 'pdfjs-dist'; pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/pdf.worker.min.js'; // 加载 PDF 文档,data 可以是 URL、ArrayBuffer 或 Uint8Array const loadingTask = pdfjsLib.getDocument({ url: '/api/contract/123.pdf', // 如果后端返回的是二进制流,用 data 字段传 ArrayBuffer cMapUrl: '/static/cmaps/', // 中文 PDF 必须配,否则中文变方块 cMapPacked: true }); const pdfDoc = await loadingTask.promise; console.log('总页数', pdfDoc.numPages);这里有几个参数值得说清楚。cMapUrl是中文 PDF 的血泪经验,不配的话合同里的中文会渲染成空白或者乱码方块,很多人第一次跑起来以为是字体问题,其实是缺 cmap 映射表。workerSrc必须指向和主库版本一致的 worker 文件,版本不匹配会出现渲染到一半卡死的情况。getDocument返回的是个 Promise,拿到pdfDoc之后才能取单页。
2.2 单页渲染与高清屏适配
拿到pdfDoc之后,逐页渲染的逻辑是核心。这里最容易翻车的是清晰度问题:直接按 PDF 原始尺寸画到 canvas 上,在 Retina 屏上会糊。解决办法是按devicePixelRatio放大 canvas 的实际像素,再用 CSS 把显示尺寸压回去。
async function renderPage(pageNum, container) { const page = await pdfDoc.getPage(pageNum); // scale 是缩放系数,1.5 是兼顾清晰度和性能的常用值 const viewport = page.getViewport({ scale: 1.5 }); const dpr = window.devicePixelRatio || 1; const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); // 实际像素放大 dpr 倍,保证高清屏不糊 canvas.width = viewport.width * dpr; canvas.height = viewport.height * dpr; // CSS 尺寸保持逻辑尺寸 canvas.style.width = viewport.width + 'px'; canvas.style.height = viewport.height + 'px'; ctx.scale(dpr, dpr); await page.render({ canvasContext: ctx, viewport: viewport }).promise; container.appendChild(canvas); return { canvas, viewport }; }scale控制渲染精度,调太大会让大文档内存飙升,1.5 到 2 之间比较稳。dpr那两行是高清屏适配的关键,少了它安卓高端机和 iPhone 上字都是虚的。page.render返回的 Promise 必须 await,否则连续渲染多页时会出现 canvas 内容错乱,这是异步竞争导致的,很多人踩过。
2.3 签名层用 canvas 叠加而不是改 PDF 源文件
签名层单独用一个透明 canvas 盖在 PDF canvas 上面,而不是去修改 PDF 本身。这样做的好处是签名可以随时撤销重画,不用反复重新解析 PDF。两层用绝对定位叠在一起,父容器设position: relative。
<div class="pdf-wrapper" style="position: relative;"> <!-- PDF 渲染出来的 canvas 放这里 --> <div id="pdf-container"></div> <!-- 签名层,透明覆盖在上面 --> <canvas id="sign-canvas" style="position: absolute; top: 0; left: 0;"></canvas> </div>签名 canvas 的宽高要和 PDF canvas 的逻辑尺寸完全一致,否则坐标对不上。手写签名的绘制逻辑就是监听touchstart、touchmove、touchend,把触点坐标转成 canvas 坐标画线。移动端要记得preventDefault阻止页面滚动,不然签名的时候页面跟着乱跑。
3. 核心模块实战:从手写签名到导出回传的完整链路
3.1 手写签名的绘制与坐标换算
签名绘制的坑集中在坐标换算上。触摸事件的坐标是相对于视口的,而 canvas 有自己的坐标系,中间还隔着滚动和缩放。正确做法是用getBoundingClientRect拿到 canvas 在视口中的位置,再减掉。
const signCanvas = document.getElementById('sign-canvas'); const sctx = signCanvas.getContext('2d'); let drawing = false; // 把触摸/鼠标事件坐标转成 canvas 内部坐标 function getPos(e) { const rect = signCanvas.getBoundingClientRect(); const point = e.touches ? e.touches[0] : e; return { x: (point.clientX - rect.left) * (signCanvas.width / rect.width), y: (point.clientY - rect.top) * (signCanvas.height / rect.height) }; } signCanvas.addEventListener('touchstart', (e) => { e.preventDefault(); // 阻止页面滚动,否则签名时页面乱动 drawing = true; const pos = getPos(e); sctx.beginPath(); sctx.moveTo(pos.x, pos.y); }, { passive: false }); signCanvas.addEventListener('touchmove', (e) => { if (!drawing) return; e.preventDefault(); const pos = getPos(e); sctx.lineTo(pos.x, pos.y); sctx.strokeStyle = '#000'; sctx.lineWidth = 2; sctx.lineCap = 'round'; // 圆头笔触,签名更自然 sctx.stroke(); }, { passive: false }); signCanvas.addEventListener('touchend', () => { drawing = false; });getPos里那个signCanvas.width / rect.width的比值不能省,因为 canvas 实际像素和 CSS 显示尺寸可能不一样,直接拿 clientX 画会导致笔迹偏移。lineCap: 'round'让笔画连接处圆润,不然签名看起来像折线。passive: false配合preventDefault才能真的阻止滚动,只写 preventDefault 在部分浏览器里会被忽略。
3.2 签名合成回 PDF 的两种方案
签名画完之后要回传,有两种主流方案。第一种是把签名 canvas 转成图片,和 PDF 一起传给后端,由后端用 PDF 库合成。第二种是纯前端用 pdf-lib 把签名图片嵌入 PDF 再导出。这个项目两种都支持,前端导出适合即时预览,后端合成适合需要存档的场景。
前端合成用 pdf-lib,思路是加载原始 PDF,把签名图片画到指定页的指定位置。
import { PDFDocument } from 'pdf-lib'; async function mergeSignature(pdfBytes, signDataUrl, pageIndex, position) { const pdfDoc = await PDFDocument.load(pdfBytes); const pages = pdfDoc.getPages(); const targetPage = pages[pageIndex]; const { width, height } = targetPage.getSize(); // 把 canvas 的 dataURL 转成 pdf-lib 能识别的图片 const signImage = await pdfDoc.embedPng(signDataUrl); // position 用相对比例传,避免不同页面尺寸对不上 targetPage.drawImage(signImage, { x: width * position.xRatio, y: height * position.yRatio, width: signImage.width * 0.5, height: signImage.height * 0.5 }); const mergedBytes = await pdfDoc.save(); return mergedBytes; // Uint8Array,可直接下载或上传 }embedPng要求传入的是 PNG 格式的 dataURL,如果签名 canvas 导出的是 JPEG,要用embedJpg。坐标这里用相对比例xRatio、yRatio而不是绝对像素,是因为 PDF 页面尺寸各不相同,写死像素换个文档就错位。pdfDoc.save()返回的是 Uint8Array,可以直接new Blob下载,也可以塞进 FormData 上传。
3.3 导出图片回传的降级方案
有些后端接口只收图片,不收 PDF,这时候就得把「PDF 页面 + 签名」整体导出成一张图。做法是把 PDF canvas 和签名 canvas 画到同一个离屏 canvas 上。
function exportAsImage(pdfCanvas, signCanvas) { const out = document.createElement('canvas'); out.width = pdfCanvas.width; out.height = pdfCanvas.height; const octx = out.getContext('2d'); octx.drawImage(pdfCanvas, 0, 0); // 先画 PDF 内容 octx.drawImage(signCanvas, 0, 0); // 再叠签名 // toBlob 比 toDataURL 省内存,适合大图 return new Promise((resolve) => { out.toBlob((blob) => resolve(blob), 'image/png', 0.92); }); }顺序不能反,先 PDF 后签名,否则签名会被 PDF 盖住。toBlob的第三个参数是质量系数,PNG 下其实无效,但换成 JPEG 时 0.92 是个清晰度和体积的平衡点。大文档导出整页图内存占用高,常见做法是只导出签名所在的那一页,而不是全部页面。
4. 避坑与常见问题排查:那些让你怀疑人生的细节
4.1 中文显示成方块或空白
现象是 PDF 能渲染,但中文全是方块。原因是 pdf.js 默认不带中文字体映射表。解决办法是在getDocument里配cMapUrl指向 cmaps 目录,并且cMapPacked: true。cmaps 文件要从 pdf.js 发行包里拷到静态资源目录,路径写错一样不生效,打开控制台能看到 404。
4.2 签名笔迹和手指位置偏移
现象是手指画在 A 点,笔迹出现在 B 点,越往右下偏得越多。原因是 canvas 实际像素尺寸和 CSS 显示尺寸不一致,代码里直接用了 clientX。解决就是前面getPos里那个缩放比值,把 clientX 减掉 rect.left 之后再乘以canvas.width / rect.width。缩放页面或者横竖屏切换后要重新计算,因为 rect 变了。
4.3 移动端签名时页面跟着滚动
现象是手指在签名区画,整个页面上下滚。原因是 touchmove 默认行为没被阻止。解决是监听时加{ passive: false },并在回调里e.preventDefault()。只加 preventDefault 不加 passive: false,在 Chrome 里会被当成 passive 监听器忽略掉,这个坑很隐蔽。
4.4 多页 PDF 渲染内存暴涨甚至崩溃
现象是几十页的 PDF 一打开浏览器就卡死。原因是所有页的 canvas 同时存在,每个都是高清大图。解决办法是只渲染当前页和相邻页,翻页时销毁远离的 canvas,把canvas.width = 0释放内存。或者用虚拟滚动,只保留视口内的页面。
4.5 导出的 PDF 签名位置对不上
现象是预览时签名位置正确,导出后签名跑到别处。原因是预览用的坐标是屏幕坐标,导出用的应该是 PDF 页面坐标,两者比例不同。解决是签名位置统一用相对比例存储,导出时按目标页面的实际尺寸换算,不要存绝对像素。
5. 二次开发改造:把签名位置做成可配置的模板
真正落地到业务里,签名位置不能写死,不同合同签字栏位置不一样。我一般会把签名区域抽象成一个配置数组,每个元素描述在哪一页、什么相对位置、多大尺寸。这样换一份合同模板只需要改配置,不用动渲染代码。
// 签名区域配置:页码从 0 开始,坐标用相对比例 const signSlots = [ { page: 2, xRatio: 0.62, yRatio: 0.18, widthRatio: 0.22, heightRatio: 0.08, label: '甲方签字' }, { page: 2, xRatio: 0.62, yRatio: 0.30, widthRatio: 0.22, heightRatio: 0.08, label: '乙方签字' } ]; // 根据配置在签名层上画出提示框,引导用户签在正确位置 function drawSlotHints(signCtx, viewport, slots, currentPage) { slots.filter(s => s.page === currentPage).forEach(slot => { signCtx.save(); signCtx.strokeStyle = 'rgba(0, 120, 255, 0.6)'; signCtx.setLineDash([6, 4]); // 虚线框,不遮挡内容 signCtx.strokeRect( viewport.width * slot.xRatio, viewport.height * slot.yRatio, viewport.width * slot.widthRatio, viewport.height * slot.heightRatio ); signCtx.restore(); }); }配置里用相对比例而不是像素,是为了适配不同尺寸的 PDF 页面。drawSlotHints在签名层上画虚线框,用户一眼就知道该签哪里,比纯文字提示友好得多。setLineDash画虚线,save和restore保证样式不污染后续绘制。
验证改造是否成功,我习惯用三份不同尺寸的合同各跑一遍:A4 纵向、A4 横向、以及一份扫描件。重点看签名框是否都落在签字栏上、导出后位置是否一致。扫描件因为本身是图片型 PDF,还要确认 pdf.js 能正常渲染,有些扫描件没嵌字体,渲染出来是空白,这种就得走后端转图片的方案。
还有一个实用技巧是把签名数据存成矢量路径而不是位图。位图放大后会糊,矢量路径导出时按目标尺寸重新描边,清晰度无损。做法是记录每次 touchmove 的点序列,导出时用这些点重新画。代价是数据量大一些,但合同这种场景清晰度优先。
从那以后我每次接这类需求,都强制先把「预览清晰度、签名坐标、导出位置」这三件事在真机上各验一遍再往下写业务逻辑,因为这三处一旦返工,牵连的是整个链路。希望帮到你。
本文还有配套的精品资源,点击获取