From 869490f40c9b654a7f469f04f86e5a26e5b06979 Mon Sep 17 00:00:00 2001 From: Aditi Date: Sun, 12 Apr 2026 20:21:07 +0530 Subject: [PATCH 01/14] 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. This commit is a part of the renderer-worker series, the worker rendering stays disabled until the final commit in this series. --- src/display/api.js | 109 +++------------------------ src/display/object_handler.js | 138 ++++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 97 deletions(-) create mode 100644 src/display/object_handler.js diff --git a/src/display/api.js b/src/display/api.js index 908b2af6b1892..5625ec6c0c053 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -41,12 +41,6 @@ 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 { getDataProp, getFactoryUrlProp, @@ -70,11 +64,13 @@ 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"; import { MathClamp } from "../shared/math_clamp.js"; import { Metadata } from "./metadata.js"; +import { ObjectHandler } from "./object_handler.js"; import { OptionalContentConfig } from "./optional_content_config.js"; import { PagesMapper } from "./pages_mapper.js"; import { PageViewport } from "./page_viewport.js"; @@ -2776,6 +2772,14 @@ class WorkerTransport { page._startRenderPage(data.transparency, data.cacheKey); }); + const objectHandler = new ObjectHandler({ + messageHandler, + commonObjs: this.commonObjs, + fontLoader: this.fontLoader, + pageCache: this.#pageCache, + pdfBug: this._params.pdfBug, + }); + messageHandler.on("commonobj", ([id, type, exportedData]) => { if (this.destroyed) { return null; // Ignore any pending requests if the worker was terminated. @@ -2785,78 +2789,7 @@ class WorkerTransport { return null; } - switch (type) { - case "Font": - if ("error" in exportedData) { - const exportedError = exportedData.error; - warn(`Error during font loading: ${exportedError}`); - this.commonObjs.resolve(id, exportedError); - break; - } - - const fontData = new FontInfo(exportedData); - const inspectFont = - this._params.pdfBug && globalThis.FontInspector?.enabled - ? (font, url) => globalThis.FontInspector.fontAdded(font, url) - : null; - const font = new FontFaceObject( - fontData, - inspectFont, - exportedData.charProcOperatorList, - exportedData.extra - ); - - this.fontLoader - .bind(font) - .catch(() => messageHandler.sendWithPromise("FontFallback", { id })) - .finally(() => { - if (!font.fontExtraProperties) { - // Immediately release the `font.data` property once the font - // has been attached to the DOM, since it's no longer needed, - // rather than waiting for a `PDFDocumentProxy.cleanup` call. - // Since `font.data` could be very large, e.g. in some cases - // multiple megabytes, this will help reduce memory usage. - font.clearData(); - } - this.commonObjs.resolve(id, font); - }); - break; - case "CopyLocalImage": - const { imageRef } = exportedData; - assert(imageRef, "The imageRef must be defined."); - - for (const pageProxy of this.#pageCache.values()) { - for (const [, data] of pageProxy.objs) { - if (data?.ref !== imageRef) { - continue; - } - if (!data.dataLen) { - return null; - } - const copy = structuredClone(data); - if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) { - copy.CopyLocalImage = true; - } - this.commonObjs.resolve(id, copy); - return data.dataLen; - } - } - break; - case "FontPath": - this.commonObjs.resolve(id, new FontPathInfo(exportedData)); - break; - case "Image": - this.commonObjs.resolve(id, exportedData); - break; - case "Pattern": - const pattern = new PatternInfo(exportedData); - this.commonObjs.resolve(id, pattern.getIR()); - break; - default: - throw new Error(`Got unknown common object type ${type}`); - } - - return null; + return objectHandler.resolveCommonObject(id, type, exportedData); }); messageHandler.on("obj", ([id, pageIndex, type, imageData]) => { @@ -2864,25 +2797,7 @@ class WorkerTransport { // Ignore any pending requests if the worker was terminated. return; } - - const pageProxy = this.#pageCache.get(pageIndex); - if (pageProxy.objs.has(id)) { - return; - } - // Don't store data *after* cleanup has successfully run, see bug 1854145. - if (pageProxy._intentStates.size === 0) { - imageData?.bitmap?.close(); // Release any `ImageBitmap` data. - return; - } - - switch (type) { - case "Image": - case "Pattern": - pageProxy.objs.resolve(id, imageData); - break; - default: - throw new Error(`Got unknown object type ${type}`); - } + objectHandler.resolveObject(id, pageIndex, type, imageData); }); messageHandler.on("DocProgress", data => { diff --git a/src/display/object_handler.js b/src/display/object_handler.js new file mode 100644 index 0000000000000..6414cecab91b6 --- /dev/null +++ b/src/display/object_handler.js @@ -0,0 +1,138 @@ +/* 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"; + +class ObjectHandler { + constructor({ + messageHandler, + commonObjs, + fontLoader, + pageCache, + pdfBug = null, + }) { + this.messageHandler = messageHandler; + this.commonObjs = commonObjs; + this.fontLoader = fontLoader; + this.pageCache = pageCache; + this.pdfBug = pdfBug; + } + + 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 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; + } + + resolveObject(id, pageIndex, type, exportedData) { + 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) { + exportedData?.bitmap?.close(); // Release any `ImageBitmap` data. + return; + } + + switch (type) { + case "Image": + case "Pattern": + pageProxy.objs.resolve(id, exportedData); + break; + default: + throw new Error(`Got unknown object type ${type}`); + } + } +} + +export { ObjectHandler }; From fc719f715e6d661c0e6d118ccb14961b0ed83e0e Mon Sep 17 00:00:00 2001 From: Aditi Date: Sun, 2 Aug 2026 00:37:24 +0530 Subject: [PATCH 02/14] [api-minor] 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. The `disableWorkerRendering` option defaults to disabled and is flipped in the final commit of this series. This commit is a part of the renderer-worker series, the worker rendering stays disabled until the final commit in this series. --- external/dist/webpack.mjs | 4 + gulpfile.mjs | 54 ++++++-- src/display/api.js | 224 +++++++++++++++++++++++++++++++-- src/display/renderer_worker.js | 58 +++++++++ src/display/worker_options.js | 20 +++ src/pdf.renderer.js | 18 +++ web/app_options.js | 22 ++++ 7 files changed, 382 insertions(+), 18 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 8712d1016960f..35a1c671c8743 100644 --- a/gulpfile.mjs +++ b/gulpfile.mjs @@ -555,6 +555,24 @@ function createWorkerBundle(defines) { .pipe(webpack2Stream(workerFileConfig)); } +function createRendererWorkerBundle(defines) { + const rendererWorkerDefines = { + ...defines, + WORKER_THREAD: true, + }; + const rendererWorkerFileConfig = createWebpackConfig(rendererWorkerDefines, { + filename: rendererWorkerDefines.MINIFIED + ? "pdf.renderer.min.mjs" + : "pdf.renderer.mjs", + library: { + type: "module", + }, + }); + return gulp + .src("./src/pdf.renderer.js", { encoding: false }) + .pipe(webpack2Stream(rendererWorkerFileConfig)); +} + function createWebBundle(defines, options) { const viewerFileConfig = createWebpackConfig(defines, { filename: "viewer.mjs", @@ -1356,6 +1374,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 @@ -1500,6 +1519,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") @@ -1625,6 +1645,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") ), @@ -1730,6 +1753,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") ), @@ -1923,7 +1949,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 } ), @@ -2822,6 +2848,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") @@ -3040,8 +3067,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 } ) @@ -3049,16 +3078,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", { @@ -3067,7 +3102,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 5625ec6c0c053..cd866181fac63 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -21,6 +21,7 @@ import { AbortException, AnnotationMode, assert, + FeatureTest, getVerbosityLevel, info, isNodeJS, @@ -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,10 @@ 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 `true` for now, since worker + * rendering stays disabled throughout this series; it becomes `false` in the + * final commit, which enables it. */ /** @@ -304,6 +310,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 +335,14 @@ 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 = + // TODO: Default to enabled once worker rendering is complete; flipped in + // the last commit of this series. + src.disableWorkerRendering !== false || + typeof Worker === "undefined" || + !FeatureTest.isOffscreenCanvasSupported || + ownerDocument !== globalThis.document || + !!styleElement; // Set the main-thread verbosity level. setVerbosityLevel(verbosity); @@ -354,6 +368,9 @@ function getDocument(src = {}) { }); task._worker = worker; } + if (!disableWorkerRendering) { + task._rendererWorker = new RendererWorker({ verbosity }); + } const docParams = { docId, @@ -397,7 +414,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 +478,10 @@ function getDocument(src = {}) { messageHandler, task, networkStream, - transportParams, + { + ...transportParams, + rendererWorker: task._rendererWorker, + }, transportFactory, pagesMapper ); @@ -510,6 +541,11 @@ class PDFDocumentLoadingTask { */ _worker = null; + /** + * @private + */ + _rendererWorker = null; + /** * Unique identifier for the document loading task. * @type {string} @@ -583,6 +619,9 @@ class PDFDocumentLoadingTask { this._worker?.destroy(); this._worker = null; + + this._rendererWorker?.destroy(); + this._rendererWorker = null; } /** @@ -2033,6 +2072,171 @@ 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 { + #capability = Promise.withResolvers(); + + #messageHandler = null; + + #webWorker = null; + + 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 messageHandler() { + return this.#messageHandler; + } + + #resolve() { + this.#capability.resolve(); + // Send global setting, e.g. verbosity level. + this.#messageHandler.send("configure", { + verbosity: this.verbosity, + }); + } + + #initialize() { + if (typeof Worker === "undefined") { + this.#capability.reject( + new Error("Renderer worker requires Worker support.") + ); + return; + } + try { + let { 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 messageHandler = new MessageHandler("main", "renderer", worker); + const terminateEarly = reason => { + ac.abort(); + messageHandler.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.#webWorker) { + // Worker failed to initialize due to an error. + terminateEarly(event.error || event.message); + } + }, + { signal: ac.signal } + ); + + messageHandler.on("test", data => { + ac.abort(); + if (this.destroyed || !data) { + terminateEarly("TypedArray transfer test failed."); + return; + } + this.#messageHandler = messageHandler; + this.#webWorker = worker; + + this.#resolve(); + }); + + messageHandler.on("ready", data => { + ac.abort(); + if (this.destroyed) { + terminateEarly("Worker was destroyed."); + return; + } + try { + sendTest(); + } catch (reason) { + terminateEarly(reason); + } + }); + + const sendTest = () => { + const testObj = new Uint8Array(); + // Ensure that we can use `postMessage` transfers. + messageHandler.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.#webWorker?.terminate(); + this.#webWorker = null; + + this.#messageHandler?.destroy(); + this.#messageHandler = null; + } + + /** + * The current `rendererSrc`, when it exists. + * @type {string} + */ + static get rendererSrc() { + if (GlobalWorkerOptions.rendererSrc) { + return GlobalWorkerOptions.rendererSrc; + } + throw new Error('No "GlobalWorkerOptions.rendererSrc" specified.'); + } +} + /** * @typedef {Object} PDFWorkerParameters * @property {string} [name] - The name of the worker. @@ -2423,6 +2627,7 @@ class WorkerTransport { styleElement: params.styleElement, }); this.enableHWA = params.enableHWA; + this.rendererWorker = params.rendererWorker || null; this.loadingParams = params.loadingParams; this._params = params; @@ -2601,6 +2806,7 @@ class WorkerTransport { this.messageHandler?.destroy(); this.messageHandler = null; + this.rendererWorker = null; this.destroyCapability.resolve(); }, this.destroyCapability.reject); diff --git a/src/display/renderer_worker.js b/src/display/renderer_worker.js new file mode 100644 index 0000000000000..de28554b6db8e --- /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 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); + }); + } + + static initializeFromPort(port) { + const handler = new MessageHandler("renderer", "main", port); + this.setup(handler); + handler.send("ready", null); + } +} + +export { RendererMessageHandler }; diff --git a/src/display/worker_options.js b/src/display/worker_options.js index e4bbb81a6ea45..3a9066d2f6e2e 100644 --- a/src/display/worker_options.js +++ b/src/display/worker_options.js @@ -16,8 +16,28 @@ class GlobalWorkerOptions { static #port = null; + static #rendererSrc = ""; + static #src = ""; + /** + * @type {string} + */ + static get rendererSrc() { + return this.#rendererSrc; + } + + /** + * @param {string} rendererSrc - A string containing the path and + * filename of the renderer worker file. + */ + static set rendererSrc(val) { + if (typeof val !== "string") { + throw new Error("Invalid `rendererSrc` type."); + } + this.#rendererSrc = val; + } + /** * @type {Worker | null} */ 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 888eea22b6eac..e66b4e111e0c4 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -778,6 +778,14 @@ const defaultOptions = new Map([ kind: OptionKind.API + OptionKind.PREFERENCE, }, ], + [ + "disableWorkerRendering", + { + /** @type {boolean} */ + value: true, + kind: OptionKind.API + OptionKind.PREFERENCE, + }, + ], [ "docBaseUrl", { @@ -915,6 +923,20 @@ const defaultOptions = new Map([ // End OptionKind.API // Begin 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, + }, + ], [ "workerPort", { From f38b42445aa86410049c7695b7a25d73c841b37c Mon Sep 17 00:00:00 2001 From: Aditi Date: Sun, 2 Aug 2026 00:37:52 +0530 Subject: [PATCH 03/14] Detect TR-based canvas filters and report them to the display layer Add a hasCanvasFilters method to PartialEvaluator that walks the page-level ExtGState dictionaries, and those of Form XObject and tiling pattern resources, to detect transfer functions (TR/TR2) 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 flag rides along on the existing StartRenderPage message down to initializeGraphics; the rendering decision that consumes it is added later in this series. SMask rendering already has a pixel-buffer fallback in canvas.js, and TR inside 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. This commit is a part of the renderer-worker series, the worker rendering stays disabled until the final commit in this series. --- src/core/document.js | 1 + src/core/evaluator.js | 130 ++++++++++++++++++++++++++++++++++++++++++ src/display/api.js | 27 +++++++-- 3 files changed, 153 insertions(+), 5 deletions(-) diff --git a/src/core/document.js b/src/core/document.js index de8a5ffc71695..453cba3f09e54 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -565,6 +565,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 9cba883b57c27..37b0713e6a3cd 100644 --- a/src/core/evaluator.js +++ b/src/core/evaluator.js @@ -388,6 +388,136 @@ class PartialEvaluator { return false; } + _hasTransferMaps(transferObj) { + let transferArray; + if (Array.isArray(transferObj)) { + transferArray = transferObj; + if ( + transferObj.length > 1 && + transferObj.every(map => map === transferObj[0]) + ) { + // All entries in the array are the same, so we can just use one of + // them; this mirrors `handleTransferFunction`. + transferArray = [transferObj[0]]; + } + } 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 { + const transferObj = graphicState.has("TR2") + ? graphicState.get("TR2") + : graphicState.get("TR"); + if (this._hasTransferMaps(transferObj)) { + return true; + } + } catch (ex) { + info(`hasCanvasFilters - failed to inspect filter data: "${ex}".`); + return true; + } + } + } + + for (const resourceType of ["XObject", "Pattern"]) { + const resourceEntries = node.get(resourceType); + if (resourceEntries instanceof Dict) { + for (let entry of resourceEntries.getRawValues()) { + if (entry instanceof Ref) { + if (processed.has(entry)) { + continue; + } + try { + entry = xref.fetch(entry); + } catch (ex) { + info( + `hasCanvasFilters - failed to fetch ${resourceType}: "${ex}".` + ); + return true; + } + } + if (!(entry instanceof BaseStream)) { + continue; + } + if (entry.dict.objId) { + processed.put(entry.dict.objId); + } + const nestedResources = entry.dict.get("Resources"); + if (!(nestedResources instanceof Dict)) { + continue; + } + if (nestedResources.objId && processed.has(nestedResources.objId)) { + continue; + } + + nodes.push(nestedResources); + if (nestedResources.objId) { + processed.put(nestedResources.objId); + } + } + } + } + } + return false; + } + async fetchBuiltInCMap(name) { const cachedData = this.builtInCMapCache.get(name); if (cachedData) { diff --git a/src/display/api.js b/src/display/api.js index cd866181fac63..1ffebc5f6ba32 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -1671,7 +1671,7 @@ class PDFPageProxy { intentState.displayReadyCapability.promise, optionalContentConfigPromise, ]) - .then(([transparency, optionalContentConfig]) => { + .then(([renderPageData, optionalContentConfig]) => { if (this.destroyed) { complete(); return; @@ -1684,8 +1684,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(); @@ -1887,16 +1892,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, + }); } /** @@ -2975,7 +2984,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({ @@ -3488,7 +3501,11 @@ class InternalRenderTask { }); } - initializeGraphics({ transparency = false, optionalContentConfig }) { + initializeGraphics({ + transparency = false, + hasCanvasFilters = false, + optionalContentConfig, + }) { if (this.cancelled) { return; } From 138f15791d02e3cc44e9026e1096ebc61c5838ff Mon Sep 17 00:00:00 2001 From: Aditi Date: Sun, 2 Aug 2026 06:53:47 +0530 Subject: [PATCH 04/14] Allow ObjectHandler to operate on a bare PDFObjects page cache The renderer worker has no PDFPageProxy objects, only per-page PDFObjects instances, so accept a page cache holding either and create missing entries on demand behind `shouldCreatePageObjs`. This commit is a part of the renderer-worker series, the worker rendering stays disabled until the final commit in this series. --- src/display/object_handler.js | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/src/display/object_handler.js b/src/display/object_handler.js index 6414cecab91b6..f48bf0add1405 100644 --- a/src/display/object_handler.js +++ b/src/display/object_handler.js @@ -21,6 +21,7 @@ import { } from "./obj_bin_transform_display.js"; import { FontFaceObject } from "./font_loader.js"; +import { PDFObjects } from "./pdf_objects.js"; class ObjectHandler { constructor({ @@ -29,12 +30,14 @@ class ObjectHandler { 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) { @@ -80,8 +83,10 @@ class ObjectHandler { const { imageRef } = exportedData; assert(imageRef, "The imageRef must be defined."); - for (const pageProxy of this.pageCache.values()) { - for (const [, data] of pageProxy.objs) { + for (const pageOrObjs of this.pageCache.values()) { + const objs = pageOrObjs.objs || pageOrObjs; + + for (const [, data] of objs) { if (data?.ref !== imageRef) { continue; } @@ -114,12 +119,21 @@ class ObjectHandler { } resolveObject(id, pageIndex, type, exportedData) { - const pageProxy = this.pageCache.get(pageIndex); - if (pageProxy.objs.has(id)) { + 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 (pageProxy._intentStates.size === 0) { + if (pageOrObjs._intentStates?.size === 0) { exportedData?.bitmap?.close(); // Release any `ImageBitmap` data. return; } @@ -127,7 +141,7 @@ class ObjectHandler { switch (type) { case "Image": case "Pattern": - pageProxy.objs.resolve(id, exportedData); + objs.resolve(id, exportedData); break; default: throw new Error(`Got unknown object type ${type}`); From 1aa13f7a61ed82a373368f7dfc2e91c3c44e2b53 Mon Sep 17 00:00:00 2001 From: Aditi Date: Sun, 2 Aug 2026 00:39:42 +0530 Subject: [PATCH 05/14] Add object forwarding between main thread and renderer worker Forward the commonobj/obj messages that WorkerTransport receives on to the renderer worker, so that it builds up the same commonObjs and per-page objs as the main thread. CopyLocalImage is handled separately, since the core worker sends only an image reference: the renderer worker is asked to resolve it from its own objects first, and only when it can't does the main thread send the image data it just found. A forwarded object that cannot be delivered is reported back as `objFailed`, so the renderer worker rejects it rather than waiting forever on a dependency that will never arrive. The renderer worker's object stores are released by the cleanupPage and Cleanup handlers added later in this series. This commit is a part of the renderer-worker series, the worker rendering stays disabled until the final commit in this series. --- src/display/api.js | 63 ++++++++++++++++++++++++++++++++++ src/display/object_handler.js | 6 +++- src/display/pdf_objects.js | 17 +++++++++ src/display/renderer_worker.js | 52 ++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 1 deletion(-) diff --git a/src/display/api.js b/src/display/api.js index 1ffebc5f6ba32..cd079c560798a 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -2702,6 +2702,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?.messageHandler ?? null; + } + getRenderingIntent( intent, annotationMode = AnnotationMode.ENABLE, @@ -2999,11 +3008,64 @@ 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. + this.rendererHandler?.on("FontFallback", data => { + if (this.destroyed) { + return null; + } + return messageHandler.sendWithPromise("FontFallback", data); + }); + + const forwardToRenderer = (action, data) => { + const { rendererHandler } = this; + if (!rendererHandler) { + return; + } + try { + rendererHandler.send(action, data); + } catch (reason) { + warn(`forwardToRenderer("${action}") failed: ${reason}`); + rendererHandler.send("objFailed", { + id: data[0], + pageIndex: action === "obj" ? data[1] : null, + reason: reason.message, + }); + } + }; + messageHandler.on("commonobj", ([id, type, exportedData]) => { if (this.destroyed) { return null; // Ignore any pending requests if the worker was terminated. } + if (type === "CopyLocalImage") { + const dataLen = this.commonObjs.has(id) + ? null + : objectHandler.resolveCommonObject(id, type, exportedData); + const { rendererHandler } = this; + if (!dataLen || !rendererHandler) { + return dataLen; + } + // If the core worker doesn't re-send the image data, ensure that + // the renderer worker has a copy too + return rendererHandler + .sendWithPromise("commonobj", [id, type, exportedData]) + .catch(() => null) + .then(rendererDataLen => { + if (!rendererDataLen) { + forwardToRenderer("commonobj", [ + id, + "Image", + this.commonObjs.get(id), + ]); + } + return dataLen; + }); + } + + forwardToRenderer("commonobj", [id, type, exportedData]); + if (this.commonObjs.has(id)) { return null; } @@ -3016,6 +3078,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); }); 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/pdf_objects.js b/src/display/pdf_objects.js index 7c54b01e41658..5fb3ecd9ec01f 100644 --- a/src/display/pdf_objects.js +++ b/src/display/pdf_objects.js @@ -97,6 +97,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 de28554b6db8e..ebd33a8cf4200 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 #fontLoader = new FontLoader({ + ownerDocument: globalThis, + }); + + static #objsMap = new Map(); + static { // Worker thread (and not Node.js)? if ( @@ -31,6 +42,45 @@ class RendererMessageHandler { } } + static #getPageObjs(pageIndex) { + let objs = this.#objsMap.get(pageIndex); + if (!objs) { + objs = new PDFObjects(); + this.#objsMap.set(pageIndex, objs); + } + return objs; + } + + 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); + }); + + handler.on("objFailed", ({ id, pageIndex, reason }) => { + const error = new Error(reason); + if (pageIndex === null) { + this.#commonObjs.reject(id, error); + return; + } + this.#getPageObjs(pageIndex).reject(id, error); + }); + } + static setup(handler) { let testMessageProcessed = false; handler.on("test", data => { @@ -46,6 +96,8 @@ class RendererMessageHandler { handler.on("configure", data => { setVerbosityLevel(data.verbosity); }); + + this.#setupObjectHandler(handler); } static initializeFromPort(port) { From bc5c0bc993985de4b824b78f116e0561b1a3c69b Mon Sep 17 00:00:00 2001 From: Aditi Date: Sun, 2 Aug 2026 00:40:35 +0530 Subject: [PATCH 06/14] Cache Path2D objects on the operator list Since operator lists will be posted across threads, Path2D objects can no longer be materialized into argsArray; they are cached in a pathCache map on the operator list instead, keeping it structured-cloneable. This commit is a part of the renderer-worker series, the worker rendering stays disabled until the final commit in this series. --- src/display/canvas.js | 21 +++++++++++++++------ src/display/pattern_helper.js | 4 ++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/display/canvas.js b/src/display/canvas.js index d9ab2025eb6e6..63775a88b450e 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; } diff --git a/src/display/pattern_helper.js b/src/display/pattern_helper.js index 0bfca2f5fee6b..241bf9eb2d1d0 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(); } From ff30337cb309084484ac920e9ec55ab3af095478 Mon Sep 17 00:00:00 2001 From: Aditi Date: Sun, 2 Aug 2026 00:41:11 +0530 Subject: [PATCH 07/14] Reuse existing annotation canvases in beginAnnotation Look up the annotation canvas in `annotationCanvasMap` before creating a new one, and resize it in place when it is already there. This lets a canvas that was created (and possibly transferred) elsewhere be drawn into, rather than being replaced by a fresh one. Track the canvas name on the object as well as in the DOM attribute, so that the same matching works for canvases without a DOM node. This commit is a part of the renderer-worker series, the worker rendering stays disabled until the final commit in this series. --- src/display/canvas.js | 57 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/src/display/canvas.js b/src/display/canvas.js index 63775a88b450e..29e3c5060febc 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"; @@ -3727,29 +3736,55 @@ class CanvasGraphics { height * this.outputScaleY * viewportScale ); - this.annotationCanvas = this.canvasFactory.create( - canvasWidth, - canvasHeight - ); - const { canvas, context } = this.annotationCanvas; + let canvas, context; if (canvasName) { 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 { - 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; From b600d5bc0b6d77959482da4d4ff3022bbb2995f6 Mon Sep 17 00:00:00 2001 From: Aditi Date: Sun, 2 Aug 2026 00:42:22 +0530 Subject: [PATCH 08/14] Execute operator lists in the renderer worker Add the handlers that let the renderer worker initialize graphics and execute an operator list against a transferred OffscreenCanvas: InitializeGraphics, ExecuteOperatorList, UpdateAnnotationCanvases, CleanupRenderTask, ReleaseCanvas, cleanupPage, restorePage and Cleanup, along with the per-render-task state they operate on. Also adds the OffscreenCanvas and worker-side filter factories, and lets CanvasGraphics.executeOperatorList report a failed object dependency through an errorCallback, so a rejected object aborts the render instead of hanging it. Nothing sends these messages yet: src/pdf.renderer.js is the only importer of this file, so no main-thread code path reaches it. This commit is a part of the renderer-worker series, the worker rendering stays disabled until the final commit in this series. --- src/display/api.js | 2 + src/display/canvas.js | 3 +- src/display/canvas_dependency_tracker.js | 12 + src/display/canvas_factory.js | 11 +- src/display/filter_factory.js | 4 +- src/display/pdf_objects.js | 7 +- src/display/renderer_worker.js | 314 +++++++++++++++++++++++ 7 files changed, 348 insertions(+), 5 deletions(-) diff --git a/src/display/api.js b/src/display/api.js index cd079c560798a..cdb3645038bf7 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -3694,6 +3694,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 29e3c5060febc..6a7feac73a01a 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -672,6 +672,7 @@ class CanvasGraphics { operatorList, executionStartIdx, continueCallback, + errorCallback, stepper, operationsFilter ) { @@ -732,7 +733,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/canvas_dependency_tracker.js b/src/display/canvas_dependency_tracker.js index 0df2528c5d8a1..7f8acf898b0f6 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; } @@ -1260,6 +1271,7 @@ class CanvasImagesTracker { } export { + BBoxReader, CanvasBBoxTracker, CanvasDependencyTracker, CanvasImagesTracker, diff --git a/src/display/canvas_factory.js b/src/display/canvas_factory.js index 16c0e55fabf94..930a8835f9cd3 100644 --- a/src/display/canvas_factory.js +++ b/src/display/canvas_factory.js @@ -89,4 +89,13 @@ class DOMCanvasFactory extends BaseCanvasFactory { } } -export { BaseCanvasFactory, DOMCanvasFactory }; +class OffscreenCanvasFactory extends BaseCanvasFactory { + /** + * @ignore + */ + _createCanvas(width, height) { + return new OffscreenCanvas(width, height); + } +} + +export { BaseCanvasFactory, DOMCanvasFactory, OffscreenCanvasFactory }; diff --git a/src/display/filter_factory.js b/src/display/filter_factory.js index 0819f811740ed..d1081b2d1a25e 100644 --- a/src/display/filter_factory.js +++ b/src/display/filter_factory.js @@ -90,6 +90,8 @@ class BaseFilterFactory { destroy(keepHCM = false) {} } +class WorkerFilterFactory extends BaseFilterFactory {} + /** * FilterFactory aims to create some SVG filters we can use when drawing an * image (or whatever) on a canvas. @@ -714,4 +716,4 @@ function blend(fg, bg, alpha) { return Math.round(alpha * fg + (1 - alpha) * bg); } -export { BaseFilterFactory, DOMFilterFactory }; +export { BaseFilterFactory, DOMFilterFactory, WorkerFilterFactory }; diff --git a/src/display/pdf_objects.js b/src/display/pdf_objects.js index 5fb3ecd9ec01f..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 diff --git a/src/display/renderer_worker.js b/src/display/renderer_worker.js index ebd33a8cf4200..6d19254730320 100644 --- a/src/display/renderer_worker.js +++ b/src/display/renderer_worker.js @@ -13,13 +13,29 @@ * 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"; 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 { + // Holds the `OffscreenCanvas` for each transferred placeholder ``, + // keyed by main-thread canvas id, so that re-renders can reuse it and the + // placeholder keeps its bitmap across `cleanupPage`. + static #offscreenCanvases = new Map(); + + static #cleanedPages = new Set(); + static #commonObjs = new PDFObjects(); static #fontLoader = new FontLoader({ @@ -28,6 +44,8 @@ class RendererMessageHandler { static #objsMap = new Map(); + static #renderTaskStates = new Map(); + static { // Worker thread (and not Node.js)? if ( @@ -42,6 +60,30 @@ class RendererMessageHandler { } } + // 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 #getPageObjs(pageIndex) { let objs = this.#objsMap.get(pageIndex); if (!objs) { @@ -51,6 +93,81 @@ class RendererMessageHandler { 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) { + this.#cleanedPages.add(pageIndex); + this.#objsMap.get(pageIndex)?.clear(); + this.#objsMap.delete(pageIndex); + for (const [renderTaskId, renderTaskState] of this.#renderTaskStates) { + if (renderTaskState.pageIndex === pageIndex) { + this.#cleanupRenderTask(renderTaskId); + } + } + } + + static #appendOperatorList( + renderTaskState, + fnArray, + argsArray, + operationsFilterMask, + lastChunk + ) { + const { operatorList } = renderTaskState; + if (fnArray) { + for (let i = 0, ii = fnArray.length; i < ii; i++) { + operatorList.fnArray.push(fnArray[i]); + operatorList.argsArray.push(argsArray[i]); + } + if (operationsFilterMask) { + const mask = (renderTaskState.operationsFilterMask ||= []); + for (let i = 0, ii = operationsFilterMask.length; i < ii; i++) { + mask.push(operationsFilterMask[i]); + } + } + } + operatorList.lastChunk = lastChunk; + renderTaskState.gfx.dependencyTracker?.growOperationsCount( + operatorList.fnArray.length + ); + } + + static async #executeOperatorList(renderTaskState) { + const { operatorList, gfx, operationsFilterMask } = renderTaskState; + const operationsFilter = operationsFilterMask + ? i => operationsFilterMask[i] + : null; + while (!renderTaskState.aborted) { + const { promise, resolve, reject } = Promise.withResolvers(); + renderTaskState.continueResolve = resolve; + + renderTaskState.operatorListIdx = gfx.executeOperatorList( + operatorList, + renderTaskState.operatorListIdx, + resolve, + reject, + undefined, // Renderer does not support stepper yet. + operationsFilter + ); + + if (renderTaskState.operatorListIdx === operatorList.argsArray.length) { + return renderTaskState.operatorListIdx; + } + await promise; + } + return renderTaskState.operatorListIdx; + } + static #setupObjectHandler(handler) { const objectHandler = new ObjectHandler({ messageHandler: handler, @@ -68,6 +185,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); }); @@ -77,6 +201,9 @@ class RendererMessageHandler { this.#commonObjs.reject(id, error); return; } + if (this.#cleanedPages.has(pageIndex)) { + return; + } this.#getPageObjs(pageIndex).reject(id, error); }); } @@ -98,6 +225,193 @@ class RendererMessageHandler { }); this.#setupObjectHandler(handler); + + handler.on("cleanupPage", ({ pageIndex }) => { + this.#cleanupPage(pageIndex); + }); + + 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("ReleaseCanvas", ({ canvasId }) => { + this.#offscreenCanvases.delete(canvasId); + }); + + handler.on("InitializeGraphics", async data => { + const { + canvasId, + pageIndex, + renderTaskId, + enableHWA = false, + annotationCanvasMap, + transform, + viewport, + transparency, + background, + recordOperations = false, + recordImages = false, + } = data; + let canvas = data.canvas; + if (canvas) { + this.#offscreenCanvases.set(canvasId, canvas); + } else { + canvas = this.#offscreenCanvases.get(canvasId); + if (!canvas) { + throw new Error( + "InitializeGraphics - the canvas was already released and " + + "cannot be reused." + ); + } + } + const renderTaskState = { + pageIndex, + gfx: null, + operatorList: { + fnArray: [], + argsArray: [], + lastChunk: false, + }, + operatorListIdx: 0, + operationsFilterMask: null, + continueResolve: null, + aborted: false, + }; + this.#renderTaskStates.set(renderTaskId, renderTaskState); + + const objs = this.#getPageObjs(pageIndex); + const optionalContentConfig = OptionalContentConfig.fromSerializable( + data.optionalContentConfig + ); + + const ctx = canvas.getContext("2d", { + alpha: false, + willReadFrequently: !enableHWA, + }); + if (!data.canvas) { + // In case of a reused canvas reset the context. + ctx.reset(); + } + const canvasFactory = new OffscreenCanvasFactory({ enableHWA }); + const filterFactory = new WorkerFilterFactory(); + const annotationCanvases = annotationCanvasMap ? new Map() : null; + if (annotationCanvasMap) { + this.#mergeAnnotationCanvases(annotationCanvases, annotationCanvasMap); + } + 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, + objs, + canvasFactory, + filterFactory, + { optionalContentConfig }, + annotationCanvases, + /* pageColors = */ null, + dependencyTracker ?? bboxTracker, + imagesTracker + ); + + gfx.beginDrawing({ + transform, + viewport, + transparency, + background, + }); + + renderTaskState.gfx = gfx; + }); + + handler.on("UpdateAnnotationCanvases", data => { + const { renderTaskId, annotationCanvasMap } = data; + if (!annotationCanvasMap) { + return; + } + const renderTaskState = this.#renderTaskStates.get(renderTaskId); + if (!renderTaskState || !renderTaskState.gfx.annotationCanvasMap) { + return; + } + this.#mergeAnnotationCanvases( + renderTaskState.gfx.annotationCanvasMap, + annotationCanvasMap + ); + }); + + handler.on("ExecuteOperatorList", async data => { + const { + renderTaskId, + fnArray, + argsArray, + operatorListIdx, + operationsFilterMask, + 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, + operationsFilterMask, + lastChunk + ); + + const currentOperatorListIdx = + await this.#executeOperatorList(renderTaskState); + + 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 { + operatorListIdx: currentOperatorListIdx, + recordedBBoxesBuffer, + imageCoordinates, + }; + }); } static initializeFromPort(port) { From b75c038869302cba3216afac6dd36a25c6bdf980 Mon Sep 17 00:00:00 2001 From: Aditi Date: Sun, 2 Aug 2026 00:43:37 +0530 Subject: [PATCH 09/14] Send the operator list to the renderer worker Decide whether to use worker rendering based on hasCanvasFilters, pageColors and the debug-recording path, transfer the canvas via transferControlToOffscreen and send the operator list to the renderer worker in chunks, along with any annotation canvases the list refers to. The recorded bounding boxes and image coordinates now come back from the worker in the final ExecuteOperatorList response, so the trackers are built inside initializeGraphics rather than by the caller. Since a canvas can only be transferred once, the OffscreenCanvas is tracked per canvas id and reused when the same canvas is re-rendered. The gate added here is off by default, so nothing takes this path yet; it is flipped in the final commit of this series. This commit is a part of the renderer-worker series, the worker rendering stays disabled until the final commit in this series. --- src/display/api.js | 466 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 399 insertions(+), 67 deletions(-) diff --git a/src/display/api.js b/src/display/api.js index cdb3645038bf7..a1c94ff344b2e 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -26,6 +26,7 @@ import { info, isNodeJS, makeObj, + OPS, RenderingIntentFlag, setVerbosityLevel, shadow, @@ -38,6 +39,7 @@ import { SerializableEmpty, } from "./annotation_storage.js"; import { + BBoxReader, CanvasBBoxTracker, CanvasDependencyTracker, CanvasImagesTracker, @@ -1539,6 +1541,7 @@ class PDFPageProxy { cacheKey, makeObj ); + // Ensure that a pending `streamReader` cancel timeout is always aborted. if (intentState.streamReaderCancelTimeout) { clearTimeout(intentState.streamReaderCancelTimeout); @@ -1575,14 +1578,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; } @@ -1590,7 +1604,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 @@ -1621,34 +1635,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, @@ -1662,6 +1660,7 @@ class PDFPageProxy { pageColors, enableHWA: this._transport.enableHWA, operationsFilter, + rendererWorker: this._transport.rendererWorker, }); (intentState.renderTasks ||= new Set()).add(internalRenderTask); @@ -1671,7 +1670,7 @@ class PDFPageProxy { intentState.displayReadyCapability.promise, optionalContentConfigPromise, ]) - .then(([renderPageData, optionalContentConfig]) => { + .then(async ([renderPageData, optionalContentConfig]) => { if (this.destroyed) { complete(); return; @@ -1688,7 +1687,7 @@ class PDFPageProxy { renderPageData && typeof renderPageData === "object" ? renderPageData : { transparency: renderPageData }; - internalRenderTask.initializeGraphics({ + await internalRenderTask.initializeGraphics({ transparency, hasCanvasFilters: hasCanvasFilters || intentState.hasCanvasFilters, optionalContentConfig, @@ -1848,6 +1847,9 @@ class PDFPageProxy { } } this.objs.clear(); + this._transport.rendererHandler?.send("cleanupPage", { + pageIndex: this._pageIndex, + }); this.#pendingCleanup = false; return Promise.all(waitOn); @@ -1885,6 +1887,9 @@ class PDFPageProxy { } this._intentStates.clear(); this.objs.clear(); + this._transport.rendererHandler?.send("cleanupPage", { + pageIndex: this._pageIndex, + }); this.#pendingCleanup = false; return true; } @@ -1947,6 +1952,13 @@ 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. + this._transport.rendererHandler?.send("restorePage", { + pageIndex: this._pageIndex, + }); + const readableStream = this._transport.messageHandler.sendWithStream( "GetOperatorList", { @@ -3402,6 +3414,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(); @@ -3496,6 +3511,13 @@ class RenderTask { get imageCoordinates() { return this._internalRenderTask.imageCoordinates || null; } + + /** + * @type {MessageHandler | null} + */ + get rendererHandler() { + return this._internalRenderTask.rendererHandler; + } } /** @@ -3507,6 +3529,16 @@ class InternalRenderTask { static #canvasInUse = new WeakSet(); + // Since `transferControlToOffscreen()` can only be called once per canvas + // we need to keep a track of Maps already transferred canvases to their + // worker-side OffscreenCanvas ids, in case they are used in multiple render + // tasks. + static #transferredCanvases = new WeakMap(); + + static #canvasId = 0; + + static #renderTaskId = 0; + constructor({ callback, params, @@ -3522,6 +3554,7 @@ class InternalRenderTask { pageColors = null, enableHWA = false, operationsFilter = null, + rendererWorker = null, }) { this.callback = callback; this.params = params; @@ -3552,9 +3585,21 @@ class InternalRenderTask { this._canvas = params.canvas; this._canvasContext = params.canvas ? null : params.canvasContext; this._enableHWA = enableHWA; - this._dependencyTracker = params.dependencyTracker; - this._imagesTracker = params.imagesTracker; + this._recordOperations = !!params.recordOperations; + this._recordImages = !!params.recordImages; + this._recordForDebugger = !!params.recordForDebugger; this._operationsFilter = operationsFilter; + this._rendererWorker = rendererWorker; + this._renderTaskId = InternalRenderTask.#renderTaskId++; + this._sentOperatorListLength = 0; + // 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; + this.debugMetadata = null; + this.imageCoordinates = null; } get completed() { @@ -3564,7 +3609,78 @@ class InternalRenderTask { }); } - initializeGraphics({ + get rendererHandler() { + return this._rendererWorker?.messageHandler ?? null; + } + + // 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, canvasName] = argsArray[i]; + if (!hasOwnCanvas) { + continue; + } + const transferredNames = this._transferredAnnotationCanvasIds.get(id); + if (transferredNames?.has(canvasName)) { + continue; + } + + 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, canvasName, offscreen]); + transfers.push(offscreen); + 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}.`); + } + } + return { annotationCanvases, transfers }; + } + + async initializeGraphics({ transparency = false, hasCanvasFilters = false, optionalContentConfig, @@ -3588,41 +3704,166 @@ class InternalRenderTask { this.stepper.init(this.operatorList); this.stepper.nextBreakPoint = this.stepper.getNextBreakPoint(); } - const { - viewport, - transform, - background, - dependencyTracker, - 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, - }); - - this.gfx = new CanvasGraphics( - canvasContext, - this.commonObjs, - this.objs, - this.canvasFactory, - this.filterFactory, - { optionalContentConfig }, - this.annotationCanvasMap, - this.pageColors, - dependencyTracker, - imagesTracker + 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.params.canvasContext && + !hasCanvasFilters && + !this.pageColors && + !this._recordForDebugger; + + const transferredEntry = InternalRenderTask.#transferredCanvases.get( + this._canvas ); - this.gfx.beginDrawing({ - transform, - viewport, - transparency, - background, - }); + if ( + transferredEntry && + (!useWorkerRendering || + transferredEntry.rendererWorker !== this._rendererWorker) + ) { + // The canvas is only a placeholder now, hence it can only be + // re-rendered by the renderer worker owning its OffscreenCanvas. + throw new Error( + "Cannot re-render a canvas whose control was transferred to a " + + "renderer worker, without using that same worker." + ); + } + + if (!useWorkerRendering && this._rendererWorker) { + // Only warn when a renderer worker is actually available, but cannot be + // used for this particular render + if (this.rendererHandler) { + warn("Falling back to main-thread rendering."); + } + this._rendererWorker = null; + } + let initPromise = null; + if (useWorkerRendering) { + try { + let offscreen = null; + let canvasId; + if (transferredEntry) { + ({ canvasId } = transferredEntry); + } else { + offscreen = this._canvas.transferControlToOffscreen(); + canvasId = InternalRenderTask.#canvasId++; + InternalRenderTask.#transferredCanvases.set(this._canvas, { + rendererWorker: this._rendererWorker, + canvasId, + }); + } + const { annotationCanvases, transfers } = + this._getAnnotationCanvasFromOpList( + 0, + this.operatorList.argsArray.length + ); + const initTransfers = offscreen ? [offscreen, ...transfers] : transfers; + const initParams = { + canvas: offscreen, + canvasId, + pageIndex: this._pageIndex, + renderTaskId: this._renderTaskId, + enableHWA: this._enableHWA, + optionalContentConfig: optionalContentConfig.serializable, + annotationCanvasMap: this.annotationCanvasMap + ? annotationCanvases + : null, + transform, + viewport, + transparency, + background, + recordOperations: this._recordOperations, + recordImages: this._recordImages, + }; + 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 { _rendererWorker, _renderTaskId } = this; + this._canvas.resetWorkerCanvas = () => { + _rendererWorker.messageHandler?.send("CleanupRenderTask", { + renderTaskId: _renderTaskId, + }); + _rendererWorker.messageHandler?.send("ReleaseCanvas", { canvasId }); + }; + } catch (ex) { + // Once the canvas has been transferred it's detached, hence falling + // back to main-thread rendering is no longer possible. + if (InternalRenderTask.#transferredCanvases.has(this._canvas)) { + throw ex; + } + warn( + `Failed to initialize graphics in renderer worker: ${ex.message}. ` + + "Falling back to main-thread rendering." + ); + this._rendererWorker = 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, + }); + + 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, + this.objs, + this.canvasFactory, + this.filterFactory, + { optionalContentConfig }, + this.annotationCanvasMap, + this.pageColors, + dependencyTracker ?? bboxTracker, + imagesTracker + ); + this.gfx.beginDrawing({ + transform, + viewport, + transparency, + 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?.(); @@ -3631,6 +3872,9 @@ class InternalRenderTask { cancel(error = null, extraDelay = 0) { this.running = false; this.cancelled = true; + this.rendererHandler?.send("CleanupRenderTask", { + renderTaskId: this._renderTaskId, + }); this.gfx?.endDrawing(); if (this.#rAF) { window.cancelAnimationFrame(this.#rAF); @@ -3652,11 +3896,16 @@ class InternalRenderTask { this.graphicsReadyCallback ||= this._continueBound; return; } - this.gfx.dependencyTracker?.growOperationsCount( - this.operatorList.fnArray.length - ); - this.stepper?.updateOperatorList(this.operatorList); - + // 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._rendererWorker) { + this.gfx.dependencyTracker?.growOperationsCount( + this.operatorList.fnArray.length + ); + this.stepper?.updateOperatorList(this.operatorList); + } if (this.running) { return; } @@ -3690,6 +3939,89 @@ class InternalRenderTask { if (this.cancelled) { return; } + const { operatorList, operatorListIdx } = this; + 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, + operatorListArgsArrayLen + ); + const { annotationCanvases, transfers } = + this._getAnnotationCanvasFromOpList( + sentLength, + operatorListArgsArrayLen + ); + if (annotationCanvases.length > 0) { + 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; + // Since operationsFilter is a function and cannot be structured-cloned, + // precomputing the results for the ops being sent as a mask that the + // worker can index into. + let operationsFilterMask = null; + if (fnArray && this._operationsFilter) { + operationsFilterMask = new Uint8Array(fnArray.length); + for (let i = 0, ii = fnArray.length; i < ii; i++) { + operationsFilterMask[i] = this._operationsFilter(sentLength + i) + ? 1 + : 0; + } + } + const response = await rendererHandler.sendWithPromise( + "ExecuteOperatorList", + { + renderTaskId: this._renderTaskId, + fnArray, + argsArray, + operatorListIdx, + operationsFilterMask, + 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; + } + + 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, From 04bac49350c8e002511bb2c0b97cccfb9baba245 Mon Sep 17 00:00:00 2001 From: Aditi Date: Sun, 2 Aug 2026 00:43:53 +0530 Subject: [PATCH 10/14] Adapt the viewer for transferred canvases A canvas whose control has been transferred to the renderer worker can no longer be resized or re-acquired on the main thread, so releasing it has to go through the worker instead of setting width/height to zero, and it cannot be reused as the source for a thumbnail. This is inert while the gate is off, since resetWorkerCanvas is never set and releaseCanvas behaves exactly as the previous width = height = 0. This commit is a part of the renderer-worker series, the worker rendering stays disabled until the final commit in this series. --- web/base_pdf_page_view.js | 26 +++++++++++++++++++++----- web/pdf_page_view.js | 5 +++++ web/pdf_rendering_queue.js | 5 +++++ 3 files changed, 31 insertions(+), 5 deletions(-) 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 6691ae349fe92..947738803ddb1 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -1298,6 +1298,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() From 236dc1be7e304a35e70fb8008262825563aaa2c0 Mon Sep 17 00:00:00 2001 From: Aditi Date: Sun, 2 Aug 2026 00:44:26 +0530 Subject: [PATCH 11/14] Adapt the test harness for transferred canvases Render into a separate canvas in the reftest driver and copy the result back, since a canvas can only be transferred once, and read pixels through createImageBitmap in the integration helpers for canvases whose context can no longer be acquired. Nothing here enables worker rendering; GlobalWorkerOptions.rendererSrc is set in the final commit of this series, together with the library and viewer defaults. Every change in this commit behaves identically on the main thread. This commit is a part of the renderer-worker series, the worker rendering stays disabled until the final commit in this series. --- test/driver.js | 32 ++++++- test/integration/reorganize_pages_spec.mjs | 19 +++- test/integration/test_utils.mjs | 26 ++++- test/integration/viewer_spec.mjs | 106 +++++++++++++++++++-- 4 files changed, 168 insertions(+), 15 deletions(-) diff --git a/test/driver.js b/test/driver.js index 86374280c70b9..2fa6b9418b4d6 100644 --- a/test/driver.js +++ b/test/driver.js @@ -571,6 +571,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() { @@ -1201,8 +1204,19 @@ class Driver { initPromise = Promise.resolve(); } } + // Render into a separate canvas to allow + // `transferControlToOffscreen`; `recordOperations` is tracked + // by the worker independently of which canvas receives the + // pixels, so `partialCrop` doesn't need `this.canvas` directly. + this.renderCanvas = document.createElement("canvas"); + this.renderCanvas.width = pixelWidth; + this.renderCanvas.height = pixelHeight; + this.renderCanvas.style.width = this.canvas.style.width; + this.renderCanvas.style.height = this.canvas.style.height; + const renderCanvas = this.renderCanvas; + const renderContext = { - canvas: this.canvas, + canvas: renderCanvas, viewport, optionalContentConfigPromise: task.optionalContentConfigPromise, annotationCanvasMap, @@ -1224,6 +1238,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") { @@ -1264,6 +1286,14 @@ class Driver { await renderTask.promise; if (partialCrop) { + if (renderCanvas !== this.canvas) { + try { + ctx.drawImage(renderCanvas, 0, 0); + } catch (ex) { + this._info(`Unable to copy the render canvas: ${ex}`); + } + renderCanvas.resetWorkerCanvas?.(); + } const clearOutsidePartial = () => { const { width, height } = ctx.canvas; // Everything above the partial area diff --git a/test/integration/reorganize_pages_spec.mjs b/test/integration/reorganize_pages_spec.mjs index 2a63fdf646927..e9265879f5558 100644 --- a/test/integration/reorganize_pages_spec.mjs +++ b/test/integration/reorganize_pages_spec.mjs @@ -144,14 +144,25 @@ async function waitForPageCanvasToHaveImage(page, pageNumber) { const selector = `.page[data-page-number = "${pageNumber}"] .canvasWrapper canvas`; await page.waitForSelector(selector, { visible: true }); await page.waitForFunction( - sel => { + async sel => { const canvas = document.querySelector(sel); if (!canvas?.width || !canvas.height) { return false; } - const { data } = canvas - .getContext("2d", { willReadFrequently: true }) - .getImageData(0, 0, canvas.width, canvas.height); + // The canvas may have been transferred to the renderer worker. + let bitmap; + try { + bitmap = await createImageBitmap(canvas); + } catch { + return false; + } + const tmp = document.createElement("canvas"); + tmp.width = canvas.width; + tmp.height = canvas.height; + const ctx = tmp.getContext("2d", { willReadFrequently: true }); + ctx.drawImage(bitmap, 0, 0); + bitmap.close(); + const { data } = ctx.getImageData(0, 0, tmp.width, tmp.height); for (let i = 0, ii = data.length; i < ii; i += 4) { if ( data[i + 3] !== 0 && diff --git a/test/integration/test_utils.mjs b/test/integration/test_utils.mjs index 2ba8a7e5a62b6..436cbc4a5db47 100644 --- a/test/integration/test_utils.mjs +++ b/test/integration/test_utils.mjs @@ -1059,12 +1059,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 b3b299d741278..40ba75f7c18b5 100644 --- a/test/integration/viewer_spec.mjs +++ b/test/integration/viewer_spec.mjs @@ -545,23 +545,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) { @@ -580,6 +631,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()", () => { @@ -621,6 +701,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 @@ -661,6 +742,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); @@ -693,6 +778,7 @@ describe("PDF viewer", () => { }); }, factor); await awaitPromise(handle); + await waitForCanvasPixels(page, 1); const after = await page.evaluate(extractCanvases, 1); @@ -733,6 +819,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); @@ -779,6 +866,7 @@ describe("PDF viewer", () => { container.scrollLeft += 1100; }); await awaitPromise(handle); + await waitForCanvasPixels(page, 1); const canvases = await page.evaluate(extractCanvases, 1); @@ -875,6 +963,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), From ffdd906dbca27f2c89ae9848734bb2bbd893c7e8 Mon Sep 17 00:00:00 2001 From: Aditi Date: Sun, 2 Aug 2026 00:44:54 +0530 Subject: [PATCH 12/14] Enable WebGPU in the renderer worker Thread the enableWebGPU flag from getDocument() through WorkerTransport and InternalRenderTask to the renderer worker's InitializeGraphics handler, where it triggers GPU device initialization. The main thread already waits for InitializeGraphics to resolve before sending any operators, so the GPU device is ready by the time drawing starts. This commit is a part of the renderer-worker series, the worker rendering stays disabled until the final commit in this series. --- src/display/api.js | 6 ++++++ src/display/renderer_worker.js | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/src/display/api.js b/src/display/api.js index a1c94ff344b2e..59e32af7ef6f4 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -410,6 +410,7 @@ function getDocument(src = {}) { pdfBug, styleElement, enableHWA, + enableWebGPU, loadingParams: { disableAutoFetch, enableXfa, @@ -1659,6 +1660,7 @@ class PDFPageProxy { pdfBug: this._pdfBug, pageColors, enableHWA: this._transport.enableHWA, + enableWebGPU: this._transport.enableWebGPU, operationsFilter, rendererWorker: this._transport.rendererWorker, }); @@ -2648,6 +2650,7 @@ class WorkerTransport { styleElement: params.styleElement, }); this.enableHWA = params.enableHWA; + this.enableWebGPU = params.enableWebGPU === true; this.rendererWorker = params.rendererWorker || null; this.loadingParams = params.loadingParams; this._params = params; @@ -3553,6 +3556,7 @@ class InternalRenderTask { pdfBug = false, pageColors = null, enableHWA = false, + enableWebGPU = false, operationsFilter = null, rendererWorker = null, }) { @@ -3585,6 +3589,7 @@ class InternalRenderTask { this._canvas = params.canvas; this._canvasContext = params.canvas ? null : params.canvasContext; this._enableHWA = enableHWA; + this._enableWebGPU = enableWebGPU; this._recordOperations = !!params.recordOperations; this._recordImages = !!params.recordImages; this._recordForDebugger = !!params.recordForDebugger; @@ -3770,6 +3775,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 6d19254730320..46cc3174cf40c 100644 --- a/src/display/renderer_worker.js +++ b/src/display/renderer_worker.js @@ -21,6 +21,7 @@ import { 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"; @@ -258,6 +259,7 @@ class RendererMessageHandler { pageIndex, renderTaskId, enableHWA = false, + enableWebGPU = false, annotationCanvasMap, transform, viewport, @@ -293,6 +295,12 @@ class RendererMessageHandler { }; this.#renderTaskStates.set(renderTaskId, renderTaskState); + if (enableWebGPU) { + await initGPU(); + if (renderTaskState.aborted) { + return; + } + } const objs = this.#getPageObjs(pageIndex); const optionalContentConfig = OptionalContentConfig.fromSerializable( data.optionalContentConfig From 312476ed0d4568f196a809e8d60b8cb8cf4ba2d6 Mon Sep 17 00:00:00 2001 From: Aditi Date: Sun, 2 Aug 2026 00:45:14 +0530 Subject: [PATCH 13/14] [api-minor] Enable worker rendering Flip disableWorkerRendering to opt-out in the API and to false in the viewer, and point the reftest driver and the unit tests at the renderer worker bundle, so the whole suite exercises this path from here on. This changes the default for API consumers: the canvas passed to render() has its control transferred to the renderer worker, so calling getContext("2d") on it afterwards will throw. Pass disableWorkerRendering: true to opt out. Getting worker rendering also requires GlobalWorkerOptions.rendererSrc to point at the pdf.renderer.mjs bundle. The viewer and the pdfjs-dist webpack entry set it automatically, but integrators who bundle the library themselves must set it as well; when it's unset, or the renderer worker fails to start, rendering falls back to the main-thread with a warning rather than failing. Thumbnails keep rendering on the main thread by passing a canvasContext rather than a canvas. Note that this also means InternalRenderTask no longer sees the canvas, so the "same canvas during multiple render() operations" guard does not cover thumbnails. This commit is the final commit of the renderer-worker series, it enables the worker rendering that the previous commits kept disabled. --- src/display/api.js | 11 +++++------ src/display/worker_options.js | 4 ++++ test/driver.js | 2 ++ test/unit/jasmine-boot.js | 2 ++ web/app_options.js | 2 +- web/pdf_thumbnail_view.js | 9 ++++++--- 6 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/display/api.js b/src/display/api.js index 59e32af7ef6f4..005ecd6755a2c 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -220,9 +220,10 @@ const RENDERING_CANCELLED_TIMEOUT = 100; // ms * 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 `true` for now, since worker - * rendering stays disabled throughout this series; it becomes `false` in the - * final commit, which enables it. + * a worker thread. Note that worker rendering also requires + * `GlobalWorkerOptions.rendererSrc` to be set; when it's unset, or the + * renderer worker fails to start, rendering falls back to the main-thread. + * The default value is `false`. */ /** @@ -338,9 +339,7 @@ function getDocument(src = {}) { isValidFetchUrl(wasmUrl, document.baseURI) ); const disableWorkerRendering = - // TODO: Default to enabled once worker rendering is complete; flipped in - // the last commit of this series. - src.disableWorkerRendering !== false || + src.disableWorkerRendering === true || typeof Worker === "undefined" || !FeatureTest.isOffscreenCanvasSupported || ownerDocument !== globalThis.document || diff --git a/src/display/worker_options.js b/src/display/worker_options.js index 3a9066d2f6e2e..3ff2eeecdc764 100644 --- a/src/display/worker_options.js +++ b/src/display/worker_options.js @@ -30,6 +30,10 @@ class GlobalWorkerOptions { /** * @param {string} rendererSrc - A string containing the path and * filename of the renderer worker file. + * + * NOTE: The `rendererSrc` option must be set in order to render pages in a + * worker thread; when it's unset, rendering falls back to the + * main-thread. */ static set rendererSrc(val) { if (typeof val !== "string") { diff --git a/test/driver.js b/test/driver.js index 2fa6b9418b4d6..887c7fea64352 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`. diff --git a/test/unit/jasmine-boot.js b/test/unit/jasmine-boot.js index c54dbe2ef50aa..5908932c01ed5 100644 --- a/test/unit/jasmine-boot.js +++ b/test/unit/jasmine-boot.js @@ -125,6 +125,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/app_options.js b/web/app_options.js index e66b4e111e0c4..3c64a62f02b57 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -782,7 +782,7 @@ const defaultOptions = new Map([ "disableWorkerRendering", { /** @type {boolean} */ - value: true, + value: false, kind: OptionKind.API + OptionKind.PREFERENCE, }, ], diff --git a/web/pdf_thumbnail_view.js b/web/pdf_thumbnail_view.js index e2c822c0fbbf6..556360b3fa86c 100644 --- a/web/pdf_thumbnail_view.js +++ b/web/pdf_thumbnail_view.js @@ -324,12 +324,14 @@ class PDFThumbnailView extends RenderableView { const canvas = document.createElement("canvas"); canvas.width = (width * outputScale.sx) | 0; canvas.height = (height * outputScale.sy) | 0; + // Get the canvas context here to ensure we use main-thread rendering. + const canvasContext = canvas.getContext("2d", { alpha: false }); const transform = outputScale.scaled ? [outputScale.sx, 0, 0, outputScale.sy, 0, 0] : null; - return { canvas, transform }; + return { canvas, canvasContext, transform }; } async #convertCanvasToImage(canvas) { @@ -366,7 +368,8 @@ class PDFThumbnailView extends RenderableView { // the `draw` and `setImage` methods (fixes issue 8233). // NOTE: To primarily avoid increasing memory usage too much, but also to // reduce downsizing overhead, we purposely limit the up-scaling factor. - const { canvas, transform } = this.#getPageDrawContext(DRAW_UPSCALE_FACTOR); + const { canvas, canvasContext, transform } = + this.#getPageDrawContext(DRAW_UPSCALE_FACTOR); const drawViewport = this.viewport.clone({ scale: DRAW_UPSCALE_FACTOR * this.scale, }); @@ -383,7 +386,7 @@ class PDFThumbnailView extends RenderableView { }; const renderContext = { - canvas, + canvasContext, transform, viewport: drawViewport, optionalContentConfigPromise: this._optionalContentConfigPromise, From 58eed71d253e4bb497de190c071f2888228518ec Mon Sep 17 00:00:00 2001 From: Aditi Date: Mon, 3 Aug 2026 19:08:15 +0530 Subject: [PATCH 14/14] Reduce the parsing overhead of hasCanvasFilters Mirror the existing nonBlendModesSet with a nonCanvasFiltersSet, so that resources already proven filter-free aren't walked again on subsequent pages. A separate set is needed since hasBlendModes descends into Form XObjects only, while hasCanvasFilters also walks tiling patterns. Replace _hasTransferMaps with _getTransferFunctions, now shared with handleTransferFunction, since the two only differed in whether the 256-entry transfer maps get built. This commit is a part of the renderer-worker series. --- src/core/catalog.js | 3 ++ src/core/document.js | 9 ++++- src/core/evaluator.js | 93 +++++++++++++++++++------------------------ 3 files changed, 51 insertions(+), 54 deletions(-) diff --git a/src/core/catalog.js b/src/core/catalog.js index 9841540055ba3..dedaf36f400e7 100644 --- a/src/core/catalog.js +++ b/src/core/catalog.js @@ -138,6 +138,8 @@ class Catalog { nonBlendModesSet = new RefSet(); + nonCanvasFiltersSet = new RefSet(); + pageDictCache = new RefSetCache(); pageIndexCache = new RefSetCache(); @@ -1305,6 +1307,7 @@ class Catalog { this.pageIndexCache.clear(); this.pageDictCache.clear(); this.nonBlendModesSet.clear(); + this.nonCanvasFiltersSet.clear(); for (const { dict } of await Promise.all(this.fontCache)) { delete dict.cacheKey; diff --git a/src/core/document.js b/src/core/document.js index 453cba3f09e54..25a1be9d51956 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -100,6 +100,7 @@ class Page { globalImageCache, systemFontCache, nonBlendModesSet, + nonCanvasFiltersSet, xfaFactory, }) { this.pdfManager = pdfManager; @@ -114,6 +115,7 @@ class Page { this.globalImageCache = globalImageCache; this.systemFontCache = systemFontCache; this.nonBlendModesSet = nonBlendModesSet; + this.nonCanvasFiltersSet = nonCanvasFiltersSet; this.evaluatorOptions = pdfManager.evaluatorOptions; this.xfaFactory = xfaFactory; @@ -565,7 +567,10 @@ class Page { resources, this.nonBlendModesSet ), - hasCanvasFilters: partialEvaluator.hasCanvasFilters(resources), + hasCanvasFilters: partialEvaluator.hasCanvasFilters( + resources, + this.nonCanvasFiltersSet + ), pageIndex, cacheKey, }); @@ -1721,6 +1726,7 @@ class PDFDocument { globalImageCache: catalog.globalImageCache, systemFontCache: catalog.systemFontCache, nonBlendModesSet: catalog.nonBlendModesSet, + nonCanvasFiltersSet: catalog.nonCanvasFiltersSet, xfaFactory, }) ); @@ -1821,6 +1827,7 @@ class PDFDocument { globalImageCache: catalog.globalImageCache, systemFontCache: catalog.systemFontCache, nonBlendModesSet: catalog.nonBlendModesSet, + nonCanvasFiltersSet: catalog.nonCanvasFiltersSet, xfaFactory: null, }) ); diff --git a/src/core/evaluator.js b/src/core/evaluator.js index 37b0713e6a3cd..f8a74d87bd588 100644 --- a/src/core/evaluator.js +++ b/src/core/evaluator.js @@ -388,49 +388,15 @@ class PartialEvaluator { return false; } - _hasTransferMaps(transferObj) { - let transferArray; - if (Array.isArray(transferObj)) { - transferArray = transferObj; - if ( - transferObj.length > 1 && - transferObj.every(map => map === transferObj[0]) - ) { - // All entries in the array are the same, so we can just use one of - // them; this mirrors `handleTransferFunction`. - transferArray = [transferObj[0]]; - } - } else if (isPDFFunction(transferObj)) { - transferArray = [transferObj]; - } else { - return false; - } - - const numFns = transferArray.length; - if (!(numFns === 1 || numFns === 4)) { + hasCanvasFilters(resources, nonCanvasFiltersSet) { + if (!(resources instanceof Dict)) { 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)) { + if (resources.objId && nonCanvasFiltersSet.has(resources.objId)) { return false; } - const processed = new RefSet(); + const processed = new RefSet(nonCanvasFiltersSet); if (resources.objId) { processed.put(resources.objId); } @@ -466,7 +432,7 @@ class PartialEvaluator { const transferObj = graphicState.has("TR2") ? graphicState.get("TR2") : graphicState.get("TR"); - if (this._hasTransferMaps(transferObj)) { + if (this._getTransferFunctions(transferObj) !== null) { return true; } } catch (ex) { @@ -515,6 +481,13 @@ class PartialEvaluator { } } } + + // When no canvas filters exist, there's no need to re-fetch/re-parse any + // of the processed `Ref`s again for subsequent pages; the early + // `return true`s above skip this, since those subtrees weren't inspected. + for (const ref of processed) { + nonCanvasFiltersSet.put(ref); + } return false; } @@ -1057,7 +1030,7 @@ class PartialEvaluator { ); } - handleTransferFunction(tr) { + _getTransferFunctions(tr) { let transferArray; if (Array.isArray(tr)) { transferArray = tr; @@ -1071,21 +1044,43 @@ class PartialEvaluator { } else { return null; // Not a valid transfer function entry. } + if (!(transferArray.length === 1 || transferArray.length === 4)) { + return null; // Only 1 or 4 functions are supported, by the specification. + } - const transferMaps = []; - let numFns = 0, - numEffectfulFns = 0; + const transferFns = []; + let numEffectfulFns = 0; for (const entry of transferArray) { const transferObj = this.xref.fetchIfRef(entry); - numFns++; if (isName(transferObj, "Identity")) { - transferMaps.push(null); + transferFns.push(null); continue; } else if (!isPDFFunction(transferObj)) { return null; // Not a valid transfer function object. } + transferFns.push(transferObj); + numEffectfulFns++; + } + if (numEffectfulFns === 0) { + return null; // Only /Identity transfer functions found, which are no-ops. + } + return transferFns; + } + + handleTransferFunction(tr) { + const transferFns = this._getTransferFunctions(tr); + if (!transferFns) { + return null; + } + + const transferMaps = []; + for (const transferObj of transferFns) { + if (!transferObj) { + transferMaps.push(null); // An `/Identity` entry. + continue; + } const transferFn = this._pdfFunctionFactory.create(transferObj); const transferMap = new Uint8Array(256), tmp = new Float32Array(1); @@ -1095,14 +1090,6 @@ class PartialEvaluator { transferMap[j] = (tmp[0] * 255) | 0; } transferMaps.push(transferMap); - numEffectfulFns++; - } - - if (!(numFns === 1 || numFns === 4)) { - return null; // Only 1 or 4 functions are supported, by the specification. - } - if (numEffectfulFns === 0) { - return null; // Only /Identity transfer functions found, which are no-ops. } return transferMaps; }