From 61eb6f6b6ceb3834f34346d0a2cc94507ae06492 Mon Sep 17 00:00:00 2001 From: Aditi Date: Sun, 12 Apr 2026 20:21:07 +0530 Subject: [PATCH 01/26] 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. --- src/display/api.js | 109 +++--------------------- src/display/object_handler.js | 152 ++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 97 deletions(-) create mode 100644 src/display/object_handler.js diff --git a/src/display/api.js b/src/display/api.js index 550b87a16b571..8ed9a478a01b3 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -41,12 +41,7 @@ import { CanvasDependencyTracker, CanvasImagesTracker, } from "./canvas_dependency_tracker.js"; -import { FontFaceObject, FontLoader } from "./font_loader.js"; -import { - FontInfo, - FontPathInfo, - PatternInfo, -} from "./obj_bin_transform_display.js"; +import { FontLoader } from "./font_loader.js"; import { getDataProp, getFactoryUrlProp, @@ -75,6 +70,7 @@ import { GlobalWorkerOptions } from "./worker_options.js"; import { initGPU } from "./webgpu.js"; import { MathClamp } from "../shared/math_clamp.js"; import { Metadata } from "./metadata.js"; +import { ObjectHandler } from "./object_handler.js"; import { OptionalContentConfig } from "./optional_content_config.js"; import { PagesMapper } from "./pages_mapper.js"; import { PageViewport } from "./page_viewport.js"; @@ -2776,6 +2772,14 @@ class WorkerTransport { page._startRenderPage(data.transparency, data.cacheKey); }); + const objectHandler = new ObjectHandler({ + messageHandler, + commonObjs: this.commonObjs, + fontLoader: this.fontLoader, + pageCache: this.#pageCache, + pdfBug: this._params.pdfBug, + }); + messageHandler.on("commonobj", ([id, type, exportedData]) => { if (this.destroyed) { return null; // Ignore any pending requests if the worker was terminated. @@ -2785,78 +2789,7 @@ class WorkerTransport { return null; } - switch (type) { - case "Font": - if ("error" in exportedData) { - const exportedError = exportedData.error; - warn(`Error during font loading: ${exportedError}`); - this.commonObjs.resolve(id, exportedError); - break; - } - - const fontData = new FontInfo(exportedData); - const inspectFont = - this._params.pdfBug && globalThis.FontInspector?.enabled - ? (font, url) => globalThis.FontInspector.fontAdded(font, url) - : null; - const font = new FontFaceObject( - fontData, - inspectFont, - exportedData.charProcOperatorList, - exportedData.extra - ); - - this.fontLoader - .bind(font) - .catch(() => messageHandler.sendWithPromise("FontFallback", { id })) - .finally(() => { - if (!font.fontExtraProperties) { - // Immediately release the `font.data` property once the font - // has been attached to the DOM, since it's no longer needed, - // rather than waiting for a `PDFDocumentProxy.cleanup` call. - // Since `font.data` could be very large, e.g. in some cases - // multiple megabytes, this will help reduce memory usage. - font.clearData(); - } - this.commonObjs.resolve(id, font); - }); - break; - case "CopyLocalImage": - const { imageRef } = exportedData; - assert(imageRef, "The imageRef must be defined."); - - for (const pageProxy of this.#pageCache.values()) { - for (const [, data] of pageProxy.objs) { - if (data?.ref !== imageRef) { - continue; - } - if (!data.dataLen) { - return null; - } - const copy = structuredClone(data); - if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) { - copy.CopyLocalImage = true; - } - this.commonObjs.resolve(id, copy); - return data.dataLen; - } - } - break; - case "FontPath": - this.commonObjs.resolve(id, new FontPathInfo(exportedData)); - break; - case "Image": - this.commonObjs.resolve(id, exportedData); - break; - case "Pattern": - const pattern = new PatternInfo(exportedData); - this.commonObjs.resolve(id, pattern.getIR()); - break; - default: - throw new Error(`Got unknown common object type ${type}`); - } - - return null; + return objectHandler.resolveCommonObject(id, type, exportedData); }); messageHandler.on("obj", ([id, pageIndex, type, imageData]) => { @@ -2864,25 +2797,7 @@ class WorkerTransport { // Ignore any pending requests if the worker was terminated. return; } - - const pageProxy = this.#pageCache.get(pageIndex); - if (pageProxy.objs.has(id)) { - return; - } - // Don't store data *after* cleanup has successfully run, see bug 1854145. - if (pageProxy._intentStates.size === 0) { - imageData?.bitmap?.close(); // Release any `ImageBitmap` data. - return; - } - - switch (type) { - case "Image": - case "Pattern": - pageProxy.objs.resolve(id, imageData); - break; - default: - throw new Error(`Got unknown object type ${type}`); - } + objectHandler.resolveObject(id, pageIndex, type, imageData); }); messageHandler.on("DocProgress", data => { diff --git a/src/display/object_handler.js b/src/display/object_handler.js new file mode 100644 index 0000000000000..f48bf0add1405 --- /dev/null +++ b/src/display/object_handler.js @@ -0,0 +1,152 @@ +/* Copyright 2026 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { assert, warn } from "../shared/util.js"; +import { + FontInfo, + FontPathInfo, + PatternInfo, +} from "./obj_bin_transform_display.js"; + +import { FontFaceObject } from "./font_loader.js"; +import { PDFObjects } from "./pdf_objects.js"; + +class ObjectHandler { + constructor({ + messageHandler, + commonObjs, + fontLoader, + pageCache, + pdfBug = null, + shouldCreatePageObjs = false, + }) { + this.messageHandler = messageHandler; + this.commonObjs = commonObjs; + this.fontLoader = fontLoader; + this.pageCache = pageCache; + this.pdfBug = pdfBug; + this.shouldCreatePageObjs = shouldCreatePageObjs; + } + + resolveCommonObject(id, type, exportedData) { + switch (type) { + case "Font": + if ("error" in exportedData) { + const exportedError = exportedData.error; + warn(`Error during font loading: ${exportedError}`); + this.commonObjs.resolve(id, exportedError); + break; + } + + const fontData = new FontInfo(exportedData); + const inspectFont = + this.pdfBug && globalThis.FontInspector?.enabled + ? (font, url) => globalThis.FontInspector.fontAdded(font, url) + : null; + const font = new FontFaceObject( + fontData, + inspectFont, + exportedData.charProcOperatorList, + exportedData.extra + ); + + this.fontLoader + .bind(font) + .catch(() => + this.messageHandler.sendWithPromise("FontFallback", { id }) + ) + .finally(() => { + if (!font.fontExtraProperties) { + // Immediately release the `font.data` property once the font + // has been attached to the DOM, since it's no longer needed, + // rather than waiting for a `PDFDocumentProxy.cleanup` call. + // Since `font.data` could be very large, e.g. in some cases + // multiple megabytes, this will help reduce memory usage. + font.clearData(); + } + this.commonObjs.resolve(id, font); + }); + break; + case "CopyLocalImage": + const { imageRef } = exportedData; + assert(imageRef, "The imageRef must be defined."); + + for (const pageOrObjs of this.pageCache.values()) { + const objs = pageOrObjs.objs || pageOrObjs; + + for (const [, data] of objs) { + if (data?.ref !== imageRef) { + continue; + } + if (!data.dataLen) { + return null; + } + const copy = structuredClone(data); + if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) { + copy.CopyLocalImage = true; + } + this.commonObjs.resolve(id, copy); + return data.dataLen; + } + } + break; + case "FontPath": + this.commonObjs.resolve(id, new FontPathInfo(exportedData)); + break; + case "Image": + this.commonObjs.resolve(id, exportedData); + break; + case "Pattern": + const pattern = new PatternInfo(exportedData); + this.commonObjs.resolve(id, pattern.getIR()); + break; + default: + throw new Error(`Got unknown common object type ${type}`); + } + return null; + } + + resolveObject(id, pageIndex, type, exportedData) { + 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; + } + // Don't store data *after* cleanup has successfully run, see bug 1854145. + if (pageOrObjs._intentStates?.size === 0) { + exportedData?.bitmap?.close(); // Release any `ImageBitmap` data. + return; + } + + switch (type) { + case "Image": + case "Pattern": + objs.resolve(id, exportedData); + break; + default: + throw new Error(`Got unknown object type ${type}`); + } + } +} + +export { ObjectHandler }; From 7cd8e2460eff628a25fd58f365cf2dd4280aaf1b Mon Sep 17 00:00:00 2001 From: Aditi Date: Mon, 13 Apr 2026 11:18:01 +0530 Subject: [PATCH 02/26] 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. --- external/dist/webpack.mjs | 4 + gulpfile.mjs | 54 +++++++-- src/display/api.js | 211 +++++++++++++++++++++++++++++++-- src/display/renderer_worker.js | 58 +++++++++ src/display/worker_options.js | 20 ++++ src/pdf.renderer.js | 18 +++ web/app_options.js | 16 +++ 7 files changed, 362 insertions(+), 19 deletions(-) create mode 100644 src/display/renderer_worker.js create mode 100644 src/pdf.renderer.js diff --git a/external/dist/webpack.mjs b/external/dist/webpack.mjs index 07396966299f4..3a403b1ae0634 100644 --- a/external/dist/webpack.mjs +++ b/external/dist/webpack.mjs @@ -21,6 +21,10 @@ if (typeof window !== "undefined" && "Worker" in window) { new URL("./build/pdf.worker.mjs", import.meta.url), { type: "module" } ); + GlobalWorkerOptions.rendererSrc = new URL( + "./build/pdf.renderer.mjs", + import.meta.url + ).href; } export * from "./build/pdf.mjs"; diff --git a/gulpfile.mjs b/gulpfile.mjs index 03325d4e0249b..e3fc4e9543f2d 100644 --- a/gulpfile.mjs +++ b/gulpfile.mjs @@ -555,6 +555,24 @@ function createWorkerBundle(defines) { .pipe(webpack2Stream(workerFileConfig)); } +function createRendererWorkerBundle(defines) { + const rendererWorkerDefines = { + ...defines, + WORKER_THREAD: true, + }; + const rendererWorkerFileConfig = createWebpackConfig(rendererWorkerDefines, { + filename: rendererWorkerDefines.MINIFIED + ? "pdf.renderer.min.mjs" + : "pdf.renderer.mjs", + library: { + type: "module", + }, + }); + return gulp + .src("./src/pdf.renderer.js", { encoding: false }) + .pipe(webpack2Stream(rendererWorkerFileConfig)); +} + function createWebBundle(defines, options) { const viewerFileConfig = createWebpackConfig(defines, { filename: "viewer.mjs", @@ -1233,6 +1251,7 @@ function buildGeneric(defines, dir) { return ordered([ createMainBundle(defines).pipe(gulp.dest(dir + "build")), createWorkerBundle(defines).pipe(gulp.dest(dir + "build")), + createRendererWorkerBundle(defines).pipe(gulp.dest(dir + "build")), createSandboxBundle(defines).pipe(gulp.dest(dir + "build")), createWebBundle(defines).pipe(gulp.dest(dir + "web")), gulp @@ -1377,6 +1396,7 @@ function buildMinified(defines, dir) { return ordered([ createMainBundle(defines).pipe(gulp.dest(dir + "build")), createWorkerBundle(defines).pipe(gulp.dest(dir + "build")), + createRendererWorkerBundle(defines).pipe(gulp.dest(dir + "build")), createSandboxBundle(defines).pipe(gulp.dest(dir + "build")), createImageDecodersBundle({ ...defines, IMAGE_DECODERS: true }).pipe( gulp.dest(dir + "image_decoders") @@ -1502,6 +1522,9 @@ gulp.task( createWorkerBundle(defines).pipe( gulp.dest(MOZCENTRAL_CONTENT_DIR + "build") ), + createRendererWorkerBundle(defines).pipe( + gulp.dest(MOZCENTRAL_CONTENT_DIR + "build") + ), createWebBundle(defines).pipe( gulp.dest(MOZCENTRAL_CONTENT_DIR + "web") ), @@ -1607,6 +1630,9 @@ gulp.task( createWorkerBundle(defines).pipe( gulp.dest(CHROME_BUILD_CONTENT_DIR + "build") ), + createRendererWorkerBundle(defines).pipe( + gulp.dest(CHROME_BUILD_CONTENT_DIR + "build") + ), createSandboxBundle(defines).pipe( gulp.dest(CHROME_BUILD_CONTENT_DIR + "build") ), @@ -1800,7 +1826,7 @@ function buildLib(defines, dir) { gulp.src( [ "src/{core,display,shared}/**/*.js", - "src/{pdf,pdf.image_decoders,pdf.worker}.js", + "src/{pdf,pdf.image_decoders,pdf.worker,pdf.renderer}.js", ], { base: "src/", encoding: false, sourcemaps: enableSourceMaps } ), @@ -2699,6 +2725,7 @@ function buildInternalViewer(defines, dir) { return ordered([ createMainBundle(defines).pipe(gulp.dest(dir + "build")), createWorkerBundle(defines).pipe(gulp.dest(dir + "build")), + createRendererWorkerBundle(defines).pipe(gulp.dest(dir + "build")), createInternalViewerBundle(defines).pipe(gulp.dest(dir + "web")), preprocessHTML("web/internal/debugger.html", defines).pipe( gulp.dest(dir + "web") @@ -2917,8 +2944,10 @@ gulp.task( gulp .src( [ - GENERIC_DIR + "build/{pdf,pdf.worker,pdf.sandbox}.mjs", - GENERIC_DIR + "build/{pdf,pdf.worker,pdf.sandbox}.mjs.map", + GENERIC_DIR + + "build/{pdf,pdf.worker,pdf.sandbox,pdf.renderer}.mjs", + GENERIC_DIR + + "build/{pdf,pdf.worker,pdf.sandbox,pdf.renderer}.mjs.map", ], { encoding: false } ) @@ -2926,16 +2955,22 @@ gulp.task( gulp .src( [ - GENERIC_LEGACY_DIR + "build/{pdf,pdf.worker,pdf.sandbox}.mjs", - GENERIC_LEGACY_DIR + "build/{pdf,pdf.worker,pdf.sandbox}.mjs.map", + GENERIC_LEGACY_DIR + + "build/{pdf,pdf.worker,pdf.sandbox,pdf.renderer}.mjs", + GENERIC_LEGACY_DIR + + "build/{pdf,pdf.worker,pdf.sandbox,pdf.renderer}.mjs.map", ], { encoding: false } ) .pipe(gulp.dest(DIST_DIR + "legacy/build/")), gulp - .src(MINIFIED_DIR + "build/{pdf,pdf.worker,pdf.sandbox}.min.mjs", { - encoding: false, - }) + .src( + MINIFIED_DIR + + "build/{pdf,pdf.worker,pdf.sandbox,pdf.renderer}.min.mjs", + { + encoding: false, + } + ) .pipe(gulp.dest(DIST_DIR + "build/")), gulp .src(MINIFIED_DIR + "image_decoders/pdf.image_decoders.min.mjs", { @@ -2944,7 +2979,8 @@ gulp.task( .pipe(gulp.dest(DIST_DIR + "image_decoders/")), gulp .src( - MINIFIED_LEGACY_DIR + "build/{pdf,pdf.worker,pdf.sandbox}.min.mjs", + MINIFIED_LEGACY_DIR + + "build/{pdf,pdf.worker,pdf.sandbox,pdf.renderer}.min.mjs", { encoding: false } ) .pipe(gulp.dest(DIST_DIR + "legacy/build/")), diff --git a/src/display/api.js b/src/display/api.js index 8ed9a478a01b3..4d794ef3cb045 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -21,6 +21,7 @@ import { AbortException, AnnotationMode, assert, + FeatureTest, getVerbosityLevel, info, isNodeJS, @@ -41,7 +42,6 @@ import { CanvasDependencyTracker, CanvasImagesTracker, } from "./canvas_dependency_tracker.js"; -import { FontLoader } from "./font_loader.js"; import { getDataProp, getFactoryUrlProp, @@ -65,6 +65,7 @@ import { CanvasGraphics } from "./canvas.js"; import { DOMBinaryDataFactory } from "display-binary_data_factory"; import { DOMCanvasFactory } from "./canvas_factory.js"; import { DOMFilterFactory } from "./filter_factory.js"; +import { FontLoader } from "./font_loader.js"; import { getNetworkStream } from "display-network_stream"; import { GlobalWorkerOptions } from "./worker_options.js"; import { initGPU } from "./webgpu.js"; @@ -185,7 +186,8 @@ const RENDERING_CANCELLED_TIMEOUT = 100; // ms * The default value is `false`. * @property {HTMLDocument} [ownerDocument] - Specify an explicit document * context to create elements with and to load resources, such as fonts, - * into. Defaults to the current document. + * into. Defaults to the current document. Renderer-worker rendering is + * disabled when this is set to a custom document. * @property {boolean} [disableRange] - Disable range request loading of PDF * files. When enabled, and if the server supports partial content requests, * then the PDF will be fetched in chunks. The default value is `false`. @@ -215,6 +217,8 @@ const RENDERING_CANCELLED_TIMEOUT = 100; // ms * @property {Object} [pagesMapper] - The pages mapper that will be used to map * page ids and page numbers. It's used when the page order is changed or some * pages are removed, cloned, etc. + * @property {boolean} [disableWorkerRendering] - Disables rendering of pages in + * a worker thread. The default value is `false`. */ /** @@ -304,6 +308,12 @@ function getDocument(src = {}) { const useWasm = src.useWasm !== false; const pagesMapper = src.pagesMapper || new PagesMapper(); + // Parameters only intended for development/testing purposes. + const styleElement = + typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING") + ? src.styleElement + : null; + // Parameters whose default values depend on other parameters. const useSystemFonts = typeof src.useSystemFonts === "boolean" @@ -323,12 +333,12 @@ function getDocument(src = {}) { isValidFetchUrl(standardFontDataUrl, document.baseURI) && isValidFetchUrl(wasmUrl, document.baseURI) ); - - // Parameters only intended for development/testing purposes. - const styleElement = - typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING") - ? src.styleElement - : null; + const disableWorkerRendering = + src.disableWorkerRendering === true || + typeof Worker === "undefined" || + !FeatureTest.isOffscreenCanvasSupported || + ownerDocument !== globalThis.document || + !!styleElement; // Set the main-thread verbosity level. setVerbosityLevel(verbosity); @@ -354,6 +364,9 @@ function getDocument(src = {}) { }); task._worker = worker; } + if (!disableWorkerRendering) { + task._rendererWorker = new RendererWorker({ verbosity }); + } const docParams = { docId, @@ -397,7 +410,18 @@ function getDocument(src = {}) { }, }; - Promise.all([worker.promise, gpuPromise]) + const workerPromises = [worker.promise, gpuPromise]; + if (task._rendererWorker) { + workerPromises.push( + task._rendererWorker.promise.catch(reason => { + warn(`Renderer worker disabled: ${reason.message}`); + task._rendererWorker?.destroy(); + task._rendererWorker = null; + }) + ); + } + + Promise.all(workerPromises) .then(function ([, hasGPU]) { if (worker.destroyed) { throw new Error("Worker was destroyed"); @@ -450,7 +474,10 @@ function getDocument(src = {}) { messageHandler, task, networkStream, - transportParams, + { + ...transportParams, + rendererHandler: task._rendererWorker?.rendererHandler || null, + }, transportFactory, pagesMapper ); @@ -510,6 +537,11 @@ class PDFDocumentLoadingTask { */ _worker = null; + /** + * @private + */ + _rendererWorker = null; + /** * Unique identifier for the document loading task. * @type {string} @@ -583,6 +615,9 @@ class PDFDocumentLoadingTask { this._worker?.destroy(); this._worker = null; + + this._rendererWorker?.destroy(); + this._rendererWorker = null; } /** @@ -2033,6 +2068,160 @@ class PDFPageProxy { } } +/** + * @typedef {Object} RendererWorkerParameters + * @property {string} [name] - The name of the worker. + * @property {number} [verbosity] - Controls the logging level; + * the constants from {@link VerbosityLevel} should be used. + */ + +/** + * Renderer worker abstraction that controls the instantiation of a dedicated + * worker that can host canvas rendering. + * + * @param {RendererWorkerParameters} params - The worker initialization + * parameters. + */ +class RendererWorker { + #worker = null; + + #rendererHandler = null; + + #capability = Promise.withResolvers(); + + constructor({ name = null, verbosity = getVerbosityLevel() } = {}) { + this.name = name; + this.destroyed = false; + this.verbosity = verbosity; + this.#initialize(); + } + + /** + * Promise for worker initialization completion. + * @type {Promise} + */ + get promise() { + return this.#capability.promise; + } + + /** + * The current MessageHandler-instance. + * @type {MessageHandler | null} + */ + get rendererHandler() { + return this.#rendererHandler; + } + + #resolve() { + this.#capability.resolve(); + // Send global setting, e.g. verbosity level. + this.#rendererHandler.send("configure", { + verbosity: this.verbosity, + }); + } + + #initialize() { + if (typeof Worker === "undefined") { + this.#capability.reject( + new Error("Renderer worker requires Worker support.") + ); + return; + } + try { + const { rendererSrc } = RendererWorker; + const worker = new Worker(rendererSrc, { type: "module" }); + const rendererHandler = new MessageHandler("main", "renderer", worker); + + rendererHandler.on("ready", () => { + ac.abort(); + if (this.destroyed) { + terminateEarly("Worker was destroyed."); + return; + } + try { + sendTest(); + } catch (reason) { + terminateEarly(reason); + } + }); + + const terminateEarly = reason => { + ac.abort(); + rendererHandler.destroy(); + worker.terminate(); + + this.#capability.reject( + new Error( + `Renderer worker failed to initialize: "${reason?.message ?? reason}".` + ) + ); + }; + + const ac = new AbortController(); + worker.addEventListener( + "error", + event => { + if (!this.#worker) { + // Worker failed to initialize due to an error. + terminateEarly(event.error || event.message); + } + }, + { signal: ac.signal } + ); + + rendererHandler.on("test", data => { + ac.abort(); + if (this.destroyed || !data) { + terminateEarly("TypedArray transfer test failed."); + return; + } + this.#rendererHandler = rendererHandler; + this.#worker = worker; + + this.#resolve(); + }); + + const sendTest = () => { + const testObj = new Uint8Array(); + // Ensure that we can use `postMessage` transfers. + rendererHandler.send("test", testObj, [testObj.buffer]); + }; + + // It might take time for the worker to initialize. We will try to send + // the "test" message immediately, and once the "ready" message arrives. + // The worker shall process only the first received "test" message. + sendTest(); + } catch (reason) { + this.#capability.reject(reason); + } + } + + /** + * Destroys the worker instance. + */ + destroy() { + this.destroyed = true; + + // We need to terminate only web worker created resource. + this.#worker?.terminate(); + this.#worker = null; + + this.#rendererHandler?.destroy(); + this.#rendererHandler = null; + } + + /** + * The current `rendererSrc`, when it exists. + * @type {string} + */ + static get rendererSrc() { + if (GlobalWorkerOptions.rendererSrc) { + return GlobalWorkerOptions.rendererSrc; + } + throw new Error('No "GlobalWorkerOptions.rendererSrc" specified.'); + } +} + /** * @typedef {Object} PDFWorkerParameters * @property {string} [name] - The name of the worker. @@ -2423,6 +2612,7 @@ class WorkerTransport { styleElement: params.styleElement, }); this.enableHWA = params.enableHWA; + this.rendererHandler = params.rendererHandler || null; this.loadingParams = params.loadingParams; this._params = params; @@ -3439,6 +3629,7 @@ export { PDFDocumentProxy, PDFPageProxy, PDFWorker, + RendererWorker, RenderTask, version, }; diff --git a/src/display/renderer_worker.js b/src/display/renderer_worker.js new file mode 100644 index 0000000000000..cd6e77f6fbfd6 --- /dev/null +++ b/src/display/renderer_worker.js @@ -0,0 +1,58 @@ +/* Copyright 2026 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { isNodeJS, setVerbosityLevel } from "../shared/util.js"; +import { MessageHandler } from "../shared/message_handler.js"; + +class RendererMessageHandler { + static { + // Worker thread (and not Node.js)? + if ( + typeof window === "undefined" && + !isNodeJS && + typeof self !== "undefined" && + /* isMessagePort = */ + typeof self.postMessage === "function" && + "onmessage" in self + ) { + this.initializeFromPort(self); + } + } + + static initializeFromPort(port) { + const handler = new MessageHandler("renderer", "main", port); + this.setup(handler); + handler.send("ready", null); + } + + static setup(handler) { + let testMessageProcessed = false; + handler.on("test", data => { + if (testMessageProcessed) { + return; + } + testMessageProcessed = true; + + // Ensure that `TypedArray`s can be sent to the worker. + handler.send("test", data instanceof Uint8Array); + }); + + handler.on("configure", data => { + setVerbosityLevel(data.verbosity); + }); + } +} + +export { RendererMessageHandler }; diff --git a/src/display/worker_options.js b/src/display/worker_options.js index e4bbb81a6ea45..ff479f8448e05 100644 --- a/src/display/worker_options.js +++ b/src/display/worker_options.js @@ -18,6 +18,8 @@ class GlobalWorkerOptions { static #src = ""; + static #rendererSrc = ""; + /** * @type {Worker | null} */ @@ -59,6 +61,24 @@ class GlobalWorkerOptions { } this.#src = val; } + + /** + * @type {string} + */ + static get rendererSrc() { + return this.#rendererSrc; + } + + /** + * @param {string} rendererSrc - A string containing the path and + * filename of the renderer worker file. + */ + static set rendererSrc(val) { + if (typeof val !== "string") { + throw new Error("Invalid `rendererSrc` type."); + } + this.#rendererSrc = val; + } } export { GlobalWorkerOptions }; diff --git a/src/pdf.renderer.js b/src/pdf.renderer.js new file mode 100644 index 0000000000000..7632c53257476 --- /dev/null +++ b/src/pdf.renderer.js @@ -0,0 +1,18 @@ +/* Copyright 2026 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RendererMessageHandler } from "./display/renderer_worker.js"; + +export { RendererMessageHandler }; diff --git a/web/app_options.js b/web/app_options.js index cb20c0f45e405..33a52b9957dbe 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -460,6 +460,11 @@ const defaultOptions = { value: false, kind: OptionKind.API + OptionKind.PREFERENCE, }, + disableWorkerRendering: { + /** @type {boolean} */ + value: false, + kind: OptionKind.API + OptionKind.PREFERENCE, + }, docBaseUrl: { /** @type {string} */ value: @@ -575,6 +580,17 @@ const defaultOptions = { : "../build/pdf.worker.mjs", kind: OptionKind.WORKER, }, + rendererSrc: { + /** @type {string} */ + value: + // eslint-disable-next-line no-nested-ternary + typeof PDFJSDev === "undefined" + ? "../src/pdf.renderer.js" + : PDFJSDev.test("MOZCENTRAL") + ? "resource://pdf.js/build/pdf.renderer.mjs" + : "../build/pdf.renderer.mjs", + kind: OptionKind.WORKER, + }, }; if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) { defaultOptions.defaultUrl = { From 3ae950b3cb3b25a1c687d45761a69da7ae8d0d58 Mon Sep 17 00:00:00 2001 From: Aditi Date: Sun, 12 Apr 2026 20:16:56 +0530 Subject: [PATCH 03/26] Add TR-based canvas filter detection in core layer 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. --- src/core/document.js | 1 + src/core/evaluator.js | 115 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/src/core/document.js b/src/core/document.js index 5f6b9b743ffe1..157cff477d24a 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -564,6 +564,7 @@ class Page { resources, this.nonBlendModesSet ), + hasCanvasFilters: partialEvaluator.hasCanvasFilters(resources), pageIndex, cacheKey, }); diff --git a/src/core/evaluator.js b/src/core/evaluator.js index e0cb73d01ac0a..9dfc41fdff384 100644 --- a/src/core/evaluator.js +++ b/src/core/evaluator.js @@ -398,6 +398,121 @@ class PartialEvaluator { return false; } + _hasTransferMaps(transferObj) { + let transferArray; + if (Array.isArray(transferObj)) { + transferArray = transferObj; + } else if (isPDFFunction(transferObj)) { + transferArray = [transferObj]; + } else { + return false; + } + + const numFns = transferArray.length; + if (!(numFns === 1 || numFns === 4)) { + return false; + } + + let numEffectfulFns = 0; + for (const entry of transferArray) { + const transfer = this.xref.fetchIfRef(entry); + if (isName(transfer, "Identity")) { + continue; + } + if (!isPDFFunction(transfer)) { + return false; + } + numEffectfulFns++; + } + return numEffectfulFns > 0; + } + + hasCanvasFilters(resources) { + if (!(resources instanceof Dict)) { + return false; + } + + const processed = new RefSet(); + if (resources.objId) { + processed.put(resources.objId); + } + const xref = this.xref; + const nodes = [resources]; + while (nodes.length) { + const node = nodes.shift(); + + const graphicStates = node.get("ExtGState"); + 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); + } + } + } + } + return false; + } + async fetchBuiltInCMap(name) { const cachedData = this.builtInCMapCache.get(name); if (cachedData) { From 089354d3426137614108149086444f41c3756a74 Mon Sep 17 00:00:00 2001 From: Aditi Date: Mon, 13 Apr 2026 11:38:05 +0530 Subject: [PATCH 04/26] 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. --- src/display/api.js | 52 ++++++++++++++++++++++++++++++---- src/display/object_handler.js | 6 +++- src/display/renderer_worker.js | 34 ++++++++++++++++++++++ 3 files changed, 85 insertions(+), 7 deletions(-) diff --git a/src/display/api.js b/src/display/api.js index 4d794ef3cb045..379c537e9ca2a 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -1667,7 +1667,7 @@ class PDFPageProxy { intentState.displayReadyCapability.promise, optionalContentConfigPromise, ]) - .then(([transparency, optionalContentConfig]) => { + .then(([renderPageData, optionalContentConfig]) => { if (this.destroyed) { complete(); return; @@ -1680,8 +1680,13 @@ class PDFPageProxy { "and `PDFDocumentProxy.getOptionalContentConfig` methods." ); } + const { transparency, hasCanvasFilters = false } = + renderPageData && typeof renderPageData === "object" + ? renderPageData + : { transparency: renderPageData }; internalRenderTask.initializeGraphics({ transparency, + hasCanvasFilters: hasCanvasFilters || intentState.hasCanvasFilters, optionalContentConfig, }); internalRenderTask.operatorListChanged(); @@ -1883,16 +1888,20 @@ class PDFPageProxy { /** * @private */ - _startRenderPage(transparency, cacheKey) { + _startRenderPage(transparency, cacheKey, hasCanvasFilters = false) { const intentState = this._intentStates.get(cacheKey); if (!intentState) { return; // Rendering was cancelled. } this._stats?.timeEnd("Page Request"); + intentState.hasCanvasFilters ||= hasCanvasFilters; // TODO Refactor RenderPageRequest to separate rendering // and operator list logic - intentState.displayReadyCapability?.resolve(transparency); + intentState.displayReadyCapability?.resolve({ + transparency, + hasCanvasFilters, + }); } /** @@ -2798,7 +2807,7 @@ class WorkerTransport { } setupMessageHandler() { - const { messageHandler, loadingTask } = this; + const { messageHandler, loadingTask, rendererHandler } = this; messageHandler.on("GetReader", (data, sink) => { assert( @@ -2959,7 +2968,11 @@ class WorkerTransport { } const page = this.#pageCache.get(data.pageIndex); - page._startRenderPage(data.transparency, data.cacheKey); + page._startRenderPage( + data.transparency, + data.cacheKey, + data.hasCanvasFilters + ); }); const objectHandler = new ObjectHandler({ @@ -2970,11 +2983,33 @@ class WorkerTransport { pdfBug: this._params.pdfBug, }); + // TODO: add a direct channel between the renderer worker and the core + // worker so these main-thread forwarders can be removed. + rendererHandler?.on("FontFallback", data => { + if (this.destroyed) { + return null; + } + return messageHandler.sendWithPromise("FontFallback", data); + }); + + const forwardToRenderer = (action, data) => { + if (!rendererHandler) { + return; + } + try { + rendererHandler.send(action, data); + } catch { + // Ignore errors if the renderer worker has been destroyed. + } + }; + messageHandler.on("commonobj", ([id, type, exportedData]) => { if (this.destroyed) { return null; // Ignore any pending requests if the worker was terminated. } + forwardToRenderer("commonobj", [id, type, exportedData]); + if (this.commonObjs.has(id)) { return null; } @@ -2987,6 +3022,7 @@ class WorkerTransport { // Ignore any pending requests if the worker was terminated. return; } + forwardToRenderer("obj", [id, pageIndex, type, imageData]); objectHandler.resolveObject(id, pageIndex, type, imageData); }); @@ -3474,7 +3510,11 @@ class InternalRenderTask { }); } - initializeGraphics({ transparency = false, optionalContentConfig }) { + initializeGraphics({ + transparency = false, + hasCanvasFilters = false, + optionalContentConfig, + }) { if (this.cancelled) { return; } diff --git a/src/display/object_handler.js b/src/display/object_handler.js index f48bf0add1405..91a796546b336 100644 --- a/src/display/object_handler.js +++ b/src/display/object_handler.js @@ -65,7 +65,11 @@ class ObjectHandler { this.fontLoader .bind(font) .catch(() => - this.messageHandler.sendWithPromise("FontFallback", { id }) + this.messageHandler + .sendWithPromise("FontFallback", { id }) + .catch(reason => { + warn(`FontFallback failed for "${id}": ${reason}`); + }) ) .finally(() => { if (!font.fontExtraProperties) { diff --git a/src/display/renderer_worker.js b/src/display/renderer_worker.js index cd6e77f6fbfd6..bb1775804e2a4 100644 --- a/src/display/renderer_worker.js +++ b/src/display/renderer_worker.js @@ -14,9 +14,20 @@ */ import { isNodeJS, setVerbosityLevel } from "../shared/util.js"; +import { FontLoader } from "./font_loader.js"; import { MessageHandler } from "../shared/message_handler.js"; +import { ObjectHandler } from "./object_handler.js"; +import { PDFObjects } from "./pdf_objects.js"; class RendererMessageHandler { + static #commonObjs = new PDFObjects(); + + static #objsMap = new Map(); + + static #fontLoader = new FontLoader({ + ownerDocument: globalThis, + }); + static { // Worker thread (and not Node.js)? if ( @@ -37,6 +48,27 @@ class RendererMessageHandler { handler.send("ready", null); } + static #setupObjectHandler(handler) { + const objectHandler = new ObjectHandler({ + messageHandler: handler, + commonObjs: this.#commonObjs, + fontLoader: this.#fontLoader, + pageCache: this.#objsMap, + shouldCreatePageObjs: true, + }); + + handler.on("commonobj", ([id, type, exportedData]) => { + if (this.#commonObjs.has(id)) { + return null; + } + return objectHandler.resolveCommonObject(id, type, exportedData); + }); + + handler.on("obj", ([id, pageIndex, type, imageData]) => { + objectHandler.resolveObject(id, pageIndex, type, imageData); + }); + } + static setup(handler) { let testMessageProcessed = false; handler.on("test", data => { @@ -52,6 +84,8 @@ class RendererMessageHandler { handler.on("configure", data => { setVerbosityLevel(data.verbosity); }); + + this.#setupObjectHandler(handler); } } From 2db76315c08f43f3bd1430c678161bf0f0ea0dc8 Mon Sep 17 00:00:00 2001 From: Aditi Date: Mon, 13 Apr 2026 19:07:14 +0530 Subject: [PATCH 05/26] 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. 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. --- src/display/api.js | 312 +++++++++++++++++++++++++++++---- src/display/canvas.js | 50 ++++-- src/display/canvas_factory.js | 11 +- src/display/filter_factory.js | 4 +- src/display/pattern_helper.js | 4 + src/display/renderer_worker.js | 215 +++++++++++++++++++++++ 6 files changed, 549 insertions(+), 47 deletions(-) diff --git a/src/display/api.js b/src/display/api.js index 379c537e9ca2a..8183635541962 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -26,6 +26,7 @@ import { info, isNodeJS, makeObj, + OPS, RenderingIntentFlag, setVerbosityLevel, shadow, @@ -1347,6 +1348,8 @@ class PDFDocumentProxy { * Proxy to a `PDFPage` in the worker thread. */ class PDFPageProxy { + #keepRendererCanvas = false; + #pendingCleanup = false; #pagesMapper = null; @@ -1535,6 +1538,7 @@ class PDFPageProxy { cacheKey, makeObj ); + // Ensure that a pending `streamReader` cancel timeout is always aborted. if (intentState.streamReaderCancelTimeout) { clearTimeout(intentState.streamReaderCancelTimeout); @@ -1658,6 +1662,10 @@ class PDFPageProxy { pageColors, enableHWA: this._transport.enableHWA, operationsFilter, + rendererHandler: + shouldRecordOperations || shouldRecordImages + ? null + : this._transport.rendererHandler, }); (intentState.renderTasks ||= new Set()).add(internalRenderTask); @@ -1844,6 +1852,15 @@ class PDFPageProxy { } } this.objs.clear(); + if (this._transport.rendererHandler) { + try { + this._transport.rendererHandler.send("cleanupPage", { + pageIndex: this._pageIndex, + }); + } catch { + // Ignore errors if the renderer worker has been destroyed. + } + } this.#pendingCleanup = false; return Promise.all(waitOn); @@ -1854,10 +1871,13 @@ class PDFPageProxy { * * @param {boolean} [resetStats] - Reset page stats, if enabled. * The default value is `false`. + * @param {boolean} [keepRendererCanvas] - When true, keeps the + * OffscreenCanvas reference alive in the renderer worker. * @returns {boolean} Indicates if clean-up was successfully run. */ - cleanup(resetStats = false) { + cleanup(resetStats = false, keepRendererCanvas = false) { this.#pendingCleanup = true; + this.#keepRendererCanvas = keepRendererCanvas; const success = this.#tryCleanup(); if (resetStats && success) { @@ -1881,6 +1901,17 @@ class PDFPageProxy { } this._intentStates.clear(); this.objs.clear(); + if (this._transport.rendererHandler) { + try { + this._transport.rendererHandler.send("cleanupPage", { + pageIndex: this._pageIndex, + keepCanvas: this.#keepRendererCanvas, + }); + } catch { + // Ignore errors if the renderer worker has been destroyed. + } + } + this.#keepRendererCanvas = false; this.#pendingCleanup = false; return true; } @@ -1943,6 +1974,19 @@ class PDFPageProxy { } const { map, transfer } = annotationStorageSerializable; + // Restore the page in the renderer worker before any `obj` message can + // be forwarded, since the core worker emits each object only once and a + // dropped one would hang `ExecuteOperatorList` on its dependency. + if (this._transport.rendererHandler) { + try { + this._transport.rendererHandler.send("restorePage", { + pageIndex: this._pageIndex, + }); + } catch { + // Ignore errors if the renderer worker has been destroyed. + } + } + const readableStream = this._transport.messageHandler.sendWithStream( "GetOperatorList", { @@ -3336,7 +3380,9 @@ class WorkerTransport { await this.messageHandler.sendWithPromise("Cleanup", null); for (const page of this.#pageCache.values()) { - const cleanupSuccessful = page.cleanup(); + // Keep the OffscreenCanvas reference alive in the renderer worker + // during idle cleanup. + const cleanupSuccessful = page.cleanup(false, !!this.rendererHandler); if (!cleanupSuccessful) { throw new Error( @@ -3348,6 +3394,9 @@ class WorkerTransport { if (!keepLoadedFonts) { this.fontLoader.clear(); } + // Keep the renderer worker's document-level state in sync with the main + // thread. + this.rendererHandler?.send("Cleanup", { keepLoadedFonts }); this.#methodPromises.clear(); this.filterFactory.destroy(/* keepHCM = */ true); TextLayer.cleanup(); @@ -3442,6 +3491,13 @@ class RenderTask { get imageCoordinates() { return this._internalRenderTask.imageCoordinates || null; } + + /** + * @type {MessageHandler | null} + */ + get rendererHandler() { + return this._internalRenderTask.rendererHandler; + } } /** @@ -3453,6 +3509,8 @@ class InternalRenderTask { static #canvasInUse = new WeakSet(); + static #renderTaskId = 0; + constructor({ callback, params, @@ -3468,6 +3526,7 @@ class InternalRenderTask { pageColors = null, enableHWA = false, operationsFilter = null, + rendererHandler = null, }) { this.callback = callback; this.params = params; @@ -3501,6 +3560,10 @@ class InternalRenderTask { this._dependencyTracker = params.dependencyTracker; this._imagesTracker = params.imagesTracker; this._operationsFilter = operationsFilter; + this._rendererHandler = rendererHandler; + this._renderTaskId = InternalRenderTask.#renderTaskId++; + this._sentOperatorListLength = 0; + this._transferredAnnotationCanvasIds = new Set(); } get completed() { @@ -3510,6 +3573,52 @@ class InternalRenderTask { }); } + get rendererHandler() { + return this._rendererHandler; + } + + // Transfer annotation canvases to the renderer worker which show up in the + // operator list later when it is updated. + _getAnnotationCanvasFromOpList(startIdx, endIdx) { + const annotationCanvases = []; + const transfers = []; + if ( + !this.annotationCanvasMap || + !this._canvas?.ownerDocument || + typeof this._canvas.ownerDocument.createElement !== "function" + ) { + return { annotationCanvases, transfers }; + } + const { fnArray, argsArray } = this.operatorList; + for (let i = startIdx; i < endIdx; i++) { + if (fnArray[i] !== OPS.beginAnnotation) { + continue; + } + const [id, , , , hasOwnCanvas] = argsArray[i]; + if (!hasOwnCanvas || this._transferredAnnotationCanvasIds.has(id)) { + continue; + } + + let canvas = this.annotationCanvasMap.get(id); + if (!canvas) { + canvas = this._canvas.ownerDocument.createElement("canvas"); + this.annotationCanvasMap.set(id, canvas); + } + if (typeof canvas.transferControlToOffscreen !== "function") { + continue; + } + try { + const offscreen = canvas.transferControlToOffscreen(); + annotationCanvases.push([id, offscreen]); + transfers.push(offscreen); + this._transferredAnnotationCanvasIds.add(id); + } catch (ex) { + warn(`Failed to transfer annotation canvas to worker: ${ex.message}.`); + } + } + return { annotationCanvases, transfers }; + } + initializeGraphics({ transparency = false, hasCanvasFilters = false, @@ -3542,33 +3651,98 @@ class InternalRenderTask { imagesTracker, } = this.params; - // When printing in Firefox, we get a specific context in mozPrintCallback - // which cannot be created from the canvas itself. - const canvasContext = - this._canvasContext || - this._canvas.getContext("2d", { - alpha: false, - willReadFrequently: !this._enableHWA, - }); + // TODO: Add support for dependencyTracker and imagesTracker when + // rendering in the worker + // Worker Rendering is disabled when canvas filters are present because + // OffscreenCanvas's OffscreenCanvasRenderingContext2D completely ignores + // the value of .filter defined with a data url. + // See bug 2011237 + + let useWorkerRendering = + this._rendererHandler && + this._canvasContext === null && + !hasCanvasFilters && + !this.pageColors && + !dependencyTracker && + !imagesTracker; + + if (!useWorkerRendering) { + this._rendererHandler = null; + } + if (useWorkerRendering) { + try { + const offscreen = this._canvas.transferControlToOffscreen(); + const { annotationCanvases, transfers } = + this._getAnnotationCanvasFromOpList( + 0, + this.operatorList.argsArray.length + ); + const initTransfers = [offscreen, ...transfers]; + const initParams = { + canvas: offscreen, + pageIndex: this._pageIndex, + renderTaskId: this._renderTaskId, + enableHWA: this._enableHWA, + optionalContentConfig: optionalContentConfig.serializable, + annotationCanvasMap: this.annotationCanvasMap + ? annotationCanvases + : null, + transform, + viewport, + transparency, + background, + }; + this._rendererHandler.send( + "InitializeGraphics", + initParams, + initTransfers + ); + // Mark the canvas as worker-rendered so that consumers (thumbnail + // generation, test driver) can detect and clean up appropriately. + const rendererHandler = this._rendererHandler; + const renderTaskId = this._renderTaskId; + this._canvas.resetWorkerCanvas = () => { + rendererHandler.send("CleanupRenderTask", { renderTaskId }); + }; + } catch (ex) { + warn( + `Failed to initialize graphics in renderer worker: ${ex.message}. ` + + "Falling back to main-thread rendering." + ); + // Fallback to regular rendering. + this._rendererHandler = null; + useWorkerRendering = false; + } + } + if (!useWorkerRendering) { + // When printing in Firefox, we get a specific context in mozPrintCallback + // which cannot be created from the canvas itself. + const canvasContext = + this._canvasContext || + this._canvas.getContext("2d", { + alpha: false, + willReadFrequently: !this._enableHWA, + }); - this.gfx = new CanvasGraphics( - canvasContext, - this.commonObjs, - this.objs, - this.canvasFactory, - this.filterFactory, - { optionalContentConfig }, - this.annotationCanvasMap, - this.pageColors, - dependencyTracker, - imagesTracker - ); - this.gfx.beginDrawing({ - transform, - viewport, - transparency, - background, - }); + this.gfx = new CanvasGraphics( + canvasContext, + this.commonObjs, + this.objs, + this.canvasFactory, + this.filterFactory, + { optionalContentConfig }, + this.annotationCanvasMap, + this.pageColors, + dependencyTracker, + imagesTracker + ); + this.gfx.beginDrawing({ + transform, + viewport, + transparency, + background, + }); + } this.operatorListIdx = 0; this.graphicsReady = true; this.graphicsReadyCallback?.(); @@ -3577,6 +3751,15 @@ class InternalRenderTask { cancel(error = null, extraDelay = 0) { this.running = false; this.cancelled = true; + if (this._rendererHandler) { + try { + this._rendererHandler.send("CleanupRenderTask", { + renderTaskId: this._renderTaskId, + }); + } catch { + // Ignore errors if the renderer worker has been destroyed. + } + } this.gfx?.endDrawing(); if (this.#rAF) { window.cancelAnimationFrame(this.#rAF); @@ -3598,11 +3781,14 @@ class InternalRenderTask { this.graphicsReadyCallback ||= this._continueBound; return; } - this.gfx.dependencyTracker?.growOperationsCount( - this.operatorList.fnArray.length - ); - this.stepper?.updateOperatorList(this.operatorList); - + // Support for dependency tracking and stepper when rendering + // in the worker has not been added. + if (!this._rendererHandler) { + this.gfx.dependencyTracker?.growOperationsCount( + this.operatorList.fnArray.length + ); + this.stepper?.updateOperatorList(this.operatorList); + } if (this.running) { return; } @@ -3636,6 +3822,66 @@ class InternalRenderTask { if (this.cancelled) { return; } + const { operatorList, operatorListIdx } = this; + if (this._rendererHandler) { + const operatorListArgsArrayLen = operatorList.argsArray.length; + const sentLength = Math.min( + this._sentOperatorListLength, + operatorListArgsArrayLen + ); + const { annotationCanvases, transfers } = + this._getAnnotationCanvasFromOpList( + sentLength, + operatorListArgsArrayLen + ); + if (annotationCanvases.length > 0) { + this._rendererHandler.send( + "UpdateAnnotationCanvases", + { + renderTaskId: this._renderTaskId, + annotationCanvasMap: annotationCanvases, + }, + transfers + ); + } + const fnArray = + sentLength < operatorListArgsArrayLen + ? operatorList.fnArray.slice(sentLength, operatorListArgsArrayLen) + : null; + const argsArray = + sentLength < operatorListArgsArrayLen + ? operatorList.argsArray.slice(sentLength, operatorListArgsArrayLen) + : null; + this.operatorListIdx = await this._rendererHandler.sendWithPromise( + "ExecuteOperatorList", + { + renderTaskId: this._renderTaskId, + fnArray, + argsArray, + operatorListIdx, + operationsFilter: + typeof this._operationsFilter === "function" + ? null + : this._operationsFilter, + lastChunk: operatorList.lastChunk, + } + ); + this._sentOperatorListLength = operatorListArgsArrayLen; + if (this.cancelled) { + return; + } + + if (this.operatorListIdx === operatorList.argsArray.length) { + this.running = false; + if (this.operatorList.lastChunk) { + InternalRenderTask.#canvasInUse.delete(this._canvas); + this.callback(); + } + } else { + this._continue(); + } + return; + } this.operatorListIdx = this.gfx.executeOperatorList( this.operatorList, this.operatorListIdx, diff --git a/src/display/canvas.js b/src/display/canvas.js index 01a9a2f7d7d09..a882b57e83b13 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -668,6 +668,10 @@ class CanvasGraphics { ) { const argsArray = operatorList.argsArray; const fnArray = operatorList.fnArray; + // Cache materialized Path2D objects on the operatorList itself rather + // than by mutating `argsArray[i][0]`, so the op list stays structured- + // cloneable for postMessage to the renderer worker. + this._pathCache = operatorList.pathCache ||= new Map(); let i = executionStartIdx || 0; const argsArrayLen = argsArray.length; @@ -2064,10 +2068,12 @@ class CanvasGraphics { // Path constructPath(opIdx, op, data, minMax) { - let [path] = data; + let path = this._pathCache.get(opIdx); if (!minMax) { - // The path is empty, so no need to update the current minMax. - path ||= data[0] = new Path2D(); + if (!path) { + path = new Path2D(); + this._pathCache.set(opIdx, path); + } if (op !== OPS.stroke && op !== OPS.closeStroke) { this.current.tilingPatternDims = null; } @@ -2090,8 +2096,9 @@ class CanvasGraphics { .recordDependencies(opIdx, ["transform"]); } - if (!(path instanceof Path2D)) { - path = data[0] = makePathFromDrawOPS(path); + if (!path) { + path = makePathFromDrawOPS(data[0]); + this._pathCache.set(opIdx, path); } Util.axialAlignedBoundingBox( minMax, @@ -2944,11 +2951,12 @@ class CanvasGraphics { ctx.scale(textHScale, fontDirection); // Type3 fonts have their own operator list. Avoid mixing it up with the - // dependency tracker of the main operator list. + // dependency tracker and `pathCache` of the main operator list. const dependencyTracker = this.dependencyTracker; this.dependencyTracker = dependencyTracker ? new CanvasNestedDependencyTracker(dependencyTracker, opIdx) : null; + const prevPathCache = this._pathCache; for (i = 0; i < glyphsLength; ++i) { glyph = glyphs[i]; @@ -2987,6 +2995,7 @@ class CanvasGraphics { current.x += width * textHScale; } ctx.restore(); + this._pathCache = prevPathCache; if (dependencyTracker) { this.dependencyTracker = dependencyTracker; } @@ -3678,12 +3687,13 @@ class CanvasGraphics { height * this.outputScaleY * viewportScale ); - this.annotationCanvas = this.canvasFactory.create( - canvasWidth, - canvasHeight - ); - const { canvas, context } = this.annotationCanvas; + let canvas, context; if (canvasName) { + this.annotationCanvas = this.canvasFactory.create( + canvasWidth, + canvasHeight + ); + ({ canvas, context } = this.annotationCanvas); const canvases = this.annotationCanvasMap.getOrInsertComputed( id, makeArr @@ -3700,7 +3710,23 @@ class CanvasGraphics { canvases[index] = canvas; } } else { - this.annotationCanvasMap.set(id, canvas); + canvas = this.annotationCanvasMap.get(id); + if (canvas) { + canvas.width = canvasWidth; + canvas.height = canvasHeight; + context = canvas.getContext("2d"); + if (!context) { + throw new Error("Unable to initialize annotation canvas."); + } + this.annotationCanvas = { canvas, context }; + } else { + this.annotationCanvas = this.canvasFactory.create( + canvasWidth, + canvasHeight + ); + ({ canvas, context } = this.annotationCanvas); + this.annotationCanvasMap.set(id, canvas); + } } this.annotationCanvas.savedCtx = this.ctx; this.ctx = context; diff --git a/src/display/canvas_factory.js b/src/display/canvas_factory.js index 16c0e55fabf94..930a8835f9cd3 100644 --- a/src/display/canvas_factory.js +++ b/src/display/canvas_factory.js @@ -89,4 +89,13 @@ class DOMCanvasFactory extends BaseCanvasFactory { } } -export { BaseCanvasFactory, DOMCanvasFactory }; +class OffscreenCanvasFactory extends BaseCanvasFactory { + /** + * @ignore + */ + _createCanvas(width, height) { + return new OffscreenCanvas(width, height); + } +} + +export { BaseCanvasFactory, DOMCanvasFactory, OffscreenCanvasFactory }; diff --git a/src/display/filter_factory.js b/src/display/filter_factory.js index 0819f811740ed..d1081b2d1a25e 100644 --- a/src/display/filter_factory.js +++ b/src/display/filter_factory.js @@ -90,6 +90,8 @@ class BaseFilterFactory { destroy(keepHCM = false) {} } +class WorkerFilterFactory extends BaseFilterFactory {} + /** * FilterFactory aims to create some SVG filters we can use when drawing an * image (or whatever) on a canvas. @@ -714,4 +716,4 @@ function blend(fg, bg, alpha) { return Math.round(alpha * fg + (1 - alpha) * bg); } -export { BaseFilterFactory, DOMFilterFactory }; +export { BaseFilterFactory, DOMFilterFactory, WorkerFilterFactory }; diff --git a/src/display/pattern_helper.js b/src/display/pattern_helper.js index d0434a547d352..59e8c342c819a 100644 --- a/src/display/pattern_helper.js +++ b/src/display/pattern_helper.js @@ -699,7 +699,11 @@ class TilingPattern { this.clipBbox(owner, x0, y0, x1, y1); owner.baseTransformStack.push(owner.baseTransform); owner.baseTransform = getCurrentTransform(owner.ctx); + // The nested execution swaps in the pattern's `pathCache`; restore the + // outer operator list's cache afterwards. + const prevPathCache = owner._pathCache; owner.executeOperatorList(this.operatorList); + owner._pathCache = prevPathCache; owner.baseTransform = owner.baseTransformStack.pop(); } diff --git a/src/display/renderer_worker.js b/src/display/renderer_worker.js index bb1775804e2a4..9f07162dd9577 100644 --- a/src/display/renderer_worker.js +++ b/src/display/renderer_worker.js @@ -14,16 +14,26 @@ */ import { isNodeJS, setVerbosityLevel } from "../shared/util.js"; +import { CanvasGraphics } from "./canvas.js"; import { FontLoader } from "./font_loader.js"; import { MessageHandler } from "../shared/message_handler.js"; import { ObjectHandler } from "./object_handler.js"; +import { OffscreenCanvasFactory } from "./canvas_factory.js"; +import { OptionalContentConfig } from "./optional_content_config.js"; import { PDFObjects } from "./pdf_objects.js"; +import { WorkerFilterFactory } from "./filter_factory.js"; class RendererMessageHandler { static #commonObjs = new PDFObjects(); static #objsMap = new Map(); + static #renderTaskStates = new Map(); + + static #canvasMap = new Map(); + + static #cleanedPages = new Set(); + static #fontLoader = new FontLoader({ ownerDocument: globalThis, }); @@ -48,6 +58,72 @@ class RendererMessageHandler { handler.send("ready", null); } + static #getPageObjs(pageIndex) { + let objs = this.#objsMap.get(pageIndex); + if (!objs) { + objs = new PDFObjects(); + this.#objsMap.set(pageIndex, objs); + } + return objs; + } + + static #cleanupRenderTask(renderTaskId) { + const renderTaskState = this.#renderTaskStates.get(renderTaskId); + if (!renderTaskState) { + return; + } + renderTaskState.aborted = true; + renderTaskState.continueResolve?.(); + + renderTaskState.gfx.endDrawing(); + this.#renderTaskStates.delete(renderTaskId); + } + + static #cleanupPage(pageIndex, keepCanvas = false) { + this.#cleanedPages.add(pageIndex); + this.#objsMap.delete(pageIndex); + for (const [renderTaskId, renderTaskState] of this.#renderTaskStates) { + if (renderTaskState.pageIndex === pageIndex) { + this.#cleanupRenderTask(renderTaskId); + } + } + if (!keepCanvas) { + this.#canvasMap.delete(pageIndex); + } + } + + static #appendOperatorList(renderTaskState, fnArray, argsArray, lastChunk) { + const { operatorList } = renderTaskState; + if (fnArray) { + operatorList.fnArray.push(...fnArray); + operatorList.argsArray.push(...argsArray); + } + operatorList.lastChunk = lastChunk; + } + + static async #executeOperatorList(renderTaskState, operationsFilter) { + const { operatorList, gfx } = renderTaskState; + while (!renderTaskState.aborted) { + const continuePromise = new Promise(resolve => { + renderTaskState.continueResolve = resolve; + }); + + renderTaskState.operatorListIdx = gfx.executeOperatorList( + operatorList, + renderTaskState.operatorListIdx, + renderTaskState.continueResolve, + undefined, // Renderer does not support stepper yet. + operationsFilter + ); + + if (renderTaskState.operatorListIdx === operatorList.argsArray.length) { + return renderTaskState.operatorListIdx; + } + await continuePromise; + } + return renderTaskState.operatorListIdx; + } + static #setupObjectHandler(handler) { const objectHandler = new ObjectHandler({ messageHandler: handler, @@ -65,6 +141,13 @@ class RendererMessageHandler { }); handler.on("obj", ([id, pageIndex, type, imageData]) => { + // The page may have been cleaned up before this message was processed; + // drop the data and release any `ImageBitmap` instead of resurrecting + // an empty object bag for a dead page. + if (this.#cleanedPages.has(pageIndex)) { + imageData?.bitmap?.close(); + return; + } objectHandler.resolveObject(id, pageIndex, type, imageData); }); } @@ -86,6 +169,138 @@ class RendererMessageHandler { }); this.#setupObjectHandler(handler); + + handler.on("cleanupPage", ({ pageIndex, keepCanvas }) => { + this.#cleanupPage(pageIndex, keepCanvas); + }); + + handler.on("restorePage", ({ pageIndex }) => { + this.#cleanedPages.delete(pageIndex); + }); + + // Mirrors the document-level cleanup the main thread performs in + // `WorkerTransport.startCleanup`; without this the worker's copies of + // `commonObjs`/`fontLoader` would outlive their main-thread counterparts. + handler.on("Cleanup", ({ keepLoadedFonts }) => { + this.#commonObjs.clear(); + if (!keepLoadedFonts) { + this.#fontLoader.clear(); + } + }); + + handler.on("CleanupRenderTask", ({ renderTaskId }) => { + this.#cleanupRenderTask(renderTaskId); + }); + + handler.on("InitializeGraphics", async data => { + const { + canvas, + pageIndex, + renderTaskId, + enableHWA = false, + annotationCanvasMap, + transform, + viewport, + transparency, + background, + } = data; + if (enableWebGPU) { + await initGPU(); + } + const objs = this.#getPageObjs(pageIndex); + const optionalContentConfig = OptionalContentConfig.fromSerializable( + data.optionalContentConfig + ); + + const ctx = canvas.getContext("2d", { + alpha: false, + willReadFrequently: !enableHWA, + }); + const canvasFactory = new OffscreenCanvasFactory({ enableHWA }); + const filterFactory = new WorkerFilterFactory(); + const annotationCanvases = annotationCanvasMap + ? new Map(annotationCanvasMap) + : null; + const gfx = new CanvasGraphics( + ctx, + this.#commonObjs, + objs, + canvasFactory, + filterFactory, + { optionalContentConfig }, + annotationCanvases + /** Renderer worker doesn't support pageColors and dependencyTracker */ + ); + + gfx.beginDrawing({ + transform, + viewport, + transparency, + background, + }); + + // Store a reference to the OffscreenCanvas + this.#canvasMap.set(pageIndex, canvas); + + this.#renderTaskStates.set(renderTaskId, { + pageIndex, + gfx, + operatorList: { + fnArray: [], + argsArray: [], + lastChunk: false, + }, + operatorListIdx: 0, + continueResolve: null, + aborted: false, + }); + }); + + handler.on("UpdateAnnotationCanvases", data => { + const { renderTaskId, annotationCanvasMap } = data; + if (!annotationCanvasMap) { + return; + } + const renderTaskState = this.#renderTaskStates.get(renderTaskId); + if (!renderTaskState || !renderTaskState.gfx.annotationCanvasMap) { + return; + } + for (const [id, canvas] of annotationCanvasMap) { + renderTaskState.gfx.annotationCanvasMap.set(id, canvas); + } + }); + + handler.on("ExecuteOperatorList", async data => { + const { + renderTaskId, + fnArray, + argsArray, + operatorListIdx, + operationsFilter, + lastChunk, + } = data; + const renderTaskState = this.#renderTaskStates.get(renderTaskId); + if (!renderTaskState) { + // A render task can be cleaned up before queued + // ExecuteOperatorList messages for that task are processed. + return operatorListIdx; + } + + renderTaskState.operatorListIdx = operatorListIdx; + this.#appendOperatorList(renderTaskState, fnArray, argsArray, lastChunk); + + const currentOperatorListIdx = await this.#executeOperatorList( + renderTaskState, + operationsFilter + ); + if ( + renderTaskState.operatorList.lastChunk && + currentOperatorListIdx === renderTaskState.operatorList.argsArray.length + ) { + this.#cleanupRenderTask(renderTaskId); + } + return currentOperatorListIdx; + }); } } From 9b65c486e7d2e576c9b099d6b4358248465a7217 Mon Sep 17 00:00:00 2001 From: Aditi Date: Mon, 13 Apr 2026 22:40:46 +0530 Subject: [PATCH 06/26] 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. --- test/driver.js | 31 ++++++++- test/integration/test_utils.mjs | 26 +++++++- test/integration/viewer_spec.mjs | 106 ++++++++++++++++++++++++++++--- test/unit/jasmine-boot.js | 2 + web/base_pdf_page_view.js | 26 ++++++-- web/pdf_page_view.js | 5 ++ web/pdf_rendering_queue.js | 5 ++ web/pdf_thumbnail_view.js | 9 ++- 8 files changed, 191 insertions(+), 19 deletions(-) diff --git a/test/driver.js b/test/driver.js index 7ba6d6a8c21fa..8821c7a86f79a 100644 --- a/test/driver.js +++ b/test/driver.js @@ -41,6 +41,7 @@ const IMAGE_RESOURCES_PATH = "/web/images/"; const VIEWER_CSS = "../build/components/pdf_viewer.css"; const VIEWER_LOCALE = "en-US"; const WORKER_SRC = "../build/generic/build/pdf.worker.mjs"; +const RENDERER_SRC = "../build/generic/build/pdf.renderer.mjs"; const RENDER_TASK_ON_CONTINUE_DELAY = 5; // ms const SVG_NS = "http://www.w3.org/2000/svg"; @@ -534,6 +535,7 @@ class Driver { constructor(options) { // Configure the global worker options. GlobalWorkerOptions.workerSrc = WORKER_SRC; + GlobalWorkerOptions.rendererSrc = RENDERER_SRC; // We only need to initialize the `L10n`-instance here, since translation is // triggered by a `MutationObserver`; see e.g. `Rasterize.annotationLayer`. @@ -571,6 +573,9 @@ class Driver { // Create a working canvas this.canvas = document.createElement("canvas"); + // Used as the render-target when testing rendering in worker, since a + // canvas can only be transferred once using `transferControlToOffscreen`. + this.renderCanvas = null; } run() { @@ -1202,8 +1207,23 @@ class Driver { initPromise = Promise.resolve(); } } + // Render into a separate canvas to allow + // `transferControlToOffscreen` + if (partialCrop) { + // Rendering directly into `this.canvas` is required to support + // `recordOperations` and cropping operations. + this.renderCanvas = this.canvas; + } else { + this.renderCanvas = document.createElement("canvas"); + this.renderCanvas.width = pixelWidth; + this.renderCanvas.height = pixelHeight; + this.renderCanvas.style.width = this.canvas.style.width; + this.renderCanvas.style.height = this.canvas.style.height; + } + const renderCanvas = this.renderCanvas; + const renderContext = { - canvas: this.canvas, + canvas: renderCanvas, viewport, optionalContentConfigPromise: task.optionalContentConfigPromise, annotationCanvasMap, @@ -1225,6 +1245,14 @@ class Driver { } const completeRender = error => { + if (renderCanvas !== this.canvas) { + try { + ctx.drawImage(renderCanvas, 0, 0); + } catch (ex) { + this._info(`Unable to copy the render canvas: ${ex}`); + } + renderCanvas.resetWorkerCanvas?.(); + } // if text layer is present, compose it on top of the page if (textLayerCanvas) { if (task.type === "text") { @@ -1265,6 +1293,7 @@ class Driver { await renderTask.promise; if (partialCrop) { + ctx = this.canvas.getContext("2d", { alpha: false }); const clearOutsidePartial = () => { const { width, height } = ctx.canvas; // Everything above the partial area diff --git a/test/integration/test_utils.mjs b/test/integration/test_utils.mjs index 8aab655d6bc63..67e0434d87f60 100644 --- a/test/integration/test_utils.mjs +++ b/test/integration/test_utils.mjs @@ -988,12 +988,34 @@ function waitForTooltipToBe(page, selector, text) { function isCanvasMonochrome(page, pageNumber, rectangle, color) { return page.evaluate( - (rect, pageN, col) => { + async (rect, pageN, col) => { const canvas = document.querySelector( `.page[data-page-number = "${pageN}"] .canvasWrapper canvas` ); + if (!canvas) { + return false; + } const canvasRect = canvas.getBoundingClientRect(); - const ctx = canvas.getContext("2d"); + let ctx; + try { + ctx = canvas.getContext("2d", { willReadFrequently: true }); + } catch { + // Happens when the canvas has been transferred to OffscreenCanvas. + } + if (!ctx) { + const bitmap = await createImageBitmap(canvas); + let tempCanvas; + if (typeof OffscreenCanvas === "function") { + tempCanvas = new OffscreenCanvas(canvas.width, canvas.height); + } else { + tempCanvas = document.createElement("canvas"); + tempCanvas.width = canvas.width; + tempCanvas.height = canvas.height; + } + ctx = tempCanvas.getContext("2d", { willReadFrequently: true }); + ctx.drawImage(bitmap, 0, 0); + bitmap.close(); + } rect ||= canvasRect; const { data } = ctx.getImageData( rect.x - canvasRect.x, diff --git a/test/integration/viewer_spec.mjs b/test/integration/viewer_spec.mjs index a7ff8b7af758d..e6bc785aa8d1c 100644 --- a/test/integration/viewer_spec.mjs +++ b/test/integration/viewer_spec.mjs @@ -544,23 +544,74 @@ describe("PDF viewer", () => { }; } - function extractCanvases(pageNumber) { + async function extractCanvases(pageNumber) { const pageOne = document.querySelector( `.page[data-page-number='${pageNumber}']` ); - return Array.from(pageOne.querySelectorAll("canvas"), canvas => { + async function getContextFromCanvas(canvas) { + try { + return canvas.getContext("2d", { willReadFrequently: true }); + } catch { + // Can happen when the canvas has been transferred to OffscreenCanvas. + } + + const bitmap = await createImageBitmap(canvas); + let tempCanvas; + if (typeof OffscreenCanvas === "function") { + tempCanvas = new OffscreenCanvas(canvas.width, canvas.height); + } else { + tempCanvas = document.createElement("canvas"); + tempCanvas.width = canvas.width; + tempCanvas.height = canvas.height; + } + const tempCtx = tempCanvas.getContext("2d", { + willReadFrequently: true, + }); + tempCtx.drawImage(bitmap, 0, 0); + bitmap.close(); + return tempCtx; + } + + if (!pageOne) { + return []; + } + + const canvases = pageOne.querySelectorAll("canvas"); + const results = []; + for (const canvas of canvases) { const { width, height } = canvas; - const ctx = canvas.getContext("2d"); - const topLeft = ctx.getImageData(2, 2, 1, 1).data; - const bottomRight = ctx.getImageData(width - 3, height - 3, 1, 1).data; - return { + if (width === 0 || height === 0) { + results.push({ + size: 0, + width, + height, + topLeft: null, + bottomRight: null, + }); + continue; + } + const ctx = await getContextFromCanvas(canvas); + const topLeft = ctx.getImageData( + Math.min(2, width - 1), + Math.min(2, height - 1), + 1, + 1 + ).data; + const bottomRight = ctx.getImageData( + Math.max(0, width - 3), + Math.max(0, height - 3), + 1, + 1 + ).data; + results.push({ size: width * height, width, height, topLeft: globalThis.pdfjsLib.Util.makeHexColor(...topLeft), bottomRight: globalThis.pdfjsLib.Util.makeHexColor(...bottomRight), - }; - }); + }); + } + return results; } function waitForDetailRendered(page) { @@ -579,6 +630,35 @@ describe("PDF viewer", () => { }); } + // Wait for canvas pixels to be available + function waitForCanvasPixels(page, pageNumber, canvasIndex = 1) { + return page.waitForFunction( + async (pgNum, idx) => { + const pageEl = document.querySelector( + `.page[data-page-number='${pgNum}']` + ); + if (!pageEl) { + return false; + } + const canvas = pageEl.querySelectorAll("canvas")[idx]; + if (!canvas || canvas.width === 0 || canvas.height === 0) { + return false; + } + const bitmap = await createImageBitmap(canvas, 2, 2, 1, 1); + const tmp = document.createElement("canvas"); + tmp.width = tmp.height = 1; + const ctx = tmp.getContext("2d"); + ctx.drawImage(bitmap, 0, 0); + bitmap.close(); + const { data } = ctx.getImageData(0, 0, 1, 1); + return data[0] !== 0 || data[1] !== 0 || data[2] !== 0; + }, + { timeout: 5000 }, + pageNumber, + canvasIndex + ); + } + for (const pixelRatio of [1, 2]) { describe(`with pixel ratio ${pixelRatio}`, () => { describe("setupPages()", () => { @@ -620,6 +700,7 @@ describe("PDF viewer", () => { }); }, factor); await awaitPromise(handle); + await waitForCanvasPixels(page, 1); const after = await page.evaluate(extractCanvases, 1); // The page dimensions are 595x841, so the base canvas is a scale @@ -660,6 +741,10 @@ describe("PDF viewer", () => { await page.waitForSelector( ".page[data-page-number='1'] .textLayer" ); + // Wait for the canvas to have actual pixels before reading + // colors; with the renderer worker, the first frame may not + // have been committed to the placeholder yet. + await waitForCanvasPixels(page, 1, 0); const before = await page.evaluate(extractCanvases, 1); @@ -692,6 +777,7 @@ describe("PDF viewer", () => { }); }, factor); await awaitPromise(handle); + await waitForCanvasPixels(page, 1); const after = await page.evaluate(extractCanvases, 1); @@ -732,6 +818,7 @@ describe("PDF viewer", () => { await page.waitForSelector( ".page[data-page-number='1'] canvas:nth-child(2)" ); + await waitForCanvasPixels(page, 1); const canvases = await page.evaluate(extractCanvases, 1); @@ -778,6 +865,7 @@ describe("PDF viewer", () => { container.scrollLeft += 1100; }); await awaitPromise(handle); + await waitForCanvasPixels(page, 1); const canvases = await page.evaluate(extractCanvases, 1); @@ -874,6 +962,8 @@ describe("PDF viewer", () => { container.scrollTop += 3000; }); await awaitPromise(handle); + await waitForCanvasPixels(page, 1); + await waitForCanvasPixels(page, 2); const [canvases1, canvases2] = await Promise.all([ page.evaluate(extractCanvases, 1), diff --git a/test/unit/jasmine-boot.js b/test/unit/jasmine-boot.js index 836a9b52ded99..c72652ada0898 100644 --- a/test/unit/jasmine-boot.js +++ b/test/unit/jasmine-boot.js @@ -123,6 +123,8 @@ async function initializePDFJS(callback) { } // Configure the worker. GlobalWorkerOptions.workerSrc = "../../build/generic/build/pdf.worker.mjs"; + GlobalWorkerOptions.rendererSrc = + "../../build/generic/build/pdf.renderer.mjs"; callback(); } diff --git a/web/base_pdf_page_view.js b/web/base_pdf_page_view.js index d85cac82e3ed8..9646a34a6cc09 100644 --- a/web/base_pdf_page_view.js +++ b/web/base_pdf_page_view.js @@ -16,6 +16,17 @@ import { FeatureTest, RenderingCancelledException } from "pdfjs-lib"; import { RenderableView, RenderingStates } from "./renderable_view.js"; +function releaseCanvas(canvas) { + if (!canvas) { + return; + } + if (canvas.resetWorkerCanvas) { + canvas.resetWorkerCanvas(); + return; + } + canvas.width = canvas.height = 0; +} + class BasePDFPageView extends RenderableView { #loadingId = null; @@ -124,13 +135,18 @@ class BasePDFPageView extends RenderableView { this.#showCanvas = isLastShow => { if (updateOnFirstShow) { let tempCanvas = this.#tempCanvas; - if (!isLastShow && this.minDurationToUpdateCanvas > 0) { + if ( + !isLastShow && + this.minDurationToUpdateCanvas > 0 && + !this.renderTask?.rendererHandler + ) { // We draw on the canvas at 60fps (in using `requestAnimationFrame`), // so if the canvas is large, updating it at 60fps can be a way too // much and can cause some serious performance issues. // To avoid that we only update the canvas every // `this.#minDurationToUpdateCanvas` ms. - + // When rendering in worker, we don't need this optimization because + // the rendering is already happening off the main thread. if (Date.now() - this.#startTime < this.minDurationToUpdateCanvas) { return; } @@ -167,7 +183,7 @@ class BasePDFPageView extends RenderableView { if (prevCanvas) { prevCanvas.replaceWith(canvas); - prevCanvas.width = prevCanvas.height = 0; + releaseCanvas(prevCanvas); } else { onShow(canvas); } @@ -195,14 +211,14 @@ class BasePDFPageView extends RenderableView { return; } canvas.remove(); - canvas.width = canvas.height = 0; + releaseCanvas(canvas); this.canvas = null; this.#resetTempCanvas(); } #resetTempCanvas() { if (this.#tempCanvas) { - this.#tempCanvas.width = this.#tempCanvas.height = 0; + releaseCanvas(this.#tempCanvas); this.#tempCanvas = null; } } diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index 3ee59520c23c4..ffee050cb854b 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -1289,6 +1289,11 @@ class PDFPageView extends BasePDFPageView { get thumbnailCanvas() { const { directDrawing, initialOptionalContent, regularAnnotations } = this.#useThumbnailCanvas; + // When worker rendering is used, we cannot use the OffScreen canvas + // for thumbnail generation. + if (this.canvas?.resetWorkerCanvas) { + return null; + } return directDrawing && initialOptionalContent && regularAnnotations ? this.canvas : null; diff --git a/web/pdf_rendering_queue.js b/web/pdf_rendering_queue.js index 1e424b123134b..8c18d92376f87 100644 --- a/web/pdf_rendering_queue.js +++ b/web/pdf_rendering_queue.js @@ -85,6 +85,11 @@ class PDFRenderingQueue { return; } // No pages needed rendering, so check thumbnails. + // TODO: When worker rendering is enabled, thumbnails are + // re-rendered from scratch because the page canvas is offscreen- + // transferred and cannot be reused as a thumbnail source. Consider + // having the worker emit a downscaled ImageBitmap to reuse the main + // render. if ( this.isThumbnailViewEnabled && this.#pdfThumbnailViewer?.forceRendering() diff --git a/web/pdf_thumbnail_view.js b/web/pdf_thumbnail_view.js index e2c822c0fbbf6..556360b3fa86c 100644 --- a/web/pdf_thumbnail_view.js +++ b/web/pdf_thumbnail_view.js @@ -324,12 +324,14 @@ class PDFThumbnailView extends RenderableView { const canvas = document.createElement("canvas"); canvas.width = (width * outputScale.sx) | 0; canvas.height = (height * outputScale.sy) | 0; + // Get the canvas context here to ensure we use main-thread rendering. + const canvasContext = canvas.getContext("2d", { alpha: false }); const transform = outputScale.scaled ? [outputScale.sx, 0, 0, outputScale.sy, 0, 0] : null; - return { canvas, transform }; + return { canvas, canvasContext, transform }; } async #convertCanvasToImage(canvas) { @@ -366,7 +368,8 @@ class PDFThumbnailView extends RenderableView { // the `draw` and `setImage` methods (fixes issue 8233). // NOTE: To primarily avoid increasing memory usage too much, but also to // reduce downsizing overhead, we purposely limit the up-scaling factor. - const { canvas, transform } = this.#getPageDrawContext(DRAW_UPSCALE_FACTOR); + const { canvas, canvasContext, transform } = + this.#getPageDrawContext(DRAW_UPSCALE_FACTOR); const drawViewport = this.viewport.clone({ scale: DRAW_UPSCALE_FACTOR * this.scale, }); @@ -383,7 +386,7 @@ class PDFThumbnailView extends RenderableView { }; const renderContext = { - canvas, + canvasContext, transform, viewport: drawViewport, optionalContentConfigPromise: this._optionalContentConfigPromise, From bfa56ac5c574b516861c6d82925d9a214d587491 Mon Sep 17 00:00:00 2001 From: Aditi Date: Mon, 13 Apr 2026 22:56:16 +0530 Subject: [PATCH 07/26] Enable WebGPU in 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. --- src/display/api.js | 6 ++++++ src/display/renderer_worker.js | 2 ++ 2 files changed, 8 insertions(+) diff --git a/src/display/api.js b/src/display/api.js index 8183635541962..f0bb01cc6e8d3 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -405,6 +405,7 @@ function getDocument(src = {}) { pdfBug, styleElement, enableHWA, + enableWebGPU, loadingParams: { disableAutoFetch, enableXfa, @@ -1661,6 +1662,7 @@ class PDFPageProxy { pdfBug: this._pdfBug, pageColors, enableHWA: this._transport.enableHWA, + enableWebGPU: this._transport.enableWebGPU, operationsFilter, rendererHandler: shouldRecordOperations || shouldRecordImages @@ -2665,6 +2667,7 @@ class WorkerTransport { styleElement: params.styleElement, }); this.enableHWA = params.enableHWA; + this.enableWebGPU = params.enableWebGPU === true; this.rendererHandler = params.rendererHandler || null; this.loadingParams = params.loadingParams; this._params = params; @@ -3525,6 +3528,7 @@ class InternalRenderTask { pdfBug = false, pageColors = null, enableHWA = false, + enableWebGPU = false, operationsFilter = null, rendererHandler = null, }) { @@ -3557,6 +3561,7 @@ class InternalRenderTask { this._canvas = params.canvas; this._canvasContext = params.canvas ? null : params.canvasContext; this._enableHWA = enableHWA; + this._enableWebGPU = enableWebGPU; this._dependencyTracker = params.dependencyTracker; this._imagesTracker = params.imagesTracker; this._operationsFilter = operationsFilter; @@ -3683,6 +3688,7 @@ class InternalRenderTask { pageIndex: this._pageIndex, renderTaskId: this._renderTaskId, enableHWA: this._enableHWA, + enableWebGPU: this._enableWebGPU, optionalContentConfig: optionalContentConfig.serializable, annotationCanvasMap: this.annotationCanvasMap ? annotationCanvases diff --git a/src/display/renderer_worker.js b/src/display/renderer_worker.js index 9f07162dd9577..4951a772d4a0e 100644 --- a/src/display/renderer_worker.js +++ b/src/display/renderer_worker.js @@ -16,6 +16,7 @@ import { isNodeJS, setVerbosityLevel } from "../shared/util.js"; import { CanvasGraphics } from "./canvas.js"; import { FontLoader } from "./font_loader.js"; +import { initGPU } from "./webgpu.js"; import { MessageHandler } from "../shared/message_handler.js"; import { ObjectHandler } from "./object_handler.js"; import { OffscreenCanvasFactory } from "./canvas_factory.js"; @@ -198,6 +199,7 @@ class RendererMessageHandler { pageIndex, renderTaskId, enableHWA = false, + enableWebGPU = false, annotationCanvasMap, transform, viewport, From 2b4aecd888a45c81bede06dde122cadfe3a3995f Mon Sep 17 00:00:00 2001 From: Aditi Date: Tue, 5 May 2026 03:05:24 +0530 Subject: [PATCH 08/26] Wait for GPU rendering --- src/display/api.js | 20 +++++++++++++++----- src/display/renderer_worker.js | 9 +++++++-- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/display/api.js b/src/display/api.js index f0bb01cc6e8d3..abce60cf8a39c 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -1677,7 +1677,7 @@ class PDFPageProxy { intentState.displayReadyCapability.promise, optionalContentConfigPromise, ]) - .then(([renderPageData, optionalContentConfig]) => { + .then(async ([renderPageData, optionalContentConfig]) => { if (this.destroyed) { complete(); return; @@ -1694,7 +1694,7 @@ class PDFPageProxy { renderPageData && typeof renderPageData === "object" ? renderPageData : { transparency: renderPageData }; - internalRenderTask.initializeGraphics({ + await internalRenderTask.initializeGraphics({ transparency, hasCanvasFilters: hasCanvasFilters || intentState.hasCanvasFilters, optionalContentConfig, @@ -3624,7 +3624,7 @@ class InternalRenderTask { return { annotationCanvases, transfers }; } - initializeGraphics({ + async initializeGraphics({ transparency = false, hasCanvasFilters = false, optionalContentConfig, @@ -3674,6 +3674,7 @@ class InternalRenderTask { if (!useWorkerRendering) { this._rendererHandler = null; } + let initPromise = null; if (useWorkerRendering) { try { const offscreen = this._canvas.transferControlToOffscreen(); @@ -3698,7 +3699,7 @@ class InternalRenderTask { transparency, background, }; - this._rendererHandler.send( + initPromise = this._rendererHandler.sendWithPromise( "InitializeGraphics", initParams, initTransfers @@ -3715,7 +3716,9 @@ class InternalRenderTask { `Failed to initialize graphics in renderer worker: ${ex.message}. ` + "Falling back to main-thread rendering." ); - // Fallback to regular rendering. + // Fallback to regular rendering. Only safe for synchronous failures + // before transferControlToOffscreen detaches the canvas; an async + // rejection from sendWithPromise is awaited below and propagates. this._rendererHandler = null; useWorkerRendering = false; } @@ -3749,6 +3752,13 @@ class InternalRenderTask { background, }); } + if (initPromise) { + // Wait for the renderer worker to finish setup. + await initPromise; + if (this.cancelled) { + return; + } + } this.operatorListIdx = 0; this.graphicsReady = true; this.graphicsReadyCallback?.(); diff --git a/src/display/renderer_worker.js b/src/display/renderer_worker.js index 4951a772d4a0e..c2813645083aa 100644 --- a/src/display/renderer_worker.js +++ b/src/display/renderer_worker.js @@ -31,6 +31,9 @@ class RendererMessageHandler { static #renderTaskStates = new Map(); + // Holds references to `OffscreenCanvas` instances transferred from the + // main thread. This is used to preserve the placeholder `` bitmap + // across page cleanup (e.g. during scrolling/idle cleanup in the viewer). static #canvasMap = new Map(); static #cleanedPages = new Set(); @@ -76,7 +79,7 @@ class RendererMessageHandler { renderTaskState.aborted = true; renderTaskState.continueResolve?.(); - renderTaskState.gfx.endDrawing(); + renderTaskState.gfx?.endDrawing(); this.#renderTaskStates.delete(renderTaskId); } @@ -241,7 +244,9 @@ class RendererMessageHandler { background, }); - // Store a reference to the OffscreenCanvas + // Keep a strong reference to the OffscreenCanvas so the placeholder + // `` can continue to display the last rendered output after + // `cleanupPage` (when `keepCanvas` is true). this.#canvasMap.set(pageIndex, canvas); this.#renderTaskStates.set(renderTaskId, { From 767b5f1d1066cf3508b449441143bd9b3ac87e0c Mon Sep 17 00:00:00 2001 From: Aditi Date: Sat, 16 May 2026 05:39:17 +0530 Subject: [PATCH 09/26] Add support for images and dependency tracker --- src/display/api.js | 127 ++++++++++++++--------- src/display/canvas_dependency_tracker.js | 12 +++ src/display/renderer_worker.js | 47 ++++++++- 3 files changed, 132 insertions(+), 54 deletions(-) diff --git a/src/display/api.js b/src/display/api.js index abce60cf8a39c..a94018d39e55a 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -39,6 +39,7 @@ import { SerializableEmpty, } from "./annotation_storage.js"; import { + BBoxReader, CanvasBBoxTracker, CanvasDependencyTracker, CanvasImagesTracker, @@ -1576,14 +1577,25 @@ class PDFPageProxy { const complete = error => { intentState.renderTasks.delete(internalRenderTask); + // Get the trackers from `gfx` into the task's. The worker path populates + // them from the final `ExecuteOperatorList` response, so we don't need + // this in that case. + if (!internalRenderTask._rendererHandler && internalRenderTask.gfx) { + const { dependencyTracker, imagesTracker } = internalRenderTask.gfx; + internalRenderTask.recordedBBoxes = dependencyTracker?.take() ?? null; + internalRenderTask.debugMetadata = recordForDebugger + ? (dependencyTracker?.takeDebugMetadata() ?? null) + : null; + internalRenderTask.imageCoordinates = imagesTracker?.take() ?? null; + } + if (shouldRecordOperations) { - const recordedBBoxes = internalRenderTask.gfx?.dependencyTracker.take(); + const { recordedBBoxes, debugMetadata } = internalRenderTask; if (recordedBBoxes) { internalRenderTask.stepper?.setOperatorBBoxes( recordedBBoxes, - internalRenderTask.gfx.dependencyTracker.takeDebugMetadata() + debugMetadata ); - if (recordOperations) { this.recordedBBoxes = recordedBBoxes; } @@ -1591,7 +1603,7 @@ class PDFPageProxy { } if (shouldRecordImages && !error) { - this.imageCoordinates = internalRenderTask.gfx?.imagesTracker.take(); + this.imageCoordinates = internalRenderTask.imageCoordinates; } // Attempt to reduce memory usage during *printing*, by always running @@ -1622,34 +1634,18 @@ class PDFPageProxy { } }; - let dependencyTracker = null; - let bboxTracker = null; - if (shouldRecordOperations || shouldRecordImages) { - bboxTracker = new CanvasBBoxTracker( - canvas, - intentState.operatorList.length - ); - } - if (shouldRecordOperations) { - dependencyTracker = new CanvasDependencyTracker( - bboxTracker, - recordForDebugger - ); - } - const internalRenderTask = new InternalRenderTask({ callback: complete, // Only include the required properties, and *not* the entire object. params: { canvas, canvasContext, - dependencyTracker: dependencyTracker ?? bboxTracker, - imagesTracker: shouldRecordImages - ? new CanvasImagesTracker(canvas) - : null, viewport, transform, background, + recordOperations: shouldRecordOperations, + recordImages: shouldRecordImages, + recordForDebugger, }, objs: this.objs, commonObjs: this.commonObjs, @@ -1664,10 +1660,7 @@ class PDFPageProxy { enableHWA: this._transport.enableHWA, enableWebGPU: this._transport.enableWebGPU, operationsFilter, - rendererHandler: - shouldRecordOperations || shouldRecordImages - ? null - : this._transport.rendererHandler, + rendererHandler: this._transport.rendererHandler, }); (intentState.renderTasks ||= new Set()).add(internalRenderTask); @@ -3562,13 +3555,19 @@ class InternalRenderTask { this._canvasContext = params.canvas ? null : params.canvasContext; this._enableHWA = enableHWA; this._enableWebGPU = enableWebGPU; - this._dependencyTracker = params.dependencyTracker; - this._imagesTracker = params.imagesTracker; + this._recordOperations = !!params.recordOperations; + this._recordImages = !!params.recordImages; + this._recordForDebugger = !!params.recordForDebugger; this._operationsFilter = operationsFilter; this._rendererHandler = rendererHandler; this._renderTaskId = InternalRenderTask.#renderTaskId++; this._sentOperatorListLength = 0; this._transferredAnnotationCanvasIds = new Set(); + // We get the recordedBBoxes and debugMetadata from the worker + // when recording is enabled, + this.recordedBBoxes = null; + this.debugMetadata = null; + this.imageCoordinates = null; } get completed() { @@ -3648,30 +3647,25 @@ class InternalRenderTask { this.stepper.init(this.operatorList); this.stepper.nextBreakPoint = this.stepper.getNextBreakPoint(); } - const { - viewport, - transform, - background, - dependencyTracker, - imagesTracker, - } = this.params; - - // TODO: Add support for dependencyTracker and imagesTracker when - // rendering in the worker - // Worker Rendering is disabled when canvas filters are present because - // OffscreenCanvas's OffscreenCanvasRenderingContext2D completely ignores - // the value of .filter defined with a data url. - // See bug 2011237 + const { viewport, transform, background } = this.params; + // The stepper-driven debug recording path needs `gfx` on the main thread, + // so we have to fall back to local rendering when it's enabled. Plain + // `recordOperations`/`recordImages` are now handled inside the worker. + // 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 = this._rendererHandler && this._canvasContext === null && !hasCanvasFilters && !this.pageColors && - !dependencyTracker && - !imagesTracker; + !this._recordForDebugger; - if (!useWorkerRendering) { + if (!useWorkerRendering && this._rendererHandler) { + // Only warn when a renderer worker is actually available, but cannot be + // used for this particular render + warn("Falling back to main-thread rendering."); this._rendererHandler = null; } let initPromise = null; @@ -3698,6 +3692,8 @@ class InternalRenderTask { viewport, transparency, background, + recordOperations: this._recordOperations, + recordImages: this._recordImages, }; initPromise = this._rendererHandler.sendWithPromise( "InitializeGraphics", @@ -3733,6 +3729,25 @@ class InternalRenderTask { willReadFrequently: !this._enableHWA, }); + let bboxTracker = null; + let dependencyTracker = null; + let imagesTracker = null; + if (this._recordOperations || this._recordImages) { + bboxTracker = new CanvasBBoxTracker( + this._canvas, + this.operatorList.fnArray.length + ); + } + if (this._recordOperations) { + dependencyTracker = new CanvasDependencyTracker( + bboxTracker, + this._recordForDebugger + ); + } + if (this._recordImages) { + imagesTracker = new CanvasImagesTracker(this._canvas); + } + this.gfx = new CanvasGraphics( canvasContext, this.commonObjs, @@ -3742,7 +3757,7 @@ class InternalRenderTask { { optionalContentConfig }, this.annotationCanvasMap, this.pageColors, - dependencyTracker, + dependencyTracker ?? bboxTracker, imagesTracker ); this.gfx.beginDrawing({ @@ -3797,8 +3812,10 @@ class InternalRenderTask { this.graphicsReadyCallback ||= this._continueBound; return; } - // Support for dependency tracking and stepper when rendering - // in the worker has not been added. + // When rendering in the renderer worker, the worker manages its own copy + // of the bbox/dependency tracker (sized inside `#appendOperatorList`). + // The stepper is main-thread only and is mutually exclusive with worker + // rendering, so there's nothing to update here in that case. if (!this._rendererHandler) { this.gfx.dependencyTracker?.growOperationsCount( this.operatorList.fnArray.length @@ -3868,7 +3885,7 @@ class InternalRenderTask { sentLength < operatorListArgsArrayLen ? operatorList.argsArray.slice(sentLength, operatorListArgsArrayLen) : null; - this.operatorListIdx = await this._rendererHandler.sendWithPromise( + const response = await this._rendererHandler.sendWithPromise( "ExecuteOperatorList", { renderTaskId: this._renderTaskId, @@ -3882,6 +3899,16 @@ class InternalRenderTask { lastChunk: operatorList.lastChunk, } ); + 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; diff --git a/src/display/canvas_dependency_tracker.js b/src/display/canvas_dependency_tracker.js index 1cdefc715f489..8902b29301ad3 100644 --- a/src/display/canvas_dependency_tracker.js +++ b/src/display/canvas_dependency_tracker.js @@ -70,6 +70,17 @@ class BBoxReader { this.#coords = coords; } + static fromBuffer(buffer) { + return new BBoxReader( + new Uint32Array(buffer), + new Uint8ClampedArray(buffer) + ); + } + + get buffer() { + return this.#bboxes.buffer; + } + get length() { return this.#bboxes.length; } @@ -1257,6 +1268,7 @@ class CanvasImagesTracker { } export { + BBoxReader, CanvasBBoxTracker, CanvasDependencyTracker, CanvasImagesTracker, diff --git a/src/display/renderer_worker.js b/src/display/renderer_worker.js index c2813645083aa..61f4c5c2a449f 100644 --- a/src/display/renderer_worker.js +++ b/src/display/renderer_worker.js @@ -13,6 +13,11 @@ * limitations under the License. */ +import { + CanvasBBoxTracker, + CanvasDependencyTracker, + CanvasImagesTracker, +} from "./canvas_dependency_tracker.js"; import { isNodeJS, setVerbosityLevel } from "../shared/util.js"; import { CanvasGraphics } from "./canvas.js"; import { FontLoader } from "./font_loader.js"; @@ -103,6 +108,9 @@ class RendererMessageHandler { operatorList.argsArray.push(...argsArray); } operatorList.lastChunk = lastChunk; + renderTaskState.gfx.dependencyTracker?.growOperationsCount( + operatorList.fnArray.length + ); } static async #executeOperatorList(renderTaskState, operationsFilter) { @@ -208,6 +216,8 @@ class RendererMessageHandler { viewport, transparency, background, + recordOperations = false, + recordImages = false, } = data; if (enableWebGPU) { await initGPU(); @@ -226,6 +236,22 @@ class RendererMessageHandler { const annotationCanvases = annotationCanvasMap ? new Map(annotationCanvasMap) : null; + let bboxTracker = null; + let dependencyTracker = null; + let imagesTracker = null; + if (recordOperations || recordImages) { + bboxTracker = new CanvasBBoxTracker(canvas, 0); + } + if (recordOperations) { + dependencyTracker = new CanvasDependencyTracker( + bboxTracker, + /* recordDebugMetadata = */ false + ); + } + if (recordImages) { + imagesTracker = new CanvasImagesTracker(canvas); + } + const gfx = new CanvasGraphics( ctx, this.#commonObjs, @@ -233,8 +259,10 @@ class RendererMessageHandler { canvasFactory, filterFactory, { optionalContentConfig }, - annotationCanvases - /** Renderer worker doesn't support pageColors and dependencyTracker */ + annotationCanvases, + /* pageColors = */ null, + dependencyTracker ?? bboxTracker, + imagesTracker ); gfx.beginDrawing({ @@ -290,7 +318,7 @@ class RendererMessageHandler { if (!renderTaskState) { // A render task can be cleaned up before queued // ExecuteOperatorList messages for that task are processed. - return operatorListIdx; + return { operatorListIdx }; } renderTaskState.operatorListIdx = operatorListIdx; @@ -300,13 +328,24 @@ class RendererMessageHandler { renderTaskState, operationsFilter ); + + let recordedBBoxesBuffer = null; + let imageCoordinates = null; if ( renderTaskState.operatorList.lastChunk && currentOperatorListIdx === renderTaskState.operatorList.argsArray.length ) { + const reader = renderTaskState.gfx.dependencyTracker?.take(); + recordedBBoxesBuffer = reader?.buffer; + const images = renderTaskState.gfx.imagesTracker?.take(); + imageCoordinates = images || null; this.#cleanupRenderTask(renderTaskId); } - return currentOperatorListIdx; + return { + operatorListIdx: currentOperatorListIdx, + recordedBBoxesBuffer, + imageCoordinates, + }; }); } } From 73eefc24d2ceedb2464a1d15dec9f057a97d832d Mon Sep 17 00:00:00 2001 From: Aditi Date: Tue, 2 Jun 2026 10:26:53 +0530 Subject: [PATCH 10/26] Make error handling for forwarding objects better --- src/display/api.js | 14 ++++++++++++-- src/display/canvas.js | 3 ++- src/display/pdf_objects.js | 24 ++++++++++++++++++++++-- src/display/renderer_worker.js | 22 +++++++++++++++++----- src/shared/message_handler.js | 4 ++++ 5 files changed, 57 insertions(+), 10 deletions(-) diff --git a/src/display/api.js b/src/display/api.js index a94018d39e55a..637480d851271 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -3038,8 +3038,16 @@ class WorkerTransport { } try { rendererHandler.send(action, data); - } catch { - // Ignore errors if the renderer worker has been destroyed. + } catch (reason) { + if (rendererHandler.destroyed) { + return; + } + warn(`forwardToRenderer("${action}") failed: ${reason}`); + rendererHandler.send("objFailed", { + id: data[0], + pageIndex: action === "obj" ? data[1] : null, + reason: reason.message, + }); } }; @@ -3929,6 +3937,8 @@ class InternalRenderTask { this.operatorList, this.operatorListIdx, this._continueBound, + // main-thread doesn't reject objects + null, this.stepper, this._operationsFilter ); diff --git a/src/display/canvas.js b/src/display/canvas.js index a882b57e83b13..5403c8202d7d2 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -663,6 +663,7 @@ class CanvasGraphics { operatorList, executionStartIdx, continueCallback, + errorCallback, stepper, operationsFilter ) { @@ -723,7 +724,7 @@ class CanvasGraphics { // If the promise isn't resolved yet, add the continueCallback // to the promise and bail out. if (!objsPool.has(depObjId)) { - objsPool.get(depObjId, continueCallback); + objsPool.get(depObjId, continueCallback, errorCallback); return i; } } diff --git a/src/display/pdf_objects.js b/src/display/pdf_objects.js index 7c54b01e41658..f48f66f241360 100644 --- a/src/display/pdf_objects.js +++ b/src/display/pdf_objects.js @@ -38,14 +38,17 @@ class PDFObjects { * * @param {string} objId * @param {function} [callback] + * @param {function} [errorCallback] - Called with the rejection reason if the + * object fails to resolve (e.g. it could never be delivered). Only used + * together with `callback`. * @returns {any} */ - get(objId, callback = null) { + get(objId, callback = null, errorCallback = null) { // If there is a callback, then the get can be async and the object is // not required to be resolved right now. if (callback) { const obj = this.#objs.getOrInsertComputed(objId, dataObj); - obj.promise.then(() => callback(obj.data)); + obj.promise.then(() => callback(obj.data), errorCallback); return null; } // If there isn't a callback, the user expects to get the resolved data @@ -97,6 +100,23 @@ class PDFObjects { obj.resolve(); } + /** + * Rejects the object `objId`, signalling that it will never be resolved. + * + * @param {string} objId + * @param {Error} reason + */ + reject(objId, reason) { + const obj = this.#objs.getOrInsertComputed(objId, dataObj); + if (obj.data !== INITIAL_DATA) { + return; + } + // Make sure a rejection that lands before a consumer calls + // `get` doesn't surface as an unhandled rejection + obj.promise.catch(() => {}); + obj.reject(reason); + } + clear() { for (const { data } of this.#objs.values()) { data?.bitmap?.close(); // Release any `ImageBitmap` data. diff --git a/src/display/renderer_worker.js b/src/display/renderer_worker.js index 61f4c5c2a449f..355d8cd367643 100644 --- a/src/display/renderer_worker.js +++ b/src/display/renderer_worker.js @@ -116,14 +116,14 @@ class RendererMessageHandler { static async #executeOperatorList(renderTaskState, operationsFilter) { const { operatorList, gfx } = renderTaskState; while (!renderTaskState.aborted) { - const continuePromise = new Promise(resolve => { - renderTaskState.continueResolve = resolve; - }); + const { promise, resolve, reject } = Promise.withResolvers(); + renderTaskState.continueResolve = resolve; renderTaskState.operatorListIdx = gfx.executeOperatorList( operatorList, renderTaskState.operatorListIdx, - renderTaskState.continueResolve, + resolve, + reject, undefined, // Renderer does not support stepper yet. operationsFilter ); @@ -131,7 +131,7 @@ class RendererMessageHandler { if (renderTaskState.operatorListIdx === operatorList.argsArray.length) { return renderTaskState.operatorListIdx; } - await continuePromise; + await promise; } return renderTaskState.operatorListIdx; } @@ -162,6 +162,18 @@ class RendererMessageHandler { } objectHandler.resolveObject(id, pageIndex, type, imageData); }); + + handler.on("objFailed", ({ id, pageIndex, reason }) => { + const error = new Error(reason); + if (pageIndex === null) { + this.#commonObjs.reject(id, error); + return; + } + if (this.#cleanedPages.has(pageIndex)) { + return; + } + this.#getPageObjs(pageIndex).reject(id, error); + }); } static setup(handler) { diff --git a/src/shared/message_handler.js b/src/shared/message_handler.js index a1af0aab27b54..940ce1832be6a 100644 --- a/src/shared/message_handler.js +++ b/src/shared/message_handler.js @@ -533,6 +533,10 @@ class MessageHandler { this.#messageAC?.abort(); this.#messageAC = null; } + + get destroyed() { + return this.#messageAC === null; + } } export { MessageHandler, wrapReason }; From fc86d762224f69bc840c9d4201bfeb563a91875e Mon Sep 17 00:00:00 2001 From: Aditi Date: Sat, 13 Jun 2026 04:27:46 +0530 Subject: [PATCH 11/26] Couple the renderer-worker handler lifecycle to its consumers --- src/display/api.js | 116 ++++++++++++++++------------------ src/shared/message_handler.js | 4 -- 2 files changed, 54 insertions(+), 66 deletions(-) diff --git a/src/display/api.js b/src/display/api.js index 637480d851271..1d2743d17962d 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -479,7 +479,7 @@ function getDocument(src = {}) { networkStream, { ...transportParams, - rendererHandler: task._rendererWorker?.rendererHandler || null, + rendererWorker: task._rendererWorker, }, transportFactory, pagesMapper @@ -1580,7 +1580,7 @@ class PDFPageProxy { // Get the trackers from `gfx` into the task's. The worker path populates // them from the final `ExecuteOperatorList` response, so we don't need // this in that case. - if (!internalRenderTask._rendererHandler && internalRenderTask.gfx) { + if (!internalRenderTask.rendererHandler && internalRenderTask.gfx) { const { dependencyTracker, imagesTracker } = internalRenderTask.gfx; internalRenderTask.recordedBBoxes = dependencyTracker?.take() ?? null; internalRenderTask.debugMetadata = recordForDebugger @@ -1660,7 +1660,7 @@ class PDFPageProxy { enableHWA: this._transport.enableHWA, enableWebGPU: this._transport.enableWebGPU, operationsFilter, - rendererHandler: this._transport.rendererHandler, + rendererWorker: this._transport.rendererWorker, }); (intentState.renderTasks ||= new Set()).add(internalRenderTask); @@ -1847,15 +1847,9 @@ class PDFPageProxy { } } this.objs.clear(); - if (this._transport.rendererHandler) { - try { - this._transport.rendererHandler.send("cleanupPage", { - pageIndex: this._pageIndex, - }); - } catch { - // Ignore errors if the renderer worker has been destroyed. - } - } + this._transport.rendererHandler?.send("cleanupPage", { + pageIndex: this._pageIndex, + }); this.#pendingCleanup = false; return Promise.all(waitOn); @@ -1896,16 +1890,10 @@ class PDFPageProxy { } this._intentStates.clear(); this.objs.clear(); - if (this._transport.rendererHandler) { - try { - this._transport.rendererHandler.send("cleanupPage", { - pageIndex: this._pageIndex, - keepCanvas: this.#keepRendererCanvas, - }); - } catch { - // Ignore errors if the renderer worker has been destroyed. - } - } + this._transport.rendererHandler?.send("cleanupPage", { + pageIndex: this._pageIndex, + keepCanvas: this.#keepRendererCanvas, + }); this.#keepRendererCanvas = false; this.#pendingCleanup = false; return true; @@ -1972,15 +1960,9 @@ class PDFPageProxy { // Restore the page in the renderer worker before any `obj` message can // be forwarded, since the core worker emits each object only once and a // dropped one would hang `ExecuteOperatorList` on its dependency. - if (this._transport.rendererHandler) { - try { - this._transport.rendererHandler.send("restorePage", { - pageIndex: this._pageIndex, - }); - } catch { - // Ignore errors if the renderer worker has been destroyed. - } - } + this._transport.rendererHandler?.send("restorePage", { + pageIndex: this._pageIndex, + }); const readableStream = this._transport.messageHandler.sendWithStream( "GetOperatorList", @@ -2661,7 +2643,7 @@ class WorkerTransport { }); this.enableHWA = params.enableHWA; this.enableWebGPU = params.enableWebGPU === true; - this.rendererHandler = params.rendererHandler || null; + this.rendererWorker = params.rendererWorker || null; this.loadingParams = params.loadingParams; this._params = params; @@ -2727,6 +2709,15 @@ class WorkerTransport { return shadow(this, "annotationStorage", new AnnotationStorage()); } + /** + * Reading through the `RendererWorker` ensures that destroying the renderer + * worker is observed here, without holding a stale handler reference. + * @type {MessageHandler | null} + */ + get rendererHandler() { + return this.rendererWorker?.rendererHandler ?? null; + } + getRenderingIntent( intent, annotationMode = AnnotationMode.ENABLE, @@ -2840,6 +2831,7 @@ class WorkerTransport { this.messageHandler?.destroy(); this.messageHandler = null; + this.rendererWorker = null; this.destroyCapability.resolve(); }, this.destroyCapability.reject); @@ -2847,7 +2839,7 @@ class WorkerTransport { } setupMessageHandler() { - const { messageHandler, loadingTask, rendererHandler } = this; + const { messageHandler, loadingTask } = this; messageHandler.on("GetReader", (data, sink) => { assert( @@ -3025,7 +3017,7 @@ class WorkerTransport { // TODO: add a direct channel between the renderer worker and the core // worker so these main-thread forwarders can be removed. - rendererHandler?.on("FontFallback", data => { + this.rendererHandler?.on("FontFallback", data => { if (this.destroyed) { return null; } @@ -3033,15 +3025,13 @@ class WorkerTransport { }); const forwardToRenderer = (action, data) => { + const { rendererHandler } = this; if (!rendererHandler) { return; } try { rendererHandler.send(action, data); } catch (reason) { - if (rendererHandler.destroyed) { - return; - } warn(`forwardToRenderer("${action}") failed: ${reason}`); rendererHandler.send("objFailed", { id: data[0], @@ -3531,7 +3521,7 @@ class InternalRenderTask { enableHWA = false, enableWebGPU = false, operationsFilter = null, - rendererHandler = null, + rendererWorker = null, }) { this.callback = callback; this.params = params; @@ -3567,7 +3557,7 @@ class InternalRenderTask { this._recordImages = !!params.recordImages; this._recordForDebugger = !!params.recordForDebugger; this._operationsFilter = operationsFilter; - this._rendererHandler = rendererHandler; + this._rendererWorker = rendererWorker; this._renderTaskId = InternalRenderTask.#renderTaskId++; this._sentOperatorListLength = 0; this._transferredAnnotationCanvasIds = new Set(); @@ -3586,7 +3576,7 @@ class InternalRenderTask { } get rendererHandler() { - return this._rendererHandler; + return this._rendererWorker?.rendererHandler ?? null; } // Transfer annotation canvases to the renderer worker which show up in the @@ -3664,17 +3654,19 @@ class InternalRenderTask { // because OffscreenCanvas's OffscreenCanvasRenderingContext2D ignores // `.filter` values set from a data URL. See bug 2011237. let useWorkerRendering = - this._rendererHandler && + this.rendererHandler && this._canvasContext === null && !hasCanvasFilters && !this.pageColors && !this._recordForDebugger; - if (!useWorkerRendering && this._rendererHandler) { + if (!useWorkerRendering && this._rendererWorker) { // Only warn when a renderer worker is actually available, but cannot be // used for this particular render - warn("Falling back to main-thread rendering."); - this._rendererHandler = null; + if (this.rendererHandler) { + warn("Falling back to main-thread rendering."); + } + this._rendererWorker = null; } let initPromise = null; if (useWorkerRendering) { @@ -3703,17 +3695,18 @@ class InternalRenderTask { recordOperations: this._recordOperations, recordImages: this._recordImages, }; - initPromise = this._rendererHandler.sendWithPromise( + initPromise = this.rendererHandler.sendWithPromise( "InitializeGraphics", initParams, initTransfers ); // Mark the canvas as worker-rendered so that consumers (thumbnail // generation, test driver) can detect and clean up appropriately. - const rendererHandler = this._rendererHandler; - const renderTaskId = this._renderTaskId; + const { _rendererWorker, _renderTaskId } = this; this._canvas.resetWorkerCanvas = () => { - rendererHandler.send("CleanupRenderTask", { renderTaskId }); + _rendererWorker.rendererHandler?.send("CleanupRenderTask", { + renderTaskId: _renderTaskId, + }); }; } catch (ex) { warn( @@ -3723,7 +3716,7 @@ class InternalRenderTask { // Fallback to regular rendering. Only safe for synchronous failures // before transferControlToOffscreen detaches the canvas; an async // rejection from sendWithPromise is awaited below and propagates. - this._rendererHandler = null; + this._rendererWorker = null; useWorkerRendering = false; } } @@ -3790,15 +3783,9 @@ class InternalRenderTask { cancel(error = null, extraDelay = 0) { this.running = false; this.cancelled = true; - if (this._rendererHandler) { - try { - this._rendererHandler.send("CleanupRenderTask", { - renderTaskId: this._renderTaskId, - }); - } catch { - // Ignore errors if the renderer worker has been destroyed. - } - } + this.rendererHandler?.send("CleanupRenderTask", { + renderTaskId: this._renderTaskId, + }); this.gfx?.endDrawing(); if (this.#rAF) { window.cancelAnimationFrame(this.#rAF); @@ -3824,7 +3811,7 @@ class InternalRenderTask { // of the bbox/dependency tracker (sized inside `#appendOperatorList`). // The stepper is main-thread only and is mutually exclusive with worker // rendering, so there's nothing to update here in that case. - if (!this._rendererHandler) { + if (!this._rendererWorker) { this.gfx.dependencyTracker?.growOperationsCount( this.operatorList.fnArray.length ); @@ -3864,7 +3851,12 @@ class InternalRenderTask { return; } const { operatorList, operatorListIdx } = this; - if (this._rendererHandler) { + if (this._rendererWorker) { + + const { rendererHandler } = this; + if (!rendererHandler) { + throw new Error("Renderer worker was destroyed during rendering."); + } const operatorListArgsArrayLen = operatorList.argsArray.length; const sentLength = Math.min( this._sentOperatorListLength, @@ -3876,7 +3868,7 @@ class InternalRenderTask { operatorListArgsArrayLen ); if (annotationCanvases.length > 0) { - this._rendererHandler.send( + rendererHandler.send( "UpdateAnnotationCanvases", { renderTaskId: this._renderTaskId, @@ -3893,7 +3885,7 @@ class InternalRenderTask { sentLength < operatorListArgsArrayLen ? operatorList.argsArray.slice(sentLength, operatorListArgsArrayLen) : null; - const response = await this._rendererHandler.sendWithPromise( + const response = await rendererHandler.sendWithPromise( "ExecuteOperatorList", { renderTaskId: this._renderTaskId, diff --git a/src/shared/message_handler.js b/src/shared/message_handler.js index 940ce1832be6a..a1af0aab27b54 100644 --- a/src/shared/message_handler.js +++ b/src/shared/message_handler.js @@ -533,10 +533,6 @@ class MessageHandler { this.#messageAC?.abort(); this.#messageAC = null; } - - get destroyed() { - return this.#messageAC === null; - } } export { MessageHandler, wrapReason }; From a2a486bd322d70c6e068e8e9434b920888819f9a Mon Sep 17 00:00:00 2001 From: Aditi Date: Tue, 30 Jun 2026 04:27:06 +0530 Subject: [PATCH 12/26] Allow PDFs checkboxes while rendering in worker --- src/display/api.js | 45 +++++++++++++++++++++++++++------- src/display/canvas.js | 38 ++++++++++++++++++++-------- src/display/renderer_worker.js | 38 +++++++++++++++++++++++----- 3 files changed, 96 insertions(+), 25 deletions(-) diff --git a/src/display/api.js b/src/display/api.js index 1d2743d17962d..b06507f007805 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -3560,7 +3560,9 @@ class InternalRenderTask { this._rendererWorker = rendererWorker; this._renderTaskId = InternalRenderTask.#renderTaskId++; this._sentOperatorListLength = 0; - this._transferredAnnotationCanvasIds = new Set(); + // Maps an annotation id to the set of canvas names that have + // already been transferred to the worker. + this._transferredAnnotationCanvasIds = new Map(); // We get the recordedBBoxes and debugMetadata from the worker // when recording is enabled, this.recordedBBoxes = null; @@ -3596,24 +3598,49 @@ class InternalRenderTask { if (fnArray[i] !== OPS.beginAnnotation) { continue; } - const [id, , , , hasOwnCanvas] = argsArray[i]; - if (!hasOwnCanvas || this._transferredAnnotationCanvasIds.has(id)) { + const [id, , , , hasOwnCanvas, canvasName] = argsArray[i]; + if (!hasOwnCanvas) { + continue; + } + const transferredNames = this._transferredAnnotationCanvasIds.get(id); + if (transferredNames?.has(canvasName)) { continue; } - let canvas = this.annotationCanvasMap.get(id); - if (!canvas) { - canvas = this._canvas.ownerDocument.createElement("canvas"); - this.annotationCanvasMap.set(id, canvas); + let canvas; + if (canvasName) { + let canvases = this.annotationCanvasMap.get(id); + if (!canvases) { + canvases = []; + this.annotationCanvasMap.set(id, canvases); + } + canvas = canvases.find( + c => c.getAttribute("data-canvas-name") === canvasName + ); + if (!canvas) { + canvas = this._canvas.ownerDocument.createElement("canvas"); + canvas.setAttribute("data-canvas-name", canvasName); + canvases.push(canvas); + } + } else { + canvas = this.annotationCanvasMap.get(id); + if (!canvas) { + canvas = this._canvas.ownerDocument.createElement("canvas"); + this.annotationCanvasMap.set(id, canvas); + } } if (typeof canvas.transferControlToOffscreen !== "function") { continue; } try { const offscreen = canvas.transferControlToOffscreen(); - annotationCanvases.push([id, offscreen]); + annotationCanvases.push([id, canvasName, offscreen]); transfers.push(offscreen); - this._transferredAnnotationCanvasIds.add(id); + if (!transferredNames) { + this._transferredAnnotationCanvasIds.set(id, new Set([canvasName])); + } else { + transferredNames.add(canvasName); + } } catch (ex) { warn(`Failed to transfer annotation canvas to worker: ${ex.message}.`); } diff --git a/src/display/canvas.js b/src/display/canvas.js index 5403c8202d7d2..808299f70a567 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -451,6 +451,15 @@ function copyCtxState(sourceCtx, destCtx) { } } +function setAnnotationCanvasName(canvas, canvasName) { + canvas.setAttribute?.("data-canvas-name", canvasName); + canvas._pdfjsCanvasName = canvasName; +} + +function getAnnotationCanvasName(canvas) { + return canvas._pdfjsCanvasName ?? canvas.getAttribute?.("data-canvas-name"); +} + function resetCtxToDefault(ctx) { ctx.strokeStyle = ctx.fillStyle = "#000000"; ctx.fillRule = "nonzero"; @@ -3690,25 +3699,34 @@ class CanvasGraphics { let canvas, context; if (canvasName) { - this.annotationCanvas = this.canvasFactory.create( - canvasWidth, - canvasHeight - ); - ({ canvas, context } = this.annotationCanvas); const canvases = this.annotationCanvasMap.getOrInsertComputed( id, makeArr ); - canvas.setAttribute("data-canvas-name", canvasName); // Replace any same-named canvas from a previous render so stale // low-resolution canvases don't pile up across zooms. const index = canvases.findIndex( - c => c.getAttribute("data-canvas-name") === canvasName + c => getAnnotationCanvasName(c) === canvasName ); - if (index === -1) { - canvases.push(canvas); + if (index !== -1) { + // Reuse a canvas that was already transferred from the main + // thread. + canvas = canvases[index]; + canvas.width = canvasWidth; + canvas.height = canvasHeight; + context = canvas.getContext("2d"); + if (!context) { + throw new Error("Unable to initialize annotation canvas."); + } + this.annotationCanvas = { canvas, context }; } else { - canvases[index] = canvas; + this.annotationCanvas = this.canvasFactory.create( + canvasWidth, + canvasHeight + ); + ({ canvas, context } = this.annotationCanvas); + setAnnotationCanvasName(canvas, canvasName); + canvases.push(canvas); } } else { canvas = this.annotationCanvasMap.get(id); diff --git a/src/display/renderer_worker.js b/src/display/renderer_worker.js index 355d8cd367643..2646e0d4639cf 100644 --- a/src/display/renderer_worker.js +++ b/src/display/renderer_worker.js @@ -43,6 +43,30 @@ class RendererMessageHandler { static #cleanedPages = new Set(); + // Merges `[id, canvasName, canvas]` tuples sent from the main thread into + // `map`, mirroring the tagging/matching convention `canvas.js` uses so a + // pre-transferred canvas can be found and reused instead of orphaned. + static #mergeAnnotationCanvases(map, tuples) { + for (const [id, canvasName, canvas] of tuples) { + if (!canvasName) { + map.set(id, canvas); + continue; + } + canvas._pdfjsCanvasName = canvasName; + let canvases = map.get(id); + if (!Array.isArray(canvases)) { + canvases = []; + map.set(id, canvases); + } + const index = canvases.findIndex(c => c._pdfjsCanvasName === canvasName); + if (index === -1) { + canvases.push(canvas); + } else { + canvases[index] = canvas; + } + } + } + static #fontLoader = new FontLoader({ ownerDocument: globalThis, }); @@ -245,9 +269,10 @@ class RendererMessageHandler { }); const canvasFactory = new OffscreenCanvasFactory({ enableHWA }); const filterFactory = new WorkerFilterFactory(); - const annotationCanvases = annotationCanvasMap - ? new Map(annotationCanvasMap) - : null; + const annotationCanvases = annotationCanvasMap ? new Map() : null; + if (annotationCanvasMap) { + this.#mergeAnnotationCanvases(annotationCanvases, annotationCanvasMap); + } let bboxTracker = null; let dependencyTracker = null; let imagesTracker = null; @@ -312,9 +337,10 @@ class RendererMessageHandler { if (!renderTaskState || !renderTaskState.gfx.annotationCanvasMap) { return; } - for (const [id, canvas] of annotationCanvasMap) { - renderTaskState.gfx.annotationCanvasMap.set(id, canvas); - } + this.#mergeAnnotationCanvases( + renderTaskState.gfx.annotationCanvasMap, + annotationCanvasMap + ); }); handler.on("ExecuteOperatorList", async data => { From 4df87795f3f69e7943a77e7bc198395192b15e86 Mon Sep 17 00:00:00 2001 From: Aditi Date: Tue, 30 Jun 2026 05:10:25 +0530 Subject: [PATCH 13/26] Address review comments --- src/display/api.js | 21 ++++++++++++++++----- src/display/canvas.js | 2 +- src/display/renderer_worker.js | 6 ++++-- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/display/api.js b/src/display/api.js index b06507f007805..4913e8efb8a7d 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -2113,11 +2113,11 @@ class PDFPageProxy { * parameters. */ class RendererWorker { - #worker = null; + #capability = Promise.withResolvers(); #rendererHandler = null; - #capability = Promise.withResolvers(); + #worker = null; constructor({ name = null, verbosity = getVerbosityLevel() } = {}) { this.name = name; @@ -2157,8 +2157,20 @@ class RendererWorker { ); return; } + let { rendererSrc } = RendererWorker; + try { - const { rendererSrc } = RendererWorker; + // Wraps rendererSrc path into blob URL, if the former does not belong + // to the same origin. + if ( + typeof PDFJSDev !== "undefined" && + PDFJSDev.test("GENERIC") && + !PDFWorker._isSameOrigin(window.location, rendererSrc) + ) { + rendererSrc = PDFWorker._createCDNWrapper( + new URL(rendererSrc, window.location).href + ); + } const worker = new Worker(rendererSrc, { type: "module" }); const rendererHandler = new MessageHandler("main", "renderer", worker); @@ -3560,7 +3572,7 @@ class InternalRenderTask { this._rendererWorker = rendererWorker; this._renderTaskId = InternalRenderTask.#renderTaskId++; this._sentOperatorListLength = 0; - // Maps an annotation id to the set of canvas names that have + // Maps an annotation id to the set of canvas names that have // already been transferred to the worker. this._transferredAnnotationCanvasIds = new Map(); // We get the recordedBBoxes and debugMetadata from the worker @@ -3879,7 +3891,6 @@ class InternalRenderTask { } const { operatorList, operatorListIdx } = this; if (this._rendererWorker) { - const { rendererHandler } = this; if (!rendererHandler) { throw new Error("Renderer worker was destroyed during rendering."); diff --git a/src/display/canvas.js b/src/display/canvas.js index 808299f70a567..ed0939350a1a0 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -3710,7 +3710,7 @@ class CanvasGraphics { ); if (index !== -1) { // Reuse a canvas that was already transferred from the main - // thread. + // thread. canvas = canvases[index]; canvas.width = canvasWidth; canvas.height = canvasHeight; diff --git a/src/display/renderer_worker.js b/src/display/renderer_worker.js index 2646e0d4639cf..ea7a050709591 100644 --- a/src/display/renderer_worker.js +++ b/src/display/renderer_worker.js @@ -128,8 +128,10 @@ class RendererMessageHandler { static #appendOperatorList(renderTaskState, fnArray, argsArray, lastChunk) { const { operatorList } = renderTaskState; if (fnArray) { - operatorList.fnArray.push(...fnArray); - operatorList.argsArray.push(...argsArray); + for (let i = 0, ii = fnArray.length; i < ii; i++) { + operatorList.fnArray.push(fnArray[i]); + operatorList.argsArray.push(argsArray[i]); + } } operatorList.lastChunk = lastChunk; renderTaskState.gfx.dependencyTracker?.growOperationsCount( From 01f8333f10652ef54f9c7c5471340e04d9fdd4ef Mon Sep 17 00:00:00 2001 From: Aditi Date: Tue, 14 Jul 2026 12:59:17 +0530 Subject: [PATCH 14/26] fixup! Add object forwarding between main thread and renderer worker Forwarded CopyLocalImage messages carry no pixel data, so the renderer worker could stall waiting for an image the core worker never re-sent. Forward the main thread's decoded image when the renderer worker's local copy fails. --- src/display/api.js | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/display/api.js b/src/display/api.js index 4913e8efb8a7d..81b8c4e4d8f77 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -3058,6 +3058,31 @@ class WorkerTransport { return null; // Ignore any pending requests if the worker was terminated. } + if (type === "CopyLocalImage") { + const dataLen = this.commonObjs.has(id) + ? null + : objectHandler.resolveCommonObject(id, type, exportedData); + const { rendererHandler } = this; + if (!dataLen || !rendererHandler) { + return dataLen; + } + // If the core worker doesn't re-send the image data, ensure that + // the renderer worker has a copy too + return rendererHandler + .sendWithPromise("commonobj", [id, type, exportedData]) + .catch(() => null) + .then(rendererDataLen => { + if (!rendererDataLen) { + forwardToRenderer("commonobj", [ + id, + "Image", + this.commonObjs.get(id), + ]); + } + return dataLen; + }); + } + forwardToRenderer("commonobj", [id, type, exportedData]); if (this.commonObjs.has(id)) { From a3d287914079d83006a872b729b60b3853b177b5 Mon Sep 17 00:00:00 2001 From: Aditi Date: Tue, 14 Jul 2026 12:59:17 +0530 Subject: [PATCH 15/26] fixup! Add graphics initialization and operator list execution in renderer worker Clear a page's objects before dropping them in cleanupPage, so ImageBitmaps are closed and not leaked. --- src/display/renderer_worker.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/display/renderer_worker.js b/src/display/renderer_worker.js index ea7a050709591..889ff92d0caa3 100644 --- a/src/display/renderer_worker.js +++ b/src/display/renderer_worker.js @@ -114,6 +114,7 @@ class RendererMessageHandler { static #cleanupPage(pageIndex, keepCanvas = false) { this.#cleanedPages.add(pageIndex); + this.#objsMap.get(pageIndex)?.clear(); this.#objsMap.delete(pageIndex); for (const [renderTaskId, renderTaskState] of this.#renderTaskStates) { if (renderTaskState.pageIndex === pageIndex) { From 14b136b73fa31dd59640a40786c07e43c02eb106 Mon Sep 17 00:00:00 2001 From: Aditi Date: Tue, 14 Jul 2026 12:59:17 +0530 Subject: [PATCH 16/26] fixup! Add graphics initialization and operator list execution in renderer worker Register the render-task state before awaiting GPU initialization, so a cleanup arriving during initGPU() can abort the task instead of being ignored. --- src/display/renderer_worker.js | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/display/renderer_worker.js b/src/display/renderer_worker.js index 889ff92d0caa3..3e39197dca5e6 100644 --- a/src/display/renderer_worker.js +++ b/src/display/renderer_worker.js @@ -258,8 +258,25 @@ class RendererMessageHandler { recordOperations = false, recordImages = false, } = data; + const renderTaskState = { + pageIndex, + gfx: null, + operatorList: { + fnArray: [], + argsArray: [], + lastChunk: false, + }, + operatorListIdx: 0, + continueResolve: null, + aborted: false, + }; + this.#renderTaskStates.set(renderTaskId, renderTaskState); + if (enableWebGPU) { await initGPU(); + if (renderTaskState.aborted) { + return; + } } const objs = this.#getPageObjs(pageIndex); const optionalContentConfig = OptionalContentConfig.fromSerializable( @@ -317,18 +334,7 @@ class RendererMessageHandler { // `cleanupPage` (when `keepCanvas` is true). this.#canvasMap.set(pageIndex, canvas); - this.#renderTaskStates.set(renderTaskId, { - pageIndex, - gfx, - operatorList: { - fnArray: [], - argsArray: [], - lastChunk: false, - }, - operatorListIdx: 0, - continueResolve: null, - aborted: false, - }); + renderTaskState.gfx = gfx; }); handler.on("UpdateAnnotationCanvases", data => { From d44b2697d5cad37db2303c3da93fdef8ce528f73 Mon Sep 17 00:00:00 2001 From: Aditi Date: Tue, 14 Jul 2026 12:59:17 +0530 Subject: [PATCH 17/26] fixup! Add graphics initialization and operator list execution in renderer worker A function-valued operationsFilter cannot be structured-cloned and was silently dropped. Send a precomputed mask for the ops in each chunk instead. --- src/display/api.js | 17 ++++++++++++---- src/display/renderer_worker.js | 36 ++++++++++++++++++++++++++-------- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/src/display/api.js b/src/display/api.js index 81b8c4e4d8f77..69e3923444bf9 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -3948,6 +3948,18 @@ class InternalRenderTask { sentLength < operatorListArgsArrayLen ? 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 response = await rendererHandler.sendWithPromise( "ExecuteOperatorList", { @@ -3955,10 +3967,7 @@ class InternalRenderTask { fnArray, argsArray, operatorListIdx, - operationsFilter: - typeof this._operationsFilter === "function" - ? null - : this._operationsFilter, + operationsFilterMask, lastChunk: operatorList.lastChunk, } ); diff --git a/src/display/renderer_worker.js b/src/display/renderer_worker.js index 3e39197dca5e6..2d634eadb69c4 100644 --- a/src/display/renderer_worker.js +++ b/src/display/renderer_worker.js @@ -126,13 +126,25 @@ class RendererMessageHandler { } } - static #appendOperatorList(renderTaskState, fnArray, argsArray, lastChunk) { + static #appendOperatorList( + renderTaskState, + fnArray, + argsArray, + operationsFilterMask, + lastChunk + ) { const { operatorList } = renderTaskState; if (fnArray) { for (let i = 0, ii = fnArray.length; i < ii; i++) { operatorList.fnArray.push(fnArray[i]); operatorList.argsArray.push(argsArray[i]); } + if (operationsFilterMask) { + const mask = (renderTaskState.operationsFilterMask ||= []); + for (let i = 0, ii = operationsFilterMask.length; i < ii; i++) { + mask.push(operationsFilterMask[i]); + } + } } operatorList.lastChunk = lastChunk; renderTaskState.gfx.dependencyTracker?.growOperationsCount( @@ -140,8 +152,11 @@ class RendererMessageHandler { ); } - static async #executeOperatorList(renderTaskState, operationsFilter) { - const { operatorList, gfx } = renderTaskState; + static async #executeOperatorList(renderTaskState) { + const { operatorList, gfx, operationsFilterMask } = renderTaskState; + const operationsFilter = operationsFilterMask + ? i => operationsFilterMask[i] + : null; while (!renderTaskState.aborted) { const { promise, resolve, reject } = Promise.withResolvers(); renderTaskState.continueResolve = resolve; @@ -267,6 +282,7 @@ class RendererMessageHandler { lastChunk: false, }, operatorListIdx: 0, + operationsFilterMask: null, continueResolve: null, aborted: false, }; @@ -358,7 +374,7 @@ class RendererMessageHandler { fnArray, argsArray, operatorListIdx, - operationsFilter, + operationsFilterMask, lastChunk, } = data; const renderTaskState = this.#renderTaskStates.get(renderTaskId); @@ -369,13 +385,17 @@ class RendererMessageHandler { } renderTaskState.operatorListIdx = operatorListIdx; - this.#appendOperatorList(renderTaskState, fnArray, argsArray, lastChunk); - - const currentOperatorListIdx = await this.#executeOperatorList( + this.#appendOperatorList( renderTaskState, - operationsFilter + fnArray, + argsArray, + operationsFilterMask, + lastChunk ); + const currentOperatorListIdx = + await this.#executeOperatorList(renderTaskState); + let recordedBBoxesBuffer = null; let imageCoordinates = null; if ( From 5531f28b5408ff6db6dc61140b2d0607d28401f0 Mon Sep 17 00:00:00 2001 From: Aditi Date: Tue, 14 Jul 2026 12:59:59 +0530 Subject: [PATCH 18/26] fixup! Adapt viewer and tests for OffscreenCanvas renderer worker Rendering partialCrop tasks directly into the test canvas bypassed the renderer worker. Always render into a separate canvas and copy the result back for cropping. --- test/driver.js | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/test/driver.js b/test/driver.js index 8821c7a86f79a..11f7c5fb549a7 100644 --- a/test/driver.js +++ b/test/driver.js @@ -1208,18 +1208,14 @@ class Driver { } } // Render into a separate canvas to allow - // `transferControlToOffscreen` - if (partialCrop) { - // Rendering directly into `this.canvas` is required to support - // `recordOperations` and cropping operations. - this.renderCanvas = this.canvas; - } else { - this.renderCanvas = document.createElement("canvas"); - this.renderCanvas.width = pixelWidth; - this.renderCanvas.height = pixelHeight; - this.renderCanvas.style.width = this.canvas.style.width; - this.renderCanvas.style.height = this.canvas.style.height; - } + // `transferControlToOffscreen`; `recordOperations` is tracked + // by the worker independently of which canvas receives the + // pixels, so `partialCrop` doesn't need `this.canvas` directly. + this.renderCanvas = document.createElement("canvas"); + this.renderCanvas.width = pixelWidth; + this.renderCanvas.height = pixelHeight; + this.renderCanvas.style.width = this.canvas.style.width; + this.renderCanvas.style.height = this.canvas.style.height; const renderCanvas = this.renderCanvas; const renderContext = { @@ -1293,7 +1289,14 @@ class Driver { await renderTask.promise; if (partialCrop) { - ctx = this.canvas.getContext("2d", { alpha: false }); + if (renderCanvas !== this.canvas) { + try { + ctx.drawImage(renderCanvas, 0, 0); + } catch (ex) { + this._info(`Unable to copy the render canvas: ${ex}`); + } + renderCanvas.resetWorkerCanvas?.(); + } const clearOutsidePartial = () => { const { width, height } = ctx.canvas; // Everything above the partial area From c6a06313e9084cf0a4638428c307df126ea09ccf Mon Sep 17 00:00:00 2001 From: Aditi Date: Tue, 14 Jul 2026 12:59:59 +0530 Subject: [PATCH 19/26] fixup! Adapt viewer and tests for OffscreenCanvas renderer worker getContext("2d") throws once a canvas has been transferred to the renderer worker; read the pixels via createImageBitmap instead. --- test/integration/reorganize_pages_spec.mjs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/test/integration/reorganize_pages_spec.mjs b/test/integration/reorganize_pages_spec.mjs index 2a63fdf646927..e9265879f5558 100644 --- a/test/integration/reorganize_pages_spec.mjs +++ b/test/integration/reorganize_pages_spec.mjs @@ -144,14 +144,25 @@ async function waitForPageCanvasToHaveImage(page, pageNumber) { const selector = `.page[data-page-number = "${pageNumber}"] .canvasWrapper canvas`; await page.waitForSelector(selector, { visible: true }); await page.waitForFunction( - sel => { + async sel => { const canvas = document.querySelector(sel); if (!canvas?.width || !canvas.height) { return false; } - const { data } = canvas - .getContext("2d", { willReadFrequently: true }) - .getImageData(0, 0, canvas.width, canvas.height); + // The canvas may have been transferred to the renderer worker. + let bitmap; + try { + bitmap = await createImageBitmap(canvas); + } catch { + return false; + } + const tmp = document.createElement("canvas"); + tmp.width = canvas.width; + tmp.height = canvas.height; + const ctx = tmp.getContext("2d", { willReadFrequently: true }); + ctx.drawImage(bitmap, 0, 0); + bitmap.close(); + const { data } = ctx.getImageData(0, 0, tmp.width, tmp.height); for (let i = 0, ii = data.length; i < ii; i += 4) { if ( data[i + 3] !== 0 && From d8776a2c6dc7ca56b56282a9485ad27088b6f456 Mon Sep 17 00:00:00 2001 From: Aditi Date: Tue, 14 Jul 2026 12:59:59 +0530 Subject: [PATCH 20/26] fixup! Address review comments Read RendererWorker.rendererSrc inside the try-block, so a missing GlobalWorkerOptions.rendererSrc rejects the capability instead of throwing synchronously. --- src/display/api.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/display/api.js b/src/display/api.js index 69e3923444bf9..829d40c92c295 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -2157,9 +2157,9 @@ class RendererWorker { ); return; } - let { rendererSrc } = RendererWorker; - try { + let { rendererSrc } = RendererWorker; + // Wraps rendererSrc path into blob URL, if the former does not belong // to the same origin. if ( From cf48e41b9bd1ca3076136ce19fd48892a4e47b6b Mon Sep 17 00:00:00 2001 From: Aditi Date: Tue, 14 Jul 2026 13:00:35 +0530 Subject: [PATCH 21/26] fixup! Add graphics initialization and operator list execution in renderer worker A caller that supplies its own canvasContext expects the pixels to land there; gate worker rendering on the absence of canvasContext rather than on a canvas being passed. --- src/display/api.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/display/api.js b/src/display/api.js index 829d40c92c295..18e96c6e70320 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -3719,7 +3719,7 @@ class InternalRenderTask { // `.filter` values set from a data URL. See bug 2011237. let useWorkerRendering = this.rendererHandler && - this._canvasContext === null && + !this.params.canvasContext && !hasCanvasFilters && !this.pageColors && !this._recordForDebugger; From eb2f3c07253c32b980a9e1a612229df5a0ddd2cb Mon Sep 17 00:00:00 2001 From: Aditi Date: Tue, 14 Jul 2026 13:00:35 +0530 Subject: [PATCH 22/26] fixup! Address review comments Rename #rendererHandler to #messageHandler, matching PDFWorker. --- src/display/api.js | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/display/api.js b/src/display/api.js index 18e96c6e70320..41662c4fb5144 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -2115,7 +2115,7 @@ class PDFPageProxy { class RendererWorker { #capability = Promise.withResolvers(); - #rendererHandler = null; + #messageHandler = null; #worker = null; @@ -2138,14 +2138,14 @@ class RendererWorker { * The current MessageHandler-instance. * @type {MessageHandler | null} */ - get rendererHandler() { - return this.#rendererHandler; + get messageHandler() { + return this.#messageHandler; } #resolve() { this.#capability.resolve(); // Send global setting, e.g. verbosity level. - this.#rendererHandler.send("configure", { + this.#messageHandler.send("configure", { verbosity: this.verbosity, }); } @@ -2172,9 +2172,9 @@ class RendererWorker { ); } const worker = new Worker(rendererSrc, { type: "module" }); - const rendererHandler = new MessageHandler("main", "renderer", worker); + const messageHandler = new MessageHandler("main", "renderer", worker); - rendererHandler.on("ready", () => { + messageHandler.on("ready", () => { ac.abort(); if (this.destroyed) { terminateEarly("Worker was destroyed."); @@ -2189,7 +2189,7 @@ class RendererWorker { const terminateEarly = reason => { ac.abort(); - rendererHandler.destroy(); + messageHandler.destroy(); worker.terminate(); this.#capability.reject( @@ -2211,13 +2211,13 @@ class RendererWorker { { signal: ac.signal } ); - rendererHandler.on("test", data => { + messageHandler.on("test", data => { ac.abort(); if (this.destroyed || !data) { terminateEarly("TypedArray transfer test failed."); return; } - this.#rendererHandler = rendererHandler; + this.#messageHandler = messageHandler; this.#worker = worker; this.#resolve(); @@ -2226,7 +2226,7 @@ class RendererWorker { const sendTest = () => { const testObj = new Uint8Array(); // Ensure that we can use `postMessage` transfers. - rendererHandler.send("test", testObj, [testObj.buffer]); + messageHandler.send("test", testObj, [testObj.buffer]); }; // It might take time for the worker to initialize. We will try to send @@ -2248,8 +2248,8 @@ class RendererWorker { this.#worker?.terminate(); this.#worker = null; - this.#rendererHandler?.destroy(); - this.#rendererHandler = null; + this.#messageHandler?.destroy(); + this.#messageHandler = null; } /** @@ -2727,7 +2727,7 @@ class WorkerTransport { * @type {MessageHandler | null} */ get rendererHandler() { - return this.rendererWorker?.rendererHandler ?? null; + return this.rendererWorker?.messageHandler ?? null; } getRenderingIntent( @@ -3615,7 +3615,7 @@ class InternalRenderTask { } get rendererHandler() { - return this._rendererWorker?.rendererHandler ?? null; + return this._rendererWorker?.messageHandler ?? null; } // Transfer annotation canvases to the renderer worker which show up in the @@ -3768,7 +3768,7 @@ class InternalRenderTask { // generation, test driver) can detect and clean up appropriately. const { _rendererWorker, _renderTaskId } = this; this._canvas.resetWorkerCanvas = () => { - _rendererWorker.rendererHandler?.send("CleanupRenderTask", { + _rendererWorker.messageHandler?.send("CleanupRenderTask", { renderTaskId: _renderTaskId, }); }; From fc247fc5dd6a1b5395eb2e02f8ab310ea7080c76 Mon Sep 17 00:00:00 2001 From: Aditi Date: Tue, 14 Jul 2026 13:00:35 +0530 Subject: [PATCH 23/26] fixup! Address review comments Rename #worker to #webWorker, matching PDFWorker, and register the initialize() listeners in a consistent order. --- src/display/api.js | 37 ++++++++++++++++++------------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/src/display/api.js b/src/display/api.js index 41662c4fb5144..65f335b7fbf57 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -2117,7 +2117,7 @@ class RendererWorker { #messageHandler = null; - #worker = null; + #webWorker = null; constructor({ name = null, verbosity = getVerbosityLevel() } = {}) { this.name = name; @@ -2173,20 +2173,6 @@ class RendererWorker { } const worker = new Worker(rendererSrc, { type: "module" }); const messageHandler = new MessageHandler("main", "renderer", worker); - - messageHandler.on("ready", () => { - ac.abort(); - if (this.destroyed) { - terminateEarly("Worker was destroyed."); - return; - } - try { - sendTest(); - } catch (reason) { - terminateEarly(reason); - } - }); - const terminateEarly = reason => { ac.abort(); messageHandler.destroy(); @@ -2203,7 +2189,7 @@ class RendererWorker { worker.addEventListener( "error", event => { - if (!this.#worker) { + if (!this.#webWorker) { // Worker failed to initialize due to an error. terminateEarly(event.error || event.message); } @@ -2218,11 +2204,24 @@ class RendererWorker { return; } this.#messageHandler = messageHandler; - this.#worker = worker; + this.#webWorker = worker; this.#resolve(); }); + messageHandler.on("ready", data => { + ac.abort(); + if (this.destroyed) { + terminateEarly("Worker was destroyed."); + return; + } + try { + sendTest(); + } catch (reason) { + terminateEarly(reason); + } + }); + const sendTest = () => { const testObj = new Uint8Array(); // Ensure that we can use `postMessage` transfers. @@ -2245,8 +2244,8 @@ class RendererWorker { this.destroyed = true; // We need to terminate only web worker created resource. - this.#worker?.terminate(); - this.#worker = null; + this.#webWorker?.terminate(); + this.#webWorker = null; this.#messageHandler?.destroy(); this.#messageHandler = null; From 7cbb9feaa4bd07afa6b9d7486d1bbbae55049835 Mon Sep 17 00:00:00 2001 From: Aditi Date: Tue, 14 Jul 2026 13:00:35 +0530 Subject: [PATCH 24/26] fixup! Wait for GPU rendering Reorder the static class members alphabetically and move the public initializeFromPort() below the private methods. --- src/display/renderer_worker.js | 60 +++++++++++++++++----------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/src/display/renderer_worker.js b/src/display/renderer_worker.js index 2d634eadb69c4..47701569261d6 100644 --- a/src/display/renderer_worker.js +++ b/src/display/renderer_worker.js @@ -30,12 +30,6 @@ import { PDFObjects } from "./pdf_objects.js"; import { WorkerFilterFactory } from "./filter_factory.js"; class RendererMessageHandler { - static #commonObjs = new PDFObjects(); - - static #objsMap = new Map(); - - static #renderTaskStates = new Map(); - // Holds references to `OffscreenCanvas` instances transferred from the // main thread. This is used to preserve the placeholder `` bitmap // across page cleanup (e.g. during scrolling/idle cleanup in the viewer). @@ -43,6 +37,30 @@ class RendererMessageHandler { static #cleanedPages = new Set(); + static #commonObjs = new PDFObjects(); + + static #fontLoader = new FontLoader({ + ownerDocument: globalThis, + }); + + static #objsMap = new Map(); + + static #renderTaskStates = new Map(); + + static { + // Worker thread (and not Node.js)? + if ( + typeof window === "undefined" && + !isNodeJS && + typeof self !== "undefined" && + /* isMessagePort = */ + typeof self.postMessage === "function" && + "onmessage" in self + ) { + this.initializeFromPort(self); + } + } + // Merges `[id, canvasName, canvas]` tuples sent from the main thread into // `map`, mirroring the tagging/matching convention `canvas.js` uses so a // pre-transferred canvas can be found and reused instead of orphaned. @@ -67,30 +85,6 @@ class RendererMessageHandler { } } - static #fontLoader = new FontLoader({ - ownerDocument: globalThis, - }); - - static { - // Worker thread (and not Node.js)? - if ( - typeof window === "undefined" && - !isNodeJS && - typeof self !== "undefined" && - /* isMessagePort = */ - typeof self.postMessage === "function" && - "onmessage" in self - ) { - this.initializeFromPort(self); - } - } - - static initializeFromPort(port) { - const handler = new MessageHandler("renderer", "main", port); - this.setup(handler); - handler.send("ready", null); - } - static #getPageObjs(pageIndex) { let objs = this.#objsMap.get(pageIndex); if (!objs) { @@ -415,6 +409,12 @@ class RendererMessageHandler { }; }); } + + static initializeFromPort(port) { + const handler = new MessageHandler("renderer", "main", port); + this.setup(handler); + handler.send("ready", null); + } } export { RendererMessageHandler }; From 4557e3ed550de210373d3f28ef432c949605e9cb Mon Sep 17 00:00:00 2001 From: Aditi Date: Tue, 14 Jul 2026 13:00:35 +0530 Subject: [PATCH 25/26] fixup! Adds RendererWorker class for offloading canvas Reorder the rendererSrc getter/setter alphabetically. --- src/display/worker_options.js | 38 +++++++++++++++++------------------ 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/display/worker_options.js b/src/display/worker_options.js index ff479f8448e05..3a9066d2f6e2e 100644 --- a/src/display/worker_options.js +++ b/src/display/worker_options.js @@ -16,9 +16,27 @@ class GlobalWorkerOptions { static #port = null; + static #rendererSrc = ""; + static #src = ""; - static #rendererSrc = ""; + /** + * @type {string} + */ + static get rendererSrc() { + return this.#rendererSrc; + } + + /** + * @param {string} rendererSrc - A string containing the path and + * filename of the renderer worker file. + */ + static set rendererSrc(val) { + if (typeof val !== "string") { + throw new Error("Invalid `rendererSrc` type."); + } + this.#rendererSrc = val; + } /** * @type {Worker | null} @@ -61,24 +79,6 @@ class GlobalWorkerOptions { } this.#src = val; } - - /** - * @type {string} - */ - static get rendererSrc() { - return this.#rendererSrc; - } - - /** - * @param {string} rendererSrc - A string containing the path and - * filename of the renderer worker file. - */ - static set rendererSrc(val) { - if (typeof val !== "string") { - throw new Error("Invalid `rendererSrc` type."); - } - this.#rendererSrc = val; - } } export { GlobalWorkerOptions }; From afdfee3acc23e010387025806802bcdcfe69d868 Mon Sep 17 00:00:00 2001 From: Aditi Date: Mon, 20 Jul 2026 13:34:03 +0530 Subject: [PATCH 26/26] Update the maximum number of prefs --- test/unit/app_options_spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/app_options_spec.js b/test/unit/app_options_spec.js index fd143814d92c0..fb6a3b0d688bc 100644 --- a/test/unit/app_options_spec.js +++ b/test/unit/app_options_spec.js @@ -40,7 +40,7 @@ describe("AppOptions", function () { // If the following constant is updated then you *MUST* make the same change // in mozilla-central as well to ensure that preference-fetching works; see // https://searchfox.org/mozilla-central/source/toolkit/components/pdfjs/content/PdfStreamConverter.sys.mjs - const MAX_NUMBER_OF_PREFS = 50; + const MAX_NUMBER_OF_PREFS = 60; const options = AppOptions.getAll(OptionKind.PREFERENCE); expect(objectSize(options)).toBeLessThanOrEqual(MAX_NUMBER_OF_PREFS);