PDF.js Deep Dive from Demo 10: CanvasGraphics and operatorList Rendering
October 27, 2026 · StreamPDF Team

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:
- How does a numeric opcode find its corresponding drawing method?
- How does drawing pause and resume when a font has not yet arrived?
- Why can drawing begin before the complete operator list has been received?
- At exactly what point does
renderTask.promiseresolve?
For the complete
CanvasGraphicsclass 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 contextThe #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 drawingThe 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 i | fnArray[i] | Operator | Argument summary |
|---|---|---|---|
0 | 31 | beginText | null |
1 | 70 | beginMarkedContentProps | ["Artifact", null] |
2 | 1 | dependency | ["g_d0_f1"] |
3 | 37 | setFont | ["g_d0_f1", 1] |
4 | 33 | setCharSpacing | [0.0074] |
5 | 42 | setTextMatrix | Six matrix components |
6 | 44 | showText | Two glyphs: © and a space |
The complete list contains only these operator types:
| Operator | Count | Operator | Count |
|---|---|---|---|
beginText / endText | 1 each | beginMarkedContentProps / endMarkedContent | 13 each |
dependency | 2 | setFont | 4 |
setCharSpacing | 12 | setWordSpacing | 11 |
setTextMatrix | 6 | moveText | 5 |
showText | 16 |
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
commonobjmessage and resolved: execution continues ati = 3; - The font is not ready:
_continueBoundis registered with the object pool,executeOperatorListreturns2, 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, andisSpace; - A numeric item is not a character, but an additional horizontal-spacing adjustment; later
showTextoperators on the first page include values such as5and-28; - For ordinary fonts, paths such as
paintCharultimately call the canvas text-drawing API; - After each glyph is processed,
xadvances 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 spacingFinally, 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 reason | Current state | Resume condition |
|---|---|---|
| Time slice expires | Execution has run for more than 15ms | Next animation frame (rAF) |
Reached the current end of the arrays, but lastChunk = false | No more operators are currently available to execute | Worker pushes the next operatorList chunk |
dependency object is missing | Pauses explicitly at a particular i | Object 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:
| Index | Operator |
|---|---|
80 | endMarkedContent |
81 | endText |
82 | beginMarkedContentProps("Artifact", null) |
83 | endMarkedContent |
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:
initializeGraphicsreuses thecanvasContextsupplied by the demo and createsCanvasGraphics;beginDrawingpaints the white background and applies[0.5, 0, 0, -0.5, 0, 421], establishing the PDF-to-canvas coordinate mapping;operatorListIdxstarts at0, and streaming chunks keep appending to the samefnArray/argsArray;- Numeric opcodes dispatch through prototype aliases to same-named methods such as
beginText,setFont, andshowText; dependency("g_d0_f1")pauses when the font is not ready and resumes at the same index once the resource arrives;setFont,setCharSpacing, andsetTextMatrixupdate state first, thenshowTextdraws glyph by glyph according to that state;- Synchronous execution yields the main thread through rAF whenever it runs for more than 15ms, then resumes from the checkpoint;
- At the end of the arrays with
lastChunk = false, execution waits for the next operatorList chunk instead of finishing early; - Only after all operators have executed and
lastChunk = trueareendDrawingandcompletecalled; internalRenderTask.capability.resolve()ultimately resolvesrenderTask.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
- PDF.js Deep Dive from Demo 9: Network Streams and Range RequestsOctober 27, 2026
- PDF.js Deep Dive from Demo 8: PDFWorker.create and the Real/Fake Worker HandshakeOctober 27, 2026
- PDF.js Deep Dive from Demo 7: WorkerTransport InitializationOctober 27, 2026
- PDF.js Deep Dive from Demo 6: MessageHandler Cross-Thread CommunicationOctober 27, 2026
- PDF.js Deep Dive from Demo 5: renderOctober 27, 2026
- PDF.js Deep Dive from Demo 4: getViewportOctober 27, 2026
- PDF.js Deep Dive from Demo 3: getPageOctober 27, 2026
- PDF.js Deep Dive from Demo 2: getDocumentOctober 27, 2026
- PDF.js Source Walkthrough: TextLayerOctober 27, 2026
- PDF.js Source Walkthrough: font_loaderOctober 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_displayOctober 27, 2026
- PDF.js Source Walkthrough: PDFObjectsOctober 27, 2026
- PDF.js Source Walkthrough: Network StreamsOctober 27, 2026
- PDF.js Source Walkthrough: internal_evt.jsOctober 27, 2026
- PDF.js Source Walkthrough: math_clamp.jsOctober 27, 2026
- PDF.js Source Walkthrough: css_utils.jsOctober 27, 2026
- PDF.js Source Walkthrough: scripting_utilsOctober 27, 2026
- PDF.js Source Walkthrough: MurmurHash3_64October 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_utilsOctober 27, 2026
- PDF.js Source Walkthrough: image_utils.jsOctober 27, 2026
- PDF.js Source Walkthrough: BasePDFStreamOctober 27, 2026
- PDF.js Source Walkthrough: Shared UtilitiesOctober 27, 2026
- PDF.js Source Walkthrough: MessageHandlerOctober 27, 2026
- PDF.js Source Walkthrough: apiOctober 27, 2026
- PDF.js Source Walkthrough: fontsOctober 27, 2026
- PDF.js Source Walkthrough: glyphlistOctober 27, 2026
- PDF.js Source Walkthrough: binary_cmapOctober 27, 2026
- PDF.js Source Walkthrough: cmapOctober 27, 2026
- PDF.js Source Walkthrough: encodingsOctober 27, 2026
- PDF.js Source Walkthrough: metricsOctober 27, 2026
- PDF.js Source Walkthrough: standard_fontsOctober 27, 2026
- PDF.js Source Walkthrough: fonts_utilsOctober 27, 2026
- PDF.js Source Walkthrough: default_appearanceOctober 27, 2026
- PDF.js Source Walkthrough: metadata_parserOctober 27, 2026
- PDF.js Source Walkthrough: name_number_treeOctober 27, 2026
- PDF.js Source Walkthrough: struct_treeOctober 27, 2026
- PDF.js Source Walkthrough: catalogOctober 27, 2026
- PDF.js Source Walkthrough: pdf_managerOctober 27, 2026
- PDF.js Source Walkthrough: object_loaderOctober 27, 2026
- PDF.js Source Walkthrough: obj_bin_transform_coreOctober 27, 2026
- PDF.js Source Walkthrough: cryptoOctober 27, 2026
- PDF.js Source Walkthrough: core_utilsOctober 27, 2026
- PDF.js Source Walkthrough: primitivesOctober 27, 2026
- PDF.js Source Walkthrough: worker_streamOctober 27, 2026
- PDF.js Source Walkthrough: ccitt_streamOctober 27, 2026
- PDF.js Source Walkthrough: jpx_streamOctober 27, 2026
- PDF.js Source Walkthrough: jbig2_streamOctober 27, 2026
- PDF.js Source Walkthrough: jpeg_streamOctober 27, 2026
- PDF.js Source Walkthrough: decrypt_streamOctober 27, 2026
- PDF.js Source Walkthrough: brotli_streamOctober 27, 2026
- PDF.js Source Walkthrough: run_length_streamOctober 27, 2026
- PDF.js Source Walkthrough: ascii_hex_streamOctober 27, 2026
- PDF.js Source Walkthrough: ascii_85_streamOctober 27, 2026
- PDF.js Source Walkthrough: lzw_streamOctober 27, 2026
- PDF.js Source Walkthrough: predictor_streamOctober 27, 2026
- PDF.js Source Walkthrough: flate_streamOctober 27, 2026
- PDF.js Source Walkthrough: decode_streamOctober 27, 2026
- PDF.js Source Walkthrough: chunked_streamOctober 27, 2026
- PDF.js Source Walkthrough: streamOctober 27, 2026
- PDF.js Source Walkthrough: base_streamOctober 27, 2026
- PDF.js Deep Dive 11: ParserOctober 26, 2026
- PDF.js Deep Dive 10: XRefOctober 26, 2026
- PDF.js Deep Dive 9: CanvasGraphicsOctober 26, 2026
- PDF.js Deep Dive 8: PDFDocumentOctober 26, 2026
- The AI Era: What Should We Do to Survive? (Reflections on Founding StreamPDF)October 26, 2026
- PDF.js Deep Dive 7: PartialEvaluatorOctober 25, 2026
- PDF.js Deep Dive 6: PDFManager.initDocumentOctober 25, 2026
- PDF.js Deep Dive 5: PDFFetchStream and PDFWorkerStreamOctober 25, 2026
- PDF.js Deep Dive 4: MessageHandlerOctober 22, 2026
- PDF.js Deep Dive 3: getViewport & renderOctober 22, 2026
- PDF.js Deep Dive from Demo 1: Rendering a PDF with a Custom ViewerOctober 19, 2026
- PDF.js Deep Dive 2: getDocument & getPageOctober 19, 2026
- PDF.js Deep Dive 1: The Architecture of PDF.jsOctober 16, 2026