PDF.js 深入浅出 9: CanvasGraphics
2026年10月26日 · StreamPDF Team

前言
这是 PDF.js 系列文章的第 9 篇,介绍主线程渲染层 src/display/canvas.js 中的 CanvasGraphics 类。
前面几篇已经把「数据怎么来」讲完了:
- 第 3 篇讲
render()如何建立 RenderTask,并通过sendWithStream把 operatorList 流式拉到主线程。 - 第 7 篇讲 Worker 端的
PartialEvaluator如何把 PDF 内容流解析成 operatorList——颜色已经换算成 RGB 十六进制、字体已经翻译成FontFaceObject、图片已经解码成 RGBA/bitmap。 - 第 8 篇讲
Page.getOperatorList()如何为这份列表准备好 Resources、注释层与渲染意图。
本篇是渲染闭环的最后一环:主线程拿到 operatorList 之后,怎么把它真正画到 canvas 上。
一句话概括 CanvasGraphics 的职责:它是一台算子虚拟机——operatorList.fnArray[i] 是操作码,operatorList.argsArray[i] 是操作数,CanvasGraphics 的每个方法就是一个操作码的实现(方法名与 OPS 常量名一致),循环执行即可完成渲染。
模块总览
canvas.js 约 4400 行,结构可以切成五块:
| 区块 | 内容 | 职责 |
|---|---|---|
| 常量与工具函数 | MIN_FONT_SIZE、drawImageAtIntegerCoords、putBinaryImageData 等 | 降噪、像素级精度修正 |
CanvasExtraState | 图形状态的「PDF 扩展位」 | 保存 canvas 原生不支持的 PDF 状态 |
CanvasGraphics | 主类,约 100 个操作码方法 | 算子虚拟机 |
| 图案辅助 | pattern_helper.js 的 TilingPattern / getShadingPattern | 平铺图案与渐变 |
| 文本渲染 | showText / showType3Text | 字形绘制 |
常量
// Minimal font size that would be used during canvas fillText operations.
const MIN_FONT_SIZE = 16;
// Maximum font size that would be used during canvas fillText operations.
const MAX_FONT_SIZE = 100;
// Defines the time the `executeOperatorList`-method is going to be executing
// before it stops and schedules a continue of execution.
const EXECUTION_TIME = 15; // ms
// Defines the number of steps before checking the execution time.
const EXECUTION_STEPS = 10;
const FULL_CHUNK_HEIGHT = 16;四个常量分别对应三个问题:
- 字体缩放区间(
MIN_FONT_SIZE/MAX_FONT_SIZE)。canvas 的fillText在极端字号下会有精度问题(过小抗锯齿丢失、过大字形度量误差放大)。PDF.js 的做法是先在 16~100 号字之间绘制,再用ctx.scale缩放到位——fontSizeScale字段就是这个机制的状态。 - 时间片(
EXECUTION_TIME/EXECUTION_STEPS)。executeOperatorList是同步循环,一页复杂矢量图可能要跑几百毫秒。每执行 10 步检查一次耗时,超过 15ms 就调用continueCallback让出主线程,避免阻塞 UI 与用户输入。 - 分块高度(
FULL_CHUNK_HEIGHT)。putBinaryImageData把ImageData写入 canvas 时按 16 行一批处理——putImageData对超大图像会阻塞,分块让浏览器有机会穿插绘制、也降低单次峰值内存。
drawImageAtIntegerCoords
图像绘制的像素对齐修正。canvas 的 drawImage 如果落在小数坐标上,浏览器会做双线性插值,导致图片发虚或出现 1px 缝隙(相邻图片拼接时尤其明显)。
function drawImageAtIntegerCoords(ctx, srcImg, srcX, srcY, srcW, srcH,
destX, destY, destW, destH) {
const [a, b, c, d, tx, ty] = getCurrentTransform(ctx);
if (b === 0 && c === 0) {
// 纯缩放/平移矩阵:可以把目标矩形四舍五入到整数像素
const tlX = destX * a + tx;
const rTlX = Math.round(tlX);
const tlY = destY * d + ty;
const rTlY = Math.round(tlY);
const brX = (destX + destW) * a + tx;
// Some pdf contains images with 1x1 images so in case of 0-width after
// scaling we must fallback on 1 to be sure there is something.
const rWidth = Math.abs(Math.round(brX) - rTlX) || 1;
const brY = (destY + destH) * d + ty;
const rHeight = Math.abs(Math.round(brY) - rTlY) || 1;
ctx.setTransform(Math.sign(a), 0, 0, Math.sign(d), rTlX, rTlY);
ctx.drawImage(srcImg, srcX, srcY, srcW, srcH, 0, 0, rWidth, rHeight);
ctx.setTransform(a, b, c, d, tx, ty);
return [rWidth, rHeight];
}
// 非轴对齐矩阵:交给浏览器自行处理
ctx.drawImage(srcImg, srcX, srcY, srcW, srcH, destX, destY, destW, destH);
...
}关键细节是左上角与右下角分别取整,而不是对宽高取整:
假设 leftX = 4.321,width = 10.432,则 rightX = 14.753。四舍五入后左上角是 4、右下角是 15,实际宽度变成 11。如果对宽高单独取整(10.432 → 10),就会出现 1px 的缝隙。注释里专门写明了这一点。
第二个分支 a === 0 && d === 0 处理 90° 旋转矩阵(此时 b、c 非零),第三个分支放弃对齐、直接交给浏览器。Math.sign 的用法保证了图像镜像(a 或 d 为负数)时方向正确。
mirrorContextOperations
function mirrorContextOperations(ctx, destCtx) { ... }把一个 ctx 的状态类方法(save/restore/transform/clip/路径方法)转发到另一个 ctx。用于软遮罩场景:实际内容画在临时 canvas 上,但图形状态必须在主 canvas 上同步维护,否则 restore 之后 CTM 会对不上。
只镜像「无法复制」的状态——变换、路径、裁剪。颜色、线宽这类可以直接读取再赋值的属性不在列表里。
CanvasExtraState 类
<canvas> 的 2D context 已经原生保存了大部分图形状态,但 PDF 需要的状态比 canvas 多得多:
class CanvasExtraState {
// Are soft masks and alpha values shapes or opacities?
alphaIsShape = false;
fontSize = 0;
fontSizeScale = 1;
textMatrix = null;
textMatrixScale = 1;
fontMatrix = FONT_IDENTITY_MATRIX;
leading = 0;
// Current point (in user coordinates)
x = 0;
y = 0;
// Start of text line (in text coordinates)
lineX = 0;
lineY = 0;
charSpacing = 0;
wordSpacing = 0;
textHScale = 1;
textRenderingMode = TextRenderingMode.FILL;
textRise = 0;
fillColor = "#000000";
strokeColor = "#000000";
tilingPatternDims = null;
patternFill = false;
patternStroke = false;
// Note: fill alpha applies to all non-stroking operations
fillAlpha = 1;
strokeAlpha = 1;
lineWidth = 1;
activeSMask = null;
transferMaps = "none";
minMax = F32_BBOX_INIT.slice();
constructor(width, height) {
this.clipBox = new Float32Array([0, 0, width, height]);
}
}这些字段可分为四组:
| 组别 | 字段 | 说明 |
|---|---|---|
| 文本状态 | fontSize、textMatrix、charSpacing、textHScale、textRise 等 | 对应 PDF 的 Tc/Tw/Tz/TL/Ts/Tr 参数 |
| 颜色与图案 | fillColor、patternFill、tilingPatternDims | 记录当前填充/描边是否是 Pattern,tilingPatternDims 用于优化平铺区域 |
| 透明度 | fillAlpha、strokeAlpha、activeSMask、transferMaps | canvas 只有一个 globalAlpha,所以填充/描边透明度必须分别记录 |
| 几何 | clipBox、minMax、x/y | 裁剪框与当前路径包围盒,用于脏矩形优化 |
clone() 的写法值得注意:
clone() {
const clone = Object.create(this);
clone.clipBox = this.clipBox.slice();
clone.minMax = this.minMax.slice();
clone.tilingPatternDims = this.tilingPatternDims?.slice();
return clone;
}原型链浅拷贝 + 三个 TypedArray 单独 slice。q/Q 是最高频的算子,每次 save 都完整深拷贝状态会明显拖慢渲染;而那几个数组会被原地修改(路径累积),所以必须复制。
包围盒与裁剪
CanvasExtraState 还带了三个几何方法,它们是脏矩形优化的基础:
getPathBoundingBox(pathType = PathType.FILL, transform = null) {
const box = this.minMax.slice();
if (pathType === PathType.STROKE) {
if (!transform) {
unreachable("Stroke bounding box must include transform.");
}
// Stroked paths can be outside of the path bounding box by 1/2 the line
// width.
Util.singularValueDecompose2dScale(transform, XY);
const xStrokePad = (XY[0] * this.lineWidth) / 2;
const yStrokePad = (XY[1] * this.lineWidth) / 2;
box[0] -= xStrokePad;
box[1] -= yStrokePad;
box[2] += xStrokePad;
box[3] += yStrokePad;
}
return box;
}
updateClipFromPath() {
const intersect = Util.intersect(this.clipBox, this.getPathBoundingBox());
this.startNewPathAndClipBox(intersect || [0, 0, 0, 0]);
}描边路径的包围盒要向外扩 lineWidth / 2——因为线是以路径为中轴画的。缩放因子来自 singularValueDecompose2dScale(奇异值分解),这样在旋转/斜切矩阵下线宽的各向异性也被正确考虑。
updateClipFromPath 则把新裁剪区与已有裁剪区求交。裁剪区一旦为空(intersect 返回 null),就直接置为 [0,0,0,0],后续所有绘制都会被跳过。
设计要点:
CanvasExtraState是「canvas 状态 + PDF 扩展状态」的组合体。能用 canvas 原生能力表达的状态(CTM、线宽、颜色)直接委托给 ctx;不能表达的状态(填充/描边分离的透明度、文本矩阵、Pattern 标记、裁剪包围盒)记在这里,随save/restore一起入栈出栈。这一划分贯穿整个CanvasGraphics。
CanvasGraphics 类
constructor 方法
constructor(
canvasCtx,
commonObjs, // 文档级对象池(g_ 前缀:字体、图片、Pattern)
objs, // 页级对象池(p{n}_ 前缀)
canvasFactory, // 离屏 canvas 分配器
filterFactory, // CSS filter 生成器(转移函数)
{ optionalContentConfig, markedContentStack = null },
annotationCanvasMap,
pageColors,
dependencyTracker,
imagesTracker
) {
this.ctx = canvasCtx;
this.current = new CanvasExtraState(
this.ctx.canvas.width,
this.ctx.canvas.height
);
this.stateStack = [];
this.pendingClip = null;
this.pendingEOFill = false;
this.commonObjs = commonObjs;
this.objs = objs;
this.canvasFactory = canvasFactory;
this.filterFactory = filterFactory;
this.groupStack = [];
// Patterns are painted relative to the initial page/form transform, see
// PDF spec 8.7.2 NOTE 1.
this.baseTransform = null;
this.baseTransformStack = [];
this.groupLevel = 0;
this.smaskStack = [];
this.tempSMask = null;
...
this.suspendedCtx = null;
this.contentVisible = true;
this.cachedPatterns = new Map();
this.annotationCanvasMap = annotationCanvasMap;
this.viewportScale = 1;
this.outputScaleX = 1;
this.outputScaleY = 1;
...
this._cachedScaleForStroking = [-1, 0];
this._cachedGetSinglePixelWidth = null;
this._cachedBitmapsMap = new Map();
this.dependencyTracker = dependencyTracker ?? null;
this.imagesTracker = imagesTracker ?? null;
}按职责分成五组:
| 组别 | 字段 | 说明 |
|---|---|---|
| 绘图上下文 | ctx、suspendedCtx、current、stateStack | 主 ctx 与软遮罩模式下的「挂起」ctx |
| 对象池 | commonObjs、objs | 从 Worker 传输过来的字体/图片/Pattern,按 ID 懒取 |
| 离屏资源 | canvasFactory、groupStack、smaskStack、cachedPatterns | 透明度组、软遮罩、渐变的临时画布与缓存 |
| 变换基准 | baseTransform、baseTransformStack、viewportScale、outputScaleX/Y | 图案平铺与注释 canvas 的坐标基准 |
| 优化缓存 | _cachedScaleForStroking、_cachedGetSinglePixelWidth、_cachedBitmapsMap | 单像素线宽、位图复用的记忆化 |
三处细节:
suspendedCtx是软遮罩机制的核心。进入 SMask 模式后,主 ctx 被「挂起」,所有绘制转到一个临时 canvas 上;suspendedCtx保存挂起的那一份,inSMaskModegetter 直接返回!!this.suspendedCtx。baseTransform必须独立记录。PDF 规范 8.7.2 NOTE 1 规定图案坐标相对于初始页面/表单变换而不是当前 CTM,因此每次进入 Form XObject 都要压栈、退出时弹出。contentVisible是与可选内容(OCG)配合的开关。图层被关闭时置为false,几乎所有绘制方法第一行都会if (!this.contentVisible) return;——这是最廉价的一种「跳过」。
getObject 方法
getObject(opIdx, data, fallback = null) {
if (typeof data === "string") {
this.dependencyTracker?.recordNamedDependency(opIdx, data);
return data.startsWith("g_")
? this.commonObjs.get(data)
: this.objs.get(data);
}
return fallback;
}operatorList 里传递图片/图案时,参数可能是三种形态:
- 字符串 ID:从对象池取。
g_前缀走commonObjs(文档级),其余走objs(页级)。 - 内联对象:直接就是数据本身(如
paintInlineImageXObject的imgData)。 - 其他:返回
fallback。
对象池的 get(id, callback) 在对象尚未到达时会挂起回调——这正是 executeOperatorList 中 OPS.dependency 分支所做的事:一旦发现依赖未就绪,就把 continueCallback 挂上去然后中断当前这一轮执行,等数据到了再从同一个索引继续。
beginDrawing 方法
beginDrawing({ transform, viewport, transparency = false, background = null }) {
// For pdfs that use blend modes we have to clear the canvas else certain
// blend modes can look wrong since we'd be blending with a white
// backdrop.
const width = this.ctx.canvas.width;
const height = this.ctx.canvas.height;
const savedFillStyle = this.ctx.fillStyle;
this.ctx.fillStyle = background || "#ffffff";
this.ctx.fillRect(0, 0, width, height);
this.ctx.fillStyle = savedFillStyle;
if (transparency) {
const transparentCanvas = (this.transparentCanvasEntry =
this.canvasFactory.create(width, height));
this.compositeCtx = this.ctx;
({ canvas: this.transparentCanvas, context: this.ctx } = transparentCanvas);
this.ctx.save();
this.ctx.transform(...getCurrentTransform(this.compositeCtx));
}
this.ctx.save();
resetCtxToDefault(this.ctx);
if (transform) {
this.ctx.transform(...transform);
this.outputScaleX = transform[0];
this.outputScaleY = transform[3];
}
this.ctx.transform(...viewport.transform);
this.viewportScale = viewport.scale;
this.baseTransform = getCurrentTransform(this.ctx);
}渲染开始前的准备,做四件事:
-
铺底色。用
background或白色fillRect覆盖整块 canvas。注释里点明了原因:混合模式(Blend Mode)需要一个不透明背景,否则multiply、darken之类会与透明像素混合出错误结果。 -
透明度模式。
transparency: true时额外创建一张离屏 canvas,主 ctx 记为compositeCtx,之后所有绘制都在离屏上进行,endDrawing时统一合成。这里的注释点明了一个权衡:透明背景的 canvas 拿不到文本的次像素抗锯齿(浏览器只在不透明背景上做 sub-pixel AA)。所以需要「既要 blend mode 又要高质量文字」时,先把内容画到临时透明 canvas 上,最后再一次性合成到主 canvas。代价是多一次全画布
drawImage,收益是文字边缘不发虚。 -
重置 ctx 到默认值。
resetCtxToDefault把lineWidth、miterLimit、globalAlpha、globalCompositeOperation、font等全部还原——因为 canvas 可能被上一次渲染污染。 -
应用变换。先应用
transform(devicePixelRatio 与页面旋转的组合),再应用viewport.transform(PDF 坐标系 → canvas 坐标系)。两者都进 CTM 后,把结果存为baseTransform。
为什么分两步 transform?
transform是「输出缩放」(高分屏、devicePixelRatio),viewport.transform是「PDF 用户空间 → 设备空间」。分开记录便于outputScaleX/Y单独用于注释 canvas 的尺寸计算。
executeOperatorList 方法
整个渲染层的心脏。
executeOperatorList(operatorList, executionStartIdx, continueCallback,
stepper, operationsFilter) {
const argsArray = operatorList.argsArray;
const fnArray = operatorList.fnArray;
let i = executionStartIdx || 0;
const argsArrayLen = argsArray.length;
// Sometimes the OperatorList to execute is empty.
if (argsArrayLen === i) {
return i;
}
const chunkOperations =
argsArrayLen - i > EXECUTION_STEPS &&
typeof continueCallback === "function";
const endTime = chunkOperations ? Date.now() + EXECUTION_TIME : 0;
let steps = 0;
...
while (true) {
if (stepper !== undefined) {
if (i === stepper.nextBreakPoint) {
stepper.breakIt(i, continueCallback);
return i;
}
if (stepper.shouldSkip(i)) {
if (++i === argsArrayLen) {
return i;
}
continue;
}
}
if (!operationsFilter || operationsFilter(i)) {
fnId = fnArray[i];
fnArgs = argsArray[i] ?? null;
if (fnId !== OPS.dependency) {
if (fnArgs === null) {
this[fnId](i);
} else {
this[fnId](i, ...fnArgs);
}
} else {
for (const depObjId of fnArgs) {
const objsPool = depObjId.startsWith("g_") ? commonObjs : objs;
// If the promise isn't resolved yet, add the continueCallback
// to the promise and bail out.
if (!objsPool.has(depObjId)) {
objsPool.get(depObjId, continueCallback);
return i;
}
}
}
}
i++;
if (i === argsArrayLen) {
return i;
}
// If the execution took longer then a certain amount of time and
// `continueCallback` is specified, interrupt the execution.
if (chunkOperations && ++steps > EXECUTION_STEPS) {
if (Date.now() > endTime) {
continueCallback();
return i;
}
steps = 0;
}
}
}关键点逐条拆解:
① 操作码即方法名
if (fnArgs === null) {
this[fnId](i);
} else {
this[fnId](i, ...fnArgs);
}fnArray[i] 里存的是 OPS 的数字编号(例如 OPS.fill === 22、OPS.showText === 44)。this[fnId] 之所以能命中方法,靠的是 canvas.js 文件末尾这段注册代码:
// src/display/canvas.js(文件末尾,类定义之后)
for (const op in OPS) {
if (CanvasGraphics.prototype[op] !== undefined) {
CanvasGraphics.prototype[OPS[op]] = CanvasGraphics.prototype[op];
}
}遍历 OPS 的每个 名字 → 编号,如果 CanvasGraphics 上有同名方法,就在原型上再挂一个「编号 → 该方法」的别名。于是 this[22] 等价于 this.fill,this[44] 等价于 this.showText。
这是整篇文章的主线:每个操作码都有同名方法。它带来两个好处:
- 分发是三次哈希查找,没有
switch的逐项比较开销。 - 新增算子只需「加一条
OPS定义 + 写一个同名方法」,别名循环自动接上,不用改动executeOperatorList。
第一个参数固定是 opIdx(当前算子索引),用于 dependencyTracker 记录与脏矩形追踪。
② OPS.dependency:对象预取与中断恢复
Worker 会在真正使用图片/字体前插入一条 dependency 算子,列出所有即将用到的对象 ID(OPS.dependency 在 fnArray 里的写法是 [fnId, args] 的变体)。主线程遇到它时逐项检查对象池:
- 全部就绪 → 跳过继续。
- 有缺失 →
objsPool.get(depObjId, continueCallback)注册回调,然后return i把当前索引交回去。
调用方(InternalRenderTask.continueCallback)会在数据到达后,以 executionStartIdx = i 再次调用 executeOperatorList——从断点续跑,已执行的算子不会重跑。
③ 时间片让出
const chunkOperations =
argsArrayLen - i > EXECUTION_STEPS && typeof continueCallback === "function";
const endTime = chunkOperations ? Date.now() + EXECUTION_TIME : 0;只有「剩余算子数 > 10」且「提供了续跑回调」时才启用分片。每 10 步检查一次 Date.now() > endTime(15ms),超时则 continueCallback() 并返回当前 i。
这是一个协作式调度:CanvasGraphics 不主动 await,而是靠返回索引 + 外部重新调用来实现可中断渲染。好处是渲染过程中浏览器的输入事件、动画帧仍能正常处理,滚动时不至于卡住。
④ stepper 与 operationsFilter
stepper:用于「分组渲染」——比如只渲染依赖树中的某个节点(pdf.js的renderInteractiveForms、编辑器的局部重绘)。shouldSkip(i)跳过不需要的算子,nextBreakPoint到达时主动中断。operationsFilter:更细粒度的过滤,返回false表示本条不解码执行。
两者都是可选参数,普通渲染时传 undefined,走最短路径。
endDrawing 方法
#restoreInitialState() {
// Finishing all opened operations such as SMask group painting.
while (this.stateStack.length || this.inSMaskMode) {
this.restore();
}
this.current.activeSMask = null;
this.ctx.restore();
if (this.transparentCanvas) {
this.ctx = this.compositeCtx;
this.ctx.save();
this.ctx.setTransform(1, 0, 0, 1, 0, 0); // Avoid apply transform twice
this.ctx.drawImage(this.transparentCanvas, 0, 0);
this.ctx.restore();
this.canvasFactory.destroy(this.transparentCanvasEntry);
this.transparentCanvas = null;
this.transparentCanvasEntry = null;
}
}
endDrawing() {
this.#restoreInitialState();
// Destroy all smask group canvases now that rendering is complete.
for (const canvas of this.smaskGroupCanvases) {
this.canvasFactory.destroy(canvas);
}
...
}收尾要做两件事:补齐未闭合的状态,归还临时资源。
while (this.stateStack.length || this.inSMaskMode) this.restore(); 处理的是不规范 PDF——内容流里 q 比 Q 多(第 7 篇讲过,Worker 端也会补 restore 算子,但运行时仍可能有残留)。全部 restore 之后,ctx.restore() 回到 beginDrawing 时 save() 的那一层。
透明模式下则合成离屏 canvas:setTransform(1,0,0,1,0,0) 是关键——离屏 canvas 与主 canvas 尺寸相同,合成时不能再叠加页面变换,否则会画出「二次缩放」的效果。
离屏 canvas 一律通过 canvasFactory.destroy() 归还。PDF.js 的 canvasFactory 是有容量上限的对象池(默认 10 个),复用可以避免反复分配大块显存,销毁只是归还池子而非释放内存。
图形状态算子
save / restore 方法
save(opIdx) {
if (this.inSMaskMode) {
// SMask mode may be turned on/off causing us to lose graphics state.
// Copy the temporary canvas state to the main(suspended) canvas to keep
// it in sync.
copyCtxState(this.ctx, this.suspendedCtx);
}
this.ctx.save();
const old = this.current;
this.stateStack.push(old);
this.current = old.clone();
this.dependencyTracker?.save(opIdx);
}
restore(opIdx) {
this.dependencyTracker?.restore(opIdx);
if (this.stateStack.length === 0) {
if (this.inSMaskMode) {
this.endSMaskMode();
}
return;
}
this.current = this.stateStack.pop();
this.ctx.restore();
if (this.inSMaskMode) {
copyCtxState(this.suspendedCtx, this.ctx);
this.ctx.setTransform(this.suspendedCtx.getTransform());
}
this.checkSMaskState(opIdx);
// Ensure that the clipping path is reset (fixes issue6413.pdf).
this.pendingClip = null;
this._cachedScaleForStroking[0] = -1;
this._cachedGetSinglePixelWidth = null;
}对应 PDF 的 q/Q。它们是双栈联动:
| 栈 | 内容 | 由谁维护 |
|---|---|---|
ctx 内部状态栈 | CTM、线宽、颜色、裁剪路径 | canvas 原生,靠 ctx.save() / ctx.restore() |
stateStack | CanvasExtraState | CanvasGraphics 自己维护 |
save 时两栈同时压入,restore 时同时弹出。注意 this.current = old.clone()——压栈的是旧对象,当前状态是它的克隆。这样 restore 直接把旧对象赋回去即可,无需反向拷贝。
三个额外动作:
- 软遮罩同步。
inSMaskMode时主 ctx 被挂起、绘制发生在临时 ctx 上,而save/restore的调用可能发生在任一 ctx 的语境下。copyCtxState在切换前把状态同步过去;restore后还要setTransform显式对齐 CTM(注释解释了原因:临时 canvas 可能是刚创建的,它的 save/restore 栈为空,ctx.restore()变成了空操作)。 checkSMaskState。在某些状态下变更后重新评估是否需要进入/退出软遮罩模式。- 清理裁剪与缓存。
pendingClip = null修复 issue6413.pdf;两个线宽缓存置为失效值-1,下次用到时重新计算。
restore 在栈已空时不是报错,而是尝试退出 SMask 模式后静默返回——容错设计。
transform 方法
transform(opIdx, a, b, c, d, e, f) {
this.dependencyTracker?.recordIncrementalData("transform", opIdx);
this.ctx.transform(a, b, c, d, e, f);
this._cachedScaleForStroking[0] = -1;
this._cachedGetSinglePixelWidth = null;
}对应 cm 算子。真正的矩阵乘法交给 canvas,PDF.js 只做两件事:记录依赖增量、让两个线宽缓存失效。
线宽缓存之所以必须失效,是因为「单像素线宽」依赖于当前 CTM 的缩放因子——详见 getSinglePixelWidth。
setLineWidth 方法
setLineWidth(opIdx, width) {
this.dependencyTracker?.recordSimpleData("lineWidth", opIdx);
if (width !== this.current.lineWidth) {
this._cachedScaleForStroking[0] = -1;
}
this.current.lineWidth = width;
this.ctx.lineWidth = width;
}对应 w 算子。lineWidth 同时写入 current 和 ctx:前者供包围盒计算(描边外扩 lineWidth / 2),后者供 canvas 绘制。
PDF 的线宽单位是用户空间单位,而 canvas 的 lineWidth 也受 CTM 影响,两者的语义是一致的,所以可以直接赋值。
setDash 方法
setDash(opIdx, dashArray, dashPhase) {
this.dependencyTracker?.recordSimpleData("dash", opIdx);
const ctx = this.ctx;
if (ctx.setLineDash !== undefined) {
ctx.setLineDash(dashArray);
ctx.lineDashOffset = dashPhase;
}
}对应 d 算子。PDF 的虚线语法是 [dashArray] dashPhase,与 canvas 的 setLineDash / lineDashOffset 一一对应,无需转换。
注意 if (ctx.setLineDash !== undefined) 的兼容判断——这是很早期的代码遗留(Safari 老版本没有该方法)。
setGState 方法
setGState(opIdx, states) {
for (const [key, value] of states) {
switch (key) {
case "LW":
this.setLineWidth(opIdx, value);
break;
case "LC":
this.setLineCap(opIdx, value);
break;
case "LJ":
this.setLineJoin(opIdx, value);
break;
case "ML":
this.setMiterLimit(opIdx, value);
break;
case "D":
this.setDash(opIdx, value[0], value[1]);
break;
case "RI":
this.setRenderingIntent(opIdx, value);
break;
case "FL":
this.setFlatness(opIdx, value);
break;
case "Font":
this.setFont(opIdx, value[0], value[1]);
break;
case "CA":
this.current.strokeAlpha = value;
break;
case "ca":
this.ctx.globalAlpha = this.current.fillAlpha = value;
break;
case "BM":
this.ctx.globalCompositeOperation = value;
break;
case "SMask":
this.current.activeSMask = value ? this.tempSMask : null;
if (this.current.activeSMask) {
this.current.activeSMask.blendMode =
this.ctx.globalCompositeOperation;
}
this.tempSMask = null;
this.checkSMaskState(opIdx);
break;
case "TR":
this.ctx.filter = this.current.transferMaps =
this.filterFactory.addFilter(value);
break;
}
}
}对应 gs 算子。Worker 端(第 7 篇的 setGState)已经把这个操作编译成了 [key, value] 的扁平数组,主线程只需按 key 分发到对应的 setter。
三个值得注意的点:
CA/ca的不对称。ca(填充透明度)可以直接写globalAlpha,而CA(描边透明度)只能记在current.strokeAlpha——因为 canvas 只有一个globalAlpha,描边时才临时切换:
// stroke()
ctx.globalAlpha = this.current.strokeAlpha;
this.rescaleAndStroke(path, /* saveRestore */ true);
...
// Restore the global alpha to the fill alpha
ctx.globalAlpha = this.current.fillAlpha;SMask不立即生效,只把tempSMask挂到current.activeSMask,由checkSMaskState决定是否真正进入遮罩模式(因为软遮罩必须在q/Q边界上切换 canvas)。TR(转移函数)转成 CSS filter。Worker 算出的 256 项 LUT 由filterFactory.addFilter拼成url(#...)形式的 SVG filter 字符串,直接赋给ctx.filter——这是把颜色变换下放给浏览器合成器的高效做法。
默认分支(RI、FL)是空实现。渲染意图与平滑度在浏览器里没有对应能力,直接忽略。
constructPath 方法
constructPath(opIdx, op, data, minMax) {
let [path] = data;
if (!minMax) {
// The path is empty, so no need to update the current minMax.
path ||= (data[0] = new Path2D());
if (op !== OPS.stroke && op !== OPS.closeStroke) {
this.current.tilingPatternDims = null;
}
this[op](opIdx, path);
return;
}
if (!(path instanceof Path2D)) {
path = data[0] = makePathFromDrawOPS(path);
}
Util.axialAlignedBoundingBox(
minMax,
getCurrentTransform(this.ctx),
this.current.minMax
);
...
this[op](opIdx, path);
this._pathStartIdx = opIdx;
}路径算子的统一入口。Worker 端(第 7 篇的 buildPath)会把整段路径压缩成一个数组,而不是逐个输出 moveTo/lineTo——constructPath 的 argsArray[i] 形如 [[pathData], minMax],op 则是 fill/stroke/eoFill 等终止操作。
三个设计点:
- 路径缓存复用。
data[0]是缓存槽:第一次调用时makePathFromDrawOPS(path)把指令数组编译成Path2D,回写到data;重复渲染同一页时直接复用这个Path2D,跳过编译。
makePathFromDrawOPS 是 display_utils.js 里的工具函数,把 [op, ...coords] 的扁平序列翻译成 Path2D 的 moveTo/lineTo/bezierCurveTo 调用。
-
包围盒在设备空间累积。
Util.axialAlignedBoundingBox(minMax, ctm, this.current.minMax)把用户空间的minMax变换成轴对齐包围盒后并入当前路径包围盒。这是「脏矩形」与「裁剪区求交」的数据来源。 -
平铺图案的填充区域优化。
const tilingDims = this.current.tilingPatternDims;
if (tilingDims && op !== OPS.stroke && op !== OPS.closeStroke &&
this.current.fillColor instanceof TilingPattern) {
const clippedBBox = Util.intersect(this.current.clipBox, this.current.minMax);
if (!clippedBBox) {
this.current.tilingPatternDims = null;
} else {
this.current.fillColor.updatePatternDims(clippedBBox, tilingDims);
}
}平铺图案只需生成「实际填充区域」那么大的 pattern canvas,而不是整页大小。这里把裁剪后的包围盒换算成图案空间的 tile 范围,TilingPattern 据此决定生成多少行/列。
fill / stroke 方法
fill(opIdx, path, consumePath = true) {
const ctx = this.ctx;
const fillColor = this.current.fillColor;
const isPatternFill = this.current.patternFill;
let needRestore = false;
const intersect = this.current.getClippedPathBoundingBox();
if (isPatternFill) {
const dims = this.current.tilingPatternDims;
const tileIdx = dims && fillColor.canSkipPatternCanvas(dims);
if (tileIdx) {
// Draw the tile directly, skipping the pattern canvas.
fillColor.drawPattern(this, path, this.pendingEOFill, tileIdx, opIdx);
...
return;
}
ctx.save();
ctx.fillStyle = fillColor.getPattern(
ctx, this, getCurrentTransformInverse(ctx), PathType.FILL, opIdx
);
needRestore = true;
}
if (this.contentVisible && intersect !== null) {
if (this.pendingEOFill) {
ctx.fill(path, "evenodd");
this.pendingEOFill = false;
} else {
ctx.fill(path);
}
}
if (needRestore) {
ctx.restore();
}
if (consumePath) {
this.consumePath(opIdx, path, intersect);
}
}填充与描边共享几个套路:
- 裁剪包围盒短路。
intersect === null表示路径完全在裁剪区外,直接跳过ctx.fill——这是最常见的性能优化:页面外的内容根本不进光栅化。 - Pattern 填充的两种路径。若图案只有一个 tile 且无需铺平(
canSkipPatternCanvas),直接把 tile 画到路径上,省掉一次createPattern与 canvas 合成;否则才走getPattern+fillStyle。 pendingEOFill。奇偶填充(f*)在 PDF 里由PAINT_OP与PAINT_FORMAT的组合决定,Worker 端把它编码进算子参数,运行时用ctx.fill(path, "evenodd")表达。consumePath。真正执行裁剪与路径清理,见下节。
stroke 的差异在于透明度切换与线宽缩放:
ctx.globalAlpha = this.current.strokeAlpha;
...
this.rescaleAndStroke(path, /* saveRestore */ true);
...
ctx.globalAlpha = this.current.fillAlpha;rescaleAndStroke 解决的是「线宽在非均匀缩放下变形」的问题——canvas 的 stroke 沿 CTM 缩放,PDF 的语义也是,但极小的线宽会被缩成 0 而消失,所以需要折算到「至少 1 像素」再描。
consumePath 方法
consumePath(opIdx, path, clipBox) {
const isEmpty = this.current.isEmptyClip();
if (this.pendingClip) {
this.current.updateClipFromPath();
}
if (!this.pendingClip) {
this.compose(clipBox);
}
const ctx = this.ctx;
if (this.pendingClip) {
if (!isEmpty) {
if (this.pendingClip === EO_CLIP) {
ctx.clip(path, "evenodd");
} else {
ctx.clip(path);
}
}
this.pendingClip = null;
...
}
this.current.startNewPathAndClipBox(this.current.clipBox);
}路径的「消费」——绘制完成后的收尾。三个动作:
- 执行延迟裁剪。
clip/eoClip算子本身不立即执行(见下节),只设置pendingClip标记。真正的ctx.clip(path)发生在下一个路径被消费时——因为 PDF 的裁剪语义是「用当前路径做裁剪区,然后路径作废」,而 canvas 的clip()也是消费当前路径,两者时序需要对齐。 - 软遮罩合成。没有待处理裁剪时调用
compose(clipBox),把这一笔绘制的结果按脏矩形合成回主 canvas(软遮罩模式下)。 - 重置路径状态。
startNewPathAndClipBox把minMax重置为初始值,开启下一条路径。
clip / eoClip 方法
clip(opIdx) {
this.dependencyTracker?.recordFutureForcedDependency("clipMode", opIdx);
this.pendingClip = NORMAL_CLIP;
}
eoClip(opIdx) {
this.dependencyTracker?.recordFutureForcedDependency("clipMode", opIdx);
this.pendingClip = EO_CLIP;
}对应 W / W*。只打标记,不执行。
这不是偷懒,而是规范要求:PDF 里 W 作用于紧随其后的绘制操作,而该绘制操作会消费路径。如果把 ctx.clip() 放在这里执行,接着的 fill/stroke 会发现路径已经被消费、画不出来。
所以正确顺序是:W(打标记)→ f(填充 + 消费路径,填充时顺便 ctx.clip)。
isEmptyClip() 的判断避免了对空路径调用 ctx.clip()——某些浏览器实现里空裁剪会裁掉一切。
beginMarkedContent / endMarkedContent 方法
Marked Content(BDC/BMC/EMC)本身不绘制任何东西,主要用于:
- 可选内容组(OCG):决定这段内容是否可见。
- 无障碍结构:给内容打标签(段落、图片、表格)。
- 实际文本(ActualText):为文本替换提供语义文本。
运行时它维护 markedContentStack,只在 tag === "OC" 时才向 optionalContentConfig 询问可见性:
beginMarkedContent(opIdx, tag) {
this.dependencyTracker?.beginMarkedContent(opIdx);
this.markedContentStack.push({ visible: true });
}
beginMarkedContentProps(opIdx, tag, properties) {
this.dependencyTracker?.beginMarkedContent(opIdx);
if (tag === "OC") {
this.markedContentStack.push({
visible: this.optionalContentConfig.isVisible(properties),
});
} else {
this.markedContentStack.push({ visible: true });
}
this.contentVisible = this.isContentVisible();
}
endMarkedContent(opIdx) {
this.dependencyTracker?.endMarkedContent(opIdx);
this.markedContentStack.pop();
this.contentVisible = this.isContentVisible();
}可见性是整个栈的与运算——只要任意一层祖先不可见,当前内容就不可见:
isContentVisible() {
for (let i = this.markedContentStack.length - 1; i >= 0; i--) {
if (!this.markedContentStack[i].visible) {
return false;
}
}
return true;
}几乎所有绘制方法开头的 if (!this.contentVisible) return;,就是这一步的计算结果。图层被关闭时,整段内容的绘制被整体跳过——不生成 pattern、不解码图片、不进光栅化。
颜色与图案
setFillRGBColor / setStrokeRGBColor 方法
setFillRGBColor(opIdx, color) {
this.dependencyTracker?.recordSimpleData("fillColor", opIdx);
this.ctx.fillStyle = this.current.fillColor = color;
this.current.patternFill = false;
this.current.tilingPatternDims = null;
}
setStrokeRGBColor(opIdx, color) {
this.dependencyTracker?.recordSimpleData("strokeColor", opIdx);
this.ctx.strokeStyle = this.current.strokeColor = color;
this.current.patternStroke = false;
}这两行代码是整个「颜色下放」策略的终点。
PDF 的颜色空间极其复杂:DeviceRGB、DeviceCMYK、CalRGB、Lab、ICCBased、Indexed、Separation、DeviceN、Pattern……而 Worker 端(第 7 篇的 _handleColorSpace 与 setFillColor 系列)已经把这些全部换算成了 #RRGGBB 十六进制字符串。到了主线程,只剩下一次字符串赋值。
这不仅省掉了主线程的 ICC 色彩管理计算,还让浏览器原生使用它的 GPU 加速合成路径。
注意两个副作用的清理:
patternFill = false——从图案切回纯色。tilingPatternDims = null——平铺范围失效,下次用到时重新计算。
setFillTransparent / setStrokeTransparent 是同一模式的特殊分支,直接把颜色设为 "transparent"(用于某些 Separation 全透明的情形)。
getColorN_Pattern 方法
getColorN_Pattern(opIdx, IR) {
let pattern;
if (IR[0] === "TilingPattern") {
const baseTransform = this.baseTransform || getCurrentTransform(this.ctx);
const canvasGraphicsFactory = {
createCanvasGraphics: (ctx, renderingOpIdx) =>
new CanvasGraphics(
ctx, this.commonObjs, this.objs, this.canvasFactory, this.filterFactory,
{
optionalContentConfig: this.optionalContentConfig,
markedContentStack: this.markedContentStack,
},
undefined, undefined,
this.dependencyTracker
? new CanvasNestedDependencyTracker(
this.dependencyTracker, renderingOpIdx,
/* ignoreBBoxes */ true
)
: null
),
};
pattern = new TilingPattern(IR, this.ctx, canvasGraphicsFactory, baseTransform);
} else {
pattern = this._getPattern(opIdx, IR[1], IR[2]);
}
return pattern;
}对应 SCN/scn 算子(带 Pattern 的颜色设置)。Worker 端产出的是中间表示(IR),这里按 IR 的首元素分派:
| IR 首元素 | 类型 | 处理 |
|---|---|---|
"TilingPattern" | 平铺图案 | 构造 TilingPattern,需要传入 canvasGraphicsFactory 以便递归渲染 tile 内容 |
| 其他 | 渐变(Shading) | 从对象池取 shading IR,交给 getShadingPattern |
canvasGraphicsFactory 是这里最精巧的部分:平铺图案的每个 tile 本身就是一段 PDF 内容流,需要用一个新的 CanvasGraphics 渲染到离屏 canvas 上。工厂模式把「怎么创建子 Graphics」这个决定交给 TilingPattern——它只需知道自己能在某个 ctx 上渲染算子列表。
baseTransform 传递的是当前页/表单的初始变换而非实时 CTM,理由见 constructor 一节:PDF 规范 8.7.2 NOTE 1 规定图案坐标相对于初始变换。
new CanvasNestedDependencyTracker(..., /* ignoreBBoxes */ true) 说明图案内部的绘制不参与外围的脏矩形计算——图案的包围盒由外层 fill 的整体路径决定。
_getPattern 方法
_getPattern(opIdx, objId, matrix = null) {
const pattern = this.cachedPatterns.getOrInsertComputed(objId, () =>
getShadingPattern(this.getObject(opIdx, objId))
);
if (matrix) {
pattern.matrix = matrix;
}
return pattern;
}渐变图案的对象级缓存。cachedPatterns 以 objId 为 key,保证同一个 shading 的 CanvasGradient 只解析一次。
getOrInsertComputed 是 Map 的新标准方法(upsert 提案),语义等同「取不到就调用工厂并写回」,但避免了重复的 has + get 两次查表。PDF.js 在 shared/util.js 里预定义了 makeMap / makeArr 这类无参工厂,避免每次调用都创建新函数。
pattern.matrix 允许被外部覆盖——同一个 shading 在不同坐标系下使用时,矩阵需要重新赋值。
shadingFill 方法
shadingFill(opIdx, objId) {
if (!this.contentVisible) {
return;
}
const started = this.#beginKnockoutElement(this.current.fillAlpha);
const ctx = this.ctx;
this.save(opIdx);
const pattern = this._getPattern(opIdx, objId);
ctx.fillStyle = pattern.getPattern(
ctx, this, getCurrentTransformInverse(ctx), PathType.SHADING, opIdx
);
const inv = getCurrentTransformInverse(ctx);
if (inv) {
const { width, height } = ctx.canvas;
const minMax = F32_BBOX_INIT.slice();
Util.axialAlignedBoundingBox([0, 0, width, height], inv, minMax);
const [x0, y0, x1, y1] = minMax;
this.ctx.fillRect(x0, y0, x1 - x0, y1 - y0);
} else {
// HACK to draw the gradient onto an infinite rectangle.
// PDF gradients are drawn across the entire image while
// Canvas only allows gradients to be drawn in a rectangle
...
}
}对应 sh 算子——用渐变填充整个裁剪区(不是某条路径)。
PathType.SHADING 与 PathType.FILL 的区别很重要:填充图案只需要覆盖路径范围,而渐变(尤其是网格渐变)需要知道可用画布的全部区域才能生成正确的 CanvasGradient。getPattern 内部会根据这个类型决定渐变端点的坐标。
填充区域的算法:把 canvas 的整个矩形 [0, 0, width, height] 经逆 CTM 变换回用户空间,得到轴对齐包围盒,然后在这个范围内 fillRect。也就是说覆盖范围是「当前可见画布在用户空间里的投影」,再被当前裁剪路径裁掉——这样无论 CTM 如何缩放/旋转,渐变都能铺满整个可见区域。
inv 为空(CTM 不可逆)时走 HACK 分支:用一个极大的矩形近似「无限区域」,因为 PDF 的渐变在规范上是铺满整个平面的,而 canvas 只能在矩形内绘制。
TilingPattern 与 getShadingPattern
这两个类在 src/display/pattern_helper.js 中,是 canvas.js 的两大协作模块。
TilingPattern 的关键能力:
canSkipPatternCanvas(dims) // 只需一个 tile 时返回 tile 索引,跳过 createPattern
drawPattern(graphics, path, eoFill, tileIdx, opIdx) // 直接把 tile 画到路径上
updatePatternDims(clippedBBox, dims) // 根据实际填充区域裁掉多余的 tile
getPattern(ctx, graphics, invTransform, pathType, opIdx) // 生成 CanvasPattern
isModifyingCurrentTransform() // 图案渲染是否会改变 CTM其中 isModifyingCurrentTransform() 对应 fill 里的这段:
const baseTransform = fillColor.isModifyingCurrentTransform()
? ctx.getTransform()
: null;
ctx.save();
ctx.fillStyle = fillColor.getPattern(...);
if (baseTransform) {
const newPath = new Path2D();
newPath.addPath(path, ctx.getTransform().invertSelf().multiplySelf(baseTransform));
path = newPath;
}某些图案在生成 CanvasPattern 的过程中会修改 CTM(pattern.setTransform 的实现差异)。一旦 CTM 变了,之前构建的 Path2D 坐标系就错位了,必须用「新 CTM 的逆 × 旧 CTM」把这个路径重新映射回去。
getShadingPattern 处理 PDF 的七种 shading:
| 类型 | 名称 | canvas 映射 |
|---|---|---|
| 1 | Function-based | 退化为纯色(取函数在区间中点的输出) |
| 2 | Axial | createLinearGradient |
| 3 | Radial | createRadialGradient |
| 4-7 | Free-form / Lattice / Coons / Tensor | 无法用原生渐变表达,降级为按格点采样填充 |
类型 4-7 是网格渐变,canvas 没有对应 API。PDF.js 的做法是把 shading 函数采样成小的 ImageData,再用 createPattern 拉伸——精度换性能。
设计要点:颜色与图案的「重活」全在 Worker(颜色空间换算、ICC profile、shading 函数采样),主线程只负责把它们套进 canvas 的
fillStyle/strokeStyle。这是第 7 篇总结里「求值≠渲染」原则的直接体现。
透明度组与软遮罩
PDF 的透明模型比 canvas 复杂得多,CanvasGraphics 为此维护了三套机制:groupStack(透明度组)、smaskStack(软遮罩)、#knockoutGroupLevel(knockout 组)。
beginGroup 方法
beginGroup(opIdx, group) {
if (!this.contentVisible) {
return;
}
this.save(opIdx);
// If there's an active soft mask we don't want it enabled for the group, so
// clear it out. The mask and suspended canvas will be restored in endGroup.
const { inSMaskMode } = this;
if (inSMaskMode) {
this.endSMaskMode();
this.current.activeSMask = null;
}
const currentCtx = this.ctx;
if (
// A non-isolated group blends with its backdrop, so drawing it directly
// on the parent canvas (rather than on a transparent intermediate one)
// is correct even when it contains blend modes (bug 1873345). A soft
// mask still needs its own canvas though, and an isolated group requires
// a transparent backdrop, so both keep the intermediate canvas.
(!group.needsIsolation || (!group.isolated && !group.hasSoftMask)) &&
!group.knockout &&
!group.isGray &&
this.#knockoutGroupLevel === 0 &&
currentCtx.globalAlpha === 1 &&
currentCtx.globalCompositeOperation === "source-over" &&
!inSMaskMode
) {
if (group.bbox) { /* 用 Path2D 做裁剪 */ }
this.groupStack.push(null); // null = no intermediate canvas
this.#groupStackMeta.push(null);
this.groupLevel++;
return;
}
const currentTransform = getCurrentTransform(currentCtx);
if (group.matrix) {
currentCtx.transform(...group.matrix);
}
...
}对应 BDC 里的 /Group 或 Form XObject 的透明组。beginGroup 的核心决策是:要不要分配一张离屏 canvas。
快速路径(不分配 canvas) 需要同时满足七个条件,本质是「这个组的行为与直接绘制完全等价」:
- 不需要隔离(
!needsIsolation或非 isolated 且无软遮罩)——非隔离组本来就与背景混合,直接画在父 canvas 上是正确的(注释里引的 bug 1873345 就是曾经错误地分了离屏 canvas)。 - 不是 knockout 组、不是灰度组。
- 不在另一个 knockout 组里。
- 当前
globalAlpha === 1、globalCompositeOperation === "source-over"。 - 不在 SMask 模式。
满足时 groupStack.push(null)——null 是「无中间 canvas」的标记,endGroup 据此走快速返回分支。这个优化避免了为每个 Form XObject 都创建一张全尺寸离屏 canvas。
慢速路径则要计算裁剪边界:
// Clip the bounding box to the current canvas.
const canvasBounds = [0, 0, currentCtx.canvas.width, currentCtx.canvas.height];
let bounds;
if (group.bbox) {
bounds = F32_BBOX_INIT.slice();
Util.axialAlignedBoundingBox(group.bbox, getCurrentTransform(currentCtx), bounds);
bounds = Util.intersect(bounds, canvasBounds) || [0, 0, 0, 0];
}离屏 canvas 只分配组包围盒那么大,而不是整页大小——对小型透明组(常见的 logo、按钮)非常有效。
endGroup 方法
endGroup(opIdx, group) {
if (!this.contentVisible) {
return;
}
this.groupLevel--;
const groupCtx = this.ctx;
const ctx = this.groupStack.pop();
const groupMeta = this.#groupStackMeta.pop();
if (groupMeta) {
this.#knockoutGroupLevel = groupMeta.savedKnockoutLevel;
}
if (ctx === null) {
// Simple group: content was drawn directly on the parent canvas.
this.restore(opIdx);
return;
}
if (group.isGray) {
this.#convertGroupToGray(groupCtx);
}
this.ctx = ctx;
// Turn off image smoothing to avoid sub pixel interpolation which can
// look kind of blurry for some pdfs.
this.ctx.imageSmoothingEnabled = false;
if (group.smask) {
this.tempSMask = this.smaskStack.pop();
this.restore(opIdx);
...
} else {
this.ctx.restore();
const currentMtx = getCurrentTransform(this.ctx);
this.restore(opIdx);
this.ctx.save();
this.ctx.setTransform(...currentMtx);
...
this.ctx.drawImage(groupCtx.canvas, 0, 0);
}
this.ctx.restore();
this.canvasFactory.destroy({ canvas: groupCtx.canvas, context: groupCtx });
}结束组的处理按类型分三支:
| 分支 | 处理 |
|---|---|
ctx === null | 快速路径。内容已经直接画在父 canvas 上,只需 restore |
group.smask | 组作为软遮罩使用,tempSMask 从 smaskStack 弹出,之后由 checkSMaskState 进入遮罩模式 |
| 普通组 | 把离屏 canvas drawImage 合成回父 canvas |
imageSmoothingEnabled = false 是个细节:合成离屏 canvas 时像素应该 1:1 映射,开启平滑只会让边缘模糊。
knockout 组(#knockoutGroupLevel > 0)是最复杂的分支。Knockout 的语义是「组内元素互相不叠加,各自与组背景按 alpha 混合」。实现上需要三步:
- 把组内容画到一个透明 scratch 上(此时元素已经互相叠加了)。
- 用
destination-in把 scratch 裁剪成「元素覆盖的形状」,同时用destination-out从父 canvas 上挖掉这块区域。 - 最后把结果
drawImage回去。
代码里的 shapeMaskEntry(形状遮罩)+ destination-in + destination-out 就是这个流程。注释详细说明了 subgroup 嵌在 KO 父组里时两套坐标系(currentMtx 用于最终绘制定位,groupMeta.offsetX/Y 用于像素级裁剪)——这部分是 canvas.js 里最难读的区域,历代 bug 修复留下了大量注释。
beginSMaskMode / endSMaskMode 方法
beginSMaskMode(opIdx) {
if (this.inSMaskMode) {
throw new Error("beginSMaskMode called while already in smask mode");
}
const { width: drawnWidth, height: drawnHeight } = this.ctx.canvas;
const scratchCanvas = this.canvasFactory.create(drawnWidth, drawnHeight);
this.smaskScratchCanvas = scratchCanvas;
this.suspendedCtx = this.ctx;
const ctx = (this.ctx = scratchCanvas.context);
ctx.setTransform(this.suspendedCtx.getTransform());
copyCtxState(this.suspendedCtx, ctx);
mirrorContextOperations(ctx, this.suspendedCtx);
this._ensurePreparedSMask(this.current.activeSMask);
this.setGState(opIdx, [["BM", "source-over"]]);
}
endSMaskMode() {
if (!this.inSMaskMode) {
throw new Error("endSMaskMode called while not in smask mode");
}
// The soft mask is done, now restore the suspended canvas as the main
// drawing canvas.
this.ctx._removeMirroring();
copyCtxState(this.ctx, this.suspendedCtx);
this.ctx = this.suspendedCtx;
this.suspendedCtx = null;
this.canvasFactory.destroy(this.smaskScratchCanvas);
this.smaskScratchCanvas = null;
this._clearPreparedSMask();
}软遮罩模式 = 主 canvas 挂起,绘制转到 scratch canvas,之后按 alpha 合成回去。
进入时四步:
- 创建与主 canvas 同尺寸的 scratch。
suspendedCtx保存主 ctx,this.ctx切到 scratch。- 同步 CTM 与状态,并
mirrorContextOperations(scratch, main)——之后对 scratch 的transform/clip/save都会同时作用于主 ctx,保证状态栈不漂移。 _ensurePreparedSMask预烘焙遮罩(见下),并把BM设为source-over——遮罩绘制不能被外层的混合模式干扰。
退出时反向操作,_removeMirroring() 解除镜像,状态回拷,ctx 复位。
_ensurePreparedSMask / #createKnockoutMaskCanvas
软遮罩的性能关键在预烘焙。
const filterSpec = hasFilter
? {
url:
subtype === "Alpha"
? this.filterFactory.addAlphaFilter(transferMap)
: this.filterFactory.addLuminosityFilter(transferMap),
subtype,
transferMap,
}
: null;/S /Alpha 与 /S /Luminosity 两种软遮罩在 canvas 里的表达方式:
| 子类型 | 含义 | 实现 |
|---|---|---|
Alpha | 遮罩的 alpha 通道用作不透明度 | destination-in,可选 feComponentTransfer 转移函数 |
Luminosity | 遮罩的亮度用作不透明度(0.3R + 0.59G + 0.11B) | 先用滤镜把 RGB 转成灰度 alpha,再 destination-in |
/BC(背景色)只对 Luminosity 有意义——它参与亮度计算,所以必须「烘焙」进遮罩像素;Alpha 类型的 /BC 是纯色彩空间背景,不能烘进 alpha 输出。
代码里还做了一项经验性优化:
const SMASK_LAYER_TO_MASK_AREA_RATIO = 4;
const { width: layerW, height: layerH } = this.ctx.canvas;
const maskArea = maskCanvas.width * maskCanvas.height;
const useLayerSize = layerW * layerH < SMASK_LAYER_TO_MASK_AREA_RATIO * maskArea;如果遮罩图与绘制层尺寸接近(面积比小于 4),就把遮罩预烘焙到层尺寸,这样 compose 时能直接同尺寸 drawImage,命中 GPU 的同尺寸快速路径;否则保持遮罩原尺寸,避免为大图分配超大缓冲区。注释说明这个比值是对 bug-2033095 语料实测得出的。
#createKnockoutMaskCanvas 里还有一段无 DOM 降级:
// No-DOM fallback (Node/embedded). Scale painted alpha back to shape
// coverage; color channels are irrelevant for destination-out/in.
const sourceData = sourceCanvas.getContext("2d", { willReadFrequently: true })
.getImageData(0, 0, width, height);
...
for (let i = 3, ii = sourcePixels.length; i < ii; i += 4) {
maskPixels[i] = Math.min(Math.round(sourcePixels[i] * alphaScale), 255);
}某些环境(Node、嵌入式)里 ctx.filter 是「能设置但不生效」的。PDF.js 探测到这种情况后用 getImageData + putImageData 手工缩放 alpha 通道——慢,但正确。Safari 早期版本也有同样的 filter 惰性问题,代码里用 FeatureTest.isCanvasFilterSupported 判断。
compose / composeSMask 方法
compose(dirtyBox) {
if (!this.current.activeSMask) {
return;
}
dirtyBox = dirtyBox
? [Math.floor(dirtyBox[0]), Math.floor(dirtyBox[1]),
Math.ceil(dirtyBox[2]), Math.ceil(dirtyBox[3])]
: [0, 0, this.ctx.canvas.width, this.ctx.canvas.height];
const smask = this.current.activeSMask;
const suspendedCtx = this.suspendedCtx;
const applySMaskInPlace =
this.#knockoutElementDepth > 0 && suspendedCtx === this.ctx;
this.composeSMask(
applySMaskInPlace ? null : suspendedCtx,
smask,
this.ctx,
dirtyBox
);
if (applySMaskInPlace) {
return;
}
// Clear the full scratch canvas, not just the dirty box. Pixels left
// outside dirtyBox can leak into a later compose() whose destination-in
// pass doesn't overwrite them, producing stale output -- this is what
// breaks `firefox-issue17779-partial` (issue #21276).
this.ctx.save();
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
this.ctx.clearRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
this.ctx.restore();
}compose 在每个「绘制单元」结束时被调用(consumePath、showText、paintImageXObject 等)。它负责把 scratch canvas 上刚画好的内容,按软遮罩的 alpha 合成到主 canvas,然后清空 scratch。
三个要点:
dirtyBox取整方向。左/上向下取整、右/下向上取整——保证脏矩形完全覆盖实际绘制区域。applySMaskInPlace。在 knockout 元素内部且 scratch 就是当前 ctx 时,直接原地应用,避免一次多余的合成。- 清空整个 scratch 而非只清
dirtyBox。注释引用 issue #21276:dirtyBox之外的残留像素会在下一次compose的destination-in阶段泄漏出来。这是一个典型的「局部清理不够、必须全清」的案例。
composeSMask 则处理真正的合成:
composeSMask(ctx, smask, layerCtx, layerBox) {
const layerOffsetX = layerBox[0];
const layerOffsetY = layerBox[1];
const layerWidth = layerBox[2] - layerOffsetX;
const layerHeight = layerBox[3] - layerOffsetY;
if (layerWidth === 0 || layerHeight === 0) {
return;
}
const preparedEntry = this.smaskPreparedEntry;
if (preparedEntry) {
// Fast path: prepared-mask destination-in drawImage.
...
}
}若有预烘焙遮罩,走 destination-in 快速路径(一次 drawImage 由 GPU 完成 alpha 乘法);否则回退到逐块的 getImageData/putImageData 手工相乘。smaskPreparedOOBAlpha 处理「遮罩比层小」时的边界外(OOB)像素——是三档语义(null 全裁剪 / 255 保留 / 中间值淡出),代码里有详细注释表格。
设计要点:PDF 的透明模型(isolated/knockout/soft mask/blend mode)远超 canvas 原生能力。PDF.js 的策略是用「离屏 canvas + 合成算子」手工搭出这个模型:
destination-in表达 alpha 遮罩,destination-out表达 knockout 挖洞,copy表达 isolated 的清空。每个分支的复杂度都来自「PDF 语义 → canvas 合成算子」的映射不完全对齐。
图片绘制
PDF 里图片以 XObject 形式出现,分两类:/Subtype /Image(普通图片)与 ImageMask(1 位遮罩,用当前填充色染色)。
_scaleImage 方法
_scaleImage(img, inverseTransform) {
const width = img.width ?? img.displayWidth;
const height = img.height ?? img.displayHeight;
const widthScale = Math.max(Math.hypot(inverseTransform[0], inverseTransform[1]), 1);
const heightScale = Math.max(Math.hypot(inverseTransform[2], inverseTransform[3]), 1);
const scaleSteps = [];
let ws = widthScale, hs = heightScale, pw = width, ph = height;
while ((ws > 2 && pw > 1) || (hs > 2 && ph > 1)) {
let nw = pw, nh = ph;
if (ws > 2 && pw > 1) { nw = Math.ceil(pw / 2); ws /= pw / nw; }
if (hs > 2 && ph > 1) { nh = Math.ceil(ph / 2); hs /= ph / nh; }
scaleSteps.push({ newWidth: nw, newHeight: nh });
pw = nw; ph = nh;
}
...
}降采样必须分步做。
当一张 4000×3000 的扫描图被缩到 200×150 显示时,如果直接用 drawImage 一次性缩 20 倍,canvas 的插值算法只采样邻近少数几个像素,细节会被混叠成噪点。浏览器的实现通常只保证 2 倍以内的缩放质量。
所以 _scaleImage 逐级折半:每步最多缩小 2 倍,直到进入最终允许范围。代码里 while ((ws > 2 && pw > 1) || ...) 就是在计算需要几步。
三步以上的情形用了 ping-pong 双缓冲:
let readEntry = this.canvasFactory.create(1, 1);
let writeEntry = this.canvasFactory.create(1, 1);
...
for (const { newWidth, newHeight } of scaleSteps) {
this.canvasFactory.reset(writeEntry, newWidth, newHeight);
writeEntry.context.drawImage(source, 0, 0, paintWidth, paintHeight,
0, 0, newWidth, newHeight);
[readEntry, writeEntry] = [writeEntry, readEntry];
...
}canvasFactory.reset() 是「原地改尺寸并清空」,不重新 getContext,省掉一次上下文创建。整个循环只占用两张 canvas,而不是 N 张。
paintImageXObject / paintImageXObjectRepeat
paintImageXObject(opIdx, objId) {
if (!this.contentVisible) {
return;
}
const imgData = this.getObject(opIdx, objId);
if (!imgData) {
warn("Dependent image isn't ready yet");
return;
}
this.paintInlineImageXObject(opIdx, imgData);
}这里没有任何图像解码。
Worker 端(第 7 篇的 buildPaintImageXObject / _sendImgData)已经把图片解码、色彩空间转换、SMask 合成、JPEG 特殊路径全部处理完,发到主线程的是可以直接绘制的 imgData:
| 字段 | 类型 | 说明 |
|---|---|---|
width / height | number | 像素尺寸 |
data | Uint8ClampedArray | RGBA 像素(尚未解压时) |
kind | number | ImageKind.RGBA_32BPP / RGB_24BPP / GRAYSCALE_1BPP |
bitmap | ImageBitmap | 可选,已解码的位图 |
interpolate | boolean | 是否启用平滑(对应 PDF 的 /Interpolate) |
paintImageXObjectRepeat 处理「同一张图片重复绘制」的优化:Worker 端可以把 Do 指令压缩成 paintImageXObjectRepeat + 位置数组,主线程一次调用画 N 遍。
paintInlineImageXObject 方法
paintInlineImageXObject(opIdx, imgData) {
if (!this.contentVisible) {
return;
}
const width = imgData.width;
const height = imgData.height;
const started = this.#beginKnockoutElement(this.current.fillAlpha);
const ctx = this.ctx;
this.save(opIdx);
// The filter, if any, will be applied in applyTransferMapsToBitmap.
// It must be applied to the image before rescaling else some artifacts
// could appear.
const { filter } = ctx;
if (filter !== "none" && filter !== "") {
ctx.filter = "none";
}
// scale the image to the unit square
ctx.scale(1 / width, -1 / height);
let imgToPaint;
let inlineImgCanvas = null;
if (imgData.bitmap) {
const result = this.applyTransferMapsToBitmap(imgData);
imgToPaint = result.img;
inlineImgCanvas = result.canvasEntry;
} else {
const tmpCanvas = this.canvasFactory.create(width, height);
putBinaryImageData(tmpCanvas.context, imgData);
imgToPaint = this.applyTransferMapsToCanvas(tmpCanvas.context);
inlineImgCanvas = tmpCanvas;
}
const scaled = this._scaleImage(imgToPaint, getCurrentTransformInverse(ctx));
ctx.imageSmoothingEnabled = getImageSmoothingEnabled(
getCurrentTransform(ctx), imgData.interpolate
);
...
drawImageAtIntegerCoords(
ctx, scaled.img, 0, 0, scaled.paintWidth, scaled.paintHeight,
0, -height, width, height
);
...
this.compose();
this.restore(opIdx);
this.#endKnockoutElement(started);
}完整流程:
- 临时的 filter 屏蔽。当前 ctx 上可能挂着转移函数(
TR)的 CSS filter。这个 filter 必须在图像原始分辨率下应用,缩小后再套会掉精度。所以先filter = "none",改由applyTransferMapsToBitmap在临时 canvas 上完成。 - 归一化坐标系。
ctx.scale(1 / width, -1 / height)把绘制空间变成「单位正方形 + y 轴翻转」。PDF 的图像坐标系原点在左下、y 向上;canvas 原点在左上、y 向下。这个 scale 一步解决了尺寸与翻转两个问题,之后的drawImage参数就可以统一写成0, -height, width, height。 - 两条绘制路径。有
ImageBitmap就直接用(浏览器已解码,GPU 可用);只有裸 RGBA 数据则先putBinaryImageData写进临时 canvas。 imageSmoothingEnabled由/Interpolate决定。getImageSmoothingEnabled会同时考虑 CTM 缩放与 PDF 标志——PDF 默认对图像不做插值(要最近邻、保持锐利的像素块),但绝大多数阅读场景又希望平滑,所以 PDF.js 在这里做了启发式。compose()。若处于软遮罩模式,把刚画的图按 alpha 合成回去。
putBinaryImageData 就是开头常量 FULL_CHUNK_HEIGHT = 16 的用处所在:按 16 行一批构造 ImageData 并 putImageData,降低单次调用开销与大图的内存峰值。
paintImageMaskXObject 方法
paintImageMaskXObject(opIdx, img) {
const count = img.count;
img = this.getObject(opIdx, img.data, img);
img.count = count;
const ctx = this.ctx;
const mask = this._createMaskCanvas(opIdx, img);
const maskCanvas = mask.canvas;
ctx.save();
// The mask is drawn with the transform applied. Reset the current
// transform to draw to the identity.
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.drawImage(maskCanvas, mask.offsetX, mask.offsetY);
...
ctx.restore();
...
this.compose();
}ImageMask 是1 位图像当作模板:每个像素只有「涂当前填充色」或「透明」两种状态(对应 PDF 的 imagemask 操作符 + d1 Type3 字形)。
_createMaskCanvas 的流程体现了这个语义:
fillCtx.globalCompositeOperation = "source-in";
fillCtx.fillStyle = isPatternFill
? fillColor.getPattern(...)
: fillColor;
fillCtx.fillRect(0, 0, width, height);先画遮罩,再切到 source-in 用填充色铺满——source-in 只保留「目标(遮罩)alpha 非零」的源像素,等于「用填充色给遮罩染色」。图案填充时则用 getPattern 生成 Pattern 对象,允许用渐变/平铺图案给字符染色。
两个重要细节:
- 缓存。
img.count > 1表示同一遮罩会被用多次(Type3 字体的字形、重复的图形),此时按[CTM前四项, fillColor]做 key 缓存染色结果:
cacheKey = JSON.stringify(
isPatternFill ? currentTransform : [currentTransform.slice(0, 4), fillColor]
);只取 CTM 的前四项(缩放+旋转),因为平移只影响 offsetX/offsetY 的裁剪,不影响染色像素。这一优化在 Type3 字体(每个字形都是同一遮罩换颜色/位置重复绘制)上收益极大。
setTransform(1,0,0,1,0,0)。_createMaskCanvas生成的染色 canvas 已经是「设备像素对齐」的,绘制时必须回到恒等矩阵,用offsetX/offsetY定位——否则会把变换叠加两次。
paintSolidColorImageMask 是退化情形:遮罩全 1,等价于「用填充色画一个矩形」。因为调用前 Worker 已经把单位正方形的变换压进 CTM,所以绘制就是一行:
paintSolidColorImageMask(opIdx) {
if (!this.contentVisible) {
return;
}
const started = this.#beginKnockoutElement(this.current.fillAlpha);
this.dependencyTracker
?.resetBBox(opIdx).recordBBox(opIdx, this.ctx, 0, 1, 0, 1)
.recordDependencies(opIdx, Dependencies.fill)
.recordOperation(opIdx);
this.ctx.fillRect(0, 0, 1, 1);
this.compose();
this.#endKnockoutElement(started);
}不建 canvas、不解码任何数据,纯 fillRect(0, 0, 1, 1)——这是渲染层最短的一条路径。
文本绘制
文本是渲染层最复杂的部分,因为 canvas 有两种截然不同的绘制方式,而 PDF 又有四种渲染模式(填充、描边、两者、裁剪),再叠加字符间距、图案填充、伪粗斜体等需求。
setFont 方法
setFont(opIdx, fontRefName, size) {
const fontObj = this.commonObjs.get(fontRefName);
const current = this.current;
if (!fontObj) {
throw new Error(`Can't find font for ${fontRefName}`);
}
current.fontMatrix = fontObj.fontMatrix || FONT_IDENTITY_MATRIX;
// A valid matrix needs all main diagonal elements to be non-zero
if (current.fontMatrix[0] === 0 || current.fontMatrix[3] === 0) {
warn("Invalid font matrix for font " + fontRefName);
}
// The spec for Tf (setFont) says that 'size' specifies the font 'scale',
// and in some docs this can be negative (inverted x-y axes).
if (size < 0) {
size = -size;
current.fontDirection = -1;
} else {
current.fontDirection = 1;
}
this.current.font = fontObj;
this.current.fontSize = size;
if (fontObj.isType3Font) {
return; // we don't need ctx.font for Type3 fonts
}
const name = fontObj.loadedName || "sans-serif";
const typeface =
fontObj.systemFontInfo?.css || `"${name}", ${fontObj.fallbackName}`;
...
const browserFontSize = MathClamp(size, MIN_FONT_SIZE, MAX_FONT_SIZE);
this.current.fontSizeScale = size / browserFontSize;
this.ctx.font = `${italic} ${bold} ${browserFontSize}px ${typeface}`;
}对应 Tf 算子。三处关键处理:
- 负字号 → 方向。PDF 允许
Tf传负值表示文字上下翻转,canvas 的font不接受负数,所以取绝对值并记fontDirection = -1,绘制时用ctx.scale(textHScale, fontDirection)表达。 - 字号钳制。
MathClamp(size, MIN_FONT_SIZE, MAX_FONT_SIZE)把实际ctx.font限制在 16~100px,真实字号靠fontSizeScale在 CTM 上补偿。原因见常量一节(Firefox bug 726227)。 - Type3 直接返回。Type3 字体不用
ctx.font绘制,字形靠算子列表执行,无需设置字体字符串。
字体名优先级:systemFontInfo.css(系统字体,useSystemFonts 时 Worker 查到的本地字体)→ loadedName(@font-face 注入的嵌入字体)→ fallbackName(sans-serif 等兜底)。
showText 方法
showText(opIdx, glyphs) {
const current = this.current;
const font = current.font;
if (font.isType3Font) {
this.showType3Text(opIdx, glyphs);
return;
}
const fontSize = current.fontSize;
if (fontSize === 0) {
return;
}
...
const fontSizeScale = current.fontSizeScale;
const charSpacing = current.charSpacing;
const wordSpacing = current.wordSpacing;
const fontDirection = current.fontDirection;
const textHScale = current.textHScale * fontDirection;
const vertical = font.vertical;
const spacingDir = vertical ? 1 : -1;
const widthAdvanceScale = fontSize * current.fontMatrix[0];
const simpleFillText =
current.textRenderingMode === TextRenderingMode.FILL &&
!font.disableFontFace &&
!current.patternFill;
ctx.save();
if (current.textMatrix) {
ctx.transform(...current.textMatrix);
}
ctx.translate(current.x, current.y + current.textRise);
if (fontDirection > 0) {
ctx.scale(textHScale, -1);
} else {
ctx.scale(textHScale, 1);
}
...
}对应 Tj / TJ。glyphs 数组的元素有两种:
- 数字:表示位移(
TJ数组里的数值,单位是 1/1000 字宽),即「字距调整」。 - 对象:真正的字形,含
unicode、width、fontChar、isSpace、operatorListId(Type3)。
ctx.save() 之后的三个变换构成文本坐标系:
CTM × textMatrix × translate(x, y + rise) × scale(textHScale, ±1)
注意 ctx.scale(textHScale, -1) 的 -1:PDF 文本空间的 y 轴向上,而 canvas 向下,这里统一翻转。所以后面所有字形的 y 坐标都是「向上为正」。
循环部分处理每个字形:
let x = 0, i;
for (i = 0; i < glyphsLength; ++i) {
const glyph = glyphs[i];
if (typeof glyph === "number") {
x += (spacingDir * glyph * fontSize) / 1000;
continue;
}
const spacing = (glyph.isSpace ? wordSpacing : 0) + charSpacing;
let scaledX = x / fontSizeScale;
...
if (simpleFillText && !accent) {
ctx.fillText(glyph.fontChar, scaledX, scaledY); // 快路径
} else {
this.paintChar(opIdx, glyph.fontChar, scaledX, scaledY, ...);
}
const charWidth = vertical
? width * widthAdvanceScale - spacing * fontDirection
: width * widthAdvanceScale + spacing * fontDirection;
x += charWidth;
}
if (vertical) {
current.y -= x;
} else {
current.x += x * textHScale;
}这里不是逐字 ctx.translate,而是用一个局部变量 x 累加,最后一次性更新 current.x。
这个设计很关键:
- 每个字形的位置是
scaledX = x / fontSizeScale。除以fontSizeScale是为了抵消ctx.font的字号钳制——画布坐标系里已经被ctx.scale(fontSizeScale, fontSizeScale)放大了,但fillText使用的是钳制后的字号,两者的比例必须补偿回来。 - 循环内完全不动 ctx 的变换矩阵,只在需要时临时
ctx.scale(characterScaleX, 1)并restore。这样每处理一个字形只需一次fillText,没有矩阵乘法开销。 - 循环结束后才
current.x += x * textHScale,把这一整串文字的推进量一次性写回文本状态,供下一次Tj使用。
字宽计算是 glyph.width × fontSize × fontMatrix[0]:glyph.width 是 1/1000 em 单位的字宽,乘字号得到实际尺寸,再乘 fontMatrix[0] 应用到 FontMatrix 不为单位矩阵的字体(多为 Type3,但某些 Type1 也有)。
simpleFillText 是个快速路径标记:只有「纯填充 + 可用字体 + 无图案」时才走 fillText。其他情形都要退到路径生成(见 paintChar)。
paintChar 方法
paintChar(opIdx, character, x, y, patternFillTransform, patternStrokeTransform) {
...
let path;
if (
(font.disableFontFace || isAddToPathSet || patternFill || patternStroke) &&
!font.missingFile
) {
path = font.getPathGenerator(this.commonObjs, character);
}
if (path && (font.disableFontFace || patternFill || patternStroke)) {
ctx.save();
ctx.translate(x, y);
ctx.scale(fontSize, -fontSize);
...
ctx.fill(path);
...
ctx.stroke(path);
ctx.restore();
} else {
ctx.fillText(character, x, y);
ctx.strokeText(character, x, y);
}
}单个字形的两条绘制路径:
| 路径 | 触发条件 | 实现 |
|---|---|---|
| 字体渲染(快) | 正常情况 | ctx.fillText / ctx.strokeText,由浏览器字体引擎光栅化,可命中字形缓存与次像素抗锯齿 |
| 路径渲染(慢) | disableFontFace、图案填充、文本转路径 | font.getPathGenerator() 拿到 Path2D,ctx.fill(path) / ctx.stroke(path) |
路径渲染时 ctx.scale(fontSize, -fontSize) 把字形坐标(1/1000 em 乘以 FontMatrix)映射到当前字号,同时翻转 y 轴。这里的 fontSize 是 current.fontSize / current.fontSizeScale——showText 已经把画布按 fontSizeScale 放大过,所以路径渲染要用「未钳制的真实字号 ÷ 缩放比例」,与 fillText 那条路径保持一致。
图案填充文本是路径渲染的典型场景:canvas 的 fillText 只能用纯色 fillStyle,想让文字带渐变/平铺图案,只能先转成路径。代码里 patternFillTransform 保存了图案坐标系,绘制时临时 setTransform 过去,画完再回来:
if (patternFillTransform) {
currentTransform = ctx.getTransform();
ctx.setTransform(...patternFillTransform);
const scaledPath = this.#getScaledPath(path, currentTransform, patternFillTransform);
ctx.fill(scaledPath);
}描边图案还要额外修正线宽——图案矩阵的缩放会被叠加到线宽上,所以先算奇异值再除掉:
ctx.lineWidth *= Math.max(XY[0], XY[1]) / fontSize;注释也承认了局限:「如果 sx 与 sy 不同,我们无能为力,只能有一个渲染 bug」。
ADD_TO_PATH_FLAG(渲染模式 4/5/6/7)更特殊:文字不绘制,只累积路径。paintChar 把 { transform, x, y, fontSize, path } 推入 pendingTextPaths,由 endText 统一处理:
endText(opIdx) {
const paths = this.pendingTextPaths;
const ctx = this.ctx;
...
if (paths !== undefined) {
const newPath = new Path2D();
const invTransf = ctx.getTransform().invertSelf();
for (const { transform, x, y, fontSize, path } of paths) {
if (!path) {
continue; // Skip empty paths.
}
newPath.addPath(
path,
new DOMMatrix(transform)
.preMultiplySelf(invTransf)
.translate(x, y)
.scale(fontSize, -fontSize)
);
}
ctx.clip(newPath);
}
delete this.pendingTextPaths;
}注意 preMultiplySelf(invTransf):字形路径是在绘制当时的 CTM下记录的,而 endText 时的 CTM 可能已经不同。乘上当前 CTM 的逆矩阵,把这些路径统一换算到当前坐标系,才能合并成一条裁剪路径。
这是 PDF 里「用文字做裁剪路径」(Tr 4~Tr 7)的唯一实现方式。
字体粗细与斜体的来源
ctx.font 的字符串里只有 italic/normal 与 bold/normal 两个维度,那么 PDF 里的 /FontWeight 600、/ItalicAngle -12 怎么表达?
分两层:
第一层,canvas 层只能退化成关键字。setFont 把 Worker 端算好的布尔值翻译成关键字:
let bold = "normal";
if (fontObj.black) {
bold = "900";
} else if (fontObj.bold) {
bold = "bold";
}
const italic = fontObj.italic ? "italic" : "normal";
...
this.ctx.font = `${italic} ${bold} ${browserFontSize}px ${typeface}`;fontObj.bold / fontObj.black / fontObj.italic 是 Worker 从 FontDescriptor 推断出来的(fontWeight >= 600 视为 bold、>= 900 视为 black、italicAngle !== 0 视为 italic)。
第二层,真正的字重与倾斜角度声明在 @font-face 里。Worker 把 /FontWeight、/ItalicAngle 打包成 cssFontInfo,主线程据此注册字体:
// src/display/font_loader.js
createNativeFontFace() {
...
const css = { weight: this.cssFontInfo.fontWeight };
if (this.cssFontInfo.italicAngle) {
css.style = `oblique ${this.cssFontInfo.italicAngle}deg`;
}
nativeFontFace = new FontFace(
serializeFontFamily(this.cssFontInfo.fontFamily), this.data, css
);
}非 FontFace API 路径则插入 CSS 规则,形式等价:
let css = `font-weight: ${this.cssFontInfo.fontWeight};`;
if (this.cssFontInfo.italicAngle) {
css += `font-style: oblique ${this.cssFontInfo.italicAngle}deg;`;
}
const rule = `@font-face {font-family:${fontFamily};${css}src:${url}}`;所以「伪斜体」实际上由浏览器的字体合成完成(oblique Ndeg 是 CSS 标准的合成倾斜),而不是手工斜切矩阵;font-weight 同理,浏览器在找不到对应字重时会自行做加粗合成。
这也解释了 setFont 里的另一个分支:
const typeface =
fontObj.systemFontInfo?.css || `"${name}", ${fontObj.fallbackName}`;systemFontInfo.css 是 Worker 端通过本地字体探测得到的完整 CSS 字体族(含正确字重/斜体的系统字体),走这条路径时浏览器可以直接选中真正匹配的系统字体,连合成都不需要。
有一项与字体渲染质量相关的探测——isFontSubpixelAAEnabled:
// Checks if anti-aliasing is enabled when scaled text is painted.
// On Windows GDI scaled fonts looks bad.
const tmpCanvas = this.canvasFactory.create(10, 10);
const ctx = tmpCanvas.context;
ctx.scale(1.5, 1);
ctx.fillText("I", 0, 10);
const data = ctx.getImageData(0, 0, 10, 10).data;
...
for (let i = 3; i < data.length; i += 4) {
if (data[i] > 0 && data[i] < 255) { enabled = true; break; }
}在 10×10 的临时 canvas 上横向拉伸 1.5 倍画一个 "I",检查是否有半透明像素。有则说明浏览器做了抗锯齿。这个结果用于校正字宽测量(width < measuredWidth 的情形),避免在无抗锯齿环境下出现字距抖动。结果是 shadow() 缓存的——一次探测,全局复用。
showType3Text 方法
showType3Text(opIdx, glyphs) {
// Type3 fonts - each glyph is a "mini-PDF"
const ctx = this.ctx;
const current = this.current;
...
ctx.save();
if (current.textMatrix) {
ctx.transform(...current.textMatrix);
}
ctx.translate(current.x, current.y + current.textRise);
ctx.scale(textHScale, fontDirection);
// Type3 fonts have their own operator list. Avoid mixing it up with the
// dependency tracker of the main operator list.
const dependencyTracker = this.dependencyTracker;
this.dependencyTracker = dependencyTracker
? new CanvasNestedDependencyTracker(dependencyTracker, opIdx)
: null;
for (i = 0; i < glyphsLength; ++i) {
glyph = glyphs[i];
if (typeof glyph === "number") { ... continue; }
const spacing = (glyph.isSpace ? wordSpacing : 0) + charSpacing;
const operatorList = font.charProcOperatorList.get(glyph.operatorListId);
...
if (this.contentVisible) {
this.save();
// A d0 (setCharWidth) glyph is colored (see Table 113 in pdf 1.7 specs)
// unlike a d1 glyph painted as a stencil mask with the current fill
// color. The constant alphas (ca/CA) are the opacity of that current
// color, so they must not attenuate a d0 glyph.
if (operatorList.fnArray[0] === OPS.setCharWidth) {
current.fillAlpha = current.strokeAlpha = 1;
ctx.globalAlpha = 1;
}
ctx.scale(fontSize, fontSize);
ctx.transform(...fontMatrix);
this.executeOperatorList(operatorList);
this.restore();
}
...
}
ctx.restore();
if (dependencyTracker) {
this.dependencyTracker = dependencyTracker;
}
}第 7 篇讲的 Type3 字形流,在这里被执行。
关键点:
font.charProcOperatorList.get(glyph.operatorListId)——每个字形的算子列表由 Worker 端TranslatedFont.loadType3Data预先编译好,按operatorListId索引。这意味着一页里同一字符出现 100 次,字形流只解析一次,之后 100 次都是纯执行。d0/d1的 alpha 差异。d0(setCharWidth)字形是自带颜色的(可以画彩色的字形),此时ca/CA不应衰减它——所以把fillAlpha/strokeAlpha强制置 1。d1(setCharWidthAndBounds)字形是「模板遮罩」,用当前填充色绘制,此时 alpha 正常生效。这是 PDF 1.7 Table 113 的规定。- 嵌套依赖追踪器。字形自己的算子列表不应混入主页面的脏矩形/依赖记录,所以临时替换成
CanvasNestedDependencyTracker。 ctx.scale(fontSize, fontSize)+ctx.transform(fontMatrix)。字形坐标单位是 1/1000 em,先乘字号缩放,再应用字体自身的FontMatrix。
setCharWidth / setCharWidthAndBounds 方法
setCharWidth(opIdx, xWidth, yWidth) {
// We can safely ignore this since the width should be the same
// as the width in the Widths array.
}
setCharWidthAndBounds(opIdx, xWidth, yWidth, llx, lly, urx, ury) {
const clip = new Path2D();
clip.rect(llx, lly, urx - llx, ury - lly);
this.ctx.clip(clip);
...
}Type3 字形流自己的 d0 / d1 算子:
d0:声明字宽。宽度字典/Widths里已经有了,直接忽略。d1:声明字宽并限定字形边界框。这个矩形用作裁剪区——字形内容超出边界的部分必须被裁掉(规范要求)。这也是 Type3 渲染的一个性能陷阱:每个字形都多一次ctx.clip。
线宽与收尾工具
getSinglePixelWidth 方法
getSinglePixelWidth() {
if (!this._cachedGetSinglePixelWidth) {
const m = getCurrentTransform(this.ctx);
if (m[1] === 0 && m[2] === 0) {
// Fast path
this._cachedGetSinglePixelWidth =
1 / Math.min(Math.abs(m[0]), Math.abs(m[3]));
} else {
const absDet = Math.abs(m[0] * m[3] - m[2] * m[1]);
const normX = Math.hypot(m[0], m[2]);
const normY = Math.hypot(m[1], m[3]);
this._cachedGetSinglePixelWidth = Math.max(normX, normY) / absDet;
}
}
return this._cachedGetSinglePixelWidth;
}为什么需要「单像素宽度」:PDF 里 w 0 或极小的线宽(如 0.001)在逻辑上是「一条无限细的线」,规范要求渲染器按设备最小可见宽度画——即 1 像素。如果按真实 CTM 缩放,0.001 会缩成远小于 1 像素,结果什么都画不出来。
两种计算方式:
| 情形 | 公式 | 含义 |
|---|---|---|
无旋转/斜切(m[1] === m[2] === 0) | 1 / min(abs(a), abs(d)) | 最保守的方向决定最小线宽 |
| 一般情形 | max(normX, normY) / absDet | 像素平行四边形两条高的倒数取大 |
一般情形的公式来自几何:单位像素被矩阵 M 变换成平行四边形,面积为 |det(M)|,两条高分别是 |det(M)| / norm(Mx) 与 |det(M)| / norm(My)。要让两条高都 ≥ 1,线宽至少要取两者的较大值。
结果缓存在 _cachedGetSinglePixelWidth,restore 与 transform 会把它置 null。
getScaleForStroking / rescaleAndStroke 方法
getScaleForStroking() {
// A pixel has thicknessX = thicknessY = 1;
// A transformed pixel is a parallelogram and the thicknesses
// corresponds to the heights.
// The goal of this function is to rescale before setting the
// lineWidth in order to have both thicknesses greater or equal
// to 1 after transform.
if (this._cachedScaleForStroking[0] === -1) {
const { lineWidth } = this.current;
const { a, b, c, d } = this.ctx.getTransform();
let scaleX, scaleY;
if (b === 0 && c === 0) {
// Fast path
const normX = Math.abs(a);
const normY = Math.abs(d);
if (normX === normY) {
if (lineWidth === 0) {
scaleX = scaleY = 1 / normX;
} else {
const scaledLineWidth = normX * lineWidth;
scaleX = scaleY = scaledLineWidth < 1 ? 1 / scaledLineWidth : 1;
}
} else if (lineWidth === 0) { ... }
...
} else {
const absDet = Math.abs(a * d - b * c);
const normX = Math.hypot(a, b);
const normY = Math.hypot(c, d);
...
const baseArea = lineWidth * absDet;
scaleX = normY > baseArea ? normY / baseArea : 1;
scaleY = normX > baseArea ? normX / baseArea : 1;
}
this._cachedScaleForStroking[0] = scaleX;
this._cachedScaleForStroking[1] = scaleY;
}
return this._cachedScaleForStroking;
}与 getSinglePixelWidth 不同,这里计算的是两个方向各自的补偿比例,因为非均匀缩放(normX !== normY)下横向与纵向的线宽需要不同的放大倍数。
关键判断 scaledLineWidth < 1:如果变换后的线宽已经 ≥ 1 像素,就不需要补偿(scale = 1);否则放大到恰好 1 像素。
rescaleAndStroke 用这两个比例分两支:
rescaleAndStroke(path, saveRestore) {
const { ctx, current: { lineWidth } } = this;
const [scaleX, scaleY] = this.getScaleForStroking();
if (scaleX === scaleY) {
ctx.lineWidth = (lineWidth || 1) * scaleX;
ctx.stroke(path);
return;
}
...
ctx.scale(scaleX, scaleY);
SCALE_MATRIX.a = 1 / scaleX;
SCALE_MATRIX.d = 1 / scaleY;
const newPath = new Path2D();
newPath.addPath(path, SCALE_MATRIX);
// How the dashed line is rendered depends on the current transform...
if (dashes.length > 0) {
const scale = Math.max(scaleX, scaleY);
ctx.setLineDash(dashes.map(x => x / scale));
ctx.lineDashOffset /= scale;
}
ctx.lineWidth = lineWidth || 1;
ctx.stroke(newPath);
}- 等比缩放:直接改
lineWidth,最简单。 - 非等比缩放:无法用单一
lineWidth表达两个方向,只能ctx.scale(scaleX, scaleY)放大坐标系,再把路径用反向矩阵缩回去(SCALE_MATRIX)。这样线宽在两个方向都被正确放大,而路径位置不变。
SCALE_MATRIX 是类级静态复用的 DOMMatrix——这个方法在复杂矢量图上调用极频繁,每次都 new DOMMatrix() 会产生大量垃圾对象。
虚线还要额外处理:虚线间隔的渲染依赖当前变换,缩放之后必须同步缩放 dashArray 与 lineDashOffset。注释也承认残余缺陷:「scaleX !== scaleY 时虚线仍有 bug」。
paintFormXObjectBegin / paintFormXObjectEnd 方法
paintFormXObjectBegin(opIdx, matrix, bbox) {
if (!this.contentVisible) {
return;
}
this.save(opIdx);
this.baseTransformStack.push(this.baseTransform);
if (matrix) {
this.transform(opIdx, ...matrix);
}
this.baseTransform = getCurrentTransform(this.ctx);
if (bbox) {
Util.axialAlignedBoundingBox(bbox, this.baseTransform, this.current.minMax);
const [x0, y0, x1, y1] = bbox;
const clip = new Path2D();
clip.rect(x0, y0, x1 - x0, y1 - y0);
this.ctx.clip(clip);
this.dependencyTracker?.recordClipBox(opIdx, this.ctx, x0, x1, y0, y1);
this.endPath(opIdx);
}
}
paintFormXObjectEnd(opIdx) {
if (!this.contentVisible) {
return;
}
this.restore(opIdx);
this.baseTransform = this.baseTransformStack.pop();
}Form XObject(Do 引用的一段可复用内容流)的进入与退出。
baseTransform压栈。Form 内部的图案坐标以 Form 的Matrix为基准,而不是父级 CTM,所以进入时要更新、退出时恢复。bbox裁剪。/BBox是 Form 的边界,超出部分必须裁掉(规范强制)。很多 PDF 依赖这个裁剪来隐藏越界内容。
beginAnnotation / endAnnotation 方法
注释(Annotation)的绘制入口。与普通内容不同,注释在页面内容之后绘制,且需要干净的状态:
beginAnnotation(opIdx, id, rect, transform, matrix, hasOwnCanvas, canvasName) {
// The annotations are drawn just after the page content.
// The page content drawing can potentially have set a transform,
// a clipping path, whatever...
// So in order to have something clean, we restore the initial state.
this.#restoreInitialState();
resetCtxToDefault(this.ctx);
this.ctx.save();
this.save(opIdx);
if (this.baseTransform) {
this.ctx.setTransform(...this.baseTransform);
}
...
}#restoreInitialState() 把内容流留下的状态栈全部清空——否则注释会被页面的裁剪路径或变换污染。
hasOwnCanvas 时注释会分到独立的 canvas(存入 annotationCanvasMap),这样注释可以单独重绘/交互(表单控件、链接高亮)而不必重渲染整页:
const canvasWidth = Math.ceil(width * this.outputScaleX * viewportScale);
const canvasHeight = Math.ceil(height * this.outputScaleY * viewportScale);
...
this.annotationCanvas = this.canvasFactory.create(canvasWidth, canvasHeight);尺寸用 outputScale(devicePixelRatio)× viewportScale(缩放级别)计算,保证高分屏下注释清晰。
退出时把 ctx 换回主 canvas,并弹出 baseTransform:
endAnnotation(opIdx) {
if (this.annotationCanvas) {
this.ctx.restore();
this.#drawFilter();
this.ctx = this.annotationCanvas.savedCtx;
delete this.annotationCanvas.savedCtx;
delete this.annotationCanvas;
}
this.baseTransform = this.baseTransformStack.pop();
}注意 #drawFilter() 是在换回主 ctx 之前调用的——TR 转移函数生成的 CSS filter 必须作用在注释自己的 canvas 上。
设计要点:文本与图片的复杂度全部来自「两种绘制能力的取舍」。
fillText快但只能用纯色,路径绘制慢但支持任意图案;ImageBitmap快但只有 Worker 能解码,ImageData通用但需要putImageData。CanvasGraphics的每个分支本质上都是在「快路径不适用时」找一条正确的慢路径。
总结
canvas.js 是 PDF.js 渲染链路的终点,也是整个系列里唯一直接操作像素的模块。
它在整条链路中的位置
PDF 文件
→ PDFManager.initDocument (第 6 篇:xref / trailer / 页面树)
→ PDFDocument.getPage (第 8 篇:页面模型、Resources)
→ PartialEvaluator (第 7 篇:内容流 → operatorList)
→ MessageHandler (第 4 篇:流式传输到主线程)
→ CanvasGraphics (本篇:operatorList → 像素)
CanvasGraphics 收到的 operatorList 已经是完全求值的结果:
| Worker 已完成 | 主线程只需 |
|---|---|
颜色空间 → #RRGGBB | 赋值 fillStyle |
字体解析 → FontFaceObject | 赋值 ctx.font |
| 图片解码 → RGBA / ImageBitmap | drawImage |
内容流 → Path2D 指令数组 | 构造 Path2D 并填充 |
| 遮罩 → 预烘焙 alpha | destination-in |
五条核心设计原则
- 算子即方法。
OPS的数字编号在文件末尾被注册为「编号 → 同名方法」的原型别名,executeOperatorList用this[fnId](...)直接分发。零 switch,新增算子只需加一条OPS定义加一个方法。 - 可中断的同步循环。渲染不是
async函数,而是「执行 N 步 → 返回索引 → 外部重新调用」的协作式调度。15ms 时间片保证主线程响应性,索引返回值保证断点续跑。 - 状态双栈。canvas 原生状态与
CanvasExtraState同步入栈出栈。能用原生的就用原生(CTM、线宽、颜色),不能用的(分离的填充/描边 alpha、文本矩阵、Pattern 标记)自己记。 - 一切临时资源走
canvasFactory。离屏 canvas 是重资源,全部通过对象池分配与归还。_scaleImage的 ping-pong、图案 tile、透明度组、软遮罩 scratch、注释 canvas,无一例外。 - 能跳过的就跳过。
contentVisible(图层关闭)、intersect === null(路径在裁剪区外)、canSkipPatternCanvas(单 tile 图案)、beginGroup的七条件快速路径——这些短路逻辑构成了 PDF.js 在实际文档上的性能基础。
系列小结
至此渲染闭环完整:
- Worker 端:解析文件(6)→ 构建文档模型(8)→ 求值内容流(7)→ 生成算子列表。
- 传输层:流式消息(4)+ 数据获取策略(5)。
- 主线程:API 与渲染调度(2/3)→ 执行算子(9)。
这是 PDF.js 最核心的分层:把「理解 PDF」这种 CPU 密集、可并行的任务全部赶进 Worker;把「画像素」这种依赖浏览器能力、必须串行的任务留在主线程。两者的交界就是 operatorList——一个纯数据、可序列化、可流式传输、与 canvas API 几乎同构的中间表示。
StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.
相关文章
- 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 深入浅出 2: getDocument & getPage2026年10月19日
- PDF.js 深入浅出 1: PDF.js 的架构设计2026年10月16日