StreamPDFStreamPDF
返回博客

PDF.js 从 demo 深入浅出 3: getPage 方法全貌

2026年10月27日 · StreamPDF Team

PDF.js 从 demo 深入浅出 3: getPage 方法全貌

前言

在前一篇文章中, 我们通过 await loadingTask.promise 拿到了 pdf 变量, 它是一个 PDFDocumentProxy 实例.

回到第一篇的 demo, 第 8-10 行:

const page = await pdf.getPage(1);

传入数字参数:

  • pageNumber: 页码, 第一页是 1. 值为 1

getPage 返回一个 Promise, resolve 出一个 PDFPageProxy 对象. 本篇跟随这次调用进入源码.

getPage 调用

依据 api.js 中 PDFDocumentProxy 的定义, 代码执行流程如下.

PDFDocumentProxy.getPage: 一行委托

getPage(pageNumber) {
  return this._transport.getPage(pageNumber);
}

pdf 对象本身只是个门面, 真正的逻辑在 this._transport 上 — 即第二篇中创建的 WorkerTransport 实例 (task._transport). 以下全部流程都在 WorkerTransport.getPage 中.

第 1 步, 参数校验

if (
  !Number.isInteger(pageNumber) ||
  pageNumber <= 0 ||
  pageNumber > this.pagesMapper.pagesNumber
) {
  return Promise.reject(new Error("Invalid page request."));
}

代入 demo 的值:

  • Number.isInteger(1) 为 true
  • 1 > 0
  • this.pagesMapper.pagesNumber 为 756 — 文档就绪时由 pdfInfo.numPages 写入. 本文的 demo PDF 共 756 页, 1 <= 756

校验通过. 若传入 0, 负数, 小数或超出页数, 这里直接返回 rejected promise.

Q: 为什么用 Promise.reject 而不是 throw? A: 保持异步语义一致. getPage 的调用方总是 await 一个 promise, 参数错误也走同一条错误通道, 调用方不需要同时处理同步 throw 和异步 reject 两种情况.

第 2 步, 页码换算

const pageIndex = pageNumber - 1;                        // 1 - 1 = 0
const newPageIndex = this.pagesMapper.getPageId(pageNumber) - 1; // 1 - 1 = 0

这里出现了两套编号:

  • pageNumber: 用户视角的页码, 1-based, 符合直觉
  • pageIndex: 内部视角的页码, 0-based, 用作数组下标和缓存 key

pagesMapper.getPageId(1) 返回 1. PagesMapper 是第 2 篇参数解析时 new PagesMapper() 创建的, 它维护「用户看到的页码」与「原始文档页 ID」之间的映射. demo 没有对页面做过移动/删除/复制, 映射是恒等的 (#pageNumberToId 为 null, getPageId 原样返回入参), 因此 newPageIndex === pageIndex === 0.

Q: 为什么需要 PagesMapper 绕一圈? A: 为了支持页面重排. 在编辑器场景下, 用户可以移动, 删除, 复制页面, 此后「第 3 页」未必还是原始文档的第 3 页. worker 端只认原始文档的页序, 因此发送给 worker 的是 newPageIndex; 而主线程缓存以用户视角的 pageIndex 为 key. 恒等映射下两者相同, 可以略过不计.

第 3 步, 查询缓存

const cachedPromise = this.#pagePromises.get(0);
if (cachedPromise) {
  return cachedPromise;
}

#pagePromises 是 Map<pageIndex, Promise<PDFPageProxy>>. 首次调用未命中, 继续往下.

Q: 缓存的效果是什么? A: 实测连续两次 await pdf.getPage(1), 返回的是同一个 PDFPageProxy 对象 (page === page2 为 true). 因为第二次调用直接命中缓存的 promise, 不会再向 worker 发消息.

第 4 步, 向 worker 发送 GetPage 消息

const promise = this.messageHandler
  .sendWithPromise("GetPage", {
    pageIndex: 0, // newPageIndex
  })
  .then(pageInfo => { ... });
this.#pagePromises.set(0, promise);
return promise;

messageHandler 是第 2 篇创建的 MessageHandler, sendWithPromise 的通讯机制另文展开. 这里只需知道: 它把消息发给 worker 线程, 并返回一个 promise, 等待 worker 的回执.

worker 端收到 GetPage 后 (core/worker.js):

handler.on("GetPage", async function ({ pageIndex }) {
  const page = await pdfManager.getPage(pageIndex);
  const [rotate, ref, userUnit, view] = await Promise.all([
    pdfManager.ensure(page, "rotate"),
    pdfManager.ensure(page, "ref"),
    pdfManager.ensure(page, "userUnit"),
    pdfManager.ensure(page, "view"),
  ]);
  return {
    rotate,
    ref,
    refStr: ref?.toString() ?? null,
    userUnit,
    view,
  };
});

worker 内部如何解析页面对象, 另文展开. demo 第一页的回执为:

pageInfo = {
  rotate: 0,
  ref: { num: 4, gen: 0 }, // 跨线程结构化克隆后, ref 退化为普通对象
  refStr: "4 0 R",
  userUnit: 1,
  view: [0, 0, 595, 842],
}

Q: 为什么只取这 4 个属性? A: 它们全是页面的元数据: 旋转角度, 对象引用, 单位换算因子, 可见区域. 读取它们只需解析页面对象字典, 不碰页面内容流. 真正的内容解析 (绘制指令) 被推迟到 render 时的 GetOperatorList 消息. 这是 getPage 能快速返回的原因.

这 4 个属性随后一一对应 PDFPageProxy 上的 getter: rotate, ref, userUnit, view. 下一篇的 getViewport 会用到其中两个.

第 5 步, 创建 PDFPageProxy

回执到达后, 进入 .then 回调:

.then(pageInfo => {
  if (this.destroyed) {
    throw new Error("Transport destroyed");
  }
  if (pageInfo.refStr) {
    this.#pageRefCache.set("4 0 R", 0);
  }
  const page = new PDFPageProxy(0, pageInfo, this, this.pagesMapper, false);
  this.#pageCache.set(0, page);
  return page;
});

依次做了三件事:

  1. destroyed 检查: 等待回执期间文档可能已被销毁 (loadingTask.destroy()), 是则抛错;
  2. #pageRefCache 反向缓存: "4 0 R" → 0. 供 getPageIndex(ref) 反查 — 例如大纲跳转时, PDF 内部用对象引用 (4 0 R) 定位页面, 需要反查出页码;
  3. 创建 PDFPageProxy 并写入 #pageCache.

PDFPageProxy 的构造函数只做字段赋值, 没有任何解析:

this._pageIndex = 0;
this._pageInfo = pageInfo;
this._transport = transport;
this._stats = null; // pdfBug 为 false, 不启用性能统计
this.commonObjs = transport.commonObjs; // 全文档共享的资源容器 (字体等)
this.objs = new PDFObjects(); // 本页私有的资源容器
this._intentStates = new Map(); // 渲染意图状态缓存, 第 5 篇 render 会用到
this.destroyed = false;

commonObjs 与 objs 是后续渲染时的资源容器, PDFObjects 另文展开.

至此, getPage(1) 的 promise resolve 出 page, demo 中 await 拿到它.

总结

本节跟随 demo 走完了 getPage(1) 的完整调用:

  1. PDFDocumentProxy.getPage 一行委托给 WorkerTransport.getPage;
  2. 参数校验: 1 是合法页码 (总页数 756);
  3. 页码换算: pageNumber(1) → pageIndex(0) → newPageIndex(0), 恒等映射下三者一致;
  4. 查询 #pagePromises 缓存, 首次未命中;
  5. 向 worker 发送 GetPage 消息, 收到 4 个页面元数据;
  6. 写入 #pageRefCache, 创建 PDFPageProxy, 写入 #pageCache 与 #pagePromises;
  7. 返回 promise, demo await 得到 page.

过程中刻意略过了两个「重角色」: MessageHandler.sendWithPromise 的跨线程通讯, 以及 worker 端 pdfManager.getPage 的页面解析. 它们各自独立成文.

拿到 page 之后, demo 的下一步是 page.getViewport({ scale: 0.5 }) — 它会用到本篇 pageInfo 里的 view 和 userUnit. 下一篇展开.

相关文章