☰
Canvas移动端画板批注PDF预览:坐标映射与性能优化实践
2026/10/18 20:11:56 网站建设 项目流程

简介:面向移动端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(); });

逻辑说明:先拿 scale=1 的原始 viewport 计算网页容器宽度下的缩放比,再用这个缩放比生成正式 viewport,最后调用render。renderTask.promise是渲染完成的回调入口,画板必须等 PDF 页真正画出来后再允许落笔,否则第一笔会落在白底上。

参数说明:baseViewport.width是 PDF 页在缩放比例为 1 时的宽度,单位是 PDF 点。availableWidth是页面容器去掉左右 padding 后的实际宽度。这里用“宽度自适应”而不是固定缩放,是为了让 PDF 铺满不同宽度的手机屏,防止窄屏上出现横向滚动条。

2.3 画板层:一个透明 canvas 如何盖在 PDF 上

画板层不是新造一个页面,而是用 CSS 把一个透明 canvas 绝对定位在 PDF canvas 上方。两层结构如下:

<div id="pdfContainer" class="pdf-container"> <canvas class="pdf-layer" id="pdfCan"></canvas> <canvas class="board-layer" id="boardCan"></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做比例换算。比如画布内部宽度是 750,CSS 宽度只有 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 再存,虽然编解码有开销,但内存占用能降低不少。希望这份拆解和踩坑记录帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询