StreamPDFStreamPDF
返回博客

PDF.js 深入浅出 1: PDF.js 的架构设计

2026年10月16日 · StreamPDF Team

PDF.js 深入浅出 1: PDF.js 的架构设计

前言

这是 PDF.js 深入浅出 系列的第一篇,介绍 PDF.js 的架构设计.

本系列分析基于 PDF.js v6.3.289 源码.

什么是 PDF

Portable Document Format,缩写 PDF,是一种用独立于应用程序、硬件、操作系统的方式呈现文档的文件格式。每个 PDF 文件包含固定布局的平面文档的完整描述,包括文本、字形、图形及其他需要显示的信息。PDF 文件除平面的文本和图形外,还可能包括逻辑结构元素,注释、表单、图层、富媒体(包括视频内容)之类的交互元素,使用 U3D 或 PRC 的三维对象,以及多种其他数据内容。PDF 规范还提供了启用这些功能时所需的加密功能、数字签名、文档附件和元数据等。

PDF 源自 1991 年 Adobe Systems 共同创始人约翰·沃诺克发起的“Camelot 项目”。2008 年成为 ISO 标准的一部分,编号 ISO 32000,最新的版本 ISO 32000-2:2020 在 2020 年 12 月发布。

来自 Wikipedia:https://zh.wikipedia.org/wiki/PDF

这是一种文件格式,不依赖任何具体的运行时实现,可以在任何环境下保持稳定的显示。当然,实际上是解析器和渲染器在抹平不同环境之间的差异——就好像 Java 这样的高级语言,同一份代码可以在任何环境执行,由 Java 运行时负责把代码转译到对应的平台上。

这也是它和 Word、HTML 等文件格式最大的区别:这些格式在不同环境下会有不同的展示,尤其是 HTML,它本身就被设计为在不同环境下提供不同的体验。

什么是 PDF.js

一个基于 Web 标准构建的通用型 PDF 解析与渲染平台。

它负责解析并执行 PDF 文件中的指令,然后在 Web 中将结果渲染出来。

本系列文章会深入 PDF.js 内部, 并且深入 PDF 规范 ISO 32000 的内部. 让你读完之后, 能对 PDF 文件格式有广度+深度的理解.

PDF.js 的分层理念

PDF.js 把整个框架分为三层:

  1. 核心层(Core):解析并解释二进制 PDF 文件,是上面两层的基础。这一层的 API 不受版本号管控,可能随时改变。
  2. 展示层(Display API):构建在核心层之上,提供更易用的 API,负责渲染 PDF 页面以及从文档中读取各类信息。版本号基于这一层制定。
  3. Viewer 层:构建在展示层之上,是 Firefox 浏览器内置的默认 PDF 查看器界面。

我们从一段 demo 代码来看执行链路是如何运作的——这段代码本身实际上就是一个自定义的 Viewer 层。

首先引入 PDF.js 的资源:

import * as pdfjsLib from 'https://cdn.jsdelivr.net/npm/pdfjs-dist@6.3.289/build/pdf.min.mjs';
pdfjsLib.GlobalWorkerOptions.workerSrc =
  'https://cdn.jsdelivr.net/npm/pdfjs-dist@6.3.289/build/pdf.worker.min.mjs';

然后调用暴露出来的入口方法,传入一个文件的访问路径:

const loadingTask = pdfjsLib.getDocument({
  url: 'https://assets.streampdf.com/PDF32000_2008.pdf',
});

先停一下,不急着渲染。我们来看 getDocument 的调用链——它属于展示层 API:

  1. 校验参数
  2. 创建 Worker
  3. 建立 Worker 线程与主线程(main)之间的通信链路
  4. 发起请求获取文件数据,随后通过主线程与 Worker 之间的通信链路发送 Ready 事件
  5. Worker 收到 Ready 事件后,会创建 pdfManager 对象;该对象属于核心层,提供 startxref、xref 表等 PDF 核心数据的访问能力
  6. 返回 task 的 Promise

由于这是一个由 Promise 驱动的异步方法,我们一般要等它完成后再继续:

const pdf = await loadingTask.promise;

当这个 Promise resolve 时,意味着数据已经准备就绪,可以开始渲染了。

// 获取第一页的渲染数据
const page = await pdf.getPage(1);
// 获取第一页的视口数据
const viewport = page.getViewport({ scale: 0.5 });
// 拿到 canvas,我们将渲染到这个 canvas 上面
const canvas = document.getElementById('canvas-id');
const context = canvas.getContext('2d');
 
// 将 canvas 的尺寸匹配上 PDF 的视口大小
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
canvas.style.width = Math.floor(viewport.width) + 'px';
canvas.style.height = Math.floor(viewport.height) + 'px';
 
// 准备渲染参数
const renderContext = {
  canvasContext: context,
  viewport,
};
 
// render 返回一个 RenderTask(Promise),await 以等待实际渲染完成
await page.render(renderContext);

上面的 getPage、getViewport、render 都是展示层暴露出来的方法,它们在底层会调用核心层的代码。

注意:这里不会深入这些方法的内部实现,那是本系列下一篇文章的主题。

在线 demo:https://streampdf.com/blog/demos/a-simple-pdf-render-demo.html

Q: 为什么要分层设计?

A: 一句话:把变化关在局部。PDF 规范体量巨大、解析实现注定长期演进,而对外提供给使用者的 API 又必须长期稳定;分层就是在这两者中间放一道「稳定契约」——上层只依赖展示层暴露的少数几个方法,下面无论怎么重构、优化甚至重写,外部都不受影响。

具体到 PDF.js,分层换来了四件事

一是契约与版本:核心层的 API 不受版本号管控、可以随时改变,版本号只在展示层制定——不稳定的实现被关在最底层,而使用者依赖的 getDocument / getPage / render 都在展示层,接口长期稳定。

二是可替换:每一层都能独立替换,Viewer 层可以从 Firefox 内置查看器换成本文 demo 那种自己写的调用代码,Core 层可以从 Worker 换到 fake worker(退回主线程)乃至 node 环境,而展示层的写法完全不变。

三是性能与隔离:核心层跑在 Worker 线程里,解析这种重 CPU 的工作不会阻塞主线程 UI;分层把这个线程边界显式化——层与层之间只通过 MessageHandler 传消息,接口干净、边界清晰。

四是可测试:不用 DOM、不用 canvas,就能把核心层单独拿出来测试。

就像文中提到的 Java 类比:Java 用运行时抹平了平台差异,PDF.js 用分层抹平了「规范的复杂度」——使用者不需要了解 ISO 32000 的细节,也能把 PDF 解析并渲染出来。

架构图

PDF.js 渲染架构图

图中的每一行对应一次公开 API 调用:Viewer 列是我们自己编写的调用代码,Display 列是 PDFDocumentProxy、PDFPageProxy 等展示层对象,Core 列运行在 Worker 线程中。横向的双向箭头表示两层之间通过 MessageHandler 进行的消息往返,纵向箭头表示调用顺序。getViewport 是纯展示层的尺寸计算,不需要访问 Worker,因此图中没有对应的 Core 列;而 render 需要核心层产出操作符列表(GetOperatorList),所以三列齐全。

总结

PDF.js 让在 Web 上渲染 PDF 成为可能,并成为这一领域的通用工具。深入研究 PDF.js,能让我们对 PDF 规范有更透彻的理解——从实现去读规范,而不是直接啃枯燥的规范文档。

它独特的三层设计让开发者可以灵活替换每一层,还支持流式请求、range 请求等性能优化手段。我在创立 StreamPDF 项目时从 PDF.js 中学到了很多,也感谢所有参与过该项目的贡献者。

系列预告:下一篇文章,我们将深入 getDocument、getPage 方法,去看看核心层到底发生了什么,那将是一次非常有魅力的思维逻辑挑战。让我们下一篇文章见。

PDF.js 深入浅出 2: getDocument & getPage

相关文章