简介:面向移动端H5开发者的canvas画板批注PDF预览实现,整合PDF.js解析与Canvas触控绘制,适合为平板、手机等设备添加PDF标注、手写批注功能的前端工程师学习。压缩包共150个文件,约2.4MB,包含34个js脚本与10个css样式用于页面逻辑和界面布局,75个gif演示直观展示操作效果,11个png图标及字体、音频等辅助资源完善演示环境,html示例可快速运行查看。已有1750人学习下载。资源从PDF.js的引入与fetch获取PDF说起,覆盖PDFDocumentProxy对象加载、页面渲染到Canvas、根据viewport设置画布尺寸等关键步骤;在批注环节,重点讲解touchstart、touchmove、touchend事件实现连续画笔,兼容mousedown等桌面事件,并给出通过toDataURL导出Canvas为图片、实现批注持久化保存与回显的方法。整体目录按功能区分,方便按模块查阅,适合想独立搭建移动端PDF批注画板的中高级前端开发者参考。
1. 移动端PDF批注场景与选题价值:画板为什么要“叠”在PDF上方
移动端里让用户打开一份PDF、用手指直接在页面上画圈做批注,结果画出来的笔迹和PDF文字始终对不齐——这是很多H5开发团队踩过的坑。这个项目包装的主题是“canvas移动端画板批注PDF预览”,本质是解决一个很具体的问题:在移动端H5里,既要正常预览PDF,又要让用户能在任意一页上划线、圈重点、写备注,而且笔迹要贴着PDF内容走,缩放不跑偏。
这里有个反直觉的设计:批注不是“写进”PDF文件,而是在PDF上面盖一层透明的canvas画板。PDF只负责渲染底图,canvas层负责收集手指轨迹,两层通过同一套坐标换算对齐。这样做的直接收益是想撤销就撤销、想清空就清空,PDF原文件永远不被污染,二次渲染也不会把笔迹烧死。这个项目适合三类人:做移动端在线签批工具的开发者、需要给文档预览功能加批注能力的H5团队,以及想搞清楚canvas与PDF坐标联动的初中级前端。
2. 技术选型与整体架构:pdf.js负责显示,canvas浮层负责“动手”
2.1 三层结构:PDF渲染层、画板层、坐标换算层
整个项目在逻辑上拆成三个互不耦合的层。最底下是PDF渲染层,负责把PDF文件解析成可缩放的页面像素;中间是画板层,用户所有手指操作都落在这一层;最上面这一层其实不是UI层,而是坐标换算层,它不渲染任何东西,但所有层之间的对齐、缩放、位移都靠它算。
这种拆法最大的好处是每一层可以独立替换。PDF层想从pdf.js换成服务端渲染的图片流,画板层完全不受影响;画板层想从canvas换成SVG重构,PDF层也不用动。实际开发中,我习惯把坐标换算层做成一个纯函数模块,输入是PDF原始坐标和当前视口信息,输出是canvas上的像素坐标,单测覆盖率最容易提上去的就是这一层。
// viewer.js 核心初始化 const pdfCanvas = document.getElementById('pdf-canvas'); const boardCanvas = document.getElementById('board-canvas'); const pdfCtx = pdfCanvas.getContext('2d'); const boardCtx = boardCanvas.getContext('2d'); const coords = { scale: 1, // 当前缩放值 offsetX: 0, // 内容区在视口内的水平偏移 offsetY: 0, // 内容区在视口内的垂直偏移 dpr: window.devicePixelRatio || 1 }; async function renderPDFAtScale(page, scale) { const viewport = page.getViewport({ scale }); pdfCanvas.width = viewport.width * coords.dpr; pdfCanvas.height = viewport.height * coords.dpr; boardCanvas.width = viewport.width * coords.dpr; boardCanvas.height = viewport.height * coords.dpr; pdfCanvas.style.width = boardCanvas.style.width = viewport.width + 'px'; pdfCanvas.style.height = boardCanvas.style.height = viewport.height + 'px'; coords.scale = scale; await page.render({ canvasContext: pdfCtx, viewport }).promise; }这段代码里最关键的点在canvas的宽高赋值方式:内部尺寸(width/height)乘以dpr,外部样式尺寸(style.width/style.height)保持CSS像素不变。如果不这么做,移动端高分辨率屏上canvas会显得发虚,后文避坑章节还会展开说。viewport宽高来自pdf.js的计算结果,任何一页的缩放渲染都以它为准,后面所有坐标换算都以此为基准。
2.2 为什么不用现成的批注库
市场上确实有把PDF渲染和批注打包好的库,但在这个项目场景下选择自研,主要卡在两个点上。第一,移动端网络环境复杂,很多现成库会把整本PDF的批注状态维护在一个模型里,交互重、依赖大,H5首屏加载压力明显。第二,批注数据最终需要回传业务系统,每个团队的数据结构要求不一样,用通用库反而要花大量精力在数据映射上,不如直接拿一个干净的数据协议。
自研方案的依赖只有pdf.js一个重型库,画板部分原生canvas实现,不引入额外框架。pdf.js专注解决“PDF怎么渲染到像素”,画板专注解决“手指轨迹怎么变成笔迹”,业务系统要什么格式的批注数据,我们自己定义序列化。这个取舍在移动端弱网场景下尤其划算,首包体积能省掉不少。
2.3 移动端视口初始化
移动端H5进入这个页面时,第一步不是渲染PDF,而是先把viewport设定钉死,避免用户双击缩放导致坐标系统崩掉。常见的做法是设置maximum-scale=1, user-scalable=no,因为一旦浏览器介入页面级缩放,canvas坐标和手指触点之间就会插入一层不可控的换算。
// viewport.js const viewportMeta = document.querySelector('meta[name="viewport"]'); viewportMeta.content = [ 'width=device-width', 'initial-scale=1', 'maximum-scale=1', 'minimum-scale=1', 'user-scalable=no' ].join(', ');注意:这里禁用的是页面级缩放,不是禁用PDF的双指缩放。PDF内部的缩放由代码控制,和浏览器视口缩放是两条路径,千万别混为一谈。
触控事件采用Pointer Events而不是传统的touch事件。理由是Pointer Events天然统一了鼠标、单指、多指三类输入,后续如果要兼容桌面端调试,同一套代码不用改。监听对象也直接挂在画板canvas上,不去监听整个document,避免滚动时事件穿透到无关区域。
3. 目录结构与初始化流程:从依赖安装到PDF首屏出图
3.1 目录结构:render、board、bridge分家
项目源码的目录组织遵循一条原则:凡是直接碰canvas的代码,和纯逻辑代码分开。拆成三层之后,修改UI时不会误伤坐标换算,排查问题时也能快速定位到具体文件。
src/ api/ // 后端接口封装,比如获取PDF文件、回传批注 bridge/ // 坐标换算纯函数模块 components/ // Perview、DrawCanvas两个核心组件 hooks/ // 自定义Hooks:usePDF、useDrawBoard、useAnnotation utils/ // dpr处理、canvas裁剪等工具函数 store/ // 批注数据临时存储与撤销栈管理components/Perview负责拿到PDF文件的ArrayBuffer交给pdf.js,DrawCanvas负责挂载canvas画板。hooks层是关键,usePDF暴露当前页码、渲染进度、翻页函数;useDrawBoard暴露笔迹数据、画笔状态、撤销操作;两者通过useAnnotation做联动,比如翻页时通知画板清空临时状态。
3.2 依赖清单:精简到只有一个重型库
核心依赖只有两类,一类是渲染PDF必需的pdf.js,另一类是构建工具链自带的模块。画板部分全部手写,没有引入图形处理库。这里给一个最小依赖参考:
{ "dependencies": { "pdfjs-dist": "^4.x" }, "devDependencies": { "vite": "^5.x", "@vitejs/plugin-vue": "^5.x" } }之所以演示用Vite,是因为pdf.js提供的worker文件在现代构建工具里需要显式配置资源引用,Vite的new URL写法能直接拿到worker的最终路径,这在移动端部署时少踩一个坑。如果你用Webpack,需要额外在config里配worker-loader或copy-webpack-plugin把pdf.worker.min.mjs拷到静态目录。
3.3 初始化链路:四步走完首屏渲染
完整的启动时序是:先请求PDF文件流,转发给pdf.js的getDocument解析出文档对象,拿到当前页后按视口宽度计算初始缩放值,最后同时初始化PDF画布和批注画布。这里有一步很容易做错:批注画布必须在PDF页面首次渲染完成后才能初始化,否则用户肉眼会看到画板先白屏一闪、PDF后出现的错位。
// App.jsx 初始化流程 async function initDocument(fileUrl) { loading.value = true; const pdfData = await fetchPdfArrayBuffer(fileUrl); const doc = await pdfjsLib.getDocument(pdfData).promise; currentPage.value = 1; await renderPage(doc, 1); boardRef.value.init({ width: viewportWidth, height: viewportHeight }); loading.value = false; }fetchPdfArrayBuffer这一步我一般会在请求头里带Cache-Control: no-transform,防止某些网关把二进制流转成文本流导致PDF解析失败。这是真踩过的坑,后面避坑章节会细说。画板init的时候只传入CSS像素尺寸,内部自己乘以dpr,外部不需要关心物理像素,这也是把dpr封装进utils模块的原因。
4. 核心模块拆解:一次批注从落笔到落盘的完整链路
4.1 PDF渲染层:页码缓存与按需渲染
移动端内存宝贵,整本PDF一次性渲染会直接把低端机打爆。常见做法是只维护当前页和前后一页的缓存,翻页时销毁远处的canvas。pdf.js渲染一页A4大小的PDF,在手机上大约要占用十几MB内存,连续翻十页不释放基本就撑不住了。
// renderManager.js const pageCache = new Map(); async function getOrRenderPage(doc, pageNum, scale) { if (pageCache.has(pageNum)) return pageCache.get(pageNum); const page = await doc.getPage(pageNum); const viewport = page.getViewport({ scale }); const canvas = document.createElement('canvas'); canvas.width = viewport.width * coords.dpr; canvas.height = viewport.height * coords.dpr; const ctx = canvas.getContext('2d'); await page.render({ canvasContext: ctx, viewport }).promise; pageCache.set(pageNum, { canvas, viewport, page }); trimCache(pageNum); return pageCache.get(pageNum); } function trimCache(currentPage) { for (const key of pageCache.keys()) { if (Math.abs(key - currentPage) > 1) pageCache.delete(key); } }这里pageCache用Map而不是普通对象,是因为Map天然支持键值遍历和LRU风格的删除策略。trimCache只保留当前页相邻的两页,边界翻页看起来流畅,内存又被锁死在一个范围内。渲染完成后的canvas可以直接作为底图绘制到显示画布上,不必重新走一遍pdf.js渲染管线。
4.2 画板核心:Pointer事件采集与笔迹数据结构
手指按下的瞬间,需要拿到触点坐标并转换成canvas内部像素坐标。因为画板canvas自身尺寸是CSS像素乘以dpr,所有画笔绘制都发生在物理像素空间里,落笔坐标也要跟着换算。
// drawBoard.js let isDrawing = false; let currentStroke = []; function getTouchPos(e) { const rect = boardCanvas.getBoundingClientRect(); const x = (e.clientX - rect.left) * coords.dpr; const y = (e.clientY - rect.top) * coords.dpr; return { x, y }; } boardCanvas.addEventListener('pointerdown', (e) => { isDrawing = true; currentStroke = [{ ...getTouchPos(e), pressure: e.pressure || 0.5, width: brushWidth * coords.dpr }]; boardCtx.beginPath(); boardCtx.moveTo(currentStroke[0].x, currentStroke[0].y); }); boardCanvas.addEventListener('pointermove', (e) => { if (!isDrawing) return; const pos = getTouchPos(e); currentStroke.push({ ...pos, pressure: e.pressure || 0.5 }); boardCtx.lineTo(pos.x, pos.y); boardCtx.stroke(); }); boardCanvas.addEventListener('pointerup', () => { isDrawing = false; strokesStack.push(currentStroke); });stroke的数据结构刻意设计成数组里装点对象,每个点都带pressure值。这样导出的时候,既可以把整条stroke重绘成矢量线,也可以按pressure值还原笔锋粗细。state管理上,strokesStack保存所有已完成的笔迹,currentStroke只在绘制过程中存在,绘制结束立即入栈,形成可撤销的闭环。
4.3 坐标换算:PDF坐标、CSS像素与canvas像素的三角关系
这是整个项目里最绕、也最值得花时间理解的部分。PDF文件内部有自己的坐标系,原点在左下角,单位是pt;pdf.js渲染时通过viewport把它转换成了CSS像素画面;canvas画板又要按dpr换算成物理像素。所以一次笔触落点,其实经历了两次换算。
// bridge/pdfCoordinate.js export function pdfToBoard(pdfPoint, viewport, dpr) { return { x: (pdfPoint.x - viewport.viewBox[0]) * (viewport.width / viewport.viewBox[2]) * dpr, y: (pdfPoint.y - viewport.viewBox[1]) * (viewport.height / viewport.viewBox[3]) * dpr }; } export function boardToPdf(boardPoint, viewport, dpr) { return { x: boardPoint.x / dpr * (viewport.viewBox[2] / viewport.width) + viewport.viewBox[0], y: boardPoint.y / dpr * (viewport.viewBox[3] / viewport.height) + viewport.viewBox[1] }; }这两个函数互为逆运算。导出批注时用boardToPdf把笔迹点换算回PDF坐标系,这样批注数据存到哪里都能在相同页面上原样复现。缩放的时候不需要对已有笔迹做几何变换,因为笔迹数据里存的是PDF坐标系下的数值,每次重绘都重新经过一次pdfToBoard,自然跟随缩放。
4.4 撤销与橡皮擦:两种“后悔药”的实现差别
撤销的实现不复杂,真正的挑战在怎么避免重绘闪烁。简单粗暴的做法是撤销后把整版笔迹全部重画,如果一页上有几十条stroke,手机端会有肉眼可见的卡顿。我这里做一个分层处理:所有笔迹先画到一个离屏的“笔迹底图”canvas上,显示绘制时把笔迹底图一次性贴到画板canvas;撤销时从stroke栈里弹掉一条,然后重建笔迹底图。
// annotationStore.js const strokeStack = []; let inkLayer = document.createElement('canvas'); const inkCtx = inkLayer.getContext('2d'); function commitStroke(stroke) { strokeStack.push(stroke); redrawInkLayer(); } function undoStroke() { if (strokeStack.length === 0) return; strokeStack.pop(); redrawInkLayer(); } function redrawInkLayer() { inkCtx.clearRect(0, 0, inkLayer.width, inkLayer.height); for (const stroke of strokeStack) { inkCtx.beginPath(); stroke.points.forEach((p, i) => { i === 0 ? inkCtx.moveTo(p.x, p.y) : inkCtx.lineTo(p.x, p.y); }); inkCtx.lineWidth = stroke.points[0]?.width || brushWidth * coords.dpr; inkCtx.strokeStyle = stroke.color; inkCtx.stroke(); } boardCtx.drawImage(inkLayer, 0, 0); }橡皮擦则分两种流派:像素擦除和笔迹擦除。像素擦除实现简单,把画板canvas的globalCompositeOperation设为destination-out,擦到哪里哪里透明,但缺点是一旦导出PNG再导入,透明像素丢失,擦除就失效了;笔迹擦除操作的是strokeStack,把和擦除路径相交的笔迹整条剔除,然后走redrawInkLayer重绘。这个项目里默认用笔迹擦除,因为要保证批注数据在服务端和客户端之间可重放、可还原。
4.5 批注导出:JSON协议与PNG快照的双通道
批注数据最终要落地,这里设计了一条双通道的导出策略。一旦用户完成一页批注,前端同时生成两种产物:一份结构化JSON笔迹数据,一份PNG快照。JSON用来做数据回放、二次编辑、跨端同步,PNG用来快速预览和在无权加载pdf.js的环境里展示效果。
// export/annotation.js function exportAnnotation(pageNo) { const pdfPoints = strokesStack.map(stroke => ({ color: stroke.color, width: stroke.width / coords.dpr, points: stroke.points.map(p => boardToPdf(p, viewport, coords.dpr)) })); const snapshotCanvas = document.createElement('canvas'); snapshotCanvas.width = pdfCanvas.width; snapshotCanvas.height = pdfCanvas.height; const ctx = snapshotCanvas.getContext('2d'); ctx.drawImage(pdfCanvas, 0, 0); ctx.drawImage(inkLayer, 0, 0); return { page: pageNo, version: '1.0', strokes: pdfPoints, snapshotBase64: snapshotCanvas.toDataURL('image/png') }; }snapshotBase64这里有一个隐藏功劳:先drawImage pdfCanvas再drawImage inkLayer,意味着快照是把PDF底图和批注压在同一张图里,后端收到这个字段几乎不需要再做任何处理就能直接存库展示。JSON协议的version字段也建议留下,未来批注数据类型扩展时,旧版本才能做兼容。
5. 避坑记录:画布模糊、滚动穿透与笔迹失踪的真实翻车现场
5.1 画布模糊:canvas像素不等于CSS像素
第一个直击灵魂的坑就是canvas模糊。移动端照片级屏幕比比皆是,如果canvas.width写的是CSS像素尺寸,画出来的线条永远像隔着一层毛玻璃。原因是CSS像素和物理像素之间隔着devicePixelRatio这个倍率,canvas默认只能在物理像素里给你一倍的清晰度。
解决办法上面已经演示过:canvas.width赋值CSS尺寸乘以dpr,style.width保持CSS尺寸。每次重绘都要同步这个换算,只要有一处遗漏——比如某些工具函数直接返回CSS像素坐标用于绘制,线条就会在部分操作中变糊。建议把dpr换算封装成唯一的canvasResize工具函数,杜绝散落各处的手写乘法。
5.2 滚动穿透:touch-action与pointer事件配合失误
某次真机测试,用户在画板上画到一半,被批注的PDF页面跟着手指上下滚动,笔迹画歪了。根因是浏览器认为这次手势是一次滚动操作,画板canvas没有明确告诉浏览器“这块区域的触摸事件归我管,你别抢”。修复方式是给画板canvas设置CSS属性touch-action: none。
与之配合的另一条纪律是不要在canvas上监听touch事件后手动调用preventDefault,因为被动事件监听器会吃掉这个调用,而且容易导致报错。直接使用Pointer Events加touch-action,浏览器层面的手势识别被切断,事件分发是干净的。
5.3 翻页后笔迹消失:PDF重绘把画板层顶掉了
翻页时如果直接重绘PDF canvas,没有先保存批注画板的状态,就会看到满屏笔迹瞬间消失。这背后的逻辑其实不复杂:两份canvas是平级元素,批注canvas盖在PDF canvas上面,但PDF canvas重绘时浏览器可能触发合成层更新,把批注canvas暂时盖住或者导致画板上下文被重置。
解决方式是把笔迹底图(inkLayer)全局持有,不用的时候保留在存储层,显示时通过drawImage贴回去。翻页动作完成后,等PDF渲染结束立刻重放当前页的笔迹,而不是等用户重新落笔才补画。项目里把这一动作收敛进了useAnnotation的syncInkLayer函数,翻页回调里必须调用它。
5.4 大PDF卡顿与内存泄漏:worker与缓存失控
一份几十MB的PDF,连续快速翻页后页面变得越来越卡,甚至白屏。查下来有两个元凶:pdf.js的worker线程没有随页面销毁而释放,以及pageCache在边界判断里写漏了一个等于号导致缓存一直不清理。worker泄漏会持续占用内存,cache不断增长则直接撑爆canvas资源。
解决方式一是页面卸载时显式调用pdfDoc.destroy()和worker.terminate();二是trimCache的保留策略调成左右各1页后停止扩展。还需注意getDocument返回的Promise在页面销毁后resolve,会有报错风险,初始化时要往AbortController里注册取消信号,销毁时主动中断。
5.5 橡皮擦擦出“黑边”:destination-out的重绘时机问题
橡皮擦用destination-out擦除时,如果擦除过程中触发了其他操作——比如手指短暂停下触发pointercancel——半透明的像素残留在画板上,导出后出现一条条淡黑色的擦痕。原因在于destination-out是破坏性的像素操作,它没有“撤销本次擦除”的天然能力,一旦像素被擦掉,只能靠整个画板重绘恢复。
所以真正的橡皮擦实现不建议直接操作显示canvas,而是把擦除变成一个“笔迹编辑操作”,记录擦除路径后统一对strokeStack做裁剪,再重建笔迹底图。这样既不产生残留,也能保证任意时刻撤销、回放都不损坏像素内容。如果产品上非要做“像素级橡皮擦”,那导出时就必须连底图一起导出,并且回放逻辑要同步重建。
6. 二次开发方向:从单页批注到多页协同
6.1 从单页画板升级到多页画板
现在代码里一页一个canvas,多页化之后要改造成“一页一个画板实例”的Map结构。每条笔迹必须加上page字段,翻页时只把对应页的strokes渲染到当前可见的canvas上。业务侧建议加一层pageIndex到stroke数组的索引,避免全量遍历。
// annotationStore.js 多页改造 const strokesByPage = new Map(); function pushStroke(pageNo, stroke) { const list = strokesByPage.get(pageNo) || []; list.push(stroke); strokesByPage.set(pageNo, list); redrawPage(pageNo); }多页改造过程中最容易遗漏的是:PDF虚拟滚动复用时,移出屏幕的页面canvas会被销毁,但对应的strokes数据不能跟着销毁,否则用户滚回来笔迹就没了。思路是把stroke数据独立于canvas生命周期,canvas只负责“把数据画出来”。
6.2 笔迹数据协议设计:给后端一个可回放的格式
批注最终要进业务系统,协议里至少包含page、type、color、width、points五个字段。type区分画笔、荧光笔还是矩形框,point里的pressure字段建议保留,服务端就算不用也不碍事。字段越精简,跨端同步越稳,不要给协议加不必要的元信息。
// 笔迹协议示例 { "page": 3, "type": "pen", "color": "rgba(255, 0, 0, 0.8)", "width": 2.5, "points": [ { "x": 120.5, "y": 340.2, "pressure": 0.8 }, { "x": 121.0, "y": 341.0, "pressure": 0.6 } ] }回放时前端拿到这份协议,逐条调用drawStroke逻辑即可。这一整套流程里,最容易让后端同事崩溃的是坐标基准不统一。务必在协议文档里写清楚x和y是PDF原始坐标系的数值,不是canvas像素值,两边才不会有偏差。
6.3 性能优化三板斧:离屏缓存、合成层、降采样
移动端画板性能的瓶颈通常不在绘制本身,而在于频繁的整页重绘和canvas尺寸变换。第一板斧是离屏缓存,PDF页面渲染结束后缓存为离屏canvas,翻页时不再触发pdf.js重渲染,直接drawImage到显示画布。第二板斧是给批注canvas设置独立的合成层,用CSSwill-change: transform让浏览器把画板层提升到独立GPU层,避免每次重绘都重新参与合成。第三板斧是降采样,笔迹点过于密集时按距离阈值抽稀,比如相距小于1物理像素的点直接丢弃。
我现在接批注类需求都会先画一张坐标换算图,把PDF原始坐标系、CSS像素坐标系和canvas物理像素坐标系三者边界标清楚再动手写代码。每写完一个模块就强制走一遍“落笔→缩放→翻页→导出→回放”的完整闭环,确认数据不变形才继续下一个功能。这个习惯帮助我在这个项目里少返工了至少三轮,希望也能帮到你。
本文还有配套的精品资源,点击获取