Skip to content

Offload canvas drawing operations to a worker - #21860

Open
Aditi-1400 wants to merge 22 commits into
mozilla:masterfrom
Aditi-1400:canvas-worker
Open

Offload canvas drawing operations to a worker #21860
Aditi-1400 wants to merge 22 commits into
mozilla:masterfrom
Aditi-1400:canvas-worker

Conversation

@Aditi-1400

@Aditi-1400 Aditi-1400 commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

This follow-up aims to achieve the same goal as PR #20729 but with a slight change in the approach, as suggested by @calixteman, instead of transferring the page's canvas to the renderer worker with transferControlToOffscreen, the worker now draws into an OffscreenCanvas of its own and returns the result to the main thread as an ImageBitmap, so the main thread keeps ownership of the destination canvas.

For long-running pages the worker emits partial frames, while the operator list is executing, the canvas is snapshotted at most every 500ms (at chunk boundaries and dependency pauses), so pages paint progressively.
Returning bitmaps instead of taking over the canvas simplifies things quite a lot compared to the transferred-canvas approach. transferControlToOffscreen was irreversible and detached the canvas from the main thread, so every re-render needed a fresh canvas and a new transfer, the viewer had to track worker-owned canvases specially (thumbnails, detail views, print), falling back to main-thread rendering mid-document meant the original canvas was no longer usable, and the test harness needed workarounds for canvases it could no longer read back. With the bitmap approach the visible canvas stays an ordinary main-thread canvas at all times: the viewer, thumbnails, and fallback paths work unchanged, either rendering path can draw to the same canvas, the harness reverts to reading canvases directly etc.

Worker rendering falls back to the main thread when the render can't be handled there: a non-string background (gradients/patterns don't structured-clone), pages using TR-based canvas filters (OffscreenCanvasRenderingContext2D ignores .filter values set from a data URL, bug 2011237), pageColors (needs DOM-based SVG filters), and the pdfBug stepper (needs the graphics on the main thread). Worker rendering is disabled by default in built targets via the new disableWorkerRendering preference and enabled for local development; the test suites opt in explicitly.

Also, rendering is disabled by default in the production viewer builds: the disableWorkerRendering preference defaults to true everywhere except the local dev viewer and the TESTING builds, so Firefox and the generic viewer keep rendering on the main thread until we explicitly flip the pref. The test suites opt in through GlobalWorkerOptions.rendererSrc directly, so all of this is still covered by CI, and API consumers can opt in the same way.

@codecov-commenter

codecov-commenter commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.61538% with 70 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.46%. Comparing base (3463d92) to head (fae5181).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
src/display/api.js 85.30% 36 Missing ⚠️
src/display/object_handler.js 84.12% 10 Missing ⚠️
src/core/evaluator.js 87.67% 9 Missing ⚠️
src/display/canvas.js 86.79% 7 Missing ⚠️
src/display/pdf_objects.js 16.66% 5 Missing ⚠️
src/display/canvas_dependency_tracker.js 50.00% 1 Missing ⚠️
src/display/canvas_factory.js 0.00% 1 Missing ⚠️
src/display/worker_options.js 80.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #21860      +/-   ##
==========================================
- Coverage   90.26%   89.46%   -0.80%     
==========================================
  Files         264      265       +1     
  Lines       67287    67618     +331     
==========================================
- Hits        60738    60497     -241     
- Misses       6549     7121     +572     
Flag Coverage Δ
browsertest 65.59% <77.58%> (-0.72%) ⬇️
fonttest 8.94% <ø> (-0.01%) ⬇️
integrationtest 68.54% <75.60%> (-0.87%) ⬇️
unittest 58.55% <70.32%> (-0.13%) ⬇️
unittestcli 57.14% <49.67%> (-0.10%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@Aditi-1400
Aditi-1400 marked this pull request as ready for review September 1, 2026 03:00
Comment thread src/core/evaluator.js
}

handleTransferFunction(tr) {
_getTransferFunctions(tr) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't this method be actually private?

Suggested change
_getTransferFunctions(tr) {
#getTransferFunctions(tr) {

Comment thread src/display/filter_factory.js Outdated
destroy(keepHCM = false) {}
}

class WorkerFilterFactory extends BaseFilterFactory {}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be placed after the DOMFilterFactory class.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0dcc20f

Comment thread src/display/renderer_worker.js Outdated
Comment on lines +62 to +67
let objs = this.#objsMap.get(pageId);
if (!objs) {
objs = new PDFObjects();
this.#objsMap.set(pageId, objs);
}
return objs;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let objs = this.#objsMap.get(pageId);
if (!objs) {
objs = new PDFObjects();
this.#objsMap.set(pageId, objs);
}
return objs;
return this.#objsMap.getOrInsertComputed(pageId, () => new PDFObjects());

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1e30152

Comment thread src/core/evaluator.js
return false;
}

hasCanvasFilters(resources, nonCanvasFiltersSet) {

@Snuffleupagus Snuffleupagus Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems really unfortunate to more-or-less duplicate the parsing in hasBlendModes, since this isn't exactly free given that it requires looking up Stream-instances (and those cannot be cached on the XRef-instance).

Could we somehow get https://bugzilla.mozilla.org/show_bug.cgi?id=2011237 prioritized, such that all of this new pre-parsing isn't necessary?
/cc @calixteman

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes it's a bit painful to have to do that...

}

static async #sendFrame(handler, renderTaskState, isFinal) {
const { canvas, renderTaskId } = renderTaskState;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If transparency is true then cnavas.js::beginDrawing set a scratch canvas so all the intermediate canvases you send here will be blank.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ac59e14, the final frame should be unaffected since it's taken after endDrawing.

}

static #cleanupPage(pageId) {
this.#cleanedPages.add(pageId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I need to check to be sure, but I think it could be broken with a pdf where some pages have been removed. I'll have a look on that, but for a follow-up.

// #maybeSendInterimFrame ensures that at most one frame is sent
// when nothing was painted in between.
await this.#maybeSendInterimFrame(handler, renderTaskState);
await promise;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The promise is already settled when we reach this await because gfx.executeOperatorList is sync and resolve has been called (as the continueCallback function).
And the 2 calls to #maybeSendInterimFrame are a bit strange and they can return before the async part, so maybe I'm wrong but the while loop could just unbreakable (at least not breakable with a renderTaskState.aborted).

Comment thread src/display/renderer_worker.js Outdated
if (!renderTaskState) {
// A render task can be cleaned up before queued
// ExecuteOperatorList messages for that task are processed.
return { operatorListIdx };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't there a risk for spinning forever ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, worker does not have the task state in the following cases:

  1. CleanupRenderTask, only happens when we cancel the rendering, so cancelled is already set.
  2. Final chunk: this is safe as well.
  3. InitializeGraphics fails: we fallback to main-thread rendering, so no new messages here.
  4. Both callsites of cleanupPage are safe as well.
    Nothing else should delete the renderTaskState.
    However, if we have a wrongly targeted page, then it might happen, which is the case here: https://github.com/mozilla/pdf.js/pull/21860/changes#r3904812097

But in any case, infinite retry problem for any case should be fixed by: bd758b0

Comment thread src/display/api.js
static #renderTaskId = 0;

static handleRenderFrame(frame) {
const internalTask = InternalRenderTask.#activeRenderTasks.get(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't close the annotationBitmaps. Should we ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed here: 3e4f17c

Comment thread src/core/evaluator.js
return false;
}

hasCanvasFilters(resources, nonCanvasFiltersSet) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about annotations ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, hmm, how likely are we to encounter TR inside annotations, because then parsing/rendering is blocked on the annotations being both loaded and parsed before the page can render.

I skipped Type3 glyph streams too, because I don't expect us to encounter TR filters inside them? What do you think?

Comment thread web/base_pdf_page_view.js
if (
!isLastShow &&
this.minDurationToUpdateCanvas > 0 &&
!this.renderTask?.isWorkerRendering

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably it'd require to have:

  #renderContinueCallback = cont => {
-   this.#showCanvas?.(false);
+   // In the worker path the canvas only gains pixels in `onFrame`.
+   if (!this.renderTask?.isWorkerRendering) {
+     this.#showCanvas?.(false);
+   }
    if (this.renderingQueue && !this.renderingQueue.isHighestPriority(this)) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c190d4b

Comment thread external/dist/webpack.mjs Outdated
Comment on lines +24 to +27
GlobalWorkerOptions.rendererSrc = new URL(
"./build/pdf.renderer.mjs",
import.meta.url
).href;

@Snuffleupagus Snuffleupagus Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this, since this file is a mere helper which shouldn't unconditionally enable a (big) new feature that's still in active development.

Comment thread src/display/api.js Outdated
Comment on lines +3938 to +4004
const { rendererHandler } = this;
if (!rendererHandler) {
throw new Error("Renderer worker was destroyed during rendering.");
}
const operatorListArgsArrayLen = operatorList.argsArray.length;
const sentLength = this._sentOperatorListLength;
const hasNewOps = sentLength < operatorListArgsArrayLen;
const fnArray = hasNewOps
? operatorList.fnArray.slice(sentLength, operatorListArgsArrayLen)
: null;
const argsArray = hasNewOps
? operatorList.argsArray.slice(sentLength, operatorListArgsArrayLen)
: null;
// Since operationsFilter is a function and cannot be structured-cloned,
// precomputing the results for the ops being sent as a mask that the
// worker can index into.
let operationsFilterMask = null;
if (fnArray && this._operationsFilter) {
operationsFilterMask = new Uint8Array(fnArray.length);
for (let i = 0, ii = fnArray.length; i < ii; i++) {
operationsFilterMask[i] = this._operationsFilter(sentLength + i)
? 1
: 0;
}
}
const sentLastChunk = operatorList.lastChunk;
const response = await rendererHandler.sendWithPromise(
"ExecuteOperatorList",
{
renderTaskId: this._renderTaskId,
fnArray,
argsArray,
operatorListIdx,
operationsFilterMask,
lastChunk: sentLastChunk,
}
);
this.operatorListIdx = response.operatorListIdx;
// Only the final chunk carries `recordedBBoxes` / `imageCoordinates`.
if (response.recordedBBoxesBuffer) {
this.recordedBBoxes = BBoxReader.fromBuffer(
response.recordedBBoxesBuffer
);
}
if (response.imageCoordinates) {
this.imageCoordinates = response.imageCoordinates;
}
this._sentOperatorListLength = operatorListArgsArrayLen;
if (this.cancelled) {
return;
}
if (response.aborted) {
throw new Error("Render task was aborted in the renderer worker.");
}

if (this.operatorListIdx === operatorList.argsArray.length) {
this.running = false;
if (sentLastChunk) {
InternalRenderTask.#activeRenderTasks.delete(this._renderTaskId);
InternalRenderTask.#canvasInUse.delete(this._canvas);
this.callback();
} else if (this.operatorList.lastChunk) {
this._continue();
}
} else {
this._continue();
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like something that could be placed in its own private method, to improve readability.

Comment thread src/display/api.js Outdated
Comment on lines +2244 to +2246
messageHandler.on("RenderFrame", frame => {
InternalRenderTask.handleRenderFrame(frame);
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks quite strange (or even wrong) to place this here, since at this point there's no guarantee that renderer-worker initialization will even succeed.

Perhaps doing this e.g. just after this.setupMessageHandler(); would make more sense?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved it to this.setupMessageHandler();

Move the commonobj/obj resolution logic from WorkerTransport.setupMessageHandler
into a reusable ObjectHandler class. This enables sharing object resolution
between the main thread and other workers.

This commit is a part of the renderer-worker series, the worker rendering
stays disabled until the final commit in this series.
This commit walks the page's resource tree, same as hasBlendModes, to
detect the Transfer functions which are renderered with SVG filters,
and the result is included in StartRenderPage data.
The validation half of the handleTransferFunction is extracted
into _getTransferFunctions so that it can be reused without building
transfer maps. The negative results are cached in nonCanvasFilterSet.

This commit is a part of the renderer-worker series, the worker rendering
stays disabled until the final commit in this series.
Materializing a path currently stores the Path2D in argsArray[i][0],
i.e. in the operator list's arguments. Cache it in a Map on the
operator list instead, keyed by the operator index, so that the
arguments stay structured-cloneable while re-renders of the same list
still reuse the materialized paths.

Nested operator-list executions share one CanvasGraphics, so
executeOperatorList restores the previous cache when it returns.

This commit is a part of the renderer-worker series, the worker rendering
stays disabled until the final commit in this series.
Introduces a new RendererWorker class that spawns a dedicated worker
intended for canvas rendering, mirroring PDFWorker. The worker is
initialised via the new GlobalWorkerOptions.rendererSrc option.
In this commit, the worker only performs the transfer handshake.

getDocument creates one worker per document unless the
new disableWorkerRendering option, an unset rendererSrc, or
an unsupported environment disables it. A worker that
fails to start is discarded with a warning.

The rendererSrc is deliberately left unset in this commit so,
no worker is spawned anywhere until worker rendering is enabled.

This commit is a part of the renderer-worker series, the worker rendering
stays disabled until the final commit in this series.
Fonts, images and other objects are forwarded to the renderer worker
as they arrive from the core worker, before the main thread
resolves them, and are kept in per-page stores keyed by the stable page
id. Page and document cleanup is mirrored so that the
worker's stores don't outlive their main-thread counterparts, and a
forwarding failure rejects the object in the worker so that nothing
ever waits on it.

This commit is a part of the renderer-worker series, the worker rendering
stays disabled until the final commit in this series.
The renderer worker accepts a page's operator list in chunks with
ExecuteOperatorList, appends them to a per-task list, and draws them
into an OffscreenCanvas of its own that InitializeGraphics sets up,
resolving object dependencies from the stores that the forwarding
keeps filled. A rejected object aborts the render task instead of
hanging it.

The main thread keeps ownership of the destination canvas: the worker
returns its finished drawing as an ImageBitmap in a one-way RenderFrame
message and the main thread blits it. Renders that the worker cannot
handle yet (a caller-supplied canvas context, a non-string background,
annotation canvases, operation recording, pageColors, the pdfBug
stepper) fall back to main-thread rendering.

This commit is a part of the renderer-worker series, the worker rendering
stays disabled until the final commit in this series.
Pages whose annotations render onto separate canvases no longer fall
back to the main thread: the renderer worker creates the annotation
canvases as OffscreenCanvases, flattens them into [id, name, bitmap]
tuples alongside the final page frame, and the main thread rebuilds
them as DOM canvases for the annotation layer. Named canvases replace
any same-named entry from a previous render, exactly as beginAnnotation
does on the main thread.

This commit is a part of the renderer-worker series, the worker rendering
stays disabled until the final commit in this series.
Renders that record operation bounding boxes or image locations, used
for detail views and partial redraws, no longer fall back to the main
thread. The renderer worker builds the bbox/dependency/images trackers
itself and returns the recorded data with the final ExecuteOperatorList
response; debug metadata stays main-thread only, since the pdfBug
stepper disables worker rendering anyway.

This commit is a part of the renderer-worker series, the worker rendering
stays disabled until the final commit in this series.
Render pages in the renderer worker whenever the environment and the
render parameters allow it, gated by the new disableWorkerRendering
preference. Worker rendering is disabled by default in built targets
and enabled for local development; the test suites opt in explicitly.

Pages that use TR-based canvas filters keep rendering on the main
thread, since OffscreenCanvasRenderingContext2D ignores `.filter`
values set from a data URL (bug 2011237): the detection added earlier
in this series is now reported through StartRenderPage and consumed by
the render gate. The same holds for the pdfBug stepper, whose debug
recording needs the graphics on the main thread, and for pageColors,
which needs DOM-based SVG filters. Rendering a page that was moved or
copied is covered by a new integration test, since the renderer
worker's object stores are keyed by the stable page id.

This commit is a part of the renderer-worker series.
While the renderer worker is executing a long operator list, snapshot
the canvas at most every PARTIAL_FRAME_TIME milliseconds - at operator
list chunk boundaries and when a chunk pauses on a dependency - and
send the intermediate result to the main thread, so that long pages
paint progressively instead of appearing all at once. Interim frames
use createImageBitmap, since transferToImageBitmap would clear the
canvas that is still being drawn into.

The new RenderTask.onFrame callback fires after each frame, the final
one included. The viewer opts in with the new partialFrames render
parameter and skips its own temporary-canvas throttling for worker
renders, since each frame handed back is already a complete,
worker-throttled snapshot.

This commit is a part of the renderer-worker series.
When the enableWebGPU option is set, the renderer worker initialises
its own GPU device before creating its rendering surfaces, in the same
way that getDocument starts the GPU initialisation on the main thread.

This commit is a part of the renderer-worker series.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants