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/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 de8a5ffc71695..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,6 +567,10 @@ class Page { resources, this.nonBlendModesSet ), + hasCanvasFilters: partialEvaluator.hasCanvasFilters( + resources, + this.nonCanvasFiltersSet + ), pageIndex, cacheKey, }); @@ -1720,6 +1726,7 @@ class PDFDocument { globalImageCache: catalog.globalImageCache, systemFontCache: catalog.systemFontCache, nonBlendModesSet: catalog.nonBlendModesSet, + nonCanvasFiltersSet: catalog.nonCanvasFiltersSet, xfaFactory, }) ); @@ -1820,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 9cba883b57c27..f8a74d87bd588 100644 --- a/src/core/evaluator.js +++ b/src/core/evaluator.js @@ -388,6 +388,109 @@ class PartialEvaluator { return false; } + hasCanvasFilters(resources, nonCanvasFiltersSet) { + if (!(resources instanceof Dict)) { + return false; + } + if (resources.objId && nonCanvasFiltersSet.has(resources.objId)) { + return false; + } + + const processed = new RefSet(nonCanvasFiltersSet); + 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._getTransferFunctions(transferObj) !== null) { + 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); + } + } + } + } + } + + // 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; + } + async fetchBuiltInCMap(name) { const cachedData = this.builtInCMapCache.get(name); if (cachedData) { @@ -927,7 +1030,7 @@ class PartialEvaluator { ); } - handleTransferFunction(tr) { + _getTransferFunctions(tr) { let transferArray; if (Array.isArray(tr)) { transferArray = tr; @@ -941,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); @@ -965,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; } diff --git a/src/display/api.js b/src/display/api.js index 908b2af6b1892..005ecd6755a2c 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -21,10 +21,12 @@ import { AbortException, AnnotationMode, assert, + FeatureTest, getVerbosityLevel, info, isNodeJS, makeObj, + OPS, RenderingIntentFlag, setVerbosityLevel, shadow, @@ -37,16 +39,11 @@ import { SerializableEmpty, } from "./annotation_storage.js"; import { + BBoxReader, CanvasBBoxTracker, 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 +67,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"; @@ -189,7 +188,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`. @@ -219,6 +219,11 @@ 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. 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`. */ /** @@ -308,6 +313,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" @@ -327,12 +338,12 @@ function getDocument(src = {}) { isValidFetchUrl(standardFontDataUrl, document.baseURI) && isValidFetchUrl(wasmUrl, document.baseURI) ); - - // Parameters only intended for development/testing purposes. - const styleElement = - typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING") - ? src.styleElement - : null; + const disableWorkerRendering = + src.disableWorkerRendering === true || + typeof Worker === "undefined" || + !FeatureTest.isOffscreenCanvasSupported || + ownerDocument !== globalThis.document || + !!styleElement; // Set the main-thread verbosity level. setVerbosityLevel(verbosity); @@ -358,6 +369,9 @@ function getDocument(src = {}) { }); task._worker = worker; } + if (!disableWorkerRendering) { + task._rendererWorker = new RendererWorker({ verbosity }); + } const docParams = { docId, @@ -395,13 +409,25 @@ function getDocument(src = {}) { pdfBug, styleElement, enableHWA, + enableWebGPU, loadingParams: { disableAutoFetch, enableXfa, }, }; - 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"); @@ -454,7 +480,10 @@ function getDocument(src = {}) { messageHandler, task, networkStream, - transportParams, + { + ...transportParams, + rendererWorker: task._rendererWorker, + }, transportFactory, pagesMapper ); @@ -514,6 +543,11 @@ class PDFDocumentLoadingTask { */ _worker = null; + /** + * @private + */ + _rendererWorker = null; + /** * Unique identifier for the document loading task. * @type {string} @@ -587,6 +621,9 @@ class PDFDocumentLoadingTask { this._worker?.destroy(); this._worker = null; + + this._rendererWorker?.destroy(); + this._rendererWorker = null; } /** @@ -1504,6 +1541,7 @@ class PDFPageProxy { cacheKey, makeObj ); + // Ensure that a pending `streamReader` cancel timeout is always aborted. if (intentState.streamReaderCancelTimeout) { clearTimeout(intentState.streamReaderCancelTimeout); @@ -1540,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; } @@ -1555,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 @@ -1586,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, @@ -1626,7 +1659,9 @@ class PDFPageProxy { pdfBug: this._pdfBug, pageColors, enableHWA: this._transport.enableHWA, + enableWebGPU: this._transport.enableWebGPU, operationsFilter, + rendererWorker: this._transport.rendererWorker, }); (intentState.renderTasks ||= new Set()).add(internalRenderTask); @@ -1636,7 +1671,7 @@ class PDFPageProxy { intentState.displayReadyCapability.promise, optionalContentConfigPromise, ]) - .then(([transparency, optionalContentConfig]) => { + .then(async ([renderPageData, optionalContentConfig]) => { if (this.destroyed) { complete(); return; @@ -1649,8 +1684,13 @@ class PDFPageProxy { "and `PDFDocumentProxy.getOptionalContentConfig` methods." ); } - internalRenderTask.initializeGraphics({ + const { transparency, hasCanvasFilters = false } = + renderPageData && typeof renderPageData === "object" + ? renderPageData + : { transparency: renderPageData }; + await internalRenderTask.initializeGraphics({ transparency, + hasCanvasFilters: hasCanvasFilters || intentState.hasCanvasFilters, optionalContentConfig, }); internalRenderTask.operatorListChanged(); @@ -1808,6 +1848,9 @@ class PDFPageProxy { } } this.objs.clear(); + this._transport.rendererHandler?.send("cleanupPage", { + pageIndex: this._pageIndex, + }); this.#pendingCleanup = false; return Promise.all(waitOn); @@ -1845,6 +1888,9 @@ class PDFPageProxy { } this._intentStates.clear(); this.objs.clear(); + this._transport.rendererHandler?.send("cleanupPage", { + pageIndex: this._pageIndex, + }); this.#pendingCleanup = false; return true; } @@ -1852,16 +1898,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, + }); } /** @@ -1903,6 +1953,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", { @@ -2037,6 +2094,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. @@ -2427,6 +2649,8 @@ 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; @@ -2492,6 +2716,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, @@ -2605,6 +2838,7 @@ class WorkerTransport { this.messageHandler?.destroy(); this.messageHandler = null; + this.rendererWorker = null; this.destroyCapability.resolve(); }, this.destroyCapability.reject); @@ -2773,90 +3007,84 @@ 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({ + messageHandler, + commonObjs: this.commonObjs, + fontLoader: this.fontLoader, + pageCache: this.#pageCache, + 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 (this.commonObjs.has(id)) { - return null; + 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; + }); } - 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 - ); + forwardToRenderer("commonobj", [id, type, exportedData]); - 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}`); + if (this.commonObjs.has(id)) { + return null; } - return null; + return objectHandler.resolveCommonObject(id, type, exportedData); }); messageHandler.on("obj", ([id, pageIndex, type, imageData]) => { @@ -2864,25 +3092,8 @@ 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}`); - } + forwardToRenderer("obj", [id, pageIndex, type, imageData]); + objectHandler.resolveObject(id, pageIndex, type, imageData); }); messageHandler.on("DocProgress", data => { @@ -3205,6 +3416,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(); @@ -3299,6 +3513,13 @@ class RenderTask { get imageCoordinates() { return this._internalRenderTask.imageCoordinates || null; } + + /** + * @type {MessageHandler | null} + */ + get rendererHandler() { + return this._internalRenderTask.rendererHandler; + } } /** @@ -3310,6 +3531,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, @@ -3324,7 +3555,9 @@ class InternalRenderTask { pdfBug = false, pageColors = null, enableHWA = false, + enableWebGPU = false, operationsFilter = null, + rendererWorker = null, }) { this.callback = callback; this.params = params; @@ -3355,9 +3588,22 @@ 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._enableWebGPU = enableWebGPU; + 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() { @@ -3367,7 +3613,82 @@ class InternalRenderTask { }); } - initializeGraphics({ transparency = false, optionalContentConfig }) { + 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, + }) { if (this.cancelled) { return; } @@ -3387,41 +3708,167 @@ 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, + enableWebGPU: this._enableWebGPU, + 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?.(); @@ -3430,6 +3877,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); @@ -3451,11 +3901,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; } @@ -3489,10 +3944,95 @@ 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, 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 d9ab2025eb6e6..6a7feac73a01a 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"; @@ -663,11 +672,16 @@ class CanvasGraphics { operatorList, executionStartIdx, continueCallback, + errorCallback, stepper, operationsFilter ) { 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; @@ -719,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; } } @@ -2064,10 +2078,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 +2106,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 +2961,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 +3005,7 @@ class CanvasGraphics { current.x += width * textHScale; } ctx.restore(); + this._pathCache = prevPathCache; if (dependencyTracker) { this.dependencyTracker = dependencyTracker; } @@ -3718,29 +3737,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; 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/object_handler.js b/src/display/object_handler.js new file mode 100644 index 0000000000000..91a796546b336 --- /dev/null +++ b/src/display/object_handler.js @@ -0,0 +1,156 @@ +/* Copyright 2026 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { assert, warn } from "../shared/util.js"; +import { + FontInfo, + FontPathInfo, + PatternInfo, +} from "./obj_bin_transform_display.js"; + +import { FontFaceObject } from "./font_loader.js"; +import { PDFObjects } from "./pdf_objects.js"; + +class ObjectHandler { + constructor({ + messageHandler, + commonObjs, + fontLoader, + pageCache, + pdfBug = null, + shouldCreatePageObjs = false, + }) { + this.messageHandler = messageHandler; + this.commonObjs = commonObjs; + this.fontLoader = fontLoader; + this.pageCache = pageCache; + this.pdfBug = pdfBug; + this.shouldCreatePageObjs = shouldCreatePageObjs; + } + + resolveCommonObject(id, type, exportedData) { + switch (type) { + case "Font": + if ("error" in exportedData) { + const exportedError = exportedData.error; + warn(`Error during font loading: ${exportedError}`); + this.commonObjs.resolve(id, exportedError); + break; + } + + const fontData = new FontInfo(exportedData); + const inspectFont = + this.pdfBug && globalThis.FontInspector?.enabled + ? (font, url) => globalThis.FontInspector.fontAdded(font, url) + : null; + const font = new FontFaceObject( + fontData, + inspectFont, + exportedData.charProcOperatorList, + exportedData.extra + ); + + this.fontLoader + .bind(font) + .catch(() => + this.messageHandler + .sendWithPromise("FontFallback", { id }) + .catch(reason => { + warn(`FontFallback failed for "${id}": ${reason}`); + }) + ) + .finally(() => { + if (!font.fontExtraProperties) { + // Immediately release the `font.data` property once the font + // has been attached to the DOM, since it's no longer needed, + // rather than waiting for a `PDFDocumentProxy.cleanup` call. + // Since `font.data` could be very large, e.g. in some cases + // multiple megabytes, this will help reduce memory usage. + font.clearData(); + } + this.commonObjs.resolve(id, font); + }); + break; + case "CopyLocalImage": + const { imageRef } = exportedData; + assert(imageRef, "The imageRef must be defined."); + + for (const pageOrObjs of this.pageCache.values()) { + const objs = pageOrObjs.objs || pageOrObjs; + + for (const [, data] of objs) { + if (data?.ref !== imageRef) { + continue; + } + if (!data.dataLen) { + return null; + } + const copy = structuredClone(data); + if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) { + copy.CopyLocalImage = true; + } + this.commonObjs.resolve(id, copy); + return data.dataLen; + } + } + break; + case "FontPath": + this.commonObjs.resolve(id, new FontPathInfo(exportedData)); + break; + case "Image": + this.commonObjs.resolve(id, exportedData); + break; + case "Pattern": + const pattern = new PatternInfo(exportedData); + this.commonObjs.resolve(id, pattern.getIR()); + break; + default: + throw new Error(`Got unknown common object type ${type}`); + } + return null; + } + + resolveObject(id, pageIndex, type, exportedData) { + let pageOrObjs = this.pageCache.get(pageIndex); + if (!pageOrObjs) { + if (!this.shouldCreatePageObjs) { + return; + } + pageOrObjs = new PDFObjects(); + this.pageCache.set(pageIndex, pageOrObjs); + } + + const objs = pageOrObjs.objs || pageOrObjs; + if (objs.has(id)) { + return; + } + // Don't store data *after* cleanup has successfully run, see bug 1854145. + if (pageOrObjs._intentStates?.size === 0) { + exportedData?.bitmap?.close(); // Release any `ImageBitmap` data. + return; + } + + switch (type) { + case "Image": + case "Pattern": + objs.resolve(id, exportedData); + break; + default: + throw new Error(`Got unknown object type ${type}`); + } + } +} + +export { ObjectHandler }; 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(); } diff --git a/src/display/pdf_objects.js b/src/display/pdf_objects.js index 7c54b01e41658..f48f66f241360 100644 --- a/src/display/pdf_objects.js +++ b/src/display/pdf_objects.js @@ -38,14 +38,17 @@ class PDFObjects { * * @param {string} objId * @param {function} [callback] + * @param {function} [errorCallback] - Called with the rejection reason if the + * object fails to resolve (e.g. it could never be delivered). Only used + * together with `callback`. * @returns {any} */ - get(objId, callback = null) { + get(objId, callback = null, errorCallback = null) { // If there is a callback, then the get can be async and the object is // not required to be resolved right now. if (callback) { const obj = this.#objs.getOrInsertComputed(objId, dataObj); - obj.promise.then(() => callback(obj.data)); + obj.promise.then(() => callback(obj.data), errorCallback); return null; } // If there isn't a callback, the user expects to get the resolved data @@ -97,6 +100,23 @@ class PDFObjects { obj.resolve(); } + /** + * Rejects the object `objId`, signalling that it will never be resolved. + * + * @param {string} objId + * @param {Error} reason + */ + reject(objId, reason) { + const obj = this.#objs.getOrInsertComputed(objId, dataObj); + if (obj.data !== INITIAL_DATA) { + return; + } + // Make sure a rejection that lands before a consumer calls + // `get` doesn't surface as an unhandled rejection + obj.promise.catch(() => {}); + obj.reject(reason); + } + clear() { for (const { data } of this.#objs.values()) { data?.bitmap?.close(); // Release any `ImageBitmap` data. diff --git a/src/display/renderer_worker.js b/src/display/renderer_worker.js new file mode 100644 index 0000000000000..46cc3174cf40c --- /dev/null +++ b/src/display/renderer_worker.js @@ -0,0 +1,432 @@ +/* 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 { + 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 { initGPU } from "./webgpu.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({ + ownerDocument: globalThis, + }); + + static #objsMap = new Map(); + + static #renderTaskStates = new Map(); + + static { + // Worker thread (and not Node.js)? + if ( + typeof window === "undefined" && + !isNodeJS && + typeof self !== "undefined" && + /* isMessagePort = */ + typeof self.postMessage === "function" && + "onmessage" in self + ) { + this.initializeFromPort(self); + } + } + + // Merges `[id, canvasName, canvas]` tuples sent from the main thread into + // `map`, mirroring the tagging/matching convention `canvas.js` uses so a + // pre-transferred canvas can be found and reused instead of orphaned. + 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) { + objs = new PDFObjects(); + this.#objsMap.set(pageIndex, objs); + } + return objs; + } + + static #cleanupRenderTask(renderTaskId) { + const renderTaskState = this.#renderTaskStates.get(renderTaskId); + if (!renderTaskState) { + return; + } + renderTaskState.aborted = true; + renderTaskState.continueResolve?.(); + + renderTaskState.gfx?.endDrawing(); + this.#renderTaskStates.delete(renderTaskId); + } + + static #cleanupPage(pageIndex) { + 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, + 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]) => { + // 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); + }); + + handler.on("objFailed", ({ id, pageIndex, reason }) => { + const error = new Error(reason); + if (pageIndex === null) { + this.#commonObjs.reject(id, error); + return; + } + if (this.#cleanedPages.has(pageIndex)) { + return; + } + this.#getPageObjs(pageIndex).reject(id, error); + }); + } + + static setup(handler) { + 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); + }); + + 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, + enableWebGPU = 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); + + if (enableWebGPU) { + await initGPU(); + if (renderTaskState.aborted) { + return; + } + } + 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) { + 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..3ff2eeecdc764 100644 --- a/src/display/worker_options.js +++ b/src/display/worker_options.js @@ -16,8 +16,32 @@ 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. + * + * 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") { + 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/test/driver.js b/test/driver.js index 86374280c70b9..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`. @@ -571,6 +573,9 @@ class Driver { // Create a working canvas this.canvas = document.createElement("canvas"); + // Used as the render-target when testing rendering in worker, since a + // canvas can only be transferred once using `transferControlToOffscreen`. + this.renderCanvas = null; } run() { @@ -1201,8 +1206,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 +1240,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 +1288,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), 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 888eea22b6eac..3c64a62f02b57 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: false, + 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", { 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() 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,