StreamPDFStreamPDF
返回博客

PDF.js 深入浅出 3: getViewport & render

2026年10月22日 · StreamPDF Team

PDF.js 深入浅出 3: getViewport & render

前言

这是 PDF.js 深入浅出系列的第三篇,在上一篇中,我们探索了 Display 层的 getDocument 和 getPage 方法.

本篇中, 我们深入探索 Display 层暴露出来的 getViewport 和 render 方法.

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

getViewport

对于 page.getViewport, 调用到 PDFPageProxy.getViewport 方法.

/**
 * @param {GetViewportParameters} params - Viewport parameters.
 * @returns {PageViewport} Contains 'width' and 'height' properties
 *   along with transforms required for rendering.
 */
getViewport({
  scale,
  rotation = this.rotate,
  offsetX = 0,
  offsetY = 0,
  dontFlip = false,
} = {}) {
  return new PageViewport({
    viewBox: this.view,
    userUnit: this.userUnit,
    scale,
    rotation,
    offsetX,
    offsetY,
    dontFlip,
  });
}

实例化 PageViewport 类.

进入 PageViewport

// Source: src/display/page_viewport.js
/**
 * @param {PageViewportParameters}
 */
constructor({
  viewBox,
  userUnit,
  scale,
  rotation,
  offsetX = 0,
  offsetY = 0,
  dontFlip = false,
}) {
  this.viewBox = viewBox;
  this.userUnit = userUnit;
  this.scale = scale;
  this.rotation = rotation;
  this.offsetX = offsetX;
  this.offsetY = offsetY;
 
  scale *= userUnit; // Take the userUnit into account.
 
  // creating transform to convert pdf coordinate system to the normal
  // canvas like coordinates taking in account scale and rotation
  const centerX = (viewBox[2] + viewBox[0]) / 2;
  const centerY = (viewBox[3] + viewBox[1]) / 2;
  let rotateA, rotateB, rotateC, rotateD;
  // Normalize the rotation, by clamping it to the [0, 360) range.
  rotation %= 360;
  if (rotation < 0) {
    rotation += 360;
  }
  switch (rotation) {
    case 180:
      rotateA = -1;
      rotateB = 0;
      rotateC = 0;
      rotateD = 1;
      break;
    case 90:
      rotateA = 0;
      rotateB = 1;
      rotateC = 1;
      rotateD = 0;
      break;
    case 270:
      rotateA = 0;
      rotateB = -1;
      rotateC = -1;
      rotateD = 0;
      break;
    case 0:
      rotateA = 1;
      rotateB = 0;
      rotateC = 0;
      rotateD = -1;
      break;
    default:
      throw new Error(
        "PageViewport: Invalid rotation, must be a multiple of 90 degrees."
      );
  }
 
  if (dontFlip) {
    rotateC = -rotateC;
    rotateD = -rotateD;
  }
 
  let offsetCanvasX, offsetCanvasY;
  let width, height;
  if (rotateA === 0) {
    offsetCanvasX = Math.abs(centerY - viewBox[1]) * scale + offsetX;
    offsetCanvasY = Math.abs(centerX - viewBox[0]) * scale + offsetY;
    width = (viewBox[3] - viewBox[1]) * scale;
    height = (viewBox[2] - viewBox[0]) * scale;
  } else {
    offsetCanvasX = Math.abs(centerX - viewBox[0]) * scale + offsetX;
    offsetCanvasY = Math.abs(centerY - viewBox[1]) * scale + offsetY;
    width = (viewBox[2] - viewBox[0]) * scale;
    height = (viewBox[3] - viewBox[1]) * scale;
  }
  // creating transform for the following operations:
  // translate(-centerX, -centerY), rotate and flip vertically,
  // scale, and translate(offsetCanvasX, offsetCanvasY)
  this.transform = [
    rotateA * scale,
    rotateB * scale,
    rotateC * scale,
    rotateD * scale,
    offsetCanvasX - rotateA * scale * centerX - rotateC * scale * centerY,
    offsetCanvasY - rotateB * scale * centerX - rotateD * scale * centerY,
  ];
 
  this.width = width;
  this.height = height;
}

计算, 旋转度, 缩放, 偏移量, 是否翻转后的视口, 页面宽高.

着重说一下 PDF 的点和 CSS 的 PX 单位. 在 PDF 中, 72 点等于一英寸, 在 CSS 中, 96PX 等于一英寸, 也就是在 web 中的 100% 宽高需要将 PDF 的点放大 96/72 倍.

PDF 的坐标系是左下角为原点, 向右为 X 轴, 向上为 Y 轴. 而 CSS 的坐标系是左上角为原点, 向右为 X 轴, 向下为 Y 轴. 所以在 PDF 不旋转的情况下, 我们需要将 PDF 的 Y 轴坐标翻转以符合 web canvas 的坐标系. 所以你会看到

case 0:
  rotateA = 1;
  rotateB = 0;
  rotateC = 0;
  rotateD = -1;

中的 rotateD 是 -1, 就是为了翻转 Y 轴.

flip demo: get-viewport-demo

MDN canvas transform

render

对于 page.render, 调用到 PDFPageProxy.render 方法.

/**
 * Begins the process of rendering a page to the desired context.
 *
 * @param {RenderParameters} params - Page render parameters.
 * @returns {RenderTask} An object that contains a promise that is
 *   resolved when the page finishes rendering.
 */
render({
  canvasContext,
  canvas = canvasContext.canvas,
  viewport,
  intent = "display",
  annotationMode = AnnotationMode.ENABLE,
  transform = null,
  background = null,
  optionalContentConfigPromise = null,
  annotationCanvasMap = null,
  pageColors = null,
  printAnnotationStorage = null,
  isEditing = false,
  recordImages = false,
  recordOperations = false,
  operationsFilter = null,
}) {
  this._stats?.time("Overall");
 
  const intentArgs = this._transport.getRenderingIntent(
    intent,
    annotationMode,
    printAnnotationStorage,
    isEditing
  );
  const { renderingIntent, cacheKey } = intentArgs;
  // If there was a pending destroy, cancel it so no cleanup happens during
  // this call to render.
  this.#pendingCleanup = false;
 
  optionalContentConfigPromise ||=
    this._transport.getOptionalContentConfig(renderingIntent);
 
  const intentState = this._intentStates.getOrInsertComputed(
    cacheKey,
    makeObj
  );
  // Ensure that a pending `streamReader` cancel timeout is always aborted.
  if (intentState.streamReaderCancelTimeout) {
    clearTimeout(intentState.streamReaderCancelTimeout);
    intentState.streamReaderCancelTimeout = null;
  }
 
  const intentPrint = !!(renderingIntent & RenderingIntentFlag.PRINT);
 
  // If there's no displayReadyCapability yet, then the operatorList
  // was never requested before. Make the request and create the promise.
  if (!intentState.displayReadyCapability) {
    intentState.displayReadyCapability = Promise.withResolvers();
    intentState.operatorList = {
      fnArray: [],
      argsArray: [],
      lastChunk: false,
      separateAnnots: null,
    };
 
    this._stats?.time("Page Request");
    this._pumpOperatorList(intentArgs);
  }
 
  const recordForDebugger = !!(
    this._pdfBug && globalThis.StepperManager?.enabled
  );
  const shouldRecordOperations =
    !!canvas &&
    !this.recordedBBoxes &&
    (recordOperations || recordForDebugger);
  const shouldRecordImages =
    !!canvas && !this.imageCoordinates && recordImages;
 
  const complete = error => {
    intentState.renderTasks.delete(internalRenderTask);
 
    if (shouldRecordOperations) {
      const recordedBBoxes = internalRenderTask.gfx?.dependencyTracker.take();
      if (recordedBBoxes) {
        internalRenderTask.stepper?.setOperatorBBoxes(
          recordedBBoxes,
          internalRenderTask.gfx.dependencyTracker.takeDebugMetadata()
        );
 
        if (recordOperations) {
          this.recordedBBoxes = recordedBBoxes;
        }
      }
    }
 
    if (shouldRecordImages && !error) {
      this.imageCoordinates = internalRenderTask.gfx?.imagesTracker.take();
    }
 
    // Attempt to reduce memory usage during *printing*, by always running
    // cleanup immediately once rendering has finished.
    if (intentPrint) {
      this.#pendingCleanup = true;
    }
    this.#tryCleanup();
 
    if (error) {
      internalRenderTask.capability.reject(error);
 
      this._abortOperatorList({
        intentState,
        reason: error instanceof Error ? error : new Error(error),
      });
    } else {
      internalRenderTask.capability.resolve();
    }
 
    if (this._stats) {
      this._stats.timeEnd("Rendering");
      this._stats.timeEnd("Overall");
 
      if (globalThis.Stats?.enabled) {
        globalThis.Stats.add(this.pageNumber, this._stats);
      }
    }
  };
 
  let dependencyTracker = null;
  let bboxTracker = null;
  if (shouldRecordOperations || shouldRecordImages) {
    bboxTracker = new CanvasBBoxTracker(
      canvas,
      intentState.operatorList.length
    );
  }
  if (shouldRecordOperations) {
    dependencyTracker = new CanvasDependencyTracker(
      bboxTracker,
      recordForDebugger
    );
  }
 
  const internalRenderTask = new InternalRenderTask({
    callback: complete,
    // Only include the required properties, and *not* the entire object.
    params: {
      canvas,
      canvasContext,
      dependencyTracker: dependencyTracker ?? bboxTracker,
      imagesTracker: shouldRecordImages
        ? new CanvasImagesTracker(canvas)
        : null,
      viewport,
      transform,
      background,
    },
    objs: this.objs,
    commonObjs: this.commonObjs,
    annotationCanvasMap,
    operatorList: intentState.operatorList,
    pageIndex: this._pageIndex,
    canvasFactory: this._transport.canvasFactory,
    filterFactory: this._transport.filterFactory,
    useRequestAnimationFrame: !intentPrint,
    pdfBug: this._pdfBug,
    pageColors,
    enableHWA: this._transport.enableHWA,
    operationsFilter,
  });
 
  (intentState.renderTasks ||= new Set()).add(internalRenderTask);
  const renderTask = internalRenderTask.task;
 
  Promise.all([
    intentState.displayReadyCapability.promise,
    optionalContentConfigPromise,
  ])
    .then(([transparency, optionalContentConfig]) => {
      if (this.destroyed) {
        complete();
        return;
      }
      this._stats?.time("Rendering");
 
      if (!(optionalContentConfig.renderingIntent & renderingIntent)) {
        throw new Error(
          "Must use the same `intent`-argument when calling the `PDFPageProxy.render` " +
            "and `PDFDocumentProxy.getOptionalContentConfig` methods."
        );
      }
      internalRenderTask.initializeGraphics({
        transparency,
        optionalContentConfig,
      });
      internalRenderTask.operatorListChanged();
    })
    .catch(complete);
 
  return renderTask;
}

这是一个非常复杂的方法, 让我们分块进行分析

第一步: 调用 getRenderingIntent 方法, 获取渲染意图. 有 any, display, print 三种意图.

第二步: 调用 this._intentStates.getOrInsertComputed 方法, 基于第一步的渲染意图, 获取或插入计算后的渲染状态.

第三步: 获取 Page 的渲染操作列表.

// If there's no displayReadyCapability yet, then the operatorList
// was never requested before. Make the request and create the promise.
if (!intentState.displayReadyCapability) {
  intentState.displayReadyCapability = Promise.withResolvers();
  intentState.operatorList = {
    fnArray: [],
    argsArray: [],
    lastChunk: false,
    separateAnnots: null,
  };
 
  this._stats?.time('Page Request');
  this._pumpOperatorList(intentArgs);
}

这里会有一层缓存, 判断 intentState.displayReadyCapability 是否存在. 如果不存在, 则创建一个 Promise, 并请求 Page 的渲染操作列表.

Q: intentState.displayReadyCapability 是什么? 为什么等这个 Promise resolve 之后才能向 canvas 上真正渲染内容?

A: 它是 Promise.withResolvers() 返回的 { promise, resolve, reject } ,其 promise 解析出布尔 transparency (该页是否含 blend mode,决定渲染时是否需要 alpha canvas)。

之所以必须等它 resolve 之后才能真正渲染,核心原因有二。其一,transparency 是 initializeGraphics 的必要参数——CanvasGraphics 初始化时需要知道是否启用 alpha 通道来正确处理混合模式,而这个值只有 Worker 解析完 content stream 与 resources 后才能确定。其二,它本质上是 Worker 发来的「Page 已就绪」启动信号:Worker 在执行 getOperatorList 之前发出 StartRenderPage 消息,Display 层在 _startRenderPage 中 resolve 它。在此之前 operatorList 还是空的,分块数据要等 resolve 之后才由 _renderPageChunk 陆续送达,渲染流水线必须等这个信号到达后才能开始消费 operatorList。

需要注意的是,它表示的是「Page 资源已就绪,可以开始接收渲染操作列表」,而不是「渲染操作列表已经填充完毕」——operatorList 是否收完由 intentState.operatorList.lastChunk 标记。

此外,它是否存在还兼作一层缓存标记:只要它已存在,就说明该渲染意图(cacheKey )的 operatorList 之前已经请求过,无需重复调用 _pumpOperatorList 。

第四步: 实例化 InternalRenderTask 类, 并添加到渲染任务队列中.

第五步: 等待 intentState.displayReadyCapability 解析完成. 之后, 初始化渲染视图 initializeGraphics, 并通知渲染操作列表已改变 operatorListChanged. 正式执行渲染逻辑.

有了一个大致的流程之后, 让我们来详细分析每一个步骤的执行逻辑

第一步: getRenderingIntent 获取渲染意图

进入 PDFPageProxy.render -> WorkerTransport.getRenderingIntent

// Source: src/display/api.js
getRenderingIntent(
  intent,
  annotationMode = AnnotationMode.ENABLE,
  printAnnotationStorage = null,
  isEditing = false,
  isOpList = false
) {
  let renderingIntent = RenderingIntentFlag.DISPLAY; // Default value.
  let annotationStorageSerializable = SerializableEmpty;
 
  switch (intent) {
    case "any":
      renderingIntent = RenderingIntentFlag.ANY;
      break;
    case "display":
      break;
    case "print":
      renderingIntent = RenderingIntentFlag.PRINT;
      break;
    default:
      warn(`getRenderingIntent - invalid intent: ${intent}`);
  }
 
  const annotationStorage =
    renderingIntent & RenderingIntentFlag.PRINT &&
    printAnnotationStorage instanceof PrintAnnotationStorage
      ? printAnnotationStorage
      : this.annotationStorage;
 
  switch (annotationMode) {
    case AnnotationMode.DISABLE:
      renderingIntent += RenderingIntentFlag.ANNOTATIONS_DISABLE;
      break;
    case AnnotationMode.ENABLE:
      break;
    case AnnotationMode.ENABLE_FORMS:
      renderingIntent += RenderingIntentFlag.ANNOTATIONS_FORMS;
      break;
    case AnnotationMode.ENABLE_STORAGE:
      renderingIntent += RenderingIntentFlag.ANNOTATIONS_STORAGE;
 
      annotationStorageSerializable = annotationStorage.serializable;
      break;
    default:
      warn(`getRenderingIntent - invalid annotationMode: ${annotationMode}`);
  }
 
  if (isEditing) {
    renderingIntent += RenderingIntentFlag.IS_EDITING;
  }
  if (isOpList) {
    renderingIntent += RenderingIntentFlag.OPLIST;
  }
 
  const { ids: modifiedIds, hash: modifiedIdsHash } =
    annotationStorage.modifiedIds;
 
  const cacheKeyBuf = [
    renderingIntent,
    annotationStorageSerializable.hash,
    modifiedIdsHash,
  ];
 
  return {
    renderingIntent,
    cacheKey: cacheKeyBuf.join("_"),
    annotationStorageSerializable,
    modifiedIds,
  };
}

有三种模式: any, display, print.

  • any 用于getOperatorList /getTextContent 等"任意"场景.
  • display 用来渲染页面.
  • print 用来打印页面.

以及 annotationMode 模式, 用来控制是否渲染注释. 还有 isEditing 编辑模式.

第二步: this._intentStates.getOrInsertComputed

this._intentStates 是一个 Map, 基于渲染意图的 key 缓存数据.

makeObj 是一个工具函数, const makeObj = () => Object.create(null);

getOrInsertComputed 是一个 js 规范里面比较新的函数, 如果 key 存在, 则返回对应的 value. 如果不存在, 则通过第二个参数 callback 创建一个 value 并返回, 详见 MDN.

第三步: 获取 Page 的渲染操作列表.

这里判断 intentState.displayReadyCapability 是否存在. 如果存在, 说明数据之前已经请求过, 无需重复调用 _pumpOperatorList.

否则调用 _pumpOperatorList , 请求 Page 的渲染操作列表.

进入 PDFPageProxy.render -> PDFPageProxy._pumpOperatorList

// Source: src/display/api.js
_pumpOperatorList({
  renderingIntent,
  cacheKey,
  annotationStorageSerializable,
  modifiedIds,
}) {
  if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
    assert(
      Number.isInteger(renderingIntent) && renderingIntent > 0,
      '_pumpOperatorList: Expected valid "renderingIntent" argument.'
    );
  }
  const { map, transfer } = annotationStorageSerializable;
 
  const readableStream = this._transport.messageHandler.sendWithStream(
    "GetOperatorList",
    {
      pageId: this.#pagesMapper.getPageId(this._pageIndex + 1) - 1,
      pageIndex: this._pageIndex,
      intent: renderingIntent,
      cacheKey,
      annotationStorage: map,
      modifiedIds,
    },
    /* queueingStrategy = */ undefined,
    transfer
  );
  const reader = readableStream.getReader();
 
  const intentState = this._intentStates.get(cacheKey);
  intentState.streamReader = reader;
 
  const pump = () => {
    reader.read().then(
      ({ value, done }) => {
        if (done) {
          intentState.streamReader = null;
          return;
        }
        if (this._transport.destroyed) {
          return; // Ignore any pending requests if the worker was terminated.
        }
        this._renderPageChunk(value, intentState);
        pump();
      },
      reason => {
        intentState.streamReader = null;
 
        if (this._transport.destroyed) {
          return; // Ignore any pending requests if the worker was terminated.
        }
        if (intentState.operatorList) {
          // Mark operator list as complete.
          intentState.operatorList.lastChunk = true;
 
          for (const internalRenderTask of intentState.renderTasks) {
            internalRenderTask.operatorListChanged();
          }
          this.#tryCleanup();
        }
 
        if (intentState.displayReadyCapability) {
          intentState.displayReadyCapability.reject(reason);
        } else if (intentState.opListReadCapability) {
          intentState.opListReadCapability.reject(reason);
        } else {
          throw reason;
        }
      }
    );
  };
  pump();
}

在这里通过 transport 向 Worker 发出了重要的一个流式事件, 即 GetOperatorList.

进入 PDFPageProxy.render -> PDFPageProxy._pumpOperatorList -> Worker: on:GetOperatorList

handler.on(
  'GetOperatorList',
  function ({ pageId, pageIndex, intent, cacheKey, annotationStorage, modifiedIds }, sink) {
    pdfManager.getPage(pageId).then(function (page) {
      const task = new WorkerTask(`GetOperatorList: page ${pageIndex}`);
      startWorkerTask(task);
 
      // NOTE: Keep this condition in sync with the `info` helper function.
      const start = verbosity >= VerbosityLevel.INFOS ? Date.now() : 0;
 
      // Pre compile the pdf page and fetch the fonts/images.
      page
        .getOperatorList({
          handler,
          sink,
          task,
          intent,
          cacheKey,
          annotationStorage,
          modifiedIds,
          pageIndex,
        })
        .then(
          opListInfo => {
            if (start) {
              info(`${task.name}; time=${Date.now() - start}ms, len=${opListInfo.length}`);
            }
            sink.close();
          },
          reason => {
            if (task.terminated) {
              return; // ignoring errors from the terminated thread
            }
            sink.error(reason);
          },
        )
        .finally(() => {
          finishWorkerTask(task);
        });
    });
  },
);

调用 pdfManager.getPage 获取 Page 实例, 这里的调用指向 NetworkPDFManager.getPage, 又指向了 BasePdfManager.getPage

进入 PDFPageProxy.render -> PDFPageProxy._pumpOperatorList -> Worker: on:GetOperatorList -> NetworkPDFManager.getPage Parent BasePdfManager.getPage

getPage(pageIndex) {
  return this.pdfDocument.getPage(pageIndex);
}

调用 this.pdfDocument.getPage

让我们先看一下 this.pdfDocument 是如何来的

进入 PDFPageProxy.render -> PDFPageProxy._pumpOperatorList -> Worker: on:GetOperatorList -> NetworkPDFManager.constructor

this.pdfDocument = new PDFDocument(this, this.streamManager.getStream());

实例化 PDFDocument

进入 PDFPageProxy.render -> PDFPageProxy._pumpOperatorList -> Worker: on:GetOperatorList -> NetworkPDFManager.constructor -> PDFDocument.constructor

constructor(pdfManager, stream) {
  if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
    assert(
      stream instanceof BaseStream,
      'PDFDocument: Invalid "stream" argument.'
    );
  }
  if (stream.length <= 0) {
    throw new InvalidPDFException(
      "The PDF file is empty, i.e. its size is zero bytes."
    );
  }
 
  this.pdfManager = pdfManager;
  this.stream = stream;
  this.xref = new XRef(stream, pdfManager);
 
  const idCounters = {
    font: 0,
  };
  this._globalIdFactory = class {
    static getDocId() {
      return `g_${pdfManager.docId}`;
    }
 
    static createFontId() {
      return `f${++idCounters.font}`;
    }
 
    static createObjId() {
      unreachable("Abstract method `createObjId` called.");
    }
 
    static getPageObjId() {
      unreachable("Abstract method `getPageObjId` called.");
    }
  };
}

在这里, 实例化 XRef 实例, 用于解析 PDF 文件的 XRef 表.

Q: 什么是 XRef 表?

A: XRef (Cross-Reference) 表是 PDF 文件的"对象目录". PDF 文件内部的每个实体——页面、字体、图片、内容流等——都是一个带编号的对象 (例如 3 0 obj). XRef 表记录了每一个对象在文件中的字节偏移位置.

举个例子:

xref
0 5
0000000000 65535 f
0000000010 00000 n
0000000053 00000 n
0000000102 00000 n
0000000173 00000 n

第一行 0 5 表示接下来列出从对象 0 开始的 5 个对象. 每条记录固定 20 字节: 前 10 位是字节偏移, 中间 5 位是版本号, 最后一位是 n (in-use) 或 f (free/已删除).

解析器拿到一个对象编号 (比如 2 0 R, 意思是"引用对象 2") 时, 直接查 XRef 表就能算出它在文件里的位置, 不必像 XML/JSON 那样从头扫到尾. 这也是 PDF 支持增量更新 (append-only) 的关键——每次追加修改后, 只需要追加一个新的 XRef 段, 并把文件末尾的 startxref 指针指向最新的那个即可.

在 pdf.js 里, XRef 类就是用来解析这张表、维护 {对象编号 → 偏移位置} 映射关系, 并负责解析对象的实际内容 (遇到间接引用 2 0 R 时会递归解析直到拿到真实值).

这是 StreamPDF 中的一个优化点, 由服务端前置解析 Xref 表, 这里不展开, 后面会有一篇单独的文章详细介绍.

我们目前不再深入, 继续走渲染, 后面会有深入文章探索 PDF.js 对于 PDF 文档的词法解析和语法解析.

当 PDFDocument 初始化完成之后, 让我们回到 getPage 方法中

进入 PDFPageProxy.render -> PDFPageProxy._pumpOperatorList -> Worker: on:GetOperatorList -> NetworkPDFManager.getPage -> PDFDocument.getPage

getPage(pageIndex) {
  const cachedPromise = this.#pagePromises.get(pageIndex);
  if (cachedPromise) {
    return cachedPromise;
  }
  const { catalog, linearization, xfaFactory } = this;
 
  let promise;
  if (xfaFactory) {
    promise = Promise.resolve([Dict.empty, null]);
  } else if (linearization?.pageFirst === pageIndex) {
    promise = this.#getLinearizationPage(pageIndex);
  } else {
    promise = catalog.getPageDict(pageIndex);
  }
  promise = promise.then(
    ([pageDict, ref]) =>
      new Page({
        pdfManager: this.pdfManager,
        xref: this.xref,
        pageIndex,
        pageDict,
        ref,
        globalIdFactory: this._globalIdFactory,
        fontCache: catalog.fontCache,
        builtInCMapCache: catalog.builtInCMapCache,
        standardFontDataCache: catalog.standardFontDataCache,
        globalColorSpaceCache: catalog.globalColorSpaceCache,
        globalImageCache: catalog.globalImageCache,
        systemFontCache: catalog.systemFontCache,
        nonBlendModesSet: catalog.nonBlendModesSet,
        xfaFactory,
      })
  );
 
  this.#pagePromises.set(pageIndex, promise);
  return promise;
}

做了 catalog 的获取, 线性化的判断, 以及页面的创建.

Q: 什么是 catalog? A: Catalog (目录对象) 是 PDF 文件的"根节点字典", 位于对象图的顶端. 文件尾部的 trailer 中有一个 /Root 条目指向它, 相当于整个 PDF 的入口. 它包含了指向其他所有顶层结构的引用, 最核心的是 /Pages (页面树的根节点), 还有 /Names (命名对象), /Outlines (书签), /AcroForm (表单), /Metadata (XMP 元数据) 等.

在 pdf.js 中, Catalog 类封装了这棵根字典, 提供了跨页共享的缓存——字体缓存 (fontCache), 全局颜色空间缓存 (globalColorSpaceCache), 图片缓存 (globalImageCache), 内置 CMap 缓存 (builtInCMapCache) 等. 这些缓存会在多页之间复用, 避免重复解析相同的字体定义或颜色空间, 这就是为什么上面 Page 的构造函数里会把 catalog.fontCache, catalog.globalColorSpaceCache 等一长串传进来.

Q: 什么是 线性化(linearization)? A: 线性化 (Linearization) 是 PDF 规范提供的一项"快速 Web 视图"优化.

普通 PDF 的解析流程是: 先跳到文件末尾读 startxref → 再读 trailer → 再从 trailer 找到主 XRef 段 → 才能定位对象. 这意味着你必须先拿到文件尾部的数据才能开始工作.

线性化 PDF 在文件开头 (对象 1 的位置) 额外放置了一个 Linearized 字典, 里面记录了:

  • N — 总页数
  • P — 首页的索引 (默认 0)
  • O — 首页对象的编号
  • T — 主 XRef 段在文件中的偏移
  • H — 提示数组, 告诉浏览器哪些页在文件哪个位置

解析器只需下载文件开头的几百字节, 就能通过这个字典跳过主 XRef, 直接定位到首页的数据开始渲染, 而不用等整个文件下载完. 这在大文件 (几十 MB 的扫描件) 上效果非常明显.

在 pdf.js 里, PDFDocument.linearization getter 会尝试解析文件开头, 如果发现这个 Linearized 字典就返回一个包含 numPages, pageFirst, objectNumberFirst 等字段的对象. 上面 getPage 的逻辑就是: 如果是线性化 PDF 且当前请求的是首页, 就走 #getLinearizationPage 的快速路径, 直接用 linearization.objectNumberFirst 拿到首页面字典; 否则才用标准方式去 catalog 里查. 但是线性化非常依赖生成文件的工具, 大部分的文件都没有经过线性化处理

我们已经有一些触及到 PDF 格式的技术规范了.

这里返回了 Page 的实例, 让我们保持一下克制, 先不深入.

回到 PDFPageProxy.render -> PDFPageProxy._pumpOperatorList -> Worker: on:GetOperatorList

继续执行

// Pre compile the pdf page and fetch the fonts/images.
page
  .getOperatorList({
    handler,
    sink,
    task,
    intent,
    cacheKey,
    annotationStorage,
    modifiedIds,
    pageIndex,
  })
  .then(
    opListInfo => {
      if (start) {
        info(`${task.name}; time=${Date.now() - start}ms, len=${opListInfo.length}`);
      }
      sink.close();
    },
    reason => {
      if (task.terminated) {
        return; // ignoring errors from the terminated thread
      }
      sink.error(reason);
    },
  )
  .finally(() => {
    finishWorkerTask(task);
  });

调用了 Page.getOperatorList 方法, 来获取页面的渲染操作算子.

让我们简单看一下 Page.getOperatorList 方法的实现.

进入 PDFPageProxy.render -> PDFPageProxy._pumpOperatorList -> Worker: on:GetOperatorList -> Page.getOperatorList

// Source: src/core/document.js
async getOperatorList({
  handler,
  sink,
  task,
  intent,
  cacheKey,
  pageIndex = this.pageIndex,
  annotationStorage = null,
  modifiedIds = null,
}) {
  const contentStreamPromise = this.getContentStream();
  const resourcesPromise = this.loadResources(RESOURCES_KEYS_OPERATOR_LIST);
  // ...
 
  const pageListPromise = Promise.all([
    contentStreamPromise,
    resourcesPromise,
  ]).then(async ([contentStream]) => {
    const resources = await this.#getMergedResources(
      contentStream.dict,
      RESOURCES_KEYS_OPERATOR_LIST
    );
    const opList = new OperatorList(intent, sink);
    handler.send("StartRenderPage", {
      transparency: partialEvaluator.hasBlendModes(
        resources,
        this.nonBlendModesSet
      ),
      pageIndex,
      cacheKey,
    });
 
    await partialEvaluator.getOperatorList({
      stream: contentStream,
      task,
      resources,
      operatorList: opList,
    });
    return opList;
  });
  // ...

省略了很多逻辑, 看到关键的 handler.send("StartRenderPage") 了吗, 这个事件对应着前面的 intentState.displayReadyCapability

主进程的 StartRenderPage 事件监听函数

进入 PDFPageProxy.render -> PDFPageProxy._pumpOperatorList -> Worker: on:GetOperatorList -> Page.getOperatorList -> Main: on:StartRenderPage

messageHandler.on('StartRenderPage', data => {
  if (this.destroyed) {
    return; // Ignore any pending requests if the worker was terminated.
  }
 
  const page = this.#pageCache.get(data.pageIndex);
  page._startRenderPage(data.transparency, data.cacheKey);
});

调用了 page._startRenderPage

/**
 * @private
 */
_startRenderPage(transparency, cacheKey) {
  const intentState = this._intentStates.get(cacheKey);
  if (!intentState) {
    return; // Rendering was cancelled.
  }
  this._stats?.timeEnd("Page Request");
 
  // TODO Refactor RenderPageRequest to separate rendering
  // and operator list logic
  intentState.displayReadyCapability?.resolve(transparency);
}

这个方法 resolve 了 intentState.displayReadyCapability 这个 Promise.

好了, 让我们回到 _pumpOperatorList 方法.

回到 PDFPageProxy.render -> PDFPageProxy._pumpOperatorList

继续流式数据的处理, 通过 sink, 将数据源源不断的返给 main 进程的 reader

intentState.streamReader = reader;
 
const pump = () => {
  reader.read().then(
    ({ value, done }) => {
      if (done) {
        intentState.streamReader = null;
        return;
      }
      if (this._transport.destroyed) {
        return; // Ignore any pending requests if the worker was terminated.
      }
      this._renderPageChunk(value, intentState);
      pump();
    },
    reason => {
      intentState.streamReader = null;
 
      if (this._transport.destroyed) {
        return; // Ignore any pending requests if the worker was terminated.
      }
      if (intentState.operatorList) {
        // Mark operator list as complete.
        intentState.operatorList.lastChunk = true;
 
        for (const internalRenderTask of intentState.renderTasks) {
          internalRenderTask.operatorListChanged();
        }
        this.#tryCleanup();
      }
 
      if (intentState.displayReadyCapability) {
        intentState.displayReadyCapability.reject(reason);
      } else if (intentState.opListReadCapability) {
        intentState.opListReadCapability.reject(reason);
      } else {
        throw reason;
      }
    },
  );
};
pump();

在 Worker 每一次将 chunk 推到流里面之后, 会被这里进行消费, 调用 this._renderPageChunk(value, intentState);

进入 PDFPageProxy.render -> PDFPageProxy._pumpOperatorList -> PDFPageProxy._renderPageChunk

_renderPageChunk(operatorListChunk, intentState) {
  // Add the new chunk to the current operator list.
  for (let i = 0, ii = operatorListChunk.length; i < ii; i++) {
    intentState.operatorList.fnArray.push(operatorListChunk.fnArray[i]);
    intentState.operatorList.argsArray.push(operatorListChunk.argsArray[i]);
  }
  intentState.operatorList.lastChunk = operatorListChunk.lastChunk;
  intentState.operatorList.separateAnnots = operatorListChunk.separateAnnots;
 
  // Notify all the rendering tasks there are more operators to be consumed.
  for (const internalRenderTask of intentState.renderTasks) {
    internalRenderTask.operatorListChanged();
  }
 
  if (operatorListChunk.lastChunk) {
    this.#tryCleanup();
  }
}

对数据进行预处理之后, 调用 internalRenderTask.operatorListChanged() 方法, 通知渲染任务有新的操作算子了.

第四步: 渲染任务有新的操作算子了

进入 PDFPageProxy.render -> PDFPageProxy._pumpOperatorList -> PDFPageProxy._renderPageChunk -> InternalRenderTask.operatorListChanged

operatorListChanged() {
  if (!this.graphicsReady) {
    this.graphicsReadyCallback ||= this._continueBound;
    return;
  }
  this.gfx.dependencyTracker?.growOperationsCount(
    this.operatorList.fnArray.length
  );
  this.stepper?.updateOperatorList(this.operatorList);
 
  if (this.running) {
    return;
  }
  this._continue();
}

走向 _continue

进入 PDFPageProxy.render -> PDFPageProxy._pumpOperatorList -> PDFPageProxy._renderPageChunk -> internalRenderTask.operatorListChanged -> internalRenderTask._continue

_continue() {
  this.running = true;
  if (this.cancelled) {
    return;
  }
  if (this.task.onContinue) {
    this.task.onContinue(this._scheduleNextBound);
  } else {
    this._scheduleNext();
  }
}

走向 _scheduleNext

进入 PDFPageProxy.render -> PDFPageProxy._pumpOperatorList -> PDFPageProxy._renderPageChunk -> internalRenderTask.operatorListChanged -> internalRenderTask._continue -> internalRenderTask._scheduleNext

_scheduleNext() {
  if (this._useRequestAnimationFrame) {
    this.#rAF = window.requestAnimationFrame(() => {
      this.#rAF = null;
      this._nextBound().catch(this._cancelBound);
    });
  } else {
    Promise.resolve().then(this._nextBound).catch(this._cancelBound);
  }
}

通过 window.requestAnimationFrame, 调度到 _nextBound 方法

进入 PDFPageProxy.render -> PDFPageProxy._pumpOperatorList -> PDFPageProxy._renderPageChunk -> internalRenderTask.operatorListChanged -> internalRenderTask._continue -> internalRenderTask._scheduleNext -> internalRenderTask._nextBound

在 constructor 中, this._nextBound = this._next.bind(this);

走向 _next 方法,

进入 PDFPageProxy.render -> PDFPageProxy._pumpOperatorList -> PDFPageProxy._renderPageChunk -> internalRenderTask.operatorListChanged -> internalRenderTask._continue -> internalRenderTask._scheduleNext -> internalRenderTask._nextBound -> internalRenderTask._next

async _next() {
  if (this.cancelled) {
    return;
  }
  this.operatorListIdx = this.gfx.executeOperatorList(
    this.operatorList,
    this.operatorListIdx,
    this._continueBound,
    this.stepper,
    this._operationsFilter
  );
  if (this.operatorListIdx === this.operatorList.argsArray.length) {
    this.running = false;
    if (this.operatorList.lastChunk) {
      this.gfx.endDrawing();
      InternalRenderTask.#canvasInUse.delete(this._canvas);
      this.callback();
    }
  }
}

调用 this.gfx.executeOperatorList, 正式执行算子列表.

第五步: initializeGraphics & operatorListChanged

在 intentState.displayReadyCapability.promise 准备就绪之后, 开始初始化图形上下文

进入 initializeGraphics

// Source: src/display/api.js (InternalRenderTask)
initializeGraphics({ transparency = false, optionalContentConfig }) {
  if (this.cancelled) {
    return;
  }
  if (this._canvas) {
    if (InternalRenderTask.#canvasInUse.has(this._canvas)) {
      throw new Error(
        "Cannot use the same canvas during multiple render() operations. " +
          "Use different canvas or ensure previous operations were " +
          "cancelled or completed."
      );
    }
    InternalRenderTask.#canvasInUse.add(this._canvas);
  }
 
  if (this._pdfBug && globalThis.StepperManager?.enabled) {
    this.stepper = globalThis.StepperManager.create(this._pageIndex);
    this.stepper.init(this.operatorList);
    this.stepper.nextBreakPoint = this.stepper.getNextBreakPoint();
  }
  const {
    viewport,
    transform,
    background,
    dependencyTracker,
    imagesTracker,
  } = this.params;
 
  // When printing in Firefox, we get a specific context in mozPrintCallback
  // which cannot be created from the canvas itself.
  const canvasContext =
    this._canvasContext ||
    this._canvas.getContext("2d", {
      alpha: false,
      willReadFrequently: !this._enableHWA,
    });
 
  this.gfx = new CanvasGraphics(
    canvasContext,
    this.commonObjs,
    this.objs,
    this.canvasFactory,
    this.filterFactory,
    { optionalContentConfig },
    this.annotationCanvasMap,
    this.pageColors,
    dependencyTracker,
    imagesTracker
  );
  this.gfx.beginDrawing({
    transform,
    viewport,
    transparency,
    background,
  });
  this.operatorListIdx = 0;
  this.graphicsReady = true;
  this.graphicsReadyCallback?.();
}

通过打标 this.graphicsReady = true, 让 internalRenderTask.operatorListChanged 方法得以继续执行.

之后立即触发一次 operatorListChanged 方法, 开始执行算子列表. 该方法的代码前文中 Step 4 已完整展示过, 此处不再重复。

总结

本篇我们探索了 Display 层的 getViewport 和 render 两个方法.

getViewport 的核心是构造一个仿射变换矩阵: 把 PDF 的坐标系 (左下角为原点, 单位为点) 转换为 canvas 的坐标系 (左上角为原点, 单位为 px), 同时处理旋转, 缩放与 dontFlip.

render 则是一条横跨主线程与 Worker 的渲染流水线:

render 渲染流水线图

文字版:

Main 线程 (PDFPageProxy.render)
  |-- getRenderingIntent            计算渲染意图, 生成缓存 key
  |-- _intentStates.getOrInsert...  基于缓存 key 获取或创建渲染状态
  |-- _pumpOperatorList             向 Worker 发起 GetOperatorList 流式请求
  |     `-- reader.read() 循环消费 chunk, 每次交给 _renderPageChunk
  |-- new InternalRenderTask        创建渲染任务, 挂入 renderTasks
  `-- await displayReadyCapability
        `-- initializeGraphics      创建 CanvasGraphics, beginDrawing
              `-- operatorListChanged -> _next -> gfx.executeOperatorList
 
Worker 线程 (on:GetOperatorList)
  |-- pdfManager.getPage            定位 Page
  `-- Page.getOperatorList
        |-- handler.send("StartRenderPage")    resolve displayReadyCapability
        `-- partialEvaluator.getOperatorList   解析出算子, 经 sink 分块回传

理解了这条流水线, 也就理解了 PDF.js 渲染的骨架: 主线程只负责调度与消费算子, 真正的解析工作都在 Worker 中完成.

至此, 通过这三篇文章, PDF.js 的渲染架构已经清晰, 从下一篇开始, 将深入探索一些细节, 补全深度.

Happy reading!

下一篇预告: MessageHandler: 消息通讯的核心机制解读

相关文章