Home of the worldwide PDF technical community, the PDF Association provides a vendor-neutral platform for developing open specifications and standards for PDF technology.
Q: What is cacheKey for?
A: On the same page object, different rendering intents (display / print / annotations modified...) each maintain an independent set of operator lists and render tasks, stored in the page's _intentStates Map, keyed by cacheKey. As long as the intents differ, the caches never interfere with each other. The demo is a first-time render, so "2__" corresponds to a brand-new intentState.
#pendingCleanup = false: cancel any pending cleanup task, ensuring no memory reclamation happens during rendering;
optionalContentConfigPromise: the document's layer configuration (OCProperties). If the caller didn't pass it, it is fetched from the worker (the transport layer caches it). The layer mechanism will be covered in a separate article;
intentState: the state slot for this rendering intent, created as an empty object on first use.
Step 4: First render, start fetching the operator list
if (!intentState.displayReadyCapability) { intentState.displayReadyCapability = Promise.withResolvers(); intentState.operatorList = { fnArray: [], // opcode array argsArray: [], // argument array lastChunk: false, // whether all chunks have been received separateAnnots: null, }; this._pumpOperatorList(intentArgs);}
If displayReadyCapability doesn't exist, it means this intent has never requested an operator list, so the request is initiated immediately.
_pumpOperatorList establishes a streaming channel with the worker via messageHandler.sendWithStream("GetOperatorList", { pageId: 0, pageIndex: 0, intent: 2, cacheKey: "2__", ... }): the worker parses the page's content stream and pushes drawing instructions (opcodes + arguments) back to the main thread in chunks; each time the main thread receives a chunk, it appends it to operatorList and notifies all render tasks under this intentState that "new instructions are available to draw." Both the streaming communication and the worker-side instruction parsing will be covered in a separate article.
Q: Why streaming instead of returning everything at once?
A: Parsing instructions for large pages takes a long time. Streaming lets rendering start as early as possible (drawing while receiving), so first paint is faster; cancellation or failure can also be cleaned up partially. Correspondingly, once the worker parses the first batch of displayable content, it sends back a StartRenderPage message that resolves the displayReadyCapability above — this is one of the preconditions awaited in Step 7.
Step 5: Define the completion callback
const complete = error => { ... };
The complete closure handles the wrap-up: removing this task from intentState.renderTasks; on error, rejecting the capability and aborting the operator list fetch; on success, resolving the capability. The demo doesn't enable any recording switches, so all related branches are skipped.
Step 6: Create the InternalRenderTask
const internalRenderTask = new InternalRenderTask({ callback: complete, params: { canvas, canvasContext, dependencyTracker: null, imagesTracker: null, viewport, transform: null, background: null, }, objs: this.objs, // page-level resource container (created in Part 3) commonObjs: this.commonObjs, // document-wide shared resource container annotationCanvasMap: null, operatorList: intentState.operatorList, // shares the same object with the streaming fetch pageIndex: 0, canvasFactory: this._transport.canvasFactory, // the DOMCanvasFactory from Part 2 filterFactory: this._transport.filterFactory, useRequestAnimationFrame: true, // not printing pdfBug: false, pageColors: null, enableHWA: false, operationsFilter: null,});(intentState.renderTasks ||= new Set()).add(internalRenderTask);const renderTask = internalRenderTask.task;
Two key actions happen in the constructor:
this.capability = Promise.withResolvers() — the success/failure of rendering converges here;
this.task = new RenderTask(this) — the externally exposed handle, i.e., the final return value of render.
Note that operatorList is passed by reference: InternalRenderTask shares the same list object with the streaming fetch from Step 4, so instructions can be consumed as soon as they arrive.
Step 7: Wait for the preconditions, start drawing
Promise.all([ intentState.displayReadyCapability.promise, // operator list has received the first displayable content optionalContentConfigPromise, // layer config is ready]) .then(([transparency, optionalContentConfig]) => { if (this.destroyed) { complete(); return; } if (!(optionalContentConfig.renderingIntent & 2)) { throw new Error( "Must use the same `intent`-argument when calling the `PDFPageProxy.render` " + "and `PDFDocumentProxy.getOptionalContentConfig` methods." ); } internalRenderTask.initializeGraphics({ transparency, optionalContentConfig }); internalRenderTask.operatorListChanged(); }) .catch(complete);return renderTask;
Once both preconditions are ready:
destroyed check: the page may have been destroyed while waiting;
The layer config's intent must be compatible with this render's intent, otherwise an error is thrown — layer configs are cached per intent, and mixing them would corrupt layer state;
initializeGraphics: creates the CanvasGraphics (the object that actually executes drawing instructions), and checks that the same canvas must not be used concurrently by two render tasks (#canvasInUse is a WeakSet; reuse throws directly);
operatorListChanged: starts consuming the instructions that have already arrived in operatorList, drawing them onto the canvas one by one.
The drawing details of initializeGraphics and CanvasGraphics are covered in Article 10.
Return value: renderTask
The .then chain above advances asynchronously in the background, while render itself returns renderTasksynchronously.
Q: Does await page.render(renderContext) in the demo wait for rendering to complete?
A: No. render returns a RenderTask object, which only has a promise getter and no then method — it is not a thenable. Awaiting a non-thenable value resolves immediately with that value itself — this await actually waits for nothing. The correct way is:
The demo looks fine only because there is no code after the await; rendering proceeds in the background and draws onto the canvas. If subsequent code depends on "rendering has completed" (for example, exporting the canvas as an image), you must await renderTask.promise.
At this point, the last line of the demo has finished executing, and the first page is rendered onto the canvas.
Summary
This article followed the demo through the complete render({ canvasContext, viewport }) call:
Destructure 16 parameters; the demo only passes canvasContext and viewport;
getRenderingIntent bit operations: renderingIntent = 2, cacheKey = "2__";
Prepare the intentState and fill in the layer config promise;
First render: establish the GetOperatorList streaming channel, drawing while receiving;
Create the InternalRenderTask and the external handle RenderTask;
Once the preconditions are ready, initializeGraphics + operatorListChanged start the drawing;
renderTask is returned synchronously; the rendering completion signal lives on renderTask.promise.
Looking back at the demo from the first article: getDocument → getPage → getViewport → render, each step merely "initiates" — the real parsing and drawing happens inside the worker thread (pdfManager, GetOperatorList) and the main thread's CanvasGraphics. Starting from the next article, we enter these "heavyweight players."