diff --git a/external/dist/webpack.mjs b/external/dist/webpack.mjs index 07396966299f4..3a403b1ae0634 100644 --- a/external/dist/webpack.mjs +++ b/external/dist/webpack.mjs @@ -21,6 +21,10 @@ if (typeof window !== "undefined" && "Worker" in window) { new URL("./build/pdf.worker.mjs", import.meta.url), { type: "module" } ); + GlobalWorkerOptions.rendererSrc = new URL( + "./build/pdf.renderer.mjs", + import.meta.url + ).href; } export * from "./build/pdf.mjs"; diff --git a/gulpfile.mjs b/gulpfile.mjs index dca06a8d1fbb2..25feeccebd3b3 100644 --- a/gulpfile.mjs +++ b/gulpfile.mjs @@ -551,6 +551,24 @@ function createWorkerBundle(defines) { .pipe(webpack2Stream(workerFileConfig)); } +function createRendererWorkerBundle(defines) { + const rendererWorkerDefines = { + ...defines, + WORKER_THREAD: true, + }; + const rendererWorkerFileConfig = createWebpackConfig(rendererWorkerDefines, { + filename: rendererWorkerDefines.MINIFIED + ? "pdf.renderer.min.mjs" + : "pdf.renderer.mjs", + library: { + type: "module", + }, + }); + return gulp + .src("./src/pdf.renderer.js", { encoding: false }) + .pipe(webpack2Stream(rendererWorkerFileConfig)); +} + function createWebBundle(defines, options) { const viewerFileConfig = createWebpackConfig(defines, { filename: "viewer.mjs", @@ -1229,6 +1247,7 @@ function buildGeneric(defines, dir) { return ordered([ createMainBundle(defines).pipe(gulp.dest(dir + "build")), createWorkerBundle(defines).pipe(gulp.dest(dir + "build")), + createRendererWorkerBundle(defines).pipe(gulp.dest(dir + "build")), createSandboxBundle(defines).pipe(gulp.dest(dir + "build")), createWebBundle(defines).pipe(gulp.dest(dir + "web")), gulp @@ -1373,6 +1392,7 @@ function buildMinified(defines, dir) { return ordered([ createMainBundle(defines).pipe(gulp.dest(dir + "build")), createWorkerBundle(defines).pipe(gulp.dest(dir + "build")), + createRendererWorkerBundle(defines).pipe(gulp.dest(dir + "build")), createSandboxBundle(defines).pipe(gulp.dest(dir + "build")), createImageDecodersBundle({ ...defines, IMAGE_DECODERS: true }).pipe( gulp.dest(dir + "image_decoders") @@ -1498,6 +1518,9 @@ gulp.task( createWorkerBundle(defines).pipe( gulp.dest(MOZCENTRAL_CONTENT_DIR + "build") ), + createRendererWorkerBundle(defines).pipe( + gulp.dest(MOZCENTRAL_CONTENT_DIR + "build") + ), createWebBundle(defines).pipe( gulp.dest(MOZCENTRAL_CONTENT_DIR + "web") ), @@ -1603,6 +1626,9 @@ gulp.task( createWorkerBundle(defines).pipe( gulp.dest(CHROME_BUILD_CONTENT_DIR + "build") ), + createRendererWorkerBundle(defines).pipe( + gulp.dest(CHROME_BUILD_CONTENT_DIR + "build") + ), createSandboxBundle(defines).pipe( gulp.dest(CHROME_BUILD_CONTENT_DIR + "build") ), @@ -1805,7 +1831,7 @@ function buildLib(defines, dir) { gulp.src( [ "src/{core,display,shared}/**/*.js", - "src/{pdf,pdf.image_decoders,pdf.worker}.js", + "src/{pdf,pdf.image_decoders,pdf.worker,pdf.renderer}.js", ], { base: "src/", encoding: false, sourcemaps: enableSourceMaps } ), @@ -2639,6 +2665,7 @@ function buildInternalViewer(defines, dir) { return ordered([ createMainBundle(defines).pipe(gulp.dest(dir + "build")), createWorkerBundle(defines).pipe(gulp.dest(dir + "build")), + createRendererWorkerBundle(defines).pipe(gulp.dest(dir + "build")), createInternalViewerBundle(defines).pipe(gulp.dest(dir + "web")), preprocessHTML("web/internal/debugger.html", defines).pipe( gulp.dest(dir + "web") @@ -2857,8 +2884,10 @@ gulp.task( gulp .src( [ - GENERIC_DIR + "build/{pdf,pdf.worker,pdf.sandbox}.mjs", - GENERIC_DIR + "build/{pdf,pdf.worker,pdf.sandbox}.mjs.map", + GENERIC_DIR + + "build/{pdf,pdf.worker,pdf.sandbox,pdf.renderer}.mjs", + GENERIC_DIR + + "build/{pdf,pdf.worker,pdf.sandbox,pdf.renderer}.mjs.map", ], { encoding: false } ) @@ -2866,16 +2895,22 @@ gulp.task( gulp .src( [ - GENERIC_LEGACY_DIR + "build/{pdf,pdf.worker,pdf.sandbox}.mjs", - GENERIC_LEGACY_DIR + "build/{pdf,pdf.worker,pdf.sandbox}.mjs.map", + GENERIC_LEGACY_DIR + + "build/{pdf,pdf.worker,pdf.sandbox,pdf.renderer}.mjs", + GENERIC_LEGACY_DIR + + "build/{pdf,pdf.worker,pdf.sandbox,pdf.renderer}.mjs.map", ], { encoding: false } ) .pipe(gulp.dest(DIST_DIR + "legacy/build/")), gulp - .src(MINIFIED_DIR + "build/{pdf,pdf.worker,pdf.sandbox}.min.mjs", { - encoding: false, - }) + .src( + MINIFIED_DIR + + "build/{pdf,pdf.worker,pdf.sandbox,pdf.renderer}.min.mjs", + { + encoding: false, + } + ) .pipe(gulp.dest(DIST_DIR + "build/")), gulp .src(MINIFIED_DIR + "image_decoders/pdf.image_decoders.min.mjs", { @@ -2884,7 +2919,8 @@ gulp.task( .pipe(gulp.dest(DIST_DIR + "image_decoders/")), gulp .src( - MINIFIED_LEGACY_DIR + "build/{pdf,pdf.worker,pdf.sandbox}.min.mjs", + MINIFIED_LEGACY_DIR + + "build/{pdf,pdf.worker,pdf.sandbox,pdf.renderer}.min.mjs", { encoding: false } ) .pipe(gulp.dest(DIST_DIR + "legacy/build/")), diff --git a/src/core/document.js b/src/core/document.js index a2eaa073729de..23181059f3143 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -564,6 +564,7 @@ class Page { resources, this.nonBlendModesSet ), + hasCanvasFilters: partialEvaluator.hasCanvasFilters(resources), pageIndex, cacheKey, }); diff --git a/src/core/evaluator.js b/src/core/evaluator.js index 97736b6c030e0..9fa633cceff08 100644 --- a/src/core/evaluator.js +++ b/src/core/evaluator.js @@ -398,6 +398,121 @@ class PartialEvaluator { return false; } + _hasTransferMaps(transferObj) { + let transferArray; + if (Array.isArray(transferObj)) { + transferArray = transferObj; + } else if (isPDFFunction(transferObj)) { + transferArray = [transferObj]; + } else { + return false; + } + + const numFns = transferArray.length; + if (!(numFns === 1 || numFns === 4)) { + return false; + } + + let numEffectfulFns = 0; + for (const entry of transferArray) { + const transfer = this.xref.fetchIfRef(entry); + if (isName(transfer, "Identity")) { + continue; + } + if (!isPDFFunction(transfer)) { + return false; + } + numEffectfulFns++; + } + return numEffectfulFns > 0; + } + + hasCanvasFilters(resources) { + if (!(resources instanceof Dict)) { + return false; + } + + const processed = new RefSet(); + if (resources.objId) { + processed.put(resources.objId); + } + const xref = this.xref; + const nodes = [resources]; + while (nodes.length) { + const node = nodes.shift(); + + const graphicStates = node.get("ExtGState"); + if (graphicStates instanceof Dict) { + for (let graphicState of graphicStates.getRawValues()) { + if (graphicState instanceof Ref) { + if (processed.has(graphicState)) { + continue; + } + try { + graphicState = xref.fetch(graphicState); + } catch (ex) { + info(`hasCanvasFilters - failed to fetch ExtGState: "${ex}".`); + // A fetch failure means we can't inspect the resource, so fall + // back to main-thread rendering rather than misclassify a corrupt + // PDF as filter-free. + return true; + } + } + if (!(graphicState instanceof Dict)) { + continue; + } + if (graphicState.objId) { + processed.put(graphicState.objId); + } + try { + if (this._hasTransferMaps(graphicState.get("TR"))) { + return true; + } + } catch (ex) { + info(`hasCanvasFilters - failed to inspect filter data: "${ex}".`); + return true; + } + } + } + + const xObjects = node.get("XObject"); + if (xObjects instanceof Dict) { + for (let xObject of xObjects.getRawValues()) { + if (xObject instanceof Ref) { + if (processed.has(xObject)) { + continue; + } + try { + xObject = xref.fetch(xObject); + } catch (ex) { + info(`hasCanvasFilters - failed to fetch XObject: "${ex}".`); + return true; + } + } + if (!(xObject instanceof BaseStream)) { + continue; + } + if (xObject.dict.objId) { + processed.put(xObject.dict.objId); + } + const xResources = xObject.dict.get("Resources"); + if (!(xResources instanceof Dict)) { + continue; + } + if (xResources.objId && processed.has(xResources.objId)) { + continue; + } + + nodes.push(xResources); + if (xResources.objId) { + processed.put(xResources.objId); + } + } + } + } + return false; + } + async fetchBuiltInCMap(name) { const cachedData = this.builtInCMapCache.get(name); if (cachedData) { diff --git a/src/display/api.js b/src/display/api.js index e5ac3f3abe2c6..fd4bb42c45f0f 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,8 @@ const RENDERING_CANCELLED_TIMEOUT = 100; // ms * @property {Object} [pagesMapper] - The pages mapper that will be used to map * page ids and page numbers. It's used when the page order is changed or some * pages are removed, cloned, etc. + * @property {boolean} [disableWorkerRendering] - Disables rendering of pages in + * a worker thread. The default value is `false`. */ /** @@ -308,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" @@ -327,12 +335,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 +366,9 @@ function getDocument(src = {}) { }); task._worker = worker; } + if (!disableWorkerRendering) { + task._rendererWorker = new RendererWorker({ verbosity }); + } const docParams = { docId, @@ -395,13 +406,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 +477,10 @@ function getDocument(src = {}) { messageHandler, task, networkStream, - transportParams, + { + ...transportParams, + rendererWorker: task._rendererWorker, + }, transportFactory, pagesMapper ); @@ -514,6 +540,11 @@ class PDFDocumentLoadingTask { */ _worker = null; + /** + * @private + */ + _rendererWorker = null; + /** * Unique identifier for the document loading task. * @type {string} @@ -587,6 +618,9 @@ class PDFDocumentLoadingTask { this._worker?.destroy(); this._worker = null; + + this._rendererWorker?.destroy(); + this._rendererWorker = null; } /** @@ -1293,6 +1327,8 @@ class PDFDocumentProxy { * Proxy to a `PDFPage` in the worker thread. */ class PDFPageProxy { + #keepRendererCanvas = false; + #pendingCleanup = false; #pagesMapper = null; @@ -1481,6 +1517,7 @@ class PDFPageProxy { cacheKey, makeObj ); + // Ensure that a pending `streamReader` cancel timeout is always aborted. if (intentState.streamReaderCancelTimeout) { clearTimeout(intentState.streamReaderCancelTimeout); @@ -1517,14 +1554,25 @@ class PDFPageProxy { const complete = error => { intentState.renderTasks.delete(internalRenderTask); + // Get the trackers from `gfx` into the task's. The worker path populates + // them from the final `ExecuteOperatorList` response, so we don't need + // this in that case. + if (!internalRenderTask.rendererHandler && internalRenderTask.gfx) { + const { dependencyTracker, imagesTracker } = internalRenderTask.gfx; + internalRenderTask.recordedBBoxes = dependencyTracker?.take() ?? null; + internalRenderTask.debugMetadata = recordForDebugger + ? (dependencyTracker?.takeDebugMetadata() ?? null) + : null; + internalRenderTask.imageCoordinates = imagesTracker?.take() ?? null; + } + if (shouldRecordOperations) { - const recordedBBoxes = internalRenderTask.gfx?.dependencyTracker.take(); + const { recordedBBoxes, debugMetadata } = internalRenderTask; if (recordedBBoxes) { internalRenderTask.stepper?.setOperatorBBoxes( recordedBBoxes, - internalRenderTask.gfx.dependencyTracker.takeDebugMetadata() + debugMetadata ); - if (recordOperations) { this.recordedBBoxes = recordedBBoxes; } @@ -1532,7 +1580,7 @@ class PDFPageProxy { } if (shouldRecordImages && !error) { - this.imageCoordinates = internalRenderTask.gfx?.imagesTracker.take(); + this.imageCoordinates = internalRenderTask.imageCoordinates; } // Attempt to reduce memory usage during *printing*, by always running @@ -1563,34 +1611,18 @@ class PDFPageProxy { } }; - let dependencyTracker = null; - let bboxTracker = null; - if (shouldRecordOperations || shouldRecordImages) { - bboxTracker = new CanvasBBoxTracker( - canvas, - intentState.operatorList.length - ); - } - if (shouldRecordOperations) { - dependencyTracker = new CanvasDependencyTracker( - bboxTracker, - recordForDebugger - ); - } - const internalRenderTask = new InternalRenderTask({ callback: complete, // Only include the required properties, and *not* the entire object. params: { canvas, canvasContext, - dependencyTracker: dependencyTracker ?? bboxTracker, - imagesTracker: shouldRecordImages - ? new CanvasImagesTracker(canvas) - : null, viewport, transform, background, + recordOperations: shouldRecordOperations, + recordImages: shouldRecordImages, + recordForDebugger, }, objs: this.objs, commonObjs: this.commonObjs, @@ -1603,7 +1635,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); @@ -1613,7 +1647,7 @@ class PDFPageProxy { intentState.displayReadyCapability.promise, optionalContentConfigPromise, ]) - .then(([transparency, optionalContentConfig]) => { + .then(async ([renderPageData, optionalContentConfig]) => { if (this.destroyed) { complete(); return; @@ -1626,8 +1660,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(); @@ -1785,6 +1824,9 @@ class PDFPageProxy { } } this.objs.clear(); + this._transport.rendererHandler?.send("cleanupPage", { + pageIndex: this._pageIndex, + }); this.#pendingCleanup = false; return Promise.all(waitOn); @@ -1795,10 +1837,13 @@ class PDFPageProxy { * * @param {boolean} [resetStats] - Reset page stats, if enabled. * The default value is `false`. + * @param {boolean} [keepRendererCanvas] - When true, keeps the + * OffscreenCanvas reference alive in the renderer worker. * @returns {boolean} Indicates if clean-up was successfully run. */ - cleanup(resetStats = false) { + cleanup(resetStats = false, keepRendererCanvas = false) { this.#pendingCleanup = true; + this.#keepRendererCanvas = keepRendererCanvas; const success = this.#tryCleanup(); if (resetStats && success) { @@ -1822,6 +1867,11 @@ class PDFPageProxy { } this._intentStates.clear(); this.objs.clear(); + this._transport.rendererHandler?.send("cleanupPage", { + pageIndex: this._pageIndex, + keepCanvas: this.#keepRendererCanvas, + }); + this.#keepRendererCanvas = false; this.#pendingCleanup = false; return true; } @@ -1829,16 +1879,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, + }); } /** @@ -1880,6 +1934,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", { @@ -2014,6 +2075,172 @@ 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(); + + #rendererHandler = null; + + #worker = 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 rendererHandler() { + return this.#rendererHandler; + } + + #resolve() { + this.#capability.resolve(); + // Send global setting, e.g. verbosity level. + this.#rendererHandler.send("configure", { + verbosity: this.verbosity, + }); + } + + #initialize() { + if (typeof Worker === "undefined") { + this.#capability.reject( + new Error("Renderer worker requires Worker support.") + ); + return; + } + let { rendererSrc } = RendererWorker; + + try { + // Wraps rendererSrc path into blob URL, if the former does not belong + // to the same origin. + if ( + typeof PDFJSDev !== "undefined" && + PDFJSDev.test("GENERIC") && + !PDFWorker._isSameOrigin(window.location, rendererSrc) + ) { + rendererSrc = PDFWorker._createCDNWrapper( + new URL(rendererSrc, window.location).href + ); + } + const worker = new Worker(rendererSrc, { type: "module" }); + const rendererHandler = new MessageHandler("main", "renderer", worker); + + rendererHandler.on("ready", () => { + ac.abort(); + if (this.destroyed) { + terminateEarly("Worker was destroyed."); + return; + } + try { + sendTest(); + } catch (reason) { + terminateEarly(reason); + } + }); + + const terminateEarly = reason => { + ac.abort(); + rendererHandler.destroy(); + worker.terminate(); + + this.#capability.reject( + new Error( + `Renderer worker failed to initialize: "${reason?.message ?? reason}".` + ) + ); + }; + + const ac = new AbortController(); + worker.addEventListener( + "error", + event => { + if (!this.#worker) { + // Worker failed to initialize due to an error. + terminateEarly(event.error || event.message); + } + }, + { signal: ac.signal } + ); + + rendererHandler.on("test", data => { + ac.abort(); + if (this.destroyed || !data) { + terminateEarly("TypedArray transfer test failed."); + return; + } + this.#rendererHandler = rendererHandler; + this.#worker = worker; + + this.#resolve(); + }); + + const sendTest = () => { + const testObj = new Uint8Array(); + // Ensure that we can use `postMessage` transfers. + rendererHandler.send("test", testObj, [testObj.buffer]); + }; + + // It might take time for the worker to initialize. We will try to send + // the "test" message immediately, and once the "ready" message arrives. + // The worker shall process only the first received "test" message. + sendTest(); + } catch (reason) { + this.#capability.reject(reason); + } + } + + /** + * Destroys the worker instance. + */ + destroy() { + this.destroyed = true; + + // We need to terminate only web worker created resource. + this.#worker?.terminate(); + this.#worker = null; + + this.#rendererHandler?.destroy(); + this.#rendererHandler = null; + } + + /** + * The current `rendererSrc`, when it exists. + * @type {string} + */ + static get rendererSrc() { + if (GlobalWorkerOptions.rendererSrc) { + return GlobalWorkerOptions.rendererSrc; + } + throw new Error('No "GlobalWorkerOptions.rendererSrc" specified.'); + } +} + /** * @typedef {Object} PDFWorkerParameters * @property {string} [name] - The name of the worker. @@ -2404,6 +2631,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; @@ -2469,6 +2698,15 @@ class WorkerTransport { return shadow(this, "annotationStorage", new AnnotationStorage()); } + /** + * Reading through the `RendererWorker` ensures that destroying the renderer + * worker is observed here, without holding a stale handler reference. + * @type {MessageHandler | null} + */ + get rendererHandler() { + return this.rendererWorker?.rendererHandler ?? null; + } + getRenderingIntent( intent, annotationMode = AnnotationMode.ENABLE, @@ -2582,6 +2820,7 @@ class WorkerTransport { this.messageHandler?.destroy(); this.messageHandler = null; + this.rendererWorker = null; this.destroyCapability.resolve(); }, this.destroyCapability.reject); @@ -2750,90 +2989,59 @@ class WorkerTransport { } const page = this.#pageCache.get(data.pageIndex); - page._startRenderPage(data.transparency, data.cacheKey); + page._startRenderPage( + data.transparency, + data.cacheKey, + data.hasCanvasFilters + ); }); - messageHandler.on("commonobj", ([id, type, exportedData]) => { + 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; // Ignore any pending requests if the worker was terminated. + return null; } + return messageHandler.sendWithPromise("FontFallback", data); + }); - if (this.commonObjs.has(id)) { - return null; + 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, + }); + } + }; - switch (type) { - case "Font": - if ("error" in exportedData) { - const exportedError = exportedData.error; - warn(`Error during font loading: ${exportedError}`); - this.commonObjs.resolve(id, exportedError); - break; - } + messageHandler.on("commonobj", ([id, type, exportedData]) => { + if (this.destroyed) { + return null; // Ignore any pending requests if the worker was terminated. + } - 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]) => { @@ -2841,25 +3049,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 => { @@ -3161,7 +3352,9 @@ class WorkerTransport { await this.messageHandler.sendWithPromise("Cleanup", null); for (const page of this.#pageCache.values()) { - const cleanupSuccessful = page.cleanup(); + // Keep the OffscreenCanvas reference alive in the renderer worker + // during idle cleanup. + const cleanupSuccessful = page.cleanup(false, !!this.rendererHandler); if (!cleanupSuccessful) { throw new Error( @@ -3173,6 +3366,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(); @@ -3267,6 +3463,13 @@ class RenderTask { get imageCoordinates() { return this._internalRenderTask.imageCoordinates || null; } + + /** + * @type {MessageHandler | null} + */ + get rendererHandler() { + return this._internalRenderTask.rendererHandler; + } } /** @@ -3278,6 +3481,8 @@ class InternalRenderTask { static #canvasInUse = new WeakSet(); + static #renderTaskId = 0; + constructor({ callback, params, @@ -3292,7 +3497,9 @@ class InternalRenderTask { pdfBug = false, pageColors = null, enableHWA = false, + enableWebGPU = false, operationsFilter = null, + rendererWorker = null, }) { this.callback = callback; this.params = params; @@ -3323,9 +3530,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() { @@ -3335,7 +3555,82 @@ class InternalRenderTask { }); } - initializeGraphics({ transparency = false, optionalContentConfig }) { + get rendererHandler() { + return this._rendererWorker?.rendererHandler ?? 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; } @@ -3355,41 +3650,136 @@ 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, - }); + const { viewport, transform, background } = this.params; + + // The stepper-driven debug recording path needs `gfx` on the main thread, + // so we have to fall back to local rendering when it's enabled. Plain + // `recordOperations`/`recordImages` are now handled inside the worker. + // Worker rendering is also disabled when canvas filters (TR) are present + // because OffscreenCanvas's OffscreenCanvasRenderingContext2D ignores + // `.filter` values set from a data URL. See bug 2011237. + let useWorkerRendering = + this.rendererHandler && + this._canvasContext === null && + !hasCanvasFilters && + !this.pageColors && + !this._recordForDebugger; + + 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 { + const offscreen = this._canvas.transferControlToOffscreen(); + const { annotationCanvases, transfers } = + this._getAnnotationCanvasFromOpList( + 0, + this.operatorList.argsArray.length + ); + const initTransfers = [offscreen, ...transfers]; + const initParams = { + canvas: offscreen, + pageIndex: this._pageIndex, + renderTaskId: this._renderTaskId, + enableHWA: this._enableHWA, + 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.rendererHandler?.send("CleanupRenderTask", { + renderTaskId: _renderTaskId, + }); + }; + } catch (ex) { + warn( + `Failed to initialize graphics in renderer worker: ${ex.message}. ` + + "Falling back to main-thread rendering." + ); + // Fallback to regular rendering. Only safe for synchronous failures + // before transferControlToOffscreen detaches the canvas; an async + // rejection from sendWithPromise is awaited below and propagates. + this._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, + }); - this.gfx = new CanvasGraphics( - canvasContext, - this.commonObjs, - this.objs, - this.canvasFactory, - this.filterFactory, - { optionalContentConfig }, - this.annotationCanvasMap, - this.pageColors, - dependencyTracker, - imagesTracker - ); - this.gfx.beginDrawing({ - transform, - viewport, - transparency, - background, - }); + 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?.(); @@ -3398,6 +3788,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); @@ -3419,11 +3812,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; } @@ -3457,10 +3855,86 @@ 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; + const response = await rendererHandler.sendWithPromise( + "ExecuteOperatorList", + { + renderTaskId: this._renderTaskId, + fnArray, + argsArray, + operatorListIdx, + operationsFilter: + typeof this._operationsFilter === "function" + ? null + : this._operationsFilter, + 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 ); @@ -3490,6 +3964,7 @@ export { PDFDocumentProxy, PDFPageProxy, PDFWorker, + RendererWorker, RenderTask, version, }; diff --git a/src/display/canvas.js b/src/display/canvas.js index 01a9a2f7d7d09..ed0939350a1a0 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; } @@ -3678,29 +3697,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 1cdefc715f489..8902b29301ad3 100644 --- a/src/display/canvas_dependency_tracker.js +++ b/src/display/canvas_dependency_tracker.js @@ -70,6 +70,17 @@ class BBoxReader { this.#coords = coords; } + static fromBuffer(buffer) { + return new BBoxReader( + new Uint32Array(buffer), + new Uint8ClampedArray(buffer) + ); + } + + get buffer() { + return this.#bboxes.buffer; + } + get length() { return this.#bboxes.length; } @@ -1257,6 +1268,7 @@ class CanvasImagesTracker { } export { + BBoxReader, CanvasBBoxTracker, CanvasDependencyTracker, CanvasImagesTracker, diff --git a/src/display/canvas_factory.js b/src/display/canvas_factory.js index 16c0e55fabf94..930a8835f9cd3 100644 --- a/src/display/canvas_factory.js +++ b/src/display/canvas_factory.js @@ -89,4 +89,13 @@ class DOMCanvasFactory extends BaseCanvasFactory { } } -export { BaseCanvasFactory, DOMCanvasFactory }; +class OffscreenCanvasFactory extends BaseCanvasFactory { + /** + * @ignore + */ + _createCanvas(width, height) { + return new OffscreenCanvas(width, height); + } +} + +export { BaseCanvasFactory, DOMCanvasFactory, OffscreenCanvasFactory }; diff --git a/src/display/filter_factory.js b/src/display/filter_factory.js index 430cb697ef757..fecf40768ee76 100644 --- a/src/display/filter_factory.js +++ b/src/display/filter_factory.js @@ -90,6 +90,8 @@ class BaseFilterFactory { destroy(keepHCM = false) {} } +class WorkerFilterFactory extends BaseFilterFactory {} + /** * FilterFactory aims to create some SVG filters we can use when drawing an * image (or whatever) on a canvas. @@ -697,4 +699,4 @@ function blend(fg, bg, alpha) { return Math.round(alpha * fg + (1 - alpha) * bg); } -export { BaseFilterFactory, DOMFilterFactory }; +export { BaseFilterFactory, DOMFilterFactory, WorkerFilterFactory }; diff --git a/src/display/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 d0434a547d352..59e8c342c819a 100644 --- a/src/display/pattern_helper.js +++ b/src/display/pattern_helper.js @@ -699,7 +699,11 @@ class TilingPattern { this.clipBbox(owner, x0, y0, x1, y1); owner.baseTransformStack.push(owner.baseTransform); owner.baseTransform = getCurrentTransform(owner.ctx); + // The nested execution swaps in the pattern's `pathCache`; restore the + // outer operator list's cache afterwards. + const prevPathCache = owner._pathCache; owner.executeOperatorList(this.operatorList); + owner._pathCache = prevPathCache; owner.baseTransform = owner.baseTransformStack.pop(); } diff --git a/src/display/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..ea7a050709591 --- /dev/null +++ b/src/display/renderer_worker.js @@ -0,0 +1,393 @@ +/* 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 { + static #commonObjs = new PDFObjects(); + + static #objsMap = new Map(); + + static #renderTaskStates = new Map(); + + // Holds references to `OffscreenCanvas` instances transferred from the + // main thread. This is used to preserve the placeholder `` bitmap + // across page cleanup (e.g. during scrolling/idle cleanup in the viewer). + static #canvasMap = new Map(); + + static #cleanedPages = new Set(); + + // Merges `[id, canvasName, canvas]` tuples sent from the main thread into + // `map`, mirroring the tagging/matching convention `canvas.js` uses so a + // pre-transferred canvas can be found and reused instead of orphaned. + static #mergeAnnotationCanvases(map, tuples) { + for (const [id, canvasName, canvas] of tuples) { + if (!canvasName) { + map.set(id, canvas); + continue; + } + canvas._pdfjsCanvasName = canvasName; + let canvases = map.get(id); + if (!Array.isArray(canvases)) { + canvases = []; + map.set(id, canvases); + } + const index = canvases.findIndex(c => c._pdfjsCanvasName === canvasName); + if (index === -1) { + canvases.push(canvas); + } else { + canvases[index] = canvas; + } + } + } + + static #fontLoader = new FontLoader({ + ownerDocument: globalThis, + }); + + static { + // Worker thread (and not Node.js)? + if ( + typeof window === "undefined" && + !isNodeJS && + typeof self !== "undefined" && + /* isMessagePort = */ + typeof self.postMessage === "function" && + "onmessage" in self + ) { + this.initializeFromPort(self); + } + } + + static initializeFromPort(port) { + const handler = new MessageHandler("renderer", "main", port); + this.setup(handler); + handler.send("ready", null); + } + + static #getPageObjs(pageIndex) { + let objs = this.#objsMap.get(pageIndex); + if (!objs) { + objs = new PDFObjects(); + this.#objsMap.set(pageIndex, objs); + } + return objs; + } + + static #cleanupRenderTask(renderTaskId) { + const renderTaskState = this.#renderTaskStates.get(renderTaskId); + if (!renderTaskState) { + return; + } + renderTaskState.aborted = true; + renderTaskState.continueResolve?.(); + + renderTaskState.gfx?.endDrawing(); + this.#renderTaskStates.delete(renderTaskId); + } + + static #cleanupPage(pageIndex, keepCanvas = false) { + this.#cleanedPages.add(pageIndex); + this.#objsMap.delete(pageIndex); + for (const [renderTaskId, renderTaskState] of this.#renderTaskStates) { + if (renderTaskState.pageIndex === pageIndex) { + this.#cleanupRenderTask(renderTaskId); + } + } + if (!keepCanvas) { + this.#canvasMap.delete(pageIndex); + } + } + + static #appendOperatorList(renderTaskState, fnArray, argsArray, lastChunk) { + const { operatorList } = renderTaskState; + if (fnArray) { + for (let i = 0, ii = fnArray.length; i < ii; i++) { + operatorList.fnArray.push(fnArray[i]); + operatorList.argsArray.push(argsArray[i]); + } + } + operatorList.lastChunk = lastChunk; + renderTaskState.gfx.dependencyTracker?.growOperationsCount( + operatorList.fnArray.length + ); + } + + static async #executeOperatorList(renderTaskState, operationsFilter) { + const { operatorList, gfx } = renderTaskState; + 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, keepCanvas }) => { + this.#cleanupPage(pageIndex, keepCanvas); + }); + + handler.on("restorePage", ({ pageIndex }) => { + this.#cleanedPages.delete(pageIndex); + }); + + // Mirrors the document-level cleanup the main thread performs in + // `WorkerTransport.startCleanup`; without this the worker's copies of + // `commonObjs`/`fontLoader` would outlive their main-thread counterparts. + handler.on("Cleanup", ({ keepLoadedFonts }) => { + this.#commonObjs.clear(); + if (!keepLoadedFonts) { + this.#fontLoader.clear(); + } + }); + + handler.on("CleanupRenderTask", ({ renderTaskId }) => { + this.#cleanupRenderTask(renderTaskId); + }); + + handler.on("InitializeGraphics", async data => { + const { + canvas, + pageIndex, + renderTaskId, + enableHWA = false, + enableWebGPU = false, + annotationCanvasMap, + transform, + viewport, + transparency, + background, + recordOperations = false, + recordImages = false, + } = data; + if (enableWebGPU) { + await initGPU(); + } + const objs = this.#getPageObjs(pageIndex); + const optionalContentConfig = OptionalContentConfig.fromSerializable( + data.optionalContentConfig + ); + + const ctx = canvas.getContext("2d", { + alpha: false, + willReadFrequently: !enableHWA, + }); + const canvasFactory = new OffscreenCanvasFactory({ enableHWA }); + const filterFactory = new WorkerFilterFactory(); + const annotationCanvases = annotationCanvasMap ? new Map() : 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, + }); + + // Keep a strong reference to the OffscreenCanvas so the placeholder + // `` can continue to display the last rendered output after + // `cleanupPage` (when `keepCanvas` is true). + this.#canvasMap.set(pageIndex, canvas); + + this.#renderTaskStates.set(renderTaskId, { + pageIndex, + gfx, + operatorList: { + fnArray: [], + argsArray: [], + lastChunk: false, + }, + operatorListIdx: 0, + continueResolve: null, + aborted: false, + }); + }); + + handler.on("UpdateAnnotationCanvases", data => { + const { renderTaskId, annotationCanvasMap } = data; + if (!annotationCanvasMap) { + return; + } + const renderTaskState = this.#renderTaskStates.get(renderTaskId); + if (!renderTaskState || !renderTaskState.gfx.annotationCanvasMap) { + return; + } + this.#mergeAnnotationCanvases( + renderTaskState.gfx.annotationCanvasMap, + annotationCanvasMap + ); + }); + + handler.on("ExecuteOperatorList", async data => { + const { + renderTaskId, + fnArray, + argsArray, + operatorListIdx, + operationsFilter, + lastChunk, + } = data; + const renderTaskState = this.#renderTaskStates.get(renderTaskId); + if (!renderTaskState) { + // A render task can be cleaned up before queued + // ExecuteOperatorList messages for that task are processed. + return { operatorListIdx }; + } + + renderTaskState.operatorListIdx = operatorListIdx; + this.#appendOperatorList(renderTaskState, fnArray, argsArray, lastChunk); + + const currentOperatorListIdx = await this.#executeOperatorList( + renderTaskState, + operationsFilter + ); + + 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, + }; + }); + } +} + +export { RendererMessageHandler }; diff --git a/src/display/worker_options.js b/src/display/worker_options.js index e4bbb81a6ea45..ff479f8448e05 100644 --- a/src/display/worker_options.js +++ b/src/display/worker_options.js @@ -18,6 +18,8 @@ class GlobalWorkerOptions { static #src = ""; + static #rendererSrc = ""; + /** * @type {Worker | null} */ @@ -59,6 +61,24 @@ class GlobalWorkerOptions { } this.#src = val; } + + /** + * @type {string} + */ + static get rendererSrc() { + return this.#rendererSrc; + } + + /** + * @param {string} rendererSrc - A string containing the path and + * filename of the renderer worker file. + */ + static set rendererSrc(val) { + if (typeof val !== "string") { + throw new Error("Invalid `rendererSrc` type."); + } + this.#rendererSrc = val; + } } export { GlobalWorkerOptions }; diff --git a/src/pdf.renderer.js b/src/pdf.renderer.js new file mode 100644 index 0000000000000..7632c53257476 --- /dev/null +++ b/src/pdf.renderer.js @@ -0,0 +1,18 @@ +/* Copyright 2026 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RendererMessageHandler } from "./display/renderer_worker.js"; + +export { RendererMessageHandler }; diff --git a/test/driver.js b/test/driver.js index 473f1ef810cec..ffe6929ab3466 100644 --- a/test/driver.js +++ b/test/driver.js @@ -41,6 +41,7 @@ const IMAGE_RESOURCES_PATH = "/web/images/"; const VIEWER_CSS = "../build/components/pdf_viewer.css"; const VIEWER_LOCALE = "en-US"; const WORKER_SRC = "../build/generic/build/pdf.worker.mjs"; +const RENDERER_SRC = "../build/generic/build/pdf.renderer.mjs"; const RENDER_TASK_ON_CONTINUE_DELAY = 5; // ms const SVG_NS = "http://www.w3.org/2000/svg"; @@ -534,6 +535,7 @@ class Driver { constructor(options) { // Configure the global worker options. GlobalWorkerOptions.workerSrc = WORKER_SRC; + GlobalWorkerOptions.rendererSrc = RENDERER_SRC; // We only need to initialize the `L10n`-instance here, since translation is // triggered by a `MutationObserver`; see e.g. `Rasterize.annotationLayer`. @@ -571,6 +573,9 @@ class Driver { // Create a working canvas this.canvas = document.createElement("canvas"); + // Used as the render-target when testing rendering in worker, since a + // canvas can only be transferred once using `transferControlToOffscreen`. + this.renderCanvas = null; } run() { @@ -1204,8 +1209,23 @@ class Driver { initPromise = Promise.resolve(); } } + // Render into a separate canvas to allow + // `transferControlToOffscreen` + if (partialCrop) { + // Rendering directly into `this.canvas` is required to support + // `recordOperations` and cropping operations. + this.renderCanvas = this.canvas; + } else { + this.renderCanvas = document.createElement("canvas"); + this.renderCanvas.width = pixelWidth; + this.renderCanvas.height = pixelHeight; + this.renderCanvas.style.width = this.canvas.style.width; + this.renderCanvas.style.height = this.canvas.style.height; + } + const renderCanvas = this.renderCanvas; + const renderContext = { - canvas: this.canvas, + canvas: renderCanvas, viewport, optionalContentConfigPromise: task.optionalContentConfigPromise, annotationCanvasMap, @@ -1227,6 +1247,14 @@ class Driver { } const completeRender = error => { + if (renderCanvas !== this.canvas) { + try { + ctx.drawImage(renderCanvas, 0, 0); + } catch (ex) { + this._info(`Unable to copy the render canvas: ${ex}`); + } + renderCanvas.resetWorkerCanvas?.(); + } // if text layer is present, compose it on top of the page if (textLayerCanvas) { if (task.type === "text") { @@ -1267,6 +1295,7 @@ class Driver { await renderTask.promise; if (partialCrop) { + ctx = this.canvas.getContext("2d", { alpha: false }); const clearOutsidePartial = () => { const { width, height } = ctx.canvas; // Everything above the partial area diff --git a/test/integration/test_utils.mjs b/test/integration/test_utils.mjs index 8aab655d6bc63..67e0434d87f60 100644 --- a/test/integration/test_utils.mjs +++ b/test/integration/test_utils.mjs @@ -988,12 +988,34 @@ function waitForTooltipToBe(page, selector, text) { function isCanvasMonochrome(page, pageNumber, rectangle, color) { return page.evaluate( - (rect, pageN, col) => { + async (rect, pageN, col) => { const canvas = document.querySelector( `.page[data-page-number = "${pageN}"] .canvasWrapper canvas` ); + if (!canvas) { + return false; + } const canvasRect = canvas.getBoundingClientRect(); - const ctx = canvas.getContext("2d"); + let ctx; + try { + ctx = canvas.getContext("2d", { willReadFrequently: true }); + } catch { + // Happens when the canvas has been transferred to OffscreenCanvas. + } + if (!ctx) { + const bitmap = await createImageBitmap(canvas); + let tempCanvas; + if (typeof OffscreenCanvas === "function") { + tempCanvas = new OffscreenCanvas(canvas.width, canvas.height); + } else { + tempCanvas = document.createElement("canvas"); + tempCanvas.width = canvas.width; + tempCanvas.height = canvas.height; + } + ctx = tempCanvas.getContext("2d", { willReadFrequently: true }); + ctx.drawImage(bitmap, 0, 0); + bitmap.close(); + } rect ||= canvasRect; const { data } = ctx.getImageData( rect.x - canvasRect.x, diff --git a/test/integration/viewer_spec.mjs b/test/integration/viewer_spec.mjs index a7ff8b7af758d..e6bc785aa8d1c 100644 --- a/test/integration/viewer_spec.mjs +++ b/test/integration/viewer_spec.mjs @@ -544,23 +544,74 @@ describe("PDF viewer", () => { }; } - function extractCanvases(pageNumber) { + async function extractCanvases(pageNumber) { const pageOne = document.querySelector( `.page[data-page-number='${pageNumber}']` ); - return Array.from(pageOne.querySelectorAll("canvas"), canvas => { + async function getContextFromCanvas(canvas) { + try { + return canvas.getContext("2d", { willReadFrequently: true }); + } catch { + // Can happen when the canvas has been transferred to OffscreenCanvas. + } + + const bitmap = await createImageBitmap(canvas); + let tempCanvas; + if (typeof OffscreenCanvas === "function") { + tempCanvas = new OffscreenCanvas(canvas.width, canvas.height); + } else { + tempCanvas = document.createElement("canvas"); + tempCanvas.width = canvas.width; + tempCanvas.height = canvas.height; + } + const tempCtx = tempCanvas.getContext("2d", { + willReadFrequently: true, + }); + tempCtx.drawImage(bitmap, 0, 0); + bitmap.close(); + return tempCtx; + } + + if (!pageOne) { + return []; + } + + const canvases = pageOne.querySelectorAll("canvas"); + const results = []; + for (const canvas of canvases) { const { width, height } = canvas; - const ctx = canvas.getContext("2d"); - const topLeft = ctx.getImageData(2, 2, 1, 1).data; - const bottomRight = ctx.getImageData(width - 3, height - 3, 1, 1).data; - return { + if (width === 0 || height === 0) { + results.push({ + size: 0, + width, + height, + topLeft: null, + bottomRight: null, + }); + continue; + } + const ctx = await getContextFromCanvas(canvas); + const topLeft = ctx.getImageData( + Math.min(2, width - 1), + Math.min(2, height - 1), + 1, + 1 + ).data; + const bottomRight = ctx.getImageData( + Math.max(0, width - 3), + Math.max(0, height - 3), + 1, + 1 + ).data; + results.push({ size: width * height, width, height, topLeft: globalThis.pdfjsLib.Util.makeHexColor(...topLeft), bottomRight: globalThis.pdfjsLib.Util.makeHexColor(...bottomRight), - }; - }); + }); + } + return results; } function waitForDetailRendered(page) { @@ -579,6 +630,35 @@ describe("PDF viewer", () => { }); } + // Wait for canvas pixels to be available + function waitForCanvasPixels(page, pageNumber, canvasIndex = 1) { + return page.waitForFunction( + async (pgNum, idx) => { + const pageEl = document.querySelector( + `.page[data-page-number='${pgNum}']` + ); + if (!pageEl) { + return false; + } + const canvas = pageEl.querySelectorAll("canvas")[idx]; + if (!canvas || canvas.width === 0 || canvas.height === 0) { + return false; + } + const bitmap = await createImageBitmap(canvas, 2, 2, 1, 1); + const tmp = document.createElement("canvas"); + tmp.width = tmp.height = 1; + const ctx = tmp.getContext("2d"); + ctx.drawImage(bitmap, 0, 0); + bitmap.close(); + const { data } = ctx.getImageData(0, 0, 1, 1); + return data[0] !== 0 || data[1] !== 0 || data[2] !== 0; + }, + { timeout: 5000 }, + pageNumber, + canvasIndex + ); + } + for (const pixelRatio of [1, 2]) { describe(`with pixel ratio ${pixelRatio}`, () => { describe("setupPages()", () => { @@ -620,6 +700,7 @@ describe("PDF viewer", () => { }); }, factor); await awaitPromise(handle); + await waitForCanvasPixels(page, 1); const after = await page.evaluate(extractCanvases, 1); // The page dimensions are 595x841, so the base canvas is a scale @@ -660,6 +741,10 @@ describe("PDF viewer", () => { await page.waitForSelector( ".page[data-page-number='1'] .textLayer" ); + // Wait for the canvas to have actual pixels before reading + // colors; with the renderer worker, the first frame may not + // have been committed to the placeholder yet. + await waitForCanvasPixels(page, 1, 0); const before = await page.evaluate(extractCanvases, 1); @@ -692,6 +777,7 @@ describe("PDF viewer", () => { }); }, factor); await awaitPromise(handle); + await waitForCanvasPixels(page, 1); const after = await page.evaluate(extractCanvases, 1); @@ -732,6 +818,7 @@ describe("PDF viewer", () => { await page.waitForSelector( ".page[data-page-number='1'] canvas:nth-child(2)" ); + await waitForCanvasPixels(page, 1); const canvases = await page.evaluate(extractCanvases, 1); @@ -778,6 +865,7 @@ describe("PDF viewer", () => { container.scrollLeft += 1100; }); await awaitPromise(handle); + await waitForCanvasPixels(page, 1); const canvases = await page.evaluate(extractCanvases, 1); @@ -874,6 +962,8 @@ describe("PDF viewer", () => { container.scrollTop += 3000; }); await awaitPromise(handle); + await waitForCanvasPixels(page, 1); + await waitForCanvasPixels(page, 2); const [canvases1, canvases2] = await Promise.all([ page.evaluate(extractCanvases, 1), diff --git a/test/unit/jasmine-boot.js b/test/unit/jasmine-boot.js index a0d1842e3d0d4..b9b7f6007034a 100644 --- a/test/unit/jasmine-boot.js +++ b/test/unit/jasmine-boot.js @@ -122,6 +122,8 @@ async function initializePDFJS(callback) { } // Configure the worker. GlobalWorkerOptions.workerSrc = "../../build/generic/build/pdf.worker.mjs"; + GlobalWorkerOptions.rendererSrc = + "../../build/generic/build/pdf.renderer.mjs"; callback(); } diff --git a/web/app_options.js b/web/app_options.js index 157adccd02d69..e104ad212540c 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -451,6 +451,11 @@ const defaultOptions = { value: false, kind: OptionKind.API + OptionKind.PREFERENCE, }, + disableWorkerRendering: { + /** @type {boolean} */ + value: false, + kind: OptionKind.API + OptionKind.PREFERENCE, + }, docBaseUrl: { /** @type {string} */ value: @@ -566,6 +571,17 @@ const defaultOptions = { : "../build/pdf.worker.mjs", kind: OptionKind.WORKER, }, + rendererSrc: { + /** @type {string} */ + value: + // eslint-disable-next-line no-nested-ternary + typeof PDFJSDev === "undefined" + ? "../src/pdf.renderer.js" + : PDFJSDev.test("MOZCENTRAL") + ? "resource://pdf.js/build/pdf.renderer.mjs" + : "../build/pdf.renderer.mjs", + kind: OptionKind.WORKER, + }, }; if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) { defaultOptions.defaultUrl = { diff --git a/web/base_pdf_page_view.js b/web/base_pdf_page_view.js index d85cac82e3ed8..9646a34a6cc09 100644 --- a/web/base_pdf_page_view.js +++ b/web/base_pdf_page_view.js @@ -16,6 +16,17 @@ import { FeatureTest, RenderingCancelledException } from "pdfjs-lib"; import { RenderableView, RenderingStates } from "./renderable_view.js"; +function releaseCanvas(canvas) { + if (!canvas) { + return; + } + if (canvas.resetWorkerCanvas) { + canvas.resetWorkerCanvas(); + return; + } + canvas.width = canvas.height = 0; +} + class BasePDFPageView extends RenderableView { #loadingId = null; @@ -124,13 +135,18 @@ class BasePDFPageView extends RenderableView { this.#showCanvas = isLastShow => { if (updateOnFirstShow) { let tempCanvas = this.#tempCanvas; - if (!isLastShow && this.minDurationToUpdateCanvas > 0) { + if ( + !isLastShow && + this.minDurationToUpdateCanvas > 0 && + !this.renderTask?.rendererHandler + ) { // We draw on the canvas at 60fps (in using `requestAnimationFrame`), // so if the canvas is large, updating it at 60fps can be a way too // much and can cause some serious performance issues. // To avoid that we only update the canvas every // `this.#minDurationToUpdateCanvas` ms. - + // When rendering in worker, we don't need this optimization because + // the rendering is already happening off the main thread. if (Date.now() - this.#startTime < this.minDurationToUpdateCanvas) { return; } @@ -167,7 +183,7 @@ class BasePDFPageView extends RenderableView { if (prevCanvas) { prevCanvas.replaceWith(canvas); - prevCanvas.width = prevCanvas.height = 0; + releaseCanvas(prevCanvas); } else { onShow(canvas); } @@ -195,14 +211,14 @@ class BasePDFPageView extends RenderableView { return; } canvas.remove(); - canvas.width = canvas.height = 0; + releaseCanvas(canvas); this.canvas = null; this.#resetTempCanvas(); } #resetTempCanvas() { if (this.#tempCanvas) { - this.#tempCanvas.width = this.#tempCanvas.height = 0; + releaseCanvas(this.#tempCanvas); this.#tempCanvas = null; } } diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index 3ee59520c23c4..ffee050cb854b 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -1289,6 +1289,11 @@ class PDFPageView extends BasePDFPageView { get thumbnailCanvas() { const { directDrawing, initialOptionalContent, regularAnnotations } = this.#useThumbnailCanvas; + // When worker rendering is used, we cannot use the OffScreen canvas + // for thumbnail generation. + if (this.canvas?.resetWorkerCanvas) { + return null; + } return directDrawing && initialOptionalContent && regularAnnotations ? this.canvas : null; diff --git a/web/pdf_rendering_queue.js b/web/pdf_rendering_queue.js index 1e424b123134b..8c18d92376f87 100644 --- a/web/pdf_rendering_queue.js +++ b/web/pdf_rendering_queue.js @@ -85,6 +85,11 @@ class PDFRenderingQueue { return; } // No pages needed rendering, so check thumbnails. + // TODO: When worker rendering is enabled, thumbnails are + // re-rendered from scratch because the page canvas is offscreen- + // transferred and cannot be reused as a thumbnail source. Consider + // having the worker emit a downscaled ImageBitmap to reuse the main + // render. if ( this.isThumbnailViewEnabled && this.#pdfThumbnailViewer?.forceRendering() diff --git a/web/pdf_thumbnail_view.js b/web/pdf_thumbnail_view.js index 74d62292725a5..cc6686593287f 100644 --- a/web/pdf_thumbnail_view.js +++ b/web/pdf_thumbnail_view.js @@ -329,12 +329,14 @@ class PDFThumbnailView extends RenderableView { const canvas = document.createElement("canvas"); canvas.width = (width * outputScale.sx) | 0; canvas.height = (height * outputScale.sy) | 0; + // Get the canvas context here to ensure we use main-thread rendering. + const canvasContext = canvas.getContext("2d", { alpha: false }); const transform = outputScale.scaled ? [outputScale.sx, 0, 0, outputScale.sy, 0, 0] : null; - return { canvas, transform }; + return { canvas, canvasContext, transform }; } async #convertCanvasToImage(canvas) { @@ -371,7 +373,8 @@ class PDFThumbnailView extends RenderableView { // the `draw` and `setImage` methods (fixes issue 8233). // NOTE: To primarily avoid increasing memory usage too much, but also to // reduce downsizing overhead, we purposely limit the up-scaling factor. - const { canvas, transform } = this.#getPageDrawContext(DRAW_UPSCALE_FACTOR); + const { canvas, canvasContext, transform } = + this.#getPageDrawContext(DRAW_UPSCALE_FACTOR); const drawViewport = this.viewport.clone({ scale: DRAW_UPSCALE_FACTOR * this.scale, }); @@ -388,7 +391,7 @@ class PDFThumbnailView extends RenderableView { }; const renderContext = { - canvas, + canvasContext, transform, viewport: drawViewport, optionalContentConfigPromise: this._optionalContentConfigPromise,