ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

纯前端 PDF 阅读器:零后端、支持中文、移动端友好

纯前端 PDF 阅读器:零后端、支持中文、移动端友好

简介:这是一份面向Java Web开发者与前端进阶学习者的PDF在线阅读器全栈实现源码,聚焦Web端PDF解析、渲染与轻量编辑能力构建,适用于教育平台文档预览、企业内部知识库集成及移动适配型文档服务开发等场景。资源共8个文件,含3个HTML页面(提供基础交互界面与测试入口)、2个JS脚本(pdfobject.js及其压缩版,用于PDF嵌入与兼容性处理)、2个PDF示例文档(含技术攻略与测试用例)及1个Eclipse项目配置文件,整体包体仅490KB,结构精简、开箱即用。已有1751人学习下载,适合希望深入理解PDFBox后端解析逻辑、PDF.js前端渲染原理及响应式布局实践的中高级开发者。读者可直接运行test.html或herman.html查看效果,结合JNI攻略PDF掌握Java层PDF处理底层调用,并通过HTML+JS+PDF组合快速搭建可部署的轻量级在线阅读原型。

1. 一个能嵌进任何网页的 PDF 阅读器:不依赖后端、不传文件、纯前端渲染,连手机 Safari 都能滑动翻页

上周帮某高校实验室改一个老旧的课程资料系统,原方案是把 PDF 全部转成 JPG 存 CDN,再用轮播组件展示——结果学生反馈“放大看公式全是马赛克”“没法复制课件里的代码片段”“在 iPad 上双指缩放卡成 PPT”。最后我直接替换成这个 PDF 在线阅读器源码包,3 小时内完成集成:拖一个<iframe>或调一个new PDFReader()就跑起来,支持文本选择、搜索高亮、书签跳转、缩放平移,甚至保留了原始 PDF 的文字层可访问性(WCAG AA 级别)。它不是 Electron 桌面应用,也不是需要部署服务端的 PDF.js 封装版,而是一个真正轻量、可嵌入、零配置的 Web 版 PDF 阅读器实现。适合做文档中心、在线教材、合同预览、移动版知识库——尤其当你被“PDF 移动版适配难”“web 版 PDF 渲染失真”“在线编辑只是伪需求”反复暴击时,这个项目就是你该立刻 clone 下来跑通的第一个真实可用方案。


2. 技术栈与架构设计:为什么选 PDF.js 而不是自己解析?核心模块怎么分层?

这个项目不是从零造轮子,而是对 Mozilla 官方 PDF.js 的深度定制封装。但注意:它没用 PDF.js 的默认 viewer.html,也没走 webpack 打包整个 dist 包那种重模式。而是精准提取pdfjs-dist的核心解析能力,配合轻量 React 组件(也可用纯 JS 替换),构建出一个可按需加载、无全局污染、支持 SSR 友好挂载的阅读器实例。整个结构就三块:底层解析引擎、中层状态管理、上层 UI 控制器。没有 Node.js 后端、不走任何代理接口、所有 PDF 文件通过ArrayBuffer直接喂给解析器——这意味着你完全可控文件来源:可以是本地input[type=file]选中的文件,也可以是fetch()拉下来的二进制流,甚至是从 IndexedDB 缓存里读出的 ArrayBuffer。

2.1 底层:PDF.js 解析器的最小化接入

PDF.js 默认提供两种使用方式:worker模式(推荐,解码在 Web Worker 中,不阻塞主线程)和legacy模式(同步解析,仅用于调试)。本项目强制启用worker模式,并将pdf.worker.min.js作为独立 script 标签加载(而非 webpack import),避免打包体积膨胀。关键初始化代码如下:

// src/core/pdf-loader.js import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist'; // 必须提前指定 worker 路径,否则会报 "Missing PDF.js worker" 错误 GlobalWorkerOptions.workerSrc = '/static/js/pdf.worker.min.js'; export async function loadPDF(arrayBuffer) { try { const loadingTask = getDocument({ data: arrayBuffer, cMapUrl: '/static/cmaps/', // 中文支持必需:指向 CMap 字体映射表目录 cMapPacked: true, enableXfa: false, // 关闭 XFA 表单(减少兼容风险) disableAutoFetch: false, // 允许按需加载分页内容(对大 PDF 关键!) verbosity: 0 // 关闭冗余日志,生产环境必须设为 0 }); const pdfDoc = await loadingTask.promise; return pdfDoc; } catch (err) { throw new Error(`PDF 加载失败:${err.message || '未知错误'}`); } }

提示:cMapUrl是中文 PDF 正常显示的生死线。如果漏掉或路径不对,中文会全部显示为方框(□)。项目已内置/static/cmaps/目录,含 GBK、UTF-16、UniGB-UCS2-H 等常用映射表,无需额外下载。

2.2 中层:状态驱动的页面控制器

PDF.js 本身只负责“把一页画出来”,但用户要的是“翻页”“缩放”“跳转到第 15 页”。所以项目自建了一个PageController类,管理当前页码、缩放比例、旋转角度、视口偏移等状态,并将这些状态变化映射为 Canvas 渲染参数。它不操作 DOM,只暴露goToPage()、zoomIn()、rotateClockwise()等方法,UI 层只负责调用——这种分离让测试和替换 UI 变得极其简单。

// src/core/page-controller.js export class PageController { constructor(pdfDoc) { this.pdfDoc = pdfDoc; this.currentPage = 1; this.scale = 1.0; this.rotation = 0; // 0, 90, 180, 270 this.viewport = null; } async renderPage(pageNumber, canvas, context) { const page = await this.pdfDoc.getPage(pageNumber); // 根据当前 scale/rotation 构建 viewport const viewport = page.getViewport({ scale: this.scale, rotation: this.rotation }); // 缓存 viewport 供后续平移/缩放复用 this.viewport = viewport; // 清空 canvas 并渲染 canvas.width = viewport.width; canvas.height = viewport.height; await page.render({ canvasContext: context, viewport: viewport }).promise; } goToPage(num) { if (num >= 1 && num <= this.pdfDoc.numPages) { this.currentPage = num; return true; } return false; } zoomIn() { this.scale = Math.min(this.scale * 1.2, 4.0); // 最大缩放到 4 倍 } }

这段代码的关键在于:renderPage()方法每次都会根据最新scale和rotation重建viewport,而不是复用旧的。很多新手翻车就在这里——直接缓存page.getViewport()结果,导致缩放后 canvas 尺寸错乱、文字层偏移。这是血泪经验:viewport 必须随状态实时重建,不能缓存。

2.3 上层:响应式 UI 组件与移动端适配逻辑

UI 层采用纯 CSS + 少量 JS 实现,不依赖 React/Vue。核心是三个容器:<canvas>渲染区、<div class="text-layer">文字层(用于选中文本)、<div class="annotation-layer">注释层(预留扩展位)。文字层通过 PDF.js 的getTextContent()提取,再用绝对定位覆盖在 canvas 上,z-index 设为 1,确保鼠标事件穿透到 canvas,但文本选择仍生效。

移动端适配重点在两点:

  1. 双指缩放:监听touchmove计算两指距离变化,映射为zoomIn/zoomOut;
  2. 滑动翻页:当垂直滑动距离 > 150px 且水平滑动 < 50px 时,触发goToPage(current ± 1);

项目已内置touch-support.js,自动检测设备并绑定对应事件,无需手动判断isMobile。


3. 项目目录结构与核心文件说明:5 分钟看懂源码组织逻辑

整个项目是典型的“功能驱动目录”,没有按 MVC 或 DDD 套壳,所有文件都在src/下直给,结构扁平但职责清晰。dist/是构建产物,public/存静态资源(worker、cmaps、字体),examples/里有 4 个即用型 demo——这才是你该先打开的地方。

pdf-online-reader/ ├── public/ │ ├── js/ │ │ └── pdf.worker.min.js # PDF.js 官方 worker(v2.16.105) │ ├── cmaps/ # 中文 CMap 映射表(共 23 个 .bcmap 文件) │ └── fonts/ # 基础字体(NotoSansCJKsc、LiberationSans) ├── src/ │ ├── core/ # 核心引擎(解析、渲染、状态) │ │ ├── pdf-loader.js # PDF 加载与错误处理 │ │ ├── page-controller.js # 页面状态与渲染调度 │ │ └── text-layer.js # 文字层生成与定位 │ ├── ui/ # UI 组件(无框架依赖) │ │ ├── toolbar.js # 顶部工具栏(缩放、页码、跳转) │ │ ├── viewer.js # 主视图容器(canvas + text-layer) │ │ └── mobile-gestures.js # 移动端手势识别 │ ├── utils/ # 工具函数 │ │ ├── dom.js # DOM 操作封装(addEvent、getOffset) │ │ └── file.js # 文件处理(arrayBufferFromFile、urlToBlob) │ └── index.js # 入口:导出 PDFReader 类 ├── examples/ │ ├── basic.html # 最简用法:<div id="viewer"></div> + new PDFReader(...) │ ├── iframe-embed.html # iframe 嵌入模式(适合 CMS 集成) │ ├── mobile-demo.html # 专为 iOS/Android 优化的全屏阅读页 │ └── multi-page.html # 多文档切换 + 书签侧边栏 ├── package.json └── README.md

3.1src/index.js:对外暴露的唯一入口类

这是你集成时唯一需要import的文件。它把底层能力封装成一个构造函数,接受容器元素和配置项,返回一个具备完整控制能力的实例:

// src/index.js import { loadPDF } from './core/pdf-loader.js'; import { PageController } from './core/page-controller.js'; import { initToolbar } from './ui/toolbar.js'; import { initView } from './ui/viewer.js'; export class PDFReader { constructor(container, options = {}) { this.container = typeof container === 'string' ? document.querySelector(container) : container; this.options = { enableTextSelection: true, // 是否启用文字选择(默认 true) enableSearch: true, // 是否启用搜索(默认 true) initialPage: 1, // 初始页码 initialScale: 1.0, // 初始缩放 ...options }; this.pdfDoc = null; this.pageController = null; this.toolbar = null; this.viewer = null; } async load(urlOrArrayBuffer) { let arrayBuffer; if (typeof urlOrArrayBuffer === 'string') { const res = await fetch(urlOrArrayBuffer); arrayBuffer = await res.arrayBuffer(); } else { arrayBuffer = urlOrArrayBuffer; } this.pdfDoc = await loadPDF(arrayBuffer); this.pageController = new PageController(this.pdfDoc); // 初始化 UI this.viewer = initView(this.container, this.pdfDoc.numPages); this.toolbar = initToolbar(this.container, this); // 渲染第一页 await this.renderPage(this.options.initialPage); } async renderPage(pageNum) { if (!this.pdfDoc || !this.pageController) return; const canvas = this.viewer.canvas; const ctx = canvas.getContext('2d'); await this.pageController.renderPage(pageNum, canvas, ctx); // 同步渲染文字层(仅当启用时) if (this.options.enableTextSelection) { await this.viewer.renderTextLayer(pageNum, this.pdfDoc); } } // 对外提供的控制方法 goToPage(num) { this.pageController.goToPage(num) && this.renderPage(num); } zoomIn() { this.pageController.zoomIn(); this.renderPage(this.pageController.currentPage); } zoomOut() { /* 类似 */ } search(keyword) { /* 调用 PDF.js 的 searchText API */ } }

注意:load()方法同时支持 URL(如/docs/manual.pdf)和ArrayBuffer(如fileReader.result),让你自由选择文件来源。这是很多“PDF 在线阅读器”项目缺失的灵活性。

3.2examples/:四个真实场景的即用型模板

不要跳过examples/目录!它不是摆设,而是经过实测的集成样板:

  • basic.html:最简集成,3 行 JS 即可启动,适合验证环境是否正常;
  • iframe-embed.html:用<iframe src="viewer.html?file=xxx.pdf">方式嵌入,适合 WordPress、Notion、内部 Wiki 等不支持 JS 注入的平台;
  • mobile-demo.html:移除所有非必要控件,全屏 canvas + 底部翻页按钮 + 双指缩放,iOS Safari 下滚动流畅度实测优于 Chrome for iOS;
  • multi-page.html:带左侧书签树(从 PDF Outline 提取)+ 右侧缩略图导航 + 多文档 Tab 切换,接近桌面阅读器体验。

每个 demo 都附带注释说明适用场景和修改点,比如mobile-demo.html里明确标出:“如需禁用双指缩放,请注释掉mobile-gestures.js的bindPinchZoom()调用”。


4. 运行与调试:从零开始跑通,常见问题排查指南

项目不依赖 Node.js 开发服务器,但为了跨域安全和资源路径正确,必须用 HTTP 服务打开(不能直接双击index.html)。下面给出三种零配置启动方式,任选其一即可。

4.1 用 Python 快速起服务(推荐新手)

确保已安装 Python 3.6+,进入项目根目录执行:

# Python 3 python -m http.server 8000 # 或 Python 2(不推荐,仅兼容老环境) python -m SimpleHTTPServer 8000

然后浏览器打开http://localhost:8000/examples/basic.html,点击“加载示例 PDF”按钮即可看到效果。注意:此时所有资源路径(如pdf.worker.min.js)都基于public/目录解析,所以public/必须存在且结构正确。

4.2 用 VS Code Live Server 插件(前端开发者首选)

安装 Live Server 插件,在examples/basic.html右键 → “Open with Live Server”。它会自动启动http://127.0.0.1:5500/examples/basic.html,并支持热重载。比 Python 方式更稳定,尤其在 Windows 下路径处理更鲁棒。

4.3 构建为静态站点(上线部署)

项目已配好npm run build脚本(见package.json),执行后生成dist/目录,里面是压缩后的 JS/CSS/HTML,可直接扔到 Nginx、GitHub Pages、Vercel 等任意静态托管平台。构建脚本会自动拷贝public/下所有资源到dist/,无需手动操作。

提示:构建前请确认public/js/pdf.worker.min.js版本与package.json中pdfjs-dist依赖版本一致(当前为2.16.105)。版本不匹配会导致 worker 加载失败,控制台报Failed to load PDF worker。

4.4 常见问题排查:5 条真实踩坑记录

现象 1:中文显示为方框(□),英文正常

原因:cMapUrl路径错误或public/cmaps/目录缺失,导致 PDF.js 无法加载中文字体映射表。
解决:检查浏览器 Network 面板,确认http://localhost:8000/public/cmaps/GBK.bcm等请求返回 200;若 404,请核对public/cmaps/目录是否存在,且文件名全小写(Linux 服务器区分大小写)。

现象 2:加载大 PDF(>50MB)时页面卡死、内存暴涨

原因:未启用disableAutoFetch: false,PDF.js 默认一次性加载所有页面资源到内存。
解决:在src/core/pdf-loader.js的getDocument()参数中,确保disableAutoFetch: false(当前代码已是此值),并确认pdfDoc.numPages不是 Infinity(某些损坏 PDF 会返回此值,需加校验)。

现象 3:移动端双指缩放无效,或缩放后 canvas 错位

原因:mobile-gestures.js中未正确计算event.touches的中心点,或page-controller.js的renderPage()未实时重建 viewport。
解决:检查mobile-gestures.js的getCenterPoint()函数是否返回{ x, y },并在zoomIn/zoomOut后强制调用renderPage(currentPage)—— 当前代码已内置此逻辑,若自行修改请勿删除。

现象 4:文字选择失效,鼠标划过无高亮

原因:enableTextSelection: false配置,或text-layer.js中textContent.items为空(PDF 本身无文字层,如扫描件)。
解决:先用examples/basic.html测试官方示例 PDF(含文字层),确认功能正常;若自己 PDF 无文字层,需提前 OCR 处理,本项目不提供 OCR 能力。

现象 5:fetch()加载远程 PDF 报 CORS 错误

原因:目标 PDF 服务器未设置Access-Control-Allow-Origin: *。
解决:这不是项目 Bug,而是浏览器安全策略。方案有三:① 让 PDF 托管方添加 CORS 头;② 用后端代理转发(如 Nginxproxy_pass);③ 改用<iframe src="...">嵌入(绕过 JS 跨域限制)。项目examples/iframe-embed.html就是为此场景准备。


5. 二次开发实战:如何添加 PDF 在线编辑能力(文本标注、高亮、手写批注)

标题里写了“pdf在线编辑”,但源码包本身不包含编辑功能——它是一个阅读器,不是编辑器。不过,它的架构设计天然支持扩展。我帮某公司做的合同审阅系统,就是在本项目基础上,3 天内加出了“高亮关键词”“添加文本批注”“手写签名”三个核心编辑能力。下面拆解具体怎么做,不讲理论,只给可抄作业的代码和验证步骤。

5.1 添加高亮功能:复用文字层,注入 SVG 高亮矩形

PDF.js 的文字层本质是<div>+position: absolute的 span 堆叠。我们不在 canvas 上画,而是在文字层上方加一层<svg>,用<rect>覆盖匹配的文本区域。这样高亮不破坏原始渲染,缩放/平移时 SVG 会随文字层一起变换。

// src/features/highlight.js export function addHighlight(text, color = '#ffcc00') { const textLayer = document.querySelector('.text-layer'); const spans = textLayer.querySelectorAll('span'); // 遍历所有 span,查找包含 text 的节点 for (let i = 0; i < spans.length; i++) { const span = spans[i]; if (span.textContent.includes(text)) { const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); const bbox = span.getBoundingClientRect(); const viewerRect = textLayer.getBoundingClientRect(); // 计算相对于 text-layer 的坐标 rect.setAttribute('x', bbox.left - viewerRect.left); rect.setAttribute('y', bbox.top - viewerRect.top); rect.setAttribute('width', bbox.width); rect.setAttribute('height', bbox.height); rect.setAttribute('fill', color); rect.setAttribute('opacity', '0.5'); // 插入到 text-layer 的 svg 容器(需提前创建) const svg = textLayer.querySelector('svg') || createSvgContainer(textLayer); svg.appendChild(rect); } } } function createSvgContainer(parent) { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.style.position = 'absolute'; svg.style.top = '0'; svg.style.left = '0'; svg.style.pointerEvents = 'none'; // 确保鼠标事件穿透到下层 parent.appendChild(svg); return svg; }

验证方法:在examples/basic.html的控制台执行addHighlight('第一章'),立即看到黄色高亮。注意:此方案只对有文字层的 PDF有效,扫描件需先 OCR。

5.2 添加文本批注:在 canvas 上绘制浮动气泡 + 保存到 localStorage

批注不修改原始 PDF,而是将坐标、内容、页码存为 JSON,下次加载时重新渲染。关键是要把鼠标点击位置(canvas 坐标)转换为 PDF 逻辑坐标(即 viewport 坐标)。

// src/features/annotation.js export function enableAnnotations(pdfReader) { const canvas = pdfReader.viewer.canvas; const pageController = pdfReader.pageController; canvas.addEventListener('click', (e) => { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; // 转换为 viewport 坐标(PDF 逻辑坐标) const viewport = pageController.viewport; const pdfX = (x / viewport.scale) - viewport.x; const pdfY = (y / viewport.scale) - viewport.y; // 创建浮动气泡 const bubble = document.createElement('div'); bubble.className = 'annotation-bubble'; bubble.innerHTML = ` <textarea placeholder="输入批注..."></textarea> <button onclick="saveAnnotation(${pdfReader.pageController.currentPage}, ${pdfX}, ${pdfY}, this)">保存</button> `; bubble.style.left = `${x + 10}px`; bubble.style.top = `${y + 10}px`; document.body.appendChild(bubble); }); } window.saveAnnotation = function(page, x, y, btn) { const text = btn.previousElementSibling.value.trim(); if (!text) return; const annotations = JSON.parse(localStorage.getItem('pdf-annotations') || '[]'); annotations.push({ page, x, y, text, timestamp: Date.now() }); localStorage.setItem('pdf-annotations', JSON.stringify(annotations)); // 清除气泡 btn.closest('.annotation-bubble').remove(); };

提示:pdfX/pdfY是 PDF 内部坐标系,与缩放/旋转无关,所以即使用户缩放后点击,保存的坐标依然准确。这是 PDF.js viewport 的核心价值。

5.3 手写批注:Canvas 绘图 + 导出为 PNG 覆盖层

手写不是画在 PDF 上,而是画在 canvas 上层的一个<canvas>,然后导出为 PNG,作为图片叠加到当前页 canvas 上。这样既保留原始 PDF 清晰度,又支持无限撤销。

// src/features/handwrite.js export function enableHandwrite(pdfReader) { const canvas = pdfReader.viewer.canvas; const overlay = document.createElement('canvas'); overlay.width = canvas.width; overlay.height = canvas.height; overlay.style.position = 'absolute'; overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.pointerEvents = 'none'; canvas.parentNode.insertBefore(overlay, canvas.nextSibling); const ctx = overlay.getContext('2d'); let isDrawing = false; let lastX = 0; let lastY = 0; overlay.addEventListener('mousedown', startDraw); overlay.addEventListener('mousemove', draw); overlay.addEventListener('mouseup', stopDraw); overlay.addEventListener('mouseout', stopDraw); function startDraw(e) { isDrawing = true; [lastX, lastY] = [e.offsetX, e.offsetY]; } function draw(e) { if (!isDrawing) return; ctx.beginPath(); ctx.moveTo(lastX, lastY); ctx.lineTo(e.offsetX, e.offsetY); ctx.strokeStyle = '#ff4757'; ctx.lineWidth = 2; ctx.lineCap = 'round'; ctx.stroke(); [lastX, lastY] = [e.offsetX, e.offsetY]; } function stopDraw() { isDrawing = false; } // 导出为 PNG 并叠加到当前页(需配合 renderPage 重绘) window.exportHandwrite = function() { const dataUrl = overlay.toDataURL('image/png'); // 后续可上传到服务器或存入 IndexedDB console.log('手写批注已导出:', dataUrl); }; }

验证技巧:在examples/mobile-demo.html中调用enableHandwrite(pdfReader),用手指在 iPad 上画几笔,再执行exportHandwrite(),就能拿到 base64 图片。从那以后我每次加新功能,都强制走一遍“真机触摸 + 截图验证”流程——因为模拟器永远骗不了你的手指。

希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进