移动端PDF批注画板:Canvas叠加与坐标绑定实战

发布时间:2026/9/26 22:58:00
移动端PDF批注画板:Canvas叠加与坐标绑定实战
简介这是一份面向移动端 H5 开发者的 Canvas 画板批注 PDF 预览实战资源适合需要在小程序、WebView 或移动浏览器中实现 PDF 阅读与手写批注功能的初中级前端工程师。资源围绕 PDF.js 解析渲染与 Canvas 触控绘图两条主线展开涵盖文档加载、分页渲染、touchstart/touchmove/touchend 事件处理、线条绘制、文本与高亮批注、toDataURL 导出持久化等关键环节并涉及跨域获取文件与性能优化思路。压缩包共 150 个文件约 2.4MB包含 34 个 js 脚本、75 个 gif 演示、11 个 png 与 6 个 jpg 图示、10 个 css 样式及 4 个 html 页面另附 pdf、字体与 json 等辅助文件目录结构清晰便于按模块查阅与二次改造。目前已有 1749 人学习下载可作为移动端 PDF 批注功能的完整参考实现。1. 移动端 PDF 批注画板从「能看」到「能写」的那一层窗户纸很多团队做移动端 PDF 预览第一版几乎都是把文件塞进 iframe 或者embed在桌面浏览器上跑得挺欢一上手机就露馅iOS Safari 要么直接触发下载要么只渲染第一页安卓各家 WebView 对 PDF 插件的支持更是玄学。更关键的是业务方要的从来不只是「看」——他们要在合同上圈重点、在图纸上标尺寸、在试卷上打勾也就是批注。这时候纯 PDF 渲染方案就顶不住了必须把 PDF 当底图上面盖一层 canvas 画板手指画到哪笔迹就落在哪最后再把笔迹和 PDF 页面坐标绑死导出成新的 PDF 或者图片。这套「canvas 移动端画板批注 PDF 预览」的资源包解决的就是这个场景H5 页面里加载 PDF、逐页渲染成 canvas 底图、叠加一层透明画板接收触摸事件、支持画笔/橡皮/撤销/清空、最后把批注层和底图合成导出。它适合做电子合同、在线教育作业批改、工程图纸会审、医疗影像标注这类需要在移动端「边看边写」的开发者。下面我按自己拆包复现的顺序把关键实现和踩过的坑一条条摊开。2. PDF 转 canvas 底图pdf.js 的渲染管线与移动端适配2.1 为什么不用 iframe 而用 pdf.js 自己画iframe 方案的本质是把 PDF 渲染完全交给浏览器内置插件你拿不到页面内容也没法在上面精确叠 canvas——因为 iframe 是个独立文档你的画板只能浮在它上面滚动、缩放、翻页时坐标全乱。而 pdf.js 把 PDF 解析成操作符流再通过 canvas 2D 上下文逐页绘制每一页的渲染结果就是一张你可以完全控制的位图。这意味着你能拿到页面的实际像素尺寸、能监听渲染完成事件、能把画板 canvas 和底图 canvas 放在同一个坐标系里。移动端适配的核心参数是viewport。pdf.js 的page.getViewport({ scale })返回的宽高是 PDF 点point乘以缩放系数1 point 1/72 英寸。手机屏幕的 devicePixelRatio 通常是 2 或 3如果直接用 CSS 像素尺寸渲染字会糊。常见做法是把 canvas 的width/height属性设成viewport.width * dpr再用 CSS 把它缩回viewport.width这样笔迹和文字都锐利。// 渲染单页 PDF 到 canvas 底图 import * as pdfjsLib from pdfjs-dist; async function renderPage(pdfDoc, pageNum, container, scale 1.5) { const page await pdfDoc.getPage(pageNum); const dpr window.devicePixelRatio || 1; // viewport 决定 PDF 坐标系到屏幕坐标系的映射 const viewport page.getViewport({ scale: scale * dpr }); const canvas document.createElement(canvas); canvas.width viewport.width; // 物理像素宽 canvas.height viewport.height; // 物理像素高 canvas.style.width ${viewport.width / dpr}px; // CSS 像素宽 canvas.style.height ${viewport.height / dpr}px; canvas.dataset.pageNum pageNum; const ctx canvas.getContext(2d); await page.render({ canvasContext: ctx, viewport }).promise; container.appendChild(canvas); return { canvas, viewport, dpr }; }逻辑说明scale是业务缩放dpr是屏幕密度两者相乘才是最终渲染倍率。viewport.width是物理像素CSS 宽要除回 dpr否则页面会横向溢出。page.render返回的是 Promise必须 await否则你会在渲染完成前就去叠画板导致底图空白。参数怎么调scale建议 1.22.0太低字糊太高内存炸。一页 A4 在 scale1.5、dpr3 时canvas 物理尺寸约 1785×2526单页位图内存约 18MB十页就是 180MB移动端很容易被系统杀掉。所以不要一次性渲染所有页只渲染当前页和前后各一页其余用占位 div 撑高度。2.2 页面容器与滚动容器的分工移动端 PDF 预览通常是纵向滚动一页接一页。这里有个容易翻车的点如果你把画板 canvas 直接盖在底图 canvas 上滚动时触摸事件会被画板拦截页面滚不动。正确做法是分两层容器——外层scroll-container负责滚动内层page-wrapper负责定位画板 canvas 只在「批注模式」下才pointer-events: auto浏览模式下设成none让滚动穿透。!-- 页面结构滚动层与画板层分离 -- div idscrollContainer styleoverflow-y:auto; height:100vh; div classpage-wrapper styleposition:relative; canvas classpdf-layer/canvas canvas classdraw-layer styleposition:absolute; top:0; left:0; pointer-events:none;/canvas /div /div逻辑说明draw-layer绝对定位覆盖在pdf-layer上尺寸与底图一致。浏览模式下pointer-events:none手指滑动触发外层滚动点击「批注」按钮后切换为auto触摸事件被画板接管此时需要禁用滚动容器的overflow或者用touch-action: none阻止默认滚动。参数说明touch-action在画板 canvas 上设none告诉浏览器这个元素上的手势不要触发滚动和缩放全部交给 JS 处理。这是移动端画板不跟页面滚动打架的关键。3. 画板批注层触摸事件、笔迹平滑与撤销栈3.1 触摸坐标怎么从屏幕映射到 PDF 页面坐标移动端触摸事件给的是clientX/clientY相对于视口而画板 canvas 有自己的getBoundingClientRect()。要拿到画板内的坐标得做一次减法再乘以 dpr 还原到物理像素。但真正要存进批注数据里的是相对于 PDF 页面的归一化坐标——因为用户可能缩放、旋转、换设备只有归一化坐标才能在任何渲染倍率下还原。// 触摸坐标 - 画板物理像素 - PDF 归一化坐标 function getPoint(canvas, touch, dpr) { const rect canvas.getBoundingClientRect(); const x (touch.clientX - rect.left) * dpr; // 画板物理像素 const y (touch.clientY - rect.top) * dpr; return { x, y, nx: x / canvas.width, // 归一化 0~1与渲染倍率无关 ny: y / canvas.height }; }逻辑说明rect.left/top是画板在视口中的位置减去它得到画板内 CSS 坐标乘 dpr 得到物理像素。nx/ny是归一化坐标存数据库时只存这个回显时乘以当前 canvas 宽高即可还原。参数说明如果页面有缩放比如用户双指放大getBoundingClientRect()返回的是缩放后的尺寸此时 dpr 要换成「当前缩放倍率 × 设备 dpr」否则笔迹会偏移。我一般会在缩放结束时重新计算一次映射矩阵而不是每次触摸都算。3.2 笔迹平滑为什么你的线看起来像折线移动端触摸采样率有限快速划线时相邻两点间距可能几十像素直接lineTo画出来就是一段段折线很生硬。常见做法是用二次贝塞尔曲线做中点平滑取相邻三点的中点作为曲线端点中间点作为控制点。// 中点二次贝塞尔平滑 function drawSmooth(ctx, points) { if (points.length 2) return; ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); for (let i 1; i points.length - 1; i) { const midX (points[i].x points[i 1].x) / 2; const midY (points[i].y points[i 1].y) / 2; ctx.quadraticCurveTo(points[i].x, points[i].y, midX, midY); } // 最后一段直接连到终点 const last points[points.length - 1]; ctx.lineTo(last.x, last.y); ctx.stroke(); }逻辑说明quadraticCurveTo(cpx, cpy, x, y)以当前点为起点(cpx,cpy)为控制点(x,y)为终点。用中点做终点能让曲线在连接处切线连续视觉上顺滑。最后一段没有下一个中点直接lineTo收尾。参数说明ctx.lineWidth建议按 dpr 缩放比如设计稿 2px 笔宽dpr3 时设 6。ctx.lineCapround、ctx.lineJoinround让线头和拐角圆润。如果要做压感可以根据touch.forceiOS 支持动态改 lineWidth但安卓多数不支持得降级成固定宽度。3.3 撤销栈存点还是存图撤销有两种实现一是每画一笔就把当前画板toDataURL存一张图撤销时回退到上一张二是存笔画数据点数组 样式撤销时清空重绘。前者内存爆炸一张全屏 PNG 可能几 MB存二十步就上百 MB后者内存小但重绘有开销。我一般用笔画数组 重绘维护一个strokes数组每笔是{ points, color, width }。撤销就是strokes.pop()然后清空画板、遍历重绘。重绘时用requestAnimationFrame节流避免卡顿。// 笔画数据模型与重绘 const strokes []; let currentStroke null; function redrawAll(ctx, canvas) { ctx.clearRect(0, 0, canvas.width, canvas.height); for (const stroke of strokes) { ctx.strokeStyle stroke.color; ctx.lineWidth stroke.width; drawSmooth(ctx, stroke.points); } } function undo() { strokes.pop(); redrawAll(ctx, drawCanvas); }逻辑说明strokes是唯一数据源画板只是它的渲染结果。撤销、清空、导出都基于这个数组。导出时把底图 canvas 和按 strokes 重绘的批注层合成即可。参数说明如果笔画特别多比如几百笔全量重绘会卡可以加一层「快照」优化——每 50 笔存一张离屏 canvas 快照重绘时先贴快照再画剩余笔画。这是进阶做法一般合同批注几十笔用不上。4. 批注与 PDF 的坐标绑定翻页、缩放、旋转后的对齐4.1 归一化坐标是唯一可靠的数据格式前面提到存归一化坐标这里展开说为什么。假设用户在手机上以 scale1.5 画了一笔归一化坐标是 (0.3, 0.4)。换到平板 scale2.0 打开只要用0.3 * canvas.width就能还原到正确位置。如果存的是物理像素换设备就全错位。旋转同理PDF 页面旋转 90 度后viewport 宽高互换归一化坐标需要配合旋转矩阵变换。// 归一化坐标还原到当前画板 function restoreStrokes(strokes, canvas) { const ctx canvas.getContext(2d); ctx.clearRect(0, 0, canvas.width, canvas.height); for (const stroke of strokes) { const pts stroke.points.map(p ({ x: p.nx * canvas.width, y: p.ny * canvas.height })); ctx.strokeStyle stroke.color; ctx.lineWidth stroke.width * (canvas.width / stroke.baseWidth); drawSmooth(ctx, pts); } }逻辑说明nx/ny乘以当前 canvas 宽高得到物理像素。lineWidth也要按比例缩放否则在大屏上笔迹会显得太细。baseWidth是记录笔画时的 canvas 宽度用来算缩放比。参数说明如果 PDF 页面本身有/Rotate属性pdf.js 的 viewport 会自动处理旋转你拿到的 viewport 已经是旋转后的。此时归一化坐标基于旋转后的页面回显时只要 viewport 一致就没问题。但如果你自己做了 CSS 旋转就要额外维护旋转矩阵容易出错建议交给 pdf.js。4.2 翻页时批注数据的存取时机多页 PDF 的批注要按页存储数据结构常见是{ [pageNum]: strokes[] }。翻页时先把当前页的 strokes 存回 map再加载目标页的 strokes 重绘。这里有个时序坑pdf.js 渲染是异步的如果你在page.render完成前就重绘批注画板尺寸还是旧的笔迹会错位。// 翻页先存当前页再渲染目标页最后恢复批注 async function goToPage(pdfDoc, targetPage) { saveStrokes(currentPage, strokes); // 存当前页 const { canvas, viewport } await renderPage(pdfDoc, targetPage, container); resizeDrawLayer(canvas); // 画板对齐底图 strokes loadStrokes(targetPage); // 取目标页批注 restoreStrokes(strokes, drawCanvas); // 重绘 currentPage targetPage; }逻辑说明saveStrokes把当前 strokes 写进 maprenderPage返回后才调整画板尺寸loadStrokes取出目标页数据restoreStrokes重绘。顺序不能乱否则画板尺寸和底图不一致。参数说明resizeDrawLayer要把画板的width/height属性设成和底图 canvas 一样CSS 尺寸也同步。如果只改 CSS 不改属性画板内部坐标系还是旧的笔迹会缩放错位。5. 避坑与排查移动端画板批注的五个血泪现场5.1 现象iOS Safari 上画板导出白图原因iOS Safari 对 canvas 内存有限制单个 canvas 超过约 3840×3840 或总内存超限时toDataURL会返回空白。另外如果画板 canvas 在导出时不可见display:none或移出视口部分 iOS 版本也会导出空白。解决导出前确保画板在视口内且display不为 none如果尺寸过大分块导出再拼接或者降低导出倍率。我一般导出时用scale1而不是 dpr 倍率牺牲一点清晰度换稳定。5.2 现象安卓 WebView 里笔迹偏移手指和线差一截原因安卓部分 WebView 的getBoundingClientRect()返回值受页面缩放影响而clientX是视口坐标两者基准不一致。另外如果页面有meta viewport的initial-scale不为 1偏移会更明显。解决统一用touch.clientX - rect.left计算并在页面加载时锁定initial-scale1。如果仍有偏移用window.visualViewport的offsetLeft/offsetTop做补偿。实测在user-scalableno下最稳。5.3 现象快速划线时笔迹断断续续像虚线原因触摸事件采样率跟不上手指速度touchmove触发间隔内手指已经移动很远相邻点间距过大。另外如果每收到一个点就stroke()一次性能开销大也会丢帧。解决用requestAnimationFrame批量绘制把touchmove收集的点先存进数组下一帧统一画。同时开启中点平滑。如果还断考虑用getCoalescedEvents()获取合并的触摸点部分浏览器支持能拿到更密集的采样。5.4 现象翻页后批注跑到别的页面上原因批注数据没有按页隔离或者翻页时currentPage更新时机不对导致新页的 strokes 被写到了旧页的 key 上。解决在goToPage开头就saveStrokes(currentPage, strokes)然后立刻把currentPage更新为目标页再渲染和恢复。不要等渲染完成才更新页码否则异步期间用户又翻页就乱了。5.5 现象橡皮擦擦不干净或者擦掉了底图原因橡皮擦如果用globalCompositeOperationdestination-out直接擦画板会把画板透明区域擦出来但底图在另一个 canvas 上视觉上没问题。但如果底图和批注在同一层就会连 PDF 内容一起擦掉。解决底图和批注必须分两个 canvas橡皮只作用于批注层。擦除时用destination-out画圆或者从 strokes 数组里删除与擦除区域相交的笔画矢量擦除。前者简单但导出时透明区域会露出底图后者干净但计算量大。合同批注一般用前者就够。6. 导出合成与进阶技巧把批注烧进 PDF6.1 合成导出底图 批注层叠成一张图导出的本质是把 PDF 底图 canvas 和批注 canvas 画到同一个离屏 canvas 上。注意批注层是透明的直接drawImage叠上去即可。如果要导出 PDF 而不是图片常见做法是用jsPDF把合成图按页插入但这样 PDF 里的文字就变成位图了不可搜索。如果业务要求保留文字层得用 pdf-lib 在原始 PDF 上叠加矢量批注复杂度高一个量级。// 合成底图与批注层导出 PNG async function exportPage(pdfCanvas, drawCanvas) { const out document.createElement(canvas); out.width pdfCanvas.width; out.height pdfCanvas.height; const ctx out.getContext(2d); ctx.drawImage(pdfCanvas, 0, 0); // 先画 PDF 底图 ctx.drawImage(drawCanvas, 0, 0); // 再叠批注层 return out.toDataURL(image/png, 0.92); }逻辑说明drawImage按顺序叠加后画的盖在前面。toDataURL的第二个参数是 JPEG 质量PNG 忽略。如果导出多页循环每页合成后塞进 PDF 库。参数说明导出倍率建议用 1.52.0太高 iOS 会白图。如果只要批注不要底图跳过第一个drawImage即可得到透明背景的批注图。6.2 一个我常用的验证习惯每次改完坐标映射或翻页逻辑我不会直接上真机点而是先在桌面浏览器开设备模拟用console.log打印归一化坐标确认同一位置在不同 scale 下nx/ny一致。然后真机上只验证触摸事件和滚动冲突这两件事。这样能把「坐标算错」和「事件没绑对」两类问题分开排查快很多。从那以后我每次接移动端画板需求都强制先写一个最小 demo一个 canvas、一个 touchmove、打印归一化坐标确认映射没问题再往上叠 PDF 渲染和批注存储。这个习惯帮我省了至少三次通宵排查坐标偏移。希望帮到你。本文还有配套的精品资源点击获取