PDF 渲染方案,你知道多少?

背景

在 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。

对于一个不可执行的文件来说,它的存在只是为了被读取,具体的行为是由解析器来决定的,而解析器为了知道当前文件是否能够被自己处理,就需要一个特定标识,这被称为 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 规范中定义,它的核心是通过RangeContent-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 有145701641117128):

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 的内容结构是没有被破坏的。

相关推荐
王解1 小时前
webpack loader全解析,从入门到精通(10)
前端·webpack·node.js
老码沉思录1 小时前
写给初学者的React Native 全栈开发实战班
javascript·react native·react.js
老码沉思录1 小时前
React Native 全栈开发实战班 - 第四部分:用户界面进阶之动画效果实现
react native·react.js·ui
我不当帕鲁谁当帕鲁1 小时前
arcgis for js实现FeatureLayer图层弹窗展示所有field字段
前端·javascript·arcgis
那一抹阳光多灿烂1 小时前
工程化实战内功修炼测试题
前端·javascript
放逐者-保持本心,方可放逐2 小时前
微信小程序=》基础=》常见问题=》性能总结
前端·微信小程序·小程序·前端框架
毋若成4 小时前
前端三大组件之CSS,三大选择器,游戏网页仿写
前端·css
红中马喽4 小时前
JS学习日记(webAPI—DOM)
开发语言·前端·javascript·笔记·vscode·学习
Black蜡笔小新5 小时前
网页直播/点播播放器EasyPlayer.js播放器OffscreenCanvas这个特性是否需要特殊的环境和硬件支持
前端·javascript·html
秦jh_6 小时前
【Linux】多线程(概念,控制)
linux·运维·前端