ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Canvas移动端画板批注PDF预览:坐标映射与性能优化实践

Canvas移动端画板批注PDF预览:坐标映射与性能优化实践 简介面向移动端H5开发者的Canvas画板批注PDF预览资源包专注解决在手机端实现PDF文档渲染、手写批注与标记保存等交互需求。资源以PDF.js解析流程为技术主线覆盖从PDF文档加载、页面渲染到Canvas画板触控绘制的完整链路并给出批注导出与持久化的实现参考。包内共150个文件其中JS脚本承担核心解析与绘制逻辑HTML与CSS搭建页面结构GIF动图直观演示操作过程PNG/JPG图片提供界面预览另含字体、Map及PDF样例等辅助文件压缩后仅2.4MB小巧轻量。已有1750人学习下载适合需要快速落地移动端PDF批注功能的前端工程师与H5开发者。参考该资源可避开跨域配置、坐标适配、移动端事件兼容等常见坑点并结合layui等前端组件快速集成对分页渲染与批注存储等性能优化也给出方向提示能显著缩短从零开发周期。1. canvas 移动端画板批注 PDF 预览为什么图片方案在这里不可行做移动端 H5 PDF 预览时我最早偷懒把 PDF 转成图片再铺到页面上直到接了一个 canvas 移动端画板批注需求被打脸用户要能在 PDF 上直接画圈、写意见画完还要能导出和回放。图片方案表面简单可一旦涉及多页预览、放大后批注对位、换设备之后笔迹偏移就处处踩坑。这份资源给的是另一条路用 canvas 承载批注层和 PDF 渲染层分离再用一套坐标映射把屏幕上的笔迹还原到 PDF 原始坐标系。适合正在做 H5 文档预览、审批流、签字确认这类场景的前端也适合想搞清 canvas 分层绘制的读者。2. 工程拆解PDF 渲染层与画板批注层先分开再谈功能拿到资源后别急着跑起来先把它拆成三个模块PDF 渲染、画板绘制、坐标换算。很多半成品项目的问题就是这三块糊在一个组件里滚动、缩放、画条线互相打架。下面先看源码包的核心结构再逐层说为什么选这种方案。2.1 源码包里的核心模块长什么样这份资源解开后工程结构大致是这样src/ ├── pages/ # 页面入口预览页、批注页 │ ├── preview.js │ └── annotate.js ├── core/ │ ├── pdfRender.js # PDF 加载、翻页、渲染 │ ├── board.js # 画板层、笔迹绘制与事件绑定 │ ├── coordTransform.js # 屏幕坐标与 PDF 坐标互转 │ └── history.js # 批注记录、撤销栈 ├── utils/ │ ├── dpr.js # 设备像素比处理 │ └── storage.js # 批注数据本地保存 └── styles/ └── board.css # 图层定位、触摸行为样式这个结构对应的是分层思路pdfRender.js只负责把 PDF 画到页面board.js只负责在透明 canvas 上画线coordTransform.js是两者之间唯一的沟通桥梁。这样做的好处是后续换 PDF 渲染库、改画板样式都不会牵连到坐标换算逻辑。跑通资源里的 demo 后建议先找到coordTransform.js把里面两个核心函数读一遍。这个文件是整个项目的命脉不管你只是想要“能画”还是想要“画了能存、存了能还原”最终都要回到这里。2.2 PDF 渲染为什么选 pdf.js 而不是后端转图移动端 PDF 预览有三种常见做法后端把 PDF 转成图片一次性返回、前端用原生控件加载、前端用 PDF 渲染库逐页绘制。这里的资源选的是第三种并且用的渲染方案是 pdf.js。原因是批注场景对“坐标一致性”要求太高。如果你把 PDF 转成图片前端拿到的是一张位图就算知道图片宽高也很难定位“这个 PDF 的第 3 页里某个表格区域”在哪个像素点因为 PDF 页面里的文字和矢量图形已经被拍扁成图片了。而直接用 PDF 渲染库可以得到每一页的 viewport 数据知道当前页的原始尺寸、缩放比例这样画板上的一个点可以精确映射回 PDF 的坐标点。// 以页面原始宽度为基准构造当前页 viewport const baseViewport page.getViewport({ scale: 1 }); const availableWidth container.clientWidth; const scale availableWidth / baseViewport.width; const viewport page.getViewport({ scale }); const renderTask page.render({ canvasContext: pdfCanvasContext, viewport }); renderTask.promise.then(() { // 渲染完成后再启用画板避免用户画在空白页上 board.enable(); });逻辑说明先拿 scale1 的原始 viewport 计算网页容器宽度下的缩放比再用这个缩放比生成正式 viewport最后调用render。renderTask.promise是渲染完成的回调入口画板必须等 PDF 页真正画出来后再允许落笔否则第一笔会落在白底上。参数说明baseViewport.width是 PDF 页在缩放比例为 1 时的宽度单位是 PDF 点。availableWidth是页面容器去掉左右 padding 后的实际宽度。这里用“宽度自适应”而不是固定缩放是为了让 PDF 铺满不同宽度的手机屏防止窄屏上出现横向滚动条。2.3 画板层一个透明 canvas 如何盖在 PDF 上画板层不是新造一个页面而是用 CSS 把一个透明 canvas 绝对定位在 PDF canvas 上方。两层结构如下div idpdfContainer classpdf-container canvas classpdf-layer idpdfCan/canvas canvas classboard-layer idboardCan/canvas /div.pdf-container { position: relative; width: 100%; overflow: hidden; } .pdf-layer, .board-layer { position: absolute; inset: 0; width: 100%; height: 100%; } .board-layer { touch-action: none; /* 禁止浏览器处理这套手势 */ z-index: 2; }逻辑说明pdf-layer负责显示 PDF 页面board-layer覆盖在它上面透明背景只显示用户新画的笔迹。touch-action: none很关键它告诉浏览器这块区域的手势不要滚动页面、不要缩放页面统统交给 JS 处理否则在手机上画横线会被系统识别成滚动操作。参数说明inset: 0是让画板层和 PDF 层严格贴合任何一层的 border、padding 都会导致坐标计算起步就错。z-index不是越高越好画板层只需要比 PDF 层高一级太高了反而遮挡其他弹层组件。资源里把两个 canvas 的宽高都用 CSS 设置为 100%但 canvas 内部的像素宽高并没有写死这是给 dpr 适配预留的空间后面第三章会细讲。3. 移动端适配触摸事件、dpr 缩放与滚动冲突画板在 PC 端 mouse 事件能跑通一到手机就露馅。这章讲三个最常见又最容易忽略的移动端适配点。3.1 pointer events 替代 mouse 事件资源里的画板用的是 Pointer Events而不是分开监听 touch 和 mouse。原因很简单现在的触控笔、手指、鼠标都能统一触发 pointer 事件不用再写一套“如果支持 touch 就怎样否则就怎样”的分支代码。const board document.getElementById(boardCan); const ctx board.getContext(2d); let isDrawing false; let lastPoint null; board.addEventListener(pointerdown, (e) { isDrawing true; lastPoint getPos(e); board.setPointerCapture(e.pointerId); }); board.addEventListener(pointermove, (e) { if (!isDrawing) return; const cur getPos(e); ctx.beginPath(); ctx.moveTo(lastPoint.x, lastPoint.y); ctx.lineTo(cur.x, cur.y); ctx.stroke(); lastPoint cur; }); board.addEventListener(pointerup, (e) { isDrawing false; board.releasePointerCapture(e.pointerId); });逻辑说明pointerdown触发时用setPointerCapture锁定指针防止手指画出画板范围后事件丢失pointermove里不断从上一个点连线到当前点pointerup结束本次笔画。这里没有用mousedown/mousemove/mouseup因为触摸屏上 mouse 事件是模拟出来的会带来几十毫秒延迟画出来的线不跟手。参数说明e.pointerId表示当前触点编号。在单指批注场景我们只取第一个触点的坐标如果后续要支持双指同时书写就需要用 pointerId 区分笔画本资源默认不做多笔并发。getPos的实现在后面 3.2 节会一起说这里先记住它返回的是相对画板左上角的 CSS 坐标值不含 dpr 放大。3.2 dpr 不等于 1 时画布尺寸怎么取舍iPhone 的物理像素宽度经常是 CSS 宽度的两倍或三倍如果 canvas 的宽高直接用 CSS 尺寸出来的线条会糊。原因是 canvas 的绘图缓冲区比屏幕物理像素小浏览器把它拉伸上去就虚了。解决办法是让 canvas 内部像素尺寸乘上 dpr。function setupCanvas(canvas) { const dpr window.devicePixelRatio || 1; const rect canvas.getBoundingClientRect(); canvas.width rect.width * dpr; canvas.height rect.height * dpr; const ctx canvas.getContext(2d); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return ctx; }逻辑说明canvas.width rect.width * dpr设置的是绘图缓冲区大小getBoundingClientRect拿到的是实际排版后的 CSS 尺寸。ctx.setTransform(dpr, 0, 0, dpr, 0, 0)让后续所有绘图坐标都按 CSS 坐标计算绘制系统自动映射到物理像素。参数说明这里千万不能用window.innerWidth计算画布宽度因为画板容器可能不是全屏可能底部有工具栏、顶部有标题栏。改成getBoundingClientRect后任何布局变化都能即时反映。另一个容易忽略的点是在dpr为 3 的高端机型上整页 canvas 的像素总量可能达到 3000x6000这对 WebGL 或 canvas 合成都有压力后面第四章会专门讲内存问题。3.3 画板与页面滚动的互斥处理移动端 PDF 往往超过一屏页面需要上下滚动。当手指落在画板层上时画板要求“这一笔完全由 JS 接管不要滚动”当手指落在 PDF 页面的空白区域或工具栏时页面该滚还得滚。实现上用 CSStouch-action控制画板区域并在事件里判断是否继续传播。const isOnBoard (e) { const boardRect board.getBoundingClientRect(); return e.clientX boardRect.left e.clientX boardRect.right e.clientY boardRect.top e.clientY boardRect.bottom; }; board.addEventListener(pointerdown, (e) { if (!isOnBoard(e)) return; e.preventDefault(); isDrawing true; // 阻止页面滚动逻辑由 touch-action 控制这里不用手动调 stopPropagation });这里preventDefault()与touch-action: none是双保险。但注意preventDefault放在pointerdown里只是避免后续行为真正避免滚动还是要靠touch-action。如果你用pointermove里preventDefault部分 iOS 版本会报 Illegal invocation正确做法是在 CSS 层就声明这个区域不可滚动。还有一点是长按选择。移动端浏览器长按 canvas 会触发图片保存菜单或文本选择干扰批注体验。资源里给.board-layer加了全局样式-webkit-user-select: none; user-select: none; -webkit-touch-callout: none;这些样式在 Android 和 iOS 上都能有效减少误触但 Android WebView 的某些版本仍可能弹菜单必要时要在contextmenu事件里preventDefault()。4. 避坑批注偏移、白屏、内存暴涨的排查笔记这章写的是我反复翻车后整理出来的排查笔记每个现象都按“现象 → 原因 → 解决”展开。如果你在复现资源时遇到问题优先对照这里。4.1 现象写出来的笔迹和手指差一大截现象在手机上用手指画线笔迹明显偏到右下角位置还随滚动变化。原因多半是 canvas 的 CSS 尺寸与内部像素尺寸不一致时坐标计算直接用了canvas.width做比例换算。比如画布内部宽度是 750CSS 宽度只有 375代码里却把offsetX/clientX直接当 canvas 坐标用画出来的位置自然翻倍。解决在绘制前先做一次坐标换算把鼠标或指针的屏幕坐标转换成画板坐标系中的 CSS 坐标再交给绘图 API。function getPos(e) { const rect board.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top }; }这里返回的是未乘 dpr 的 CSS 坐标配合 3.2 节的ctx.setTransform(dpr,0,0,dpr,0,0)使用。只要这两处统一批注层就不会偏移。另一个常见错误是忽略按钮和导航栏占位导致rect.top算错所以监听事件时要绑定在画板自身而非整个 document然后用getBoundingClientRect实时取值别缓存一个静态 rect 用到翻页后。4.2 现象PDF 页面白屏只出边框不出内容现象PDF 容器有阴影边框但里面空白的控制台没有报错。原因通常是 PDF Worker 加载失败。pdf.js 的解析运行在 Worker 线程里如果资源的 worker 路径配置不对主线程会静默失败渲染层拿不到页面数据。解决检查 worker 路径是否指向正确的本地文件并在初始化后打印加载状态。pdfjsLib.GlobalWorkerOptions.workerSrc ./static/pdf.worker.min.js; pdfjsLib.getDocument(pdfUrl).promise.then((doc) { console.log(document loaded, pages:, doc.numPages); }).catch((err) { console.error(load failed, err); });注意getDocument的参数如果是跨域地址服务端必须配置跨域响应头Access-Control-Allow-Origin否则 Worker 拿不到数据。另一个白屏来源是未设置pdfRender.js里的canvasContext类型部分版本需要{ canvasContext: ctx }而非{ context: ctx }写错不会立刻报错渲染结果却是空白排查时优先看网络请求里.worker文件是否 200。4.3 现象翻页几十次后页面卡顿明显现象PDF 一共 100 页来回翻页 30 次后滑动开始掉帧内存占用翻倍。原因是每一页渲染都新建了renderTask翻页时没有销毁上一次任务甚至每次翻页都在同一个 canvas 上反复绘制但未清除画布导致 GPU 缓冲和 JS 对象越积越多。解决渲染下一页前先取消正在进行的渲染任务并清理画布。if (currentRenderTask) { currentRenderTask.cancel(); currentRenderTask null; } pdfCtx.clearRect(0, 0, pdfCanvasEl.width, pdfCanvasEl.height); currentRenderTask page.render({ canvasContext: pdfCtx, viewport });renderTask.cancel()会抛出一个 AbortException所以调用处最好用.catch判断一下避免异常被当成错误上报。另外不要为了省事把整本 PDF 的所有页都渲染成 canvas 再切换显示移动端 canvas 数量一多WebView 的位图缓存会先被击穿。资源里的方案是只渲染当前页翻页时销毁上一页最多保留上一页做过渡动画。4.4 现象viewport 缩放后出现模糊或毛边现象PDF 放大 200% 后批注线条看起来比 PDF 页面文字更模糊有的线段边缘出现明显锯齿。原因是放大后 PDF 渲染库会重新生成高分辨率 viewport但画板层内部像素尺寸没有跟着更新导致批注层仍然用低分辨率缓冲区绘制。解决在缩放操作结束后重设画板的宽高和 transform。function resizeBoardToPdf(pdfCanvas) { const rect pdfCanvas.getBoundingClientRect(); board.style.width rect.width px; board.style.height rect.height px; board.width rect.width * dpr; board.height rect.height * dpr; boardCtx.setTransform(dpr, 0, 0, dpr, 0, 0); }有一个细节canvas 重新设置width或height会清空内容所以这步必须在画板无笔迹时执行或者在重设前先用getImageData保存一份离屏位图重设后再putImageData复原。资源里用的是离屏 canvas 缓冲后面第六章会演示。5. 实战批注坐标映射与 JSON 导出让画的东西可回放移动端画板批注的真正门槛不是画线而是“在不同页面尺寸、不同缩放比例、不同设备上画出来都能对得上原始 PDF”。这里的关键是永远不要保存屏幕坐标保存 PDF 坐标。5.1 坐标转换公式与代码实现屏幕上的点(x, y)先换算成画板 CSS 坐标再除以当前缩放比例得到 PDF 页面坐标。反过来回放时把 PDF 坐标乘上当前 viewport 缩放比例即可。// 屏幕坐标 - PDF 坐标 function screenToPdf(screenX, screenY, rect, viewport) { const cssX screenX - rect.left; const cssY screenY - rect.top; return { x: cssX / viewport.scale, y: cssY / viewport.scale }; } // PDF 坐标 - 屏幕坐标 function pdfToScreen(pdfX, pdfY, rect, viewport) { return { x: pdfX * viewport.scale rect.left, y: pdfY * viewport.scale rect.top }; }逻辑说明viewport对象来自page.getViewport({ scale })viewport.scale就是页面渲染时的缩放倍数。之所以用除法而不是pageWidth / rect.width是因为缩放后 PDF 的坐标和画布 CSS 坐标是线性关系除法最简单也最不依赖页面布局。参数说明如果 PDF 页面在容器内做了水平居中还需要减去容器的scrollLeft和偏移量。资源里默认容器宽度等于容器本身所以不用额外减。但一旦你改成左右留白模式就必须把rect.left换成“PDF 实际显示区域”的 left否则批注会整体向右偏。建议把这段函数写在coordTransform.js里并加上单测因为后续所有功能都建立在它之上。5.2 批注数据结构设计一次画线动作可以抽象成一条 stroke数据结构如下const stroke { id: stroke_20240320_1645, page: 3, type: pen, color: #ff5722, width: 3, points: [ { x: 128.34, y: 56.02 }, { x: 129.1, y: 58.5 }, { x: 130.2, y: 61.0 } ] };这里points存的是 PDF 坐标不是屏幕坐标。颜色和线宽也存原始值因为 PDF 页面重新渲染时如果设备像素比不同线宽的视觉表现会变存储原始值可以保证回放时一致。数据结构定好后导出就是一个JSON.stringify(strokes)。资源里提供了history.js内部维护strokes数组和undoStack数组。每完成一笔就 push 一次每撤销一次就把当前 stroke push 到undoStack并重新绘制剩余笔画。注意撤销不是简单地strokes.pop()因为后续还有“重做”操作所以要保留被撤销的数据。function addStroke(stroke) { strokes.push(stroke); undoStack.length 0; // 新笔画会清空重做栈 redrawAll(); } function undo() { if (strokes.length 0) return; const removed strokes.pop(); undoStack.push(removed); redrawAll(); }5.3 重新打开文档时恢复批注恢复批注的流程是先渲染 PDF 当前页拿到 viewport再遍历该页的 stroke 列表把 PDF 坐标转成屏幕坐标逐段绘制。这里要特别小心跨页数据如果用户在第 3 页画到一半手指拖到了第 2 页现实中很少发生但非禁用画板时可能会导致一条笔画的前半段在第 3 页、后半段在第 2 页。解决方法是每次pointerup时按当前页号拆开保存而不是存一个全局 stroke。async function restorePageAnnotations(pageNum, strokes, viewport) { const pageStrokes strokes.filter((s) s.page pageNum); boardCtx.clearRect(0, 0, board.width, board.height); for (const stroke of pageStrokes) { boardCtx.beginPath(); boardCtx.strokeStyle stroke.color; boardCtx.lineWidth stroke.width; stroke.points.forEach((pt, i) { const sp pdfToScreen(pt.x, pt.y, boardRect, viewport); if (i 0) { boardCtx.moveTo(sp.x, sp.y); } else { boardCtx.lineTo(sp.x, sp.y); } }); boardCtx.stroke(); } }这段代码在恢复多页批注时有一个隐藏问题clearRect用的是board.width这是 dpr 放大后的像素值而clearRect的坐标单位受setTransform影响。如果用 CSS 坐标写就应该清rect.width * dpr。建议统一封装一个clearBoard()函数内部用ctx.save()、ctx.setTransform(1,0,0,1,0,0)、clearRect(0,0,canvas.width,canvas.height)、ctx.restore()避免不同缩放状态下清屏不干净的 bug。5.4 导出图片把 PDF 和批注合成为一张海报图有些业务场景要求把“PDF 页面 用户批注”导出成一张图片用于审批留档或转发。最常见做法是把 PDF 渲染到一个大 canvas再把批注层数据按同样的 viewport 画上去最后用canvas.toDataURL()导出。const mergeCanvas document.createElement(canvas); mergeCanvas.width pdfCanvas.width; mergeCanvas.height pdfCanvas.height; const mergeCtx mergeCanvas.getContext(2d); mergeCtx.drawImage(pdfCanvas, 0, 0); restorePageAnnotationsToContext(mergeCtx, pageStrokes, viewport); const imageUrl mergeCanvas.toDataURL(image/png);这里要注意drawImage(pdfCanvas, 0, 0)会把当前 PDF canvas 完整复制过去所以先画 PDF 再画批注顺序不能反。导出的图片像素大小等于 pdfCanvas 的内部像素大小也就是 dpr 放大后的尺寸在手机上看起来清晰直接放到电脑上也不糊。如果遇到图片导出后黑屏十有八九是restorePageAnnotationsToContext里没有传 viewport导致批注坐标和 PDF 图片坐标不一致。6. 进阶用离屏 canvas 与历史栈优化批注的撤销和重绘画板在笔画少时性能没问题但批注多到一页 50 条以上时每次撤销都要从头重画所有笔画肉眼可见地卡顿。我在这份资源上做了两处优化效果都很直接。第一处是离屏 canvas 缓冲。新建一个不挂在文档上的 canvas每次pointerup时把当前画板内容完整复制到离屏 canvas下一次绘制新笔画前先drawImage恢复离屏内容再画新笔迹。这样重绘画板时不需要遍历所有历史 stroke只要一次位图画布拷贝。const offscreen document.createElement(canvas); offscreen.width board.width; offscreen.height board.height; function snapshot() { offscreenCtx.clearRect(0, 0, offscreen.width, offscreen.height); offscreenCtx.drawImage(board, 0, 0); } function restore() { boardCtx.clearRect(0, 0, board.width, board.height); boardCtx.drawImage(offscreen, 0, 0); }第二处是历史栈的“全量重绘改增量恢复”。撤销一条笔画时不必重新执行整个strokes数组的绘制而是先从离屏快照里去掉当前最后一条笔画。但 canvas 位图不方便“擦除某条线”所以最稳的方案是维护两个离屏 canvas一个存笔画前快照一个存笔画后快照。撤销时直接切回前一张快照重做时切回后一张。代价是每画一条笔迹会多占一份 canvas 内存但移动端一次批注一般不会超过百条内存换流畅度值得。从那以后我每次接移动端画板需求都会先问清楚三个问题批注数据要不要保存到后端、PDF 放大后批注要不要跟着放大、撤销最多支持多少层。这三个答案决定我要不要做增量快照、要不要存 PDF 坐标、要不要限制历史栈长度。资源里默认做的是完整快照栈你在实际复现时如果发现内存吃紧可以把快照压成webp的 dataURL 再存虽然编解码有开销但内存占用能降低不少。希望这份拆解和踩坑记录帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表