StreamPDFStreamPDF
Back to blog

PDF.js Deep Dive from Demo 11: GetOperatorList and Where the 84 Operators Come From

October 27, 2026 · StreamPDF Team

PDF.js Deep Dive from Demo 11: GetOperatorList and Where the 84 Operators Come From

Preface

Part 10 had the main thread execute all 84 operators from the demo first page one by one and paint the 297 x 421 text page.

But the operatorList did not appear out of nowhere. It is the product of GetOperatorList on the worker: the main thread sends a streaming request, the worker decodes the page content stream, translates PDF operators into drawing operators, and pushes them back in chunks.

This article returns to the worker and follows only the real path for the demo first page. Here is the data used throughout:

FactValue for the demo first page
Page objectPage 1, pageIndex = 0
MediaBox[0 0 595.22 842]
CropBox[0 0 595 842]
/ContentsSingle stream 11 0 R, /Filter /FlateDecode, /Length 542
Decoded content stream1293 bytes, 83 operators
ResourcesExtGState /GS0 13 0 R, Font /T1_0 14 0 R, /T1_1 15 0 R
AnnotationsNo /Annots
operatorList84 operators, one chunk, lastChunk = true

Two terms need to be kept apart:

  • PDF operator: a token in the content stream, such as BT, Tf, or TJ;
  • drawing operator: an OPS entry in the operatorList, such as beginText, setFont, or showText.

They are not one-to-one. The central question of this article is: why do 83 PDF operators turn into 84 drawing operators?

For the complete evaluator branches, paths, images, and color-space conversions, see the PartialEvaluator source walkthrough. This article explains only the parts reached by the demo first page.

The Request: What GetOperatorList Carries

Step 1: _pumpOperatorList Sends a Streaming Request

When render first runs, as described in Part 5, intentState.displayReadyCapability is still empty, so it calls _pumpOperatorList. The demo sends this payload:

{
  pageId: 0,              // physical page id after pagesMapper
  pageIndex: 0,           // logical page index
  intent: 2,              // RenderingIntentFlag.DISPLAY
  cacheKey: "2__",
  annotationStorage: {},  // serialized empty Map
  modifiedIds: null,
}

intent defaults to DISPLAY, which is 2. The two trailing underscores come from an empty annotation hash and an empty modifiedIds hash. This is exactly where the cacheKey in Part 10 comes from.

The request is not a plain call but a stream:

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

The main thread then reads chunk by chunk with reader.read(). The streaming envelope and chunk appending were covered in Part 6; this article only cares about what the worker produces.

Step 2: The Worker Fetches the Page

The first thing the worker handler does is turn pageId into a core-layer 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));
  });
});

The demo does not set verbosity, so it defaults to WARNINGS and the time=.., len=84 line is never printed. It only appears when verbosity >= INFOS.

pdfManager.getPage(0) uses the page cache covered in Part 8: the first call constructs the Page, and later calls reuse the same promise.

Step 3: PDFPage Prepares Content Stream, Resources, and Evaluator

Page.getOperatorList starts three things immediately:

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

RESOURCES_KEYS_OPERATOR_LIST is:

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

The demo page resources contain only ExtGState and Font: no XObject, no Pattern, and no color spaces. That means no paths, images, gradients, or transparency groups will appear later, leaving only text and marked content.

The PartialEvaluator is created once. It handles content-stream parsing, font translation, and resource loading from here on.

Step 4: StartRenderPage Arrives Before Any Operator

After the content stream and resources are ready, the code sends a status message before evaluation begins:

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

The demo GS0 dictionary is:

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

hasBlendModes checks only the BM entry of ExtGState dictionaries and resources nested inside XObjects. GS0 has no BM, and the page has no XObject, so transparency is false.

On the main thread, _startRenderPage only resolves displayReadyCapability:

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

So StartRenderPage means "the page request phase is over and transparency is known", not "the operators have all arrived". initializeGraphics from Part 5 depends on this signal, while the actual operator chunk is still ahead.

Step 5: getContentStream Decodes 542 Bytes

getContentStream reads Contents from the page dictionary:

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

The demo /Contents is a single 11 0 R, not an array, so getContentStream returns that Stream directly and no StreamsSequenceStream concatenation is needed.

The stream uses /Filter /FlateDecode and is 542 bytes compressed. It decodes to 1293 bytes, beginning with:

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 is octal 251, which is © in WinAnsi; \226 is –. For decoding details, see the FlateStream source walkthrough.

EvaluatorPreprocessor then reads tokens with a Lexer and opMap:

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

opMap maps names such as BT, Tf, Tm, and Tj to OPS values and argument counts, and validates each token. This stream yields 83 operators:

OperatorCount
BT1
ET1
BDC13
EMC13
gs1
Tf4
Tc12
Tw11
Tm6
Td5
Tj9
TJ7
Total83

How 83 Operators Become 84 Operators

Step 6: The Conversion Table

The main loop in PartialEvaluator.getOperatorList reads one operator, updates state, and calls operatorList.addOp(fn, args). For the demo first page the result is:

OperatorProduced operatorCountDemo argument example
BTbeginText1null
ETendText1null
BDCbeginMarkedContentProps13["Artifact", null], ["Span", 0]
EMCendMarkedContent13null
gsnone0GS0 contains only unsupported keys
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]
TjshowText9two glyphs, © and a space
TJshowText7glyphs mixed with numeric spacing
Total84

The arithmetic takes only two steps:

83 operators
- 1 gs              -> 0 drawing operators
= 82 direct operators
+ 2 dependencies    -> injected by 4 Tf calls after deduplication
= 84 operators

The non-obvious parts are explained below.

Step 7: Why gs Produces No Operator

GS0 has the keys OP, OPM, SA, SM, Type, and op. The setGState switch only handles LW, LC, LJ, ML, D, RI, FL, CA, ca, Font, BM, SMask, and a few others; the remaining keys fall through:

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 is ignored directly, and the other keys only produce info logs. As a result, gStateObj remains empty:

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

Nothing is emitted. This is not "gs never produces an operator": if GS0 carried LW or CA, it would emit one setGState. The demo GS0 happens to contain only keys that drawing does not use.

This is why 83 operators leave only 82 direct drawing operators.

Step 8: Four Tf Calls, Only Two Dependencies

The demo content stream has four Tf calls alternating between two fonts:

Resource nameBaseFontfontIDloadedName
T1_0DDPEFM+Helveticaf1g_d0_f1
T1_1DDPEIM+Helvetica-Boldf2g_d0_f2

loadFont assigns a fontID to each font:

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

loadedName combines the document id and the fontID:

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

getDocId() returns g_${pdfManager.docId}. The demo docId is d0, producing g_d0_f1 and g_d0_f2.

The actual operator injection happens in the OPS.setFont branch:

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 deduplicates with a Set:

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

So the four Tf calls produce:

Tf in the content streamEmitted operatorIndex
/T1_0 1 Tfdependency("g_d0_f1")2
same positionsetFont("g_d0_f1", 1)3
/T1_1 1 Tfdependency("g_d0_f2")7
same positionsetFont("g_d0_f2", 1)8
/T1_0 1 TfsetFont("g_d0_f1", 1)13
/T1_1 1 TfsetFont("g_d0_f2", 1)18

That is 4 setFont plus 2 dependency = 6 operators. A dependency is a resource placeholder for the main thread: "before executing index 2, g_d0_f1 must be registered". This is exactly where Part 10 pauses when the font is not ready.

After translation, TranslatedFont.send ships the font to the main thread:

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

send has a #sent flag, so each font is sent once. The worker therefore emits only two commonobj messages even though the content stream calls Tf four times.

The second setFont argument is the font size from the content stream, which is 1 for the demo. The 1.001 browserFontSize scaling in Part 10 happens later on the main thread, not here.

Step 9: Tj and TJ Both Become showText

The content stream has nine Tj and seven TJ calls: 16 text-drawing calls in total, all becoming 16 showText operators:

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;

The key point: a TJ array mixes strings and numbers. Numbers are not separate operators; they are placed into the same showText argument array. When the main thread executes showText and sees a number, it adjusts spacing by number / 1000 * fontSize.

The first two showText entries for the demo look like this:

// 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 is a core-layer object that becomes a plain object after postMessage structured cloning. The main thread reads unicode, width, isSpace, and other fields without relying on the prototype.

This also explains why 83 PDF operators contain only 16 text-drawing operators while each may cover dozens of glyphs: one showText can draw a whole string, without generating one operator per character.

Step 10: The Six Tm Components Travel as Float32Array

Tm is explicitly converted to Float32Array in the evaluator:

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

The first matrix for the demo:

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

Six Tm calls produce six Float32Arrays. Before sending a chunk, OperatorList.flush collects the transfer list:

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

The chunk therefore carries six ArrayBuffers transferred to the main thread without copying. The main thread receives Float32Array values and can use them directly in matrix math.

This is why Math.hypot(matrix[0], matrix[1]) in Part 10 works directly. If a plain array were sent, the main thread would receive a different type.

Step 11: BDC Keeps Only the Tag and MCID

The demo has 13 BDC calls: two Artifact and eleven Span:

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

The evaluator handles them like this:

case OPS.beginMarkedContentProps:
  markedContentLevel++;
  if (!(args[0] instanceof Name)) {
    operatorList.addOp(OPS.beginMarkedContentProps, ["OC", null]);
    continue;
  }
  if (args[0].name === "OC") {
    // Optional content group, handled by parseMarkedContentProps
    ...
    return;
  }
  // Non-OC property dictionaries keep only MCID
  args = [
    args[0].name,
    args[1] instanceof Dict ? args[1].get("MCID") : null,
  ];
  break;

So the Artifact property dictionary is reduced to ["Artifact", null], while Span keeps its MCID and becomes ["Span", 0] through ["Span", 10]. The demo has no OC, so the optional-content branch is never entered.

The 13 EMC calls produce matching endMarkedContent operators. The evaluator maintains markedContentLevel to keep the stack balanced; CanvasGraphics in Part 10 uses the same tags to maintain its visibility stack.

The page dictionary contains /StructParents 1, indicating a tagged PDF with a structure tree. The MCID is preserved here, but drawing only uses it as a marker; the structure-tree and text-layer linkage is a separate path.

Step 12: State Operators Are Copied One-to-One

The remaining operators are text state:

OperatorCountDrawing operatorArgument
Tc12setCharSpacing[0.0074], [0], [-0.0279]
Tw11setWordSpacing[-0.0002], [0.0281]
Td5moveText[1.825, -1.608], [-26.62, -1.188]
Tm6setTextMatrixFloat32Array(6)

They are copied one-to-one because the main thread must rebuild TextState in exactly the same order. showText itself carries no spacing, matrix, or scale information, only glyphs; the correct position comes from the state operators before it. Step 6 of Part 10 already showed how setCharSpacing and setTextMatrix update state, so this article does not repeat it.

One Chunk and Three Arrival Times

Step 13: 84 Is Far Below 1000, So One Flush

OperatorList uses two constants to control chunking:

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

Every addOp increments 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();
    }
  }
}

The demo has only 84 operators, so neither condition is reached and no flush happens during evaluation. The page has no annotations, so getOperatorList takes this branch:

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

The final chunk contains:

{
  fnArray: [...84 OPS numbers],
  argsArray: [...84 argument arrays],
  lastChunk: true,
  separateAnnots: null,
  length: 84,
}

Plus a transfer list of six ArrayBuffers. The main thread appends this chunk to its empty arrays and has the complete operator list immediately.

There is also an entry-point difference worth noting:

  • page.getOperatorList() adds the OPLIST flag and uses NullOptimizer;
  • page.render() with intent = 2 uses QueueOptimizer.

QueueOptimizer mainly merges path and image operators. The demo first page has neither, so both entry points emit the same 84 operators. A page with many small paths would not.

Step 14: The Race Between StartRenderPage, commonobj, and the Chunk

The worker sends four kinds of messages during one request:

OrderMessageDemo content
1StartRenderPage{ transparency: false, pageIndex: 0, cacheKey: "2__" }
2commonobj["g_d0_f1", "Font", ...]
3commonobj["g_d0_f2", "Font", ...]
4operatorList chunk84 operators + 6 transfers
5stream closesink.close()

Messages reach the main thread in postMessage FIFO order. But commonobj handling does not resolve synchronously:

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 resolves only after fontLoader.bind finishes. OperatorList chunk handling, by contrast, only appends arrays and triggers drawing:

_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();
  }
  ...
}

Two timings are therefore possible for the demo:

  1. The font finishes binding before the chunk is consumed: when CanvasGraphics reaches index 2, g_d0_f1 is already in commonObjs and execution continues;
  2. The chunk arrives first and the font is still binding: CanvasGraphics pauses at index 2 and resumes at the same index after commonObjs.resolve.

Nothing in the code guarantees which one happens. The dependency pause in Part 10 is not an exceptional path; it is the normal concurrency result of this request.

In a real browser, fonts usually require registering a FontFace and waiting for loading, so the second timing is more common. The demo font is constructed from fontData directly and may also be ready very quickly. This is why PDF.js makes dependency pausable and resumable instead of blocking the whole operatorList up front.

Step 15: Worker Time Slices and Backpressure

The main loop in getOperatorList does not run to completion in one go:

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

TimeSlotManager follows these rules:

static TIME_SLOT_DURATION_MS = 20;
static CHECK_TIME_EVERY = 100;

It looks at the clock only once every 100 checks. The demo has only 83 operators, so the loop finishes after 84 iterations and never even reaches a clock check. This request runs to completion on the worker in one pass.

Large pages differ: every 100 operators it checks the clock, and when 20ms have elapsed it exits the current synchronous loop, re-enters promiseBody through next(deferred), and the preprocessor continues from its current position in the stream. Unclosed marked content and graphics states are only closed at the actual end of the stream, keeping the stacks balanced.

next also waits for operatorList.ready:

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

operatorList.ready is tied to sink.ready, which indicates whether downstream can accept more. This is the streaming backpressure from Part 6: if the main thread consumes slowly, the worker does not pile up operators without bound.

When a request is cancelled, task.ensureNotTerminated() throws on the next loop entry, the current promiseBody stops, and sink.error sends the error back. The cancel path in Part 5 ends up here.

Summary

Starting from the main-thread GetOperatorList request, this article followed the demo first page 1293-byte content stream through operator production on the worker:

  1. _pumpOperatorList sends a streaming request with pageId = 0, intent = 2, and cacheKey = "2__";
  2. the worker obtains the Page via pdfManager.getPage(0) and creates a WorkerTask;
  3. Page.getOperatorList prepares the content stream, resources, and PartialEvaluator in parallel;
  4. StartRenderPage is sent before evaluation and carries only transparency and cacheKey;
  5. /Contents 11 0 R is FlateDecode-compressed from 542 bytes to 1293 bytes;
  6. EvaluatorPreprocessor reads 83 operators and validates their argument counts;
  7. the gs for GS0 contains only unsupported keys, produces 0 operators, and turns 83 into 82 direct operators;
  8. four Tf calls emit four setFont and two deduplicated dependency operators, bringing the total to 84;
  9. sixteen Tj and TJ calls all become showText, with TJ numbers kept in the same argument array;
  10. Tm uses Float32Array, and the six matrix buffers enter the transfer list;
  11. BDC keeps only ["Artifact", null] and ["Span", MCID], with 13 BDC / EMC pairs keeping the stack balanced;
  12. 84 is far below 1000, so there is one final flush(true) with a single chunk and lastChunk = true;
  13. the completion order of StartRenderPage, the two commonobj messages, and the chunk is not fixed, so drawing pauses at a dependency if a font is not bound yet;
  14. the worker checks its 20ms time slot every 100 operators, and the demo 83 operators finish in one pass;
  15. operatorList.ready feeds main-thread consumption speed back to the worker as backpressure.

GetOperatorList is therefore not a simple "parse the page" call, but an operator production pipeline with resource dependencies, pausing, backpressure, and chunking. The number of PDF operators and the number of drawing operators have no fixed relationship: some operators are dropped, some expand into several, and some inject extra resource placeholders during evaluation.

The operators and font dependencies are now in place. But g_d0_f1 in a dependency is only a placeholder: how does the embedded Type1C byte stream (FontFile3) become a browser-loadable FontFace? Part 12 unpacks the font translation pipeline: fingerprint deduplication, CFF repackaged into OpenType, binary FontInfo transfer, and FontFace mounting on the main thread.

StreamPDF design principle: anything that can be handled ahead of time is absolutely not deferred to parse time; the server solves all performance problems once. The browser only fetches and renders the data it must.

Related articles