StreamPDFStreamPDF
Back to blog

PDF.js Deep Dive from Demo 10: CanvasGraphics and operatorList Rendering

October 27, 2026 · StreamPDF Team

PDF.js Deep Dive from Demo 10: CanvasGraphics and operatorList Rendering

Preface

Part 5 reached the final two steps of render:

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

At this point, the worker has already streamed the drawing operators back through GetOperatorList, and the main thread has already received the demo's:

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

But operatorList is still just two sets of data, fnArray and argsArray. CanvasGraphics, the subject of this article, is what actually turns them into text, paths, and images on the canvas.

Calling page.getOperatorList() separately for the demo first page and aggregating the results yields 84 operators: 16 showText operators, 2 font dependency operators, and the rest text-state and marked-content operations. This article uses these real operators to answer:

  1. How does a numeric opcode find its corresponding drawing method?
  2. How does drawing pause and resume when a font has not yet arrived?
  3. Why can drawing begin before the complete operator list has been received?
  4. At exactly what point does renderTask.promise resolve?

For the complete CanvasGraphics class and all graphics, image, and transparency branches, see the canvas.js source walkthrough. This article follows only the actual execution path for the demo first page.

initializeGraphics: Set Up the Drawing Environment

Step 1: The demo directly reuses the supplied canvasContext

InternalRenderTask stores two distinct inputs when constructed:

this._canvas = params.canvas;                 // demo: null
this._canvasContext = params.canvas
  ? null
  : params.canvasContext;                     // demo: user-provided 2d context

The #canvasInUse check in initializeGraphics runs only when this._canvas exists. The demo passes canvasContext, not canvas, so it never enters this WeakSet branch and never calls canvas.getContext("2d") again:

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

The actual drawing executor is then created:

this.gfx = new CanvasGraphics(
  canvasContext,
  this.commonObjs,   // document-level objects, e.g. the g_d0_f1 font
  this.objs,         // page-level objects
  this.canvasFactory,
  this.filterFactory,
  { optionalContentConfig },
  this.annotationCanvasMap,
  this.pageColors,
  dependencyTracker,
  imagesTracker
);

The demo does not enable recording, so dependencyTracker and imagesTracker are both null; however, commonObjs and objs still handle retrieval of resources such as fonts and images.

Step 2: beginDrawing paints the background and applies the viewport

Immediately after creating CanvasGraphics, it runs:

this.gfx.beginDrawing({
  transform: null,       // demo does not pass an additional transform
  viewport,
  transparency,          // determined by the worker's StartRenderPage response
  background: null,
});

beginDrawing first fills the canvas, whose actual pixel dimensions are 297 × 421, with white:

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

If transparency = true, it also creates a temporary transparent canvas and composites it once at the end; this branch is needed for blend modes. It then saves and resets the context and applies the matrix calculated in Part 4:

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

At this point, the PDF user coordinate system is connected to the canvas coordinate system. For example, the PDF top-left corner (0, 842) maps to canvas (0, 0).

Finally, initializeGraphics sets:

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

operatorListIdx = 0 is the checkpoint for all subsequent drawing and resumption. If operators arrived before the graphics environment was initialized, graphicsReadyCallback triggers the pending start notification here.

operatorList: How Data Becomes Method Calls

Step 3: Streaming chunks are appended to the same pair of arrays

Each time the worker sends back an operatorListChunk, PDFPageProxy._renderPageChunk appends it to the current intentState:

for (let i = 0, ii = operatorListChunk.length; i < ii; i++) {
  intentState.operatorList.fnArray.push(operatorListChunk.fnArray[i]);
  intentState.operatorList.argsArray.push(operatorListChunk.argsArray[i]);
}
intentState.operatorList.lastChunk = operatorListChunk.lastChunk;
 
for (const internalRenderTask of intentState.renderTasks) {
  internalRenderTask.operatorListChanged();
}

InternalRenderTask holds a reference to that same operatorList, so it can see subsequently appended operators without recreating the render task.

operatorListChanged uses a three-stage check:

if (!this.graphicsReady) {
  this.graphicsReadyCallback ||= this._continueBound;
  return;                       // gfx not created; wait for initializeGraphics
}
if (this.running) {
  return;                       // already running; do not re-enter concurrently
}
this._continue();               // idle; start or resume drawing

The demo does not set renderTask.onContinue, so _continue() uses _scheduleNext() to schedule a requestAnimationFrame that calls _next() on the next animation frame.

Step 4: The demo first page's actual operators

Of the 84 operators aggregated for the first page, the first 7 are:

Index ifnArray[i]OperatorArgument summary
031beginTextnull
170beginMarkedContentProps["Artifact", null]
21dependency["g_d0_f1"]
337setFont["g_d0_f1", 1]
433setCharSpacing[0.0074]
542setTextMatrixSix matrix components
644showTextTwo glyphs: © and a space

The complete list contains only these operator types:

OperatorCountOperatorCount
beginText / endText1 eachbeginMarkedContentProps / endMarkedContent13 each
dependency2setFont4
setCharSpacing12setWordSpacing11
setTextMatrix6moveText5
showText16

This table also makes clear that a showText operator does not simply draw an entire "block of text" on its own. State operators such as font, character spacing, word spacing, and text matrix change the behavior of subsequent showText operators, so they must execute strictly in index order.

Step 5: Numeric IDs are dispatched through prototype aliases

_next() calls CanvasGraphics.executeOperatorList, passing the current checkpoint:

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

The execution loop retrieves the opcode and arguments at the same index:

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

fnId is numeric, so why can this[fnId] resolve to methods such as beginText and setFont? The answer is at the end of canvas.js:

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

After the class definition, PDF.js registers an additional numeric alias for each operator:

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

Therefore, when i = 3, the actual call is equivalent to:

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

The first argument, i, is the operator index, primarily used for dependency and drawing tracking; the remaining arguments come from argsArray[i].

Following the First 7 Operators

Step 6: beginText and marked content prepare the text state

beginText at i = 0 resets the text matrix and current position:

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

beginMarkedContentProps("Artifact", null) at i = 1 then pushes a marked-content state. Optional-content group visibility only needs to be checked when tag === "OC"; here the demo uses Artifact, so it is marked visible directly:

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

The subsequent endMarkedContent pops this state.

Step 7: dependency can pause drawing at i = 2

The operator at i = 2 is:

dependency("g_d0_f1")

It means that subsequent operators need the object whose ID is g_d0_f1. Because the ID starts with g_, the lookup uses the document-level commonObjs:

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

There are two cases:

  • The font has already been loaded by a commonobj message and resolved: execution continues at i = 3;
  • The font is not ready: _continueBound is registered with the object pool, executeOperatorList returns 2, and drawing pauses.

In the second case, setFont is never called while the font is missing. When the font data arrives and the object pool resolves, the callback re-enters _continue() and execution resumes from operatorListIdx = 2. This time the dependency is satisfied, so execution can proceed.

This is why dependency must remain in the operator sequence even though it does not draw anything itself: it encodes the resource wait point directly into the drawing instructions.

Step 8: setFont and text-state operators

Once the font is ready, the operator at i = 3 executes:

setFont("g_d0_f1", 1);

setFont retrieves the font object from commonObjs, stores it in current.font, and sets fontSize = 1. For non-Type3 fonts, it also sets the canvas's ctx.font.

Here, 1 is not a final "1px font size" displayed on the web page. PDF.js clamps the browser font size to 16–100px:

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

The actual drawing size is then derived by combining fontSizeScale, fontMatrix, textMatrix, and viewport.transform. Therefore, the second argument to setFont cannot be treated directly as a CSS pixel value.

The operator at i = 4 merely updates state:

this.current.charSpacing = 0.0074;

The operator at i = 5 stores the text matrix, calculates its horizontal scale, and resets the text coordinates:

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

All of this state is used by the showText operator that immediately follows.

Step 9: showText finally touches the text pixels

The showText at i = 6 receives two glyphs: © and a space. The method begins by applying the previously saved state:

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

It then processes each glyph in turn:

  • A glyph object provides information such as fontChar, unicode, width, and isSpace;
  • A numeric item is not a character, but an additional horizontal-spacing adjustment; later showText operators on the first page include values such as 5 and -28;
  • For ordinary fonts, paths such as paintChar ultimately call the canvas text-drawing API;
  • After each glyph is processed, x advances according to the font width, charSpacing, wordSpacing, and the matrices.
if (typeof glyph === "number") {
  x += (spacingDir * glyph * fontSize) / 1000;
  continue;
}
// Otherwise, draw glyph.fontChar, then advance by width and spacing

Finally, it updates current.x and restores the context:

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

Therefore, showText is not simply a single call to fillText(text, 0, 0). It must simultaneously honor the PDF's glyph widths, manual spacing, text matrix, font matrix, and the canvas's current transform.

Three Kinds of Pause, Three Resume Conditions

Step 10: Time slicing: yield the main thread after 15ms

executeOperatorList is a synchronous loop. If more than 10 operators remain and a continueCallback exists, it enables time slicing:

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

It checks elapsed time after roughly every 10 operators. Once execution exceeds 15ms:

continueCallback();
return i;

The demo's continueCallback is _continueBound, which schedules the next requestAnimationFrame. The return value i is saved as the new operatorListIdx; execution resumes from that checkpoint on the next animation frame, and operators that have already been drawn are not run again.

Step 11: Incomplete data and an unready dependency are not the same

Besides time slicing, the execution loop can pause in two other situations:

Pause reasonCurrent stateResume condition
Time slice expiresExecution has run for more than 15msNext animation frame (rAF)
Reached the current end of the arrays, but lastChunk = falseNo more operators are currently available to executeWorker pushes the next operatorList chunk
dependency object is missingPauses explicitly at a particular iObject pool receives and resolves the corresponding resource

In the second case, _next() sees:

if (this.operatorListIdx === this.operatorList.argsArray.length) {
  this.running = false;
  if (this.operatorList.lastChunk) {
    // Only lastChunk can finish the task
  }
}

It stops the current execution but does not call endDrawing. After _renderPageChunk appends more operators, operatorListChanged() starts _continue() again.

The third case resumes through the object-pool callback. All three reuse the same operatorListIdx, but they are waiting, respectively, for the next animation frame, the next operatorList chunk, or the missing resource to arrive.

lastChunk and renderTask.promise

Step 12: Executing the last operator does not necessarily mean completion

The last few operators on the first page are:

IndexOperator
80endMarkedContent
81endText
82beginMarkedContentProps("Artifact", null)
83endMarkedContent

When the operator at i = 83 finishes, i increments to 84, equal to argsArray.length, and the execution loop returns. But _next() enters the completion path only if operatorList.lastChunk === true:

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

endDrawing() restores the state saved before drawing, composites the temporary transparency canvas, and cleans up temporary resources such as patterns and soft masks. On the demo's main text path, the most important part is restoring the context so that state from this render does not leak into the next drawing operation.

Then this.callback() invokes complete, defined in Part 5:

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

internalRenderTask.capability is precisely the promise behind renderTask.promise. Only at this point can we say: all operators required by the first page's current render task have executed, and the drawing environment has been finalized.

Therefore, if the demo needs to export the canvas after rendering completes, it should use:

const renderTask = page.render(renderContext);
await renderTask.promise;
// Now read results such as canvas.toDataURL()

Summary

This article followed the 84 real operators from the demo first page through the complete CanvasGraphics drawing path:

  1. initializeGraphics reuses the canvasContext supplied by the demo and creates CanvasGraphics;
  2. beginDrawing paints the white background and applies [0.5, 0, 0, -0.5, 0, 421], establishing the PDF-to-canvas coordinate mapping;
  3. operatorListIdx starts at 0, and streaming chunks keep appending to the same fnArray / argsArray;
  4. Numeric opcodes dispatch through prototype aliases to same-named methods such as beginText, setFont, and showText;
  5. dependency("g_d0_f1") pauses when the font is not ready and resumes at the same index once the resource arrives;
  6. setFont, setCharSpacing, and setTextMatrix update state first, then showText draws glyph by glyph according to that state;
  7. Synchronous execution yields the main thread through rAF whenever it runs for more than 15ms, then resumes from the checkpoint;
  8. At the end of the arrays with lastChunk = false, execution waits for the next operatorList chunk instead of finishing early;
  9. Only after all operators have executed and lastChunk = true are endDrawing and complete called;
  10. internalRenderTask.capability.resolve() ultimately resolves renderTask.promise.

The core of CanvasGraphics is therefore not an individual drawing API, but a pausable, resumable operator executor that advances by index. It coordinates streaming data, asynchronous resources, and main-thread frame scheduling, allowing PDF.js to draw while parsing without rendering incorrect content when resources are not ready.

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