简介:面向前端开发者与需要在网页中嵌入 PDF 阅读能力的项目团队,这份资源提供了一套基于 pdf.js 的在线文档查看器插件,可直接解决浏览器内预览 PDF、检索关键词并高亮定位的需求。压缩包共 366 个文件,约 5.53MB,主体为 168 个 bcmap 字符映射表与 110 个 properties 本地化资源,用于支撑中日韩等多语言字体的正确渲染;另有 49 个 svg 图标、14 个 pfb 与 4 个 ttf 字体、6 个 js 脚本及 2 个 css 样式,共同构成查看器的界面与交互逻辑,并附带示例 pdf 与许可说明文件。功能上覆盖全文搜索、结果高亮、区分大小写匹配、分页翻页、缩放、下载与打印等常用操作,通过 URL 参数指定文件路径即可快速集成。目前已有 359 人学习下载,适合希望省去从零搭建、直接获取可运行查看器骨架的开发者参考使用。
1. 把 PDF 塞进网页还带搜索高亮:这套 pdf.js 插件到底能省多少事
很多项目做到中后期都会撞上同一个需求:客户丢来一堆 PDF,要求在浏览器里直接看,还要能搜关键词、搜到就高亮、能翻页、能缩放、能下载、能打印。听起来像 PDF 阅读器的标配,真动手才发现坑不少——浏览器原生<iframe>嵌 PDF 只能看不能控,第三方商业 SDK 授权费劝退,自己从零写渲染引擎更不现实。这套基于 pdf.js 的在线文档插件,解决的正是这个夹缝里的问题:它把渲染、文本层、搜索高亮、分页、缩放、下载、打印这几件事打包成一个可直接嵌入的前端组件,用 JavaScript 就能接进现有页面。适合谁?做后台管理系统、知识库、合同/报告预览、教学资料展示的前端和全栈开发者,尤其是那些不想引重型框架、只想拿一份能跑源码改吧改吧就上线的人。下面按「它怎么搭起来 → 搜索高亮怎么实现 → 分页缩放怎么控 → 坑在哪 → 怎么验证」的顺序拆,能抄的部分我都给到代码和参数。
2. pdf.js 的渲染管线:从 PDF 二进制到可搜索的文本层
2.1 为什么不能只画 canvas
pdf.js 的核心思路是把 PDF 拆成两层来渲染:一层是 canvas 画视觉内容,另一层是绝对定位的文本层(text layer)盖在 canvas 上面。只画 canvas 的话,页面看起来没问题,但文字是「死」的——选不中、搜不到、Ctrl+F 无效。文本层的存在就是为了让 DOM 里有真实的文字节点,浏览器原生查找和自定义搜索才有落脚点。这也是这套插件能实现「搜索高亮」的前提:高亮本质上是在文本层里给匹配到的<span>加背景色,而不是在 canvas 上画矩形。理解这一点,后面调搜索精度、调高亮错位,方向就不会跑偏。
常见做法是先加载文档拿到PDFDocumentProxy,再按页拿PDFPageProxy,分别渲染 canvas 和文本层。下面这段是初始化与单页渲染的骨架,参数我标了注释:
import * as pdfjsLib from 'pdfjs-dist'; // worker 必须显式指定,否则主线程解析大文件会卡死 UI pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/pdf.worker.min.js'; async function loadPdf(url) { // cMapUrl 用于中文等 CID 字体的正确显示,缺了会乱码 const loadingTask = pdfjsLib.getDocument({ url, cMapUrl: '/static/cmaps/', cMapPacked: true, // 关闭自动取回,改为手动控制范围,减少首屏流量 disableAutoFetch: true, disableStream: false, }); const pdf = await loadingTask.promise; return pdf; } async function renderPage(pdf, pageNum, canvas, textLayerDiv, scale = 1.5) { const page = await pdf.getPage(pageNum); const viewport = page.getViewport({ scale }); const ctx = canvas.getContext('2d'); // 设备像素比修正,否则高分屏发虚 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`; ctx.scale(dpr, dpr); await page.render({ canvasContext: ctx, viewport }).promise; // 文本层:拿到文字内容与位置,逐条塞进 DOM const textContent = await page.getTextContent(); textLayerDiv.style.width = `${viewport.width}px`; textLayerDiv.style.height = `${viewport.height}px`; textLayerDiv.innerHTML = ''; pdfjsLib.renderTextLayer({ textContentSource: textContent, container: textLayerDiv, viewport, textDivs: [], }); }逻辑说明:getDocument负责把 PDF 解析成可操作的代理对象,getViewport根据缩放比算出画布尺寸,render把页面画到 canvas,renderTextLayer把文字按坐标铺到覆盖层。参数上,scale是缩放基准,1.5 是兼顾清晰度和内存的常用值;cMapUrl指向字体映射文件目录,中文文档不配这个大概率乱码;disableAutoFetch配合手动分页加载,能避免一打开就拉整个大文件。dpr那段是血泪经验,不做设备像素比修正,Retina 屏上文字边缘会糊成一片,客户第一眼就会挑这个毛病。
2.2 分页加载与页面缓存策略
文档页数一多,一次性渲染所有页会直接把内存打爆,浏览器标签页崩溃是常事。合理做法是只渲染当前可视区域附近的页,其余页用占位高度撑住滚动条。常见实现是维护一个pageCache,记录每页的渲染状态,滚动时计算哪些页进入视口再触发渲染。参数上建议「当前页 ±2 页」作为预渲染窗口,再多收益递减。页码切换时不要销毁已渲染的 canvas,缓存起来复用,回翻时直接显示,体验会顺很多。这套插件在分页控件上通常暴露goToPage(n)、nextPage()、prevPage()三个入口,内部就是改当前页码再触发上面这套渲染逻辑,接的时候把页码状态和你的路由或滚动容器对齐即可。
3. 搜索与高亮:区分大小写、命中计数与文本层对齐
3.1 搜索流程拆解
搜索不是对 canvas 做图像识别,而是遍历文本层里的文字节点做字符串匹配。完整流程是:拿到全文文本 → 按关键词切分 → 生成带<mark>的高亮片段 → 回填到文本层 → 滚动到第一个命中位置。区分大小写这个需求,本质是匹配时用不用toLowerCase()归一化。下面是一个可复用的搜索函数:
function searchInTextLayer(textLayerDiv, keyword, caseSensitive = false) { if (!keyword) return { count: 0, first: null }; // 先清掉上一次的高亮,避免叠加 textLayerDiv.querySelectorAll('mark[data-hit]').forEach((m) => { m.replaceWith(document.createTextNode(m.textContent)); }); const spans = textLayerDiv.querySelectorAll('span'); const needle = caseSensitive ? keyword : keyword.toLowerCase(); let count = 0; let first = null; spans.forEach((span) => { const raw = span.textContent; const hay = caseSensitive ? raw : raw.toLowerCase(); const idx = hay.indexOf(needle); if (idx === -1) return; // 只处理单次命中,多次命中需循环切分,这里给最小可用版 const before = raw.slice(0, idx); const hit = raw.slice(idx, idx + keyword.length); const after = raw.slice(idx + keyword.length); const mark = document.createElement('mark'); mark.dataset.hit = '1'; mark.textContent = hit; span.textContent = ''; span.append(before, mark, after); count += 1; if (!first) first = mark; }); return { count, first }; }逻辑说明:先清旧高亮再搜,否则连续搜索会层层嵌套;caseSensitive为false时两边都转小写再比,为true时保留原样。返回值里带命中数和第一个命中节点,方便上层做「第 1/12 个」的计数显示和自动滚动。参数上要注意,keyword如果含正则特殊字符,别直接塞进正则,用indexOf更稳。这个最小版只处理每段首次命中,实际插件里通常用循环切分处理一段多次命中,思路一样,多一层 while 而已。
3.2 高亮错位与跨节点命中的处理
高亮最常见的翻车是「框歪了」——mark 的背景色没盖住文字,或者盖到了隔壁行。原因多半是文本层 span 的定位依赖 pdf.js 生成的transform样式,你手动改 DOM 结构时把内联样式弄丢了。解决办法是切分时保留原 span 的style,或者干脆不切分原节点,改为在文本层之上叠一个绝对定位的高亮层,按命中文字的getBoundingClientRect画矩形。后者更稳,代价是要处理滚动和缩放时的重算。跨节点命中(关键词被拆在两个 span 里)是另一个坑,纯indexOf处理不了,常见做法是先把整页文本拼成一个字符串做匹配,记录偏移量,再映射回具体节点区间。这套插件如果搜索「合同」这类词经常漏,先查是不是跨节点问题,别急着怀疑 pdf.js。
提示:区分大小写开关建议做成 UI 上的显式复选框,默认关闭。用户搜「PDF」和「pdf」预期往往一致,默认开启反而制造困惑。
4. 缩放、下载与打印:交互控件的参数边界
4.1 缩放的三档策略
缩放不是简单改scale重渲染,那样每次拖动滑块都重画,卡到怀疑人生。稳妥做法分三档:小范围缩放(0.8~1.5 倍)用 CSStransform: scale()直接缩放已渲染的 canvas,零重绘、丝滑;超出这个范围再触发按新scale重渲染,保证清晰度;缩放结束后做一次防抖重渲染兜底。参数上,scale下限建议 0.5,再小文字没法看;上限 3.0,再大单页 canvas 内存吃不消。缩放时要同步更新文本层的尺寸和位置,否则高亮和文字会错位,这是最容易忽略的一步。
let currentScale = 1.5; let renderTimer = null; function onZoom(delta) { const next = Math.min(3.0, Math.max(0.5, currentScale + delta)); const ratio = next / currentScale; currentScale = next; // 快速路径:CSS 缩放,立即反馈 document.querySelectorAll('.pdf-page-canvas').forEach((c) => { c.style.transform = `scale(${ratio})`; c.style.transformOrigin = 'top left'; }); // 防抖后按真实 scale 重渲染,保证清晰 clearTimeout(renderTimer); renderTimer = setTimeout(() => rerenderAllPages(currentScale), 200); }逻辑说明:ratio是相对当前比例的增量,用于 CSS 即时缩放;setTimeout200ms 防抖,避免用户连续点按触发多次重渲染。rerenderAllPages内部就是回到 2.1 的renderPage,用新scale重画并重建文本层。参数边界写死在Math.min/max里,别让用户把 scale 拖到 0.1 或 10,前者白屏后者崩页。
4.2 下载与打印的两种实现路径
下载有两种做法:一是直接给<a download>指向原始 PDF 地址,最简单,但跨域或需要鉴权时失效;二是用 pdf.js 拿到的数据重新getData()生成 Blob 再触发下载,兼容性好但大文件占内存。打印同理,window.print()打印的是当前 DOM,canvas 内容打印出来可能是空白,稳妥做法是打开一个隐藏 iframe 加载原始 PDF 再调iframe.contentWindow.print()。参数上,下载文件名从文档元信息getMetadata()里取标题,取不到再回退到固定名。这套插件把下载和打印做成独立按钮,接的时候注意鉴权头要透传,否则后端 401 会让下载按钮「点了没反应」,排查半天以为是前端 bug。
5. 避坑清单:搜索漏词、高亮错位与内存暴涨的排查
5.1 中文搜索搜不到或乱码
现象:英文关键词能搜到,中文搜「合同」返回 0 条,页面文字显示正常但复制出来是方块。原因:cMapUrl没配或路径不对,CID 字体没被正确映射,文本层拿到的字符编码是错的。解决:确认cmaps目录随 pdf.js 一起部署,cMapUrl指向实际可访问路径,cMapPacked: true与文件格式匹配。部署到子路径时尤其容易漏,路径写相对还是绝对要跟构建配置对齐。
5.2 高亮框整体偏移
现象:搜索有结果,但黄色高亮块整体往右下偏了十几像素,缩放后偏得更多。原因:文本层容器没设position: relative,或者 canvas 的dpr缩放和文本层尺寸没同步。解决:文本层外层强制position: relative,内部 span 用 pdf.js 给的transform定位;每次重渲染后重新计算文本层宽高,别复用旧值。缩放走 CSS 快速路径时,文本层要跟着一起transform,不能只缩 canvas。
5.3 翻几页后标签页崩溃
现象:连续翻十几页,内存占用飙到 1G 以上,最后页面无响应。原因:每页都新建 canvas 且不释放,旧页的PDFPageProxy也没清理。解决:维护页面缓存上限(比如最多缓存 10 页),超出按 LRU 淘汰;淘汰时把 canvas 宽高置 0 帮助回收;pdf.destroy()在组件卸载时调用。参数上,单页 canvas 面积别超过 4000×4000,超了先降scale。
5.4 下载按钮点了没反应
现象:控制台无报错,网络面板看不到请求,按钮像没绑事件。原因:下载走的是getData()路径,鉴权 token 没带,请求被拦截但异常被吞了。解决:在getDocument的请求头里统一注入鉴权信息,下载复用同一套请求配置;给下载流程加try/catch并打日志,别让 Promise 静默失败。这类问题排查时先看网络面板有没有请求发出,比读代码快。
5.5 打印出来是空白页
现象:屏幕上正常,点打印预览一片白。原因:window.print()打印的是 DOM,canvas 在某些浏览器打印上下文中不渲染。解决:改用隐藏 iframe 加载原始 PDF 再打印,或者把当前页 canvas 转成图片塞进打印专用样式表。前者更省事,后者适合需要加页眉页脚的场景。
6. 验证与进阶:用一份自检脚本确认插件真的可用
拿到这套源码后,别急着往业务里塞,先跑一遍自检。我一般会准备一份「刁钻」测试 PDF:含中英文混排、含扫描件(无文本层)、含加密、页数 50 页以上。然后按下面这张表逐项过:
| 检查项 | 操作 | 预期 | 失败先看 |
|---|---|---|---|
| 中文渲染 | 打开含中文文档 | 文字清晰无方块 | cMapUrl 路径 |
| 搜索区分大小写 | 搜 PDF / pdf 各一次 | 开关切换结果不同 | 匹配函数归一化逻辑 |
| 高亮对齐 | 搜索后放大到 2 倍 | 高亮仍盖住文字 | 文本层尺寸同步 |
| 分页内存 | 连续翻 30 页 | 内存平稳不崩 | 页面缓存淘汰 |
| 下载 | 点下载按钮 | 文件完整可打开 | 鉴权头透传 |
| 打印 | 点打印 | 预览有内容 | 是否走 iframe |
进阶用法上,可以把搜索命中做成侧边栏列表,点条目直接跳页并滚动到命中位置,靠的就是 3.1 返回的first节点加scrollIntoView。再进一步,把getTextContent的结果缓存成全文索引,支持跨页搜索和「上一个/下一个」循环跳转,体验就接近原生阅读器了。验证时我习惯在控制台手动调一次searchInTextLayer,看返回的count和页面显示是否一致,不一致就是文本层和视觉层没对齐,先修这个再谈其他。
从那以后我每次接 PDF 预览需求,都强制先跑一遍这份自检表再谈排期,因为搜索高亮和内存这两块,不实测根本不知道会在哪个浏览器上翻车。希望帮到你。
本文还有配套的精品资源,点击获取