背景
在 PDF 预览的场景中,影响用户体验主要有两个因素,一个是首屏渲染速度 ,另一个是快速滚动的流畅度,首屏渲染速度是指用户打开 PDF 文件后,首次呈现页面的速度,这直接关系到用户等待时间,影响着用户对产品的初步印象。而快速滚动的流畅度则是指在用户快速滑动页面时,能否实现无卡顿、无延迟的浏览体验。这对于用户阅读大型文档或者快速浏览内容至关重要。
因此,在设计 PDF 渲染方案时,需要综合考虑这两个关键因素,通过技术手段和优化策略,提升用户的使用感受,实现更高效、更流畅的PDF阅读体验。
解锁 PDF 结构
PDF 包含那些内容?
PDF 文档作为一种通用的电子文档格式,可以包含丰富多样的内容,以满足不同用户的需求。一篇PDF文档可能包含以下内容:
- 文本内容:PDF 文档中的主要内容通常以文本形式存在,包括段落、标题、列表、表格等。这些文本内容可以包含各种语言、格式和样式,如字体、字号、颜色等。
- 矢量图形:PDF 文档支持矢量图形,包括直线、曲线、形状等。这些矢量图形可以通过数学公式描述,具有无损放大缩小的特性,保证图形的清晰度和质量。
- 字体信息:PDF 文档中使用的字体信息通常会被嵌入到文档中,以确保文本的正确显示。这些字体信息包括字体名称、字形、字号、字形变换等,确保文档在不同设备上的一致性显示。
- 元数据:PDF 文档可以包含元数据,如作者、标题、主题、关键词等信息,用于描述文档的属性和内容特征,方便用户和系统对文档进行管理和检索。
- 色彩空间:PDF 可以使用与特定电子或打印设备(灰度,RGB,CMYK)相关的色彩空间以及与人类色彩感知相关的色彩空间,如果简单的PDF程序(如屏幕上的查看器)不支持更高级的颜色空间, 则它们可以回退到基本的颜色空间。
- 嵌入式文件:PDF文档可以嵌入其他文件,如音频、视频、Flash动画等多媒体内容,丰富文档的呈现形式和交互方式,提供更丰富的阅读体验。
- ...
PDF 文件结构
一个 PDF 文档主要有以下 4 个部分:Header、Body、Xref、Trailer。
Header
对于一个不可执行的文件来说,它的存在只是为了被读取,具体的行为是由解析器来决定的,而解析器为了知道当前文件是否能够被自己处理,就需要一个特定标识,这被称为 magic number,它通常存在于文件的头部,PDF 的标识被定义为 % PDF - 版本号,其中 % PDF 是固定的,对应的字节为 0x25
0x50
0x44
0x46
。
在 Header 的下一行,通常会添加一些不可读的字节数据,这些数据是为了兼容传统文件传输程序,让它们知道当前文件是一个二进制文件。举个示例,一个包含了Adobe Acrobat二进制文件标志的PDF文件的Header部分可能会如下所示:
jsx
%PDF-1.7
%âãÏÓ
其中,%PDF-1.7 是 PDF 文件的标准 Header,而 %âãÏÓ 则是 Adobe Acrobat 的二进制文件标志,它告诉接收端程序这是一个PDF二进制文件,需要以二进制方式进行处理。
Body
在 PDF 文档中,并没有一个标识 Body 区域的特征,它只是泛指 PDF 中的页面、资源、流等数据,这些数据被抽象为一个个对象,通过这些对象我们才能将 PDF 文档解析为可读的内容。
一个标准的对象格式为:
jsx
1 0 obj % 1 为对象编号,0 为对象版本号
<< >>
endobj
Xref(交叉引用表)
Xref 是 PDF 中存储对象偏移的区域,通过它我们能够快速的访问到指定的对象,而不必处理完整的文档,这意味着解析 PDF 文档是非常快的,举例来说,如果我们想要解析 PDF 中的第一个页面,首先获取到这个页面对象在 Xref 中的索引,然后根据存储的偏移位置访问到这个对象。
Xref 的表的结构如下:
xref 关键字标示着交叉引用表的开始,下一行的 0 27,0 表示对象开始的索引,27 表示对象的个数,在表中第一个条目表示特殊条目,不指向任何对象。
Trailer
Trailer 位于文档尾部,其中的数据描述了如何读取当前文档,一个 PDF 解析器首先需要处理的就是这一部分的数据。
trailer 的数据存储在字典 <<
>>
里,其中必须存在 Size 和 Root 属性,Size 标识着当前文档的对象个数,Root 是一个间接引用,指向了当前文档的文档目录,通过文档目录能够获取到页面信息,进而找到需要解析的页面。
在 trailer 的后面,通常跟着 startxref 的数据,这里存放了交叉引用表的位置偏移。
PDF 数据类型
PDF 为了方便数据的管理,定义了一些数据类型,分别有:
-
字典:包裹在
<<
>>
中,其中的数据两两成对,一个为键一个为值,键必须是名称,值可以是任意类型。 -
数组:包裹在
[ ]
中,数组元素可以是任意类型,比如/MediaBox [0 0 612 792]
它表示了页面的媒体框,数组中的内容定义了页面的物理尺寸和位置。 -
引用其他对象:引用 PDF 中的其他对象,格式为 1 0 R,其中 1 为对象编号,0 为对象的版本号,比如说
/Resources 1 0 R
就表示这一页 PDF 引用了其他资源对象。 -
名称:以
/
开始,如/Catalog
,用于字典中的键或其他用途,名称通常映射了另一个实际的值。 -
带圆括号的字符串:如下面的
(David Mandelin)
、(D:20090317221100Z)
都是用来描述PDF 的作者和创建时间信息。 -
数字:整数和实数。
-
stream(流):用于存储 PDF 图形运算符的流,以及其他二进制数据,如图像和字体。
文档内容结构
前面介绍了 PDF 文件整体的结构,主要包括 Header、 Body、Xref、Trailer 四部分,这些信息只是用来描述和存储 PDF 文件的数据,接下来会重点讲解 PDF 文件内容的整体结构。
PDF 文件内容主要由以下几部分组成:
- 文档目录:它是 Body 对象图的根。
- 页面树:用于枚举文档中的页面,一个 PDF 文件可能会有一个或多个页面树。
- 页面:表示 PDF 文件的每一页,一个 PDF 文件必须至少包含一页,每个页面必须具有:
- resources(资源):包括字体。
- content(页面内容):其中包含了页面上要绘制的图形、文本和其他元素的描述信息。
- mediaBox(媒体框):媒体框定义了页面的物理尺寸和位置。
- parent(父级对象):通常指的是页面树。
- ....
整体的结构图如下:
下面是一个实际 PDF 文档的结构图,第一层是 PDF 每一页的页面对象,其 Parent
属性指向的是其所在的页面树,页面树的 Kids
属性表示这个页面树下所包含所有页面,页面树的 Parent
属性指向的是 PDF 文档目录对象,文档目录的 Kids
属性表示这个文档目录下所有的页面树。
PDF Parser
了解了 PDF 文档的基本格式和内容结构后,对于如何解析我们就有了一个概念,思路大概如下:
- 解析 Header,通过 magic number 判断当前文件是否是 PDF 文档。
- 从文件尾部读取,通过 startxref 记录找到交叉引用表的位置。
- 解析 trailer,并记录其中数据,比如文档目录所对应的对象。
- 解析 xref,找到文档目录对象的地址。
- 解析文档目录对象,记录其中页面树对象数据。
- 解析 xref,找到页面树对象的地址。
- 解析页面树对象,记录其中页面对象数据。
- 解析 xref,找到页面对象的地址。
- 解析页面对象,记录页面的 Content、Resuorce 等数据,最终解析这些数据并绘制出这一页 PDF。
PDF 渲染方案
全量请求 PDF
全量请求 PDF 渲染方案是一种常见的 PDF 文档渲染策略,其工作原理是当渲染 PDF 文档时,需要先将整个文档的内容一次性请求过来再进行渲染。这种方案的优缺点也很明显。
优点:
- 实现简单。
- 快速滚动效果优益,因为数据已经全部下载,快速滚动到某一页可以直接渲染。
缺点:
- 首屏时间长,对于大型PDF文档,一次性加载整个文档会导致加载时间较长,尤其是在网络环境较差的情况下,用户可能需要等待较长时间才能开始阅读。
分页请求 PDF
既然全量请求 PDF 会导致页面首屏时间慢,那是不是可以先将 PDF 进行拆分存储在服务端,前端根据视口按需请求某几页 PDF,同时利用预加载功能,在用户浏览 PDF 的同时去提前加载剩余的 PDF。
优点:
- 首屏时间快,分页请求方案只加载当前页面的内容,因此可以实现较快的加载速度,用户可以快速开始阅读。
缺点:
-
拆页后 PDF 体积在某些情况下会骤增,比如下面这个文档,拆分前体积是 48.4MB,拆分后体积是 1.3G,拆分后体积增大了 27 倍。
拆分后每一页的体积将近 10MB,主要原因是在于拆分的每一页都是可渲染的 PDF 文件,里面包含了渲染这一页 PDF 所需要的所有资源,包括字体、色彩空间等,原本这些资源都是公共资源,既拆分前这些资源只有一份,拆分后每一页 PDF 文件都需要存在一份。
-
快速滚动效果会受文档拆分效果影响,在上面这个例子中,拆分后的每一页体积将近 10MB,导致请求每一页所消耗的时间也会比较长。
使用 HTTP 范围请求 PDF
什么是HTTP范围请求?
HTTP 范围请求是一种 HTTP 协议中的特性,允许客户端请求服务器返回资源的特定部分而不是整个资源。范围请求的语法在 HTTP/1.1 规范中定义,它的核心是通过Range
和Content-Range
两个请求头字段来实现。因此范围请求的基本工作流程如下:
- 客户端向服务器发送 HTTP 请求,并在请求头中包含
Range
字段,该字段指定了客户端希望获取的资源范围。例如,Range: bytes=0-499
表示客户端此次请求的是资源的 0 到 500 字节的内容。 - 服务器收到范围请求后,根据请求头中的范围信息,只返回资源的相应部分。服务器会在响应头中包含
Content-Range
字段,指示返回的部分的范围,以及整个资源的总大小。例如,Content-Range: bytes 0-499/10000
表示服务器返回的是资源的 0 到 500 字节的内容,并且整个资源大小为 10000 字节。
对于PDF 文档而言,通过这种方式,首先可以避免因为拆页导致文件体积增大的问题,由于范围请求只请求所需的部分内容,不需要将整个文档拆分成单独的页或块,因此可以保持文档的原始大小,避免不必要的网络传输和资源消耗。其次也能够在首屏渲染上取得优益的效果,因为首屏渲染时,只需要通过范围请求获取视口范围内的数据。
使用 pdf.js 实现范围请求
虽然 HTTP 支持范围请求,但是手动实现范围请求以获取 PDF 文件的每一页及其相关资源所在的字节范围确实是一项复杂的任务。PDF 文件的内部结构非常复杂,它包含了许多不同类型的对象,例如页面内容、字体、图像等,每一个对象都有自己的字节范围。
pdf.js 是一个由 Mozilla 开发的强大 pdf 渲染工具,内部提供了丰富的 JavaScript API,可以用于控制 PDF 文档的加载、渲染、导航等各个方面,目前在 github 上已经有 45.7k 的 star。
pdf.js 为了帮助开发者解决范围请求的问题,它内置了范围请求功能,它能够自动解析 PDF 文件,并提供 API 来获取每一页的内容以及相关资源的字节范围,下面是官方的解答。
代码实现
前端实现:
jsx
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { getDocument, PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist';
function PDFRangeRequestViewer({url}: {url: string}) {
const [pdfDocument, setPdfDocument] = useState<PDFDocumentProxy | null>(null);
const [page, setPage] = useState(1);
const [pdfLoadingProgress, setPdfLoadingProgress] = useState<any>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const progressCallback = useCallback(({loaded, total}: {loaded: number, total: number}) => {
setPdfLoadingProgress({ loaded, total });
}, [pdfLoadingProgress])
useEffect(() => {
const loadTask = getDocument({
url: 'http://localhost:5000/',
disableAutoFetch: true,
disableStream: true
})
loadTask.onProgress = progressCallback;
loadTask.promise
.then((pdfDocument: PDFDocumentProxy) => {
setPdfDocument(pdfDocument);
});
}, []);
useEffect(() => {
if (!pdfDocument) return;
pdfDocument.getPage(page).then((pdfPage: PDFPageProxy) => {
const viewport = pdfPage.getViewport({ scale: 1.0 });
const canvas = canvasRef.current;
if (!canvas) return;
canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
const renderTask = pdfPage.render({
canvasContext: ctx,
viewport: viewport,
});
return renderTask.promise;
});
}, [pdfDocument, page, canvasRef])
return (
<div style={{background: '#ccc', paddingBottom: '10px'}}>
{pdfDocument && <div style={{background: "#222", color: 'white', padding: '15px 35px', marginBottom: '10px'}}>
Pages : {page} / {pdfDocument.numPages} Downloaded bytes : {pdfLoadingProgress && `${pdfLoadingProgress.loaded} / ${pdfLoadingProgress.total} (${(pdfLoadingProgress.loaded / pdfLoadingProgress.total * 100).toFixed(2)} %)`}
<div style={{ float: 'right' }}>
<button disabled={page - 1 <= 0} onClick={() => setPage(page - 1)}>Prev page</button>
<button disabled={page + 1 > pdfDocument.numPages} onClick={() => setPage(page + 1)}>Next page</button>
</div>
</div>}
<canvas style={{margin: '0 auto', display: 'block', boxShadow: '0 0 2px 1px #777'}} ref={canvasRef}></canvas>
</div>
);
}
export default PDFRangeRequestViewer;
后端实现:
jsx
const http = require('http');
const path = require('path');
const fs = require('mz/fs');
const server = http.createServer(async (req, res) => {
// 获取 range 请求头
let range = req.headers['range'];
// 获取请求的文件路径
let p = path.join(__dirname, `/demo.pdf`);
const fileState = await fs.stat(p);
// 获取文件的总字节大小
let total = fileState.size;
// 跨域响应头
res.setHeader('Access-Control-Allow-Headers', '*');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Expose-Headers', 'Accept-Ranges,Content-Range,Content-Length');
// 支持 HTTP-RANGES
res.setHeader('Accept-Ranges', 'bytes');
// 设置文件的 Content-Type
res.setHeader('Content-Type', 'application/pdf');
// 如果分段请求
if (range) {
// 获取范围请求的开始和结束位置
let [, start, end] = range.match(/(\d*)-(\d*)/);
// 处理请求头中范围参数不传的问题
start = start ? parseInt(start) : 0;
end = end ? parseInt(end) : total - 1;
// 响应客户端
res.statusCode = 206;
// 分段的总长度
res.setHeader('Content-Length', end - start + 1);
// 分段的开始位置和结束位置
res.setHeader('Content-Range', `bytes ${start}-${end}/${total}`);
// 返回文件流
fs.createReadStream(p, { start, end }).pipe(res);
} else {
// 没有 range 请求头时将整个文件内容返回给客户端
res.statusCode = 200;
res.setHeader('Content-Length', Number(total));
fs.createReadStream(p).pipe(res);
}
});
// 监听端口
server.listen(5000, () => {
console.log('server start 5000');
});
效果展示
从效果上看,首次渲染时,pdf.js 会通过范围请求去获取视口范围内需要渲染的数据,当用户点击下一页时,pdf.js 会判断当前这一页的数据是否已经存在,如果存在就直接渲染,如果不存在,它会继续通过范围请求获取当前页渲染所需要的数据。
通过范围请求已经能够满足文章开头提出的两个目标,但是这还存在一个问题,pdf.js 实现按需加载需要保证 PDF 的内容结构是没有被破坏的,比如 pdf.js 在渲染之前需要保证 Xref 表是正确的没有被破坏,其次需要确认文档目录中 /Count
(既文档页数)的值是否正确,下面是作者给的解释(相关 issue 有14570、16411、17128):
pdf.js 为了在渲染之前校验文档是否被破坏,他们采取了一些检查策略,比如假定文档目录中 /Count
值是正确的,看能否正确获取到最后一页,虽然这种校验策略并不能100% 确保文档没有被破坏,但是目前作者也并没有更好的方案来解决,下面是作者列出的一些负面影响:
- 这将减慢所有文档的初始加载/渲染速度,至少会减慢一定程度,因为现在需要获取/解析更多 /Pages-tree 以便能够访问 PDF 文档的最后一页。
- 对于生成质量较差的 PDF 文档,比如整个 /Pages-tree 只有一层,pdf.js 需要获取/解析整个 /Pages-tree 才能到达最后一页。虽然有缓存可以帮助减少重复的数据查找,但这会影响一些长 PDF 文档的初始加载/渲染。
- 这将对
disableAutoFetch = true
模式产生负面影响,因为现在需要在文档初始化期间获取/解析更多数据。
下面是一个只有一个页面树的 PDF 文档,那么此时 pdf.js 在首屏加载时会将整个文档下载下来再进行渲染,就会出现下面这个效果:
在这个例子中的 PDF 文档体积为 49MB,总共 855 页,所有的页面都在同一个页面树下。
总结
这篇文章主要探讨了渲染 PDF 文件的几个方案。首先,文章解析了 PDF 文档的基本格式和内容结构,包括 Header、Body、Xref、Trailer 四部分。然后,文章介绍了三种 PDF 文档的渲染策略:全量请求 PDF,分页请求 PDF 和使用HTTP范围请求 PDF。全量请求 PDF 的优点是实现简单,一次性将文档请求下载,快速滚动效果优益,缺点是首屏时间长。分页请求 PDF 的优点是首屏时间快,缺点是对于一些特殊文档拆页后 PDF 体积会骤增。使用HTTP范围请求 PDF 可以避免因为拆页导致文件体积增大的问题,实现更快的首屏渲染。最后,文章给出了如何使用 pdf.js 实现范围请求的代码示例和效果展示。需要注意的是,pdf.js 实现按需加载需要保证 PDF 的内容结构是没有被破坏的。