ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

H5 PDF在线预览与手写签名:从pdf.js渲染到pdf-lib坐标换算全攻略

H5 PDF在线预览与手写签名:从pdf.js渲染到pdf-lib坐标换算全攻略 简介面向网页前端开发者的一份可直接运行的H5 PDF在线预览与电子签名示例解决合同签署、表单填写等场景中用户需下载后再签名的痛点。压缩包共15个文件体积约711KB核心为10个JS脚本PDF渲染、签名画布操作及移动端适配库与2个CSS样式文件另含1个PDF示例文件及1个HTML入口页面结构精简便于快速定位逻辑。实现上以PDF.js/PDFH5完成文档渲染借助Canvas捕获用户笔迹并将签名合成至PDF页面涵盖前端交互与安全传输思路适合需要快速集成或学习移动端签名方案的中级前端开发者。资源包内提供可直接运行的HTML示例与完整前端依赖无需复杂配置即可本地打开验证同时展示了笔迹采集、图片化签名与PDF页面合并的处理路径并可扩展到Puppeteer或PDFKit等服务端落地方案。目前已有419人学习浏览文件类型与依赖说明清晰可作为二次开发基座或教学参考。1. 先说结论H5 pdf在线预览并支持签名真正的坑在签名怎么落回 PDF“H5 pdf在线预览 并支持签名”是个表面普通、做起来全是颗粒度的需求。产品把一份合同 PDF 丢到手机里说要能看、能翻页、能在落款处手写签名——你以为预览用 iframe 就够了真到了线上才发现微信里的 WebView 根本不给你 PDF 插件iOS 还动不动跳去系统阅读器签名画完要合回 PDF屏幕坐标和 PDF 坐标又隔着一套翻转逻辑。这篇按做项目的顺序拆开讲预览选型怎么最稳签名怎么采集和导出最后是合成回 PDF 的坐标换算和常见翻车点。适合在 H5、小程序 WebView 里做合同签署或确认流程的开发者也适合想判断“纯前端能不能搞定”的接单同学。2. 预览选型iframe、object 在 H5 里靠不住pdf.js 最小实例怎么跑通2.1 三种原生预览的分工桌面能用、H5 不可控先说一个很多人踩过的直觉陷阱桌面 Chrome 里iframe srcxxx.pdf能直接看到自带的 PDF 阅读器于是把这个方案平移到 H5结果在微信里打开一片白。原因不是代码写错了是移动端 WebView 根本没给你可靠的 PDF 插件。常见的三种“白嫖”预览方式各有脾气。iframe 在 PC Chrome 上会进入内置 viewer带工具栏和下载按钮这套 UI 你控制不了iOS Safari 点开 PDF 链接会直接跳转到系统阅读器页面上下文丢失用户看完点返回才能回你的 H5安卓微信里 object、embed 标签经常渲染不出来返回空白。还有一种想法是用img直接显示 PDF这里明确说img 标签不支持 PDF只有 Canvas 层渲染才能做到跨端一致。所以 H5 场景下我一般直接放弃原生预览方案统一走 pdf.js。理由不是它功能最全而是它把 PDF 当数据渲染成 Canvas视觉、交互、签名叠加层全都在你手里微信、iOS Safari、安卓 WebView 行为一致。这是后面所有签名逻辑能成立的前提。2.2 最小可运行的 pdf.js 渲染代码与 worker 配置用 Vue 3 的组合式写法渲染一份 PDF 的第一页最小代码是这样template div classpdf-page refwrapRef canvas refcanvasRef/canvas /div /template script setup import { ref, onMounted } from vue; import * as pdfjsLib from pdfjs-dist; // worker 的加载路径是这道工序的第一个坑后面细说 pdfjsLib.GlobalWorkerOptions.workerSrc /pdf.worker.min.js; const canvasRef ref(null); const wrapRef ref(null); async function previewPdf(url) { const doc await pdfjsLib.getDocument(url).promise; const page await doc.getPage(1); // 先按 1.0 scale 取原始尺寸再反推自适应缩放 const baseViewport page.getViewport({ scale: 1 }); const wrapWidth wrapRef.value.clientWidth; const scale Math.min(1.5, wrapWidth / baseViewport.width); const viewport page.getViewport({ scale }); const canvas canvasRef.value; canvas.width viewport.width; canvas.height viewport.height; canvas.style.width viewport.width px; await page.render({ canvasContext: canvas.getContext(2d), viewport, }).promise; // doc 不在这里释放交给组件卸载时统一 destroy currentDoc doc; } onMounted(() previewPdf(/contract.pdf)); /script这里有三个参数值得你盯着看。scale控制渲染清晰度我建议封顶 1.5超过这个值在 2K 屏上肉眼提升有限内存占用却翻倍baseViewport拿到的原始宽度是 PDF 点除以容器宽度得到适配比例这样小屏显示完整页、大屏不过度放大。workerSrc指向的 pdf.worker.min.js 需要从pdfjs-dist/build/拷贝到 public 目录Vite 项目放 public 根路径即可直接引用 CDN 有时会撞上跨域限制浏览器报Failed to construct Worker本地拷贝最省心。canvas.width赋值之后画布内容会被清空所以必须先设尺寸、再调render这个顺序别反。2.3 分页渲染与内存释放移动端能不能看完第 20 页预览单页很容易PDF 一多就露馅。最蠢的写法是拿到doc.numPages后循环把每一页全渲染成 canvas——20 页的合同在低端安卓机上大概率白屏或卡死这是把桌面端思维带到了移动端。常见做法是滚动到哪页渲染哪页只保留当前页前后 23 个 canvas其余移除同时对 page 对象调用cleanup()释放临时资源。骨架大概是async function renderVisiblePage(doc, pageNo, scale, container) { const page await doc.getPage(pageNo); const viewport page.getViewport({ scale }); const canvas document.createElement(canvas); canvas.width viewport.width; canvas.height viewport.height; container.appendChild(canvas); await page.render({ canvasContext: canvas.getContext(2d), viewport, }).promise; // 渲染完成后及时释放该页的临时对象 page.cleanup(); }滚动监听里再用requestAnimationFrame节流不要在 scroll 回调里直接渲染。如果你还要做“js 中 pdf 缩略图”这类侧边栏导航额外开一个小 scale 的 canvas 就行记得和主视图共用一个 pdf.js 实例别每个缩略图都getDocument一次。离开页面时一定要把currentDoc.destroy()调掉否则 WebView 内存只涨不降这也是“预览两次就卡死”的元凶之一。3. 签名采集Canvas 触摸留痕、笔迹平滑与高清透明导出3.1 初始化签名画布devicePixelRatio 与被动事件绑定签名区域本质是块透明 canvas用户手指划过留下墨迹。关键在两点一是画布物理尺寸必须按设备像素比放大否则 iPhone 上导出必然发虚二是触摸事件的 passive 属性要关掉不然preventDefault拦不住页面滚动签个字页面跟着滚笔迹全断。初始化函数const PAD document.querySelector(#signPad); const CTX PAD.getContext(2d); const DPR window.devicePixelRatio || 1; const RECT PAD.getBoundingClientRect(); // 物理像素 逻辑像素 × DPR画笔坐标仍按逻辑像素走 PAD.width RECT.width * DPR; PAD.height RECT.height * DPR; CTX.scale(DPR, DPR); CTX.lineWidth 2.5; CTX.lineCap round; CTX.lineJoin round; CTX.strokeStyle #1a1a1a; let drawing false; function getPoint(e) { const src e.touches ? e.touches[0] : e; return { x: src.clientX - RECT.left, y: src.clientY - RECT.top, }; } PAD.addEventListener(touchstart, (e) { e.preventDefault(); drawing true; const p getPoint(e); CTX.beginPath(); CTX.moveTo(p.x, p.y); }, { passive: false }); PAD.addEventListener(touchmove, (e) { if (!drawing) return; e.preventDefault(); const p getPoint(e); CTX.lineTo(p.x, p.y); CTX.stroke(); }, { passive: false }); PAD.addEventListener(touchend, () { drawing false; });CTX.scale(DPR, DPR)这行是精髓它让后续moveTo(x, y)里的坐标直接使用 CSS 逻辑像素和触摸事件返回的 clientX 对齐不需要手动乘 DPR。桌面调试时再补一套 mousedown / mousemove / mouseup逻辑完全一样只是取点来源从e.touches[0]换成e。还要注意 CSS 里给签名 canvas 加上touch-action: none否则部分安卓机即使加了 passive: false 依然会在滑动时触发浏览器手势。3.2 笔迹平滑用中点贝塞尔代替直连线段直接lineTo连点结果是折线速度快一点就能看出棱角。要做出接近真实钢笔的效果常见做法是二次贝塞尔曲线配中点每次移动不直接画到当前点而是画到上一段中点让曲线在点之间圆滑过渡。let last null; function strokeTo(to) { if (!last) { last to; return; } const mid { x: (last.x to.x) / 2, y: (last.y to.y) / 2, }; CTX.quadraticCurveTo(last.x, last.y, mid.x, mid.y); CTX.stroke(); last to; }touchend时补一笔把 last 到终点连起来否则最后一段路径不会闭合PAD.addEventListener(touchend, (e) { drawing false; if (last) { const p getPoint(e.changedTouches[0]); CTX.lineTo(p.x, p.y); CTX.stroke(); } last null; });笔宽建议 2.53太细的签名在 PDF 打印出来会显得没底气。strokeStyle 用接近黑的深色不要用纯黑扫描打印时更自然。3.3 导出签名图透明裁剪、逻辑像素换算与体积控制手写在画布上的墨迹只占一小块区域直接导出整块 canvas白底或透明区域太大。这里先做“逻辑像素还原”再做“透明区域裁剪”。逻辑像素还原是为了摆脱 DPR 对输出尺寸的干扰——我们最终希望拿到的是 CSS 像素为单位的 PNG尺寸和显示在页面上的签名区块一致。function trimTransparent(sourceCanvas) { const ctx sourceCanvas.getContext(2d); const { width: w, height: h } sourceCanvas; const imageData ctx.getImageData(0, 0, w, h); const data imageData.data; let minX w, minY h, maxX -1, maxY -1; // 步长 2 采样足够定位墨迹区域边缘最多差 1px for (let y 0; y h; y 2) { for (let x 0; x w; x 2) { const alpha data[(y * w x) * 4 3]; if (alpha 0) { if (x minX) minX x; if (x maxX) maxX x; if (y minY) minY y; if (y maxY) maxY y; } } } if (maxX 0) return null; // 画布全空 const pad 8; minX Math.max(0, minX - pad); minY Math.max(0, minY - pad); maxX Math.min(w - 1, maxX pad); maxY Math.min(h - 1, maxY pad); const out document.createElement(canvas); out.width maxX - minX 1; out.height maxY - minY 1; out.getContext(2d).drawImage( sourceCanvas, minX, minY, out.width, out.height, 0, 0, out.width, out.height ); return out; } function exportSignPng(canvas, dpr) { const logical document.createElement(canvas); logical.width canvas.width / dpr; logical.height canvas.height / dpr; logical.getContext(2d).drawImage(canvas, 0, 0, logical.width, logical.height); const trimmed trimTransparent(logical); if (!trimmed) return null; return trimmed.toDataURL(image/png); }导出后检查和体积控制正常手写签名的 PNG 应在 520KB 以内如果超过 50KB大概率是透明区域没裁干净或者画布上残留了肉眼看不见的低透明度噪点。签名图最终会合成到 PDF 里PNG 透明底是必需的别用 JPEG否则白色底会变成一块白斑盖住合同文字。4. 签名合成pdf-lib 把图片画到 PDF 页面坐标换算是绕不开的那道坎4.1 PDF 坐标系的左下原点与屏幕坐标的翻转换算签名图拿到之后下一个问题是“画到 PDF 的哪个位置”。这里有个新手必翻车的坐标体系差异屏幕坐标原点在左上角Y 轴向下PDF 页面坐标原点在左下角Y 轴向上。pdf.js 渲染时已经帮你做了翻转但你要把签名坐标传回 PDF就要自己再翻一次。pdf.js 的getViewport({ scale })返回的 viewport 自带宽高单位为 PDF 点canvas 的显示尺寸等于viewport.width / viewport.height。假设签名框完全覆盖在 PDF canvas 之上签名点在 canvas 上的位置是(screenX, screenY)换算到 PDF 坐标function screenPointToPdfPoint(clientX, clientY, canvas, viewport) { const rect canvas.getBoundingClientRect(); const screenX clientX - rect.left; const screenY clientY - rect.top; // viewport.scale 把 PDF 点放大成屏幕像素反转即得 PDF 点 const pdfX screenX / viewport.scale; const pdfY (viewport.height - screenY) / viewport.scale; return { x: pdfX, y: pdfY }; }(viewport.height - screenY)是翻转动作屏幕底部对应 PDF 的 Y0屏幕顶部对应 PDF 的 Y 最大值。这里不需要额外乘 DPI 换算系数因为我们用的是渲染后的 CSS 像素viewport.scale 已经承担了从 PDF 点到屏幕像素的映射。签名图的宽度同理CSS 像素宽度除以 scale得到 PDF 点宽度再按图片原始宽高比算出高度。如果你在原 PDF 渲染时做了 rotation签名层和 PDF 页都要用同一个getViewport({ scale, rotation })结果否则坐标会整体偏 90 度。4.2 把签名 PNG 合成到 PDFpdf-lib 最小代码合成用的是 pdf-lib相比直接用 pdf.js 的 annotation API它把“打开、画图、保存”封装得直白很多。import { PDFDocument } from pdf-lib; function dataUrlToUint8Array(dataUrl) { const base64 dataUrl.split(,)[1]; const bin atob(base64); const arr new Uint8Array(bin.length); for (let i 0; i bin.length; i) { arr[i] bin.charCodeAt(i); } return arr; } async function applySignature(pdfArrayBuffer, sigDataUrl, pdfPoint, imgWidthPt) { const doc await PDFDocument.load(pdfArrayBuffer, { ignoreEncryption: true }); const page doc.getPages()[0]; // embedPng 只接受二进制dataURL 必须先转 Uint8Array const png await doc.embedPng(dataUrlToUint8Array(sigDataUrl)); const imgHeightPt (png.height / png.width) * imgWidthPt; // x/y 是图片左下角在 PDF 页面上的位置 page.drawImage(png, { x: pdfPoint.x, y: pdfPoint.y, width: imgWidthPt, height: imgHeightPt, }); const bytes await doc.save(); return bytes; }imgWidthPt怎么定我一般取页面宽度的 0.3 倍作为签名区宽度上限比如 A4 横向 595 点签名宽 180 点左右既能看清又不压正文。如果签名位置恰好有合同预置的签字栏先按 4.1 的公式换算签字栏位置再把签名图宽度压到签字栏宽度的 90%。保存后的bytes是新的 PDF 二进制交付时转 Blob 触发下载const blob new Blob([bytes], { type: application/pdf }); const link document.createElement(a); link.href URL.createObjectURL(blob); link.download signed.pdf; link.click();iOS Safari 对a[download]支持不稳定常见做法是改成window.open(link.href)在新标签页打开用户自行用系统分享保存如果业务有后端直接把这个 Blob 上传由后端落库更稳。4.3 多点签名与多页签名一次 save 与边签边存的取舍签一份 3 页合同可能每页都有需要签字的地方。不要把每次签名都单独“load PDF → 画图 → save”这样不仅慢每 save 一次 pdf-lib 会重写整个文档多次叠加容易让文件体积膨胀。我一般在前端维护一个签名数组把所有签名动作攒着// 每次签完只往数组里追加不急着碰原 PDF const signatures [ { pageIndex: 0, pdfX: 120, pdfY: 400, widthPt: 180, dataUrl: data:image/png;base64,... }, { pageIndex: 1, pdfX: 130, pdfY: 420, widthPt: 160, dataUrl: data:image/png;base64,... }, ]; async function applyAllSignatures(pdfArrayBuffer, signatures) { const doc await PDFDocument.load(pdfArrayBuffer, { ignoreEncryption: true }); for (const sig of signatures) { const page doc.getPages()[sig.pageIndex]; const png await doc.embedPng(dataUrlToUint8Array(sig.dataUrl)); const imgHeightPt (png.height / png.width) * sig.widthPt; page.drawImage(png, { x: sig.pdfX, y: sig.pdfY, width: sig.widthPt, height: imgHeightPt, }); } return doc.save(); }最后一次性 save用户看到的结果是“签完所有位置点提交拿到的就是完整 PDF”。中途如果用户反悔删掉某个签名直接在前端数组里移除对应项不需要还原原文件这也是边签边存做不到的后悔药。5. 避坑排查白屏、断笔、文件体积与加密 PDF 的五个现场5.1 iframe 在微信里全屏白屏PDF 原生预览失效现象手机微信里打开合同页面原本放 PDF 的区域一片白iOS 上甚至整个页面被切走。 原因安卓微信 WebView 内置的 PDF 插件不可用iframe / object / embed 都拿不到渲染结果iOS Safari 则把 PDF 交给系统阅读器H5 上下文被替换。 解决不要试图修原生预览直接统一走 pdf.js Canvas 渲染。代码层面判断一次平台做降级即可渲染入口始终是 pdf.js桌面和移动端共用同一套 UI。5.2 手指一动页面跟着滚签名断成好几截现象在签名区写字时笔迹连续但中间夹着几段空白或者页面突然滑动签名位置偏移。 原因touchmove 事件没拦住默认滚动行为。新版浏览器对 touch 监听默认是 passivepreventDefault不生效或 CSS 没配合限制触摸行为。 解决canvas 样式加touch-action: none同时给 touchmove 监听器传{ passive: false }。注意 touch-action 只加在签名画布上不要给外层滚动容器加否则整页滚不了。5.3 透明底签名在预览里“看不见”被当成 bug 报上来现象签名导出 PNG 后在页面上预览白底上看不出内容测试人员说“签名丢了”。 原因导出的是透明背景 PNG墨迹是深色、背景透明在白底上确实只有墨迹可见。如果墨迹本身也很浅几乎像消失了。 解决交付时把签名 PNG 合成到 PDF 上再看PDF 是白底效果正常。如果预览阶段就得给用户看效果临时在签名 canvas 底下垫一层白色 div但导出时必须去掉这层白底否则会带白块。另外一个隐藏问题若用户在签名画布上用白色画笔写了字透明底上导出后全看不见这种业务上要直接禁用白色画笔。5.4 加密 PDF 加载即失败大文件把低端机内存打爆现象某些内部系统下发的 PDF 带权限密码pdf.js 加载时报错pdf-lib 合成时也报错另一些 30MB 以上的扫描件在低端安卓机上直接黑屏或浏览器崩溃。 原因pdf.js 和 pdf-lib 默认对加密文档支持有限ignoreEncryption: true只能绕过部分权限标记真正加密的文档必须提供密码大 PDF 每页渲染成高分辨率 canvas内存轻松破 200MB。 解决内部系统统一下发前解密开放版给 H5 用加密版在 load 时传{ password }不要在前端硬破。大文件分页渲染、限制 scale 上限 1.5、滚动时及时 remove 离屏 canvas并调用doc.destroy()。扫描版 PDF 本质是图片签名只能叠在页面上做不了“把签名嵌进文字层”这个边界要提前跟业务对齐。5.5 合成后的 PDF 体积暴涨PNG 尺寸、透明通道与重复 save 的叠加现象原文件 2MB签完名变 15MB。 原因三个因素的叠加。签名 PNG 没有裁剪整块画布带大尺寸透明通道被嵌入多次 save 反复重写文档原 PDF 内嵌字体被 pdf-lib 重复保留。 解决签名导出先做透明裁剪3.3 节控制单张签名图在 20KB 以内所有签名攒一次 save如果原 PDF 非常大考虑先用压缩工具预处理再上业务。合成后检查bytes.byteLength超过预期就逐项排查是图的问题还是文档重写的问题。6. 进阶签名后的坐标校验台与发布前检查习惯签名功能最怕的不是实现而是“看着对齐了打印出来偏了 2 毫米”。我现在的习惯是准备一份测试 PDF页面上画满 1cm 见方的网格和 10 个标记点把签名坐标写死在测试页的标记点附近然后走完整链路pdf.js 预览 → 点击标记点 → 签名 → pdf-lib 合成 → 导出 → 打开导出 PDF 对比。对比时看三件事签名是否落在预期标记点上、是否旋转、是否被压缩变形。位置偏差如果小于 2mm打印出来基本可接受如果偏了半个格子回查坐标换算函数里 viewport.scale 是否传错或者签名容器和 PDF canvas 是否被 CSS 拉伸过。还有一个快速自查办法合成前后各读一次页面宽高pdf-lib 返回的page.getWidth()应该和 pdf.js 的page.getViewport({ scale: 1 }).width一致不一致就说明 PDF 在某个环节被缩放重写了。真机验证清单我会固定在发布前列一遍iOS Safari、安卓微信、安卓系统浏览器、PC Chrome。每台设备都过同样路径——打开合同、翻到最后一页、签名、导出、检查清晰度。低配安卓机如果打开 20MB PDF 后内存告急Scale 上限改成 1.25 再试。页面卸载时检查currentDoc.destroy()是否真的执行了用 Memory 面板看 WebView 内存是否回落这个动作我吃过亏阴影至今没散。签名 PNG 的字节数也要盯。一次正常的签名应该在 10KB 量级如果某天突然变成 100KB先怀疑透明裁剪没跑再看是不是误用了 JPEG 或白色描边。签字宽度我固定设置为页面宽度的 0.3合同正文的字号通常在 1012pt这个宽度下签名字迹识别度足够又不至于盖住条款。最后再说一个个人习惯坐标换算函数不散落在组件里统一放一个signatureCoord.js输入参数只有“签名框相对 PDF canvas 的偏移”和“viewport”输出永远是 PDF 点坐标。改动预览缩放比例时只动一个文件坐标逻辑不会因为换个页面结构就崩。H5 PDF 签名这件事能跑通只是第一步能稳定跑到打印还不变形靠的就是这些反复校验的习惯。希望帮到你。本文还有配套的精品资源点击获取
返回列表