PDF.js 从 demo 深入浅出 10: CanvasGraphics 与 operatorList 绘制
2026年10月27日 · StreamPDF Team

前言
第 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 操作. 本篇用这批真实算子回答:
- 数字操作码如何找到对应的绘制方法?
- 字体尚未到达时, 绘制如何暂停并恢复?
- 全量算子尚未收完时, 为什么已经能开始绘制?
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 contextinitializeGraphics 里的 #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 条如下:
索引 i | fnArray[i] | 算子名 | 参数摘要 |
|---|---|---|---|
0 | 31 | beginText | null |
1 | 70 | beginMarkedContentProps | ["Artifact", null] |
2 | 1 | dependency | ["g_d0_f1"] |
3 | 37 | setFont | ["g_d0_f1", 1] |
4 | 33 | setCharSpacing | [0.0074] |
5 | 42 | setTextMatrix | 六个矩阵分量 |
6 | 44 | showText | © 与空格两个 glyph |
整份列表只包含这些算子类型:
| 算子 | 次数 | 算子 | 次数 |
|---|---|---|---|
beginText / endText | 各 1 | beginMarkedContentProps / endMarkedContent | 各 13 |
dependency | 2 | setFont | 4 |
setCharSpacing | 12 | setWordSpacing | 11 |
setTextMatrix | 6 | moveText | 5 |
showText | 16 |
这张表也说明: 并不是每来一条 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 步, 最后一条算子执行完还不一定结束
首页最后几条算子是:
| 索引 | 算子 |
|---|---|
80 | endMarkedContent |
81 | endText |
82 | beginMarkedContentProps("Artifact", null) |
83 | endMarkedContent |
当 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 的绘制路径:
initializeGraphics复用 demo 传入的canvasContext, 创建CanvasGraphics;beginDrawing铺设白色背景, 应用[0.5, 0, 0, -0.5, 0, 421], 建立 PDF 到 canvas 的坐标映射;operatorListIdx从0开始, 流式 chunk 持续追加到同一个fnArray/argsArray;- 数字操作码通过原型别名分发到
beginText、setFont、showText等同名方法; dependency("g_d0_f1")在字体未就绪时暂停, 资源到达后从同一索引恢复;setFont、setCharSpacing、setTextMatrix先更新状态,showText再按这些状态逐 glyph 绘制;- 同步执行每超过 15ms 就通过 rAF 让出主线程, 并从断点继续;
- 数组末尾但
lastChunk = false时等待下一批算子, 不提前结束; - 只有算子全部执行且
lastChunk = true, 才调用endDrawing与complete; internalRenderTask.capability.resolve()最终 resolverenderTask.promise.
所以 CanvasGraphics 的核心不是单个绘图 API, 而是一套可暂停、可恢复、按索引推进的算子执行器. 它同时协调流式数据、异步资源与主线程帧调度, 让 PDF.js 可以边解析边绘制, 又不会在资源未就绪时画出错误内容.
StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.
相关文章
- PDF.js 从 demo 深入浅出 9: 网络流与 Range 请求2026年10月27日
- PDF.js 从 demo 深入浅出 8: PDFWorker.create 与真假 Worker 握手2026年10月27日
- PDF.js 从 demo 深入浅出 7: WorkerTransport 初始化2026年10月27日
- PDF.js 从 demo 深入浅出 6: MessageHandler 跨线程通讯2026年10月27日
- PDF.js 从 demo 深入浅出 5: render 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 4: getViewport 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 3: getPage 方法全貌2026年10月27日
- PDF.js 从 demo 深入浅出 2: getDocument 方法全貌2026年10月27日
- PDF.js 源码解读: TextLayer2026年10月27日
- PDF.js 源码解读: font_loader2026年10月27日
- PDF.js 源码解读: obj_bin_transform_display2026年10月27日
- PDF.js 源码解读: PDFObjects2026年10月27日
- PDF.js 源码解读: 网络流2026年10月27日
- PDF.js 源码解读: internal_evt.js2026年10月27日
- PDF.js 源码解读: math_clamp.js2026年10月27日
- PDF.js 源码解读: css_utils.js2026年10月27日
- PDF.js 源码解读: scripting_utils2026年10月27日
- PDF.js 源码解读: MurmurHash3_642026年10月27日
- PDF.js 源码解读: obj_bin_transform_utils2026年10月27日
- PDF.js 源码解读: image_utils.js2026年10月27日
- PDF.js 源码解读: BasePDFStream2026年10月27日
- PDF.js 源码解读: 共享工具层2026年10月27日
- PDF.js 源码解读: MessageHandler2026年10月27日
- PDF.js 源码解读: api2026年10月27日
- PDF.js 源码解读: fonts2026年10月27日
- PDF.js 源码解读: glyphlist2026年10月27日
- PDF.js 源码解读: binary_cmap2026年10月27日
- PDF.js 源码解读: cmap2026年10月27日
- PDF.js 源码解读: encodings2026年10月27日
- PDF.js 源码解读: metrics2026年10月27日
- PDF.js 源码解读: standard_fonts2026年10月27日
- PDF.js 源码解读: fonts_utils2026年10月27日
- PDF.js 源码解读: default_appearance2026年10月27日
- PDF.js 源码解读: metadata_parser2026年10月27日
- PDF.js 源码解读: name_number_tree2026年10月27日
- PDF.js 源码解读: struct_tree2026年10月27日
- PDF.js 源码解读: catalog2026年10月27日
- PDF.js 源码解读: pdf_manager2026年10月27日
- PDF.js 源码解读: object_loader2026年10月27日
- PDF.js 源码解读: obj_bin_transform_core2026年10月27日
- PDF.js 源码解读: crypto2026年10月27日
- PDF.js 源码解读: core_utils2026年10月27日
- PDF.js 源码解读: primitives2026年10月27日
- PDF.js 源码解读: worker_stream2026年10月27日
- PDF.js 源码解读: ccitt_stream2026年10月27日
- PDF.js 源码解读: jpx_stream2026年10月27日
- PDF.js 源码解读: jbig2_stream2026年10月27日
- PDF.js 源码解读: jpeg_stream2026年10月27日
- PDF.js 源码解读: decrypt_stream2026年10月27日
- PDF.js 源码解读: brotli_stream2026年10月27日
- PDF.js 源码解读: run_length_stream2026年10月27日
- PDF.js 源码解读: ascii_hex_stream2026年10月27日
- PDF.js 源码解读: ascii_85_stream2026年10月27日
- PDF.js 源码解读: lzw_stream2026年10月27日
- PDF.js 源码解读: predictor_stream2026年10月27日
- PDF.js 源码解读: flate_stream2026年10月27日
- PDF.js 源码解读: decode_stream2026年10月27日
- PDF.js 源码解读: chunked_stream2026年10月27日
- PDF.js 源码解读: stream2026年10月27日
- PDF.js 源码解读: base_stream2026年10月27日
- PDF.js 深入浅出 11: Parser2026年10月26日
- PDF.js 深入浅出 10: XRef2026年10月26日
- PDF.js 深入浅出 9: CanvasGraphics2026年10月26日
- PDF.js 深入浅出 8: Document 与 Page2026年10月26日
- AI 时代, 我们到底应该做什么才能活下去(StreamPDF 创立有感)2026年10月26日
- PDF.js 深入浅出 7: PartialEvaluator2026年10月25日
- PDF.js 深入浅出 6: PDFManager.initDocument2026年10月25日
- PDF.js 深入浅出 5: PDFFetchStream 和 PDFWorkerStream2026年10月25日
- PDF.js 深入浅出 4: MessageHandler2026年10月22日
- PDF.js 深入浅出 3: getViewport & render2026年10月22日
- PDF.js 从 demo 深入浅出 1: 自定义 viewer 渲染 PDF2026年10月19日
- PDF.js 深入浅出 2: getDocument & getPage2026年10月19日
- PDF.js 深入浅出 1: PDF.js 的架构设计2026年10月16日