简介:面向需要在网页端嵌入 PDF 阅读能力的开发者,这份资源提供基于 pdf.js 的在线文档查看器插件,可直接集成到项目中实现文档预览与交互操作。插件覆盖在线搜索并高亮命中结果、区分大小写匹配、分页翻页、页面放大缩小、文件下载与打印等常用功能,适合前端工程师、后台管理系统开发者以及需要内嵌文档阅读模块的技术团队参考使用。压缩包共 366 个文件,约 5.53MB,以 bcmap 字符映射表、properties 本地化配置、svg 图标、pfb 与 ttf 字体、js 脚本及 css 样式为主,另含示例 pdf 与许可说明,构成一套可直接运行的完整查看器目录。已有 359 人学习下载。借助现成的目录结构与功能实现,读者可快速搭建本地预览环境,理解搜索高亮、大小写匹配与分页缩放等交互逻辑,并在此基础上按需裁剪或扩展,减少从零搭建 PDF 阅读器的时间成本。
1. 从一份“能搜能标”的 PDF 阅读器说起
很多项目里都会遇到同一个需求:把 PDF 嵌进网页,让用户能像看网页一样搜索关键词、高亮命中、翻页缩放,最好还能下载和打印。听起来简单,真动手才发现浏览器原生<embed>和<iframe>基本是个黑匣子,搜索、高亮、分页这些能力全都没有。这个项目就是围绕 pdf.js 做的一层封装插件,把在线搜索文档、高亮显示、区分大小写、分页、放大缩小、下载、打印这些功能一次性补齐。它适合两类人:一类是手里有现成 PDF 文档站、需要快速加阅读器的开发者;另一类是想基于 pdf.js 做二次开发、但不想从零啃渲染层的人。下面按“能干什么、怎么跑、怎么改”的顺序拆开讲。
2. 技术选型与整体架构:pdf.js 之上的一层薄封装
2.1 为什么选 pdf.js 而不是浏览器原生预览
浏览器自带的 PDF 预览器(Chrome 的 PDF Viewer、Firefox 的 pdf.js 内置版)能力其实不弱,但它是“页面级”的,你没法从外部拿到文本层、没法控制高亮、没法把搜索框接到自己的 UI 上。常见做法是直接用 pdf.js 的getDocument拿到 PDF 文档对象,再自己渲染 canvas 和文本层。这个项目的思路也是这样:底层用 pdf.js 负责解析和渲染,上层用一层 JavaScript 封装出搜索、高亮、分页、缩放这些交互。选 pdf.js 的另一个原因是它纯前端、无服务端依赖,PDF 文件放静态目录就能跑,部署成本低。
需要说明的是,pdf.js 的文本层(text layer)是搜索和高亮的基础。它会把每一页的文字按坐标铺成透明的<span>,你看到的“选中文字”其实是这些 span。搜索的本质就是遍历这些 span 的文本内容做匹配,高亮则是给命中的 span 加背景色。理解这一点,后面看代码就不会迷路。
2.2 目录结构与模块职责
这类插件的目录一般不会太深,典型结构如下(不同版本可能略有差异,以实际源码为准):
pdf-viewer-plugin/ ├── index.html # 演示页,挂载阅读器 ├── css/ │ └── viewer.css # 工具栏、高亮、分页样式 ├── js/ │ ├── main.js # 入口:初始化、事件绑定 │ ├── pdf-loader.js # 封装 getDocument、加载进度 │ ├── renderer.js # canvas + 文本层渲染 │ ├── search.js # 搜索、大小写、高亮 │ ├── toolbar.js # 分页、缩放、下载、打印 │ └── utils.js # 防抖、坐标换算等 ├── lib/ │ └── pdf.min.js # pdf.js 构建产物 └── assets/ └── sample.pdf # 示例文档pdf-loader.js只干一件事:把 URL 或 ArrayBuffer 变成 pdf.js 的文档对象,并暴露总页数。renderer.js负责把某一页画到 canvas 上,同时生成文本层。search.js是功能核心,处理关键词匹配和高亮。toolbar.js把按钮事件翻译成对 renderer 的调用。这种拆分的好处是搜索逻辑和渲染逻辑解耦,改高亮样式不用动渲染代码。
2.3 一次加载的完整链路
从用户打开页面到看到第一页,链路大致是:main.js读取配置(PDF 地址、初始页码、是否显示工具栏)→ 调pdf-loader.js的loadPdf→getDocument返回pdfDoc→ 取getPage(1)→renderer用page.getViewport({ scale })算尺寸 → 画 canvas →page.getTextContent()拿文本项 → 生成文本层 span。这条链路里最容易出问题的是缩放后的坐标换算,后面避坑章节会专门讲。
提示:pdf.js 的
getDocument是异步的,且大文件加载慢,务必在 UI 上给加载进度,否则用户会以为页面卡死。
3. 核心功能拆解:搜索、高亮与分页的实现细节
3.1 搜索与区分大小写:从文本层里捞关键词
搜索的实现分两步:先把当前页(或全部页)的文本项收集起来,再做匹配。区分大小写就是一个开关,决定匹配前是否toLowerCase()。下面是一段贴近项目实际的搜索核心代码:
// search.js async function searchInPage(page, keyword, caseSensitive) { const textContent = await page.getTextContent(); const needle = caseSensitive ? keyword : keyword.toLowerCase(); const hits = []; textContent.items.forEach((item, index) => { const haystack = caseSensitive ? item.str : item.str.toLowerCase(); let pos = haystack.indexOf(needle); // 一个文本项里可能命中多次,全部记录下来 while (pos !== -1) { hits.push({ itemIndex: index, start: pos, length: keyword.length }); pos = haystack.indexOf(needle, pos + needle.length); } }); return hits; }这段代码的关键参数:page是 pdf.js 的页面对象,keyword是用户输入,caseSensitive控制是否忽略大小写。item.str是文本项内容,itemIndex用来回定位到文本层的 span。注意这里只做了单页搜索,实际项目里通常再加一层循环遍历所有页,并把结果按页分组,方便“上一处/下一处”跳转。跨文本项的匹配(关键词被拆在两个 span 里)是常见难点,简单做法是先按页拼接文本再匹配,但高亮定位会变复杂,多数插件选择不处理这种边界情况。
3.2 高亮显示:给命中的 span 加类名
拿到命中位置后,高亮不是直接改文本,而是给对应的文本层 span 加一个 CSS 类。因为文本层 span 是 pdf.js 生成的,顺序和textContent.items基本一一对应,所以可以用itemIndex定位:
// search.js function highlightHits(container, hits) { // 先清掉上一次的高亮,避免叠加 container.querySelectorAll('.pdf-hit').forEach(el => { el.classList.remove('pdf-hit'); }); hits.forEach(hit => { const span = container.querySelectorAll('.textLayer span')[hit.itemIndex]; if (span) span.classList.add('pdf-hit'); }); }配套 CSS 很简单:.pdf-hit { background: rgba(255, 235, 59, 0.6); }。这里有个细节:如果同一个 span 里命中多次,上面的写法只能整体高亮,做不到“只标关键词那几个字”。要做到字符级高亮,得把 span 拆成三段(前、命中、后)再分别包元素,代码量会翻倍。项目里如果只要求“整段标黄”,当前写法够用;要求精确到字,就得走拆分方案。
3.3 分页与缩放:viewport 是唯一真相
分页本质是切换当前页码后重新渲染。缩放则是改scale后重算 viewport。核心代码:
// renderer.js async function renderPage(pdfDoc, pageNum, scale, canvas, textLayerDiv) { const page = await pdfDoc.getPage(pageNum); const viewport = page.getViewport({ scale }); const ctx = canvas.getContext('2d'); canvas.width = viewport.width; canvas.height = viewport.height; await page.render({ canvasContext: ctx, viewport }).promise; // 文本层按同样 viewport 生成,保证坐标对齐 const textContent = await page.getTextContent(); textLayerDiv.style.width = `${viewport.width}px`; textLayerDiv.style.height = `${viewport.height}px`; // 这里通常调用 pdf.js 的 renderTextLayer 或手动铺 span }参数说明:scale是缩放倍数,1 表示原始尺寸;viewport决定了 canvas 像素尺寸和文本层容器尺寸,两者必须用同一个 viewport,否则文字和画面会错位。分页时只需把pageNum换成目标页再调一次renderPage。缩放时建议加防抖,连续点“放大”不要每次都重渲染,等用户停手再画。
3.4 下载与打印:别自己造轮子
下载最省事的做法是直接用原始 PDF 的 URL 触发<a download>,不需要经过 pdf.js。打印则有两种思路:一是调window.print()配合打印样式,二是用 pdf.js 把页面渲染成图片再拼打印页。前者简单但依赖浏览器打印对话框,后者可控但代码多。项目里如果只是“能打印”,window.print()加一段@media print隐藏工具栏就够了:
@media print { .toolbar, .search-bar { display: none !important; } canvas { page-break-after: always; } }注意:打印时 canvas 是位图,分辨率不够会糊。需要高清打印的话,打印前用更高
scale重渲染一遍再调window.print()。
4. 跑起来与改造前必看的避坑清单
4.1 本地起服务,别直接双击 HTML
pdf.js 加载 PDF 走的是 fetch/XHR,file://协议下大概率被浏览器拦截,表现为控制台报跨域或加载失败。正确姿势是在项目根目录起一个静态服务:
# 任选一种,Python 自带的最省事 python3 -m http.server 8080 # 或者 npx serve .然后访问http://localhost:8080。如果 PDF 放在别的域名,记得服务端配 CORS,否则getDocument会失败。
4.2 文本层错位:viewport 不一致是元凶
现象:放大后文字选中区域和画面对不上,或者高亮块飘到别处。原因几乎都是 canvas 用的 viewport 和文本层容器用的尺寸不是同一个 scale 算出来的。解决方法是把 viewport 算一次、存下来,canvas 和文本层都从它取值,不要各自调getViewport。另外文本层 span 的定位依赖 pdf.js 的renderTextLayer,手动铺 span 时 transform 矩阵不能省。
4.3 搜索卡顿:全量遍历要加节流
现象:文档几百页时,输入关键词页面直接卡住。原因是每次输入都遍历所有页的getTextContent。解决:输入加 300ms 防抖;文本内容按页缓存,第二次搜索直接读缓存;只搜当前页或可视范围,提供“全文档搜索”按钮让用户主动触发。
4.4 高亮残留:切换页面没清理
现象:翻到下一页,上一页的高亮类名还在,或者同一位置叠了好几层。原因是高亮加在 span 上,重渲染后旧 span 被替换但类名逻辑没重置。解决:每次renderPage前清空文本层容器,或者在高亮函数入口统一querySelectorAll('.pdf-hit')移除。这个坑很隐蔽,因为 DOM 被替换后视觉上可能看不出来,直到你做“上一处/下一处”跳转才发现索引错乱。
4.5 打印空白页:canvas 没等渲染完
现象:点打印,预览里是空白或只有第一页。原因是window.print()在 canvas 渲染完成前就触发了。解决:打印前await所有页渲染完成,或者监听渲染 promise 再调打印。另一个常见原因是打印样式把 canvas 设成了display: none,检查@media print里有没有误伤。
5. 二次开发:把插件改成你自己的阅读器
5.1 先定改造边界,再动代码
拿到这份源码,别急着改 UI。先想清楚你要的是“换皮”还是“换骨”。换皮只动viewer.css和toolbar.js的按钮布局;换骨比如要支持批注、书签、双页模式,就得动renderer.js和search.js的数据结构。我的习惯是先跑通示例 PDF,确认搜索、缩放、打印都正常,再建一个分支做改造,避免一上来就改得跑不起来。
5.2 三个高频改造点与验证方法
第一个是搜索范围。默认单页搜索改成全文档搜索,改动点在search.js外层加页循环,验证方法是拿一份 50 页以上、关键词分布在不同页的 PDF,看“下一处”能否跨页跳转。第二个是高亮精度。整段标黄改成字符级,需要拆 span,验证时重点看同一段里出现两次关键词是否都能单独标出。第三个是移动端适配。工具栏在窄屏会挤,常见做法是折叠成图标菜单,验证用浏览器设备模拟即可。
| 改造点 | 改动文件 | 验证要点 |
|---|---|---|
| 全文档搜索 | search.js | 跨页跳转、结果计数准确 |
| 字符级高亮 | search.js + viewer.css | 同段多次命中分别高亮 |
| 移动端工具栏 | toolbar.js + viewer.css | 375px 宽度下按钮不溢出 |
5.3 一个容易忽略的细节:页码与滚动同步
如果要做“滚动加载所有页”而不是“单页翻页”,页码和滚动位置的同步是个技术活。常见做法是监听滚动,用每页容器的offsetTop判断当前可视页,再更新工具栏页码。这里别用固定高度估算,PDF 每页高度可能不同,必须读实际 DOM 高度。我在这上面翻过车:早期用平均高度算,结果长页文档页码全乱,后来改成遍历页容器取getBoundingClientRect才稳。
5.4 收尾的一点经验
改造这类基于 pdf.js 的插件,最省时间的路径永远是“先让官方示例跑通,再一处一处替换”。我现在的习惯是每改一个功能就先跑一遍示例 PDF,确认搜索、缩放、打印三条主链路没断,再继续下一个。血泪经验是:文本层和高亮相关的改动最容易引入玄学错位,改完一定用放大到 200% 的状态检查一遍对齐。希望这份拆解能帮你少走几步弯路,把这份插件真正用进自己的项目里。
本文还有配套的精品资源,点击获取