尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
PDF在线阅读器源码:Canvas渲染与移动端Range分片实现
简介面向Web端PDF阅读与在线编辑场景的实战源码包适合具备Java与前端基础、希望打通PDF解析、渲染与交互链路的开发者参考。内容围绕PDF格式解析、页面渲染和在线编辑三条主线展开既涉及用PDFBox读取文档、通过PDFRenderer将页面转为位图并绘制到HTML5 canvas也提到借助PDF.js在浏览器中呈现文档以及结合iText实现注释、文本与图像的修改同时兼顾移动端响应式布局、Web Workers后台处理、离线存储和嵌入脚本过滤等适配与安全要点。压缩包共8个文件以3个html页面、2个js脚本和2个pdf示例文档为主另含1个项目配置文件整体约490KB体积轻量便于快速导入IDE对照调试。已有1752人浏览学习可作为理解Web版PDF阅读器目录组织与模块划分的入门样本并据此扩展编辑与服务端处理能力。1. 一份 30MB 的标书在手机浏览器里翻了 40 秒一份 30MB 的招标文件丢进聊天窗口对方要在地铁上用手机翻到第 87 页核对一条参数。系统自带的预览打开要等半分钟双指一放大字全糊成色块想复制一段型号都选不中。这就是pdf在线阅读器存在的意义文件不落地、点开就能看、放大不糊、能搜能选能标注改过的地方还得能存回去。这份pdf阅读器源码给的是一套完整实现web 版负责桌面端的连续滚动、缩略图导航、全文搜索和批注编辑pdf移动版走单页加手势的交互服务端只干一件事——把 HTTP Range 请求接住。它适合两类人手里有一堆 PDF 要塞进业务系统内嵌展示、不想让用户先下载再打开的开发者以及想在自家产品里加一层高亮、手写、表单填写能力又不愿意从零写渲染内核的团队。2. 架构与目录engine、page-pool、layer 三层怎么分工2.1 三条实现路线的取舍做 web 版 PDF 在线阅读能走的路其实就三条选错了后面全是返工。最省事的是iframe srcx.pdf三行代码搞定浏览器自带内核什么都能显示。但一嵌进业务系统问题就来了工具栏是浏览器的改不了想让用户直接跳到第 87 页只能让他自己翻批注、水印、下载拦截这些全插不进去文件跨域还得服务端配合配 header。这条路只适合内部临时看一眼。第二条是服务端转图后端把每页转成 PNG 或 WebP前端就是个图片列表兼容性最好老安卓也不挑。代价是首屏要等转码30MB 的文件转完几十秒文字选不中、搜不了放大还是糊。适合「只读且要防下载」的场景。第三条就是这份源码走的路前端渲染内核解析 PDF按需取页画到 Canvas再叠一层透明的文字层选中和搜索就有了批注在最上面再加一层。方案首屏速度可选中/搜索批注扩展前端包体典型场景iframe 原生预览快用原生能力几乎插不进去0内部临时查看服务端转图片慢等转码不支持要自己叠层小只读、防下载前端渲染内核 Canvas中可做到 1s 内支持天然支持300KB1MB业务系统内嵌真正的分水岭在「文字层」。只有内核知道每个字符在页面上的精确坐标把它渲染成一堆绝对定位的span浏览器的选区、CtrlF、右键复制才有意义。服务端转图这条路永远补不上这块。2.2 目录结构core 管内核components 管视图composables 管交互源码拆成四块边界划得很清楚二次开发时按需改一块就行pdf-reader/ ├─ public/ │ ├─ cmaps/ # CJK 字体映射表中文不乱码的关键 │ ├─ standard_fonts/ # 内核自带的替代字体 │ └─ pdf.worker.mjs # 解析线程必须同源 ├─ src/ │ ├─ core/ │ │ ├─ engine.ts # 内核初始化全项目唯一入口 │ │ ├─ page-pool.ts # 页面渲染 renderTask 生命周期管理 │ │ └─ text-layer.ts # 文本层构建负责选中与搜索 │ ├─ components/ │ │ ├─ PdfViewer.vue # 外层容器管滚动和缩放状态 │ │ ├─ PdfPage.vue # 单页canvas textLayer 批注层三件套 │ │ ├─ Toolbar.vue # 工具栏页码、缩放、搜索、模式切换 │ │ └─ AnnotationLayer.vue # 批注绘制与命中检测 │ ├─ composables/ │ │ ├─ usePageVirtual.ts # 虚拟滚动只挂可视区的页 │ │ ├─ useRangeLoader.ts # 分片加载与进度 │ │ └─ usePinch.ts # 双指缩放、双击放大 │ ├─ store/ │ │ └─ reader.ts # 当前页、缩放比、批注集合 │ └─ utils/ │ ├─ dpr.ts # 设备像素比封顶 │ └─ coord.ts # 屏幕坐标 ↔ PDF 用户坐标互转 ├─ server/ │ └─ range.js # 支持 206 的静态文件服务 ├─ vite.config.ts └─ package.jsoncore/是唯一允许import渲染内核的地方业务组件一律通过它暴露的方法拿页面对象。这么分层的好处很实际将来内核升级大版本、或者换成商业内核只动core/视图层一行不改。components/PdfPage.vue里那个「三件套」是核心结构三层绝对定位叠在一起canvas在最下textLayer中间且pointer-events: auto批注层最上但默认pointer-events: none只在批注模式下打开。三层共用同一个 viewport尺寸永远一致这是后面不出现「选中的字和看到的字对不上」的前提。2.3 三条命令跑起来静态资源必须落到 public依赖装完还不能直接跑内核自带的两套静态资源得手动拷贝到public/下面。这一步跳过中文文档十有八九会出方块字。# 内核包目录名以实际安装的为准下文用 $ENGINE 指代 ENGINEnode_modules/pdf-render-core npm install # 1. CJK 字体映射表中文不显示方块全靠它 cp -r $ENGINE/cmaps public/cmaps # 2. 标准 14 号字体的替代文件有些 PDF 没内嵌字体靠它兜底 cp -r $ENGINE/standard_fonts public/standard_fonts # 3. 解析 worker必须同源别挂 CDN cp $ENGINE/build/pdf.worker.mjs public/pdf.worker.mjs npm run devvite.config.ts里做两件事一是把内核路径起个别名二是把内核单独打成一个 chunk。内核压缩后三百多 KB跟业务代码混在一起会让首屏白白多等一截。// vite.config.ts import { defineConfig } from vite import vue from vitejs/plugin-vue import path from node:path export default defineConfig({ resolve: { alias: { // 业务代码只认 engine将来换内核只改这一行 engine: path.resolve(__dirname, node_modules/pdf-render-core/build/pdf.mjs), }, }, optimizeDeps: { include: [pdf-render-core], // 预构建一次省掉每次冷启动的重编译 }, build: { rollupOptions: { output: { // 内核独立分包改业务代码时它的 hash 不变用户缓存能一直命中 manualChunks: { engine: [pdf-render-core] }, }, }, }, })2.4 初始化入口engine.ts 里五个不能省的参数整个项目只有engine.ts直接碰内核初始化参数在这里一次性配齐// src/core/engine.ts import * as pdfEngine from engine export interface EngineOptions { url: string rangeChunkSize?: number withCredentials?: boolean } let current: { url: string; promise: Promiseany } | null null export function openDocument(opts: EngineOptions) { // 同一份文件重复打开时复用任务避免二次解析整个文档结构 if (current?.url opts.url) return current.promise const promise pdfEngine.getDocument({ url: opts.url, cMapUrl: /cmaps/, // 指向 public/cmaps cMapPacked: true, standardFontDataUrl: /standard_fonts/, rangeChunkSize: opts.rangeChunkSize ?? 256 * 1024, withCredentials: opts.withCredentials ?? false, disableAutoFetch: true, // 关掉后台预取交给虚拟滚动决定取哪页 disableStream: false, // 保持流式解析首屏不用等整包 }).promise current { url: opts.url, promise } return promise }几个参数值得单独说一下它们直接决定首屏体验参数作用不配的后果cMapUrlCJK 字体映射表路径中文 PDF 部分字变方块或直接空白rangeChunkSize单次 Range 请求的字节数默认 64KB30MB 文件要发近 500 次请求disableAutoFetch关掉后台自动预取打开就开始偷偷下整包分片白做disableStream是否关闭流式解析设成true首屏要等整页数据到齐withCredentials是否携带 Cookie走鉴权的文件全 401rangeChunkSize我从默认的 64KB 调到 256KB是因为实测在 4G 网络下 64KB 的分片会让 RTT 成为瓶颈一页 A4 往往要串行发四五个请求调到 256KB 后请求数降到四分之一弱网首屏能快一秒以上。但也不能调太大超过 1MB 在 3G 下会有明显的等待感用户会觉得卡住没反应。3. 渲染链路Canvas 按页绘制、DPR 缩放和文本层对齐3.1 viewport 是唯一坐标源头内核里所有跟位置有关的东西都从 viewport 出来。page.getViewport({ scale })返回的对象带三个关键属性width/height是这一页在当前缩放下占多少 CSS 像素transform是一个六元矩阵负责把 PDF 用户坐标系原点在页面左下角单位 pt映射到屏幕坐标。const page await doc.getPage(1) // scale: 1 时1 个 PDF 单位pt≈ 1.333 CSS 像素A4 大约 794×1123 const base page.getViewport({ scale: 1 }) // 注意 rotation 也要传扫描件经常带 90/270 度旋转 const rotated page.getViewport({ scale: 1, rotation: page.rotate }) console.log(base.width, base.height, rotated.width, rotated.height)缩放比例、设备像素比、旋转角度全都体现在 viewport 里。所以三层canvas、文字层、批注层必须用同一份 viewport 派生出来的数据任何一层自己算一遍就会出现「能看到但选不中」「批注飘到页外」这类玄学问题。3.2 渲染一页canvas 尺寸、DPR 封顶和 renderTask 取消// src/core/page-pool.ts import * as engine from engine const MAX_DPR 2 // 再往上收益极小内存直接翻倍 const tasks new WeakMapHTMLCanvasElement, { cancel(): void }() export async function renderPage( doc: engine.PDFDocumentProxy, pageNo: number, canvas: HTMLCanvasElement, scale: number, ) { const page await doc.getPage(pageNo) const dpr Math.min(window.devicePixelRatio || 1, MAX_DPR) const viewport page.getViewport({ scale: scale * dpr }) // 同一个 canvas 上有未完成的任务就先取消否则会抛 // Cannot use the same canvas during multiple render() operations tasks.get(canvas)?.cancel() const ctx canvas.getContext(2d, { alpha: false })! canvas.width Math.floor(viewport.width) // 物理像素 canvas.height Math.floor(viewport.height) canvas.style.width ${Math.floor(viewport.width / dpr)}px // 逻辑像素 canvas.style.height ${Math.floor(viewport.height / dpr)}px const task page.render({ canvasContext: ctx, viewport }) tasks.set(canvas, task) await task.promise // 文字层用不带 dpr 的 viewport它是按 CSS 像素排版的 return { page, viewport, cssViewport: page.getViewport({ scale }) } }这里两个参数最容易翻车。canvas.width是物理像素也就是 GPU 真正要画多少点canvas.style.width是逻辑像素决定它在页面上占多大地方。两者比例必须等于 dpr否则要么糊要么被放大一倍。alpha: false是顺手的一个优化PDF 页面本来就铺满不透明背景关掉 alpha 通道能让合成快一点。DPR 封顶在 2 是血泪经验。现在不少手机报devicePixelRatio 3一页 A4 在 dpr3 下是 2382×3369 物理像素单张 canvas 占 32MB 显存连续滚动挂五六页就是 160MB中低端机直接爆掉表现是滚动时页面变白、切后台回来重绘。3.3 文本层让一个字能被选中、被搜索、被复制// src/core/text-layer.ts import * as engine from engine export async function buildTextLayer( page: engine.PDFPageProxy, layer: HTMLDivElement, cssViewport: engine.PageViewport, ) { const content await page.getTextContent() const frag document.createDocumentFragment() for (const item of content.items as engine.TextItem[]) { if (!item.str) continue // item.transform 是字符在 PDF 坐标系里的位置乘上 viewport.transform 才是屏幕坐标 const tx engine.Util.transform(cssViewport.transform, item.transform) const fontSize Math.hypot(tx[2], tx[3]) const span document.createElement(span) span.textContent item.str span.style.left ${tx[4]}px span.style.top ${tx[5] - fontSize}px span.style.fontSize ${fontSize}px span.style.transformOrigin 0 0 // 横向拉伸对齐字宽不校正的话长段落会累积出几十像素的偏移 const target item.width * cssViewport.scale const natural item.str.length * fontSize span.style.transform scaleX(${natural 0 ? target / natural : 1}) frag.appendChild(span) } layer.replaceChildren(frag) layer.style.width ${cssViewport.width}px layer.style.height ${cssViewport.height}px }tx[4]/tx[5]是字符基线的 x 和 yfontSize取矩阵的缩放分量长度得到。这几个值全部来自 viewport所以缩放变了以后文本层必须跟着重建不能只改 CSS。还有一个细节文本层里的span颜色要设成完全透明。它的作用是撑开一个能被打中的盒子让浏览器的选区逻辑和 CtrlF 有东西可搜视觉上还是 canvas 在显示。所以 CSS 里要写color: transparent加user-select: text并且这一层的pointer-events必须开着否则整页选不中任何字。3.4 清晰度与性能的折中什么时候该重新渲染缩放是个高频操作双指一动就是几十个touchmove。每个事件都触发重渲染主线程直接堵死。这份源码的做法是「预览用 CSS落地才重绘」// src/components/PdfViewer.vue 里的缩放处理 let rafId 0 function onZoomPreview(scale: number) { cancelAnimationFrame(rafId) rafId requestAnimationFrame(() { // 只有可视页做变换先拉大顶一下画面会糊但跟手 for (const el of visiblePageEls.value) { el.style.transform scale(${scale / renderedScale.value}) el.style.transformOrigin 0 0 } }) } async function onZoomEnd(scale: number) { if (Math.abs(scale - renderedScale.value) 0.05) return // 幅度太小不值得重绘 renderedScale.value scale for (const el of visiblePageEls.value) el.style.transform // 只重渲染可视区那两三页其余页等滚到再渲 await Promise.all(visiblePages.value.map((p) renderPage(doc, p, canvasOf(p), scale))) }onZoomPreview绑在touchmove上onZoomEnd绑在touchend上。判断阈值 0.05 是为了过滤掉手指轻微抖动不然一次点击都会触发全量重绘。4. 大文件与移动端Range 分片、虚拟滚动、双指手势4.1 别把 30MB 一次拉进内存HTTP Range 与 206内核拿到 URL 后是按 byte range 请求的服务端不认Range头它就只能退化成整包下载。判断标准很简单响应必须是206 Partial Content并且带Content-Range。// server/range.js import fs from node:fs import path from node:path import express from express const app express() const ROOT path.resolve(storage) const CHUNK 512 * 1024 app.get(/files/:name, (req, res) { // 只取 basename挡掉 ../ 之类的路径穿越 const file path.join(ROOT, path.basename(req.params.name)) if (!fs.existsSync(file)) return res.status(404).end() const { size } fs.statSync(file) const range req.headers.range res.setHeader(Accept-Ranges, bytes) res.setHeader(Content-Type, application/pdf) if (!range) { // 没有 Range 就整包发首屏会慢一个量级但不至于打不开 res.setHeader(Content-Length, size) return fs.createReadStream(file).pipe(res) } const [startRaw, endRaw] range.replace(/bytes/, ).split(-) const start Number(startRaw) || 0 const end endRaw ? Math.min(Number(endRaw), size - 1) : Math.min(start CHUNK - 1, size - 1) res.status(206) res.setHeader(Content-Range, bytes ${start}-${end}/${size}) res.setHeader(Content-Length, end - start 1) fs.createReadStream(file, { start, end }).pipe(res) }) app.listen(3000, () console.log(http://localhost:3000))关键点有三个状态码必须是 206 而不是 200Content-Range的格式必须是bytes start-end/totalContent-Length是这一段的长度而不是文件总长。少任何一个内核都会退回整包下载而且它不会报错你只会觉得「怎么这么慢」。验证就用一行 curl看返回码和头curl -sI -H Range: bytes0-1023 http://localhost:3000/files/demo.pdf # 期望看到 # HTTP/1.1 206 Partial Content # Accept-Ranges: bytes # Content-Range: bytes 0-1023/31457280 # Content-Length: 1024生产环境一般挂 Nginx 直出静态文件它默认就支持 Range反而不要手写这层。但要确认两件事gzip没有作用在 PDF 上压缩后字节偏移全乱Range 直接失效以及 CDN 没有把Range头吃掉。4.2 虚拟滚动只挂载可视区上下两页页数一多把所有页的 canvas 都建出来必然崩。这份源码的做法是「占位常驻、内容按需」// src/composables/usePageVirtual.ts import { ref, onMounted, onBeforeUnmount, type Ref } from vue export const PAGE_GAP 12 export function usePageVirtual( container: RefHTMLElement | null, opts: { total: number; ratio: number; width: number }, ) { // 用第 1 页的宽高比估算所有页的占位高度保证滚动条长度是对的 const slotH opts.width * opts.ratio const slots Array.from({ length: opts.total }, (_, i) ({ index: i 1, top: i * (slotH PAGE_GAP), height: slotH, })) const mounted refSetnumber(new Set()) let io: IntersectionObserver | null null onMounted(() { io new IntersectionObserver( (entries) { for (const e of entries) { const idx Number((e.target as HTMLElement).dataset.page) if (e.isIntersecting) mounted.value.add(idx) else mounted.value.delete(idx) // 离开视口的页在这里顺手销毁 canvas释放显存 } mounted.value new Set(mounted.value) // 触发一次响应式更新 }, // 上下各多留两页的余量快速滚动时不会看到空白 { root: container.value, rootMargin: 200% 0px }, ) container.value?.querySelectorAll([data-page]).forEach((el) io!.observe(el)) }) onBeforeUnmount(() io?.disconnect()) return { slots, mounted } }注意占位div是一直存在于 DOM 里的只有里面的canvas和文字层按mounted集合条件渲染。这样IntersectionObserver有稳定的观察目标滚动条长度也不会因为卸载页面而跳动。用第 1 页的宽高比去估所有页是有意的取舍真实 PDF 经常混着 A4 和 A3逐页测量要先把所有页对象取出来跟按需加载矛盾。估算带来的误差在滚动时会被纠正用户感知不到。真要做精确代价是打开时多等一次全量getPage。4.3 移动端手势双指缩放、双击放大、单页与连续模式// src/composables/usePinch.ts export function usePinch(el: HTMLElement, onPreview: (s: number) void, onCommit: (s: number) void) { let startDist 0 let startScale 1 let current 1 const dist (t: TouchList) Math.hypot(t[0].clientX - t[1].clientX, t[0].clientY - t[1].clientY) el.addEventListener(touchstart, (e) { if (e.touches.length ! 2) return startDist dist(e.touches) startScale current }, { passive: true }) el.addEventListener(touchmove, (e) { if (e.touches.length ! 2) return e.preventDefault() // 挡住浏览器自带的整页缩放否则会和画布缩放打架 const next startScale * (dist(e.touches) / startDist) current Math.min(Math.max(next, 0.5), 5) // 限制在 50%500% onPreview(current) // 这一步只改 CSS transform不重渲染 }, { passive: false }) el.addEventListener(touchend, () { if (current ! startScale) onCommit(current) // 手势结束才真正重绘 }) }passive: false是必须的preventDefault只有在非 passive 监听里才生效。少了它安卓上会出现「页面整体被放大canvas 却没变」的诡异现象排查起来很费时间。移动端还有一个模式差异桌面默认连续滚动手机默认单页翻页。原因很实际连续滚动在手机上会把一页拉到两三屏高用户滑半天不知道自己在哪单页模式下滑动一次翻一页横竖屏切换也好处理。源码里用store/reader.ts的mode字段控制切换时只重建外层容器的布局不重新解析文档。5. 避坑与排查五个让阅读器白屏、错位、本文还有配套的精品资源点击获取
RELATED

相关推荐

以太坊三棵树架构解析与MPT优化实践

以太坊三棵树架构解析与MPT优化实践

1. 以太坊三棵树架构概述在以太坊区块链系统中,状态树、交易树和收据树构成了核心数据结构三叉戟。这个设计源于对BTC简单Merkle树的创新性改进,专门针对以太坊智能合约平台的复杂需求而打造。我最早接触这个概念是在2017年开发DApp时遇到的存储查询性能…

📅 2026/11/2 5:33:09
多目标优化中的决策变量关系建模与算法设计

多目标优化中的决策变量关系建模与算法设计

1. 多目标优化问题概述在工程设计和科学计算领域,我们经常面临需要同时优化多个相互冲突的目标函数的情况。这类问题被称为多目标优化问题(Multi-objective Optimization Problem, MOP)。与单目标优化不同,多目标优化的解通常不是一个单一的最优解&#…

📅 2026/11/2 5:28:09
面向对象编程:继承与组合的本质区别与应用

面向对象编程:继承与组合的本质区别与应用

1. 继承与组合的本质区别在面向对象编程中,继承(Inheritance)和组合(Composition)是两种最基础的代码复用机制。理解它们的本质区别是掌握面向对象设计的关键。继承建立的是"is-a"关系,即子类是父类的一种特例。例如,Dog继承自Anim…

📅 2026/11/2 5:28:09
MORE NEWS

更多资讯

📰

你的迭代器为何总在“第二次”失灵?——Python iter() 与 next() 的协议真相与完全驾驭手册

你的迭代器为何总在“第二次”失灵?——Python iter() 与 next() 的协议真相与完全驾驭手册 在 Python 中,for 循环几乎无处不在,但支撑它运转的底层机制——iter() 和 next()——却常常被开发者忽略。你可能已经习惯写 for x in data:&#…

📰

【八个月网安课程】第十三周·周五:命令拼接符号、管道符与长度限制绕过

在掌握了命令执行函数后,今天将聚焦于如何突破命令字符串本身的限制:当分隔符被过滤时,如何用其他符号替代;当命令长度受限时,如何通过精巧的构造在极短空间内实现完整攻击。这是命令注入从理论走向实战的关键一步&…

📰

基于 LSTM 的车载 CAN 总线入侵检测:从 125 万条真实数据的“毒打”到工程化落地

一、项目背景与真实数据的挑战 智能网联汽车里,CAN 总线是连接各个 ECU 的核心网络。不过 CAN 总线在设计之初极度缺乏安全认证与加密机制,很容易遭到黑客的 DoS、重放、模糊测试等攻击。 这个项目主要是利用深度学习(LSTM)的时序…

📰

为什么电池测试结果会出现噪声和波动?到底是电池问题还是设备问题?

现场最让设备工程师头疼的情况之一就是:“数据有点飘。”它可能表现为:电压抖动。电流波动。曲线出现尖峰。不同通道数据不一致。那么到底是什么原因?一、首先要考虑测试对象本身电池并不是理想元件。在充放电过程中,它本身就可能…

📰

Python 数据处理进阶(二十四)把脚本变成工具:argparse、logging 与定时任务

写在前面 这是第二批的最后一篇。前 23 篇把「怎么处理数据」讲完了,这一篇讲怎么让脚本被别人用起来。 写给自己用的脚本,改参数、print 调试、手动运行都没问题;但只要交给同事、或者要每周自动跑,就必须补三件事:参…

📰

猫抓 cat-catch 完整教程:5 分钟嗅探网页视频并下载 M3U8

猫抓 cat-catch 完整教程:5 分钟嗅探网页视频并下载 M3U8 【免费下载链接】cat-catch 猫抓 浏览器资源嗅探扩展 / cat-catch Browser Resource Sniffing Extension 项目地址: https://gitcode.com/GitHub_Trending/ca/cat-catch 猫抓 cat-catch 是一款开源浏…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬