简介:面向移动端H5前端开发者的canvas画板批注PDF预览资源,围绕PDF.js与Canvas技术栈,给出在手机浏览器中解析PDF、渲染页面并添加手写批注的完整实现思路与代码参考,适用于需要文档预览、在线标注、电子签章等交互场景的前端工程师。资源包共150个文件,压缩包大小约2.4MB,核心为JS逻辑脚本、HTML页面结构和CSS样式,同时附带了大量演示效果GIF、界面PNG/JPG截图以及字体、图标等素材,便于直接打开示例页面查看触控绘制和批注效果。项目内含手写板样式模块与基础交互示例,对笔迹绘制、批注叠加方式做了直观演示,方便在此基础上继续扩展。目前已有1750人学习下载。内容覆盖PDF文档加载、分页渲染、touchstart/touchmove/touchend事件处理、线条绘制、批注叠加与Canvas导出保存等关键环节,帮助读者快速掌握从PDF解析到移动端批注的完整链路,节省自行搭建和调试成本,对入门移动端Canvas批注开发具有较高参考价值。
1. Canvas 移动端画板批注与 PDF 预览:一份能直接落地的双画布方案
移动端 H5 里做 canvas 画板批注、PDF 预览,第一反应通常是“把 PDF 渲染到一个 Canvas,批注直接画上去”。但真动手做的人会告诉你,这个思路大概率会在滚动、缩放、撤回、导出这四个环节出问题。这份资源的核心是双 Canvas 分层方案:一层负责 PDF 底图的渲染与缩放平移,一层单独承载手写笔迹,两层之间用同一套坐标换算联动。它解决的是移动端触摸笔迹偏移、页面滚动导致画线中断、批注数据难以回传这些高频痛点。适合做移动端审批签名、文档批注、试卷电子批改类 H5 的开发者。下面从分层原因开始,按可复现的顺序一步步拆开。
2. PDF 渲染与双 Canvas 分层:不拆开画布后面每一步都返工
先把方案定下来,再谈代码。PDF 预览和手写批注如果放在同一张 Canvas 上,每次画笔落下都要触发整张画布重绘,而 PDF 底图的页面级渲染非常重,实际效果是笔画越快,画面越卡,批注还容易在重绘时被清掉。更麻烦的是,撤回的时候你要么整页重绘,要么把笔迹和 PDF 像素耦合在一起,数据完全没法分离。
拆成两层之后,底图层只在 PDF 翻页或缩放时重绘,批注层只维护自己的笔迹列表,两层通过相同的 CSS 尺寸和坐标变换对齐。这样撤回重做只需要动批注层,导出时也能把两层合成一张新图。常见做法是底图先渲染到一个离屏 Canvas 上,再把离屏结果贴到可视区,批注层用透明 Canvas 叠在上面。
2.1 PDF.js 渲染管线与画布选型
渲染 PDF 这层我一般直接用 PDF.js,在这个场景里它有两点不可替代:按需加载页面,以及把 PDF 页面绘制到指定 Canvas 上下文。移动端 PDF 动辄几十上百页,一次性全部渲染完内存直接爆掉,所以正确做法是只渲染当前页和相邻一两页。
// 初始化 PDF.js 并渲染第一页到 canvas import * as pdfjsLib from 'pdfjs-dist'; // worker 源必须指向同源静态目录,线上用 CDN 时要注意静态资源跨域配置 pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/pdf.worker.min.js'; async function loadPdfPreview(url, container) { // getDocument 返回 PDFDocumentProxy,支持分页懒加载 const pdf = await pdfjsLib.getDocument({ url, disableAutoFetch: true }).promise; const page = await pdf.getPage(1); // 高分屏适配:画布物理像素 = CSS 像素 * devicePixelRatio const dpr = window.devicePixelRatio || 1; const viewport = page.getViewport({ scale: 1 }); // paper 是离屏底图,不挂到 DOM 上,后续负责和批注层合成 const paper = document.createElement('canvas'); const ctx = paper.getContext('2d'); // 按 dpr 放大后再渲染,比渲染完再 style 缩放清晰得多 paper.width = Math.floor(viewport.width * dpr); paper.height = Math.floor(viewport.height * dpr); ctx.scale(dpr, dpr); const renderTask = page.render({ canvasContext: ctx, viewport, enableWebGL: true, }); await renderTask.promise; // 底图准备好之后,把它挂到容器最下层 paper.className = 'pdf-paper'; container.appendChild(paper); }这里有两个参数值得单独说。disableAutoFetch: true会让 PDF.js 在获取页面数据时不做预加载,翻页时多一次网络开销,但内存占用会明显下降,适合移动端网页。enableWebGL: true在较新浏览器上会把光栅化交给 GPU,对高清 PDF 的滚动流畅度有可感知的提升,但如果你要兼容比较旧的 WebView,建议先关掉验证效果。
另一个容易忽略的点是ctx.scale(dpr, dpr)被放在page.render之前,这意味着渲染时所有绘制指令都在物理像素坐标系里执行。如果先渲染再缩放,文字边缘会被插值算法抹糊,放大后尤其明显。
2.2 坐标原点与缩放因子:批注对齐的前提
Canvas 的坐标原点和 PDF 的坐标原点完全不同。PDF 页面原点在左下角,x 向右 y 向上;Canvas 原点在左上角,y 向下。直接用 PDF 数据里的坐标画批注,得到的线条会上下颠倒。PDF.js 提供viewport.convertToViewportPoint方法,负责把 PDF 内部坐标转成视口坐标。
还有一个必须提前定好的约定:批注层里保存的坐标,到底存屏幕坐标还是文档坐标。我一般存“PDF 文档坐标”,也就是未经缩放平移的原始 PDF 坐标。这样在任何缩放级别下都可以用同一套数据重绘,不影响撤回重做和跨端恢复。
// PDF 内部坐标转 Canvas 视口坐标 function pageToCanvasPoint(pagePt, viewport) { // convertToViewportPoint 返回 [x, y],单位是 CSS 像素 const [x, y] = viewport.convertToViewportPoint(pagePt.x, pagePt.y); return { x, y }; }实际写入批注数据结构时,我习惯再存一份pageWidth和pageHeight。原因是不同设备上 PDF 的初始显示宽度可能不同,恢复批注时按宽高比例重新映射,比例不变,笔迹就不会错位。比如手机上宽度 375px,平板上宽度 768px,如果直接恢复原始 JS 坐标,笔迹会全部挤到一个角上去。
2.3 从零初始化:图层结构、设备像素比与清晰度
双 Canvas 的结构在 DOM 里非常直接:一个容器,两个绝对定位的 Canvas。底图层负责展示,批注层负责交互,二者尺寸保持一致。这里最容易踩的坑是 Canvas 的width属性与 CSS 尺寸不一致,导致鼠标或触摸事件拿到的坐标和实际绘制位置错位。
<div id="pdfViewer" style="position: relative; overflow: hidden; touch-action: none;"> <!-- 批注层在上层,底图通过 JS 动态插入 --> <canvas id="penLayer" style="position: absolute; left: 0; top: 0;"></canvas> </div>// 初始化批注层,并与底图尺寸对齐 function setupPenLayer(container, paperCanvas) { const pen = document.createElement('canvas'); pen.id = 'penLayer'; pen.style.position = 'absolute'; pen.style.left = '0'; pen.style.top = '0'; // 批注层和底图必须用同一套物理像素尺寸 const rect = paperCanvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; pen.width = Math.floor(rect.width * dpr); pen.height = Math.floor(rect.height * dpr); pen.style.width = rect.width + 'px'; pen.style.height = rect.height + 'px'; container.appendChild(pen); return pen; }touch-action: none写在容器样式里,是为了防止移动端浏览器把触摸手势解释为页面滚动。这样做的代价是画布区域无法通过手势滚动页面,所以实际项目中通常把整个 PDF 预览放在一个子页面里,用按钮或页面外层区域处理翻页和滚动。
初始化完成后的第一步建议是画一条对角十字线做对齐测试,确认底图、批注层、触摸事件三者的坐标系完全一致,再开始写真正的业务功能。这一步能省掉后面至少两个小时的排查时间。
3. 手写批注交互:从 Pointer Events 到缩放平移坐标换算
图层分层解决的是“画在哪”,这一章解决的是“怎么画”。移动端批注的交互核心是触摸点采样、笔画平滑、缩放状态下的坐标换算。这三个点直接决定批注用起来是不是跟手。
3.1 Pointer Events 捕获笔迹:为什么不用 touch 事件
很多人写移动端画板还在用touchstart/touchmove/touchend,这套事件体系的问题在于多指触摸时要自己维护一个touchMap,而且它和鼠标事件是两套逻辑。Pointer Events 把鼠标、触摸、触控笔统一成同一种事件模型,在移动端 WebView 的兼容性已经足够好。
// 使用 Pointer Events 捕获笔画 let drawing = false; let currentStroke = []; penLayer.addEventListener('pointerdown', (e) => { if (!e.isPrimary) return; // 只处理主轴,忽略第二根手指 drawing = true; // 捕获指针,手指滑出画布也能继续收到 move 事件 penLayer.setPointerCapture(e.pointerId); const point = screenToCanvas(e.clientX, e.clientY); currentStroke = [{ x: point.x, y: point.y, p: e.pressure }]; }); penLayer.addEventListener('pointermove', (e) => { if (!drawing) return; const point = screenToCanvas(e.clientX, e.clientY); currentStroke.push({ x: point.x, y: point.y, p: e.pressure }); drawSmoothPath(penCtx, currentStroke); }); penLayer.addEventListener('pointerup', () => { drawing = false; commitStroke(currentStroke); });screenToCanvas是这里的核心函数,网上很多错误示例直接使用e.offsetX,但一旦画布加了 CSStransform做缩放,offsetX拿到的值就会和真实文档坐标不一致。正确做法是拿getBoundingClientRect做一次换算。
// 把屏幕坐标转成文档坐标,兼容缩放平移状态 function screenToCanvas(clientX, clientY) { const rect = penCanvas.getBoundingClientRect(); const x = (clientX - rect.left - currentTransform.tx) / currentTransform.scale; const y = (clientY - rect.top - currentTransform.ty) / currentTransform.scale; return { x, y }; }3.2 笔迹采样与平滑:把折线优化成贝塞尔曲线
直接把采样点用lineTo连起来,快速书写时会看到明显折线。移动端pointermove触发频率通常在 60Hz 到 100Hz 之间,但手指快速划过时相邻采样点之间仍然有像素级间隔,如果把点之间的连线直接描出来,线条会有明显的棱角。
常见做法是用二次贝塞尔曲线替代直线段。具体操作不是把贝塞尔曲线的控制点设成前后采样点,而是取相邻两个点的中点作为曲线终点,让曲线在采样点之间平滑过渡。
// 用二次贝塞尔曲线平滑笔迹 function drawSmoothPath(ctx, pts) { if (pts.length < 3) { // 点太少时直接画个圆,避免出现看不见的短线 ctx.beginPath(); ctx.arc(pts[0].x, pts[0].y, ctx.lineWidth / 2, 0, Math.PI * 2); ctx.fill(); return; } ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); for (let i = 1; i < pts.length - 1; i++) { // 中点作为贝塞尔终点,前一个采样点作为控制点 const midX = (pts[i].x + pts[i + 1].x) / 2; const midY = (pts[i].y + pts[i + 1].y) / 2; ctx.quadraticCurveTo(pts[i].x, pts[i].y, midX, midY); } ctx.stroke(); }这里有一个取舍:quadraticCurveTo会让线条在小角度转弯处更顺滑,但如果采样间隔过大,也会出现“转弯被削平”的感觉。遇到这种问题不要急着改曲线算法,先看pointermove的坐标是不是经过了多余的平滑处理。有些浏览器会在低功耗模式下主动降低事件频率,此时需要补点:在两个大间隔采样点之间插入一个中间点,让曲线更贴合真实手写轨迹。
3.3 缩放平移双画布联动:坐标换算的两种做法
缩放平移是批注场景里最容易被忽视的环节。第一版往往只用触摸画线,不做缩放,但实际使用 PDF 时用户一定会双指放大看细节,再在放大的画布上批注。这里有两种做法,我对比一下。
| 方案 | 优点 | 代价 |
|---|---|---|
| 每次缩放后重绘所有笔迹 | 逻辑直观,所有坐标都在同一变换下 | 笔迹多时重绘开销大,容易卡顿 |
| 用 CSS transform 缩放画布 | 走浏览器合成器,性能好 | 坐标换算必须反向计算,代码稍复杂 |
我一般用第二种,两层 Canvas 同步应用同一个transform。实现时把缩放因子和偏移量存进一个对象里,所有批注绘制都基于文档坐标,只有最终上屏时才做变换。
// 统一管理缩放和平移 const currentTransform = { scale: 1, tx: 0, ty: 0 }; function applyTransform(scale, tx, ty) { currentTransform.scale = scale; currentTransform.tx = tx; currentTransform.ty = ty; const transformStr = `translate(${tx}px, ${ty}px) scale(${scale})`; paperCanvas.style.transform = transformStr; penCanvas.style.transform = transformStr; } // 双指手势的简化更新逻辑:按捏合距离变化调整 scale penCanvas.addEventListener('pointermove', (e) => { if (pinchActive) { const distance = Math.hypot(e.clientX - pinchCenter.x, e.clientY - pinchCenter.y); const newScale = pinchStartScale * (distance / pinchStartDistance); applyTransform(newScale, currentTransform.tx, currentTransform.ty); } });这个方案的关键点是:批注层的style.transform变化后,已经画出来的笔迹会同步缩放,而pointerdown时写入的坐标始终是未缩放的文档坐标。下次pointermove继续按文档坐标采样,就不会出现缩放到一半笔迹突然跳到另一个位置的问题。
4. 批注数据管理:撤回重做、JSON 恢复与合成导出
批注不是画完就结束,数据要能存、能恢复、能导出。这一章处理的是批注的数据模型、存取方式和最终合成导出。
4.1 笔迹数据模型与撤回重做栈
批注数据不能存成图片像素,必须存成矢量轨迹。这样撤回可以精确到某一段笔画,导出时还能重新设置颜色和线宽。数据模型一般长这样:
{ "version": 1, "pageNumber": 1, "strokes": [ { "id": 1700001, "tool": "pen", "color": "#ff5252", "width": 2, "points": [ { "x": 12.3, "y": 34.1, "p": 0.8 }, { "x": 14.5, "y": 36.2, "p": 0.6 } ] } ] }width字段最好存的是“文档坐标系下的宽度”,而不是屏幕像素宽度。缩放 200% 后重绘,线宽应该按比例放大,同理撤销重做后也要保持视觉一致。p字段保存压感值,没有压感数据时默认为 1。
// 撤回重做操作栈 const undoStack = []; const redoStack = []; function commitStroke(stroke) { pageStrokes.push(stroke); undoStack.push({ type: 'add', stroke }); redoStack.length = 0; // 新操作产生后清空重做栈 } function undo() { const op = undoStack.pop(); if (!op) return; if (op.type === 'add') { const index = pageStrokes.indexOf(op.stroke); if (index > -1) pageStrokes.splice(index, 1); } redoStack.push(op); renderAllStrokes(); } function redo() { const op = redoStack.pop(); if (!op) return; if (op.type === 'add') { pageStrokes.push(op.stroke); } undoStack.push(op); renderAllStrokes(); }这个栈结构保存的是“操作事件”而不是“整页快照”,笔迹数量几百条时也不会内存暴涨。边界条件只有一个要注意:每次commitStroke之后必须清空redoStack,否则会出现“撤回后又画新笔迹,旧的重做操作还在”的诡异现象。
renderAllStrokes每次会把当前页所有笔迹重绘一遍。笔迹数量不多时没问题,如果单页超过 300 笔,建议只重绘受影响区域,或者把每页批注缓存到离屏 Canvas 上,滚动回来时直接贴图。
4.2 JSON 序列化与跨端恢复
批注数据持久化最通用的格式就是 JSON。移动端一般存localStorage,多端同步时传入后端接口。这里要有一个意识:JSON 存的是文档坐标,但恢复时的显示尺寸可能和保存时不同,所以必须做等比映射。
// 保存批注数据 function saveAnnotations(pdfKey, pageNumber, pageData) { const key = `annot:${pdfKey}:${pageNumber}`; const payload = JSON.stringify({ w: pageData.pageWidth, h: pageData.pageHeight, strokes: pageData.strokes }); localStorage.setItem(key, payload); } // 恢复批注数据,并按新的视口尺寸等比映射 function restoreAnnotations(pdfKey, pageNumber, viewport) { const key = `annot:${pdfKey}:${pageNumber}`; const raw = localStorage.getItem(key); if (!raw) return []; const data = JSON.parse(raw); const scaleX = viewport.width / data.w; const scaleY = viewport.height / data.h; return data.strokes.map(stroke => ({ ...stroke, points: stroke.points.map(p => ({ x: p.x * scaleX, y: p.y * scaleY, p: p.p })) })); }localStorage容量限制一般是 5MB 左右,纯文本轨迹数据很难超过这个值,但如果一个文档批注了几十页,每页都有几百条笔画,建议还是走后端存储。这种场景下前端的职责是生成 JSON,后端负责持久化和版本控制,避免用户换设备后批注丢失。
4.3 导出批注后的 PDF:合成策略
导出是批注功能的最后一步,也是最容易让用户觉得“白做了”的环节。前端能做的是把底图和批注层合成一张图片,再把图片嵌入 PDF 新页面或传给后端合成。两种方案各有适用场景:如果只是预览分享,直接导出 PNG/JPEG;如果要求保留 PDF 里的文字可复制,必须由后端在原始 PDF 文件上叠加批注图层。
// 把底图和批注层合成为一张画布 function mergeLayers(paperCanvas, penCanvas) { const merged = document.createElement('canvas'); merged.width = paperCanvas.width; merged.height = paperCanvas.height; const ctx = merged.getContext('2d'); // 先画底图,再画批注层 ctx.drawImage(paperCanvas, 0, 0); ctx.drawImage(penCanvas, 0, 0); return merged; } // 导出当前页图片 function exportPage(mergedCanvas) { return mergedCanvas.toDataURL('image/jpeg', 0.92); }一个常被忽略的细节:merged.width用的是物理像素尺寸,导出图片的分辨率等于屏幕像素。在 dpr=2 的屏幕上导出的图片,单页宽度可能有 1500px 以上,对移动端足够,但如果你要拿到印刷级别,就需要在渲染底图时把 viewport 的 scale 放大到目标 DPI,而不是直接截屏。
如果需要生成一个新的 PDF 文件,前端可以用 pdf-lib 或 jsPDF 把导出的 JPG 插入空白页。但要注意,这个 PDF 是“图片型 PDF”,原来的文字层已经丢失。这个边界必须在技术方案评审时讲清楚,否则产品经理会以为批注后的 PDF 还能搜索原文。
5. 移动端 Canvas 批注避坑:五个高频问题的定位与修正
分层方案和交互编码都清楚了,接下来是真实使用中最常遇到的五个问题。这些坑很多不是写代码时立刻暴露的,而是测试机换到第三台才开始出现。
5.1 坐标偏移、滚动断线与大 PDF 白屏
现象:手指落笔的位置和画出来的线有偏移,横屏正常竖屏偏移,或者屏幕顶部和底部偏移方向相反。原因:Canvas 的物理像素尺寸与 CSS 尺寸不一致,没有乘devicePixelRatio,也没有在ctx.scale前设置正确尺寸。解决:统一按“物理像素 = CSS 尺寸 × dpr”初始化画布,确保screenToCanvas拿到的是同一个尺寸基准。
现象:手机上下滑动时页面跟随滚动,笔画断成几截;画快速横线时中间突然少一段。原因:touchmove的默认滚动行为没有被阻止,或者setPointerCapture没有调用,手指稍微滑出画布就丢失了事件。解决:在画布容器上加touch-action: none,pointerdown后立即调用setPointerCapture(e.pointerId)。
现象:大型 PDF 加载后白屏,页面没有任何报错打印。原因:PDF.js 的 worker 文件路径配置错误,或者一次性把当前页和所有页全部渲染,内存溢出。解决:把workerSrc指向和pdf.min.js同目录的本地静态文件;业务代码里只渲染当前页,使用disableAutoFetch关闭自动预加载。
5.2 压感失效、缩放错位与导出丢字
现象:部分安卓机型写字宽度始终不变,e.pressure要么是 0 要么是 1。原因:Pointer Events 的pressure在部分 WebView 里没有真实传感器数据,而是返回一个固定值。解决:不要单独依赖pressure,可以结合相邻采样点的时间间隔和位移距离做加权计算,时间间隔短且位移小的点视为重压。
// 在没有真实压感时的近似处理 function estimatePressure(prevPt, currPt, timeDelta) { const distance = Math.hypot(currPt.x - prevPt.x, currPt.y - prevPt.y); const speed = distance / Math.max(timeDelta, 1); // 速度越慢,压感越大 return Math.min(1, 1 - speed / 10); }现象:双指缩放后再画线,笔迹出现在完全错误的位置,甚至跑到画布外。原因:坐标换算时把缩放后的屏幕坐标直接保存了,没有按currentTransform反算成文档坐标。解决:严格区分三个坐标系——屏幕坐标、图层坐标、文档坐标。数据结构里只存文档坐标,事件处理时先反算再落盘。
现象:导出 PDF 后发现批注比屏幕清晰度差很多,或者部分笔画在翻页后消失。原因:导出用的是当前屏幕 Canvas 的快照,缩放后的批注层在低分辨率缓存中丢失了细节,或者翻页时没有等待page.render完成就直接合成。解决:导出前重新以目标 DPI 渲染底图,并在renderTask.promise完成后执行合成。
6. 进阶技巧:离屏渲染与一条十字线查坐标对齐
当单个 PDF 页面的批注数量超过两百条时,每次renderAllStrokes都会带来肉眼可见的延迟。进阶做法是把已确定的批注渲染到一个离屏 Canvas 上,新笔画正在绘制时才走实时路径,笔迹完成后再把整层缓存更新一次。
// 新笔迹落笔时只更新局部区域,避免整层重绘 function renderIncremental(ctx, pendingStroke) { const bbox = getStrokeBBox(pendingStroke); ctx.save(); ctx.beginPath(); // 多留 8px 缓冲,避免贝塞尔曲线溢出裁剪区 ctx.rect(bbox.x - 8, bbox.y - 8, bbox.width + 16, bbox.height + 16); ctx.clip(); drawSmoothPath(ctx, pendingStroke.points); ctx.restore(); }这里的取舍很直接:局部裁剪把绘制范围限制在笔迹边界框内,但每次裁剪和恢复也会消耗资源。实际项目中我会先判断pendingStroke.points.length > 30才走增量绘制,点数少时直接整层重绘更快。
另一个我每次交付前都强制执行的验证手段是十字线自检。在批注层上画两条贯穿整个页面的横线和竖线,然后分别测试三种状态:原始比例直接导出、缩放 200% 后导出、横屏翻转后导出。把导出的图片叠到原 PDF 上,如果十字线交叉点偏移超过 1px,说明坐标换算链路有未发现的 bug。
// 自检用十字线,可临时加在批注层上 function drawCrossCheck(ctx, width, height) { ctx.strokeStyle = 'rgba(255, 0, 0, 0.6)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(0, height / 2); ctx.lineTo(width, height / 2); ctx.moveTo(width / 2, 0); ctx.lineTo(width / 2, height); ctx.stroke(); }这个方法的妙处在于,十字线的任何一端发生偏差都能直接锁定问题方向:水平和垂直线在同一个点错位,说明坐标原点问题;歪斜但不过交点,说明旋转矩阵有误;只在缩放状态下错位,说明变换没有同步到底图层和批注层。
从那以后,我每次接这类移动端批注需求,都会强制走一遍“横屏、缩放、导出叠加”三项自检,再复杂的坐标换算也会把底图和批注层当成两个独立图层分开验证。批注功能看着简单,但真正考验的是坐标体系的纪律性。这套双 Canvas 思路和实践步骤希望能帮到你,按章复现后遇到问题,优先从坐标换算层开始查。
本文还有配套的精品资源,点击获取