
简介面向需要在移动端网页中直接查看并签署PDF的开发者与业务团队这套H5 PDF在线预览与签名示例聚焦合同签署、表单确认等场景提供可直接运行的页面结构、样式与脚本帮助理解浏览器端渲染PDF、捕获手写签名并与文档结合的实现路径。压缩包共15个文件约711KB以JavaScript脚本为主体配合CSS样式、HTML入口页和一份示例PDF涵盖PDF.js渲染、触屏事件与移动端适配等关键环节目录精炼便于按模块阅读。已有420人学习适合前端初中级开发者、需要快速验证方案的产品技术人员参考。代码将预览、签名画布与移动端交互串成完整流程可作为原型基础继续扩展签名位置、保存与安全传输策略减少从零搭建和选型的时间。1. 一个 PDF 预览加签名的需求八成会先死在移动端 WebView 上需求方给的描述往往就一句“h5 pdf在线预览 并支持签名”听起来是两件事把 PDF 显示出来再挂一块手写板。真动手才知道pdf签名在线预览的难点根本不在签名而在“预览”这两个字——移动端内置 WebView 里iframe srcxxx.pdf和embed大概率只给你一页往下滑不动或者干脆一片白。这个方案干的事就是用 canvas 渲染接管预览再用一层透明画板收笔迹最后把签名图按 PDF 用户空间坐标写回文件导出的 PDF 在任何阅读器里打开签名都钉在原位。适合正在做合同、审批、回执类 H5 的前端也适合后端想少一个合成服务的人。2. 选型不是拍脑袋H5 预览 PDF 的三条路和 pdfjs 的落地成本2.1 iframe 与 embed 在移动端 WebView 里的真实边界桌面浏览器里iframe srca.pdf能出效果是因为浏览器自带 PDF 插件。移动端内置 WebView 没有这个插件能不能显示完全取决于宿主 App 有没有自己实现同一个页面在不同 App 里表现能差出三种正常滚动、只显示第一页、白屏。更麻烦的是你没法在 iframe 上叠一层手写板——跨文档拿不到渲染区域尺寸也拿不到页码和滚动位置签名坐标根本无从谈起。所以这条路只在一种场景下成立PC 端内部系统浏览器固定且不需要在 PDF 上做任何叠加交互。只要需求里有“签”“批注”“盖章”“指定位置”iframe 就该被排除。这不是性能问题是能力问题。服务端转图是第二条路。后端用无头浏览器或 PDF 渲染库把每页转成图片前端拿到一串图片做展示和签名。优点是前端极简图片天生好叠东西缺点是清晰度受限于转换时的 DPI放大就糊文件一多首屏等待明显而且渲染压力全压到服务器上一份 50 页的扫描件能把接口拖到十几秒。如果你们的 PDF 是固定模板、页数少、并发低这条路的开发成本确实最低。第三条是前端用 pdfjs 把 PDF 解析成 canvas。首屏只渲染当前页翻页或滚动到哪渲染到哪清晰度靠 scale 和 devicePixelRatio 控制坐标换算有现成 API。成本集中在前端服务器只负责发文件。这份资源走的就是第三条签名的整套坐标体系也是建立在它之上的。2.2 版本差异带来的第一个坑worker 怎么接pdfjs 的解析跑在 worker 里主线程只负责画。接错 worker控制台会飘一条“Setting up fake worker”警告然后所有解析退回主线程十几页的 PDF 能把页面卡住两三秒滚动直接掉帧。这不是报错是性能塌方很容易被当成“这库就这速度”。用构建工具的项目常见做法是把 worker 当静态资源引进来// pdf-setup.js import * as pdfjsLib from pdfjs-dist; // Vite 的 ?url 后缀会把 worker 文件打成独立资源并返回它的最终地址 import workerUrl from pdfjs-dist/build/pdf.worker.min.mjs?url; pdfjsLib.GlobalWorkerOptions.workerSrc workerUrl; // 中文 PDF 必须配 cMap否则部分字体渲染成空白或方块 export const PDF_LOAD_OPTIONS { cMapUrl: /cmaps/, // 从 pdfjs-dist/cmaps 拷到 public 目录 cMapPacked: true, // 使用压缩版 .bcmap体积小一半 standardFontDataUrl: /standard_fonts/, };不用构建工具的老项目就把pdf.worker.min.mjs老版本是.js和cmaps目录一起拷到静态目录直接赋路径字符串。这里有个容易踩的点主包和 worker 的版本必须完全一致混用会在解析时报Cannot read properties of undefined报错位置还在解析内部看不出根因。我一般把 worker 的引入写在统一入口文件里全项目只此一处避免某个页面自己又 import 一遍。cMapUrl只对中文、日文这类 CJK 字体有意义但国内业务里几乎绕不开。漏配的表现很迷惑目录、页码正常正文一片空白控制台只有一条字体加载失败的警告。2.3 一次加载、按页渲染的四个对象pdfjs 的渲染管线有四个对象理清楚它们的关系后面所有坐标问题都好解释对象拿到方式作用生命周期loadingTaskgetDocument(opts)发起解析可 abort一次加载pdfDocumentawait loadingTask.promise页数、元信息、取页整个预览期间pdfPageawait pdf.getPage(n)单页资源渲染完可 cleanupviewportpage.getViewport({scale})尺寸与坐标换算的唯一依据跟页面绑定加载入口长这样// 注意pdfjs 会 transfer 掉传入的 ArrayBuffer后续再用会变成 detached async function loadPdf(arrayBuffer) { const data new Uint8Array(arrayBuffer.slice(0)); // 复制一份原件留给后续合成 const loadingTask pdfjsLib.getDocument({ data, ...PDF_LOAD_OPTIONS, // 加密文件会走这个回调返回密码字符串 password: , }); const pdf await loadingTask.promise; return pdf; // pdf.numPages 拿总页数 }arrayBuffer.slice(0)这行不是冗余。pdfjs 内部会把data的底层 buffer transfer 给 worker之后原 buffer 变成 detached 状态长度归零。如果你打算在导出时用 pdf-lib 重新打开原始文件合成签名就会发现拿到的是一份空数据报错还特别隐晦。这个坑我在两个项目里各踩过一次现在养成习惯原始 buffer 永远复制一份再给解析器。viewport是整个方案的地基。它由页面自身的 MediaBox、/Rotate旋转角度和传入的scale共同算出viewport.width/height是 CSS 像素尺寸viewport.convertToPdfPoint(x, y)能把画布坐标转回 PDF 用户空间坐标。后面签名落位靠的就是它不要自己写三角函数去算旋转。3. 把 PDF 画到 canvas分页渲染、高清屏换算与内存回收3.1 从 ArrayBuffer 到第一页像素的完整链路渲染单页的代码不长但每一行都有讲究// render-page.js const MAX_DPR 2; // 上限4K 屏 dpr 能到 3~4内存直接翻倍 const BASE_SCALE 1.2; // 以 CSS 像素为基准1.2 在手机上已经够清楚 export async function renderPage(pdf, pageNo, container) { const page await pdf.getPage(pageNo); const viewport page.getViewport({ scale: BASE_SCALE }); const dpr Math.min(window.devicePixelRatio || 1, MAX_DPR); const canvas document.createElement(canvas); // 位图尺寸 CSS 尺寸 × dpr这是清晰度的来源 canvas.width Math.floor(viewport.width * dpr); canvas.height Math.floor(viewport.height * dpr); // 显示尺寸必须写回 CSS 像素否则位图会被拉伸显示 canvas.style.width ${viewport.width}px; canvas.style.height ${viewport.height}px; const ctx canvas.getContext(2d); // 先铺白底PDF 页面本身是透明底直接画在浅灰背景上会发灰 ctx.fillStyle #ffffff; ctx.fillRect(0, 0, canvas.width, canvas.height); await page.render({ canvasContext: ctx, viewport, // 等价于 ctx.scale(dpr, dpr)但走渲染参数性能更好且不影响后续手动绘制 transform: dpr 1 ? null : [dpr, 0, 0, dpr, 0, 0], }).promise; container.appendChild(canvas); return { canvas, viewport, page }; }三个参数各自的作用canvas.width/height决定位图的像素总量直接吃内存一页 A4 在 dpr2 时约 1600×2200占 14MB 左右canvas.style.width/height决定它在页面上占多大地方必须用 CSS 像素否则会出现“图很清楚但特别大”的错位transform负责把绘制内容按 dpr 放大让 1 个 CSS 像素对应 dpr 个物理像素。BASE_SCALE不是越大越好。调到 2.0 时位图面积翻 2.8 倍十页预览在低端安卓上就容易触发内存回收表现是滚动时页面突然白一下再恢复。1.2 到 1.5 之间是个舒服的区间字体边缘在手机屏上已经看不出锯齿。3.2 scale 与 devicePixelRatio 别混着用这两个值最容易被当成一回事。scale变的是 viewport 尺寸进而改变 canvas 的 CSS 尺寸和位图尺寸dpr只影响位图密度不改变布局尺寸。判断自己有没有混用看一个现象就够如果你把签名画布按viewport.width * dpr去算坐标签出来的名字会偏到右下角偏移量正好是 dpr 倍。因为用户的点击位置是 CSS 像素而 viewport 的坐标系也是 CSS 像素中间根本不该出现 dpr。我给自己定的规矩是所有跟用户交互相关的坐标一律用 CSS 像素只在设置canvas.width/height和transform的时候碰 dpr。这条边界守住了后面的坐标换算基本不会出问题。另外page.getViewport默认已经应用了页面自身的旋转。同一份 PDF 里如果有一页被扫描成横向viewer 里看是正的你按不旋转去算尺寸签名图贴上去就是歪的。不要试图自己修正把旋转交给 viewport。3.3 滚动加载与不可见页的卸载一次性渲染全部页面是新手最容易做的事20 页以内看不出问题超过 30 页在手机上必崩。常见做法是给每页先占位、再按需渲染// lazy-render.js export function setupLazyRender(pdf, scroller, pageNodes) { const rendered new Map(); // pageNo - {task, canvas} const io new IntersectionObserver(async (entries) { for (const entry of entries) { const node entry.target; const pageNo Number(node.dataset.page); if (entry.isIntersecting !rendered.has(pageNo)) { // 先拿到 viewport 撑开高度避免渲染完成时滚动条跳动 const page await pdf.getPage(pageNo); const vp page.getViewport({ scale: BASE_SCALE }); node.style.height ${vp.height}px; const { canvas } await renderPage(pdf, pageNo, node); rendered.set(pageNo, canvas); } // 离屏超过 3 屏的页释放位图只留占位高度 if (!entry.isIntersecting rendered.has(pageNo)) { const canvas rendered.get(pageNo); canvas.width 0; canvas.height 0; canvas.remove(); rendered.delete(pageNo); } } }, { root: scroller, rootMargin: 600px 0px }); // 提前 600px 预热 pageNodes.forEach((n) io.observe(n)); }rootMargin是提前量给 600px 意味着手指还没滑到下一页已经开始渲染观感上是无缝的。给太小会看到白块给太大等于回到全量渲染。释放时把canvas.width和height置 0再remove()。只 remove 不置 0某些 WebView 里位图内存不会立刻归还滚上几十页依然会崩。这个细节在桌面浏览器上完全测不出来只有真机上才暴露。page.cleanup()也可以调但它释放的是解析中间态不是画布。频繁 cleanup 再重新 getPage会有额外的解析开销我一般只在页面彻底离开可视区很久之后才连 page 一起放掉。4. 手写签名层笔迹插值、坐标归位与写回 PDF4.1 用 Pointer Events 做一个跟手的签名板签名板要解决三件事手指按下不能带动页面滚动、笔迹要平滑、导出要透明背景。// sign-pad.js export class SignPad { constructor(canvas, { baseWidth 3, color #1a1a1a } {}) { this.canvas canvas; this.ctx canvas.getContext(2d); this.baseWidth baseWidth; this.color color; this.points []; this.dragging false; const dpr Math.min(window.devicePixelRatio || 1, 3); const rect canvas.getBoundingClientRect(); canvas.width Math.floor(rect.width * dpr); canvas.height Math.floor(rect.height * dpr); this.ctx.scale(dpr, dpr); // 之后所有绘制都用 CSS 像素 this.ctx.lineCap round; this.ctx.lineJoin round; // 关键禁止浏览器把触摸手势解释成滚动/缩放 canvas.style.touchAction none; canvas.addEventListener(pointerdown, (e) { canvas.setPointerCapture(e.pointerId); // 手指移出画布也不丢事件 this.dragging true; this.points [this.pos(e)]; this.ctx.beginPath(); this.ctx.arc(this.points[0].x, this.points[0].y, this.baseWidth / 2, 0, Math.PI * 2); this.ctx.fillStyle this.color; this.ctx.fill(); // 单击也要留一个点 }); canvas.addEventListener(pointermove, (e) { if (!this.dragging) return; this.points.push(this.pos(e)); this.drawTail(); }); const end (e) { this.dragging false; canvas.releasePointerCapture?.(e.pointerId); }; canvas.addEventListener(pointerup, end); canvas.addEventListener(pointercancel, end); } pos(e) { const rect this.canvas.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; } // 中点二次贝塞尔用前后两点的中点做端点中间点做控制点 drawTail() { const p this.points; const n p.length; if (n 3) return; const [p0, p1, p2] [p[n - 3], p[n - 2], p[n - 1]]; const m1 { x: (p0.x p1.x) / 2, y: (p0.y p1.y) / 2 }; const m2 { x: (p1.x p2.x) / 2, y: (p1.y p2.y) / 2 }; // 用两点距离模拟压感划得越快线越细 const speed Math.hypot(p2.x - p1.x, p2.y - p1.y); const w this.baseWidth * (1 - Math.min(speed / 40, 0.55)); this.ctx.beginPath(); this.ctx.lineWidth Math.max(w, this.baseWidth * 0.45); this.ctx.strokeStyle this.color; this.ctx.moveTo(m1.x, m1.y); this.ctx.quadraticCurveTo(p1.x, p1.y, m2.x, m2.y); this.ctx.stroke(); } clear() { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.points []; } }touchAction none是一行顶十行的写法。没有它手指一划页面跟着滚笔迹断成好几截用preventDefault()也行但默认监听是 passive 的得在addEventListener第三个参数里显式写{ passive: false }注册阶段就细心一点。二次贝塞尔中点法比直接lineTo平滑得多代价只是延迟一个点。速度映射线宽这个技巧是从手写笔的压感思路搬过来的真机没有压感数据也能出一点笔锋视觉上不像用鼠标画出来的线。画布必须保持透明不要fillRect铺白底。导出时 PNG 的 alpha 通道会被 pdf-lib 原样嵌进 PDF白底会变成一块实心矩形正好把合同正文盖住——这个现象后面还会专门说。4.2 屏幕坐标到 PDF 用户空间的换算签名板是浮在 PDF 上的一层用户在画布上落笔的位置要能映射到 PDF 页面上的具体坐标。自己算旋转和缩放会写出一堆难维护的代码直接借 viewport// coords.js /** * 把签名板上的矩形CSS 像素相对页面容器换算成 PDF 用户空间矩形 * param viewport 渲染该页时用的那份 viewportscale 必须一致 * param rect { left, top, width, height }相对 canvas 左上角 */ export function toPdfRect(viewport, rect) { // convertToPdfPoint 处理了 y 轴翻转、scale 和页面旋转 const [x1, y1] viewport.convertToPdfPoint(rect.left, rect.top); const [x2, y2] viewport.convertToPdfPoint( rect.left rect.width, rect.top rect.height ); return { x: Math.min(x1, x2), y: Math.min(y1, y2), // pdf-lib 的 drawImage 用的也是左下角原点 width: Math.abs(x2 - x1), height: Math.abs(y2 - y1), }; }三个必须守住的前提传入的rect是相对 canvas 左上角的 CSS 像素不是pageX/pageY那种相对文档的坐标也不是乘过 dpr 的位图坐标viewport必须是渲染这一页时用的那一份scale 换过就对不上页面本身的旋转交给convertToPdfPoint不要在外面再乘一次旋转矩阵。如果签名板覆盖整个预览区而不是单页取坐标时先减掉目标页容器的getBoundingClientRect()偏移再进这个函数。我见过有实现直接拿e.offsetX用滚动之后offsetX相对的是画板本身跟页面位置无关签在第二页的名字最后落到了第一页。4.3 用 pdf-lib 嵌入签名并导出导出有两种策略先想清楚再写代码。展示层方案是签名只画在 canvas 上提交时把图片和坐标传给后端由后端合成前端合成方案是在浏览器里用 pdf-lib 直接改写 PDF 再下载。前者适合有存证要求、后端要统一归档的场景后者适合“下载即用”的工具型页面省一个接口。下面这套是前端合成// export.js import { PDFDocument } from pdf-lib; /** * param originalBuffer 原始 PDF 的 ArrayBuffer未被 pdfjs transfer 的那份 * param items [{ pageIndex, rect, pngBytes }] 签名项pageIndex 从 0 开始 */ export async function signPdf(originalBuffer, items) { const doc await PDFDocument.load(originalBuffer, { ignoreEncryption: true, // 只读加密文件不加这行会直接抛错 updateMetadata: false, // 不改元信息避免下游校验失败 }); for (const item of items) { const page doc.getPages()[item.pageIndex]; const image await doc.embedPng(item.pngBytes); page.drawImage(image, { x: item.rect.x, y: item.rect.y, width: item.rect.width, height: item.rect.height, opacity: 1, }); } const bytes await doc.save(); // 返回 Uint8Array return new Blob([bytes], { type: application/pdf }); } // 签名板导出必须 PNGJPEG 没有透明通道 export async function padToPngBytes(canvas) { const blob await new Promise((res) canvas.toBlob(res, image/png)); return new Uint8Array(await blob.arrayBuffer()); }embedPng处理的是图片字节不是 base64 字符串。用toDataURL再手动切前缀能跑但一份签名图转出来上百 KB 的字符串在低端机上解析明显卡顿toBlob走的是二进制路径省这一步。ignoreEncryption: true只解决“能读”不解决“能改”。权限位里禁止修改的 PDFpdf-lib 保存出来依然可能被阅读器拒绝这种情况要退回后端处理别在前端死磕。导出之后别急着给下载先在自己这边用 pdfjs 再加载一遍页数和第一页尺寸对得上说明文件没写坏。这一步十秒钟能挡掉尺寸错位和空白页两类问题。5. 避坑记录五个在真机上翻车的签名预览问题5.1 签名位置整体偏移或左右镜像现象PC 浏览器上调好的位置到手机上签出来整体右移、下移个别机型还是镜像的。原因是坐标口径不统一。用户点击给的是 CSS 像素canvas.width是位图像素而 PDF 用户空间原点在左下角、y 轴向上。三套坐标系混用偏移量恰好等于 dpr 或者页面高度。解决方式是把换算收敛到一个函数里只接受 CSS 像素内部通过viewport.convertToPdfPoint完成翻转。团队里加一条约束任何直接用canvas.width参与坐标计算的地方都要 review。另外如果签名板尺寸用了transform: scale()做自适应getBoundingClientRect()拿到的是缩放后的尺寸而offsetX是缩放前的这类页面要把缩放系数除掉再进换算。5.2 手指一划整个页面跟着滚现象签名时笔迹断成几截页面上下抖动回执单上留下几道折线。原因是浏览器默认把pointermove当滚动手势的前置信号且触摸事件监听默认 passivepreventDefault()静默失效——不报错只是没效果。解决办法只有两条一起上给签名画布设touch-action: none同时给外层滚动容器在签名激活期间加overflow: hidden。只做第一条安卓部分机型仍会在边缘触发下拉刷新只做第二条笔迹依然会断。另外要监听pointercancel来电、通知栏下拉都会触发它不处理的话dragging状态卡在 true下次落笔会从上次断点连一条直线过去。5.3 大文件白屏、滚动掉帧甚至闪退现象十几页的扫描件打开后前两页正常往下滑开始白屏滑到第十页左右 WebView 直接重启。原因是每页 canvas 的位图常驻内存dpr 上去之后单页十几 MB十几页就是一二百 MB移动端 WebView 的可用内存远没有想象中宽裕。解决靠三件事dpr 封顶在 2、离屏页把canvas.width/height置 0 后移除、rootMargin别超过一屏半。另外签名板的 canvas 也别忽略它同样按 dpr 放大如果整页铺满又是一份额外开销用完即清。5.4 透明签名变成黑底导出后还发虚现象页面上签名好好的导出的 PDF 用别的阅读器打开签名位置是一块黑矩形或者签名糊成一团。黑底的原因是签名 canvas 铺了白色或深色背景或者在导出前用了toDataURL(image/jpeg)。JPEG 没有 alpha 通道透明区域一律按黑或白填充。改成 PNG并且签名板从头到尾不填充背景。发虚是分辨率不够签名图按 CSS 像素尺寸导出贴进 PDF 后按点计算实际只有 100 多像素宽打印或放大就糊。解决是把签名板的位图尺寸按 dpr 放大嵌入时width/height仍然用 PDF 空间的尺寸图像细节自然就上去了。5.5 加密 PDF 与“空白页”现象某几份 PDF 打开报PasswordException或者渲染出来是空白页。加密文件需要走getDocument的password回调拿不到密码就只能提示用户。空白页多半是 cMap 或标准字体没配好中文内容整段不显示但页面框架、线条都正常——很容易被误判成文件损坏。排查顺序是先看控制台有没有字体加载失败的警告再看pdf.numPages和实际渲染出的 canvas 数量是否一致最后单独把出问题的那页getPage出来打印 viewport 尺寸尺寸为 0 说明页面本身是空的扫描件里有这种占位页。这三步走完基本能定位到是文件问题还是配置问题。6. 导出前的三分钟自检签名分辨率、页码锚定与文件可打开性签名功能做完真正让人返工的不是画不出来而是导出文件在别人机器上打开出了问题而自己这边一切正常。所以每次交付前我会跑一遍固定动作。先看签名图的分辨率。在控制台把导出前的 canvas 尺寸打出来和它在 PDF 里的显示尺寸比一下位图宽度低于显示宽度的 2 倍就等着打印发虚。要做的调整是把签名板位图按 dpr 放大嵌入时仍然用toPdfRect算出来的 PDF 空间尺寸两套尺寸分开控制。再看页码锚定。签名项里存的是pageIndex从 0 开始而用户界面上显示的是第几页从 1 开始。这个 off-by-one 在单页 PDF 上永远测不出来多页合同一签就错位。我会在签名数据结构里强制两个字段都存pageIndex给 pdf-libpageLabel给界面回显谁也别猜谁。最后是文件可打开性。导出后立刻用同一套 pdfjs 重新加载比对三件事页数是否一致、签名所在页的 viewport 尺寸是否和原页一致、签名矩形是否落在页面边界内。第三项容易被忽略坐标算错时 pdf-lib 不会报错它老老实实把图贴在页面外面导出的 PDF 看着正常翻到那一页才发现什么都没有。自检项判定标准不通过时的典型表现签名位图分辨率位图宽 ≥ 显示宽 × 2打印或放大后笔迹发虚签名背景PNG 带 alpha无填充导出后一块黑/白矩形页码映射pageIndex 与界面页码分离存储多页文件签错页坐标边界0 ≤ x, xwidth ≤ pageWidth签名贴到页面外肉眼看不见文件完整性重新加载页数、尺寸一致打开报错或部分页空白这套检查我写成了一段脚本挂在开发环境的导出按钮后面生产构建里剔除。它不替代真机测试但能挡掉八成的低级问题。从那以后我每次给签名功能收尾都会拿一台低端安卓机和一份 30 页左右、带旋转页和加密页的扫描件跑一遍完整导出再把导出的 PDF 用另一款阅读器打开确认落位。这套动作做过一次之后剩下的就是肌肉记忆了。希望帮到你。本文还有配套的精品资源点击获取