From 7ab4143d4d9eb14d726b4cb9e317f020009a8fdb Mon Sep 17 00:00:00 2001 From: Aditi Date: Sun, 12 Apr 2026 20:21:07 +0530 Subject: [PATCH 01/13] 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 e5ac3f3abe2c6..5829b690f46f2 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"; @@ -2753,6 +2749,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. @@ -2762,78 +2766,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]) => { @@ -2841,25 +2774,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 c5b0368482d8ae56649447de18418ec96bf6e3f2 Mon Sep 17 00:00:00 2001 From: Aditi Date: Mon, 13 Apr 2026 11:18:01 +0530 Subject: [PATCH 02/13] 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 dca06a8d1fbb2..25feeccebd3b3 100644 --- a/gulpfile.mjs +++ b/gulpfile.mjs @@ -551,6 +551,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", @@ -1229,6 +1247,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 @@ -1373,6 +1392,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") @@ -1498,6 +1518,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") ), @@ -1603,6 +1626,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") ), @@ -1805,7 +1831,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 } ), @@ -2639,6 +2665,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") @@ -2857,8 +2884,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 } ) @@ -2866,16 +2895,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", { @@ -2884,7 +2919,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 5829b690f46f2..54770de3794df 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; } /** @@ -2010,6 +2045,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. @@ -2400,6 +2589,7 @@ class WorkerTransport { styleElement: params.styleElement, }); this.enableHWA = params.enableHWA; + this.rendererHandler = params.rendererHandler || null; this.loadingParams = params.loadingParams; this._params = params; @@ -3405,6 +3595,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 157adccd02d69..e104ad212540c 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -451,6 +451,11 @@ const defaultOptions = { value: false, kind: OptionKind.API + OptionKind.PREFERENCE, }, + disableWorkerRendering: { + /** @type {boolean} */ + value: false, + kind: OptionKind.API + OptionKind.PREFERENCE, + }, docBaseUrl: { /** @type {string} */ value: @@ -566,6 +571,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 b425a10dd95c6d7a6da137ba780d65a53a4dad42 Mon Sep 17 00:00:00 2001 From: Aditi Date: Sun, 12 Apr 2026 20:16:56 +0530 Subject: [PATCH 03/13] 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 a2eaa073729de..23181059f3143 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 97736b6c030e0..9fa633cceff08 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 4d5968ba215c4611ba0eaf9a3041be88c1f29ea0 Mon Sep 17 00:00:00 2001 From: Aditi Date: Mon, 13 Apr 2026 11:38:05 +0530 Subject: [PATCH 04/13] 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 54770de3794df..f6820537b17ad 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -1644,7 +1644,7 @@ class PDFPageProxy { intentState.displayReadyCapability.promise, optionalContentConfigPromise, ]) - .then(([transparency, optionalContentConfig]) => { + .then(([renderPageData, optionalContentConfig]) => { if (this.destroyed) { complete(); return; @@ -1657,8 +1657,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(); @@ -1860,16 +1865,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, + }); } /** @@ -2775,7 +2784,7 @@ class WorkerTransport { } setupMessageHandler() { - const { messageHandler, loadingTask } = this; + const { messageHandler, loadingTask, rendererHandler } = this; messageHandler.on("GetReader", (data, sink) => { assert( @@ -2936,7 +2945,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({ @@ -2947,11 +2960,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; } @@ -2964,6 +2999,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); }); @@ -3440,7 +3476,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 06abbab3357688e7453ff1ff2aec30ec5380aa46 Mon Sep 17 00:00:00 2001 From: Aditi Date: Mon, 13 Apr 2026 19:07:14 +0530 Subject: [PATCH 05/13] 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 f6820537b17ad..bb5b51f1ecf00 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -26,6 +26,7 @@ import { info, isNodeJS, makeObj, + OPS, RenderingIntentFlag, setVerbosityLevel, shadow, @@ -1324,6 +1325,8 @@ class PDFDocumentProxy { * Proxy to a `PDFPage` in the worker thread. */ class PDFPageProxy { + #keepRendererCanvas = false; + #pendingCleanup = false; #pagesMapper = null; @@ -1512,6 +1515,7 @@ class PDFPageProxy { cacheKey, makeObj ); + // Ensure that a pending `streamReader` cancel timeout is always aborted. if (intentState.streamReaderCancelTimeout) { clearTimeout(intentState.streamReaderCancelTimeout); @@ -1635,6 +1639,10 @@ class PDFPageProxy { pageColors, enableHWA: this._transport.enableHWA, operationsFilter, + rendererHandler: + shouldRecordOperations || shouldRecordImages + ? null + : this._transport.rendererHandler, }); (intentState.renderTasks ||= new Set()).add(internalRenderTask); @@ -1821,6 +1829,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); @@ -1831,10 +1848,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) { @@ -1858,6 +1878,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; } @@ -1920,6 +1951,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", { @@ -3302,7 +3346,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( @@ -3314,6 +3360,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(); @@ -3408,6 +3457,13 @@ class RenderTask { get imageCoordinates() { return this._internalRenderTask.imageCoordinates || null; } + + /** + * @type {MessageHandler | null} + */ + get rendererHandler() { + return this._internalRenderTask.rendererHandler; + } } /** @@ -3419,6 +3475,8 @@ class InternalRenderTask { static #canvasInUse = new WeakSet(); + static #renderTaskId = 0; + constructor({ callback, params, @@ -3434,6 +3492,7 @@ class InternalRenderTask { pageColors = null, enableHWA = false, operationsFilter = null, + rendererHandler = null, }) { this.callback = callback; this.params = params; @@ -3467,6 +3526,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() { @@ -3476,6 +3539,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, @@ -3508,33 +3617,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?.(); @@ -3543,6 +3717,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); @@ -3564,11 +3747,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; } @@ -3602,6 +3788,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 430cb697ef757..fecf40768ee76 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. @@ -697,4 +699,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 b2f577857c8e8298d9f9e57eaf65883fbff50dbd Mon Sep 17 00:00:00 2001 From: Aditi Date: Mon, 13 Apr 2026 22:40:46 +0530 Subject: [PATCH 06/13] 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 473f1ef810cec..ffe6929ab3466 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() { @@ -1204,8 +1209,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, @@ -1227,6 +1247,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") { @@ -1267,6 +1295,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 a0d1842e3d0d4..b9b7f6007034a 100644 --- a/test/unit/jasmine-boot.js +++ b/test/unit/jasmine-boot.js @@ -122,6 +122,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 74d62292725a5..cc6686593287f 100644 --- a/web/pdf_thumbnail_view.js +++ b/web/pdf_thumbnail_view.js @@ -329,12 +329,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) { @@ -371,7 +373,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, }); @@ -388,7 +391,7 @@ class PDFThumbnailView extends RenderableView { }; const renderContext = { - canvas, + canvasContext, transform, viewport: drawViewport, optionalContentConfigPromise: this._optionalContentConfigPromise, From 91e835a7509ef1fa5815df4b4c3b95683f5a9ff5 Mon Sep 17 00:00:00 2001 From: Aditi Date: Mon, 13 Apr 2026 22:56:16 +0530 Subject: [PATCH 07/13] 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 bb5b51f1ecf00..425e9917f8a01 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, @@ -1638,6 +1639,7 @@ class PDFPageProxy { pdfBug: this._pdfBug, pageColors, enableHWA: this._transport.enableHWA, + enableWebGPU: this._transport.enableWebGPU, operationsFilter, rendererHandler: shouldRecordOperations || shouldRecordImages @@ -2642,6 +2644,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; @@ -3491,6 +3494,7 @@ class InternalRenderTask { pdfBug = false, pageColors = null, enableHWA = false, + enableWebGPU = false, operationsFilter = null, rendererHandler = null, }) { @@ -3523,6 +3527,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; @@ -3649,6 +3654,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 7ba3f6b716782c40a8508459cc0fc1923a8085f7 Mon Sep 17 00:00:00 2001 From: Aditi Date: Tue, 5 May 2026 03:05:24 +0530 Subject: [PATCH 08/13] 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 425e9917f8a01..dd7dd567f82d1 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -1654,7 +1654,7 @@ class PDFPageProxy { intentState.displayReadyCapability.promise, optionalContentConfigPromise, ]) - .then(([renderPageData, optionalContentConfig]) => { + .then(async ([renderPageData, optionalContentConfig]) => { if (this.destroyed) { complete(); return; @@ -1671,7 +1671,7 @@ class PDFPageProxy { renderPageData && typeof renderPageData === "object" ? renderPageData : { transparency: renderPageData }; - internalRenderTask.initializeGraphics({ + await internalRenderTask.initializeGraphics({ transparency, hasCanvasFilters: hasCanvasFilters || intentState.hasCanvasFilters, optionalContentConfig, @@ -3590,7 +3590,7 @@ class InternalRenderTask { return { annotationCanvases, transfers }; } - initializeGraphics({ + async initializeGraphics({ transparency = false, hasCanvasFilters = false, optionalContentConfig, @@ -3640,6 +3640,7 @@ class InternalRenderTask { if (!useWorkerRendering) { this._rendererHandler = null; } + let initPromise = null; if (useWorkerRendering) { try { const offscreen = this._canvas.transferControlToOffscreen(); @@ -3664,7 +3665,7 @@ class InternalRenderTask { transparency, background, }; - this._rendererHandler.send( + initPromise = this._rendererHandler.sendWithPromise( "InitializeGraphics", initParams, initTransfers @@ -3681,7 +3682,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; } @@ -3715,6 +3718,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 ad45c15814c7812d5bccc6fa46b2ef7fc3cade66 Mon Sep 17 00:00:00 2001 From: Aditi Date: Sat, 16 May 2026 05:39:17 +0530 Subject: [PATCH 09/13] 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 dd7dd567f82d1..9222e8a4ed6e9 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, @@ -1553,14 +1554,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; } @@ -1568,7 +1580,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 @@ -1599,34 +1611,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, @@ -1641,10 +1637,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); @@ -3528,13 +3521,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() { @@ -3614,30 +3613,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; @@ -3664,6 +3658,8 @@ class InternalRenderTask { viewport, transparency, background, + recordOperations: this._recordOperations, + recordImages: this._recordImages, }; initPromise = this._rendererHandler.sendWithPromise( "InitializeGraphics", @@ -3699,6 +3695,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, @@ -3708,7 +3723,7 @@ class InternalRenderTask { { optionalContentConfig }, this.annotationCanvasMap, this.pageColors, - dependencyTracker, + dependencyTracker ?? bboxTracker, imagesTracker ); this.gfx.beginDrawing({ @@ -3763,8 +3778,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 @@ -3834,7 +3851,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, @@ -3848,6 +3865,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 e688602a95803bf4725c40849547eee68064ef04 Mon Sep 17 00:00:00 2001 From: Aditi Date: Tue, 2 Jun 2026 10:26:53 +0530 Subject: [PATCH 10/13] 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 9222e8a4ed6e9..959732f064330 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -3015,8 +3015,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, + }); } }; @@ -3895,6 +3903,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 559a37faeab8d3225ad231babf11598096b54d6c Mon Sep 17 00:00:00 2001 From: Aditi Date: Sat, 13 Jun 2026 04:27:46 +0530 Subject: [PATCH 11/13] 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 959732f064330..2f014cd938773 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 @@ -1557,7 +1557,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 @@ -1637,7 +1637,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); @@ -1824,15 +1824,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); @@ -1873,16 +1867,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; @@ -1949,15 +1937,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", @@ -2638,7 +2620,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; @@ -2704,6 +2686,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, @@ -2817,6 +2808,7 @@ class WorkerTransport { this.messageHandler?.destroy(); this.messageHandler = null; + this.rendererWorker = null; this.destroyCapability.resolve(); }, this.destroyCapability.reject); @@ -2824,7 +2816,7 @@ class WorkerTransport { } setupMessageHandler() { - const { messageHandler, loadingTask, rendererHandler } = this; + const { messageHandler, loadingTask } = this; messageHandler.on("GetReader", (data, sink) => { assert( @@ -3002,7 +2994,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; } @@ -3010,15 +3002,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], @@ -3497,7 +3487,7 @@ class InternalRenderTask { enableHWA = false, enableWebGPU = false, operationsFilter = null, - rendererHandler = null, + rendererWorker = null, }) { this.callback = callback; this.params = params; @@ -3533,7 +3523,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(); @@ -3552,7 +3542,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 @@ -3630,17 +3620,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) { @@ -3669,17 +3661,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( @@ -3689,7 +3682,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; } } @@ -3756,15 +3749,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); @@ -3790,7 +3777,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 ); @@ -3830,7 +3817,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, @@ -3842,7 +3834,7 @@ class InternalRenderTask { operatorListArgsArrayLen ); if (annotationCanvases.length > 0) { - this._rendererHandler.send( + rendererHandler.send( "UpdateAnnotationCanvases", { renderTaskId: this._renderTaskId, @@ -3859,7 +3851,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 8436cb585597e45b415f48e0f69e6833f3b5a4f7 Mon Sep 17 00:00:00 2001 From: Aditi Date: Tue, 30 Jun 2026 04:27:06 +0530 Subject: [PATCH 12/13] 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 2f014cd938773..f5580b15c0589 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -3526,7 +3526,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; @@ -3562,24 +3564,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 959954944d52c57aa1748e4627609a72f7892657 Mon Sep 17 00:00:00 2001 From: Aditi Date: Tue, 30 Jun 2026 05:10:25 +0530 Subject: [PATCH 13/13] 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 f5580b15c0589..fd4bb42c45f0f 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -2090,11 +2090,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; @@ -2134,8 +2134,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); @@ -3526,7 +3538,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 @@ -3845,7 +3857,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(