简介:面向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 src="x.pdf">,三行代码搞定,浏览器自带内核什么都能显示。但一嵌进业务系统问题就来了:工具栏是浏览器的,改不了;想让用户直接跳到第 87 页,只能让他自己翻;批注、水印、下载拦截这些全插不进去;文件跨域还得服务端配合配 header。这条路只适合内部临时看一眼。
第二条是服务端转图,后端把每页转成 PNG 或 WebP,前端就是个图片列表,兼容性最好,老安卓也不挑。代价是首屏要等转码,30MB 的文件转完几十秒;文字选不中、搜不了;放大还是糊。适合「只读且要防下载」的场景。
第三条就是这份源码走的路:前端渲染内核解析 PDF,按需取页画到 Canvas,再叠一层透明的文字层,选中和搜索就有了,批注在最上面再加一层。
| 方案 | 首屏速度 | 可选中/搜索 | 批注扩展 | 前端包体 | 典型场景 |
|---|---|---|---|---|---|
| iframe 原生预览 | 快 | 用原生能力 | 几乎插不进去 | 0 | 内部临时查看 |
| 服务端转图片 | 慢(等转码) | 不支持 | 要自己叠层 | 小 | 只读、防下载 |
| 前端渲染内核 + Canvas | 中(可做到 1s 内) | 支持 | 天然支持 | 300KB~1MB | 业务系统内嵌 |
真正的分水岭在「文字层」。只有内核知道每个字符在页面上的精确坐标,把它渲染成一堆绝对定位的span,浏览器的选区、Ctrl+F、右键复制才有意义。服务端转图这条路永远补不上这块。
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 指代 ENGINE=node_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: Promise<any> } | 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 }几个参数值得单独说一下,它们直接决定首屏体验:
| 参数 | 作用 | 不配的后果 |
|---|---|---|
cMapUrl | CJK 字体映射表路径 | 中文 PDF 部分字变方块或直接空白 |
rangeChunkSize | 单次 Range 请求的字节数 | 默认 64KB,30MB 文件要发近 500 次请求 |
disableAutoFetch | 关掉后台自动预取 | 打开就开始偷偷下整包,分片白做 |
disableStream | 是否关闭流式解析 | 设成true首屏要等整页数据到齐 |
withCredentials | 是否携带 Cookie | 走鉴权的文件全 401 |
rangeChunkSize我从默认的 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 WeakMap<HTMLCanvasElement, { 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 在 dpr=3 下是 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 和 y,fontSize取矩阵的缩放分量长度得到。这几个值全部来自 viewport,所以缩放变了以后文本层必须跟着重建,不能只改 CSS。
还有一个细节:文本层里的span颜色要设成完全透明。它的作用是撑开一个能被打中的盒子,让浏览器的选区逻辑和 Ctrl+F 有东西可搜,视觉上还是 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 而不是 200,Content-Range的格式必须是bytes start-end/total,Content-Length是这一段的长度而不是文件总长。少任何一个,内核都会退回整包下载,而且它不会报错,你只会觉得「怎么这么慢」。
验证就用一行 curl,看返回码和头:
curl -sI -H "Range: bytes=0-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: Ref<HTMLElement | 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 = ref<Set<number>>(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. 避坑与排查:五个让阅读器白屏、错位、
本文还有配套的精品资源,点击获取