H5移动端PDF在线预览与手写签名完整落地方案

发布时间:2026/10/30 7:44:15
H5移动端PDF在线预览与手写签名完整落地方案 简介面向需要在网页端查看并签署 PDF 的开发者这套 H5 PDF 在线预览与签名示例围绕浏览器内渲染、触屏/鼠标签名采集及签名图像合成展开可用于合同签署、表单确认等交互页面的原型参考。压缩包共 15 个文件以 10 个 JavaScript 脚本为主体配合 2 个 CSS 样式、1 个 HTML 页面和 1 个 PDF 样例整体约 711KB目录轻量便于快速理解页面加载、PDF 渲染与签名交互的代码组织。已有 420 人浏览学习适合前端初中级开发者对照排查 PDF.js 集成、移动端触摸事件与样式适配问题也可作为二次开发的基础示例进一步接入签名落位、导出保存和 HTTPS 传输等业务环节。1. 手机上没有 PDF 插件一套 H5 在线预览加签名的完整落地方案某公司做电子合同业务方提的需求很朴素用户在微信里点开链接翻到第三页在指定位置用手指签个名点提交。真正动手才发现桌面端浏览器自带的 PDF 阅读器在移动端几乎不存在iframe塞 PDF 在 iOS 上只显示第一页Android 各家 WebView 表现还不一致更别提在 PDF 上叠一层手写签名了。所以这一整套「h5 pdf在线预览 并支持签名」的链路只能自己实现PDF.js 负责把文档渲染成 canvas触摸事件在 canvas 上采集手指轨迹pdf-lib 负责把签名图按坐标回写进原 PDF最后导出或上传。这套方案适合做合同签署、巡检单确认、报名表签字、保单回执这类业务的 H5 前端也适合需要在内嵌 WebView 里做文档确认的场景。下面的内容按「选型 → 预览 → 签名 → 合成 → 排错 → 改造」的顺序拆代码都是能直接抄进项目的量级。2. 技术选型与目录结构pdfjs-dist、Canvas、pdf-lib 三层怎么分工2.1 三个库各管一段为什么不用 iframe 和后端转图先把最容易走歪的两条路堵死。第一条是iframe srcxxx.pdf或者embed桌面 Chrome 能用到了移动端直接翻车iOS Safari 只会渲染首页并且不给任何回调Android 上部分 WebView 干脆触发下载。第二条是把 PDF 传到服务端转成图片序列再返回清晰度受限于图片分辨率且签名回写要再跑一趟服务端链路长、首屏慢一份十几页的合同要等好几秒。拆开看一个「预览 签名」的需求其实是三件独立的事每件事对应一个库把 PDF 画出来——pdfjs-distPDF.js 的 npm 包。它把 PDF 的页面内容解析成绘制指令输出到 canvas。好处是纯前端、支持分页、支持缩放重绘、能拿到原始页面尺寸和坐标变换矩阵。把手指轨迹记下来—— 原生 Canvas 2D。不需要signature_pad之类的封装因为我们要的是「归一化坐标 笔锋宽度」这种可复用的数据结构封装库反而把点数据藏起来了导出后拿不到轨迹。把签名图写回 PDF——pdf-lib。纯 JS 的 PDF 读写库能加载原始字节、嵌入 PNG、在指定坐标画图再save()出新的Uint8Array。它的坐标系原点在页面左下角这点和 PDF.js 的convertToPdfPoint输出一致省掉一次翻转。三层之间只传两种东西PDF 的ArrayBuffer和一组归一化坐标点。模块边界清楚后面换 UI 框架、换渲染方式都不影响核心逻辑。提示不要试图直接修改 PDF 的注释层Annotation来做签名。PDF 的签名域AcroForm Signature Field需要数字证书和哈希校验属于电子签章范畴和「手写签名图片盖上去」是两回事。业务上叫「签名」技术上就是「往页面上画一张透明 PNG」。2.2 目录结构与模块边界工程用 Vue 3 Vite 搭这是这类 H5 项目最常见的组合React 或原生也一样套核心逻辑全部抽到src/core下和框架解耦h5-pdf-sign/ ├── public/ │ └── pdf.worker.min.js # worker 单独放避免打包器改路径 ├── src/ │ ├── components/ │ │ ├── PdfViewer.vue # 页面容器 虚拟滚动 │ │ ├── PdfPage.vue # 单页 canvas 签名图层 │ │ ├── SignaturePad.vue # 全屏签名板弹层 │ │ └── SignToolbar.vue # 撤销、清除、确认 │ ├── core/ │ │ ├── loader.js # 加载 PDF、缓存 pdfDoc │ │ ├── renderer.js # 单页渲染、dpr 处理 │ │ ├── coords.js # 屏幕坐标 ↔ PDF 坐标 │ │ ├── signature.js # 签名板笔迹模型 │ │ └── composer.js # pdf-lib 回写与导出 │ ├── hooks/ │ │ └── usePdfDocument.js # 文档生命周期、页码状态 │ ├── utils/ │ │ └── file.js # Blob 下载 / FormData 上传 │ └── main.js ├── vite.config.js └── package.jsoncore下的文件不接受任何框架 API全是纯函数或普通 class这样单元测试好写将来接到小程序或 uni-app 里也能直接搬。components只负责 DOM 和交互状态。2.3 依赖清单与 worker 的引入方式npm i pdfjs-dist pdf-lib # pdfjs-dist 4.x 之后主包和 worker 必须是同一个版本 # 升级时两个一起升不要只升一个worker 的引入是最容易踩的一个点。pdfjs-dist默认会去猜 worker 路径在 Vite / Webpack 下经常猜错表现是控制台报Setting up fake worker failed或者干脆白屏。稳妥写法是用打包器的?url语法把 worker 当静态资源拿到最终 URL// src/core/loader.js import * as pdfjsLib from pdfjs-dist import workerUrl from pdfjs-dist/build/pdf.worker.min.js?url // 只在应用入口执行一次 pdfjsLib.GlobalWorkerOptions.workerSrc workerUrl /** * 加载 PDF 文档 * param {ArrayBuffer|string} source 文件字节流或已鉴权的 URL * returns {PromisepdfjsLib.PDFDocumentProxy} */ export async function loadPdf(source) { const task pdfjsLib.getDocument({ data: typeof source string ? undefined : source, url: typeof source string ? source : undefined, // 中文字体包不传的话部分扫描件的中文会渲染成方块 cMapUrl: /cmaps/, cMapPacked: true, // 只下载需要的页十几页的合同首屏能快一半 rangeChunkSize: 65536, }) return task.promise }参数说明cMapUrl指向中文字符映射表pdfjs-dist包里自带cmaps目录构建时拷到public/cmaps即可这是国内项目的高频坑不配的话带中文的 PDF 会出现一堆方框。rangeChunkSize配合服务端支持Range请求时才有意义能让大文件按需分段下载。注意getDocument返回的是PDFDocumentLoadingTask不是文档本身。组件卸载时一定要调task.destroy()否则 worker 线程和已解码的页面资源会一直挂在内存里来回切几次页面就能把低端安卓机的内存吃干净。3. 预览层拆解一份 PDF 从字节流到页面 canvas 的渲染链路3.1 单页渲染scale、devicePixelRatio 与 canvas 尺寸的三方关系移动端 canvas 糊成一片九成原因是这三者的关系没理清。CSS 像素和物理像素之间差一个devicePixelRatio而 PDF.js 的viewport是按 CSS 像素算的。正确做法是用dpr放大后的 viewport 去渲染用原始 viewport 去设置 CSS 尺寸这样 canvas 的位图分辨率是屏幕的两倍显示尺寸不变视觉上就清晰了。// src/core/renderer.js /** * 渲染一页到 canvas * param {pdfjsLib.PDFPageProxy} page * param {HTMLCanvasElement} canvas * param {number} scale 基础缩放1 表示 72dpi 原始尺寸 * returns {PromisepdfjsLib.PageViewport} 返回基础 viewport坐标换算要用 */ export async function renderPage(page, canvas, scale 1.5) { // 低端机 dpr 可能报到 3 甚至 4限制在 2 以内兼顾清晰度和显存 const dpr Math.min(window.devicePixelRatio || 1, 2) const baseViewport page.getViewport({ scale }) // 用于坐标换算 const renderViewport page.getViewport({ scale: scale * dpr }) // 用于实际绘制 canvas.width Math.floor(renderViewport.width) canvas.height Math.floor(renderViewport.height) canvas.style.width ${Math.floor(baseViewport.width)}px canvas.style.height ${Math.floor(baseViewport.height)}px const ctx canvas.getContext(2d, { alpha: false }) ctx.fillStyle #fff ctx.fillRect(0, 0, canvas.width, canvas.height) const task page.render({ canvasContext: ctx, viewport: renderViewport, // 关键告诉 PDF.js 这次绘制被放大了 dpr 倍 // 否则文字层的抗锯齿和路径描边会按 dpr 倍偏移 transform: [dpr, 0, 0, dpr, 0, 0], }) await task.promise return baseViewport // 一定要返回这个后面算坐标全靠它 }render返回的RenderTask带cancel()。快速翻页时上一页还没画完就要画下一页不取消的话会出现两页内容叠在同一个 canvas 上看起来像 PDF 内容错乱实际是并发渲染。封装一层时给每个页面记一个task重绘前先cancel()。transform这个参数是老版本的遗留设计新版pdfjs-dist直接用scale: scale * dpr也能正确绘制文字但加上transform后路径和图像会更锐利。这两个方式二选一即可同时用会导致二次缩放这也是个经典翻车点签名坐标会整体偏出去。3.2 多页与长文档虚拟滚动和 canvas 回收十页以内的合同全部渲染问题不大三十页的体检报告一次性创建三十个 canvas每个都是 dpr 倍尺寸内存直接爆。常见的做法是给每页一个占位div高度按baseViewport算好然后用IntersectionObserver监听可见性进入视口前后一屏才真正渲染离开视口若干屏就销毁 canvas 内容。// src/components/PdfViewer.vue 里的关键逻辑伪代码结构 const observer new IntersectionObserver( (entries) { for (const entry of entries) { const index Number(entry.target.dataset.pageIndex) if (entry.isIntersecting) { renderIfNeeded(index) // 进入视口渲染 } else if (Math.abs(entry.boundingClientRect.top) window.innerHeight * 2) { releasePage(index) // 离得够远释放位图 } } }, { rootMargin: 100% 0px } // 提前一屏开始渲染滑动时看不到白块 ) function releasePage(index) { const canvas canvasMap.get(index) if (!canvas) return // 置空宽高是释放 canvas 位图的唯一可靠方式 canvas.width 0 canvas.height 0 canvasMap.delete(index) renderTaskMap.get(index)?.cancel() renderTaskMap.delete(index) }rootMargin: 100% 0px的意思是上下各提前一屏触发用户在快速滑动时不会看到占位白框。releasePage里把width/height置 0 是重点仅把 canvas 从 DOM 里移除并不会立刻释放显存在 iOS 上尤其明显滑动几十页后整个页面会卡顿到不可用。3.3 文本层与横竖屏切换纯预览不要求选中复制的话可以不渲染文本层省下不少 DOM 节点。但如果业务方要「长按复制合同编号」就需要page.getTextContent()拿到文本片段按viewport.transform算出每条文本的位置用绝对定位的span盖在 canvas 上再用pdf_viewer.css里的样式做透明化。横竖屏切换时容器宽度变了scale要重算并触发全部已渲染页面重绘。这里的血泪经验是不要监听resize直接重绘移动端浏览器地址栏收起也会触发resize会连续触发十几次。加个 200ms 的防抖并且只有宽度变化超过 20px 才重绘。4. 签名与合成手指轨迹怎么变成 PDF 里一块固定的图画4.1 为什么签名板要存归一化坐标签名板弹层的尺寸和设备有关iPhone 上是 375×220iPad 上是 700×300。如果直接存像素坐标换设备、转屏幕、甚至弹层从全屏切成半屏笔迹就全乱了。所以签名板内部统一按「相对签名板宽高的 0~1 比例」存点只在绘制时乘回实际像素。// src/core/signature.js export function createPoint(event, canvas, now) { const rect canvas.getBoundingClientRect() return { x: (event.clientX - rect.left) / rect.width, // 0~1 y: (event.clientY - rect.top) / rect.height, // 0~1 t: now, w: 0, // 该点的线宽绘制时填 } }这样一套点数据既可以渲染到任意尺寸的签名板也可以渲染到导出用的离屏 canvas 上还能直接序列化存到后端下次打开时回显。归一化在这里不是洁癖是唯一的正确解法。4.2 手指轨迹怎么变成平滑笔迹直接lineTo连接采样点画出来的字棱角分明像火柴棍拼的。要平滑用中点加二次贝塞尔每两个相邻点的中点作为曲线端点中间那个采样点作为控制点逐段往后画。同时用两点之间的距离除以时间差算出速度速度快的段细、速度慢的段粗模拟毛笔的提按。// src/core/signature.js export class SignaturePad { constructor(canvas, { minWidth 1.2, maxWidth 3.6 } {}) { this.canvas canvas this.ctx canvas.getContext(2d) this.dpr Math.min(window.devicePixelRatio || 1, 3) this.minWidth minWidth this.maxWidth maxWidth this.strokes [] // 多笔[[point, point...], [...]] this.current null this.lastTime 0 this.resize() this.bind() } resize() { const rect this.canvas.getBoundingClientRect() this.cssW rect.width this.cssH rect.height this.canvas.width Math.round(rect.width * this.dpr) this.canvas.height Math.round(rect.height * this.dpr) // 用 setTransform 而不是 scale避免多次 resize 后缩放累积 this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0) this.redraw() } /** 速度映射线宽慢 → 粗快 → 细 */ widthFor(speed) { const k Math.min(speed / 1.6, 1) return this.maxWidth - (this.maxWidth - this.minWidth) * k } bind() { // 必须设为 none否则手指在 canvas 上滑动会触发页面滚动 this.canvas.style.touchAction none this.canvas.addEventListener(pointerdown, (e) { this.canvas.setPointerCapture(e.pointerId) const t performance.now() const p { ...this.toLocal(e), t, w: this.maxWidth } this.current [p] this.strokes.push(this.current) this.lastTime t }) this.canvas.addEventListener(pointermove, (e) { if (!this.current) return // 高刷屏一帧内可能有多个采样点不取出来线条会断成一节一节 const list e.getCoalescedEvents ? e.getCoalescedEvents() : [e] for (const ev of list) { const t performance.now() const p this.toLocal(ev) const last this.current[this.current.length - 1] const dist Math.hypot(p.x - last.x, p.y - last.y) * Math.max(this.cssW, this.cssH) const dt Math.max(t - this.lastTime, 1) p.w this.widthFor(dist / dt) // px/ms p.t t this.current.push(p) this.lastTime t } this.drawTail() }) const finish () { this.current null } this.canvas.addEventListener(pointerup, finish) this.canvas.addEventListener(pointercancel, finish) this.canvas.addEventListener(pointerleave, finish) } /** 只画新增的最后一段避免每次 move 都全量重绘 */ drawTail() { const pts this.current const n pts.length if (n 3) return const a pts[n - 3], b pts[n - 2], c pts[n - 1] const ctx this.ctx ctx.beginPath() ctx.lineWidth b.w ctx.lineCap round ctx.lineJoin round ctx.strokeStyle #000 // 以上一段的中点为起点b 为控制点画到 b、c 的中点 ctx.moveTo(((a.x b.x) / 2) * this.cssW, ((a.y b.y) / 2) * this.cssH) ctx.quadraticCurveTo( b.x * this.cssW, b.y * this.cssH, ((b.x c.x) / 2) * this.cssW, ((b.y c.y) / 2) * this.cssH ) ctx.stroke() } toLocal(e) { const rect this.canvas.getBoundingClientRect() return { x: (e.clientX - rect.left) / rect.width, y: (e.clientY - rect.top) / rect.height, } } }pointer事件比touch好用一套代码同时覆盖鼠标、触摸笔和手指签名板。setPointerCapture保证手指滑出 canvas 边界后仍能收到pointermove不然写到边缘笔画就断了。getCoalescedEvents是移动端签名顺滑度的分水岭不开的话快速连笔会明显看到折线。导出签名图时直接canvas.toDataURL(image/png)透明底、位图已经是 dpr 倍分辨率贴进 PDF 里够清晰。别用 JPEG白底会把 PDF 正文盖住一块。4.3 屏幕坐标到 PDF 坐标的换算这是整个项目最容易出错的一步值得单拎出来说。用户在页面上拖了一个签名框框的位置是相对 canvas 的 CSS 像素而pdf-lib要的是 PDF 用户空间的坐标单位是 point1/72 英寸原点在页面左下角。PDF.js 的viewport上挂着一个convertToPdfPoint(x, y)输入 viewport 坐标系也就是渲染时传给page.render的那个 viewport 的 CSS 像素输出 PDF 用户坐标。用两个对角点分别转换再取最小值和差值就能兼容页面旋转 90/180/270 度的情况// src/core/coords.js /** * 把 canvas 上的 CSS 矩形换算成 PDF 用户空间的矩形 * param {pdfjsLib.PageViewport} viewport 渲染时用的 baseViewport未乘 dpr * param {{left:number, top:number, width:number, height:number}} rect canvas 内坐标 */ export function cssRectToPdf(viewport, rect) { 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), width: Math.abs(x2 - x1), height: Math.abs(y2 - y1), } } /** * DOM 事件坐标 → canvas 内 CSS 坐标。 * 当 canvas 被 CSS 拉伸例如 width:100%时必须做这一步换算 */ export function clientToCanvas(canvas, viewport, clientX, clientY) { const box canvas.getBoundingClientRect() const scaleX viewport.width / box.width // 注意viewport 用未乘 dpr 的那个 const scaleY viewport.height / box.height return { x: (clientX - box.left) * scaleX, y: (clientY - box.top) * scaleY, } }cssRectToPdf的四个参数必须和renderPage里返回的那个baseViewport是同一个对象用scale: 1现算一个新的也能得到正确结果因为convertToPdfPoint内部自带逆变换。真正会出事的是拿renderViewport乘过 dpr 的去换算坐标会整体缩小一半签名贴上去位置全错。4.4 pdf-lib 回写与导出拿到 PDF 坐标矩形和签名 PNG 之后剩下的工作就是把图贴上去// src/core/composer.js import { PDFDocument } from pdf-lib /** * 把签名图盖到指定页的指定位置 * param {ArrayBuffer} pdfBytes 原始 PDF 字节 * param {Array{pageIndex:number, rect:object, pngDataUrl:string}} marks 签名标记列表 * returns {PromiseUint8Array} */ export async function composeSignatures(pdfBytes, marks) { const pdfDoc await PDFDocument.load(pdfBytes, { // 只读加密仅限制编辑、不限制打开的 PDF 也能处理 // 但真正带打开密码的会抛 EncryptedPDFError需要后端先解密 ignoreEncryption: true, updateMetadata: false, }) const pages pdfDoc.getPages() for (const mark of marks) { const page pages[mark.pageIndex] if (!page) continue // 透明 PNG 会被原样嵌入不产生白色背景块 const image await pdfDoc.embedPng(mark.pngDataUrl) page.drawImage(image, { x: mark.rect.x, y: mark.rect.y, width: mark.rect.width, height: mark.rect.height, opacity: 1, }) } return pdfDoc.save() } /** 触发下载iOS 上建议改用上传后端返回链接 */ export function downloadPdf(bytes, filename signed.pdf) { const blob new Blob([bytes], { type: application/pdf }) const url URL.createObjectURL(blob) const a document.createElement(a) a.href url a.download filename document.body.appendChild(a) a.click() a.remove() // 稍后释放立即释放会导致部分浏览器下载失败 setTimeout(() URL.revokeObjectURL(url), 3000) }drawImage的x、y指的是图片左下角在 PDF 页面上的位置单位是 point和cssRectToPdf返回的语义完全对齐不需要再做 Y 轴翻转。embedPng接受 dataURL 字符串也接受ArrayBuffer传 dataURL 更方便但内存占用略高签名图通常只有几十 KB差别可以忽略。导出后的验证方式很简单把pdfDoc.save()的结果丢进桌面浏览器的 PDF 阅读器缩放到 400% 看签名边缘是否清晰。清晰说明走的是矢量嵌入的位图且分辨率足够如果边缘有明显锯齿多半是签名板导出时没带 dpr。5. 避坑排查那些只在真机上才冒出来的问题5.1 PDF 白屏控制台报 worker 版本不匹配现象本地开发一切正常打包上线后预览区一片白控制台出现The API version 4.x.x does not match the Worker version 4.y.y。原因pdfjs-dist主包和 worker 必须严格同版本。用?url引入时如果node_modules里存在两份不同版本的pdfjs-dist通常是对依赖包的锁版本不一致导致的打包器可能把旧版本的 worker 拷进来。另一种情况是把 worker 手动放到public目录后忘了跟着升级。解决不要在public里手放 worker 文件一律走import workerUrl from pdfjs-dist/build/pdf.worker.min.js?url锁定版本时把pdfjs-dist写死精确版本号并在 CI 里加一条npm ls pdfjs-dist的检查出现多个版本直接失败。5.2 签名位置整体偏移且页面旋转后偏得更多现象签名画在第 2 页正中间导出后在 PDF 里跑到了左上角页面顺时针旋转 90 度的 PDF偏移方向还会变。原因换算时用错了 viewport。常见有三种用了乘过 dpr 的renderViewport用了page.getViewport({ scale: 1 })而渲染时用的是scale: 1.5页面容器有padding或transform: scale()导致getBoundingClientRect拿到的尺寸和 canvas 的实际 CSS 尺寸不一致。解决把「渲染时返回的 baseViewport」作为唯一坐标基准往下传所有换算函数签名里强制带上它clientToCanvas里用viewport.width / box.width动态算比例不要假设box.width viewport.width旋转页面用两个对角点转换再取 min/差值别自己写if (rotate 90)的分支。5.3 手指一滑动页面就跟着滚签名断断续续现象在签名板上写字页面跟着上下滚画出来的笔画只剩零星几段。原因touchmove默认会触发页面滚动浏览器在滚动开始后会取消后续的 touch 事件同时如果没有设置touch-actionpointer 事件可能在手指移动一段距离后被判定为滚动手势而中断。解决canvas 上设style.touchAction none签名弹层打开时给document.body加overflow: hiddenpointerdown里调setPointerCapture(e.pointerId)同时在pointermove里用getCoalescedEvents()把一帧内合并的采样点补回来。5.4 大文件打开后手机发烫、切页卡死现象十几页的扫描件 PDF打开后手机明显发烫滑到后面几页卡顿严重切后台再回来直接白屏。原因所有页面的 canvas 一直挂着没释放。canvas 的位图占的是显存一张 dpr2 的 A4 页面大约 8~10MB二十页就是 200MB移动端直接顶到上限。解决用IntersectionObserver做懒渲染和回收离开视口两屏以上的页面把canvas.width 0重置组件卸载时调pdfDoc.destroy()而不是仅仅置空引用在线预览扫描件时把dpr上限压到 1.5肉眼几乎看不出差别但内存能省三成。5.5 加密 PDF 加载报 EncryptedPDFError现象pdf-lib的PDFDocument.load()抛EncryptedPDFError但同一份文件在浏览器里能正常预览。原因PDF 的加密分两种一种是「打开密码」一种是「权限密码」只限制打印/编辑打开不需要密码。浏览器阅读器和 PDF.js 对后者是宽松放行的pdf-lib默认不放行。解决load()时加ignoreEncryption: true能处理权限密码这一类真正带打开密码的文件ignoreEncryption也只是跳过校验保存出来的文件结构可能不完整稳妥做法是让后端用服务端 PDF 库先解密一份再下发给前端前端只负责画和贴图。注意ignoreEncryption不会帮你解密内容流只跳过检查。如果打开后页面内容是空白但页数正确基本可以确定是加密流没解开别在前端死磕交给服务端。6. 二次开发从单页签名到多签名域与印章叠加的改造路径原始需求是「签一个名」业务跑起来之后大概率会变成「多个人签、签完盖个章、还要能改」。想让这套代码撑住后续需求改造的关键在数据结构把「签名」抽象成「标注mark」而不是写死一个全屏签名弹层。// 一个 mark 的最小结构存后端时直接 JSON.stringify const mark { id: m_1, type: signature, // signature | seal | date | text pageIndex: 2, rect: { x: 128.5, y: 96.2, width: 120, height: 48 }, // PDF 用户坐标 rotate: 0, // 印章常用0 / 90 / 180 / 270 imageKey: oss://.../sign_1.png, createdAt: 1730000000000, }type一扩展composer.js里只需要在drawImage外面套一层switchseal类型加上rotateDegrees参数date类型用pdfDoc.embedFont加page.drawText直接写文本比贴图清晰且可搜索text类型可以走表单域填充。页面这一侧则把「拖拽签名框」做成一个通用的可拖拽矩形组件type只影响框内渲染什么预览图。几个值得优先做的改造点改造点具体做法容易踩的地方多签名域后端下发signFields: [{pageIndex, rect, role}]前端按角色高亮提示后端给的矩形若按「左上角原点、像素单位」算要先除以 96 再翻 Y 轴印章叠加复用drawImage加rotateDegrees和opacity旋转是绕左下角转的转了之后 x/y 要按旋转角重算撤销重做marks数组 一个游标指针撤销就是游标左移重做右移别用pop()销毁数据重做就没法做了草稿保存每次 mark 变化防抖 800ms 存 localStoragekey 带上文档 id只存 mark 和页码别存 PDF 字节localStorage 有 5MB 上限端上回显重新打开时先渲染 PDF再把已有 marks 用绝对定位的img叠在 canvas 上回显图的位置要用convertToViewportPoint反向换算最后说个我自己的习惯。这套东西最贵的不是写代码是验证坐标。所以我现在每接一个 PDF 项目第一件事一定是在页面上开一个调试开关双击任意位置就在控制台打印三组数DOM 事件坐标、canvas 内 CSS 坐标、转换后的 PDF 坐标同时在页面上画个红框标出来。听起来很土但这三组数对齐了后面所有签名、印章、水印的定位都是一次过对不齐还硬往下写就只能靠截图对比反复试那才是真的耗时间。签名这块的玄学部分笔锋、平滑、采样调参数就行坐标这块不行必须靠数据对齐。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询