简介一套基于 pdf.js 开发的在线文档处理插件专为 Web 开发者提供可直接部署的 PDF 查看解决方案核心功能包括全文搜索、结果高亮、区分大小写匹配、分页浏览、放大缩小、下载与打印可通过 viewer.html?file 方式快速加载指定 PDF 文件。压缩包共 366 个文件体积仅 5.53MB其中包含 168 个 bcmap 字体映射文件、110 个 properties 配置、49 个 svg 图标、14 个 pfb 字体、6 个核心 js 脚本及 ttf 字体、css 样式等各类型分工明确方便前端开发者按需修改或集成。目前已有 358 人学习下载该插件既适合入门者研究 pdf.js 的目录结构与插件机制也能作为现成组件快速嵌入在线文档、电子书或 OA 系统省去从零搭建的兼容性与字体处理工作。1. 把 PDF 阅读功能搬进浏览器pdf.js 能替你干哪些活如果你要在网页里让用户“看 PDF、搜词、高亮、翻页、缩放、下载、打印”——用 pdf.js 做不走服务端。pdf.js 是 Mozilla 开源的 PDF 解析与渲染库浏览器端就能把 PDF 文件渲染成 canvas同时保留文本层信息供全文搜索定位和关键词高亮。这份插件的核心价值在于把在线搜索、区分大小写、高亮显示、分页、放大缩小、下载、打印全部集成进一个前端插件工程拿过来改一下配置和样式就能嵌入已有系统。适合有文档在线阅读需求的 Web 前端开发、做文档管理系统的团队也适合想在内部网络中离线部署查看器、不想受商业 PDF 控件限制的开发者。2. 从加载到分页渲染骨架与状态管理的实现2.1 初始化装依赖、配置 Worker、加载文档pdf.js 的工作方式不是直接把 PDF 变成图片扔给浏览器而是自己解析 PDF 数据结构再通过 canvas API 把每一页画出来。解析和渲染计算量都很大所以 pdf.js 设计了一个 Web Worker 做背后计算主线程只负责任务调度和最终绘制。如果 Worker 配置不对界面会直接卡住或者白屏这一步是整个插件的地基。先装依赖再初始化 Worker这是每个 pdf.js 项目绕不开的第一步# 以 Vue 3 Vite 工程为例 npm install pdfjs-dist3.11.174// main.js 或 viewer 入口文件 import * as pdfjsLib from pdfjs-dist; import workerSrc from pdfjs-dist/build/pdf.worker.min.js?url; // 关键配置workerSrc 必须指向 worker 脚本的实际可访问地址 // Vite 环境下用 ?url 会把资源重新拷贝到构建输出目录避免打包路径错乱 pdfjsLib.GlobalWorkerOptions.workerSrc workerSrc; // 打开远程 PDF 文件 const loadingTask pdfjsLib.getDocument({ url: /static/help-manual.pdf, cMapUrl: /static/cmaps/, cMapPacked: true, }); loadingTask.promise .then(pdf { console.log(PDF 加载成功总页数 ${pdf.numPages}); }) .catch(err { console.error(加载失败, err); });逻辑说明getDocument 返回的是一个 loadingTask它内部会拉取 PDF 字节流、解析目录结构并准备渲染所需的数据。真正打开文档后拿到的 pdf 对象上有 numPages、getPage 等方法和属性。cMapUrl 指定字符映射表的路径处理中文 PDF 或特殊编码字符时必须配上否则容易出现乱码或文本提取错位。参数说明cMapPacked 设成 true 表示使用压缩过的 .bcmap 二进制映射文件体积小、加载快如果服务器上只有 .jcmap 文本格式就把 cMapPacked 改成 false。workerSrc 也可以写成 CDN 地址但内网部署时建议拷贝到本地静态目录避免跨域问题。2.2 渲染第一页viewport 与 canvas 的尺寸对齐pdf.js 里的 viewport 是一个很核心的概念它负责把 PDF 文件里的坐标单位“点”换算成浏览器里的像素。渲染每一页前都要先用 getViewport 拿到当前页面在某个缩放比例下的可视尺寸再同步设置 canvas 的宽高。// 假设已经通过 getDocument 拿到 pdf 对象 const pdf await loadingTask.promise; const page await pdf.getPage(1); // 注意页码从 1 开始不是 0 // 渲染缩放比1 表示 100% 显示 const scale 1.5; const viewport page.getViewport({ scale }); const canvas document.getElementById(pdf-viewer); const context canvas.getContext(2d); // canvas 的像素尺寸必须等于 viewport 尺寸否则画面会被拉伸变形 canvas.width viewport.width; canvas.height viewport.height; const renderTask page.render({ canvasContext: context, viewport, }); renderTask.promise .then(() console.log(第一页渲染完成)) .catch(err console.error(渲染失败, err));逻辑说明page.render 是渲染动作的发起处它把当前页的内容在指定 viewport 下绘制到 canvas 上。renderTask 是一个带 promise 的任务对象可以监听渲染完成或失败也可以调用 cancel 取消渲染。这里最容易犯的错是只设置了 canvas 的 CSS 宽度而不设置像素宽高结果看起来很模糊或者比例不对。参数说明scale 不是固定的它决定页面显示大小。1.0 表示原始尺寸1.5 表示放大 50%常用区间在 0.5 到 4 之间。如果你的阅读器容器宽度是固定的可以用容器宽度除以 viewport 原始宽度来动态计算 scale这样不管什么屏幕都能撑满。2.3 分页状态页码切换与渲染取消一个能用的阅读器必须有清晰的页码状态管理。翻页时不仅仅是改一个数字要把上一页的渲染任务取消、加载新页面、重新创建 viewport、再次渲染。如果这些流程不做好状态管理快速翻页的时候会有一堆渲染任务堆积在队列里前面的还没画完后面的就来了最终显示的是乱页。class PdfViewer { constructor({ pdf, canvas, pageLabel }) { this.pdf pdf; this.canvas canvas; this.pageLabel pageLabel; this.currentPage 1; this.scale 1.5; this.renderTask null; } async jumpTo(pageNo) { if (pageNo 1 || pageNo this.pdf.numPages) return; // 切换页码前取消未完成的渲染任务避免新任务和旧任务交叠 if (this.renderTask) { this.renderTask.cancel(); } this.currentPage pageNo; const page await this.pdf.getPage(pageNo); const viewport page.getViewport({ scale: this.scale }); const ctx this.canvas.getContext(2d); this.canvas.width viewport.width; this.canvas.height viewport.height; this.renderTask page.render({ canvasContext: ctx, viewport, }); await this.renderTask.promise; this.pageLabel.textContent ${pageNo} / ${this.pdf.numPages}; } next() { return this.jumpTo(this.currentPage 1); } prev() { return this.jumpTo(this.currentPage - 1); } }逻辑说明jumpTo 是核心翻页方法它先校验页码范围再取消上一个渲染任务然后渲染新页面。cancel 不是一个可选操作尤其是在快速点击翻页时它会直接终止之前尚未画完的任务避免 canvas 上出现图像覆盖错乱。页码标签也在这里一并更新保持界面状态同步。参数说明currentPage 是当前页码的状态变量后续搜索高亮要确定“当前页显示哪些高亮”也依赖它。scale 在这个类里作为全局属性保存渲染页面时统一使用避免不同方法里各传各的数值导致比例不一致。3. 搜索与高亮全文匹配与 canvas 高亮叠加的完整步骤3.1 搜索实现遍历页面提取文本并匹配PDF 页面上的文字并不像 HTML 一样可以直接用 DOM 查找需要调用 getTextContent 把页面里的文本块提取出来。返回的 textContent 对象包含一个 items 数组每个 item 里至少有 str、transform、width、height 等属性代表一个文本片段的位置和内容。搜索就是在这些 items 里做字符串匹配然后记录命中的 item 信息供后续高亮绘制使用。async function searchInPage(pdf, pageNum, keyword, caseSensitive) { const page await pdf.getPage(pageNum); const textContent await page.getTextContent(); const matches []; textContent.items.forEach((item, index) { // 有些 item 是空字符串或不可见字符直接过滤掉 const raw item.str || ; if (!raw.trim()) return; // 匹配判断按是否区分大小写构建源字符串 const haystack caseSensitive ? raw : raw.toLowerCase(); const needle caseSensitive ? keyword : keyword.toLowerCase(); if (haystack.includes(needle)) { matches.push({ pageNum, index, str: raw, transform: item.transform, width: item.width, height: item.height, }); } }); return matches; }逻辑说明搜索结果不是只记一个“找到了”必须把文本位置信息一起存下来。transform 是一个长度为 6 的矩阵数组第 5 和第 6 个元素分别代表文本的 x 和 y 坐标这是之后画高亮矩形时定位的关键。width 和 height 是当前文本块在 PDF 坐标系下的宽度和高度画矩形时要用到。参数说明caseSensitive 为 true 时严格区分大小写为 false 时先统一转成小写再做比较。这种逐页扫描式搜索对 100 页以内的文档完全够用如果文档上千页第一次加载时就应该把每页的 textContent 缓存成一个索引搜索直接从索引里找。注意getTextContent 是异步操作每调用一次都要重新解析文本层频繁翻页搜索时要注意缓存避免每次都重复取文本。3.2 区分大小写与匹配模式参数化区分大小写这个需求看起来简单实际业务里往往还叠加“整词匹配”这种要求。比如用户搜“Type”不希望匹配出“TypeScript”里的“Type”搜“C”如果不处理正则转义控制台直接给你抛异常。所以匹配逻辑最好封装成一个独立的 matcher把规则统一进去。function buildMatcher(keyword, { caseSensitive, wholeWord }) { // 先转义正则特殊字符避免关键词里的括号、加号、点号破坏正则 let pattern keyword.replace(/[.*?^${}()|[\]\\]/g, \\$); // 不区分大小写时统一转小写 if (!caseSensitive) { pattern pattern.toLowerCase(); } // 整词匹配用单词边界约束左右两侧 if (wholeWord) { pattern \\b${pattern}\\b; } return (text) { const source caseSensitive ? text : text.toLowerCase(); const regex new RegExp(pattern, g); return source.match(regex); }; } // 用法示例 const matchType buildMatcher(Type, { caseSensitive: true, wholeWord: true }); const result matchType(TypeScript 中的 Type 定义); // 结果只匹配到后半段 “Type”不会匹配开头的 “TypeScript”逻辑说明把关键词直接塞进 new RegExp 是高风险操作因为方括号、括号、加号、星号这些字符都是正则语法不转义轻则匹配结果错误重则直接报错。replace 那一行就是在做转义把特殊字符前面加上反斜杠。整词匹配用的是 \b 单词边界英文场景下效果很好。参数说明caseSensitive 和 wholeWord 是两个独立开关可以自由组合。中文没有词边界概念所以整词匹配对中文基本不生效如果阅读器要支持中英混排建议在构建 matcher 时判断关键词里是否包含汉字包含就不启用 \b避免中文被错误过滤。3.3 在当前 canvas 上绘制高亮矩形搜索命中后要做的就是把文字区域变成醒目的色块。这个插件用的是 canvas 叠加绘制不操作 DOM性能好也不受文本层重排影响。具体做法是拿到 PDF 坐标系下的坐标经过翻转和缩放换算成 canvas 像素坐标再用 fillRect 画一个半透明矩形。function drawHighlights(canvas, viewport, matches, scale) { const ctx canvas.getContext(2d); ctx.save(); ctx.fillStyle rgba(255, 255, 0, 0.4); // 黄色半透明 matches.forEach(item { // transform 数组的第 5、6 位分别是 x、y 坐标 const pdfX item.transform[4]; const pdfY item.transform[5]; const pdfWidth item.width; const pdfHeight item.height; // 关键换算PDF 原点在左下角canvas 原点在左上角y 方向必须翻转 const canvasX pdfX * scale; const canvasY viewport.height - (pdfY pdfHeight) * scale; ctx.fillRect( canvasX, canvasY, pdfWidth * scale, pdfHeight * scale ); }); ctx.restore(); }逻辑说明坐标翻转是这里最容易写错的地方。PDF 坐标系的 y 轴向上canvas 的 y 轴向下直接把 PDF 里的 y 拿过来用矩形会跑到完全相反的位置。正确做法是先算出文本块顶部在 PDF 坐标系里的 y 值pdfY pdfHeight再乘以 scale最后用 viewport.height 减掉它。参数说明scale 必须和渲染页面时用的是同一个值。假如渲染时用 1.5画高亮时用 1高亮位置会整体偏移放大缩小时更明显。drawHighlights 最好在每次渲染完成后调用把当前页面所有命中的高亮重新画一遍保证翻页或缩放后高亮仍然对上。4. 缩放、下载与打印交互出口的三种联动方案4.1 缩放scale 的统一管理与渲染重绘缩放功能表面上是改一个数字实际牵扯到三件事更新 scale 变量、重新渲染当前页、重绘高亮和标注。还有一个容易忽略的点是缩放中心。如果不记录鼠标位置或页面中心点用户在放大时视野会漂移比如正在看右下角内容放大后跑到画面外了。function zoom(delta) { // delta 0 表示放大delta 0 表示缩小 const oldScale this.scale; const newScale Math.min(4, Math.max(0.5, oldScale delta * 0.25)); if (newScale oldScale) return; // 以容器中心为缩放基准保存固定点 const containerRect this.container.getBoundingClientRect(); const centerX containerRect.width / 2; const centerY containerRect.height / 2; this.scale newScale; this.rerender({ offsetX: centerX, offsetY: centerY }); }这里的 rerender 内部逻辑很简单重新取当前页、创建带新 scale 的 viewport、渲染到 canvas、再调用 drawHighlights。关键是 scale 不能散落在各个方法里要统一成一个全局状态渲染、搜索、高亮全都读取同一个值。参数说明缩放范围限制在 0.5 到 4 之间是文档阅读器的通行做法太小的 PDF 看起来吃力太大则细节丢失严重。delta * 0.25 这步是调速控制每次点击按钮或滚动一格缩放 25%连续缩放时有平滑的视觉反馈。触屏设备上更合理的做法是把这段替换成 pinch 手势的实时 scale 值那就不需要 delta 了。4.2 下载用 blob 导出 PDF 原文件下载功能有个常见误区大家以为需要拿 canvas 截图去生成图片。其实用户要下载的通常是原始 PDF 文件直接 fetch 再转成 blob 触发浏览器下载是最干净的做法文件不会被二次编码体积和清晰度都保持原样。async function downloadPdf(url, filename) { const response await fetch(url); const blob await response.blob(); const link document.createElement(a); link.href URL.createObjectURL(blob); link.download filename || document.pdf; document.body.appendChild(link); link.click(); document.body.removeChild(link); // 延迟释放对象 URL避免下载还没开始就被浏览器回收 setTimeout(() URL.revokeObjectURL(link.href), 2000); }逻辑说明URL.createObjectURL 会在内存里生成一个指向 blob 的临时地址把它赋值给 a 标签的 href 并触发 click就能启动下载。销毁这个地址之前临时文件一直占着内存销毁太早又会打断下载所以用 setTimeout 延迟两秒释放是工程里常用的保险写法。参数说明filename 可以动态生成比如按文档编号加日期拼接。fetch 的 url 如果和页面不同源依然会遇到跨域问题服务端需要正确返回 CORS 响应头。对于几十 MB 的大文件如果服务端已经配了 Content-Disposition: attachment直接用 window.open 或 a 标签访问原始地址就行不需要在 JS 里再造一遍 blob。4.3 打印隐藏 iframe 与加载时序打印功能在 pdf.js 项目里属于典型的“功能两行代码坑在时序上”的需求。最直接的方案是创建一个隐藏 iframe把 PDF 地址填进去再触发打印。难点在于 iframe 必须等 PDF 完全加载完成后再调 print否则打出来的全是空白页。function printPdf(url) { // 创建隐藏 iframe不占任何视觉空间 const iframe document.createElement(iframe); iframe.style.cssText position:fixed;right:0;bottom:0;width:0;height:0;border:0;; iframe.src url; document.body.appendChild(iframe); // 等加载完成再触发打印 iframe.onload () { iframe.contentWindow.focus(); iframe.contentWindow.print(); }; }这份代码可以满足整份文档打印的需求。但如果你要做“只打印当前页”纯前端就变得很别扭因为你不能只裁剪原始 PDF 的一页让 iframe 直接显示。常见做法是后端提供一个单页裁剪接口返回一个临时 PDF 文件地址前端还是用这个 iframe 方法去打印。前后端配合是打印功能落地的稳定路径。参数说明iframe 的 cssText 里 width 和 height 必须显式设 0并加上 position:fixed避免打印窗口或者页面布局被这个隐藏元素影响。onload 事件监听的是 PDF 在 iframe 里的加载完成信号比 setTimeout 猜时间进度要可靠得多。5. 避坑pdf.js 项目里最容易翻车的五个位置5.1 跨域 PDF 加载失败报错信息与排查顺序现象页面打开后一片空白控制台报 “Failed to fetch” 或 “Unexpected server response”。原因绝大多数情况是 PDF 资源地址跨域服务端没配置 CORS 头还有一种可能是 workerSrc 指向了跨域 CDN浏览器拦截了 worker 脚本。解决先单独用 fetch 请求一次 PDF 地址看能不能拿到字节流。如果 fetch 也跨域失败就去服务端为 PDF 所在域名加上 Access-Control-Allow-Origin 响应头。如果 fetch 没问题但 pdf.js 还是加载不了可以把 workerSrc 改成和 PDF 同源的本地静态路径一般能直接解决。我在内网系统里就是这么处理的切换成本低效果立竿见影。5.2 搜索无结果或漏匹配文本层被字体子集化带偏现象用浏览器打开 PDF 能正常复制文字但在 pdf.js 里搜索同样的关键词却搜不到甚至 getTextContent 返回的是乱码。原因PDF 里嵌入了子集化字体也就是只包含实际使用字符的子集这种情况下提取出的 Unicode 码点经常对不上正常文字尤其是一些国产字体或经过压缩工具的 PDF。还有一种经典场景是扫描版 PDF 根本没有真正的文本层文字本来就是图片。解决先用 getTextContent 把 items 里的 str 原文打印出来对照一下是不是乱码。如果是字体子集化问题需要回到 PDF 生成端输出时嵌入完整字符集或关闭字符压缩。如果根本没有文本层那纯前端做不了搜索得在文档上传时走 OCR 生成文本层。这个坑不是靠前端代码能绕开的要在流程设计时提前想清楚。5.3 高亮错位坐标翻转公式的常见失误现象搜索到了关键词高亮矩形却飘在文字的上面、下面或者宽度不对放大后偏得更厉害。原因坐标翻转公式写错的比例很高。最常见的是把 pdfY 直接当 canvasY 用没做 y 轴翻转还有一种隐蔽错误是渲染时用的 scale 和高亮时用的 scale 不是同一个变量两个值各管各的高亮自然对不上。解决统一维护一个全局 scale 状态渲染、重绘、高亮都从同一个变量取值。写代码时可以先用一个只有一行文字的测试 PDF 验证坐标把某一个字符在高亮前后的 x、y、width、height 打印出来人眼对照一下。这个验证做完一次后面基本不会错。5.4 打印空白页iframe 加载时序的两个大坑现象点击打印后弹出了打印预览但预览页面上什么都没有或者只有第一页有内容后面全是空白。原因第一是 iframe 还没加载完就调用了 print浏览器打出来的内容自然为空。第二是用 blob URL 做打印地址时代码里提前 revokeObjectURL 了iframe 加载到一半地址失效渲染失败。解决打印调用移进 iframe.onload 回调里确保 PDF 已就绪。blob URL 的 revoke 操作一定要延迟到打印对话框关闭之后简单做法是监听 iframe 的 afterprint 事件在里面做清理。这个事件在 Chrome 和 Firefox 里都是支持的Safari 下则需要降级用 setTimeout。5.5 渲染模糊Retina 屏下的 devicePixelRatio 适配现象普通桌面显示器上看着正常的文字拿到高分屏笔记本或平板上明显发虚边缘有锯齿。原因canvas 的 width 是渲染像素数而页面显示按 CSS 像素算。高分屏的 devicePixelRatio 大于 1canvas 如果只按 CSS 尺寸画物理像素数不足系统就会把它拉伸放大自然发虚。解决渲染前把 devicePixelRatio 乘进 canvas 的宽高里再通过 style 把 CSS 尺寸设回正常的逻辑尺寸const dpr window.devicePixelRatio || 1; canvas.width viewport.width * dpr; canvas.height viewport.height * dpr; canvas.style.width ${viewport.width}px; canvas.style.height ${viewport.height}px; const actualViewport page.getViewport({ scale: this.scale * dpr });逻辑说明渲染时用的还是同一个 viewport 变换但 canvas 像素数提升到物理分辨率画面就锐利了。这里唯一要注意的是所有依赖 viewport 尺寸做坐标计算的高亮矩形也要按同一个 dpr 缩放否则电影院效果出来后高亮又对不上位置。做一个系统性的验证脚本把缩放和 dpr 的变化统一代入可以避免这类回归。6. 把阅读器收进组件接口设计与性能习惯6.1 组件参数表如果你要把这份插件能力嵌入现有系统最好的方式不是散落一堆函数而是封装成一个组件把配置项全部暴露成可调参数。我习惯用下面这样一组接口参数名类型默认值说明srcstring必填PDF 文件地址defaultScalenumber1.2初始缩放比例enableSearchbooleantrue是否启用搜索框caseSensitivebooleanfalse是否区分大小写wholeWordbooleanfalse是否整词匹配highlightColorstringrgba(255,255,0,0.4)高亮颜色onPageChangefunction-页码切换回调onSearchResultfunction-返回匹配数量参数说明defaultScale 这里设成 1.2 是因为大多数 A4 文档在此比例下的字号都适合阅读。highlightColor 的透明度不要超过 0.4太深会盖住原文浅了又看不出重点。onSearchResult 回调可以用来在页面上显示“共找到 X 处”的提示这是文档管理中一个很细节但用户很看重的体验点。6.2 一个性能习惯渲染耗时测量与文本缓存我每次集成完都会在组件里加一段性能埋点记录每次渲染的耗时观察用户高频操作时的流畅度。下面是马上能用的埋点方式async function renderWithMetric(page, viewport, canvas) { const start performance.now(); const renderTask page.render({ canvasContext: canvas.getContext(2d), viewport, }); await renderTask.promise; const cost performance.now() - start; console.log(第 ${page.pageNumber} 页渲染耗时 ${cost.toFixed(1)}ms); }另外getTextContent 也是解析文本层的高频操作一定要缓存。做法很简单首次搜索某页时把 textContent 存到 Map 里后续再次搜索或翻页后重新高亮时直接取缓存不重复解析。const textCache new Map(); async function getPageTextCached(pageNum) { if (textCache.has(pageNum)) { return textCache.get(pageNum); } const page await pdf.getPage(pageNum); const textContent await page.getTextContent(); textCache.set(pageNum, textContent); return textContent; }这两个习惯看着不起眼实际做完整页面体系时收益非常明显。文档越厚缓存和性能埋点的价值越大。这份代码资源里已经把这些功能串成了完整插件包括搜索框、高亮、翻页按钮、缩放按钮、下载和打印入口。遇到 PDF 相关的业务需求时直接拿这套实现做底座把参数和样式调整成自己的就行。从那次为了解决 Retina 屏模糊问题把三种设备的渲染效果一个个对比过之后我就养成了一个习惯每次改动涉及缩放、高亮或打印逻辑都强制走一遍“加载→搜词→翻页→放大→打印”这五个动作的冒烟测试确保底层依赖没被顺手带歪。希望帮到你。本文还有配套的精品资源点击获取