StreamPDFStreamPDF
返回博客

PDF.js 从 demo 深入浅出 11: GetOperatorList 与 84 条算子的生成

2026年10月27日 · StreamPDF Team

PDF.js 从 demo 深入浅出 11: GetOperatorList 与 84 条算子的生成

前言

第 10 篇 里, 主线程已经把 demo 首页的 84 条算子逐条执行完, 画出了 297 x 421 的文字页面.

但 operatorList 不是凭空出现的. 它是 worker 端 GetOperatorList 的产物: 主线程发一个流式请求, worker 解开页面内容流, 把 PDF 操作符翻译成绘制算子, 再按 chunk 推回主线程.

本篇回到 worker, 只跟随 demo 首页这一条真实路径. 先列出本篇会用到的数据:

事实demo 首页的值
页面对象第 1 页, pageIndex = 0
MediaBox[0 0 595.22 842]
CropBox[0 0 595 842]
/Contents单个流 11 0 R, /Filter /FlateDecode, /Length 542
解码后的内容流1293 字节, 83 个操作符
ResourcesExtGState /GS0 13 0 R, Font /T1_0 14 0 R, /T1_1 15 0 R
注释无 /Annots
operatorList84 条算子, 单个 chunk, lastChunk = true

这里要区分两个词:

  • 操作符: 内容流里的 PDF token, 例如 BT、Tf、TJ;
  • 算子: operatorList 里的 OPS 枚举, 例如 beginText、setFont、showText.

它们不是一一对应. 本篇的核心问题就是: 83 个操作符为什么变成 84 条算子?

完整的求值器分支、路径、图片与颜色空间转换见 PartialEvaluator 源码解读. 本篇只解释 demo 首页走到的部分.

主线程的请求: GetOperatorList 携带什么

第 1 步, _pumpOperatorList 发出流式请求

第 5 篇 里首次进入 render 时, intentState.displayReadyCapability 还是空的, 于是调用 _pumpOperatorList. demo 实际发出的消息体是:

{
  pageId: 0,              // pagesMapper 映射后的物理页 id
  pageIndex: 0,           // 逻辑页码
  intent: 2,              // RenderingIntentFlag.DISPLAY
  cacheKey: "2__",
  annotationStorage: {},  // 空 Map 序列化后的结果
  modifiedIds: null,
}

intent 默认是 DISPLAY, 也就是 2. 后面的两个下划线来自 annotation hash 与 modifiedIds hash 都为空. 这正是 第 10 篇 里 cacheKey 的来源.

发送方式不是普通请求, 而是 sendWithStream:

const readableStream = this._transport.messageHandler.sendWithStream(
  "GetOperatorList",
  { pageId, pageIndex, intent, cacheKey, annotationStorage, modifiedIds },
  undefined,
  transfer
);
const reader = readableStream.getReader();

主线程随后用 reader.read() 逐块读取. 这套流式信封与 chunk 追加逻辑已经在 第 6 篇 讲过, 本篇只关心 worker 端生产了什么.

第 2 步, worker 先取页

worker 的 handler 第一件事不是解析内容流, 而是把 pageId 换成 core 层的 Page:

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);
 
    const start = verbosity >= VerbosityLevel.INFOS ? Date.now() : 0;
    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 => sink.error(reason))
      .finally(() => finishWorkerTask(task));
  });
});

demo 没有设置 verbosity, 默认是 WARNINGS, 所以那句 time=.., len=84 不会打印. 只有在 verbosity >= INFOS 时才会看到这行 info 日志.

pdfManager.getPage(0) 一侧是 第 8 篇 讲过的页缓存: 第一次取页时构造 Page, 后续直接复用同一个 promise.

第 3 步, PDFPage 同时准备内容流、资源与求值器

Page.getOperatorList 一进来就并行启动三件事:

const contentStreamPromise = this.getContentStream();
const resourcesPromise = this.loadResources(RESOURCES_KEYS_OPERATOR_LIST);
 
const partialEvaluator = this._createPartialEvaluator(handler, pageIndex);

RESOURCES_KEYS_OPERATOR_LIST 是:

["ColorSpace", "ExtGState", "Font", "Pattern", "Properties", "Shading", "XObject"]

demo 的页面资源只有 ExtGState 与 Font, 没有 XObject, 没有 Pattern, 也没有颜色空间. 这意味着后面不会出现路径、图片、渐变或透明度组, 只剩文本与 marked content.

PartialEvaluator 只创建一次, 后面的内容流解析、字体翻译、资源加载都由它完成.

第 4 步, StartRenderPage 先于任何算子

等内容流和资源都就绪后, 代码先发一条状态消息, 再开始求值:

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;
});

demo 的 GS0 字典是:

<< /OP false /OPM 1 /SA false /SM 0.02 /Type /ExtGState /op false >>

hasBlendModes 只检查 ExtGState 的 BM 以及 XObject 内部的资源. GS0 没有 BM, 页面也没有 XObject, 所以 transparency 是 false.

主线程收到这条消息后, _startRenderPage 只是 resolve displayReadyCapability:

_startRenderPage(transparency, cacheKey) {
  const intentState = this._intentStates.get(cacheKey);
  if (!intentState) {
    return;
  }
  intentState.displayReadyCapability?.resolve(transparency);
}

所以 StartRenderPage 的语义是 "页面请求阶段结束, transparency 已确定", 不是 "算子已经到齐". 第 5 篇 的 initializeGraphics 依赖的是这个信号, 而真正的算子 chunk 还在后面.

第 5 步, getContentStream 解开 542 字节

getContentStream 从页面字典的 Contents 取内容:

get content() {
  return this.pageDict.getArray("Contents");
}

demo 的 /Contents 是单个 11 0 R, 不是数组, 因此 getContentStream 直接返回这条 Stream, 不需要 StreamsSequenceStream 拼接.

这条流带 /Filter /FlateDecode, 压缩长度 542 字节. 解压后是 1293 字节, 开头如下:

BT
/Artifact <</Type /Pagination >>BDC
/GS0 gs
/T1_0 1 Tf
0.0074 Tc 7.98 0 0 7.98 70.8 36.8003 Tm
(\251 )Tj
/T1_1 1 Tf
0.0009 Tc -0.0002 Tw 10.98 0 0 10.98 78.96 36.8003 Tm
[(Adobe Sys)5(t)1(ems Inc)5(orporated)5( 20)5(08 \226 All rights)5( reser)-9(ved)]TJ
...

\251 是八进制 251, 在 WinAnsi 编码里对应 ©; \226 对应 –. 解压与解码细节见 FlateStream 源码解读.

随后 EvaluatorPreprocessor 用 Lexer 与 opMap 逐条读取:

this.parser = new Parser({
  lexer: new Lexer(stream, EvaluatorPreprocessor.opMap),
  xref,
});

opMap 把 BT、Tf、Tm、Tj 等名字映射到 OPS 枚举与参数个数, 并逐条校验. demo 这条流一共读出 83 个操作符:

操作符次数
BT1
ET1
BDC13
EMC13
gs1
Tf4
Tc12
Tw11
Tm6
Td5
Tj9
TJ7
合计83

83 个操作符如何变成 84 条算子

第 6 步, 一张表看清转换

PartialEvaluator.getOperatorList 的主循环每读到一个操作符, 就更新状态并调用 operatorList.addOp(fn, args). 对 demo 首页, 转换结果如下:

操作符生成算子算子条数demo 参数示例
BTbeginText1null
ETendText1null
BDCbeginMarkedContentProps13["Artifact", null], ["Span", 0]
EMCendMarkedContent13null
gs无0GS0 全是不支持的键
TfsetFont + dependency4 + 2["g_d0_f1", 1]
TcsetCharSpacing12[0.0074]
TwsetWordSpacing11[-0.0002]
TmsetTextMatrix6Float32Array(7.98, 0, 0, 7.98, 70.8, 36.8003)
TdmoveText5[1.825, -1.608]
TjshowText9© 与空格两个 glyph
TJshowText7glyph 与数字间距混合
合计84

算式只有两步:

83 个操作符
- 1 个 gs            -> 0 条算子
= 82 条直接算子
+ 2 条 dependency    -> 由 4 次 Tf 去重后注入
= 84 条算子

下面逐条解释这几个不直观的地方.

第 7 步, gs 为什么不产生算子

GS0 的键是 OP、OPM、SA、SM、Type、op. setGState 的 switch 只处理 LW、LC、LJ、ML、D、RI、FL、CA、ca、Font、BM、SMask 等键, 其余键落到:

case "HT":
case "SM":
case "SA":
case "AIS":
case "TK":
  info("graphic state operator " + key);
  break;
default:
  info("Unknown graphic state operator " + key);
  break;

Type 直接忽略, 其余键都只是打 info 日志. 因此 gStateObj 最终还是空数组:

if (gStateObj.length > 0) {
  operatorList.addOp(OPS.setGState, [gStateObj]);
}

没有产生任何算子. 注意这里不是 "gs 永远没有算子": 如果 GS0 带 LW 或 CA, 就会生成一条 setGState. demo 的 GS0 恰好只含绘制阶段用不到的键.

这就是 83 个操作符只剩 82 条直接算子的原因.

第 8 步, 四次 Tf, 只注入两条 dependency

demo 的内容流里有 4 次 Tf, 轮流使用两个字体:

资源名BaseFontfontIDloadedName
T1_0DDPEFM+Helveticaf1g_d0_f1
T1_1DDPEIM+Helvetica-Boldf2g_d0_f2

loadFont 为每个字体分配 fontID:

fontID = this.idFactory.createFontId(); // f1 / f2

loadedName 由文档 id 与 fontID 拼成:

font.loadedName = `${this.idFactory.getDocId()}_${fontID}`;

getDocId() 返回 g_${pdfManager.docId}. demo 的 docId 是 d0, 所以得到 g_d0_f1 与 g_d0_f2.

真正的算子注入发生在 OPS.setFont 分支:

case OPS.setFont:
  next(
    self.handleSetFont(resources, args, null, operatorList, task, stateManager.state, ...)
      .then(function (loadedName) {
        operatorList.addDependency(loadedName);
        operatorList.addOp(OPS.setFont, [loadedName, fontSize]);
      })
  );
  return;

addDependency 内部用 Set 去重:

addDependency(dependency) {
  if (this.dependencies.has(dependency)) {
    return;
  }
  ...
}

于是 4 次 Tf 的实际结果是:

内容流中的 Tf生成的算子索引
/T1_0 1 Tfdependency("g_d0_f1")2
同一位置setFont("g_d0_f1", 1)3
/T1_1 1 Tfdependency("g_d0_f2")7
同一位置setFont("g_d0_f2", 1)8
/T1_0 1 TfsetFont("g_d0_f1", 1)13
/T1_1 1 TfsetFont("g_d0_f2", 1)18

所以是 4 条 setFont + 2 条 dependency = 6 条算子. dependency 是给主线程的资源占位符, 告诉 CanvasGraphics "执行到索引 2 之前, g_d0_f1 必须已经注册". 第 10 篇 里字体未就绪时暂停, 暂停点正是这条算子.

字体翻译完成后, TranslatedFont.send 把字体数据发给主线程:

handler.send("commonobj", [this.loadedName, "Font", fontData], transfers);

send 里有 #sent 标记, 同一个字体只会发一次. 所以 worker 端一共只发送两条 commonobj, 尽管内容流里调用了 4 次 Tf.

setFont 的第二个参数是内容流里的字号, demo 是 1. 第 10 篇 里 browserFontSize 的 1.001 倍缩放是主线程之后的事, 不在这里.

第 9 步, Tj 与 TJ 都叫 showText

内容流里 9 次 Tj, 7 次 TJ, 一共 16 次文本绘制, 全部变成 16 条 showText:

case OPS.showText:
  args[0] = self.handleText(args[0], stateManager.state);
  break;
 
case OPS.showSpacedText:
  const combinedGlyphs = [];
  for (const arrItem of args[0]) {
    if (typeof arrItem === "string") {
      combinedGlyphs.push(...self.handleText(arrItem, state));
    } else if (typeof arrItem === "number") {
      combinedGlyphs.push(arrItem);
    }
  }
  args[0] = combinedGlyphs;
  fn = OPS.showText;
  break;

关键点: TJ 的数组里混着字符串与数字, 数字不是单独算子, 而是被塞进同一个 showText 的参数数组. 主线程执行 showText 时遇到数字就按 number / 1000 * fontSize 调整字距.

demo 的前两条 showText 长这样:

// i = 6: (\251 )Tj
showText [
  Glyph { unicode: "©", width: 737 },
  Glyph { unicode: " ", width: 278 },
]
 
// i = 12: [(Adobe Sys)5(t)1(ems ... reser)-9(ved)]TJ
showText [
  Glyph { unicode: "A", width: 722 },
  Glyph { unicode: "d", width: 611 },
  ...
  5, 1, 5, 5, 5, 5, 5, 5, -9,
  ...
]

Glyph 是 core 层的对象, 经过 postMessage 的结构化克隆后变成普通对象. 主线程只读取 unicode、width、isSpace 等字段, 不依赖原型.

这里也解释了为什么 83 个操作符里只有 16 个文本绘制操作, 但每个操作可能包含几十个 glyph: 一条 showText 可以画一串文字, 不必逐字生成算子.

第 10 步, Tm 的六个分量走 Float32Array 与 transfer

Tm 在求值器里被显式转成 Float32Array:

case OPS.setTextMatrix:
  operatorList.addOp(fn, [new Float32Array(args)]);
  continue;

demo 的第一条矩阵:

setTextMatrix [Float32Array(7.98, 0, 0, 7.98, 70.8, 36.8003)]

6 次 Tm 就是 6 个 Float32Array. OperatorList.flush 在发送 chunk 前收集 transfer list:

case OPS.setTextMatrix:
  transfers.push(argsArray[i][0].buffer);
  break;

于是这个 chunk 携带 6 个 ArrayBuffer 一起转移给主线程, 不复制内存. 主线程收到后仍是 Float32Array, 可以直接参与矩阵运算.

这正是 第 10 篇 里 Math.hypot(matrix[0], matrix[1]) 能直接工作的前提: 如果这里传的是普通数组, 主线程拿到的类型就会不同.

第 11 步, BDC 只保留 tag 与 MCID

demo 有 13 个 BDC, 2 个是 Artifact, 11 个是 Span:

/Artifact <</Type /Pagination >>BDC
/Span <</MCID 0 >>BDC
...
/Span <</MCID 10 >>BDC

求值器的处理是:

case OPS.beginMarkedContentProps:
  markedContentLevel++;
  if (!(args[0] instanceof Name)) {
    operatorList.addOp(OPS.beginMarkedContentProps, ["OC", null]);
    continue;
  }
  if (args[0].name === "OC") {
    // 可选内容组, 走 parseMarkedContentProps
    ...
    return;
  }
  // 非 OC 的属性字典只保留 MCID
  args = [
    args[0].name,
    args[1] instanceof Dict ? args[1].get("MCID") : null,
  ];
  break;

所以 Artifact 的属性字典被压缩成 ["Artifact", null]; Span 保留 MCID, 变成 ["Span", 0] 到 ["Span", 10]. demo 没有 OC, 不会进入可选内容分支.

13 个 EMC 则一一对应 endMarkedContent. 求值器维护 markedContentLevel, 保证栈平衡; 第 10 篇 的 CanvasGraphics 用同样的 tag 维护可见性栈.

页面字典里有 /StructParents 1, 说明这是带结构树的 tagged PDF. MCID 在这里被保留下来, 但绘制阶段只用它做标记; 结构树与文本层的关联是另一条链路.

第 12 步, 状态类操作符一一复制

剩下的是文本状态:

操作符次数算子参数
Tc12setCharSpacing[0.0074], [0], [-0.0279]
Tw11setWordSpacing[-0.0002], [0.0281]
Td5moveText[1.825, -1.608], [-26.62, -1.188]
Tm6setTextMatrixFloat32Array(6)

它们都是 1:1 复制, 因为主线程必须按完全相同的顺序重建 TextState. showText 本身不带字距、矩阵、缩放信息, 只带 glyph; 正确的位置来自前面这些状态算子. 第 10 篇 的第 6 步已经展示过 setCharSpacing 与 setTextMatrix 如何更新状态, 这里不再重复.

一个 chunk 与三个到达时刻

第 13 步, 84 远小于 1000, 只 flush 一次

OperatorList 用两个常量控制分片:

static CHUNK_SIZE = 1000;
static CHUNK_SIZE_ABOUT = this.CHUNK_SIZE - 5; // 995

每次 addOp 都会累加 weight:

addOp(fn, args) {
  this.optimizer.push(fn, args);
  this.weight++;
  if (this._streamSink) {
    if (this.weight >= OperatorList.CHUNK_SIZE) {
      this.flush();
    } else if (
      this.weight >= OperatorList.CHUNK_SIZE_ABOUT &&
      (fn === OPS.restore || fn === OPS.endText)
    ) {
      this.flush();
    }
  }
}

demo 只有 84 条算子, 两个条件都达不到, 所以求值过程中一次都不 flush. 页面没有注释, getOperatorList 直接走:

if (
  annotations.length === 0 ||
  intent & RenderingIntentFlag.ANNOTATIONS_DISABLE
) {
  pageOpList.flush(/* lastChunk = */ true);
  return { length: pageOpList.totalLength };
}

最终 chunk 的内容是:

{
  fnArray: [...84 个 OPS 数字],
  argsArray: [...84 组参数],
  lastChunk: true,
  separateAnnots: null,
  length: 84,
}

外加 6 个 ArrayBuffer 的 transfer list. 主线程把这个 chunk 追加到已有的空数组里, 一开始就拿到完整算子.

这里还有一个容易忽略的入口差异:

  • page.getOperatorList() 带 OPLIST 标志, 使用 NullOptimizer;
  • page.render() 的 intent = 2, 使用 QueueOptimizer.

QueueOptimizer 主要合并路径与图片算子. demo 首页没有路径与图片, 所以两个入口输出的 84 条算子完全一致. 如果页面有大量小路径, 两种入口的条数就会不同.

第 14 步, StartRenderPage、commonobj 与 chunk 的赛跑

worker 在一次请求里按顺序发出四类消息:

顺序消息demo 内容
1StartRenderPage{ transparency: false, pageIndex: 0, cacheKey: "2__" }
2commonobj["g_d0_f1", "Font", ...]
3commonobj["g_d0_f2", "Font", ...]
4operatorList chunk84 条算子 + 6 个 transfer
5流关闭sink.close()

消息按 postMessage 的 FIFO 到达主线程. 但 commonobj 的处理不是同步 resolve:

messageHandler.on("commonobj", ([id, type, exportedData]) => {
  const fontData = new FontInfo(exportedData);
  const font = new FontFaceObject(fontData, ...);
 
  this.fontLoader.bind(font)
    .catch(() => messageHandler.sendWithPromise("FontFallback", { id }))
    .finally(() => {
      font.clearData();
      this.commonObjs.resolve(id, font);
    });
});

commonObjs 要等 fontLoader.bind 完成才 resolve. 而 operatorList chunk 的处理只是把数组追加进去, 然后触发绘制:

_renderPageChunk(operatorListChunk, intentState) {
  for (let i = 0; i < operatorListChunk.length; 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;
 
  for (const internalRenderTask of intentState.renderTasks) {
    internalRenderTask.operatorListChanged();
  }
  ...
}

因此 demo 有两种可能的时序:

  1. 字体在 chunk 被消费前完成 bind: CanvasGraphics 执行到索引 2 时, g_d0_f1 已经在 commonObjs 里, 直接继续;
  2. chunk 先到, 字体还在 bind: CanvasGraphics 在索引 2 暂停, 等 commonObjs.resolve 后再从同一索引恢复.

代码没有保证哪一种. 第 10 篇 里的 dependency 暂停分支不是异常路径, 而是这条请求的正常并发结果.

实际浏览器里, 字体通常需要注册 FontFace 并等待加载, 所以第二种时序更常见; 但 demo 的字体是从 fontData 直接构造的, 也可能很快就绪. 这也是为什么 PDF.js 要把 dependency 做成 可暂停、可恢复, 而不是提前阻塞整个 operatorList.

第 15 步, worker 的时间片与背压

getOperatorList 的主循环不是一口气跑到底:

while (!(stop = timeSlotManager.check())) {
  task.ensureNotTerminated();
  if (!preprocessor.read(operation)) {
    break;
  }
  ...
}
if (stop) {
  next(deferred);
  return;
}

TimeSlotManager 的规则是:

static TIME_SLOT_DURATION_MS = 20;
static CHECK_TIME_EVERY = 100;

每 100 次检查才看一次时钟. demo 只有 83 个操作符, 循环 84 次就结束, 连一次时钟检查都触发不了, 所以这条请求在 worker 端是一次跑完的.

大页面则不同: 每 100 个操作符检查一次, 超过 20ms 就退出当前同步循环, 通过 next(deferred) 重新进入 promiseBody, 预处理器从流中的当前位置继续读. 真正读到流末尾时才补上未关闭的 marked content 与 graphics state, 保证栈平衡.

next 同时还等待 operatorList.ready:

const next = function (promise) {
  Promise.all([promise, operatorList.ready]).then(function () {
    promiseBody(resolve, reject);
  }, reject);
};

operatorList.ready 与 sink.ready 相连, 表示下游还能不能接收. 这就是 第 6 篇 的流式背压: 主线程消费得慢, worker 就不会无限堆算子.

请求被取消时, task.ensureNotTerminated() 会在下一次进入循环时抛出, 当前 promiseBody 终止, sink.error 把错误传回主线程. 第 5 篇 的 cancel 最终走到这里.

总结

本篇从主线程的 GetOperatorList 请求出发, 跟着 demo 首页的 1293 字节内容流, 走完了 worker 端的算子生产:

  1. _pumpOperatorList 发送 pageId = 0、intent = 2、cacheKey = "2__" 的流式请求;
  2. worker 用 pdfManager.getPage(0) 取得 Page, 创建 WorkerTask;
  3. Page.getOperatorList 并行准备内容流、资源与 PartialEvaluator;
  4. StartRenderPage 在求值前发出, 只携带 transparency 与 cacheKey;
  5. /Contents 11 0 R 经 FlateDecode 从 542 字节解压成 1293 字节;
  6. EvaluatorPreprocessor 读出 83 个操作符, 校验参数个数;
  7. gs 的 GS0 只含不支持的键, 产生 0 条算子, 83 变成 82 条直接算子;
  8. 4 次 Tf 生成 4 条 setFont 与 2 条去重后的 dependency, 总数变成 84;
  9. Tj 与 TJ 共 16 次, 全部变成 showText, TJ 的数字留在同一参数数组里;
  10. Tm 使用 Float32Array, 6 个矩阵的 buffer 进入 transfer list;
  11. BDC 只保留 ["Artifact", null] 与 ["Span", MCID], 13 对 BDC / EMC 保持栈平衡;
  12. 84 远小于 1000, 只在最后 flush(true) 一次, 单 chunk、lastChunk = true;
  13. StartRenderPage、两条 commonobj 与 chunk 的先后完成顺序不固定, 字体未绑定时绘制在 dependency 处暂停;
  14. worker 每 100 个操作符检查一次 20ms 时间片, demo 的 83 个操作符一次跑完;
  15. operatorList.ready 把主线程的消费速度反馈给 worker, 形成背压.

所以 GetOperatorList 不是一次简单的 "解析页面", 而是 一条带资源依赖、可暂停、可背压、可分片的算子生产流水线. 操作符数量与算子数量也没有固定关系: 有的操作符被丢弃, 有的会展开成多条, 有的会在解析过程中注入额外的资源占位.

至此, 算子与字体依赖都已就位. 但 dependency 里的 g_d0_f1 只是占位符: 内嵌的 Type1C 字节流 (FontFile3) 如何变成浏览器可加载的 FontFace? 第 12 篇 展开字体翻译管线: 指纹去重、CFF 重打包成 OpenType、二进制 FontInfo 传输与主线程的 FontFace 挂载.

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

相关文章