StreamPDFStreamPDF
返回博客

PDF.js 从 demo 深入浅出 10: CanvasGraphics 与 operatorList 绘制

2026年10月27日 · StreamPDF Team

PDF.js 从 demo 深入浅出 10: CanvasGraphics 与 operatorList 绘制

前言

第 5 篇走到 render 的最后两步:

internalRenderTask.initializeGraphics({ transparency, optionalContentConfig });
internalRenderTask.operatorListChanged();

此时 worker 已经通过 GetOperatorList 流式返回绘制算子, 主线程也已经拿到 demo 的:

viewport.transform = [0.5, 0, 0, -0.5, 0, 421];
canvas.width = 297;
canvas.height = 421;

但 operatorList 仍然只是 fnArray 与 argsArray 两组数据. 真正把它们变成 canvas 上文字、路径和图片的, 是本篇的 CanvasGraphics.

对 demo 首页单独调用 page.getOperatorList() 并汇总后, 可以得到 84 条算子: 16 条 showText、2 条字体 dependency, 其余是文本状态与 marked content 操作. 本篇用这批真实算子回答:

  1. 数字操作码如何找到对应的绘制方法?
  2. 字体尚未到达时, 绘制如何暂停并恢复?
  3. 全量算子尚未收完时, 为什么已经能开始绘制?
  4. renderTask.promise 在哪一刻 resolve?

完整的 CanvasGraphics 类与所有图形、图片、透明度分支见 canvas.js 源码解读. 本篇只跟随 demo 首页的实际执行路径.

initializeGraphics: 准备绘制现场

第 1 步, demo 直接复用传入的 canvasContext

InternalRenderTask 构造时保存了两个不同的输入:

this._canvas = params.canvas;                 // demo: null
this._canvasContext = params.canvas
  ? null
  : params.canvasContext;                     // demo: 用户传入的 2d context

initializeGraphics 里的 #canvasInUse 检查只在 this._canvas 存在时执行. demo 传的是 canvasContext, 没有传 canvas, 所以不会进入这个 WeakSet 分支, 也不会重新调用 canvas.getContext("2d"):

const canvasContext =
  this._canvasContext ||
  this._canvas.getContext("2d", {
    alpha: false,
    willReadFrequently: !this._enableHWA,
  });

随后创建真正的绘制执行器:

this.gfx = new CanvasGraphics(
  canvasContext,
  this.commonObjs,   // 文档级对象, 例如 g_d0_f1 字体
  this.objs,         // 页面级对象
  this.canvasFactory,
  this.filterFactory,
  { optionalContentConfig },
  this.annotationCanvasMap,
  this.pageColors,
  dependencyTracker,
  imagesTracker
);

demo 未开启记录功能, dependencyTracker 与 imagesTracker 都是 null; 但 commonObjs 与 objs 仍然承担字体、图片等资源的读取.

第 2 步, beginDrawing 铺背景并应用 viewport

创建 CanvasGraphics 后立刻执行:

this.gfx.beginDrawing({
  transform: null,       // demo 未额外传 transform
  viewport,
  transparency,          // 由 worker 的 StartRenderPage 回包决定
  background: null,
});

beginDrawing 首先用白色覆盖实际像素尺寸为 297 × 421 的画布:

this.ctx.fillStyle = background || "#ffffff";
this.ctx.fillRect(0, 0, 297, 421);

如果 transparency = true, 还会创建临时透明画布, 最后统一合成; 这是混合模式所需的分支. 随后保存并重置 context, 应用第 4 篇算出的矩阵:

this.ctx.save();
resetCtxToDefault(this.ctx);
this.ctx.transform(0.5, 0, 0, -0.5, 0, 421);
this.viewportScale = 0.5;
this.baseTransform = getCurrentTransform(this.ctx);

到这里, PDF 用户坐标系已经与 canvas 坐标系连接起来. 例如 PDF 左上角 (0, 842) 会映射到 canvas 的 (0, 0).

最后, initializeGraphics 设置:

this.operatorListIdx = 0;
this.graphicsReady = true;
this.graphicsReadyCallback?.();

operatorListIdx = 0 是后续所有绘制与恢复的断点. 如果算子在图形环境初始化前就已经到达, graphicsReadyCallback 会在这里补一次启动通知.

operatorList: 数据如何变成方法调用

第 3 步, 流式 chunk 追加到同一组数组

worker 每推回一个 operatorListChunk, PDFPageProxy._renderPageChunk 就把它追加到当前 intentState:

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;
 
for (const internalRenderTask of intentState.renderTasks) {
  internalRenderTask.operatorListChanged();
}

InternalRenderTask 持有的是同一个 operatorList 引用, 因此无需重新创建渲染任务, 就能看到后续追加的算子.

operatorListChanged 做三层判断:

if (!this.graphicsReady) {
  this.graphicsReadyCallback ||= this._continueBound;
  return;                       // gfx 未创建, 等 initializeGraphics
}
if (this.running) {
  return;                       // 已在执行, 不并发重入
}
this._continue();               // 空闲, 启动或恢复绘制

demo 未设置 renderTask.onContinue, 所以 _continue() 通过 _scheduleNext() 安排一次 requestAnimationFrame, 在下一帧调用 _next().

第 4 步, demo 首页的真实算子

首页汇总后的 84 条算子, 开头 7 条如下:

索引 ifnArray[i]算子名参数摘要
031beginTextnull
170beginMarkedContentProps["Artifact", null]
21dependency["g_d0_f1"]
337setFont["g_d0_f1", 1]
433setCharSpacing[0.0074]
542setTextMatrix六个矩阵分量
644showText© 与空格两个 glyph

整份列表只包含这些算子类型:

算子次数算子次数
beginText / endText各 1beginMarkedContentProps / endMarkedContent各 13
dependency2setFont4
setCharSpacing12setWordSpacing11
setTextMatrix6moveText5
showText16

这张表也说明: 并不是每来一条 showText 才画一次「整段文字」. 字体、字距、词距、文本矩阵等状态算子会改变后续 showText 的行为, 必须严格按索引顺序执行.

第 5 步, 数字编号通过原型别名分发

_next() 调用 CanvasGraphics.executeOperatorList, 并传入当前断点:

this.operatorListIdx = this.gfx.executeOperatorList(
  this.operatorList,
  this.operatorListIdx,
  this._continueBound,
  this.stepper,             // demo: undefined
  this._operationsFilter    // demo: null
);

执行循环取出同一索引上的操作码与参数:

fnId = fnArray[i];
fnArgs = argsArray[i] ?? null;
 
if (fnArgs === null) {
  this[fnId](i);
} else {
  this[fnId](i, ...fnArgs);
}

fnId 是数字, this[fnId] 为什么能命中 beginText、setFont 这些方法? 答案在 canvas.js 末尾:

for (const op in OPS) {
  if (CanvasGraphics.prototype[op] !== undefined) {
    CanvasGraphics.prototype[OPS[op]] = CanvasGraphics.prototype[op];
  }
}

类定义完成后, PDF.js 给每个算子再注册一个数字别名:

CanvasGraphics.prototype[31] = CanvasGraphics.prototype.beginText;
CanvasGraphics.prototype[37] = CanvasGraphics.prototype.setFont;
CanvasGraphics.prototype[44] = CanvasGraphics.prototype.showText;

因此 i = 3 时, 实际调用等价于:

this.setFont(3, "g_d0_f1", 1);

第一个参数 i 是算子索引, 主要供依赖与绘制追踪使用; 后面的参数才来自 argsArray[i].

跟随前 7 条算子执行

第 6 步, beginText 与 marked content 准备文本状态

i = 0 的 beginText 重置文本矩阵与当前位置:

this.current.textMatrix = null;
this.current.textMatrixScale = 1;
this.current.x = this.current.lineX = 0;
this.current.y = this.current.lineY = 0;

i = 1 的 beginMarkedContentProps("Artifact", null) 则压入一个 marked content 状态. 只有 tag === "OC" 时才需要查询可选内容图层的可见性; demo 这里是 Artifact, 直接标记为可见:

this.markedContentStack.push({ visible: true });
this.contentVisible = this.isContentVisible();

后续的 endMarkedContent 会把这个状态弹出.

第 7 步, dependency 可能让绘制停在 i = 2

i = 2 是:

dependency("g_d0_f1")

它表示后续算子需要 ID 为 g_d0_f1 的对象. 因为 ID 以 g_ 开头, 查找的是文档级 commonObjs:

const objsPool = depObjId.startsWith("g_") ? commonObjs : objs;
 
if (!objsPool.has(depObjId)) {
  objsPool.get(depObjId, continueCallback);
  return i;
}

这里有两种情况:

  • 字体已经由 commonobj 消息装载并 resolve: 继续执行 i = 3;
  • 字体尚未就绪: 把 _continueBound 注册到对象池, executeOperatorList 返回 2, 暂停绘制.

第二种情况下, setFont 绝不会在字体缺失时被调用. 等字体数据到达、对象池 resolve 后, 回调重新进入 _continue(), 再从 operatorListIdx = 2 执行. 这次依赖已满足, 才继续前进.

这就是 dependency 不直接绘制任何内容、却必须留在算子序列里的原因: 它把资源等待点编码进了绘制指令本身.

第 8 步, setFont 与文本状态算子

字体就绪后, i = 3 执行:

setFont("g_d0_f1", 1);

setFont 从 commonObjs 取出字体对象, 保存到 current.font, 并设置 fontSize = 1. 对非 Type3 字体, 还会设置 canvas 的 ctx.font.

这里的 1 不是最终显示在网页上的「1px 字号」. PDF.js 会把浏览器字号限制在 16–100px:

const browserFontSize = MathClamp(size, 16, 100); // size = 1 → 16
this.current.fontSizeScale = size / browserFontSize; // 1 / 16
this.ctx.font = `normal normal 16px ${typeface}`;

之后再结合 fontSizeScale、fontMatrix、textMatrix 与 viewport.transform 得到实际绘制尺寸. 所以不能把 setFont 的第二个参数直接当作 CSS 像素值.

i = 4 只是更新状态:

this.current.charSpacing = 0.0074;

i = 5 保存文本矩阵, 计算它的水平缩放, 并重置文本坐标:

current.textMatrix = matrix;
current.textMatrixScale = Math.hypot(matrix[0], matrix[1]);
current.x = current.lineX = 0;
current.y = current.lineY = 0;

这些状态都会被紧接着的 showText 使用.

第 9 步, showText 才真正触碰文字像素

i = 6 的 showText 收到两个 glyph: © 与空格. 方法开头先应用之前保存的状态:

ctx.save();
if (current.textMatrix) {
  ctx.transform(...current.textMatrix);
}
ctx.translate(current.x, current.y + current.textRise);

随后逐个处理 glyph:

  • glyph 对象提供 fontChar、unicode、width、isSpace 等信息;
  • 数字项不是字符, 而是额外的水平间距调整, 首页后续 showText 中就能看到 5、-28 这样的值;
  • 普通字体最终通过 paintChar 等路径调用 canvas 的文本绘制 API;
  • 每处理一个 glyph, 根据字体宽度、charSpacing、wordSpacing 与矩阵推进 x.
if (typeof glyph === "number") {
  x += (spacingDir * glyph * fontSize) / 1000;
  continue;
}
// 否则绘制 glyph.fontChar, 再按 width 与 spacing 前进

最后更新 current.x 并恢复 context:

current.x += x * textHScale;
ctx.restore();

因此 showText 不是简单调用一次 fillText(text, 0, 0). 它要同时遵守 PDF 的字形宽度、手动间距、文本矩阵、字体矩阵与 canvas 当前变换.

三种暂停, 三种恢复条件

第 10 步, 时间片: 超过 15ms 主动让出主线程

executeOperatorList 是同步循环. 如果剩余算子超过 10 条, 且存在 continueCallback, 它会启用时间片:

const EXECUTION_TIME = 15; // ms
const EXECUTION_STEPS = 10;
 
const endTime = Date.now() + EXECUTION_TIME;
let steps = 0;

每执行约 10 条算子检查一次耗时. 超过 15ms 就:

continueCallback();
return i;

demo 的 continueCallback 是 _continueBound, 它会安排下一次 requestAnimationFrame. 返回值 i 被保存为新的 operatorListIdx, 下一帧从断点继续, 已经画过的算子不会重跑.

第 11 步, 数据未收完与依赖未就绪, 不能混为一谈

除了时间片, 执行循环还可能在另外两种情况下暂停:

暂停原因当前表现恢复条件
时间片到期已执行超过 15ms下一次 rAF
追到当前数组末尾, 但 lastChunk = false暂时没有更多算子可执行worker 推来下一个 chunk
dependency 对象缺失明确停在某个 i对象池收到并 resolve 对应资源

第二种情况下, _next() 看到:

if (this.operatorListIdx === this.operatorList.argsArray.length) {
  this.running = false;
  if (this.operatorList.lastChunk) {
    // 只有 lastChunk 才能结束
  }
}

它会停止当前执行, 但不调用 endDrawing. 等 _renderPageChunk 追加更多算子后, operatorListChanged() 再启动 _continue().

第三种情况则由对象池回调恢复. 三者都复用同一个 operatorListIdx, 但等待的分别是下一帧、下一批算子、下一份资源.

lastChunk 与 renderTask.promise

第 12 步, 最后一条算子执行完还不一定结束

首页最后几条算子是:

索引算子
80endMarkedContent
81endText
82beginMarkedContentProps("Artifact", null)
83endMarkedContent

当 i = 83 执行完成, i 增加为 84, 等于 argsArray.length, 执行循环返回. 但只有 operatorList.lastChunk === true, _next() 才会进入完成路径:

if (this.operatorListIdx === this.operatorList.argsArray.length) {
  this.running = false;
  if (this.operatorList.lastChunk) {
    this.gfx.endDrawing();
    InternalRenderTask.#canvasInUse.delete(this._canvas);
    this.callback();
  }
}

endDrawing() 恢复绘制前保存的状态, 合成临时透明画布, 并清理图案、软遮罩等临时资源. demo 的文本主路径最重要的是恢复 context, 避免这次渲染的状态泄漏到下一次绘制.

随后 this.callback() 调用第 5 篇定义的 complete:

intentState.renderTasks.delete(internalRenderTask);
this.#tryCleanup();
internalRenderTask.capability.resolve();

internalRenderTask.capability 正是 renderTask.promise 背后的 promise. 到这里才能说:首页当前渲染任务需要的全部算子已经执行, 绘制现场也已收尾.

因此 demo 若要在渲染完成后导出 canvas, 应使用:

const renderTask = page.render(renderContext);
await renderTask.promise;
// 现在再读取 canvas.toDataURL() 等结果

总结

本篇跟随 demo 首页的 84 条真实算子, 走完了 CanvasGraphics 的绘制路径:

  1. initializeGraphics 复用 demo 传入的 canvasContext, 创建 CanvasGraphics;
  2. beginDrawing 铺设白色背景, 应用 [0.5, 0, 0, -0.5, 0, 421], 建立 PDF 到 canvas 的坐标映射;
  3. operatorListIdx 从 0 开始, 流式 chunk 持续追加到同一个 fnArray / argsArray;
  4. 数字操作码通过原型别名分发到 beginText、setFont、showText 等同名方法;
  5. dependency("g_d0_f1") 在字体未就绪时暂停, 资源到达后从同一索引恢复;
  6. setFont、setCharSpacing、setTextMatrix 先更新状态, showText 再按这些状态逐 glyph 绘制;
  7. 同步执行每超过 15ms 就通过 rAF 让出主线程, 并从断点继续;
  8. 数组末尾但 lastChunk = false 时等待下一批算子, 不提前结束;
  9. 只有算子全部执行且 lastChunk = true, 才调用 endDrawing 与 complete;
  10. internalRenderTask.capability.resolve() 最终 resolve renderTask.promise.

所以 CanvasGraphics 的核心不是单个绘图 API, 而是一套可暂停、可恢复、按索引推进的算子执行器. 它同时协调流式数据、异步资源与主线程帧调度, 让 PDF.js 可以边解析边绘制, 又不会在资源未就绪时画出错误内容.

StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.

相关文章