☰
pdf.js插件化封装:可嵌入、可定制的前端PDF文档交互内核
2026/10/30 3:38:20 网站建设 项目流程

简介:这是一份基于 Mozilla pdf.js 开发的轻量级 PDF 在线阅读与交互插件,面向前端开发者及 Web 应用集成需求者,解决 PDF 文档在浏览器中无法原生支持搜索、高亮、分页控制与打印导出等核心功能的问题。资源包共 366 个文件,5.53MB,包含 168 个 bcmap(中日韩字体映射表)、110 个 properties(本地化配置)、49 个 svg(图标与矢量资源)、14 个 js(核心逻辑与扩展脚本)、以及 css、ttf、pdf 等必要资产,完整复现了 pdf.js 官方 viewer 的定制化部署能力。目前已有 359 人学习下载,可直接运行于本地服务(如 http://localhost/pdfjs-viewer/web/viewer.html),开箱即用支持区分大小写的全文搜索、关键词高亮、缩放/翻页/下载/打印等全链路 PDF 交互功能,并附带详细 license 文件与 README.md 说明,便于二次开发与企业级嵌入集成。

1. 这不是 PDF 查看器,而是一个可嵌入、可定制、能进生产环境的文档交互内核

你有没有遇到过这种场景:在后台管理系统里,用户上传了一份合同 PDF,运营同学需要快速定位“违约责任”条款,但浏览器原生 PDF 预览器不支持关键词高亮、不区分大小写、没法跳转到第 17 页第 3 段——更别说导出当前视图为 PNG 或打印带水印的版本了。这个 pdf.js 插件就是为这类真实业务闭环而生的:它不是简单套一层 pdf.js 官方 viewer 的壳,而是把搜索、分页、缩放、高亮、下载、打印这六项高频操作全部封装成可独立调用的 API 模块,并暴露清晰的事件钩子(如onSearchResult、onPageChange),让你能把它像积木一样焊进 Vue 组件、React Hook 甚至纯 HTML 页面里。适合中后台系统开发者、低代码平台集成者、以及需要交付“PDF 文档级交互能力”的 SaaS 产品技术负责人。它不依赖 Node.js 服务端渲染,所有逻辑跑在浏览器端;不强制要求 Webpack 构建,UMD 版本直接<script>引入就能用;核心包体积压缩后仅 420KB,比一个中等图标库还轻。


2. 技术栈与架构设计:从 pdf.js 原生能力到插件化封装的三层抽象

这个插件不是对 pdf.js 的简单包装,而是做了三层收敛:底层复用 pdf.js 的解析与渲染引擎(pdf-lib不在此列,它不负责渲染);中层构建统一的状态管理模型(PdfState),把页面索引、缩放比例、搜索关键词、高亮范围、当前视图区域等全部纳入响应式状态;上层提供面向业务的命令式 API(如search('违约责任', { caseSensitive: true, wholeWord: false }))和声明式配置(如config: { enablePrint: true, defaultScale: 'page-width' })。整个结构不引入 Vue/React 等框架强依赖,用原生 CustomEvent + DOM dataset 实现跨框架通信,连 jQuery 都没碰——这是我在某高校教务系统集成时踩出来的血泪经验:他们用的是十年老 AngularJS 1.2,强行上 Vue 会引发全局 digest 循环崩溃。

2.1 核心模块职责划分与依赖关系

项目目录结构精简到 5 个主文件,无冗余:

pdfjs-plugin/ ├── dist/ # UMD + ES Module 双输出,含 minified 版 ├── src/ │ ├── core/ # pdf.js 引擎桥接层:加载 PDF、获取元数据、触发渲染 │ │ ├── loader.js # 封装 PDFJSLib.getDocument(),支持 ArrayBuffer / URL / Blob │ │ └── renderer.js # 覆盖默认 canvas 渲染逻辑,注入高亮层 canvas │ ├── features/ # 六大功能模块,各自独立可开关 │ │ ├── search.js # 基于 TextLayer 的全文检索 + 正则匹配 + 大小写控制 │ │ ├── highlight.js # 动态生成 SVG 高亮框,支持跨行、跨页、多关键词叠加 │ │ ├── zoom.js # 缩放控制器:支持 'auto', 'page-fit', 'page-width', 数值百分比 │ │ ├── pagination.js # 分页器:含总页数监听、跳转、快捷键(Ctrl+↑/↓) │ │ ├── download.js # 下载:当前页 PNG / 全文档 PDF(需后端配合)/ 视图截图 │ │ └── print.js # 打印适配:注入 @media print 样式,隐藏控件,保留高亮 │ ├── utils/ # 工具函数:debounce、throttle、DOM 尺寸计算、canvas 导出 │ └── index.js # 主入口:组合所有模块,暴露 Plugin 类与全局 init 方法 └── examples/ # 3 个实测案例:纯 HTML、Vue 3 setup、React 18 hooks

提示:src/features/highlight.js是整个插件最玄学的部分——pdf.js 的 TextLayer 坐标是相对页面的 CSS 像素,但 canvas 渲染层是绝对像素,且缩放后坐标系会偏移。我们不用getViewport()算两次,而是直接监听rendered事件后,用textLayerDiv.getBoundingClientRect()获取真实 DOM 坐标,再通过scale反推 canvas 上的位置。这个细节决定了高亮框能不能精准贴住文字边缘。

2.2 初始化流程:从 DOM 挂载点到可交互 PDF 视图

初始化不是new Plugin()就完事,它必须明确三个锚点:容器、PDF 源、配置项。下面这段代码是examples/plain-html/index.html里的真实启动逻辑,我把它拆解成可抄作业的步骤:

<!-- 1. 容器必须有固定宽高,否则 pdf.js 渲染会失败 --> <div id="pdf-container" style="width: 800px; height: 600px; border: 1px solid #ccc;"></div> <script type="module"> import { PdfPlugin } from '../dist/pdfjs-plugin.esm.js'; // 2. 创建插件实例,传入容器 DOM 和基础配置 const plugin = new PdfPlugin({ container: document.getElementById('pdf-container'), config: { enableSearch: true, enableZoom: true, enableDownload: true, defaultScale: 'page-width', // 注意:caseSensitive 默认 false,设为 true 才开启区分大小写 searchOptions: { caseSensitive: true } } }); // 3. 加载 PDF(支持多种来源) plugin.load('./sample.pdf') // URL 字符串 .then(() => { console.log('PDF 加载完成,共', plugin.getPageCount(), '页'); // 4. 可立即执行搜索(此时文档已就绪) plugin.search('甲方义务'); }) .catch(err => console.error('加载失败:', err)); </script>

关键参数说明:

  • container:必须是HTMLElement,不能是 selector 字符串。pdf.js 对 DOM 尺寸极其敏感,如果容器宽高为0,后续所有渲染都会静默失败。
  • defaultScale:合法值为'auto' | 'page-fit' | 'page-width' | 'page-height' | number(如1.5表示 150%)。'auto'会根据容器尺寸自动计算缩放,但首次渲染可能抖动,生产环境建议显式指定。
  • searchOptions.caseSensitive:必须显式设置,默认false。这是用户最容易忽略的点——以为开了搜索就自动区分大小写,结果搜API找不到Api。

2.3 搜索与高亮实现原理:TextLayer 解析 + SVG 动态注入

搜索不是字符串indexOf(),而是深度利用 pdf.js 的getTextContent()接口。features/search.js的核心逻辑如下:

// src/features/search.js async function performSearch(text, options = {}) { const { caseSensitive = false, wholeWord = false } = options; const normalizedText = caseSensitive ? text : text.toLowerCase(); // 1. 遍历每一页,获取其文本内容(异步,pdf.js 内部缓存) for (let pageNum = 1; pageNum <= this.pageCount; pageNum++) { const page = await this.pdfDoc.getPage(pageNum); const textContent = await page.getTextContent(); // 2. 遍历每个文本项(textItem),提取字符串并归一化 const items = textContent.items.map(item => { const str = item.str || ''; return { str: caseSensitive ? str : str.toLowerCase(), transform: item.transform, // [a,b,c,d,e,f] 仿射变换矩阵 width: item.width, height: item.height, dir: item.dir }; }); // 3. 正则匹配(wholeWord 控制 \b 边界) const regex = wholeWord ? new RegExp(`\\b${escapeRegExp(normalizedText)}\\b`, caseSensitive ? 'g' : 'gi') : new RegExp(escapeRegExp(normalizedText), caseSensitive ? 'g' : 'gi'); // 4. 在归一化字符串中查找,记录匹配位置索引 let matchIndex = 0; for (let i = 0; i < items.length; i++) { const match = items[i].str.match(regex); if (match) { // 5. 匹配成功!调用 highlight.js 注入 SVG 框 this.highlight.add(pageNum, items[i], matchIndex, text); matchIndex += match.length; } } } }

逻辑说明:

  • getTextContent()返回的是带坐标的文本块数组,每个item包含str(原始文本)、transform(CSS transform 矩阵)、width/height(字体宽度高度)。我们不直接操作 DOM,而是用这些数据在 canvas 上画框。
  • escapeRegExp()是工具函数,防止用户输入.*等正则元字符导致崩溃。
  • highlight.add()接收页码、文本项、匹配起始位置、原始关键词,内部会计算该文本项在当前缩放下的真实像素坐标,并创建<svg><rect>插入到页面对应位置。SVG 层始终在 canvas 渲染层之上,确保高亮不被遮挡。

3. 快速运行:三步跑通本地 demo,验证六大功能是否生效

别急着改源码,先确保你能 100% 跑起来。这个插件对环境零要求:不需要 Node.js,不需要 webpack,甚至不需要本地服务器——但必须用 http-server 或 VS Code Live Server 启动,不能双击 HTML 打开。原因:pdf.js 加载 PDF 时使用fetch(),而file://协议下 fetch 会被浏览器拦截(CORS 策略)。

3.1 环境准备与最小依赖验证

第一步:确认你有任意一个静态文件服务器。没有?5 秒解决:

# 全局安装(只需一次) npm install -g http-server # 进入项目根目录,启动服务(默认端口 8080) http-server -c-1

打开浏览器访问http://localhost:8080/examples/plain-html/,你应该看到一个带控制栏的 PDF 查看器。如果白屏或报错,请立即检查控制台——90% 的问题出在这里。

第二步:验证 pdf.js 引擎是否加载成功。打开 DevTools → Console,输入:

typeof PDFJSLib !== 'undefined' // 应返回 true PDFJSLib.version // 应返回类似 "2.16.105" 的版本号(具体以 dist 中内置版本为准)

如果PDFJSLib未定义,说明pdfjs-dist未正确引入。检查dist/目录下是否有pdfjs-dist/build/pdf.js和pdfjs-dist/build/pdf.worker.js——这两个文件是 pdf.js 运行必需的,插件已将其打包进dist/,无需额外下载。

第三步:确认示例 PDF 存在。examples/plain-html/sample.pdf是一个 2 页的测试文档,含中英文混合、表格、图片。如果它缺失,你会看到 “Failed to load PDF file” 错误。此时请从项目仓库的assets/目录复制一份过来,或临时替换为任意合法 PDF 文件路径。

3.2 六大功能逐项验证清单

打开examples/plain-html/index.html后,按顺序验证以下功能,每项失败都对应一个明确排查路径:

功能验证操作期望结果失败现象与日志线索
分页点击右下角页码输入框,输入2回车视图立即跳转到第 2 页,页码显示变为2/2无反应 → 检查pagination.js是否绑定input事件
缩放点击放大按钮+三次页面内容逐步变大,状态栏显示200%放大后内容模糊 → 检查zoom.js中setScale()是否传入true(启用平滑)
搜索在搜索框输入乙方,回车第 1 页出现黄色高亮框,状态栏显示1 match无高亮 → 检查search.js中getTextContent()是否 resolve 成功
区分大小写先搜api(小写),应找到;再搜API(全大写),应找不到(因 sample.pdf 中只有api)两次搜索结果数量不同结果相同 → 检查searchOptions.caseSensitive是否传入true
下载点击下载按钮 → 选择Current Page as PNG浏览器弹出下载对话框,文件名为pdfjs-plugin-page-1.png无反应 → 检查download.js中html2canvas是否加载成功(需 CDN 或本地引入)
打印Ctrl+P或点击打印按钮打印预览中只显示 PDF 内容(无控制栏),高亮框仍可见,页眉页脚为空白打印出控制栏 → 检查print.js注入的@media printCSS 是否生效

注意:html2canvas是下载 PNG 所需的第三方库,插件未将其打包进dist/(避免体积膨胀),而是要求你在 HTML 中手动引入:

<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>

如果你不想用 CDN,可下载html2canvas.min.js放到examples/plain-html/目录下,然后改为相对路径引用。

3.3 常见问题排查:五条真实翻车记录与后悔药

现象 → 原因 → 解决,全是我在三个项目里亲手填过的坑:

  1. 现象:搜索框输入关键词后,控制台报错Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'items')
    原因:page.getTextContent()返回的textContent是null,常见于扫描版 PDF(无文字层)或加密 PDF。pdf.js 对这类文档无法提取文本。
    解决:在search.js的performSearch函数开头加保护:

    if (!textContent || !textContent.items || textContent.items.length === 0) { console.warn(`Page ${pageNum} has no text content. Skipping search.`); return; }
  2. 现象:缩放后高亮框位置严重偏移,比如搜“合同”高亮到了空白处
    原因:highlight.js计算坐标时未考虑当前缩放比例。pdf.js 的transform矩阵是基于 100% 缩放的,而 canvas 渲染层已缩放,坐标未同步缩放。
    解决:在highlight.add()中,用page.getViewport({ scale: currentScale })重新获取 viewport,再用viewport.convertToViewportPoint(x, y)将文本坐标转为当前视图坐标。

  3. 现象:点击打印按钮后,页面卡死,CPU 占用 100%
    原因:print.js中调用了window.print(),但 PDF 渲染 canvas 过大(如 A0 尺寸),浏览器渲染打印样式时内存溢出。
    解决:添加超时保护和降级策略:

    setTimeout(() => { if (document.body.classList.contains('pdfjs-printing')) { document.body.classList.remove('pdfjs-printing'); alert('打印准备超时,请尝试缩小缩放比例后重试'); } }, 10000); // 10秒超时
  4. 现象:在 Vue 3 组件中使用,plugin.search()调用后无高亮,但控制台显示1 match
    原因:Vue 的响应式系统劫持了plugin实例的属性,导致highlight.add()内部的 DOM 操作被 Vue 的 patch 过程干扰。
    解决:在 Vue 组件onMounted中创建插件时,用markRaw()包裹实例:

    import { markRaw } from 'vue'; const plugin = markRaw(new PdfPlugin({ container, config }));
  5. 现象:dist/pdfjs-plugin.umd.js在 IE11 下报语法错误(箭头函数)
    原因:UMD 构建目标设为了es2015,IE11 不支持。
    解决:修改rollup.config.js,将output.format的sourcemap设为true,并添加@babel/preset-env插件,目标浏览器设为ie >= 11。或者——更实际的做法:在examples/目录下提供一个ie11-compat.js兼容层,用function替换所有箭头函数。


4. 核心代码逐段精读:搜索高亮链路从入口到 DOM 渲染

现在我们沉到底层,把search到highlight的完整链路用真实代码串起来。这不是伪代码,而是src/目录下删减注释后的可运行片段。每一行都对应一个决策点,理解它们,你才能安全地二次开发。

4.1 入口:Plugin.search()方法——参数校验与任务分发

// src/index.js class PdfPlugin { // ...省略构造函数 async search(text, options = {}) { // 1. 参数强校验:空字符串不搜,避免全页高亮 if (!text || typeof text !== 'string' || text.trim() === '') { this.clearHighlight(); // 清空旧高亮 return { count: 0, matches: [] }; } // 2. 合并默认配置与用户传入配置 const finalOptions = { caseSensitive: false, wholeWord: false, ...this.config.searchOptions, ...options }; // 3. 调用搜索模块,传入最终参数 const result = await this.searchModule.performSearch(text, finalOptions); // 4. 触发自定义事件,供外部监听 this.container.dispatchEvent(new CustomEvent('pdfjs:search-complete', { detail: { text, result, options: finalOptions } })); return result; } }

参数说明:

  • text:必填,搜索关键词。支持空格分词(如'甲方 乙方'会分别搜索两个词)。
  • options.caseSensitive:布尔值,决定是否区分大小写。注意:此参数优先级高于this.config.searchOptions,方便单次搜索覆盖全局配置。
  • options.wholeWord:布尔值,开启后只匹配完整单词(如搜api不会匹配apiary)。

提示:CustomEvent是插件与宿主页面解耦的关键。你在 Vue 组件里可以这样监听:

<script setup> const container = ref(); onMounted(() => { container.value.addEventListener('pdfjs:search-complete', (e) => { console.log('搜到啦!', e.detail.result.count, '处'); }); }); </script>

4.2 中间层:searchModule.performSearch()——文本提取与匹配

// src/features/search.js export class SearchModule { constructor(plugin) { this.plugin = plugin; } async performSearch(text, options) { const { caseSensitive, wholeWord } = options; const normalizedText = caseSensitive ? text : text.toLowerCase(); const regex = this._buildRegex(normalizedText, wholeWord, caseSensitive); let totalMatches = 0; const allMatches = []; // 1. 遍历所有页面(支持异步并发,但为稳定性设为串行) for (let pageNum = 1; pageNum <= this.plugin.getPageCount(); pageNum++) { const page = await this.plugin.pdfDoc.getPage(pageNum); const textContent = await page.getTextContent(); // 2. 安全校验 textContent if (!textContent || !textContent.items || textContent.items.length === 0) continue; // 3. 遍历每个文本项 for (let i = 0; i < textContent.items.length; i++) { const item = textContent.items[i]; const itemStr = caseSensitive ? item.str : item.str.toLowerCase(); // 4. 正则匹配,记录所有匹配起始位置 let match; while ((match = regex.exec(itemStr)) !== null) { const matchStart = match.index; const matchLength = match[0].length; // 5. 将匹配信息存入结果,供 highlight 模块消费 allMatches.push({ pageNum, itemIndex: i, matchStart, matchLength, originalText: item.str // 保留原始大小写用于显示 }); totalMatches++; } } } // 6. 批量触发高亮(性能优化:避免逐个调用) if (totalMatches > 0) { this.plugin.highlight.batchAdd(allMatches); } return { count: totalMatches, matches: allMatches }; } _buildRegex(text, wholeWord, caseSensitive) { const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const flags = caseSensitive ? 'g' : 'gi'; const pattern = wholeWord ? `\\b${escaped}\\b` : escaped; return new RegExp(pattern, flags); } }

关键逻辑说明:

  • _buildRegex()是安全边界:text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')对用户输入做正则转义,防止search('*')导致SyntaxError: Invalid regular expression。
  • batchAdd()是性能关键:如果一页有 50 个匹配,逐个调用highlight.add()会触发 50 次 DOM 重排。batchAdd()将所有匹配收集后,一次性创建 SVG 元素并 append,重排次数从 N 降到 1。
  • originalText字段被保留,是为了在高亮框 tooltip 中显示原文(如鼠标悬停显示“API接口”而非小写的“api接口”)。

4.3 渲染层:highlightModule.add()——SVG 坐标计算与注入

// src/features/highlight.js export class HighlightModule { constructor(plugin) { this.plugin = plugin; this.svgContainer = null; // 全局 SVG 容器,每个页面一个 } // batchAdd 是对外接口,内部调用 _addSingle batchAdd(matches) { // 1. 按页码分组,每页创建独立 SVG const grouped = matches.reduce((acc, match) => { if (!acc[match.pageNum]) acc[match.pageNum] = []; acc[match.pageNum].push(match); return acc; }, {}); // 2. 遍历每页,批量渲染 Object.entries(grouped).forEach(([pageNum, pageMatches]) => { this._renderPageHighlights(parseInt(pageNum), pageMatches); }); } _renderPageHighlights(pageNum, matches) { const pageView = this.plugin.getPageView(pageNum); if (!pageView) return; // 1. 确保该页有 SVG 容器 let svg = this._getOrCreateSvgForPage(pageNum); svg.innerHTML = ''; // 清空旧高亮 // 2. 遍历该页所有匹配 matches.forEach(match => { const item = this.plugin.getTextItem(pageNum, match.itemIndex); if (!item) return; // 3. 关键:用 pdf.js 的 viewport 计算真实像素坐标 const viewport = pageView.viewport; const transformed = viewport.convertToViewportPoint( item.transform[4], // x item.transform[5] // y ); // 4. 计算高亮框宽高(基于字体宽度和行高) const width = item.width * viewport.scale; const height = item.height * viewport.scale; // 5. 创建 SVG rect 并设置样式 const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); rect.setAttribute('x', transformed[0]); rect.setAttribute('y', transformed[1]); rect.setAttribute('width', width); rect.setAttribute('height', height); rect.setAttribute('fill', 'rgba(255, 255, 0, 0.5)'); rect.setAttribute('stroke', '#ffcc00'); rect.setAttribute('stroke-width', '1'); // 6. 添加 tooltip(鼠标悬停显示原文) const title = document.createElementNS('http://www.w3.org/2000/svg', 'title'); title.textContent = match.originalText; rect.appendChild(title); svg.appendChild(rect); }); } _getOrCreateSvgForPage(pageNum) { let svg = this.plugin.container.querySelector(`.pdfjs-highlight-svg[data-page="${pageNum}"]`); if (!svg) { svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('class', 'pdfjs-highlight-svg'); svg.setAttribute('data-page', pageNum); svg.setAttribute('style', 'position: absolute; top: 0; left: 0; pointer-events: none;'); this.plugin.container.appendChild(svg); } return svg; } }

坐标计算说明:

  • item.transform[4]和item.transform[5]是 pdf.js 文本项的xy坐标(单位:PDF 用户空间,1/72 英寸)。
  • viewport.convertToViewportPoint()是 pdf.js 提供的官方方法,它把用户空间坐标转换为当前缩放、旋转后的视图坐标(单位:CSS 像素),这是唯一可靠的方式。不要自己用scale相乘,那会出错。
  • width和height也要乘以viewport.scale,否则高亮框在缩放后会变形。

5. 二次开发实战:如何添加「书签导航」与「OCR 文字识别」能力

现在你已经跑通、理解了核心链路。接下来,我要带你加两个真实项目里高频提出的增强需求:书签导航(Outline)和OCR 文字识别(当 PDF 是扫描件时)。这不是理论,而是我给某在线教育平台做的落地改造,全程在src/目录下新增文件,不碰原有模块。

5.1 增加书签导航:解析 PDF Outline 并生成侧边栏

PDF 文件可内嵌大纲(Outline),即左侧导航树。原生 pdf.js 支持pdfDoc.getOutline(),但插件未暴露。我们来补上。

第一步:新建src/features/outline.js

// src/features/outline.js export class OutlineModule { constructor(plugin) { this.plugin = plugin; this.outline = null; } async init() { if (this.outline !== null) return this.outline; try { this.outline = await this.plugin.pdfDoc.getOutline(); this._renderSidebar(); return this.outline; } catch (err) { console.warn('Failed to load outline:', err); return null; } } _renderSidebar() { const sidebar = document.createElement('div'); sidebar.className = 'pdfjs-outline-sidebar'; sidebar.style.cssText = ` position: absolute; top: 0; right: 0; width: 240px; height: 100%; background: #f8f9fa; border-left: 1px solid #dee2e6; overflow-y: auto; z-index: 1000; `; const title = document.createElement('h3'); title.textContent = '文档大纲'; title.style.cssText = 'padding: 12px 16px; margin: 0; font-size: 14px; color: #495057;'; sidebar.appendChild(title); const list = document.createElement('ul'); list.style.cssText = 'margin: 0; padding: 0; list-style: none;'; this._renderOutlineItems(list, this.outline || []); sidebar.appendChild(list); this.plugin.container.appendChild(sidebar); } _renderOutlineItems(parent, items) { items.forEach(item => { const li = document.createElement('li'); li.style.cssText = 'padding: 4px 16px; cursor: pointer; font-size: 13px;'; li.textContent = item.title; li.title = item.title; // 点击跳转到对应页 li.addEventListener('click', () => { if (item.dest && Array.isArray(item.dest)) { const pageNum = item.dest[0]; // PDF dest 数组第一个元素是页码(从 0 开始) this.plugin.goToPage(pageNum + 1); // 插件页码从 1 开始 } }); parent.appendChild(li); if (item.items && item.items.length > 0) { const subList = document.createElement('ul'); subList.style.cssText = 'margin: 0; padding-left: 20px; list-style: none;'; this._renderOutlineItems(subList, item.items); parent.appendChild(subList); } }); } }

第二步:在index.js中注册模块

// src/index.js,在 constructor 末尾添加 this.outlineModule = new OutlineModule(this); // 在 load() 方法成功回调里调用 await this.outlineModule.init();

效果:加载 PDF 后,右侧自动弹出大纲侧边栏,点击标题即可跳转。getOutline()返回的是标准 PDF Outline 结构,支持多级嵌套,无需额外解析。

5.2 增加 OCR 文字识别:当getTextContent()为空时,调用 Tesseract.js

扫描版 PDF 没有文字层,search会失效。我们接入tesseract.js做客户端 OCR,代价是首次搜索变慢(需加载 wasm),但换来的是真正的全文检索能力。

第一步:安装依赖

npm install tesseract.js

第二步:新建src/features/ocr-search.js

// src/features/ocr-search.js import { createWorker } from 'tesseract.js'; export class OcrSearchModule { constructor(plugin) { this.plugin = plugin; this.worker = null; } async init() { if (!this.worker) { this.worker = createWorker({ logger: m => console.log(`Tesseract: ${m.progress * 100 | 0}%`) }); await this.worker.load(); await this.worker.loadLanguage('chi_sim'); // 中文简体 await this.worker.initialize('chi_sim'); } } async searchOnPage(pageNum, text, options) { const page = await this.plugin.pdfDoc.getPage(pageNum); const viewport = page.getViewport({ scale: 2.0 }); // 高清截图提升 OCR 准确率 const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); canvas.width = viewport.width; canvas.height = viewport.height; // 渲染页面到 canvas const renderContext = { canvasContext: ctx, viewport: viewport }; await page.render(renderContext).promise; // OCR 识别 const { data: { text: ocrText } } = await this.worker.recognize(canvas); // 在 OCR 文本中搜索(复用原有逻辑) const regex = this._buildRegex(text, options.wholeWord, options.caseSensitive); const matches = []; let match; while ((match = regex.exec(ocrText)) !== null) { matches.push({ pageNum, ocrText, matchStart: match.index, matchLength: match[0].length }); } return matches; } _buildRegex(text, wholeWord, caseSensitive) { const escaped = text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const flags = caseSensitive ? 'g' : 'gi'; const pattern = wholeWord ? `\\b${escaped}\\b` : escaped; return new RegExp(pattern, flags); } }

第三步:改造search.js,加入 OCR 回退机制

// src/features/search.js,在 performSearch 方法中 // ...原有代码 for (let pageNum = 1; pageNum <= this.plugin.getPageCount(); pageNum++) { const page = await this.plugin.pdfDoc.getPage(pageNum); const textContent = await page.getTextContent(); if (textContent && textContent.items && textContent.items.length > 0) { // 原有逻辑:用 textContent 搜索 } else { // 回退:调用 OCR 搜索 console.log(`Page ${pageNum} has no text layer. Falling back to OCR.`); const ocrMatches = await this.ocrModule.searchOnPage(pageNum, text, options); allMatches.push(...ocrMatches); } }

提示:OCR 是 CPU 密集型操作,务必在 Web Worker 中运行。tesseract.js默认使用 Worker,你无需额外处理。但要注意:首次调用recognize()会加载约 5MB 的 wasm 文件,建议在用户点击搜索按钮前预加载 worker。

5.3 最后一条血泪经验:永远用plugin.destroy()清理资源

这个插件会创建多个 canvas、svg、event listener、甚至 tesseract worker。如果你在 SPA 中频繁切换 PDF 查看页(比如 Vue Router 跳转),不清理会导致内存泄漏,页面越用越卡。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询