ARTICLE DETAIL

资讯详情

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

H5中PDF高亮标注的两种实现方案:覆盖层与文件写入

H5中PDF高亮标注的两种实现方案:覆盖层与文件写入 在做一个在线审阅项目的时候我被一个看似简单的问题卡了很久H5 里要渲染 PDF还要支持用户给关键内容加高亮。一开始我觉得 PDF.js 渲染出来不就完事了吗但真正做起来才发现“加高亮”这三个字背后藏着完全不同的两条技术路线选错了后面全是返工。这两条路线分别是一条是在 PDF 渲染结果之上“贴”一层高亮文件本身不动另一条是直接改写 PDF 文件把高亮作为内容或批注“写进” PDF 里。听起来差别不大但它们的坐标体系、交互方式、数据存储、导出逻辑完全是两套玩法。这篇文章我把自己完整跑通的两种方案都整理出来附可运行的核心源码顺便把我踩过的几个坑一起说出来希望帮你少走弯路。1. 动手之前先想清楚高亮是“画在纸上”还是“贴在玻璃上”1.1 PDF 在网页里的渲染机制一次“矢量坐标到像素”的测绘PDF 这个格式和图片不太一样。图片是一格一格的像素而 PDF 内部记录的是“在页面坐标 (x, y) 处用某种字体绘制一段文字”“在这个矩形区域画一条线”这类矢量指令。页面的原始单位是 Point1 Point 1/72 英寸坐标原点在页面左下角y 轴向上。浏览器本身不认识 PDF所以 H5 里渲染 PDF 基本都会借助 PDF.js。PDF.js 做的工作本质上是“测绘”它解析 PDF 里的矢量指令然后通过 Canvas 2D 的 API 把这些指令一笔一划地画出来。这个过程中有个关键对象叫viewport它负责把 PDF 左下角原点、y 轴向上的坐标系转换成 Canvas 左上角原点、y 轴向下的像素坐标系。理解了这一点你就能明白为什么“加高亮”容易出问题PDF 坐标是“文档坐标”它和设备的像素、屏幕的缩放没有直接关系而你在 Canvas 上画东西用的是“像素坐标”。高亮数据必须在这两套坐标系之间做换算换算错了高亮就会歪。1.2 两种方案的分水岭文件是否被改变我最终把方案分成两派判断标准非常朴素高亮之后原 PDF 文件字节有没有变。方案一渲染完 PDF 之后在 Canvas 上方再叠一层“覆盖层”高亮矩形画在覆盖层上。原 PDF 文件一个字节都没变高亮数据单独存比如存到后端数据库里。这种方案的优点是灵活同一份 PDF 每个人可以有自己的一套高亮随时可以删除、修改、换颜色文件本身保持“干净”。方案二用 PDF 处理库我用的是 pdf-lib加载原始 PDF在目标页面上直接绘制半透明矩形或者创建标注对象然后保存成一个新的 PDF 文件。高亮变成了文件的一部分发给别人、归档、甚至用别的 PDF 阅读器打开高亮都在。1.3 根据业务场景选方案三个自测问题我后来总结出三个问题基本能帮你快速判断该走哪条路高亮是给谁看的如果高亮只是某个用户自己看或者在线协作时多人共享推荐方案一如果高亮要随文件一起发出去、归档、打印推荐方案二。高亮可以删除或修改吗业务上要求高亮是临时标注、可以随时撤销方案一更合适要求高亮一旦生成就是正式交付物的一部分方案二更符合直觉。谁能承受文件体积变化方案二每次都会生成新文件如果你的 PDF 动辄几十上百 MB多次标注后文件膨胀会很难受方案一则完全没有这个问题。千万别一上来就写代码。这个选择做错了后面数据模型、接口设计、导出逻辑全都要跟着改。2. 方案一PDF.js Canvas 覆盖层高亮留在页面层上2.1 整体架构两层渲染各司其职方案一的页面结构是两层 Canvas 叠加底层 Canvas专门承载 PDF.js 渲染出来的页面内容。上层 Canvas透明背景只画高亮矩形。为什么要用两层而不是一层因为 PDF 渲染和高亮绘制是两个生命周期。翻页、缩放时 PDF 需要重新渲染如果高亮也画在同一个 Canvas 上每次 PDF 重新渲染你就得把所有高亮重新画一遍。分开之后底层负责“重绘 PDF”上层负责“重绘高亮”互不干扰。布局代码很简单两层 Canvas 通过绝对定位叠在一起div idpdf-container styleposition: relative; overflow: auto; canvas idpdf-canvas styleposition: absolute; top: 0; left: 0;/canvas canvas idhighlight-canvas styleposition: absolute; top: 0; left: 0; pointer-events: none;/canvas /div上层 Canvas 我加了pointer-events: none这样鼠标事件会直接穿透到下层方便后续做拖拽选择文字之类的交互不会因为覆盖层挡住操作。2.2 用 PDF.js 把页面渲染到 CanvasPDF.js 的引入方式很灵活可以直接用 CDN 的全局脚本也可以用 npm 包。我项目中用的是 npm 方式关键是workerSrc要配置对这个问题后面在避坑章节会详细说。npm install pdfjs-dist页面渲染的核心逻辑import * as pdfjsLib from pdfjs-dist; pdfjsLib.GlobalWorkerOptions.workerSrc /static/pdfjs/pdf.worker.min.js; let pdfDoc null; let currentPage 1; const scale 1.5; async function loadPdf(url) { const loadingTask pdfjsLib.getDocument({ url }); pdfDoc await loadingTask.promise; await renderPage(currentPage); } async function renderPage(pageNum) { if (!pdfDoc) return; const page await pdfDoc.getPage(pageNum); const viewport page.getViewport({ scale }); const canvas document.getElementById(pdf-canvas); const ctx canvas.getContext(2d); canvas.width viewport.width; canvas.height viewport.height; // 渲染PDF页面到Canvas await page.render({ canvasContext: ctx, viewport }).promise; // PDF渲染完成后在上层Canvas重绘当前页的高亮 const hlCanvas document.getElementById(highlight-canvas); const hlCtx hlCanvas.getContext(2d); hlCanvas.width viewport.width; hlCanvas.height viewport.height; drawHighlights(hlCtx, viewport); }我建议把scale抽成变量因为移动端和 PC 端对清晰度的要求不一样。Retina 屏下如果 scale 1PDF 文字边缘会发虚通常要按window.devicePixelRatio做适配。2.3 坐标换算PDF 点坐标如何变成屏幕像素这是整个方案一里最容易出错的地方。我在项目里设计的高亮数据结构存的是 PDF 坐标而不是屏幕像素坐标原因是屏幕坐标随缩放、窗口大小变化会变但 PDF 坐标永远是文档自身的坐标无论你在什么设备上打开同一段文字的 PDF 坐标都是一样的。高亮数据模型大概长这样const highlightData [ { id: uuid-xxx, page: 1, pdfX: 100, // PDF坐标系左下角的x pdfY: 600, // PDF坐标系左下角的y pdfWidth: 220, // 高亮矩形宽度 pdfHeight: 24, // 高亮矩形高度 color: #FFEB3B } ];渲染高亮时用viewport.convertToViewport把 PDF 坐标转成 Canvas 像素坐标。这里有个细节PDF 的 y 轴向上Canvas 的 y 轴向下所以“矩形底部”的 PDF 坐标对应“Canvas 上更大的 y 值”换算高度时方向要反一下。function drawHighlights(ctx, viewport) { ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); highlightData .filter(item item.page currentPage) .forEach(item { // PDF坐标转屏幕坐标 const bottomLeft viewport.convertToViewport(item.pdfX, item.pdfY); const topLeft viewport.convertToViewport(item.pdfX, item.pdfY item.pdfHeight); const bottomRight viewport.convertToViewport(item.pdfX item.pdfWidth, item.pdfY); const x bottomLeft.x; const y topLeft.y; const width bottomRight.x - bottomLeft.x; const height bottomLeft.y - topLeft.y; ctx.fillStyle hexToRgba(item.color, 0.4); ctx.fillRect(x, y, width, height); }); } function hexToRgba(hex, alpha) { const r parseInt(hex.slice(1, 3), 16); const g parseInt(hex.slice(3, 5), 16); const b parseInt(hex.slice(5, 7), 16); return rgba(${r}, ${g}, ${b}, ${alpha}); }反过来如果用户在前端框选了一个区域想要高亮就调用viewport.convertToPdfPoint把鼠标的像素坐标转回 PDF 坐标再存储// 假设鼠标拖拽选中的屏幕坐标是 rectX, rectY, rectWidth, rectHeight const startPdf viewport.convertToPdfPoint(rectX, rectY); const endPdf viewport.convertToPdfPoint(rectX rectWidth, rectY rectHeight); const newHighlight { page: currentPage, pdfX: startPdf.x, pdfY: endPdf.y, // 注意这里 pdfWidth: endPdf.x - startPdf.x, pdfHeight: startPdf.y - endPdf.y };因为鼠标拖拽时 rectY 是屏幕坐标、向下增长和 PDF 坐标方向相反所以代码里pdfY取的是endPdf.y即屏幕下方对应较小的 PDF y高度才是正数。2.4 画高亮一个半透明矩形就够了高亮本质就是一个半透明矩形fillRect就能搞定。但想要好看有几个小技巧透明度不要太高0.3 到 0.5 之间比较合适太高会盖住正文文字太低又看不清。高亮的矩形高度可以比文字行高稍微大几个像素视觉上更接近荧光笔的效果。如果需要“圆角荧光笔”效果可以用ctx.roundRect或者手动画圆角路径但移动端兼容性要考察一下老一点的内置浏览器可能不支持。在实际项目里我还会给当前选中的高亮加一个描边方便用户知道自己点中了哪块if (item.id selectedHighlightId) { ctx.strokeStyle #333; ctx.lineWidth 1; ctx.strokeRect(x, y, width, height); }2.5 方案一的上限与边界方案一最大的好处是“轻”PDF 文件始终是原始的高亮数据独立存储可以做多人多版本、可以随意撤销重做初始开发量也不大。但它也有明显的边界。第一高亮只存在于你的 H5 页面里用户如果把 PDF 下载到本地再打开高亮就没了。第二如果要支持“选中一段文字然后高亮”而不是“画一个矩形”你需要结合 PDF.js 的文本层做文本定位复杂度会上一截。这时候就要考虑方案二了。3. 方案二PDF-lib 直改文件把高亮写进 PDF 内容流3.1 思路切换从“展示层”到“数据层”方案二的思路完全反过来不搞覆盖层了直接动用 PDF 处理库修改文件本身。这个方案适合“标注完要导出交付”的场景。比如在线签批系统里用户给合同某一段画了高亮最终要生成一份带高亮的 PDF 发出去方案二就是正解。我用的是 pdf-lib它是一个纯 JS 的 PDF 处理库可以在浏览器和 Node.js 里运行不需要原生依赖这一点在 H5 场景非常重要。npm install pdf-lib3.2 用 pdf-lib 加载并定位目标页面这里有个关键点pdf-lib 不会帮你渲染 PDF它只做数据层面的读写。所以它和 PDF.js 不是替代关系而是“渲染归渲染写文件归写文件”。加载 PDF 并定位页面的代码import { PDFDocument, rgb } from pdf-lib; async function addHighlightsToPdf(sourceUrl, highlights) { // 1. 读取原始PDF的ArrayBuffer const arrayBuffer await fetch(sourceUrl).then(res res.arrayBuffer()); // 2. 加载PDF文档 const pdfDoc await PDFDocument.load(arrayBuffer, { updateMetadata: false // 保持原始元数据不变 }); const pages pdfDoc.getPages(); // 3. 获取目标页面 for (const item of highlights) { const page pages[item.page - 1]; const { width, height } page.getSize(); // 下面详细展开绘制高亮 } // 4. 保存为新PDF const pdfBytes await pdfDoc.save(); return new Blob([pdfBytes], { type: application/pdf }); }注意page.getSize()返回的height是页面原始高度因为 pdf-lib 操作的是 PDF 坐标原点在左下角。3.3 绘制高亮色块与文字覆盖如果高亮只需要视觉上的“底色”直接用drawRectangle就够了for (const item of highlights) { const page pages[item.page - 1]; page.drawRectangle({ x: item.pdfX, y: item.pdfY, width: item.pdfWidth, height: item.pdfHeight, color: rgb(1, 0.92, 0.23), // 黄色 opacity: 0.4, borderColor: rgb(0, 0, 0), borderWidth: 0, }); }这里的高亮坐标我直接使用了方案一定义的数据结构也就是 PDF 坐标。如果你手头的高亮数据是屏幕坐标比如从 Canvas 拖拽得到的像素位置在传给 pdf-lib 之前必须先通过pageHeight - screenY的换算转成 PDF 坐标否则高亮位置会上下颠倒。我实际项目里更常用的做法是页面里用方案一实时预览高亮用户点“导出”按钮时再把高亮数据按 PDF 坐标传给方案二生成新文件。一套高亮数据两处用预览和导出效果完全一致。3.4 需要原生 Highlight 批注时的低层写法drawRectangle是在页面内容流里画了一个矩形本质上和你在页面上画一条线、放一张图没有区别PDF 阅读器不会把它识别成“高亮批注”。如果你的业务要求导出后用户在 Adobe Acrobat 里还能选中、删除这个高亮那就要创建真正的 Highlight 标注对象。pdf-lib 没有提供现成的createHighlightAnnotation方法但可以通过底层 API 构造。核心思路是创建一个Annot字典Subtype设为Highlight并通过QuadPoints声明高亮覆盖的四边形区域import { PDFName, PDFArray } from pdf-lib; // 为某个页面创建原生高亮批注 function createHighlightAnnotation(pdfDoc, page, x, y, width, height) { const { context } pdfDoc; // QuadPoints 是 8 个数一组表示一个四边形坐标顺序有特定要求 const quadPoints context.obj([ [ x, y height, x width, y height, x width, y, x, y ] ]); const annotDict context.obj({ Type: Annot, Subtype: Highlight, Rect: [x, y, x width, y height], QuadPoints: quadPoints, C: [1, 1, 0], // RGB颜色0~1 T: H5Highlighter, // 标题/作者 Contents: 高亮备注, // 批注内容 }); // 把标注挂到页面的 Annots 数组上 let annots page.node.lookup(PDFName.of(Annots)); if (!annots) { annots context.obj([]); page.node.set(PDFName.of(Annots), annots); } annots.push(annotDict); }这块代码需要对 PDF 对象模型有一定理解才能改得动而且不同 PDF 阅读器对QuadPoints的容错性不一样。如果只是“视觉高亮”我建议用drawRectangle就足够了如果确实要做专业批注工具再考虑原生 Annot 方案。3.5 导出验证高亮是否真的“长在” PDF 里写完导出逻辑后一定要做一步验证把生成的 Blob 转成 URL在页面里打开或者在 Chrome 自带的 PDF 阅读器里预览。const blob await addHighlightsToPdf(pdfUrl, highlightData); const url URL.createObjectURL(blob); window.open(url, _blank);我踩过的一个教训是用 pdf.js 渲染方案二生成的 PDF 时某些高亮矩形位置会偏。为什么因为 pdf-lib 保存时默认可能会做一些优化而且如果源 PDF 本身带有CropBox和MediaBox不一致的情况页面尺寸的基准点不同绘制坐标就会出现偏移。遇到这种情况创建新 PDF 时指定页面尺寸const page pdfDoc.addPage([width, height]); // 显式指定页面尺寸就能规避大部分偏差。4. 真实项目里怎么选性能、交互与协作模式的三方权衡4.1 两张方案的横向对比把两套方案放在一张表里看取舍关系非常清晰对比维度方案一Canvas 覆盖层方案二pdf-lib 写入文件原 PDF 文件不改变被修改生成新文件高亮数据存储独立存储数据库/JSON嵌入 PDF 内部高亮可撤销/修改容易改数据重绘即可麻烦需要重新生成文件多用户独立标注天然支持多人改同一文件会互相覆盖导出分发高亮不会带出去高亮跟随文件任何阅读器可见移动端性能依赖 PDF.js 渲染大文件有压力不需要完整渲染但保存大文件耗内存开发复杂度低核心就是坐标换算中等需要理解 PDF 对象模型典型场景在线审阅、课堂标注、协作批注合同签批、档案归档、文件交付4.2 一种很务实的组合用法预览用方案一导出用方案二我在正式项目里最终采用的是组合方案用户在 H5 页面上看到的效果完全由方案一承载高亮实时生成、实时显示想改就改体验非常顺滑当用户点击“导出带标注 PDF”按钮时后端或前端再调用方案二把高亮数据写入 PDF 文件生成一份正式的交付文件。这个组合方案的好处是日常操作高频、交互复杂的部分用最灵活的覆盖层最终交付低频、要求可靠的部分用文件写入。两套代码各干各的高亮数据用同一套 PDF 坐标结构转换层写好之后上面所有的业务逻辑都是复用。4.3 我自己的选择逻辑如果你现在要动手做我的建议是这样如果需求是“给 PDF 加个标注能力”但用户不一定需要导出文件先做方案一它能覆盖 80% 的轻量标注需求。如果需求是“生产带批注的 PDF 文件”只做方案二就够了渲染部分可以用 iframe 里的 PDF.js 辅助展示。如果需求是“既要在线标注又要导出交付”直接按组合方案设计数据模型别做一半再回头。数据模型是整个方案的灵魂。我吃过亏的地方是一开始用屏幕坐标存高亮后来加了缩放功能所有历史高亮全部对不上位置最后重构数据结构才解决。强烈建议从第一天起就统一使用 PDF 坐标。5. 落地过程中踩过的坑提前写出来给你避雷5.1 坑一缩放之后高亮位置对不上这个是方案一最常见的“翻车点”。我在开发时一开始把高亮的 x、y、width、height 直接存成 Canvas 像素值当时的想法是“画的时候方便直接填 fillRect”结果一加缩放功能重新以不同 scale 渲染页面后新 Canvas 的像素坐标和旧数据对不上了所有高亮全部错位看起来就像文本框被甩到了别的位置。解决办法就是前面强调的高亮数据一律存 PDF 坐标渲染时动态通过 viewport 换算。如果你已经有存量数据是像素坐标要尽快写一个迁移函数用当时的 renderScale 反推 PDF 坐标越早改越好。5.2 坑二大 PDF 在 H5 上首屏慢而且卡PDF.js 渲染大 PDF 时如果一次性把几十页全部渲染出来移动端基本会卡死。我经历过一个 100 多页的文件白屏时间长达十几秒用户直接打不开。我的优化思路有三个层次按需渲染只渲染当前页和相邻页比如页面容器滚到第 10 页时才调用pdfDoc.getPage(10)渲染而不是初始化时循环渲染所有页。控制 scale移动端不要一味追求高清先按Math.min(window.devicePixelRatio, 2)控制兼顾清晰度和性能。请求字体分片如果 PDF 内嵌字体很大考虑让后端对 PDF 做预处理或者至少用disableAutoFetch: true让 PDF.js 按需拉取数据const loadingTask pdfjsLib.getDocument({ url, disableAutoFetch: true, disableStream: false });5.3 坑三pdf-lib 写出的文件体积膨胀方案二的一个隐含成本是文件体积。我第一次做导出时一个 2MB 的 PDF加了 20 处高亮后变成了 8MB吓得我以为代码有 bug。排查后发现pdfDoc.load()默认会保留所有页面内容和资源每次save()都可能把资源重新组织一遍源文件越复杂、内嵌字体越多膨胀越明显。几个缓解手段PDFDocument.load(arrayBuffer, { updateMetadata: false })减少不必要的元数据重写。如果只是加高亮不要加载后立刻 save 两次一次 load、一次 save 保持最小操作闭环。针对特别大的文件考虑高亮数据不入文件而是用方案一存数据库只在必要时候导出。5.4 坑四文本层挡住了高亮的点击事件做方案一时如果既需要 PDF.js 的文本选择能力又需要自定义高亮点击事件很容易遇到“文本层挡事件”的问题。PDF.js 默认的textLayer会有一层透明的 span 覆盖在 Canvas 上方用于文本选择但它也把鼠标事件全拦截了导致上层 Canvas 或自定义按钮点不到。我的做法是如果不需要文本选择只做框选高亮就把 textLayer 的pointer-events: none设成 none如果需要文本选择就不要在同一层做高亮的元素级点击事件而是监听容器层的事件通过坐标判断是否命中某个高亮矩形。这样虽然多写一点命中检测逻辑但事件不会打架。5.5 坑五worker 路径在打包后失效PDF.js 的 worker 加载是很多 H5 项目必踩的坑。CDN 引入时GlobalWorkerOptions.workerSrc填一个公开 URL 通常没问题但 webpack/vite 打包时如果把 worker 文件放在源码目录里构建后路径会变掉导致 worker 加载 404PDF.js 直接回退到主线程渲染页面卡到怀疑人生。我的解决办法是在public或static目录放一份 pdf.worker.min.js然后显式指定路径# 以 vite 为例把 node_modules/pdfjs-dist/build/pdf.worker.min.js # 拷贝到 public/pdfjs/pdfjsLib.GlobalWorkerOptions.workerSrc ${import.meta.env.BASE_URL}pdfjs/pdf.worker.min.js;如果用的是 webpack可以用new URL(pdfjs-dist/build/pdf.worker.min.js, import.meta.url)这种写法让打包工具正确处理 worker。核心思路是worker 路径必须是运行时能访问到的静态资源不能依赖相对路径在源码和构建产物里的隐式匹配。真正把这两套方案跑通之后我的感受是技术上最难的其实不是 API 调用而是理解 PDF 坐标系统以及想清楚高亮数据到底应该放在哪个坐标系里。只要数据模型设计对了渲染、交互、导出都只是“换一层皮”而已。希望这篇整理能帮你把这块硬骨头啃下来。
返回列表