简介:这是一份面向前端开发者的H5 PDF在线预览与电子签名实现方案,适用于合同签署、表单填写等需要网页内直接查看并手写签名的业务场景。资源包共15个文件,约711KB,以10个JavaScript脚本为核心,配合2个CSS样式、1个HTML页面与1个示例PDF文档,涵盖PDF渲染、手势触控、二维码生成等模块,结构紧凑便于快速集成。技术实现上,预览部分基于PDF.js在Canvas中渲染文档,签名部分借助HTML5 Canvas捕获鼠标或触笔笔迹并转为图像,再结合PDF处理库将签名合并回原文档,同时涉及Web Worker避免主线程阻塞及HTTPS传输等安全考量。已有419人学习下载,读者可从中获取完整的页面结构、脚本调用方式与签名落位思路,适合需要快速搭建在线预览签名功能的中级前端开发者参考。
1. 从一次合同回传的翻车说起:H5 里预览 PDF 并签名的真实需求
去年帮一个做企业服务的团队改后台,对方运营提了个需求:销售在手机上打开客户发来的合同 PDF,看完直接在末尾手写签名,点确认回传。听起来简单,结果第一版用 iframe 直接嵌 PDF 链接,安卓微信里白屏,iOS Safari 里能看但手指一划就整页缩放,签名板叠上去坐标全乱。这就是典型的「预览和签名各做各的,最后对不上」。
H5 PDF 在线预览并支持签名,本质是两件事拼在一起:一是把 PDF 渲染到 canvas 或 DOM 上,让用户能翻页、缩放;二是拿到用户在某一页上的手写笔迹,把它转成坐标数据,最终合成到 PDF 文件里。它解决的是「不装 App、不依赖桌面软件,在浏览器里完成看和签」的场景,适合电子合同、审批单、回执确认这类业务。下面按我实际拆过的路径,从渲染选型讲到签名合成,把参数和坑都摊开。
2. 预览方案选型:pdf.js 渲染到 canvas 还是直接塞 iframe
2.1 为什么不能只靠浏览器原生 PDF 插件
浏览器自带的 PDF 查看器(Chrome 的 pdfium、Safari 的 PDFKit)确实能显示,但它是黑匣子:你拿不到当前页码、缩放比例、滚动位置,也没法在上面精确叠加一个签名层。移动端更麻烦,微信内置浏览器对原生 PDF 的支持时好时坏,经常直接触发下载而不是预览。所以只要涉及「在 PDF 上做交互」,就必须自己接管渲染。
常见做法是用 Mozilla 的 pdf.js,它把 PDF 解析成页面对象,每页可以渲染到独立的 canvas 上。这样页码、缩放、坐标全在你手里,签名层想放哪就放哪。代价是要引入 pdf.js 的库文件,并且处理 worker 路径和跨域。
2.2 pdf.js 的最小可用渲染流程
先看一段能跑起来的核心代码,把第一页渲染到 canvas:
// 引入 pdf.js 后,pdfjsLib 是全局对象 const loadingTask = pdfjsLib.getDocument({ url: '/files/contract.pdf', // 关键参数:cMap 用于中文等复杂字体,不配会乱码 cMapUrl: '/pdfjs/cmaps/', cMapPacked: true, }); loadingTask.promise.then((pdf) => { // 取第一页 return pdf.getPage(1); }).then((page) => { const scale = 1.5; // 缩放比例,移动端建议 1.2~2.0 const viewport = page.getViewport({ scale }); const canvas = document.getElementById('pdf-canvas'); const ctx = canvas.getContext('2d'); // canvas 尺寸必须按 viewport 设置,否则模糊或裁切 canvas.width = viewport.width; canvas.height = viewport.height; const renderTask = page.render({ canvasContext: ctx, viewport: viewport, }); return renderTask.promise; }).then(() => { console.log('第一页渲染完成'); }).catch((err) => { console.error('渲染失败', err); });逻辑说明:getDocument负责加载和解析,getPage拿到单页对象,getViewport根据缩放算出画布尺寸,render把页面画到 canvas。参数上,scale直接决定清晰度和内存占用,移动端别超过 2.0,否则大文档容易卡死;cMapUrl指向 pdf.js 包里的 cmaps 目录,中文合同不配这个大概率显示方块。
2.3 多页与翻页控制
单页渲染只是起点,合同通常十几页。我一般维护一个currentPage变量,翻页时清空 canvas 再渲染目标页:
let currentPage = 1; let pdfDoc = null; function renderPage(num) { pdfDoc.getPage(num).then((page) => { const viewport = page.getViewport({ scale: 1.5 }); const canvas = document.getElementById('pdf-canvas'); const ctx = canvas.getContext('2d'); canvas.width = viewport.width; canvas.height = viewport.height; page.render({ canvasContext: ctx, viewport }).promise.then(() => { currentPage = num; document.getElementById('page-info').textContent = num + ' / ' + pdfDoc.numPages; }); }); } // 上一页 / 下一页按钮绑定 document.getElementById('prev').onclick = () => { if (currentPage <= 1) return; renderPage(currentPage - 1); }; document.getElementById('next').onclick = () => { if (currentPage >= pdfDoc.numPages) return; renderPage(currentPage + 1); };这里有个容易忽略的点:每次渲染前必须重设canvas.width和canvas.height,否则 pdf.js 会在旧画布上叠加,出现重影。翻页边界要自己判断,pdf.js 不会帮你拦。
2.4 iframe 方案什么时候还能用
如果需求只是「看一眼」,不需要签名和交互,iframe 嵌 PDF 链接仍然是最省事的,代码一行:
<iframe src="/files/contract.pdf" width="100%" height="600px"></iframe>但它和签名需求天然冲突:你无法在 iframe 内部定位坐标,跨域时连内容都读不到。所以只要标题里带了「签名」,iframe 就只能当降级方案,不能当主方案。
3. 签名层实现:canvas 手写、坐标换算与笔迹数据
3.1 签名 canvas 的初始化与绘制
签名层是叠在 PDF canvas 上面的另一个 canvas,用绝对定位覆盖。核心是监听 touch 和 mouse 事件,把轨迹画出来:
const signCanvas = document.getElementById('sign-canvas'); const sctx = signCanvas.getContext('2d'); let drawing = false; let lastPoint = null; // 初始化画笔 sctx.lineWidth = 3; sctx.lineCap = 'round'; sctx.lineJoin = 'round'; sctx.strokeStyle = '#000'; function getPos(e) { const rect = signCanvas.getBoundingClientRect(); const touch = e.touches ? e.touches[0] : e; return { x: touch.clientX - rect.left, y: touch.clientY - rect.top, }; } signCanvas.addEventListener('touchstart', (e) => { e.preventDefault(); // 阻止页面滚动,否则签名时页面乱跑 drawing = true; lastPoint = getPos(e); }); signCanvas.addEventListener('touchmove', (e) => { e.preventDefault(); if (!drawing) return; const p = getPos(e); sctx.beginPath(); sctx.moveTo(lastPoint.x, lastPoint.y); sctx.lineTo(p.x, p.y); sctx.stroke(); lastPoint = p; }); signCanvas.addEventListener('touchend', () => { drawing = false; });逻辑说明:getBoundingClientRect把屏幕坐标转成 canvas 内部坐标,这是坐标不漂移的关键。touchstart里必须preventDefault,不然手指一划页面跟着滚,签名直接断线。lineCap和lineJoin设成 round,笔迹才圆润,默认的 miter 会有尖角。
3.2 坐标换算:屏幕像素到 PDF 点
签名最终要落到 PDF 上,而 PDF 的坐标单位是「点」(1 点 = 1/72 英寸),原点在左下角,和 canvas 的左上角原点相反。所以存笔迹时不能直接存 canvas 像素,要按比例换算:
// 假设 PDF 页面原始宽度是 pdfWidth(点),canvas 显示宽度是 canvasWidth(像素) const ratio = pdfWidth / canvasWidth; function toPdfPoint(canvasPoint) { return { x: canvasPoint.x * ratio, // y 轴翻转:canvas 从上往下,PDF 从下往上 y: pdfHeight - canvasPoint.y * ratio, }; }参数说明:pdfWidth和pdfHeight从page.getViewport({ scale: 1 })拿,那是 PDF 的原始尺寸。ratio是显示缩放和原始尺寸的桥梁。y 轴翻转是最容易翻车的地方,我第一次做时签名整体上下颠倒,排查了半天才发现是原点方向问题。
3.3 笔迹数据的存储结构
不要只存一张图片,要存可重绘的轨迹点,这样缩放、重签、撤销都好做:
// 每条笔迹是一个点数组 let strokes = []; function startStroke(point) { strokes.push([point]); } function addPoint(point) { strokes[strokes.length - 1].push(point); } // 重绘所有笔迹(比如窗口 resize 后) function redraw() { sctx.clearRect(0, 0, signCanvas.width, signCanvas.height); strokes.forEach((stroke) => { sctx.beginPath(); stroke.forEach((p, i) => { if (i === 0) sctx.moveTo(p.x, p.y); else sctx.lineTo(p.x, p.y); }); sctx.stroke(); }); }存点而不是存图,好处是撤销只需strokes.pop(),重绘也快。点太密时可以按距离抽稀,比如两点距离小于 2 像素就丢弃,能显著减小数据量。
3.4 撤销与清空
撤销就是弹出最后一条笔迹再重绘,清空就是重置数组:
document.getElementById('undo').onclick = () => { strokes.pop(); redraw(); }; document.getElementById('clear').onclick = () => { strokes = []; redraw(); };这两个操作都依赖redraw,所以笔迹数据必须完整保留,不能画完就丢。我见过有人只往 canvas 上画不存数据,结果撤销功能根本没法实现,只能整页重来。
4. 合成与导出:把签名烧进 PDF 的两种路径
4.1 前端合成:pdf-lib 叠加图片
签名画完后,最直接的做法是把签名 canvas 导出成 PNG,再用 pdf-lib 贴到指定页的指定位置:
import { PDFDocument } from 'pdf-lib'; async function mergeSignature(pdfBytes, signDataUrl, pageIndex, pos) { const pdfDoc = await PDFDocument.load(pdfBytes); const pages = pdfDoc.getPages(); const page = pages[pageIndex]; // 把 dataURL 转成图片嵌入 const pngImage = await pdfDoc.embedPng(signDataUrl); const { width, height } = pngImage.scale(0.5); // 缩放签名大小 page.drawImage(pngImage, { x: pos.x, y: pos.y, width: width, height: height, }); const mergedBytes = await pdfDoc.save(); return mergedBytes; }逻辑说明:PDFDocument.load读入原始 PDF 字节,embedPng把签名图嵌进去,drawImage定位。参数pos.x、pos.y用的是 PDF 坐标(左下角原点),所以要用 3.2 节换算后的值。scale(0.5)控制签名显示大小,具体倍数看合同留白。
4.2 后端合成:前端只传坐标和图片
前端合成的问题是:大 PDF 在手机上加载和保存都吃内存,而且原始文件暴露给了客户端。更稳的做法是前端只传「签名图片 + 页码 + 坐标」,后端用 Python 的 pypdf 或 Java 的 PDFBox 合成:
# 后端示例(Python + pypdf + reportlab 生成签名图) from pypdf import PdfReader, PdfWriter from reportlab.pdfgen import canvas from reportlab.lib.utils import ImageReader import io def sign_pdf(src_path, sign_img_path, page_num, x, y, out_path): reader = PdfReader(src_path) writer = PdfWriter() # 生成一个只含签名的透明覆盖层 packet = io.BytesIO() can = canvas.Canvas(packet) can.drawImage(ImageReader(sign_img_path), x, y, width=120, height=60, mask='auto') can.save() packet.seek(0) overlay = PdfReader(packet) for i, page in enumerate(reader.pages): if i == page_num: page.merge_page(overlay.pages[0]) writer.add_page(page) with open(out_path, 'wb') as f: writer.write(f)参数说明:x、y是签名左下角在 PDF 页面的坐标,width、height控制签名尺寸,mask='auto'让 PNG 透明背景生效。merge_page把覆盖层合到目标页,其他页原样保留。这种方案前端压力小,也便于留痕审计。
4.3 两种路径怎么选
| 维度 | 前端 pdf-lib | 后端合成 |
|---|---|---|
| 实现速度 | 快,纯 JS | 需接口联调 |
| 大文件表现 | 手机易卡 | 稳定 |
| 安全性 | 原文件下发 | 原文件不出服务端 |
| 适用场景 | 小合同、Demo | 正式业务、批量 |
我一般建议:验证阶段用前端 pdf-lib 快速跑通,正式上线切后端合成。两者坐标体系一致,切换成本主要在接口。
5. 避坑与排查:签名错位、中文乱码、移动端手势冲突
5.1 签名位置整体偏移
现象:签名贴上去后整体偏左或偏上,和预期位置差几十像素。原因:canvas 显示尺寸和 PDF 原始尺寸的比例没算对,或者忽略了 canvas 的 CSS 尺寸和实际像素尺寸不一致。解决:统一用getBoundingClientRect拿显示尺寸,用getViewport({ scale: 1 })拿原始尺寸,两者相除得到比例,所有坐标都过一遍这个比例。
5.2 中文合同显示方块
现象:PDF 里中文全是方块或空白。原因:pdf.js 默认不带中文字体映射,缺少 cmaps。解决:下载 pdf.js 发行包里的cmaps目录,配置cMapUrl指向它,并设cMapPacked: true。这一步漏了,英文文档正常,中文必挂。
5.3 移动端签名时页面跟着滚
现象:手指在签名区滑动,页面上下滚动,笔迹断断续续。原因:touchmove 默认行为触发页面滚动。解决:在touchstart和touchmove里都调e.preventDefault(),同时给签名 canvas 加 CSStouch-action: none,双保险。
5.4 翻页后签名消失或错页
现象:签完第一页翻到第二页再翻回来,签名没了,或者签到了错误的页。原因:笔迹数据没和页码绑定,或者翻页时清空了 canvas 但没清空 strokes。解决:用{ page: 1, strokes: [...] }的结构按页存笔迹,翻页时根据当前页取对应数据重绘,别用全局单一数组。
5.5 导出 PDF 体积暴涨
现象:合成后文件从几百 KB 变成几 MB。原因:签名 PNG 分辨率过高,或者 pdf-lib 重复嵌入图片。解决:导出签名图时限制尺寸,比如最大宽度 400 像素,用canvas.toDataURL('image/png')前先缩小;同一张签名图只 embed 一次,多页复用同一个 image 对象。
6. 进阶技巧:用坐标归一化让签名适配任意缩放
前面所有坐标换算都依赖一个前提:你知道当前显示比例。但用户可能双指缩放、旋转屏幕、切换横竖屏,比例随时在变。如果每次都重新算,代码会散得到处都是。我的习惯是把签名坐标归一化到 0~1 区间,存相对位置,渲染时再乘当前尺寸。
具体做法:存笔迹时,x 除以 canvas 显示宽度,y 除以显示高度,得到 0~1 的相对值。重绘或合成时,用相对值乘以目标尺寸。这样无论页面怎么缩放,签名永远落在合同的同一个相对位置。
// 存归一化坐标 function normalize(point, w, h) { return { x: point.x / w, y: point.y / h }; } // 还原到目标尺寸 function denormalize(point, w, h) { return { x: point.x * w, y: point.y * h }; }配合一个 resize 监听,屏幕变化时用归一化数据重绘,签名不会跑偏:
window.addEventListener('resize', () => { const rect = signCanvas.getBoundingClientRect(); signCanvas.width = rect.width; signCanvas.height = rect.height; redrawWithNormalized(strokes, rect.width, rect.height); });验证方法也简单:签完后把手机横过来再竖回去,看签名是否还在原位;再导出 PDF,用桌面阅读器打开,对比签名和合同文字的相對位置。如果两边一致,说明归一化链路是通的。
还有一个细节:合成到 PDF 时,归一化坐标要乘的是 PDF 原始尺寸(点),不是屏幕尺寸。所以链路是「屏幕坐标 → 归一化 → PDF 点坐标」,中间别混用。我踩过一次坑,归一化后直接乘了屏幕宽度去合成,结果签名跑到页面外,排查时一度怀疑是 pdf-lib 的 bug,其实是自己单位搞混了。
从那以后我每次做签名合成,都强制走一遍「归一化 → 目标尺寸 → 导出验证」三步,不管需求多简单都不省。希望帮到你。
本文还有配套的精品资源,点击获取