
简介面向前端开发者与需要在网页中嵌入 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 为什么不能只画 canvaspdf.js 的核心思路是把 PDF 拆成两层来渲染一层是 canvas 画视觉内容另一层是绝对定位的文本层text layer盖在 canvas 上面。只画 canvas 的话页面看起来没问题但文字是「死」的——选不中、搜不到、CtrlF 无效。文本层的存在就是为了让 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把页面画到 canvasrenderTextLayer把文字按坐标铺到覆盖层。参数上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.81.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()打印的是当前 DOMcanvas 内容打印出来可能是空白稳妥做法是打开一个隐藏 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()打印的是 DOMcanvas 在某些浏览器打印上下文中不渲染。解决改用隐藏 iframe 加载原始 PDF 再打印或者把当前页 canvas 转成图片塞进打印专用样式表。前者更省事后者适合需要加页眉页脚的场景。6. 验证与进阶用一份自检脚本确认插件真的可用拿到这套源码后别急着往业务里塞先跑一遍自检。我一般会准备一份「刁钻」测试 PDF含中英文混排、含扫描件无文本层、含加密、页数 50 页以上。然后按下面这张表逐项过检查项操作预期失败先看中文渲染打开含中文文档文字清晰无方块cMapUrl 路径搜索区分大小写搜 PDF / pdf 各一次开关切换结果不同匹配函数归一化逻辑高亮对齐搜索后放大到 2 倍高亮仍盖住文字文本层尺寸同步分页内存连续翻 30 页内存平稳不崩页面缓存淘汰下载点下载按钮文件完整可打开鉴权头透传打印点打印预览有内容是否走 iframe进阶用法上可以把搜索命中做成侧边栏列表点条目直接跳页并滚动到命中位置靠的就是 3.1 返回的first节点加scrollIntoView。再进一步把getTextContent的结果缓存成全文索引支持跨页搜索和「上一个/下一个」循环跳转体验就接近原生阅读器了。验证时我习惯在控制台手动调一次searchInTextLayer看返回的count和页面显示是否一致不一致就是文本层和视觉层没对齐先修这个再谈其他。从那以后我每次接 PDF 预览需求都强制先跑一遍这份自检表再谈排期因为搜索高亮和内存这两块不实测根本不知道会在哪个浏览器上翻车。希望帮到你。本文还有配套的精品资源点击获取