H5 PDF在线预览与手写签名坐标落版实战

发布时间:2026/10/30 6:06:12

H5 PDF在线预览与手写签名坐标落版实战 简介面向需要在浏览器端完成 PDF 预览与手写签名的前端开发者这份代码包给出了可运行的 H5 落地示例借助 pdf.js 与 pdf.worker.js 解析并渲染文档配合 pdfh5.js 封装移动端翻页、缩放与手势交互再通过 Canvas 采集笔迹把签名叠加到页面上可用于电子合同签署、表单回填、在线审批等场景。包内共 15 个文件以 10 个 JavaScript 脚本为主体另有 2 个 CSS 文件控制阅读器样式、1 个 HTML 入口页和 1 份示例 PDF压缩包约 711KB体积轻量便于直接嵌入现有工程调试。目前已有 420 人学习下载读者可据此理清渲染、手势与签名合成三条链路如何衔接以及在 zepto、fastclick、touch 等移动端适配脚本的配合下这些脚本的依赖如何组合减少自行拼装 PDF.js 与签名模块时的试错成本。1. 先把边界说清H5 PDF 在线预览并支持签名难的不是预览真正难的不是把 PDF 画在 H5 里而是用户签完字后那条手写笔迹能否落在正确的落款坐标上。这份源码包做的正是「H5 PDF 在线预览并支持签名」手机浏览器里翻页看单据、在当前页手写签名、导出带签名的 PDF 回传。它适合合同回执、审批单、巡检记录这类轻量表单场景不适合需要 CA 证书、时间戳、防篡改审计的正式电子签章。下面按预览、签名、落版、排错四段拆重点讲坐标换算和移动端适配这两块最容易翻车。2. 链路拆成三层PDF 签名在线预览为什么不选 iframe 截图2.1 三种常见方案的真实边界做 PDF 签名在线预览第一道选择题不是选哪个库而是要不要自己控制渲染层。很多项目一开始图省事直接iframe srcxxx.pdf或者新窗口打开桌面端看着能用一到移动端就原形毕露有的浏览器直接下载文件有的内置阅读器盖住页面最要命的是拿不到页面坐标签名只能浮在iframe上面导出时根本不知道该贴到 PDF 的哪一页、哪个位置。第二种方案是服务端把 PDF 转成图片再返回兼容性确实好但每次预览都走一次转换大文件延迟明显签名坐标还要回传服务端二次合成链路长、排查成本高。第三种是 PDF.js 把页面渲染到 canvas上面盖一层透明签名 canvas导出时用 pdf-lib 把签名图片写回 PDF。这套方案把预览、签名、落版都放在前端坐标可控适合轻量表单场景。方案优点主要限制适合场景iframe / 新窗口接入最快移动端行为不可控拿不到页面坐标不能叠加签名只读预览、内部临时查看服务端转图片兼容性好前端简单有网络延迟签名要回传坐标大文件成本高弱终端、老浏览器PDF.js Canvas pdf-lib坐标可控纯前端落版需要处理 worker、DPR、旋转页、加密 PDFH5 表单、回执、审批单这张表不是让你背下来而是提醒一个选型原则只要需求里出现“在指定页手写签名并导出”渲染层就必须可控。iframe 方案不是不能用而是它把控制权交给了浏览器内置阅读器后面所有坐标问题都会变成黑匣子。PDF.js 方案多出来的工作量主要集中在 worker 配置、DPR 适配和坐标换算这三块在源码包里已经拆成了独立模块照着改参数就能跑。2.2 三层数据流与最小骨架这套 H5 PDF 在线预览并支持签名的链路可以拆成三层预览层负责把 PDF 页面画到pdfCanvas签名层用一张同尺寸的透明signCanvas接 Pointer Events落版层把签名笔迹裁成透明 PNG用viewport.convertToPdfPoint换成 PDF 坐标再交给 pdf-lib 写入。三层之间靠一个很小的状态对象传递当前页码、当前页 viewport、签名包围盒、签名 PNG 字节。不要把签名直接画在 PDF canvas 上否则翻页、缩放、重渲染都会把笔迹冲掉后期也没法单独调整签名大小。!-- 两个 canvas 必须完全重叠pdfCanvas 在底signCanvas 在上 -- div classpdf-stage idpdfStage canvas idpdfCanvas/canvas canvas idsignCanvas/canvas /div style .pdf-stage { position: relative; width: 100%; /* 关键签名层要接 pointer 事件必须禁止浏览器手势滚动抢事件 */ touch-action: none; background: #f3f4f6; } .pdf-stage canvas { position: absolute; left: 0; top: 0; /* 显示尺寸由 JS 按 viewport 设置这里不要写死 width:100% */ } #signCanvas { /* 透明层不能给它背景色否则导出签名会带底色 */ background: transparent; z-index: 2; } #pdfCanvas { z-index: 1; } /style上面这段骨架只做一件事保证两张 canvas 的左上角严格对齐。pdfStage用position: relative当坐标系原点两个 canvas 都用position: absolute; left:0; top:0显示宽高由 JS 按 PDF viewport 设置。这里有个细节不要给#signCanvas写width:100%因为签名坐标是按 canvas 的 CSS 尺寸算的一旦 CSS 和 JS 设置的尺寸不一致笔迹就会整体偏移。touch-action: none也不是装饰移动端如果不加手指在签名层上滑动时页面会跟着滚pointermove会断断续续签出来的线像虚线。3. PDF.js 预览层worker、viewport 与按页渲染3.1 worker 和加载参数先配对PDF.js 的第一个拦路虎不是渲染而是 worker。GlobalWorkerOptions.workerSrc必须指向一个浏览器能直接访问的 worker 文件路径不对时控制台会抛Setting up fake worker failed页面白屏。常见做法是把构建产物里的pdf.worker.min.js复制到静态目录用同源绝对路径引用不要依赖打包器自动推断。中文 PDF 还要配cMapUrl否则部分字体映射缺失页面会出现空白或乱码标准字体数据用standardFontDataUrl指向静态目录。下面这段加载代码把原始ArrayBuffer复制了一份再交给 PDF.js原因是 PDF.js 在某些传输模式下会 transfer 掉 buffer后面再用同一个 buffer 做别的解析就会拿到空数据。import * as pdfjsLib from pdfjs-dist; // worker 文件按你构建产物里的实际文件名替换必须同源可访问 pdfjsLib.GlobalWorkerOptions.workerSrc /static/pdf/pdf.worker.min.js; async function loadPdf(file) { const raw await file.arrayBuffer(); const loadingTask pdfjsLib.getDocument({ // slice(0) 复制一份保留 raw 给后续可能的复用 data: raw.slice(0), cMapUrl: /static/pdf/cmaps/, cMapPacked: true, standardFontDataUrl: /static/pdf/standard_fonts/, }); const pdf await loadingTask.promise; return { pdf, raw }; }这段代码的重点在三个参数data传Uint8Array或ArrayBuffer都可以但复制一份能避免 buffer 被 transfer 后不可用cMapUrl指向 cMap 文件目录中文、日文、韩文 PDF 依赖它做字符映射cMapPacked表示使用压缩后的 cMap 文件静态目录里一般同时有打包版和未打包版保持一致即可。standardFontDataUrl指向标准字体目录缺它时某些老 PDF 会回退到替代字体字宽变化会连带影响签名坐标的视觉对齐。worker 路径如果走 CDN要注意跨域和 MIME 类型最稳的还是同源静态目录。3.2 按页渲染scale、DPR 和 renderTask 取消预览层不要一次性把所有页画出来。移动端内存有限几十页的 PDF 全量渲染很容易把标签页拖崩。正确做法是只渲染当前页翻页时取消上一页还没完成的renderTask。下面这段渲染函数先算baseViewport再根据容器可用宽度求scale最后把 canvas 内部像素乘上devicePixelRatio。注意ctx.setTransform(dpr, 0, 0, dpr, 0, 0)之后PDF.js 仍然按 CSS 尺寸绘制但实际输出到高分辨率像素上这样签名层和预览层在视网膜屏上都不会糊。let currentRenderTask null; async function renderPage(pdf, pageNo, container, pdfCanvas) { const page await pdf.getPage(pageNo); const baseViewport page.getViewport({ scale: 1 }); // 容器宽度减去左右留白得到页面 CSS 显示宽度 const availableWidth container.clientWidth - 32; const scale availableWidth / baseViewport.width; const viewport page.getViewport({ scale }); const dpr window.devicePixelRatio || 1; const ctx pdfCanvas.getContext(2d); // 内部像素按 DPR 放大CSS 尺寸保持 viewport 尺寸 pdfCanvas.width Math.floor(viewport.width * dpr); pdfCanvas.height Math.floor(viewport.height * dpr); pdfCanvas.style.width ${viewport.width}px; pdfCanvas.style.height ${viewport.height}px; // 先重置变换再按 DPR 缩放避免重复渲染时叠加 ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.clearRect(0, 0, viewport.width, viewport.height); // 快速翻页时取消旧任务避免后发先至把页面画串 if (currentRenderTask) { currentRenderTask.cancel(); } currentRenderTask page.render({ canvasContext: ctx, viewport, }); await currentRenderTask.promise; // 释放页面资源但保留 page 对象给坐标转换用 page.cleanup(); return { page, viewport }; }这段代码里有几个参数值得单独说。scale是用容器宽度反算的不是固定 1.5 或 2因为不同手机宽度差异很大固定缩放会让小屏横向溢出、大屏留白过多。dpr取window.devicePixelRatio一般手机是 2 或 3桌面可能是 1乘上去之后 canvas 内部像素变大但显示尺寸不变。page.cleanup()释放页面内部缓存但page对象本身还能继续调getViewport和convertToPdfPoint所以不要把它置空。currentRenderTask.cancel()是快速翻页时的后悔药没有它用户连点下一页旧页面的渲染结果可能后到把新页面覆盖掉。3.3 翻页、缩放与销毁的节奏翻页和缩放不要直接复用上一次的viewport。每次渲染都重新getPage、重新算scale把新的{ page, viewport }存到当前状态里签名层再按新尺寸重绘。缩放时如果签名已经存在不能把旧像素坐标直接搬过去应该把签名数据存成 PDF 坐标或按比例重新映射。一个稳妥的习惯是缩放前先保存当前页签名包围盒重渲染后按新旧viewport.width的比例换算签名层显示位置导出时仍然用convertToPdfPoint重新算一遍。PDF 关闭时调pdf.destroy()把 worker 和页面缓存释放掉单页应用里反复上传大文件时这个动作能明显降低内存增长。async function goToPage(state, pageNo) { if (pageNo 1 || pageNo state.pdf.numPages) return; state.pageNo pageNo; // 重渲染前先清空签名层避免旧笔迹停留在新页面上 const signCtx state.signCanvas.getContext(2d); signCtx.clearRect(0, 0, state.signCanvas.width, state.signCanvas.height); const { page, viewport } await renderPage( state.pdf, pageNo, state.container, state.pdfCanvas ); state.page page; state.viewport viewport; // 签名层显示尺寸必须跟 PDF 层一致 state.signCanvas.style.width ${viewport.width}px; state.signCanvas.style.height ${viewport.height}px; state.signCanvas.width Math.floor(viewport.width * (window.devicePixelRatio || 1)); state.signCanvas.height Math.floor(viewport.height * (window.devicePixelRatio || 1)); const ctx state.signCanvas.getContext(2d); const dpr window.devicePixelRatio || 1; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }上面这段把翻页流程串了起来先改页码再清签名层再渲染 PDF最后同步签名 canvas 的显示尺寸和内部像素。顺序不能反先渲染再清签名层会把刚画好的内容擦掉。signCanvas.width和style.width要同时设置前者决定像素密度后者决定布局尺寸。setTransform每次重设避免上一页的缩放矩阵残留。翻页时如果签名层还有未保存的笔迹业务上要么提示用户先保存要么自动把当前页签名存入Map不要静默丢掉。4. 手写签名层把笔迹坐标压进 PDF 签名在线预览的落版框4.1 Pointer Events 采集别把 touch 和 mouse 混着用签名层用 Pointer Events 统一处理鼠标、手指和触控笔不要同时监听touchstart和mousedown否则移动端可能触发两次笔迹变粗或出现重影。核心状态只有三个是否正在绘制、上一个点、笔迹包围盒。下面这段代码在pointerdown时记录起点pointermove时画线并更新包围盒pointerup时结束。setPointerCapture保证手指滑出 canvas 边界后还能继续收到事件touch-action: none已经在 CSS 里禁止了浏览器手势JS 里再preventDefault一次双保险。function initSignature(canvas, onChange) { const ctx canvas.getContext(2d); let drawing false; let last null; // 包围盒按 CSS 像素记录导出时再乘 DPR const box { left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity }; function resetBox() { box.left Infinity; box.top Infinity; box.right -Infinity; box.bottom -Infinity; } function updateBox(x, y) { // 线宽会向外扩包围盒多留 6px避免导出裁掉笔锋 const pad 6; box.left Math.min(box.left, x - pad); box.top Math.min(box.top, y - pad); box.right Math.max(box.right, x pad); box.bottom Math.max(box.bottom, y pad); } function getPoint(e) { const rect canvas.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top, }; } canvas.addEventListener(pointerdown, (e) { e.preventDefault(); drawing true; last getPoint(e); canvas.setPointerCapture(e.pointerId); ctx.beginPath(); ctx.moveTo(last.x, last.y); updateBox(last.x, last.y); }); canvas.addEventListener(pointermove, (e) { if (!drawing) return; e.preventDefault(); const p getPoint(e); // 二次贝塞尔让快速滑动时线条不那么折 const midX (last.x p.x) / 2; const midY (last.y p.y) / 2; ctx.quadraticCurveTo(last.x, last.y, midX, midY); ctx.stroke(); updateBox(p.x, p.y); last p; onChange?.({ box: { ...box }, hasInk: true }); }); canvas.addEventListener(pointerup, (e) { if (!drawing) return; drawing false; last null; canvas.releasePointerCapture?.(e.pointerId); onChange?.({ box: { ...box }, hasInk: true }); }); // 初始化线宽、颜色、圆角笔锋 ctx.lineWidth 2.4; ctx.lineCap round; ctx.lineJoin round; ctx.strokeStyle #111827; return { box, clear() { const dpr window.devicePixelRatio || 1; ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr); resetBox(); onChange?.({ box: null, hasInk: false }); }, }; }这段代码的关键参数是lineWidth、lineCap、lineJoin和包围盒的pad。lineWidth不要设太细手机屏幕上 1px 线看起来像划痕2.2 到 2.6 比较稳lineCap: round和lineJoin: round让转折处不出现尖角pad是给包围盒留的余量线宽的一半加上抗锯齿边缘6px 足够。getPoint用getBoundingClientRect而不是offsetX/offsetY因为offsetX在子元素和 CSS 变换下容易偏。quadraticCurveTo的终点取中点是常见的笔迹平滑做法不会明显增加代码量。4.2 从签名 canvas 裁出透明 PNG导出时不要直接signCanvas.toDataURL()整页导出那样会把整页透明区域也塞进 PDF文件体积大pdf-lib 嵌入也慢。正确做法是按包围盒裁一块离屏 canvas只导出有笔迹的区域。下面这段函数把 CSS 坐标乘上 DPR映射到签名 canvas 的像素坐标再画到离屏 canvas 上。toBlob(image/png)保留透明背景不要给离屏 canvas 填白色否则 pdf-lib 贴上去是一块白底盖住 PDF 原文。async function cropSignaturePng(signCanvas, box) { if (!box || !Number.isFinite(box.left)) { throw new Error(没有可导出的签名笔迹); } const dpr window.devicePixelRatio || 1; const left Math.max(0, box.left); const top Math.max(0, box.top); const width Math.max(1, box.right - left); const height Math.max(1, box.bottom - top); const out document.createElement(canvas); out.width Math.ceil(width * dpr); out.height Math.ceil(height * dpr); const ctx out.getContext(2d); // 不填背景色保持透明 ctx.drawImage( signCanvas, left * dpr, top * dpr, width * dpr, height * dpr, 0, 0, out.width, out.height ); const blob await new Promise((resolve, reject) { out.toBlob((b) (b ? resolve(b) : reject(new Error(签名导出失败))), image/png); }); return { bytes: new Uint8Array(await blob.arrayBuffer()), cssBox: { left, top, width, height }, }; }这里最容易出错的是坐标单位。box存的是签名 canvas 的 CSS 像素坐标signCanvas内部像素是 CSS 尺寸乘 DPR所以drawImage的源坐标要乘dpr源宽高也要乘dpr目标 canvas 的宽高同样按 DPR 放大。cssBox保留 CSS 尺寸后面做 PDF 坐标转换时要用它不要拿像素尺寸去算。签名笔迹为空时直接抛错业务层提示“请先签名”不要导出一张 1x1 的透明图。4.3 convertToPdfPoint把 CSS 坐标换成 PDF 坐标落版的核心不是手算x cssX / scale而是用 PDF.js viewport 自带的convertToPdfPoint。这个方法会把页面旋转、缩放、偏移都算进去返回 PDF 用户空间坐标pdf-lib 的drawImage正好吃这套坐标。下面这段导出函数先加载原始 PDF拿到目标页再把签名包围盒的左上角和右下角分别转成 PDF 坐标取最小 x、最小 y 作为图片左下角宽高取绝对值。drawImage的y是图片左下角不是左上角这是 pdf-lib 和屏幕坐标最大的区别。import { PDFDocument } from pdf-lib; async function exportSignedPdf({ rawPdf, pageIndex, viewport, signPng, cssBox }) { const pdfDoc await PDFDocument.load(rawPdf); const pages pdfDoc.getPages(); const target pages[pageIndex]; if (!target) throw new Error(目标页不存在); // 签名包围盒相对 PDF canvas 的 CSS 坐标转成 PDF 用户空间坐标 const [x1, y1] viewport.convertToPdfPoint(cssBox.left, cssBox.top); const [x2, y2] viewport.convertToPdfPoint( cssBox.left cssBox.width, cssBox.top cssBox.height ); const x Math.min(x1, x2); const y Math.min(y1, y2); const width Math.abs(x2 - x1); const height Math.abs(y2 - y1); const png await pdfDoc.embedPng(signPng); target.drawImage(png, { x, y, width, height }); const out await pdfDoc.save(); return new Blob([out], { type: application/pdf }); }这段代码的输入要卡死cssBox必须是签名包围盒相对 PDF canvas 左上角的 CSS 坐标不能是相对浏览器窗口的clientX/clientYviewport必须是当前页当前缩放下的 viewport不能拿第一页的 viewport 去算第二页pageIndex从 0 开始和 UI 上的页码差 1。convertToPdfPoint返回的坐标已经处理了旋转页所以横版、竖版、90 度旋转页都能落对位置前提是 viewport 是这一页自己的。embedPng接受Uint8Array不要传 base64 字符串。pdfDoc.save()返回Uint8Array包成 Blob 后走下载。4.4 多页签名每页独立存别共用一套坐标多页场景不要只维护一个全局包围盒。每页的 viewport 尺寸、缩放比例、旋转角度都可能不同第一页的 scale 不能套到第二页。常见做法是用Map按页码存{ cssBox, pngBytes }切换页时把当前页签名存进去再清空签名层导出时遍历 Map逐页embedPng、逐页drawImage。下面这段是状态管理的骨架重点看“切页先保存、导出再遍历”这个顺序。const signatures new Map(); function saveCurrentSignature(pageNo, png, cssBox) { signatures.set(pageNo, { png, cssBox }); } async function exportAll({ rawPdf, viewports, pdfDocPages }) { const pdfDoc await PDFDocument.load(rawPdf); for (const [pageNo, sig] of signatures.entries()) { const pageIndex pageNo - 1; const target pdfDoc.getPages()[pageIndex]; const viewport viewports.get(pageNo); if (!target || !viewport) continue; const [x1, y1] viewport.convertToPdfPoint(sig.cssBox.left, sig.cssBox.top); const [x2, y2] viewport.convertToPdfPoint( sig.cssBox.left sig.cssBox.width, sig.cssBox.top sig.cssBox.height ); const png await pdfDoc.embedPng(sig.png); target.drawImage(png, { x: Math.min(x1, x2), y: Math.min(y1, y2), width: Math.abs(x2 - x1), height: Math.abs(y2 - y1), }); } return new Blob([await pdfDoc.save()], { type: application/pdf }); }这段代码里viewports要按页缓存不能只存当前页。signatures的 key 用 UI 页码导出时减 1 转成数组下标。pdfDocPages这个参数在实际代码里可以去掉直接从pdfDoc.getPages()取。多页导出时每次embedPng都会把图片写进 PDF 资源字典同一张签名图如果重复用可以只embed一次然后多次drawImage但不同页的签名通常不同按页存更直观。导出顺序不影响结果但建议按页码顺序写方便排查。5. 避坑排查六类翻车现场与验收动作5.1 预览与资源加载的坑现象控制台报Setting up fake worker failedPDF 区域一直空白。原因通常是workerSrc路径不对或者打包器把 worker 文件塞进了 chunk浏览器按 URL 取不到。解决动作很直接去构建产物里找pdf.worker.min.js复制到静态目录把GlobalWorkerOptions.workerSrc写成同源绝对路径再用浏览器直接访问这个 URL确认返回 200 且 MIME 是 JavaScript。不要依赖相对路径H5 路由带 hash 或二级目录时相对路径很容易偏。现象中文 PDF 打开后部分文字空白、乱码英文 PDF 正常。原因是缺 cMap 文件PDF.js 找不到字符映射表只能跳过。解决方法是把 cMap 目录完整复制到静态资源里cMapUrl指向目录并以斜杠结尾cMapPacked和目录里的文件格式保持一致。标准字体目录也一起配上老 PDF 缺字体时会走替代逻辑。验收时不要只测自己生成的 PDF找一份带中文嵌入字体的样例和一份没嵌入字体的样例各测一次。现象移动端点击预览浏览器直接下载 PDF页面根本没渲染。这不是 PDF.js 的问题而是文件响应头带了Content-Disposition: attachment或者用 iframe 打开了下载链接。H5 预览应该先用fetch或File拿到ArrayBuffer再交给 PDF.js不要直接把 URL 塞给 iframe。如果文件在服务端接口返回二进制流前端用response.arrayBuffer()接住。大文件可以配合Range请求但首版先把完整 buffer 跑通再考虑分片。5.2 签名坐标与导出的坑现象签名看起来在落款处导出后整体偏上或偏左。血泪经验九成是 CSS 坐标和 canvas 像素坐标混用了。getBoundingClientRect拿到的是 CSS 像素签名 canvas 内部像素乘了 DPR导出时如果直接拿 CSS 坐标去裁像素就会偏。解决方式是包围盒只存 CSS 坐标裁 PNG 时统一乘 DPR转 PDF 坐标时只用 CSS 坐标和viewport.convertToPdfPoint。不要在中间步骤手动加减scrollTopconvertToPdfPoint的输入是相对 PDF canvas 的坐标不是相对窗口。现象签名导出后模糊放大看边缘有锯齿。原因是签名 canvas 的内部宽高等于 CSS 宽高没有乘 DPR实际像素密度不够。解决方式是在初始化签名层时同时设置canvas.width cssWidth * dpr和canvas.style.width cssWidth px绘制前ctx.setTransform(dpr, 0, 0, dpr, 0, 0)。导出裁剪时也按 DPR 放大离屏 canvas。注意 DPR 不要超过 3部分低端机 DPR 虚高乘太大反而卡顿可以Math.min(window.devicePixelRatio || 1, 3)。现象签名 PNG 贴到 PDF 上变成黑底或白底盖住原文。原因是导出时给离屏 canvas 填了背景色或者用了 JPEG。PNG 本身支持透明toBlob(image/png)默认保留透明通道前提是不要fillRect白色。如果业务要求白底签名那就在导出前明确填白并且接受它会盖住底下内容。另一个隐蔽原因是 CSS 给签名 canvas 加了background: #fff视觉上看不出来导出时 canvas 像素里没有背景但用户以为有排查时容易绕晕。签名层 CSS 背景必须是transparent。现象iOS 上点击导出没反应安卓正常。a.download在部分 iOS 浏览器里行为不一致异步生成 Blob 后再触发下载可能被当成非用户手势拦截。解决方式是把导出动作放在用户点击事件的第一层调用栈里先同步创建空 Blob 或打开占位再异步填充或者导出后展示一个预览页提示用户长按保存。不要在一个await之后再创建a标签并click()那样很容易被拦。验收时真机测不要只看桌面模拟器。现象加密 PDF 加载时报错或者导出后文件打不开。PDFDocument.load遇到加密 PDF 会抛错这是正常保护。不要用ignoreEncryption: true硬加载再保存很多加密文档保存后会损坏。解决方式是捕获错误提示用户先输入密码或先解密业务上把“不支持加密 PDF”写进上传说明。如果确实要支持密码得用支持解密的后端或专门的 PDF 库前端轻量方案不要硬扛。验收样例里放一份加密 PDF确认错误提示能出来而不是白屏。6. 进阶验收用基准页和压缩比把签名 PDF 管住6.1 三张基准页每次改坐标都跑一遍我后来固定用三张基准页做回归A4 竖版、Letter 横版、旋转 90 度的 A4。每张页在左上、右下、中线各签一次导出后用 PDF.js 重新加载检查签名是否还在原位置。表格比肉眼靠谱样例检查点通过标准A4 竖版四角 中线签名不越界位置与预览一致Letter 横版缩放 80% / 120%缩放后重签、导出仍对齐旋转 90 度旋转页落款convertToPdfPoint后不偏移这套验收不花哨但能拦住大多数坐标翻车。尤其是旋转页手算坐标的方案几乎必挂用 viewport 转换的方案才能过。6.2 签名图别无限大裁完再压一道签名 PNG 只裁包围盒还不够手写笔迹在 DPR 3 的设备上可能导出上千像素宽多页叠加后 PDF 体积涨得很快。我一般会在裁剪后加一道最大边限制超过 800px 的按比例缩到 800px再embedPng。签名是手写线条800px 足够打印看清再大只是浪费。压缩时用离屏 canvas 重绘保持 PNG 透明不要转 JPEG。导出后对比文件大小和打印效果找到一个业务能接受的平衡点。从那以后我每次改坐标逻辑都强制先跑这三张基准页再谈 UI 细节希望帮到你。本文还有配套的精品资源点击获取
延伸阅读

更多相关文章

2026/10/30 6:01:12

RV1103 NPU部署图像分类模型:实测与调优指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/30 6:01:12

放弃Arduino IDE:VScode+ESP-IDF搭建ESP32开发环境实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/30 6:01:12

单片机控制板故障排查六步法:解决死机、复位与干扰

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/30 7:11:15

vLLM推理性能优化:Nsight工具实战指南

1. 项目概述:当vLLM遇上Nsight性能剖析套件在大模型推理领域,vLLM已成为开源社区最受关注的高性能推理框架之一。其创新的PagedAttention内存管理机制和连续批处理技术,使得单卡可承载的并发请求量提升3-4倍。但在实际生产部署中,…

2026/10/30 7:11:15

Unity 3D太阳系VR模型:轨道交互与性能优化实践

简介:基于Unity 3D的太阳系VR模型是一份面向虚拟现实爱好者、Unity引擎学习者以及具备初级到中级水平的3D交互开发者的完整工程实例,它围绕太阳与八大行星按真实比例建模、轨道运行与沉浸式漫游展开,可用于课程设计、VR入门练习或教学演示参考…

2026/10/30 7:11:15

游戏设计实战:从机制拆解到原型开发的完整方法论

1. 游戏设计实践指南(三):从概念到原型的完整路径在游戏行业摸爬滚打十几年,我见过太多团队在早期开发阶段就陷入泥潭。上周还有个独立开发者向我抱怨:"明明想做个roguelike卡牌游戏,结果三个月过去连…

2026/10/30 7:11:15

信号检测与估计习题解答:从假设检验到克拉美-罗下界

简介:围绕《信号检测与估计理论与应用》关欣译本整理的课后答案包,面向通信工程、信号处理及相关专业的学习者,用于对照教材习题梳理检测与估计的基本框架,并借助可运行材料检查理解偏差。压缩包共95个文件、约9.55MB,…

2026/10/30 7:11:15

GigaBrain-0:达摩院VLA模型在具身智能中的创新实践

1. GigaBrain-0项目概述GigaBrain-0是阿里巴巴达摩院最新推出的VLA(Vision-Language-Action)模型,专为具身智能场景设计。这个项目最吸引我的地方在于它创造性地将世界模型生成数据与真实机器人数据结合,大幅降低了传统机器人学习…

2026/10/30 7:06:15

STM32F407实现硬实时USB音频声卡(48kHz/16bit双通道)

简介:本资源是面向嵌入式音频开发者的STM32 USB Audio设备进阶实践项目,聚焦于48kHz/16bit高保真双通道音频输入输出功能实现,适用于USB声卡、会议终端、双麦录音等实时音频场景,适合具备HAL库基础的中级以上STM32开发者快速验证U…

2026/10/29 17:05:52

全国计算机二级C语言真题PDF高效刷题指南

简介:这份PDF是全国计算机二级C语言考试的试题与答案解析,专为备考二级C语言的考生准备。内容涵盖C语言特点与设计理念、程序基本组成单位、main函数执行流程、函数嵌套调用规则、标识符命名规范、整型实型字符型数据类型、八进制十六进制数合法形式、运…

2026/10/29 17:06:02

requests+BeautifulSoup:爬虫抓取h1标题的入门实战指南

简介:一份以实战为导向的Python爬虫入门资源,面向具备基本编程基础、希望快速掌握网络数据抓取的初学者,可解决从零编写简单爬虫的常见困惑。实例以requests库发送HTTP请求、BeautifulSoup解析HTML文档,完整演示从网页中提取 标题…

2026/10/29 17:06:13

递推最小二乘参数辨识MATLAB编程:二阶系统与渐消记忆实现

简介:这是一篇面向控制系统、信号处理与机器学习等领域学习者的最小二乘参数辨识程序说明文档。资源共包含一个docx格式文档,压缩包大小七十八KB,内容围绕递推最小二乘算法和渐消记忆最小二乘算法展开:先构造一个极点位于单位圆内…

2026/10/30 0:00:46

NCP1342高频准谐振反激控制器:从设计约束到验收的完整方案

简介:NCP1342 高频准谐振反激控制器应用介绍译文文档,面向电源设计工程师、硬件研发人员及开关电源学习者,帮助读者跨越英文原版资料的语言门槛,系统理解这款高集成度 QR Flyback 控制器的设计要点。文档围绕芯片核心特性展开&…

2026/10/30 0:00:46

NCP1342准谐振反激电源设计:从选型到验收的全流程解析

简介:这份文档面向电源设计工程师与开关电源学习者,系统梳理NCP1342高频准谐振反激控制器的应用要点,帮助读者快速理解这款高集成度离线电源变换器控制芯片的工作机制与设计方法。内容涵盖芯片核心特性、引脚功能定义、电气特性参数表、最大额…

2026/10/30 0:00:46

Web系统自动登出机制设计与实现全解析

1. 系统自动登出机制的设计初衷在各类Web应用和后台管理系统中,自动登出功能早已成为标配。这个看似简单的功能背后,实际上涉及用户安全、资源管理和体验优化的多重考量。作为从业十多年的老开发,我见过太多因为登出逻辑设计不当导致的安全事…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑