Skip to content

Offload canvas drawing operations to a worker #20729

Open
Aditi-1400 wants to merge 11 commits into
mozilla:masterfrom
Aditi-1400:worker-drawing
Open

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

Conversation

@Aditi-1400

@Aditi-1400 Aditi-1400 commented Feb 24, 2026

Copy link
Copy Markdown
Collaborator

Commit descriptions

Note that commit hashes might change in the future due to commit edits later

1. 34ab2b6: Extract ObjectHandler from WorkerTransport …

Extract ObjectHandler from WorkerTransport
Move the commonobj/obj resolution logic from WorkerTransport.setupMessageHandler
into a reusable ObjectHandler class. This enables sharing the object resolution
logic between the main thread (WorkerTransport) and the renderer worker.

Notable changes:

  1. The new argument this.shouldCreatePageObjs is added in object handler, it does not affect the main-thread rendering, it is set to false, but for worker-rendering, the renderer worker keeps only Map<pageIndex, PDFObjects>, not PDFPageProxy instances. So object_handler.js (line 126) has to handle both shapes.
    The shouldCreatePageObjs part exists because renderer-worker obj messages can arrive before InitializeGraphics has called #getPageObjs(pageIndex). In that case the renderer still must cache the image/pattern object, otherwise later CanvasGraphics will hit an unresolved dependency while executing the operator list.

Relevent code:

    let pageOrObjs = this.pageCache.get(pageIndex);
    if (!pageOrObjs) {
      if (!this.shouldCreatePageObjs) {
        return;
      }
      pageOrObjs = new PDFObjects();
      this.pageCache.set(pageIndex, pageOrObjs);
    }

    const objs = pageOrObjs.objs || pageOrObjs;
    if (objs.has(id)) {
      return;
    }

Open questions:

  1. We should probably add unit tests for ObjectHandler class?

2. 741ca8d: Adds RendererWorker class for offloading canvas …

Adds RendererWorker class for offloading canvas

Introduce the RendererWorker class for offloading canvas rendering
to a dedicated Web Worker. Alongside, it adds RendererMessageHandler,
GlobalWorkerOptions.rendererSrc configuration, entrypoints for
pdf.renderer.js bundle and build targets in gulpfile.

No rendering changes are introduced in this commit, this is a setup for
later commits that wire-up graphics execution and object forwarding.

Notable changes

This commit just sets up the renderer worker while following the same pattern as PDFWorker setup.

  1. Introduces a new global flag disableWorkerRendering for disabling the worker-rendering. Note that the flag is only checked once to check whether we should set up the RendererHandler, all the further decisions to use the worker-rendering are deferred to whether RendererHandler is not null.
  2. In this commit the worker rendering is disabled when there is no Worker API, OffScreenCanvas is not supported, a custom ownerDocument is present, since custom ownerDocument can be iframe document etc. which cannot be transferred to a worker and the worker cannot create DOM nodes inside it. Same for styleElement, it is used by FontLoader, and is also a DOM element.
    In the renderer worker, fonts would instead be loaded via the FontFace API (self.fonts.add()). When a custom styleElement is provided (testing scenarios), it forces CSS-based font loading instead of the FontFace API. Since CSS font loading doesn't work in a worker context, the renderer worker can't render fonts correctly if styleElement is in use.

3. c28992d: Add canvas filter detection in core layer …

Add canvas filter detection in core layer

Add hasCanvasFilters method to PartialEvaluator that traverses page
resources (including Pattern and Type3 Font sub-resources) to detect
transfer maps and SMask operations requiring DOM SVG filters. These
filters are unsupported by OffscreenCanvas, so detecting them early
allows the display layer to fall back to main-thread rendering.

Due to bug https://bugzilla.mozilla.org/show_bug.cgi?id=2011237, since there's no DOM access from a worker the filter has to be defined with an external URL which is not currently supported in OffscreenCanvasRenderingContext2D

Notable changes:

  1. The hasCanvasFilters method is very similar to hasBlendModes where both of these methods traverse the resource graph to check the presence of canvas filters. hasCanvasFilters however returns true conservatively. I considererd reusing some of the code from hasBlendModes but that made the patch more complex and hard to read.
  2. It also checks annotations for resources since an annotation can have its own appearance stream, and that appearance stream has its own /Resources dictionary. Annotation rendering later calls annotation.getOperatorList(...), and that uses the appearance stream resources, not the page resources.

4. ffe8e9a: Add object forwarding between main thread and renderer worker …

Add object forwarding between main thread and renderer worker

Add object forwarding so the renderer worker receives
the same commonobj/obj messages as the main thread

WorkerTransport now forwards commonobj/obj to the rendererHandler.
Additionally, allow _startRenderPage propagates
hasCanvasFilters from core layer.

Open questions

At present the way renderer works is we use the main thread for forwarding the objs/commonObjs and font fallback instead of PDFworker directly sending it to the renderer worker. So objs/commonObjs are duplicated and also adds an additional hop.

  1. The commonobjs/objs are still being duplicated for sending to each of main-thread and renderer, which can be fixed in the following two ways:
    a. Use SAB
    b. Move the InternalRenderTask entirely to the renderer worker, I think this is possible and this is the approach I would prefer, to not have main thread deal with objs/commonObjs at all, but this is a much larger refactor which I am not sure should be a part of this patch.

  2. While fixing a. appears to be somewhat easy, I have tried with SAB and the current version of sending to both main and renderer is not the best approach because there is a race-condition that it introduces that causes browser tests to almost always timeout, I've tried fixing several but so far, the tests still timeout, if we want to remove the forwarding, I can spend more time looking into forwarding, however I think if we remove the dependency on main thread entirely, it should fix both issues.

For now there's a TODO comment to remove the forwarding in the future.

5. 9ddbd41: Add graphics initialization and operator list execution in renderer worker …

Add graphics initialization and operator list execution in renderer worker

This decides whether to use worker rendering based on hasCanvasFilters,
pageColors,dependency/image tracking. It transfers the canvas via
transferControlToOffscreen and sends operator list chunks incrementally.
In the renderer worker, it adds the functionality to initialize
graphics and execute the operator list.

Notable changes

  1. api.js
    a. keepRendererCanvas: This flag is added to to still clear normal page state, but ask the renderer worker to keep the transferred canvas alive. This lets PDF.js free operator lists, page objects, fonts/images tied to the page, etc., without making already-rendered visible pages go blank when scrolling etc.
    b. In main-thread rendering, CanvasGraphics gets the actual OptionalContentConfig instance directly. In renderer-worker rendering, that object cannot be sent as-is: it has class methods/private fields. There is a change to pass the plain data we receive from PDFWorker and rebuilding the object on the worker side.
    c. We also need to transfer the annotation canvases, so we find the annotations with hasOwnCanvas, and send it to renderer worker. It also caches the transferred canvases in _transferredAnnotationCanvasIds.
    d. Worker rendering is disabled when we have canvas filters and page colors for reasons described above and it falls back to main-thread rendering. It is also disabled when dependencyTracker and imagesTracker are present, this would require making them transferable, which is not too complex and can be done in future iterations.
    e. Operator list is sent to renderer worker in chunks. Sending the full growing operator list every time would be very expensive. So the main thread only sends the new tail of the operator list: from the last sent index to the current length. After the renderer worker receives it, it appends that partition to its own worker-local operator list.

7. f085c07: Adapt viewer and tests for OffscreenCanvas renderer worker …

Adapt viewer and tests for OffscreenCanvas renderer worker

Update the viewer and tests to handle canvases that have
been transferred to an OffscreenCanvas via the renderer worker.

Notable changes

  1. In viewer_spec.mjs, it first tries the old direct path, and then snapshots the placeholder canvas with createImageBitmap(canvas), draws that bitmap into temporary canvs, calls getImageData on the temporary canvas.
  2. waitForDetailRendered at viewer_spec.mjs waits specifically for pagerendered where isDetailView is true.
  3. waitForCanvasPixels handles another worker-rendering detail: even after the render event, the transferred canvas placeholder may not have committed visible pixels yet. It polls a tiny bitmap snapshot until a non-empty pixel is readable, avoiding flakes where the test sees a canvas element but reads blank/stale pixels.

@codecov-commenter

codecov-commenter commented Feb 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.25000% with 79 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.25%. Comparing base (ea4fe68) to head (1ce560b).
⚠️ Report is 20 commits behind head on master.

Files with missing lines Patch % Lines
src/display/api.js 84.37% 35 Missing ⚠️
src/display/object_handler.js 80.95% 12 Missing ⚠️
web/base_pdf_page_view.js 0.00% 10 Missing ⚠️
src/core/evaluator.js 88.23% 8 Missing ⚠️
src/display/pdf_objects.js 16.66% 5 Missing ⚠️
src/display/canvas.js 87.50% 2 Missing ⚠️
src/display/canvas_dependency_tracker.js 0.00% 2 Missing ⚠️
web/pdf_page_view.js 0.00% 2 Missing ⚠️
src/display/canvas_factory.js 0.00% 1 Missing ⚠️
src/display/worker_options.js 80.00% 1 Missing ⚠️
... and 1 more
Additional details and impacted files
@@            Coverage Diff             @@
##           master   #20729      +/-   ##
==========================================
- Coverage   89.65%   87.25%   -2.40%     
==========================================
  Files         260      261       +1     
  Lines       66010    70195    +4185     
==========================================
+ Hits        59181    61251    +2070     
- Misses       6829     8944    +2115     
Flag Coverage Δ
browsertest 66.44% <77.25%> (-0.33%) ⬇️
fonttest 9.02% <ø> (ø)
unittest 57.13% <60.75%> (+0.01%) ⬆️
unittestcli 55.98% <45.25%> (-0.08%) ⬇️

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.

@nicolo-ribaudo

Copy link
Copy Markdown
Collaborator

/botio test

@moz-tools-bot

Copy link
Copy Markdown
Collaborator

From: Bot.io (Windows)


Received

Command cmd_test from @nicolo-ribaudo received. Current queue size: 0

Live output at: http://54.193.163.58:8877/e0227fb9789b076/output.txt

@moz-tools-bot

Copy link
Copy Markdown
Collaborator

From: Bot.io (Linux m4)


Received

Command cmd_test from @nicolo-ribaudo received. Current queue size: 0

Live output at: http://54.241.84.105:8877/b6e4acc21cb49a8/output.txt

@moz-tools-bot

Copy link
Copy Markdown
Collaborator

From: Bot.io (Linux m4)


Failed

Full output at http://54.241.84.105:8877/b6e4acc21cb49a8/output.txt

Total script time: 46.91 mins

  • Unit tests: FAILED
  • Integration Tests: FAILED
  • Regression tests: FAILED
  different ref/snapshot: 14

Image differences available at: http://54.241.84.105:8877/b6e4acc21cb49a8/reftest-analyzer.html#web=eq.log

@moz-tools-bot

Copy link
Copy Markdown
Collaborator

From: Bot.io (Windows)


Failed

Full output at http://54.193.163.58:8877/e0227fb9789b076/output.txt

Total script time: 90.47 mins

  • Unit tests: FAILED
  • Integration Tests: FAILED
  • Regression tests: FAILED
  different ref/snapshot: 13

Image differences available at: http://54.193.163.58:8877/e0227fb9789b076/reftest-analyzer.html#web=eq.log

@nicolo-ribaudo

Copy link
Copy Markdown
Collaborator

I was looking through the reftest failures, it seems like the only real ones (the others are minor pixel differences due to the different rendering pipeline, but not visible to humans) are:

  • issue8092
  • issue16127 (for the gradients inside the font)
  • ShowText-ShadingPattern (probably same problem as issue8092)
  • issue1133 (page 4, in the huawei logo)
  • issue19022

@Aditi-1400 Aditi-1400 force-pushed the worker-drawing branch 4 times, most recently from 1d6de6c to 43a73d7 Compare March 9, 2026 17:10
@Aditi-1400

Aditi-1400 commented Mar 9, 2026

Copy link
Copy Markdown
Collaborator Author

I was looking through the reftest failures, it seems like the only real ones (the others are minor pixel differences due to the different rendering pipeline, but not visible to humans) are:

  • issue8092
  • issue16127 (for the gradients inside the font)
  • ShowText-ShadingPattern (probably same problem as issue8092)
  • issue1133 (page 4, in the huawei logo)
  • issue19022

There's also gradient difference in 17069, also there are differences in 19022, same as issue8092

@nicolo-ribaudo

Copy link
Copy Markdown
Collaborator

/botio test

@moz-tools-bot

Copy link
Copy Markdown
Collaborator

From: Bot.io (Windows)


Received

Command cmd_test from @nicolo-ribaudo received. Current queue size: 0

Live output at: http://54.193.163.58:8877/832219967f9b55d/output.txt

@moz-tools-bot

Copy link
Copy Markdown
Collaborator

From: Bot.io (Linux m4)


Received

Command cmd_test from @nicolo-ribaudo received. Current queue size: 0

Live output at: http://54.241.84.105:8877/659eb95d2835e52/output.txt

@moz-tools-bot

Copy link
Copy Markdown
Collaborator

From: Bot.io (Linux m4)


Failed

Full output at http://54.241.84.105:8877/659eb95d2835e52/output.txt

Total script time: 46.69 mins

  • Unit tests: Passed
  • Integration Tests: FAILED
  • Regression tests: FAILED
  different ref/snapshot: 12

Image differences available at: http://54.241.84.105:8877/659eb95d2835e52/reftest-analyzer.html#web=eq.log

@moz-tools-bot

Copy link
Copy Markdown
Collaborator

From: Bot.io (Windows)


Failed

Full output at http://54.193.163.58:8877/832219967f9b55d/output.txt

Total script time: 78.06 mins

  • Unit tests: Passed
  • Integration Tests: Passed
  • Regression tests: FAILED
  different ref/snapshot: 12

Image differences available at: http://54.193.163.58:8877/832219967f9b55d/reftest-analyzer.html#web=eq.log

@Aditi-1400 Aditi-1400 force-pushed the worker-drawing branch 2 times, most recently from 134955c to ba0fa2c Compare March 16, 2026 15:29
@nicolo-ribaudo

Copy link
Copy Markdown
Collaborator

/botio test

@moz-tools-bot

Copy link
Copy Markdown
Collaborator

From: Bot.io (Windows)


Received

Command cmd_test from @nicolo-ribaudo received. Current queue size: 0

Live output at: http://54.193.163.58:8877/528a274cb665c1e/output.txt

@moz-tools-bot

Copy link
Copy Markdown
Collaborator

From: Bot.io (Linux m4)


Received

Command cmd_test from @nicolo-ribaudo received. Current queue size: 0

Live output at: http://54.241.84.105:8877/67316c0b179c2dc/output.txt

@moz-tools-bot

Copy link
Copy Markdown
Collaborator

From: Bot.io (Linux m4)


Failed

Full output at http://54.241.84.105:8877/67316c0b179c2dc/output.txt

Total script time: 46.49 mins

  • Unit tests: Passed
  • Integration Tests: FAILED
  • Regression tests: FAILED
  different ref/snapshot: 1

Image differences available at: http://54.241.84.105:8877/67316c0b179c2dc/reftest-analyzer.html#web=eq.log

@moz-tools-bot

Copy link
Copy Markdown
Collaborator

From: Bot.io (Windows)


Failed

Full output at http://54.193.163.58:8877/528a274cb665c1e/output.txt

Total script time: 75.41 mins

  • Unit tests: Passed
  • Integration Tests: FAILED
  • Regression tests: FAILED
  different ref/snapshot: 1

Image differences available at: http://54.193.163.58:8877/528a274cb665c1e/reftest-analyzer.html#web=eq.log

@Aditi-1400 Aditi-1400 force-pushed the worker-drawing branch 2 times, most recently from 96dda63 to cca0198 Compare March 23, 2026 11:47
@moz-tools-bot

Copy link
Copy Markdown
Collaborator

From: Bot.io (Windows)


Received

Command cmd_browsertest from @Aditi-1400 received. Current queue size: 0

Live output at: http://54.193.163.58:8877/6d3106a5424b20c/output.txt

@moz-tools-bot

Copy link
Copy Markdown
Collaborator

From: Bot.io (Windows)


Failed

Full output at http://54.193.163.58:8877/6d3106a5424b20c/output.txt

Total script time: 1.68 mins

  • Regression tests: FAILED

Image differences available at: http://54.193.163.58:8877/6d3106a5424b20c/reftest-analyzer.html#web=eq.log

@moz-tools-bot

Copy link
Copy Markdown
Collaborator

From: Bot.io (Linux m4)


Success

Full output at http://54.241.84.105:8877/0b2cff5f6dbf79c/output.txt

Total script time: 19.74 mins

  • Regression tests: Passed

@Aditi-1400

Copy link
Copy Markdown
Collaborator Author

I've updated the canvas filter detection in commit c44cfbc to a minimal detection, from the commit message:
Since #21236, SMask filter path now has a pixel-buffer fallback in canvas.js, so
SMask detection in the pre-scan is no longer needed. So, I've dropped the SMask
branch. Additionally, also drop the Pattern and Type3 sub-walks, I looked up and TR in tiling patterns or Type3
glyph content streams is essentially never seen in practice, and the walks add code without a measurable benefit?

Also drops the annotation-appearance scan in document.js. The earlier shape forced StartRenderPage to wait on _parsedAnnotation, as Jonas pointed out, it was making it slower, the first paint is now no longer gated on annotation parsing. TR in annotation appearance streams should be rare enough that the correctness concession is acceptable.

The walker now covers page-level ExtGState plus Form XObject Resources, which captures the realistic cases for TR while keeping the per-page scan.

Comment thread src/core/evaluator.js
Comment on lines +444 to +511
if (graphicStates instanceof Dict) {
for (let graphicState of graphicStates.getRawValues()) {
if (graphicState instanceof Ref) {
if (processed.has(graphicState)) {
continue;
}
try {
graphicState = xref.fetch(graphicState);
} catch (ex) {
info(`hasCanvasFilters - failed to fetch ExtGState: "${ex}".`);
// A fetch failure means we can't inspect the resource, so fall
// back to main-thread rendering rather than misclassify a corrupt
// PDF as filter-free.
return true;
}
}
if (!(graphicState instanceof Dict)) {
continue;
}
if (graphicState.objId) {
processed.put(graphicState.objId);
}
try {
if (this._hasTransferMaps(graphicState.get("TR"))) {
return true;
}
} catch (ex) {
info(`hasCanvasFilters - failed to inspect filter data: "${ex}".`);
return true;
}
}
}

const xObjects = node.get("XObject");
if (xObjects instanceof Dict) {
for (let xObject of xObjects.getRawValues()) {
if (xObject instanceof Ref) {
if (processed.has(xObject)) {
continue;
}
try {
xObject = xref.fetch(xObject);
} catch (ex) {
info(`hasCanvasFilters - failed to fetch XObject: "${ex}".`);
return true;
}
}
if (!(xObject instanceof BaseStream)) {
continue;
}
if (xObject.dict.objId) {
processed.put(xObject.dict.objId);
}
const xResources = xObject.dict.get("Resources");
if (!(xResources instanceof Dict)) {
continue;
}
if (xResources.objId && processed.has(xResources.objId)) {
continue;
}

nodes.push(xResources);
if (xResources.objId) {
processed.put(xResources.objId);
}
}
}
}

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 two loops look very similar, maybe we could have an helper function to avoid redundancy.

@Aditi-1400 Aditi-1400 Jun 1, 2026

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.

I did try doing that, in one of the earlier versions of the patch, I think this is probably the commit which captures the idea, later though I felt it added too much complexity and reduced readability of the code without significant returns, so I isolated them, let me know if you prefer extracting the common bits though, it should be a minimal effort to go back to that approach

Comment thread src/display/api.js
this._canvas.resetWorkerCanvas = () => {
rendererHandler.send("ResetCanvas", { renderTaskId });
};
} catch (ex) {

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 does happen if the worker throws ? this catch won't catch it, right ?

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.

Yes, however we are not expecting the worker to throw by this point, the worker rejecting is caught later at await initPromise → .catch(complete) → render promise rejects. This is only for setup failures, while/before transferring canvas since we want to fallback to main-thread there, which is not possible if the worker rejects so we just cancel the rendering in that case

@calixteman

Copy link
Copy Markdown
Contributor

@Snuffleupagus For context the idea for this patch is to set up the base to eventually spawn multiple workers that render pages in parallel which is when we expect to see significant improvements in performance. At present, we don't expect any large improvements since there's no parallelisation so far.

@Snuffleupagus and it's worth mentioning that for now that rendering in the worker is using software rendering at least in Firefox (see https://bugzilla.mozilla.org/show_bug.cgi?id=2025755); I think that HWA is enabled in Chrome but I'm not sure.
So the real improvement will come when rendering the two first pages at the same time for example, maybe the first one in the main thread in order to have the benefits of GPU acceleration and let the user sees the progress and the second in its worker.

Comment thread src/display/api.js Outdated
try {
rendererHandler.send(action, data);
} catch {
// Ignore errors if the renderer worker has been destroyed.

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 does happen if something somewhere is waiting for the result of this forwarding ?

Comment thread src/display/api.js
default:
throw new Error(`Got unknown object type ${type}`);
}
forwardToRenderer("obj", [id, pageIndex, type, imageData]);

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.

imageData should be transfered or is there any reasons for cloning it ?

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.

We cannot transfer it unconditionally here because we also need a copy on the main-thread for fallback in case the worker rendering fails

Comment thread src/display/api.js
}
// Keep the renderer worker's document-level state in sync with the main
// thread.
this.rendererHandler?.send("Cleanup", { keepLoadedFonts });

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 above this.messageHandler.sendWithPromise("Cleanup", null); is awaited, why not this one ?

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.

Oh, because, it's simply just a send instead of sendWithPromise, nothing in the handler is async either, it's just cleanup to sync with main, and we are not waiting for this to finish in any of the steps later.

Comment thread src/display/api.js
// Worker rendering is also disabled when canvas filters (TR) are present
// because OffscreenCanvas's OffscreenCanvasRenderingContext2D ignores
// `.filter` values set from a data URL. See bug 2011237.
let useWorkerRendering =

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 must really have a pref in app_options.js for enabling/disabling this feature.

Comment thread src/display/renderer_worker.js Outdated
Comment thread src/display/renderer_worker.js Outdated
Comment thread src/core/document.js Outdated
resources,
this.nonBlendModesSet
),
hasCanvasFilters,

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.

Why use a temporary variable here, rather than in-lining the call?

Suggested change
hasCanvasFilters,
hasCanvasFilters: partialEvaluator.hasCanvasFilters(resources),

Comment thread src/display/api.js Outdated
Comment on lines +305 to +322
// styleElement is only intended for development/testing purposes.
const styleElement =
typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")
? src.styleElement
: null;
// Custom DOM contexts are main-thread only and cannot be transferred to
// the renderer worker.
const hasCustomOwnerDocument =
src.ownerDocument !== undefined &&
src.ownerDocument !== globalThis.document;
const hasCustomStyleElement = !!styleElement;
const disableWorkerRendering =
src.disableWorkerRendering === true ||
typeof Worker === "undefined" ||
typeof MessageChannel === "undefined" ||
!FeatureTest.isOffscreenCanvasSupported ||
hasCustomOwnerDocument ||
hasCustomStyleElement;

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.

Speaking as someone that's spent a bunch of time re-factoring API code to increase readability: Why are you just dumping a bunch of things here under the "wrong" sections?

Suggested change
// styleElement is only intended for development/testing purposes.
const styleElement =
typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")
? src.styleElement
: null;
// Custom DOM contexts are main-thread only and cannot be transferred to
// the renderer worker.
const hasCustomOwnerDocument =
src.ownerDocument !== undefined &&
src.ownerDocument !== globalThis.document;
const hasCustomStyleElement = !!styleElement;
const disableWorkerRendering =
src.disableWorkerRendering === true ||
typeof Worker === "undefined" ||
typeof MessageChannel === "undefined" ||
!FeatureTest.isOffscreenCanvasSupported ||
hasCustomOwnerDocument ||
hasCustomStyleElement;
// Parameters only intended for development/testing purposes.
const styleElement =
typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")
? src.styleElement
: null;

The rest should go at the end of the // Parameters whose default values depend on other parameters.-block below (and can also be simplified).

  const disableWorkerRendering =
    src.disableWorkerRendering === true ||
    typeof Worker === "undefined" ||
    typeof MessageChannel === "undefined" ||
    !FeatureTest.isOffscreenCanvasSupported ||
    ownerDocument !== globalThis.document ||
    !!styleElement;

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.

Apologies, this was an oversight on my part, I've fixed this and did a pass to look for any other places with similar issues, I fixed the ones I could find, I'll take another look.
Also, I would like to add, that I really appreciate the time and work you put into improving the readability, it's been very helpful to me and everyone else contributing, I intend to try my best to keep up the standard :)

Comment thread src/display/api.js Outdated
Comment on lines +1508 to +1513
// When rendererHandler is used, the OptionalContentConfig needs to be
// transferred to the renderer
const optionalContentConfigDataPromise = this._transport.rendererHandler
? this._transport.getOptionalContentConfigData()
: null;

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.

Sorry to say, but the OptionalContentConfig handling in this patch is neither correct nor particularly readable.

Hence PR #21349 ought to land first, and this patch re-factored to use it, since that'll allow considerable simplification here.

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.

Oh thank you, this did make it much cleaner, I have rebased on top of this

Comment thread src/display/canvas_factory.js Outdated
Comment on lines +97 to +99
if (typeof OffscreenCanvas === "undefined") {
throw new Error("OffscreenCanvas is not supported");
}

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.

Is this check even necessary, since OffscreenCanvas support is checked in the API before enabling worker-rendering?

Comment thread gulpfile.mjs Outdated
Comment on lines +555 to +556
const rendererWorkerFileConfig = createWebpackConfig(defines, {
filename: defines.MINIFIED ? "pdf.renderer.min.mjs" : "pdf.renderer.mjs",

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.

Since this is a worker, it should be built correctly; compare with the "normal" pdf.worker.mjs file:

pdf.js/gulpfile.mjs

Lines 538 to 544 in b43ef1c

function createWorkerBundle(defines) {
const workerDefines = {
...defines,
WORKER_THREAD: true,
};
const workerFileConfig = createWebpackConfig(workerDefines, {
filename: workerDefines.MINIFIED ? "pdf.worker.min.mjs" : "pdf.worker.mjs",

Comment thread test/driver.js Outdated
const SVG_NS = "http://www.w3.org/2000/svg";
const RENDERER_SRC = "../build/generic/build/pdf.renderer.mjs";

GlobalWorkerOptions.rendererSrc = RENDERER_SRC;

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.

Why not set this in the same place that the pre-existing workerSrc is set?

Comment thread src/display/api.js
* @param {RendererWorkerParameters} params - The worker initialization
* parameters.
*/
class RendererWorker {

@Snuffleupagus Snuffleupagus Jun 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.

It doesn't appear that the verbosity parameter is sent to the worker-thread, compare with the existing PDFWorker implementation, why not?

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.

This now gets sent to renderer worker.

@Snuffleupagus

Copy link
Copy Markdown
Collaborator

The new RendererWorker doesn't appear to report test coverage data, which it probably should?

Comment thread src/display/api.js Outdated
? "resource://pdf.js/build/pdf.renderer.mjs"
: "../build/pdf.renderer.mjs";
}

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.

Why is this code placed here, since it really ought to be handled in the same way as the workerSrc instead?

pdf.js/web/app_options.js

Lines 553 to 563 in e9a946e

workerSrc: {
/** @type {string} */
value:
// eslint-disable-next-line no-nested-ternary
typeof PDFJSDev === "undefined"
? "../src/pdf.worker.js"
: PDFJSDev.test("MOZCENTRAL")
? "resource://pdf.js/build/pdf.worker.mjs"
: "../build/pdf.worker.mjs",
kind: OptionKind.WORKER,
},

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.

Removed this block with build-target defaults was removed from api.js; RendererWorker.rendererSrc now throws if GlobalWorkerOptions.rendererSrc is unset, mirroring PDFWorker.workerSrc.
If rendererSrc is unset, the throw is caught, the worker capability rejects, and just disables worker rendering with a warning.

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

@Snuffleupagus Snuffleupagus left a comment

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.

As mentioned in #20729 (comment) the RendererWorker must report coverage data; note how #20729 (comment) reports significantly reduced coverage with this patch.

Edit: Also, the commit history should be cleaned-up before this lands by folding any "fixup" commits into their parent commits, in order to keep the commit history clean.

Comment thread src/display/api.js Outdated
renderTaskId: this._renderTaskId,
enableHWA: this._enableHWA,
enableWebGPU: this._enableWebGPU,
optionalContentConfig: optionalContentConfig?.serializable ?? null,

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.

The optionalContentConfig must be available here, otherwise there's a bug somewhere else.

Suggested change
optionalContentConfig: optionalContentConfig?.serializable ?? null,
optionalContentConfig: optionalContentConfig.serializable,

Comment thread src/display/renderer_worker.js Outdated
Comment on lines +236 to +238
const optionalContentConfig = data.optionalContentConfig
? OptionalContentConfig.fromSerializable(data.optionalContentConfig)
: new OptionalContentConfig(null);

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.

Again, it shouldn't be possible for the optionalContentConfig-data to be undefined here.

Suggested change
const optionalContentConfig = data.optionalContentConfig
? OptionalContentConfig.fromSerializable(data.optionalContentConfig)
: new OptionalContentConfig(null);
const optionalContentConfig =
OptionalContentConfig.fromSerializable(data.optionalContentConfig);

Comment thread src/display/api.js Outdated
}
internalRenderTask.initializeGraphics({
const { transparency, hasCanvasFilters = false } =
typeof renderPageData === "object" && renderPageData !== null

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.

Nit: This can be shortened.

Suggested change
typeof renderPageData === "object" && renderPageData !== null
renderPageData && typeof renderPageData === "object"

@calixteman

Copy link
Copy Markdown
Contributor

As mentioned in #20729 (comment) the RendererWorker must report coverage data; note how #20729 (comment) reports significantly reduced coverage with this patch.

I'll do it in a follow-up.

Introduce the RendererWorker class for offloading canvas rendering
to a dedicated Web Worker. Alongside, it adds RendererMessageHandler,
GlobalWorkerOptions.rendererSrc configuration, entrypoints for
pdf.renderer.js bundle and build targets in gulpfile.

No rendering changes are introduced in this commit, this is a setup for
later commits that wire-up graphics execution and object forwarding.
Add a hasCanvasFilters method to PartialEvaluator that walks the
page-level ExtGState dictionaries, and of Form XObject
resources, to detect transfer functions (TR) that require DOM SVG
filters. Such filters are unavailable on OffscreenCanvas, so detecting
them up front lets the display layer fall back to main-thread rendering
for affected pages.

The detection is deliberately limited to TR: SMask rendering already
has a pixel-buffer fallback in canvas.js, and TR inside tiling
patterns, Type3 glyph streams or annotation appearance streams is rare
enough in practice that walking those sub-resources (and gating first
paint on annotation parsing) isn't worth it.
Add object forwarding so the renderer worker receives
the same commonobj/obj messages as the main thread

WorkerTransport now forwards commonobj/obj to the rendererHandler.
Additionally, allow _startRenderPage  propagates
hasCanvasFilters from core layer.
…orker

This decides whether to use worker rendering based on hasCanvasFilters,
pageColors, dependency/image tracking. It transfers the canvas via
transferControlToOffscreen and sends operator list chunks incrementally.
In the renderer worker, it adds the functionality to initialize
graphics and execute the operator list.

Since operator lists are now posted across threads, Path2D objects are
no longer materialized into argsArray; they are cached in a pathCache
map on the operator list instead, keeping it structured-cloneable.
Update the viewer and tests to handle canvases that have
been transferred to an OffscreenCanvas via the renderer worker.
Thread the enableWebGPU flag from getDocument() through WorkerTransport
and InternalRenderTask to the renderer worker's InitializeGraphics
handler, where it triggers GPU device initialization.
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.

8 participants