十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PDF.js在线批注实现:Canvas分层与坐标映射全解析

PDF.js在线批注实现:Canvas分层与坐标映射全解析 简介基于PDFJS实现的PDF文档在线批注与手绘工具源码包面向需要在前端集成PDF阅读、标注、签批等交互能力的前端开发者与项目组适用于文档审阅、在线签字、教学批注等常见场景。压缩包共14个文件包含4个JavaScript逻辑文件、2个PDF测试文档、2个CSS样式、2个docx开发说明文档、2个PNG效果截图、1个HTML入口页面及1个Markdown说明文件整体仅2.99MB目录结构清晰便于直接查看与二次改造。截止目前已有4869人学习或下载在PDF前端处理方面具有一定参考价值。源码完整支持多页面PDF、自由绘制、文本/箭头/矩形批注、在线签字、颜色与画笔大小调节、字体大小设置、对象级调整、canvas数据序列化为JSON并支持重绘、删除单个对象、清除页面以及历史前进/后退等丰富功能配套文档包含开发说明与使用指南可帮助开发者快速理清PDFJS批注功能的实现脉络并灵活应用到实际项目中。1. PDFJS在线批注为什么不用Canvas而用分层结构做过Web端批注的人大概都踩过同一个坑直接在PDF渲染结果上画线页面一滚动或者缩放笔迹就错位了。这套基于PDFJS的批注源码思路是把「显示」和「批注」拆成两个独立层——PDF用canvas渲染批注画在透明的覆盖层上。清晰分层带来的直接收益是获取标注时可以单独序列化批注层的像素数据不必反复读取PDF底层内容多页和缩放也不会互相干扰。从功能列表看它不只是能画线还包括箭头、矩形、文本、签字、颜色与画笔大小调整而且所有对象都能被选中、移动和删除。这个能力边界意味着它不只是一个教学Demo而是一个可以直接嵌入到合同审批、在线作业批改场景的轻量方案。适合前端开发者、需要给内部系统加批量审阅能力的全栈工程师以及想弄明白Canvas标注如何与PDF坐标互转的读者。下面会从坐标映射、事件状态机、JSON序列化三个关键点拆解这套源码的实现逻辑最后聊一聊我在实际运行时遇到的DPI和重绘问题。2. 核心实现PDF.js渲染、批注层与坐标映射2.1 PDF.js渲染管线与页面缩放源码里引入的是PDFJS的现代版本写法pdfjsLib.getDocument({data})返回PDFDocumentProxy再通过getPage()逐页渲染。核心是渲染参数中viewport的设置它决定了PDF固有的用户单位如何映射到CSS像素。默认情况下PDF的宽度单位是Point1/72英寸而viewport.scale就是Point到像素的放大系数。常见做法是先把scale初始化为1然后根据容器宽度计算实际比例const loadingTask pdfjsLib.getDocument({ data: pdfData }); const pdf await loadingTask.promise; const page await pdf.getPage(1); // 计算适合容器宽度的缩放比例 const containerWidth document.querySelector(#pdfContainer).clientWidth; const baseViewport page.getViewport({ scale: 1 }); const scale containerWidth / baseViewport.width; const viewport page.getViewport({ scale }); const canvas document.createElement(canvas); canvas.width viewport.width; canvas.height viewport.height; canvas.style.width viewport.width px; canvas.style.height viewport.height px; const ctx canvas.getContext(2d); const renderContext { canvasContext: ctx, viewport }; await page.render(renderContext).promise;这里要注意canvas.width和canvas.style.width的区别。canvas.width是绘图缓冲区的实际像素数style.width是CSS显示尺寸。如果两者不一致浏览器会强制拉伸画布导致渲染出来的PDF文字变模糊。源码里将两者设置为相同值避免了缩放失真。同时scale不是固定值它是相对容器宽度动态计算的因此在不同分辨率屏幕下能保持PDF页面完整可见。2.2 批注层的建立透明Canvas与像素坐标批注层不是直接覆盖在PDF画布上而是作为一个独立的兄弟节点和PDF画布拥有同样的CSS尺寸和位置。这种做法叫覆盖层overlay模型。每个批注对象都在这个透明的canvas上绘制而PDF画布始终保持原始内容不变。这样做的可维护点在于清除批注时只需要clearRect覆盖层不需要重新渲染PDF。源码中有一个AnnotationLayer的概念但实际上它并非PDF.js官方注释层那通常是DOM元素构成的而是自定义的批注画布管理器。核心结构类似class AnnotationLayer { constructor(container, viewport) { this.canvas document.createElement(canvas); this.canvas.className annotation-layer; this.canvas.width viewport.width; this.canvas.height viewport.height; this.canvas.style.position absolute; this.canvas.style.top 0; this.canvas.style.left 0; this.container container.appendChild(this.canvas); this.ctx this.canvas.getContext(2d); this.objects []; // 存储每个批注对象的元数据 } }关键参数是position: absolute它的定位上下文是包裹PDF画布和批注层的父容器。父容器必须设置position: relative否则批注层会相对页面或更外层元素定位导致笔迹偏移。批注层自身的width/height和PDF画布保持一致保证笔迹和PDF文字在同一边界内。2.3 坐标映射PDF坐标系到屏幕坐标系的转换PDF坐标系的原点在页面左下角y轴向上而Canvas和DOM坐标系的原点在左上角y轴向下。如果直接拿鼠标事件的clientX/clientY去画画出来的图形是垂直镜像的。源码在记录鼠标坐标时做了转换处理function pageToCanvasPoint(event, layer) { const rect layer.canvas.getBoundingClientRect(); return { x: event.clientX - rect.left, y: event.clientY - rect.top }; }这里用的是DOM坐标即左上角原点所以鼠标移动轨迹已经是正确的Canvas坐标。但也要区分「页面坐标」和「批注坐标」当PDF被缩放或滚动时批注坐标应先转成PDF逻辑坐标再存储。常见的做法是保存时记录scale在绘制时把逻辑坐标乘以当前scale得到屏幕坐标。这套源码里每个对象序列化时保存的是相对于当前viewport的比例坐标0到1之间的float这样即使载入时scale变了重绘也不会跑偏# 伪代码说明坐标归一化逻辑 normalized_x object.x / page_width normalized_y object.y / page_height # 重绘时 screen_x normalized_x * current_viewport.width screen_y normalized_y * current_viewport.height实际实现中用的是JavaScript思想一致。在做此转换时需要注意浮点精度问题连续多次缩放后坐标可能偏移半个像素。源码在重绘时使用了Math.round对最终屏幕坐标取整这能显著减少模糊边缘。3. 手绘与图形批注从鼠标事件到JSON序列化3.1 工具状态机与鼠标事件处理批注工具的切换可以理解为一个简单的状态机。当前工具类型手绘、箭头、矩形、文本、签字决定了鼠标事件处理函数的行为。源码在Toolbar事件绑定中维护了一个currentTool变量并在每次工具切换时改变光标的样式和操作模式。let currentTool pen; const tools { pen: { cursor: crosshair, onMouseDown: startFreehand }, arrow: { cursor: pointer, onMouseDown: startArrow }, rect: { cursor: crosshair, onMouseDown: startRect }, text: { cursor: text, onMouseDown: startText }, sign: { cursor: cell, onMouseDown: startSign } };鼠标按下时状态从IDLE变为DRAWING并记录起始点和临时上下文。移动时根据当前工具调用不同的绘制函数弹起时保存对象并退出绘制状态。这个机制最需要注意的是防止状态遗漏——如果用户在绘制过程中把鼠标移出canvas再松开mouseup事件很可能没有触发状态卡在DRAWING。源码的兜底是监听window的mouseup事件而不是只监听canvaswindow.addEventListener(mouseup, (e) { if (state DRAWING) { state IDLE; finalizeObject(); } });3.2 自由绘制与箭头、矩形的算法实现自由绘制的本质是记录鼠标移动路径上的点序列然后用lineTo画折线。源码处理了笔迹的平滑直接连接所有点会产生尖锐拐角它采用了二次贝塞尔曲线插值每三个相邻点生成一段平滑曲线。function smoothPath(ctx, points) { 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); } ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y); ctx.stroke(); }箭头和矩形则不同它们不需要逐点记录只要首尾两个点。矩形直接用strokeRect(startX, startY, width, height)箭头的关键在于绘制箭头尖端。源码在画完直线后根据直线的方向角计算两条短斜线function drawArrowhead(ctx, fromX, fromY, toX, toY, headLength 10) { const angle Math.atan2(toY - fromY, toX - fromX); ctx.beginPath(); ctx.moveTo(toX, toY); ctx.lineTo(toX - headLength * Math.cos(angle - Math.PI / 6), toY - headLength * Math.sin(angle - Math.PI / 6)); ctx.moveTo(toX, toY); ctx.lineTo(toX - headLength * Math.cos(angle Math.PI / 6), toY - headLength * Math.sin(angle Math.PI / 6)); ctx.stroke(); }headLength决定了箭头尖的大小和画笔尺寸联动。注意如果是空心箭头需要先画主直线再画箭头端否则主直线会被箭头覆盖如果改成填充箭头则要用fill而非stroke并且要调整路径闭合方式。3.3 序列化把Canvas数据转成JSON并重绘对象序列化是批注系统能够保存和恢复的基础。源码没有把Canvas位图直接转成base64因为位图体积太大且无法单独编辑对象。它保存的是每个批注对象的矢量参数类型、起点、终点、颜色、线宽、字体大小、时间戳等。示例如下{ id: uuid-1234, type: arrow, x1: 0.12, y1: 0.35, x2: 0.48, y2: 0.62, color: #ff0000, strokeWidth: 3, timestamp: 1710000000000 }绘制时遍历objects数组根据type分发到对应的绘制函数。因为存储的是归一化坐标重绘前要用当前页面的宽高还原为屏幕坐标。这个设计思路也方便扩展以后如果要在后端做权限校验或搜索直接解析JSON即可不需要OCR处理位图。4. 对象调整、历史撤销与多页面支持4.1 批注对象的选择与变换要让对象可调整需要实现命中检测。源码的做法是为每种类型提供hitTest逻辑矩形判断坐标是否在矩形内部箭头判断点到线段的距离是否小于阈值文本则用measureText计算包围盒。命中后进入选中态并显示8个控制点四角四边中点。控制点拖动的计算方式是以对象原始顶点为基准计算缩放量。矩形缩放相对简单直接更新x2/y2。但手绘路径缩放更麻烦因为它由几十个点构成不能只改两个端点。源码在处理手绘对象时保存的是路径的归一化包围盒和相对坐标数组拖动控制点时乘以缩放系数实现对整条路径的等比变换// 路径对象结构pathPoints 存储每个点的归一化坐标 function scalePath(path, scaleX, scaleY) { return path.map(p ({ x: p.x * scaleX, y: p.y * scaleY })); }这里有一个容易忽略的问题缩放后线宽也会被放大或缩小。源码默认不缩线宽因为批注的视觉一致性通常要求线宽不变。如果希望线宽随对象缩放需要在重绘时把strokeWidth乘以scaleX和scaleY的平均值。4.2 历史前进/后退命令模式还是快照源码中历史记录采用的是快照模式而不是命令模式。每次操作结束后将整个页面的objects深拷贝进undoStack同时清空redoStack。撤销时从undoStack弹出上一份快照代入当前objects并重绘。function snapshot() { undoStack.push(JSON.parse(JSON.stringify(annotationLayer.objects))); if (undoStack.length 50) undoStack.shift(); // 限制栈深度 } function undo() { if (undoStack.length 0) return; redoStack.push(JSON.parse(JSON.stringify(annotationLayer.objects))); annotationLayer.objects undoStack.pop(); annotationLayer.redraw(); }快照模式实现简单但内存占用随对象数增长。如果页码很多、每页对象密集JSON深拷贝会造成明显卡顿。这时可以只对发生变更的页面做快照或者用命令模式只记录操作类型和参数。源码中把栈深度限制为50算是一个折中方案。4.3 多页面场景下的数据隔离与批注同步支持多页PDF时每个页面拥有独立的AnnotationLayer实例。源码用一个数组pageLayers保存每个页面的批量数据数据结构为const pageLayers { 1: { viewport: { scale: 1.0 }, objects: [] }, 2: { viewport: { scale: 1.0 }, objects: [] } };页面切换时只需要隐藏/显示对应的层不需要重新渲染整个PDF。批注数据也按页面ID存储避免不同页面的对象互相污染。但要注意滚动和虚拟渲染如果PDF有100页所有页面都创建canvas会导致内存暴涨。常见做法是只渲染可视区域附近的页面不在可视区的页面销毁批注canvas恢复时再重建。源码中通过IntersectionObserver实现懒加载只有进入视口的页面才执行渲染和批注重绘。相关参数参数作用建议值rootMargin预加载范围200px 0pxthreshold触发比例0.1unloadDistance距离视口多远时销毁600px5. 重绘闪烁与DPI兼容源码包里的几个实战坑5.1 避免全量重绘带来的闪烁对象增多时每次redraw()清空整个Canvas再重绘全部对象肉眼可见闪烁。源码在redraw时只清除发生变化的区域或者利用requestAnimationFrame合并同一帧内的多次重绘请求。核心逻辑let redrawPending false; function requestRedraw() { if (redrawPending) return; redrawPending true; requestAnimationFrame(() { redrawPending false; const ctx annotationLayer.ctx; ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); for (const obj of annotationLayer.objects) { drawObject(ctx, obj); } }); }requestAnimationFrame会把同一帧内的多次requestRedraw合并为一次绘制减少无效计算。如果拖动控制点实时调整大小时仍然卡顿可以把绘制分成两阶段拖动过程中只绘制一个简单的矩形框提示比如虚线框鼠标松开后才重绘真正的对象。5.2 字体尺寸与Canvas高DPI适配批注文本的字体大小在普通屏幕上是正常的但在高DPI的Retina屏幕/缩放浏览器中字会发虚。原因在于CSS像素和物理像素的比例devicePixelRatio不是1。源码处理时读取了全局的DPR然后调整Canvas的绘图缓冲区和坐标缩放const DPR window.devicePixelRatio || 1; canvas.width cssWidth * DPR; canvas.height cssHeight * DPR; canvas.style.width cssWidth px; canvas.style.height cssHeight px; ctx.setTransform(DPR, 0, 0, DPR, 0, 0);做了这一步后字体size参数就可以直接用CSS像素表示否则ctx.font 14px sans-serif里的14会按物理像素渲染实际显示只有7pt。设置完DPR后还要注意getBoundingClientRect()返回的是CSS像素坐标所以鼠标坐标不需要乘以DPR保持和Canvas CSS尺寸一致即可。另一个关于字体大小的坑是PDF.js渲染出的PDF文本是从字体文件解析出的字形而不是可编辑的DOM文本。批注层加入的文本对象只是覆盖在PDF上方的一层透明区域它不会影响PDF内容。所以在导出时如果要合并回PDF需要额外使用jsPDF.addFont()或者后端库解析JSON绘制文本而不是直接canvas.toBlob()输出。这是这套源码最值得研究和扩展的方向。本文还有配套的精品资源点击获取
返回列表