From dfd66ee5c8d4adef6f44a9caba7c15cbf0fea17c Mon Sep 17 00:00:00 2001 From: Ujjwal Sharma Date: Mon, 12 May 2025 11:17:53 +0200 Subject: [PATCH 1/6] Implement offscreen rendering with workers Move the work of drawing the PDF onto the cavas to a worker thread using OffscreenCanvas. This should free up the main thread a bit by moving all of the CanvasGraphics operations to this "renderer" worker. --- src/core/document.js | 16 ++- src/core/evaluator.js | 35 +++-- src/core/worker.js | 24 +++- src/display/api.js | 223 ++++++++++++++++++++++++------ src/display/canvas_factory.js | 12 +- src/display/display_utils.js | 109 +++++++++++++++ src/display/renderer_worker.js | 243 +++++++++++++++++++++++++++++++++ src/shared/message_handler.js | 1 + web/base_pdf_page_view.js | 12 +- web/pdf_thumbnail_view.js | 4 +- 10 files changed, 614 insertions(+), 65 deletions(-) create mode 100644 src/display/renderer_worker.js diff --git a/src/core/document.js b/src/core/document.js index 9d65d04ac84ab..0c2b8c4027ebb 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -128,10 +128,11 @@ class Page { }; } - #createPartialEvaluator(handler) { + #createPartialEvaluator(handler, rendererHandler) { return new PartialEvaluator({ xref: this.xref, handler, + rendererHandler, pageIndex: this.pageIndex, idFactory: this._localIdFactory, fontCache: this.fontCache, @@ -459,6 +460,7 @@ class Page { async getOperatorList({ handler, + rendererHandler, sink, task, intent, @@ -471,7 +473,10 @@ class Page { const contentStreamPromise = this.getContentStream(); const resourcesPromise = this.loadResources(RESOURCES_KEYS_OPERATOR_LIST); - const partialEvaluator = this.#createPartialEvaluator(handler); + const partialEvaluator = this.#createPartialEvaluator( + handler, + rendererHandler + ); const newAnnotsByPage = !this.xfaFactory ? getNewAnnotationsMap(annotationStorage) @@ -1310,7 +1315,7 @@ class PDFDocument { this.xfaFactory.setImages(xfaImages); } - async #loadXfaFonts(handler, task) { + async #loadXfaFonts(handler, task, rendererHandler) { const acroForm = await this.pdfManager.ensureCatalog("acroForm"); if (!acroForm) { return; @@ -1336,6 +1341,7 @@ class PDFDocument { const partialEvaluator = new PartialEvaluator({ xref: this.xref, handler, + rendererHandler, pageIndex: -1, idFactory: this._globalIdFactory, fontCache, @@ -1448,9 +1454,9 @@ class PDFDocument { this.xfaFactory.appendFonts(pdfFonts, reallyMissingFonts); } - loadXfaResources(handler, task) { + loadXfaResources(handler, task, rendererHandler) { return Promise.all([ - this.#loadXfaFonts(handler, task).catch(() => { + this.#loadXfaFonts(handler, task, rendererHandler).catch(() => { // Ignore errors, to allow the document to load. }), this.#loadXfaImages(), diff --git a/src/core/evaluator.js b/src/core/evaluator.js index 6a2ba2986ee9f..6745761707bbf 100644 --- a/src/core/evaluator.js +++ b/src/core/evaluator.js @@ -226,6 +226,7 @@ class PartialEvaluator { constructor({ xref, handler, + rendererHandler, pageIndex, idFactory, fontCache, @@ -238,6 +239,7 @@ class PartialEvaluator { }) { this.xref = xref; this.handler = handler; + this.rendererHandler = rendererHandler; this.pageIndex = pageIndex; this.idFactory = idFactory; this.fontCache = fontCache; @@ -557,13 +559,19 @@ class PartialEvaluator { const transfers = imgData ? [imgData.bitmap || imgData.data.buffer] : null; if (this.parsingType3Font || cacheGlobally) { - return this.handler.send( + this.handler.send("commonobj", [objId, "Image", imgData], transfers); + return this.rendererHandler.send( "commonobj", [objId, "Image", imgData], transfers ); } - return this.handler.send( + this.handler.send( + "obj", + [objId, this.pageIndex, "Image", imgData], + transfers + ); + return this.rendererHandler.send( "obj", [objId, this.pageIndex, "Image", imgData], transfers @@ -791,11 +799,10 @@ class PartialEvaluator { // globally, check if the image is still cached locally on the main-thread // to avoid having to re-parse the image (since that can be slow). if (w * h > 250000 || hasMask) { - const localLength = await this.handler.sendWithPromise("commonobj", [ - objId, - "CopyLocalImage", - { imageRef }, - ]); + const localLength = await this.rendererHandler.sendWithPromise( + "commonobj", + [objId, "CopyLocalImage", { imageRef }] + ); if (localLength) { this.globalImageCache.setData(imageRef, globalCacheData); @@ -1025,6 +1032,7 @@ class PartialEvaluator { state.font = translated.font; translated.send(this.handler); + translated.send(this.rendererHandler); return translated.loadedName; } @@ -1045,7 +1053,7 @@ class PartialEvaluator { PartialEvaluator.buildFontPaths( font, glyphs, - this.handler, + this.rendererHandler, this.options ); } @@ -1526,8 +1534,19 @@ class PartialEvaluator { const patternBuffer = PatternInfo.write(patternIR); transfers.push(patternBuffer); this.handler.send("commonobj", [id, "Pattern", patternBuffer], transfers); + this.rendererHandler.send( + "commonobj", + [id, "Pattern", patternBuffer], + transfers + ); } else { this.handler.send("obj", [id, this.pageIndex, "Pattern", patternIR]); + this.rendererHandler.send("obj", [ + id, + this.pageIndex, + "Pattern", + patternIR, + ]); } return id; } diff --git a/src/core/worker.js b/src/core/worker.js index ae3c48be932c9..495006b7816c1 100644 --- a/src/core/worker.js +++ b/src/core/worker.js @@ -83,6 +83,8 @@ class WorkerMessageHandler { static setup(handler, port) { let testMessageProcessed = false; + let rendererHandler = null; + handler.on("test", data => { if (testMessageProcessed) { return; // we already processed 'test' message once @@ -95,12 +97,19 @@ class WorkerMessageHandler { handler.on("configure", data => { setVerbosityLevel(data.verbosity); + rendererHandler = new MessageHandler( + "worker-channel", + "renderer-channel", + data.channelPort + ); }); - handler.on("GetDocRequest", data => this.createDocumentHandler(data, port)); + handler.on("GetDocRequest", data => + this.createDocumentHandler(data, port, rendererHandler) + ); } - static createDocumentHandler(docParams, port) { + static createDocumentHandler(docParams, port, rendererHandler) { // This context is actually holds references on pdfManager and handler, // until the latter is destroyed. let pdfManager; @@ -174,7 +183,11 @@ class WorkerMessageHandler { const task = new WorkerTask("loadXfaResources"); startWorkerTask(task); - await pdfManager.ensureDoc("loadXfaResources", [handler, task]); + await pdfManager.ensureDoc("loadXfaResources", [ + handler, + task, + rendererHandler, + ]); finishWorkerTask(task); } @@ -865,6 +878,7 @@ class WorkerMessageHandler { page .getOperatorList({ handler, + rendererHandler, sink, task, intent: data.intent, @@ -950,8 +964,8 @@ class WorkerMessageHandler { .then(page => pdfManager.ensure(page, "getStructTree")); }); - handler.on("FontFallback", function (data) { - return pdfManager.fontFallback(data.id, handler); + rendererHandler.on("FontFallback", function (data) { + return pdfManager.fontFallback(data.id, rendererHandler); }); handler.on("Cleanup", function (data) { diff --git a/src/display/api.js b/src/display/api.js index 9ffb982f0a512..05f93b36c010d 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -43,6 +43,7 @@ import { isValidFetchUrl, PagesMapper, PageViewport, + PDFObjects, RenderingCancelledException, StatTimer, } from "./display_utils.js"; @@ -81,7 +82,6 @@ import { PDFDataTransportStream } from "./transport_stream.js"; import { PDFFetchStream } from "display-fetch_stream"; import { PDFNetworkStream } from "display-network"; import { PDFNodeStream } from "display-node_stream"; -import { PDFObjects } from "./pdf_objects.js"; import { TextLayer } from "./text_layer.js"; import { XfaText } from "./xfa_text.js"; @@ -396,16 +396,23 @@ function getDocument(src = {}) { : new WasmFactory({ baseUrl: wasmUrl }), }; + const workerChannel = new MessageChannel(); + if (!worker) { // Worker was not provided -- creating and owning our own. If message port // is specified in global worker options, using it. worker = PDFWorker.create({ verbosity, port: GlobalWorkerOptions.workerPort, + channelPort: workerChannel.port1, }); task._worker = worker; } + const renderer = new RendererWorker(workerChannel.port2, enableHWA); + const rendererHandler = renderer.handler; + task.renderer = renderer; + const docParams = { docId, apiVersion: @@ -511,10 +518,13 @@ function getDocument(src = {}) { task, networkStream, transportParams, - transportFactory + transportFactory, + enableHWA, + rendererHandler ); task._transport = transport; messageHandler.send("Ready", null); + rendererHandler.send("Ready", null); }); }) .catch(task._capability.reject); @@ -553,6 +563,8 @@ class PDFDocumentLoadingTask { */ _worker = null; + #renderer = null; + /** * Unique identifier for the document loading task. * @type {string} @@ -612,6 +624,8 @@ class PDFDocumentLoadingTask { this._worker?.destroy(); this._worker = null; + this.#renderer.destroy(); + this.#renderer = null; } /** @@ -623,6 +637,17 @@ class PDFDocumentLoadingTask { async getData() { return this._transport.getData(); } + + get renderer() { + return this.#renderer; + } + + set renderer(renderer) { + if (this.#renderer) { + throw new Error("PDFDocumentLoadingTask.renderer: already set."); + } + this.#renderer = renderer; + } } /** @@ -1598,7 +1623,7 @@ class PDFPageProxy { useRequestAnimationFrame: !intentPrint, pdfBug: this._pdfBug, pageColors, - enableHWA: this._transport.enableHWA, + rendererHandler: this._transport.rendererHandler, operationsFilter, }); @@ -2018,6 +2043,12 @@ class PDFPageProxy { get stats() { return this._stats; } + + resetCanvas(taskID) { + this._transport.rendererHandler.send("resetCanvas", { + taskID, + }); + } } /** @@ -2026,6 +2057,8 @@ class PDFPageProxy { * @property {Worker} [port] - The `workerPort` object. * @property {number} [verbosity] - Controls the logging level; * the constants from {@link VerbosityLevel} should be used. + * @property {MessagePort} [channelPort] - The channel port to use for + * communication with the renderer thread. */ /** @@ -2107,10 +2140,12 @@ class PDFWorker { name = null, port = null, verbosity = getVerbosityLevel(), + channelPort = null, } = {}) { this.name = name; this.destroyed = false; this.verbosity = verbosity; + this.channelPort = channelPort; if (port) { if (PDFWorker.#workerPorts.has(port)) { @@ -2143,9 +2178,14 @@ class PDFWorker { #resolve() { this.#capability.resolve(); // Send global setting, e.g. verbosity level. - this.#messageHandler.send("configure", { - verbosity: this.verbosity, - }); + this.#messageHandler.send( + "configure", + { + verbosity: this.verbosity, + channelPort: this.channelPort, + }, + [this.channelPort] + ); } /** @@ -2381,6 +2421,34 @@ class PDFWorker { } } +class RendererWorker { + #worker; + + #handler; + + constructor(channelPort, enableHWA) { + this.#worker = new Worker("../src/display/renderer_worker.js", { + type: "module", + }); + this.#handler = new MessageHandler("main", "renderer", this.#worker); + this.#handler.send("configure", { channelPort, enableHWA }, [channelPort]); + this.#handler.on("ready", () => { + // DO NOTHING + }); + } + + get handler() { + return this.#handler; + } + + destroy() { + this.#worker.terminate(); + this.#worker = null; + this.#handler.destroy(); + this.#handler = null; + } +} + /** * For internal use only. * @ignore @@ -2404,8 +2472,17 @@ class WorkerTransport { #pagesMapper = PagesMapper.instance; - constructor(messageHandler, loadingTask, networkStream, params, factory) { + constructor( + messageHandler, + loadingTask, + networkStream, + params, + factory, + enableHWA, + rendererHandler + ) { this.messageHandler = messageHandler; + this.rendererHandler = rendererHandler; this.loadingTask = loadingTask; this.#networkStream = networkStream; @@ -2596,8 +2673,13 @@ class WorkerTransport { const terminated = this.messageHandler.sendWithPromise("Terminate", null); waitOn.push(terminated); + const terminatedRenderer = this.rendererHandler.sendWithPromise( + "Terminate", + null + ); + waitOn.push(terminatedRenderer); + Promise.all(waitOn).then(() => { - this.commonObjs.clear(); this.fontLoader.clear(); this.#methodPromises.clear(); this.filterFactory.destroy(); @@ -2616,7 +2698,13 @@ class WorkerTransport { } setupMessageHandler() { - const { messageHandler, loadingTask } = this; + const { messageHandler, loadingTask, rendererHandler } = this; + + rendererHandler.on("continue", ({ taskID, arg }) => { + const continueFn = InternalRenderTask.continueFnMap.get(taskID); + assert(continueFn, `No continue function for taskID: ${taskID}`); + continueFn.call(arg); + }); messageHandler.on("GetReader", (data, sink) => { assert( @@ -3215,6 +3303,10 @@ class RenderTask { (separateAnnots.canvas && annotationCanvasMap?.size > 0) ); } + + get taskID() { + return this.#internalRenderTask.taskID; + } } /** @@ -3226,6 +3318,10 @@ class InternalRenderTask { static #canvasInUse = new WeakSet(); + static #taskCounter = 0n; + + static continueFnMap = new Map(); + constructor({ callback, params, @@ -3239,9 +3335,10 @@ class InternalRenderTask { useRequestAnimationFrame = false, pdfBug = false, pageColors = null, - enableHWA = false, + rendererHandler, operationsFilter = null, }) { + this.taskID = InternalRenderTask.#taskCounter++; this.callback = callback; this.params = params; this.objs = objs; @@ -3270,7 +3367,9 @@ class InternalRenderTask { this._nextBound = this._next.bind(this); this._canvas = params.canvas; this._canvasContext = params.canvas ? null : params.canvasContext; - this._enableHWA = enableHWA; + this._renderInWorker = this._canvasContext === null; + this.rendererHandler = rendererHandler; + InternalRenderTask.continueFnMap.set(this.taskID, this._continueBound); this._dependencyTracker = params.dependencyTracker; this._operationsFilter = operationsFilter; } @@ -3305,31 +3404,47 @@ class InternalRenderTask { const { viewport, transform, background, dependencyTracker } = 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, + // which cannot be created from the canvas itself. In this case, we don't + // render in the worker and use the context directly. + if (this._renderInWorker) { + const offscreen = this._canvas.transferControlToOffscreen(); + this.rendererHandler.send( + "init", + { + pageIndex: this._pageIndex, + canvas: offscreen, + map: this.annotationCanvasMap, + colors: this.pageColors, + taskID: this.taskID, + transform, + viewport, + transparency, + background, + optionalContentConfig, + dependencyTracker, + }, + [offscreen] + ); + } else { + this.gfx = new CanvasGraphics( + this._canvasContext, + this.commonObjs, + this.objs, + this.canvasFactory, + this.filterFactory, + { optionalContentConfig }, + this.annotationCanvasMap, + this.pageColors, + dependencyTracker + ); + this.gfx.beginDrawing({ + transform, + viewport, + transparency, + background, }); + } - this.gfx = new CanvasGraphics( - canvasContext, - this.commonObjs, - this.objs, - this.canvasFactory, - this.filterFactory, - { optionalContentConfig }, - this.annotationCanvasMap, - this.pageColors, - dependencyTracker - ); - this.gfx.beginDrawing({ - transform, - viewport, - transparency, - background, - }); this.operatorListIdx = 0; this.graphicsReady = true; this.graphicsReadyCallback?.(); @@ -3338,7 +3453,12 @@ class InternalRenderTask { cancel(error = null, extraDelay = 0) { this.running = false; this.cancelled = true; - this.gfx?.endDrawing(); + if (this._renderInWorker) { + this.rendererHandler.send("end", { taskID: this.taskID }); + } else { + this.gfx.endDrawing(); + } + InternalRenderTask.continueFnMap.delete(this.taskID); if (this.#rAF) { window.cancelAnimationFrame(this.#rAF); this.#rAF = null; @@ -3397,17 +3517,34 @@ class InternalRenderTask { if (this.cancelled) { return; } - this.operatorListIdx = this.gfx.executeOperatorList( - this.operatorList, - this.operatorListIdx, - this._continueBound, - this.stepper, - this._operationsFilter - ); + const { operatorList, operatorListIdx, taskID } = this; + if (this._renderInWorker) { + this.operatorListIdx = await this.rendererHandler.sendWithPromise( + "execute", + { + operatorList, + operatorListIdx, + taskID, + operationsFilter: this._operationsFilter, + } + ); + } else { + this.operatorListIdx = this.gfx.executeOperatorList( + this.operatorList, + this.operatorListIdx, + this._continueBound, + this.stepper, + this._operationsFilter + ); + } if (this.operatorListIdx === this.operatorList.argsArray.length) { this.running = false; if (this.operatorList.lastChunk) { - this.gfx.endDrawing(); + if (this._renderInWorker) { + this.rendererHandler.send("end", { taskID }); + } else { + this.gfx.endDrawing(); + } InternalRenderTask.#canvasInUse.delete(this._canvas); this.callback(); } diff --git a/src/display/canvas_factory.js b/src/display/canvas_factory.js index 988e764859828..fbe02d27348a6 100644 --- a/src/display/canvas_factory.js +++ b/src/display/canvas_factory.js @@ -89,4 +89,14 @@ class DOMCanvasFactory extends BaseCanvasFactory { } } -export { BaseCanvasFactory, DOMCanvasFactory }; +class OffscreenCanvasFactory extends BaseCanvasFactory { + constructor({ enableHWA = false }) { + super({ enableHWA }); + } + + _createCanvas(width, height) { + return new OffscreenCanvas(width, height); + } +} + +export { BaseCanvasFactory, DOMCanvasFactory, OffscreenCanvasFactory }; diff --git a/src/display/display_utils.js b/src/display/display_utils.js index a529acef42816..9774a975307a3 100644 --- a/src/display/display_utils.js +++ b/src/display/display_utils.js @@ -1034,6 +1034,114 @@ function makePathFromDrawOPS(data) { } return path; } +const INITIAL_DATA = Symbol("INITIAL_DATA"); + +/** + * A PDF document and page is built of many objects. E.g. there are objects for + * fonts, images, rendering code, etc. These objects may get processed inside of + * a worker. This class implements some basic methods to manage these objects. + */ +class PDFObjects { + #objs = Object.create(null); + + /** + * Ensures there is an object defined for `objId`. + * + * @param {string} objId + * @returns {Object} + */ + #ensureObj(objId) { + return (this.#objs[objId] ||= { + ...Promise.withResolvers(), + data: INITIAL_DATA, + }); + } + + /** + * If called *without* callback, this returns the data of `objId` but the + * object needs to be resolved. If it isn't, this method throws. + * + * If called *with* a callback, the callback is called with the data of the + * object once the object is resolved. That means, if you call this method + * and the object is already resolved, the callback gets called right away. + * + * @param {string} objId + * @param {function} [callback] + * @returns {any} + */ + get(objId, callback = 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.#ensureObj(objId); + obj.promise.then(() => callback(obj.data)); + return null; + } + // If there isn't a callback, the user expects to get the resolved data + // directly. + const obj = this.#objs[objId]; + // If there isn't an object yet or the object isn't resolved, then the + // data isn't ready yet! + if (!obj || obj.data === INITIAL_DATA) { + throw new Error(`Requesting object that isn't resolved yet ${objId}.`); + } + return obj.data; + } + + /** + * @param {string} objId + * @returns {boolean} + */ + has(objId) { + const obj = this.#objs[objId]; + return !!obj && obj.data !== INITIAL_DATA; + } + + /** + * @param {string} objId + * @returns {boolean} + */ + delete(objId) { + const obj = this.#objs[objId]; + if (!obj || obj.data === INITIAL_DATA) { + // Only allow removing the object *after* it's been resolved. + return false; + } + delete this.#objs[objId]; + return true; + } + + /** + * Resolves the object `objId` with optional `data`. + * + * @param {string} objId + * @param {any} [data] + */ + resolve(objId, data = null) { + const obj = this.#ensureObj(objId); + obj.data = data; + obj.resolve(); + } + + clear() { + for (const objId in this.#objs) { + const { data } = this.#objs[objId]; + data?.bitmap?.close(); // Release any `ImageBitmap` data. + } + this.#objs = Object.create(null); + } + + *[Symbol.iterator]() { + for (const objId in this.#objs) { + const { data } = this.#objs[objId]; + + if (data === INITIAL_DATA) { + continue; + } + yield [objId, data]; + } + } +} /** * Maps between page IDs and page numbers, allowing bidirectional conversion @@ -1278,6 +1386,7 @@ export { PagesMapper, PageViewport, PDFDateString, + PDFObjects, PixelsPerInch, RenderingCancelledException, renderRichText, diff --git a/src/display/renderer_worker.js b/src/display/renderer_worker.js new file mode 100644 index 0000000000000..6b7e7c96c76b9 --- /dev/null +++ b/src/display/renderer_worker.js @@ -0,0 +1,243 @@ +import { assert, warn } from "../shared/util.js"; +import { FontFaceObject, FontLoader } from "./font_loader.js"; +import { CanvasGraphics } from "./canvas.js"; +import { DOMFilterFactory } from "./filter_factory.js"; +import { MessageHandler } from "../shared/message_handler.js"; +import { OffscreenCanvasFactory } from "./canvas_factory.js"; +import { PDFObjects } from "./display_utils.js"; + +class RendererMessageHandler { + static #commonObjs = new PDFObjects(); + + static #objsMap = new Map(); + + static #tasks = new Map(); + + static #fontLoader = new FontLoader({ + ownerDocument: self, + }); + + static #canvasFactory; + + static #filterFactory; + + static #enableHWA = false; + + static { + this.initializeFromPort(self); + } + + static pageObjs(pageIndex) { + let objs = this.#objsMap.get(pageIndex); + if (!objs) { + objs = new PDFObjects(); + this.#objsMap.set(pageIndex, objs); + } + return objs; + } + + static initializeFromPort(port) { + let terminated = false; + let mainHandler = new MessageHandler("renderer", "main", port); + mainHandler.send("ready", null); + mainHandler.on("Ready", function () { + // DO NOTHING + }); + + mainHandler.on("configure", ({ channelPort, enableHWA }) => { + this.#enableHWA = enableHWA; + const workerHandler = new MessageHandler( + "renderer-channel", + "worker-channel", + channelPort + ); + this.#canvasFactory = new OffscreenCanvasFactory({ + enableHWA, + }); + this.#filterFactory = new DOMFilterFactory({}); + + workerHandler.on("commonobj", ([id, type, data]) => { + if (terminated) { + throw new Error("Renderer worker has been terminated."); + } + this.handleCommonObj(id, type, data, workerHandler); + }); + + workerHandler.on("obj", ([id, pageIndex, type, data]) => { + if (terminated) { + throw new Error("Renderer worker has been terminated."); + } + this.handleObj(pageIndex, id, type, data); + }); + }); + + mainHandler.on( + "init", + ({ + pageIndex, + canvas, + map, + colors, + taskID, + transform, + viewport, + transparency, + background, + optionalContentConfig, + }) => { + assert(!this.#tasks.has(taskID), "Task already initialized"); + const ctx = canvas.getContext("2d", { + alpha: false, + willReadFrequently: this.#enableHWA, + }); + const objs = this.pageObjs(pageIndex); + const gfx = new CanvasGraphics( + ctx, + this.#commonObjs, + objs, + this.#canvasFactory, + this.#filterFactory, + { optionalContentConfig }, + map, + colors + ); + gfx.beginDrawing({ transform, viewport, transparency, background }); + this.#tasks.set(taskID, { canvas, gfx }); + } + ); + + mainHandler.on( + "execute", + async ({ operatorList, operatorListIdx, taskID }) => { + if (terminated) { + throw new Error("Renderer worker has been terminated."); + } + const task = this.#tasks.get(taskID); + assert(task !== undefined, "Task not initialized"); + return task.gfx.executeOperatorList( + operatorList, + operatorListIdx, + arg => mainHandler.send("continue", { taskID, arg }) + ); + } + ); + + mainHandler.on("end", ({ taskID }) => { + if (terminated) { + throw new Error("Renderer worker has been terminated."); + } + const task = this.#tasks.get(taskID); + assert(task !== undefined, "Task not initialized"); + task.gfx.endDrawing(); + }); + + mainHandler.on("resetCanvas", ({ taskID }) => { + if (terminated) { + throw new Error("Renderer worker has been terminated."); + } + const task = this.#tasks.get(taskID); + assert(task !== undefined, "Task not initialized"); + const canvas = task.canvas; + canvas.width = canvas.height = 0; + }); + + mainHandler.on("Terminate", async () => { + terminated = true; + this.#commonObjs.clear(); + for (const objs of this.#objsMap.values()) { + objs.clear(); + } + this.#objsMap.clear(); + this.#tasks.clear(); + this.#fontLoader.clear(); + mainHandler.destroy(); + mainHandler = null; + }); + } + + static handleCommonObj(id, type, exportedData, handler) { + if (this.#commonObjs.has(id)) { + return null; + } + + switch (type) { + case "Font": + if ("error" in exportedData) { + const exportedError = exportedData.error; + warn(`Error during font loading: ${exportedError}`); + this.#commonObjs.resolve(id, exportedError); + break; + } + + // TODO: Make FontInspector work again. + const inspectFont = null; + // this._params.pdfBug && globalThis.FontInspector?.enabled + // ? (font, url) => globalThis.FontInspector.fontAdded(font, url) + // : null; + const font = new FontFaceObject(exportedData, inspectFont); + + this.#fontLoader + .bind(font) + .catch(() => handler.sendWithPromise("FontFallback", { id })) + .finally(() => { + if (!font.fontExtraProperties && font.data) { + // 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.data = null; + } + this.#commonObjs.resolve(id, font); + }); + break; + case "CopyLocalImage": + const { imageRef } = exportedData; + assert(imageRef, "The imageRef must be defined."); + + for (const objs of this.#objsMap.values()) { + for (const [, data] of objs) { + if (data?.ref !== imageRef) { + continue; + } + if (!data.dataLen) { + return null; + } + this.#commonObjs.resolve(id, structuredClone(data)); + return data.dataLen; + } + } + break; + case "FontPath": + case "Image": + case "Pattern": + this.#commonObjs.resolve(id, exportedData); + break; + default: + throw new Error(`Got unknown common object type ${type}`); + } + + return null; + } + + static handleObj(pageIndex, id, type, exportedData) { + const objs = this.pageObjs(pageIndex); + + if (objs.has(id)) { + return; + } + + switch (type) { + case "Image": + case "Pattern": + objs.resolve(id, exportedData); + break; + default: + throw new Error( + `Got unknown object type ${type} id ${id} for page ${pageIndex} data ${JSON.stringify(exportedData)}` + ); + } + } +} + +export { RendererMessageHandler }; diff --git a/src/shared/message_handler.js b/src/shared/message_handler.js index a1af0aab27b54..633534087deda 100644 --- a/src/shared/message_handler.js +++ b/src/shared/message_handler.js @@ -90,6 +90,7 @@ class MessageHandler { comObj.addEventListener("message", this.#onMessage.bind(this), { signal: this.#messageAC.signal, }); + comObj.start?.(); } #onMessage({ data }) { diff --git a/web/base_pdf_page_view.js b/web/base_pdf_page_view.js index c37b5e4e22c51..3165c0189696e 100644 --- a/web/base_pdf_page_view.js +++ b/web/base_pdf_page_view.js @@ -48,6 +48,12 @@ class BasePDFPageView extends RenderableView { renderingQueue = null; + renderTask = null; + + renderTaskID = null; + + resume = null; + constructor(options) { super(); this.eventBus = options.eventBus; @@ -159,7 +165,7 @@ class BasePDFPageView extends RenderableView { if (prevCanvas) { prevCanvas.replaceWith(canvas); - prevCanvas.width = prevCanvas.height = 0; + this.pdfPage.resetCanvas(this.renderTaskID); } else { onShow(canvas); } @@ -187,7 +193,7 @@ class BasePDFPageView extends RenderableView { return; } canvas.remove(); - canvas.width = canvas.height = 0; + this.pdfPage.resetCanvas(this.renderTaskID); this.canvas = null; this.#resetTempCanvas(); } @@ -209,6 +215,8 @@ class BasePDFPageView extends RenderableView { } }; + this.renderTaskID = renderTask.taskID; + let error = null; try { await renderTask.promise; diff --git a/web/pdf_thumbnail_view.js b/web/pdf_thumbnail_view.js index 706febbbedfa3..6730a9287a21c 100644 --- a/web/pdf_thumbnail_view.js +++ b/web/pdf_thumbnail_view.js @@ -319,6 +319,7 @@ class PDFThumbnailView extends RenderableView { await renderTask.promise; } catch (e) { if (e instanceof RenderingCancelledException) { + pdfPage.resetCanvas(renderTask.taskID); return; } error = e; @@ -332,7 +333,8 @@ class PDFThumbnailView extends RenderableView { } this.renderingState = RenderingStates.FINISHED; - await this.#convertCanvasToImage(canvas); + this.#convertCanvasToImage(canvas); + pdfPage.resetCanvas(renderTask.taskID); this.eventBus.dispatch("thumbnailrendered", { source: this, From 3909b9aab0fc0aa24a5ce1843a330954d79297f6 Mon Sep 17 00:00:00 2001 From: Ujjwal Sharma Date: Mon, 14 Jul 2025 23:08:36 +0200 Subject: [PATCH 2/6] Add a pdf.renderer.js entrypoint and gulp function to build renderer --- gulpfile.mjs | 20 ++++++++++++++++++++ src/display/api.js | 11 ++++++++--- src/pdf.renderer.js | 18 ++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 src/pdf.renderer.js diff --git a/gulpfile.mjs b/gulpfile.mjs index 43bafdbf00d51..352dfcb77760f 100644 --- a/gulpfile.mjs +++ b/gulpfile.mjs @@ -543,6 +543,18 @@ function createWorkerBundle(defines) { .pipe(webpack2Stream(workerFileConfig)); } +function createRendererWorkerBundle(defines) { + const rendererWorkerFileConfig = createWebpackConfig(defines, { + filename: defines.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", @@ -1055,6 +1067,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 @@ -1215,6 +1228,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") @@ -1344,6 +1358,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") ), @@ -1449,6 +1466,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") ), diff --git a/src/display/api.js b/src/display/api.js index 05f93b36c010d..dd3ba39bf2f0f 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -2427,9 +2427,14 @@ class RendererWorker { #handler; constructor(channelPort, enableHWA) { - this.#worker = new Worker("../src/display/renderer_worker.js", { - type: "module", - }); + const src = + // eslint-disable-next-line no-nested-ternary + typeof PDFJSDev === "undefined" + ? "../src/pdf.worker.js" + : PDFJSDev.test("MOZCENTRAL") + ? "resource://pdf.js/build/pdf.worker.mjs" + : "../build/pdf.worker.mjs"; + this.#worker = new Worker(src, { type: "module" }); this.#handler = new MessageHandler("main", "renderer", this.#worker); this.#handler.send("configure", { channelPort, enableHWA }, [channelPort]); this.#handler.on("ready", () => { diff --git a/src/pdf.renderer.js b/src/pdf.renderer.js new file mode 100644 index 0000000000000..5f16c287ce09b --- /dev/null +++ b/src/pdf.renderer.js @@ -0,0 +1,18 @@ +/* Copyright 2025 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 }; From a906d0ed3c981218b8fd9082f14ad15e3f22acc2 Mon Sep 17 00:00:00 2001 From: Ujjwal Sharma Date: Wed, 16 Jul 2025 12:58:54 +0200 Subject: [PATCH 3/6] move common handler code to a shared file --- src/core/document.js | 15 ++--- src/core/evaluator.js | 35 +++------- src/core/worker.js | 4 ++ src/display/api.js | 119 +++------------------------------ src/display/renderer_worker.js | 109 +++--------------------------- src/shared/handle_objs.js | 116 ++++++++++++++++++++++++++++++++ 6 files changed, 152 insertions(+), 246 deletions(-) create mode 100644 src/shared/handle_objs.js diff --git a/src/core/document.js b/src/core/document.js index 0c2b8c4027ebb..0360d9aa80304 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -128,11 +128,10 @@ class Page { }; } - #createPartialEvaluator(handler, rendererHandler) { + #createPartialEvaluator(handler) { return new PartialEvaluator({ xref: this.xref, handler, - rendererHandler, pageIndex: this.pageIndex, idFactory: this._localIdFactory, fontCache: this.fontCache, @@ -473,10 +472,7 @@ class Page { const contentStreamPromise = this.getContentStream(); const resourcesPromise = this.loadResources(RESOURCES_KEYS_OPERATOR_LIST); - const partialEvaluator = this.#createPartialEvaluator( - handler, - rendererHandler - ); + const partialEvaluator = this.#createPartialEvaluator(handler); const newAnnotsByPage = !this.xfaFactory ? getNewAnnotationsMap(annotationStorage) @@ -1315,7 +1311,7 @@ class PDFDocument { this.xfaFactory.setImages(xfaImages); } - async #loadXfaFonts(handler, task, rendererHandler) { + async #loadXfaFonts(handler, task) { const acroForm = await this.pdfManager.ensureCatalog("acroForm"); if (!acroForm) { return; @@ -1341,7 +1337,6 @@ class PDFDocument { const partialEvaluator = new PartialEvaluator({ xref: this.xref, handler, - rendererHandler, pageIndex: -1, idFactory: this._globalIdFactory, fontCache, @@ -1454,9 +1449,9 @@ class PDFDocument { this.xfaFactory.appendFonts(pdfFonts, reallyMissingFonts); } - loadXfaResources(handler, task, rendererHandler) { + loadXfaResources(handler, task) { return Promise.all([ - this.#loadXfaFonts(handler, task, rendererHandler).catch(() => { + this.#loadXfaFonts(handler, task).catch(() => { // Ignore errors, to allow the document to load. }), this.#loadXfaImages(), diff --git a/src/core/evaluator.js b/src/core/evaluator.js index 6745761707bbf..f0706f1f519b1 100644 --- a/src/core/evaluator.js +++ b/src/core/evaluator.js @@ -226,7 +226,6 @@ class PartialEvaluator { constructor({ xref, handler, - rendererHandler, pageIndex, idFactory, fontCache, @@ -239,7 +238,6 @@ class PartialEvaluator { }) { this.xref = xref; this.handler = handler; - this.rendererHandler = rendererHandler; this.pageIndex = pageIndex; this.idFactory = idFactory; this.fontCache = fontCache; @@ -559,19 +557,13 @@ class PartialEvaluator { const transfers = imgData ? [imgData.bitmap || imgData.data.buffer] : null; if (this.parsingType3Font || cacheGlobally) { - this.handler.send("commonobj", [objId, "Image", imgData], transfers); - return this.rendererHandler.send( + return this.handler.send( "commonobj", [objId, "Image", imgData], transfers ); } - this.handler.send( - "obj", - [objId, this.pageIndex, "Image", imgData], - transfers - ); - return this.rendererHandler.send( + return this.handler.send( "obj", [objId, this.pageIndex, "Image", imgData], transfers @@ -799,10 +791,11 @@ class PartialEvaluator { // globally, check if the image is still cached locally on the main-thread // to avoid having to re-parse the image (since that can be slow). if (w * h > 250000 || hasMask) { - const localLength = await this.rendererHandler.sendWithPromise( - "commonobj", - [objId, "CopyLocalImage", { imageRef }] - ); + const localLength = await this.sendWithPromise("commonobj", [ + objId, + "CopyLocalImage", + { imageRef }, + ]); if (localLength) { this.globalImageCache.setData(imageRef, globalCacheData); @@ -1032,7 +1025,6 @@ class PartialEvaluator { state.font = translated.font; translated.send(this.handler); - translated.send(this.rendererHandler); return translated.loadedName; } @@ -1053,7 +1045,7 @@ class PartialEvaluator { PartialEvaluator.buildFontPaths( font, glyphs, - this.rendererHandler, + this.handler, this.options ); } @@ -1534,19 +1526,8 @@ class PartialEvaluator { const patternBuffer = PatternInfo.write(patternIR); transfers.push(patternBuffer); this.handler.send("commonobj", [id, "Pattern", patternBuffer], transfers); - this.rendererHandler.send( - "commonobj", - [id, "Pattern", patternBuffer], - transfers - ); } else { this.handler.send("obj", [id, this.pageIndex, "Pattern", patternIR]); - this.rendererHandler.send("obj", [ - id, - this.pageIndex, - "Pattern", - patternIR, - ]); } return id; } diff --git a/src/core/worker.js b/src/core/worker.js index 495006b7816c1..33a214ab8b652 100644 --- a/src/core/worker.js +++ b/src/core/worker.js @@ -964,6 +964,10 @@ class WorkerMessageHandler { .then(page => pdfManager.ensure(page, "getStructTree")); }); + handler.on("FontFallback", function (data) { + return pdfManager.fontFallback(data.id, handler); + }); + rendererHandler.on("FontFallback", function (data) { return pdfManager.fontFallback(data.id, rendererHandler); }); diff --git a/src/display/api.js b/src/display/api.js index dd3ba39bf2f0f..dd7badf9f38b1 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -47,12 +47,6 @@ import { RenderingCancelledException, StatTimer, } from "./display_utils.js"; -import { FontFaceObject, FontLoader } from "./font_loader.js"; -import { - FontInfo, - FontPathInfo, - PatternInfo, -} from "../shared/obj-bin-transform.js"; import { getDataProp, getFactoryUrlProp, @@ -75,6 +69,7 @@ import { DOMCMapReaderFactory } from "display-cmap_reader_factory"; import { DOMFilterFactory } from "./filter_factory.js"; import { DOMStandardFontDataFactory } from "display-standard_fontdata_factory"; import { DOMWasmFactory } from "display-wasm_factory"; +import { FontLoader } from "./font_loader.js"; import { GlobalWorkerOptions } from "./worker_options.js"; import { Metadata } from "./metadata.js"; import { OptionalContentConfig } from "./optional_content_config.js"; @@ -82,6 +77,7 @@ import { PDFDataTransportStream } from "./transport_stream.js"; import { PDFFetchStream } from "display-fetch_stream"; import { PDFNetworkStream } from "display-network"; import { PDFNodeStream } from "display-node_stream"; +import { setupHandler } from "../shared/handle_objs.js"; import { TextLayer } from "./text_layer.js"; import { XfaText } from "./xfa_text.js"; @@ -2873,110 +2869,13 @@ class WorkerTransport { page._startRenderPage(data.transparency, data.cacheKey); }); - 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; - } - - 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.extra, - exportedData.charProcOperatorList - ); - - this.fontLoader - .bind(font) - .catch(() => messageHandler.sendWithPromise("FontFallback", { id })) - .finally(() => { - if (!font.fontExtraProperties && font.data) { - // 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; - } - this.commonObjs.resolve(id, structuredClone(data)); - 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; - }); - - messageHandler.on("obj", ([id, pageIndex, type, imageData]) => { - if (this.destroyed) { - // 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}`); - } - }); + setupHandler( + messageHandler, + this.destroyed, + this.commonObjs, + this.#pageCache, + this.fontLoader + ); messageHandler.on("DocProgress", data => { if (this.destroyed) { diff --git a/src/display/renderer_worker.js b/src/display/renderer_worker.js index 6b7e7c96c76b9..bb2464bd436c2 100644 --- a/src/display/renderer_worker.js +++ b/src/display/renderer_worker.js @@ -1,10 +1,11 @@ -import { assert, warn } from "../shared/util.js"; -import { FontFaceObject, FontLoader } from "./font_loader.js"; +import { assert } from "../shared/util.js"; import { CanvasGraphics } from "./canvas.js"; import { DOMFilterFactory } from "./filter_factory.js"; +import { FontLoader } from "./font_loader.js"; import { MessageHandler } from "../shared/message_handler.js"; import { OffscreenCanvasFactory } from "./canvas_factory.js"; import { PDFObjects } from "./display_utils.js"; +import { setupHandler } from "../shared/handle_objs.js"; class RendererMessageHandler { static #commonObjs = new PDFObjects(); @@ -56,19 +57,13 @@ class RendererMessageHandler { }); this.#filterFactory = new DOMFilterFactory({}); - workerHandler.on("commonobj", ([id, type, data]) => { - if (terminated) { - throw new Error("Renderer worker has been terminated."); - } - this.handleCommonObj(id, type, data, workerHandler); - }); - - workerHandler.on("obj", ([id, pageIndex, type, data]) => { - if (terminated) { - throw new Error("Renderer worker has been terminated."); - } - this.handleObj(pageIndex, id, type, data); - }); + setupHandler( + workerHandler, + terminated, + this.#commonObjs, + this.#objsMap, + this.#fontLoader + ); }); mainHandler.on( @@ -154,90 +149,6 @@ class RendererMessageHandler { mainHandler = null; }); } - - static handleCommonObj(id, type, exportedData, handler) { - if (this.#commonObjs.has(id)) { - return null; - } - - switch (type) { - case "Font": - if ("error" in exportedData) { - const exportedError = exportedData.error; - warn(`Error during font loading: ${exportedError}`); - this.#commonObjs.resolve(id, exportedError); - break; - } - - // TODO: Make FontInspector work again. - const inspectFont = null; - // this._params.pdfBug && globalThis.FontInspector?.enabled - // ? (font, url) => globalThis.FontInspector.fontAdded(font, url) - // : null; - const font = new FontFaceObject(exportedData, inspectFont); - - this.#fontLoader - .bind(font) - .catch(() => handler.sendWithPromise("FontFallback", { id })) - .finally(() => { - if (!font.fontExtraProperties && font.data) { - // 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.data = null; - } - this.#commonObjs.resolve(id, font); - }); - break; - case "CopyLocalImage": - const { imageRef } = exportedData; - assert(imageRef, "The imageRef must be defined."); - - for (const objs of this.#objsMap.values()) { - for (const [, data] of objs) { - if (data?.ref !== imageRef) { - continue; - } - if (!data.dataLen) { - return null; - } - this.#commonObjs.resolve(id, structuredClone(data)); - return data.dataLen; - } - } - break; - case "FontPath": - case "Image": - case "Pattern": - this.#commonObjs.resolve(id, exportedData); - break; - default: - throw new Error(`Got unknown common object type ${type}`); - } - - return null; - } - - static handleObj(pageIndex, id, type, exportedData) { - const objs = this.pageObjs(pageIndex); - - if (objs.has(id)) { - return; - } - - switch (type) { - case "Image": - case "Pattern": - objs.resolve(id, exportedData); - break; - default: - throw new Error( - `Got unknown object type ${type} id ${id} for page ${pageIndex} data ${JSON.stringify(exportedData)}` - ); - } - } } export { RendererMessageHandler }; diff --git a/src/shared/handle_objs.js b/src/shared/handle_objs.js new file mode 100644 index 0000000000000..029b6d49f9695 --- /dev/null +++ b/src/shared/handle_objs.js @@ -0,0 +1,116 @@ +import { assert, warn } from "../shared/util.js"; +import { + FontInfo, + FontPathInfo, + PatternInfo, +} from "../shared/obj-bin-transform.js"; +import { FontFaceObject } from "../display/font_loader.js"; + +function setupHandler(handler, destroyed, commonObjs, pages, fontLoader) { + handler.on("commonobj", ([id, type, exportedData]) => { + if (destroyed) { + return null; // Ignore any pending requests if the worker was terminated. + } + + if (commonObjs.has(id)) { + return null; + } + + switch (type) { + case "Font": + if ("error" in exportedData) { + const exportedError = exportedData.error; + warn(`Error during font loading: ${exportedError}`); + 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.extra, + exportedData.charProcOperatorList + ); + + fontLoader + .bind(font) + .catch(() => handler.sendWithPromise("FontFallback", { id })) + .finally(() => { + if (!font.fontExtraProperties && font.data) { + // 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(); + } + commonObjs.resolve(id, font); + }); + break; + case "CopyLocalImage": + const { imageRef } = exportedData; + assert(imageRef, "The imageRef must be defined."); + + for (const page of pages.values()) { + for (const [, data] of page.objs) { + if (data?.ref !== imageRef) { + continue; + } + if (!data.dataLen) { + return null; + } + commonObjs.resolve(id, structuredClone(data)); + return data.dataLen; + } + } + break; + case "FontPath": + commonObjs.resolve(id, new FontPathInfo(exportedData)); + break; + case "Image": + commonObjs.resolve(id, exportedData); + break; + case "Pattern": + const pattern = new PatternInfo(exportedData); + commonObjs.resolve(id, pattern.getIR()); + break; + default: + throw new Error(`Got unknown common object type ${type}`); + } + + return null; + }); + + handler.on("obj", ([id, pageIndex, type, imageData]) => { + if (destroyed) { + // Ignore any pending requests if the worker was terminated. + return; + } + + const page = pages.get(pageIndex); + if (page.objs.has(id)) { + return; + } + // Don't store data *after* cleanup has successfully run, see bug 1854145. + if (page._intentStates.size === 0) { + imageData?.bitmap?.close(); // Release any `ImageBitmap` data. + return; + } + + switch (type) { + case "Image": + case "Pattern": + page.objs.resolve(id, imageData); + break; + default: + throw new Error(`Got unknown object type ${type}`); + } + }); +} + +export { setupHandler }; From 0cbebd652428324de523601ffe9780f842b4364f Mon Sep 17 00:00:00 2001 From: Aditi Date: Fri, 10 Oct 2025 03:21:21 +0530 Subject: [PATCH 4/6] Rebase and bug fixes --- src/core/document.js | 112 +++++-- src/core/evaluator.js | 121 ++++++-- src/core/pdf_manager.js | 4 +- src/core/worker.js | 67 +++-- src/display/api.js | 491 +++++++++++++++++++++++-------- src/display/display_utils.js | 109 ------- src/display/filter_factory.js | 4 +- src/display/renderer_worker.js | 131 +++++++-- src/display/worker_options.js | 23 ++ src/shared/handle_objs.js | 54 +++- test/driver.js | 37 ++- test/integration/test_utils.mjs | 25 +- test/integration/viewer_spec.mjs | 40 ++- test/unit/jasmine-boot.js | 2 + web/base_pdf_page_view.js | 23 +- web/pdf_page_detail_view.js | 7 +- web/pdf_page_view.js | 4 + web/pdf_thumbnail_view.js | 17 +- 18 files changed, 902 insertions(+), 369 deletions(-) diff --git a/src/core/document.js b/src/core/document.js index 0360d9aa80304..8269ae420db44 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -67,6 +67,7 @@ import { Catalog } from "./catalog.js"; import { clearGlobalCaches } from "./cleanup_helper.js"; import { DatasetReader } from "./dataset_reader.js"; import { Intersector } from "./intersector.js"; +import { isPDFFunction } from "./function.js"; import { Linearization } from "./parser.js"; import { ObjectLoader } from "./object_loader.js"; import { OperatorList } from "./operator_list.js"; @@ -128,10 +129,11 @@ class Page { }; } - #createPartialEvaluator(handler) { + #createPartialEvaluator(handler, rendererHandler = null) { return new PartialEvaluator({ xref: this.xref, handler, + rendererHandler, pageIndex: this.pageIndex, idFactory: this._localIdFactory, fontCache: this.fontCache, @@ -359,11 +361,21 @@ class Page { await Promise.all(promises); } - async saveNewAnnotations(handler, task, annotations, imagePromises, changes) { + async saveNewAnnotations( + handler, + task, + annotations, + imagePromises, + changes, + rendererHandler = null + ) { if (this.xfaFactory) { throw new Error("XFA: Cannot save new annotations."); } - const partialEvaluator = this.#createPartialEvaluator(handler); + const partialEvaluator = this.#createPartialEvaluator( + handler, + rendererHandler + ); const deletedAnnotations = new RefSetCache(); const existingAnnotations = new RefSet(); @@ -405,8 +417,17 @@ class Page { } } - async save(handler, task, annotationStorage, changes) { - const partialEvaluator = this.#createPartialEvaluator(handler); + async save( + handler, + task, + annotationStorage, + changes, + rendererHandler = null + ) { + const partialEvaluator = this.#createPartialEvaluator( + handler, + rendererHandler + ); // Fetch the page's annotations and save the content // in case of interactive form fields. @@ -459,7 +480,7 @@ class Page { async getOperatorList({ handler, - rendererHandler, + rendererHandler = null, sink, task, intent, @@ -472,7 +493,10 @@ class Page { const contentStreamPromise = this.getContentStream(); const resourcesPromise = this.loadResources(RESOURCES_KEYS_OPERATOR_LIST); - const partialEvaluator = this.#createPartialEvaluator(handler); + const partialEvaluator = this.#createPartialEvaluator( + handler, + rendererHandler + ); const newAnnotsByPage = !this.xfaFactory ? getNewAnnotationsMap(annotationStorage) @@ -551,12 +575,49 @@ class Page { RESOURCES_KEYS_OPERATOR_LIST ); const opList = new OperatorList(intent, sink); + + let hasFilterOps = false; + const extGState = resources.get("ExtGState"); + if (extGState instanceof Dict) { + for (const [, gState] of extGState) { + if (!(gState instanceof Dict)) { + continue; + } + + const tr = gState.get("TR"); + if (tr !== undefined) { + if (Array.isArray(tr)) { + const trArray = gState.getArray("TR") || tr; + if (!trArray.every(entry => isName(entry, "Identity"))) { + hasFilterOps = true; + break; + } + } else if (!isName(tr, "Identity")) { + hasFilterOps = true; + break; + } + } + + const sMask = gState.get("SMask"); + if (!sMask || isName(sMask, "None") || !(sMask instanceof Dict)) { + continue; + } + const sMaskType = sMask.get("S"); + const sMaskTR = sMask.get("TR"); + if (isName(sMaskType, "Luminosity") || isPDFFunction(sMaskTR)) { + hasFilterOps = true; + break; + } + } + } + handler.send("StartRenderPage", { transparency: partialEvaluator.hasBlendModes( resources, this.nonBlendModesSet ), pageIndex, + hasFilterOps, cacheKey, }); @@ -660,6 +721,7 @@ class Page { async extractTextContent({ handler, + rendererHandler = null, task, includeMarkedContent, disableNormalization, @@ -680,7 +742,10 @@ class Page { RESOURCES_KEYS_TEXT_CONTENT ); - const partialEvaluator = this.#createPartialEvaluator(handler); + const partialEvaluator = this.#createPartialEvaluator( + handler, + rendererHandler + ); return partialEvaluator.getTextContent({ stream: contentStream, @@ -727,7 +792,7 @@ class Page { return tree; } - async getAnnotationsData(handler, task, intent) { + async getAnnotationsData(handler, task, intent, rendererHandler = null) { const annotations = await this._parsedAnnotations; if (annotations.length === 0) { return annotations; @@ -752,7 +817,10 @@ class Page { } if (annotation.hasTextContent && isVisible) { - partialEvaluator ??= this.#createPartialEvaluator(handler); + partialEvaluator ??= this.#createPartialEvaluator( + handler, + rendererHandler + ); textContentPromises.push( annotation @@ -778,6 +846,7 @@ class Page { textContentPromises.push( this.extractTextContent({ handler, + rendererHandler, task, includeMarkedContent: false, disableNormalization: false, @@ -883,7 +952,8 @@ class Page { task, types, promises, - annotationGlobals + annotationGlobals, + rendererHandler = null ) { const { pageIndex } = this; @@ -917,7 +987,10 @@ class Page { } annotation.data.pageIndex = pageIndex; if (annotation.hasTextContent && annotation.viewable) { - const partialEvaluator = this.#createPartialEvaluator(handler); + const partialEvaluator = this.#createPartialEvaluator( + handler, + rendererHandler + ); await annotation.extractTextContent(partialEvaluator, task, [ -Infinity, -Infinity, @@ -1311,7 +1384,7 @@ class PDFDocument { this.xfaFactory.setImages(xfaImages); } - async #loadXfaFonts(handler, task) { + async #loadXfaFonts(handler, task, rendererHandler = null) { const acroForm = await this.pdfManager.ensureCatalog("acroForm"); if (!acroForm) { return; @@ -1337,6 +1410,7 @@ class PDFDocument { const partialEvaluator = new PartialEvaluator({ xref: this.xref, handler, + rendererHandler, pageIndex: -1, idFactory: this._globalIdFactory, fontCache, @@ -1449,9 +1523,9 @@ class PDFDocument { this.xfaFactory.appendFonts(pdfFonts, reallyMissingFonts); } - loadXfaResources(handler, task) { + loadXfaResources(handler, task, rendererHandler = null) { return Promise.all([ - this.#loadXfaFonts(handler, task).catch(() => { + this.#loadXfaFonts(handler, task, rendererHandler).catch(() => { // Ignore errors, to allow the document to load. }), this.#loadXfaImages(), @@ -1817,12 +1891,16 @@ class PDFDocument { } } - async fontFallback(id, handler) { + async fontFallback(id, handler, rendererHandler = null) { const { catalog, pdfManager } = this; for (const translatedFont of await Promise.all(catalog.fontCache)) { if (translatedFont.loadedName === id) { - translatedFont.fallback(handler, pdfManager.evaluatorOptions); + translatedFont.fallback( + handler, + pdfManager.evaluatorOptions, + rendererHandler + ); return; } } diff --git a/src/core/evaluator.js b/src/core/evaluator.js index f0706f1f519b1..2d1ac31c56374 100644 --- a/src/core/evaluator.js +++ b/src/core/evaluator.js @@ -226,6 +226,7 @@ class PartialEvaluator { constructor({ xref, handler, + rendererHandler = null, pageIndex, idFactory, fontCache, @@ -238,6 +239,7 @@ class PartialEvaluator { }) { this.xref = xref; this.handler = handler; + this.rendererHandler = rendererHandler; this.pageIndex = pageIndex; this.idFactory = idFactory; this.fontCache = fontCache; @@ -554,20 +556,31 @@ class PartialEvaluator { ) { assert(Number.isInteger(imgData.dataLen), "Expected dataLen to be set."); } - const transfers = imgData ? [imgData.bitmap || imgData.data.buffer] : null; - if (this.parsingType3Font || cacheGlobally) { - return this.handler.send( - "commonobj", - [objId, "Image", imgData], - transfers + const action = this.parsingType3Font || cacheGlobally ? "commonobj" : "obj"; + + const buildArgs = data => + action === "commonobj" + ? [objId, "Image", data] + : [objId, this.pageIndex, "Image", data]; + + const getTransfers = data => { + if (!data) { + return null; + } + const transferable = data.bitmap || data.data?.buffer; + return transferable ? [transferable] : null; + }; + + if (this.rendererHandler) { + const clonedData = imgData ? structuredClone(imgData) : imgData; + this.rendererHandler.send( + action, + buildArgs(clonedData), + getTransfers(clonedData) ); } - return this.handler.send( - "obj", - [objId, this.pageIndex, "Image", imgData], - transfers - ); + this.handler.send(action, buildArgs(imgData), getTransfers(imgData)); } async buildPaintImageXObject({ @@ -791,7 +804,7 @@ class PartialEvaluator { // globally, check if the image is still cached locally on the main-thread // to avoid having to re-parse the image (since that can be slow). if (w * h > 250000 || hasMask) { - const localLength = await this.sendWithPromise("commonobj", [ + const localLength = await this.handler.sendWithPromise("commonobj", [ objId, "CopyLocalImage", { imageRef }, @@ -802,6 +815,21 @@ class PartialEvaluator { this.globalImageCache.addByteSize(imageRef, localLength); return; } + + // ImageRef not found on main thread - check renderer worker if + // it exists + if (this.rendererHandler) { + const rendererLength = await this.rendererHandler.sendWithPromise( + "commonobj", + [objId, "CopyLocalImage", { imageRef }] + ); + + if (rendererLength) { + this.globalImageCache.setData(imageRef, globalCacheData); + this.globalImageCache.addByteSize(imageRef, rendererLength); + return; + } + } } } @@ -1024,7 +1052,7 @@ class PartialEvaluator { } state.font = translated.font; - translated.send(this.handler); + translated.send(this.handler, this.rendererHandler); return translated.loadedName; } @@ -1046,7 +1074,8 @@ class PartialEvaluator { font, glyphs, this.handler, - this.options + this.options, + this.rendererHandler ); } } @@ -1520,15 +1549,28 @@ class PartialEvaluator { id = `${this.idFactory.getDocId()}_type3_${id}`; } localShadingPatternCache.set(shading, id); - - if (this.parsingType3Font) { - const transfers = []; - const patternBuffer = PatternInfo.write(patternIR); - transfers.push(patternBuffer); - this.handler.send("commonobj", [id, "Pattern", patternBuffer], transfers); - } else { - this.handler.send("obj", [id, this.pageIndex, "Pattern", patternIR]); + const patternBuffer = PatternInfo.write(patternIR); + const action = this.parsingType3Font ? "commonobj" : "obj"; + if (this.rendererHandler) { + const clonedBuffer = patternBuffer.slice(0); + const clonedTransfers = clonedBuffer ? [clonedBuffer] : []; + this.rendererHandler.send( + action, + action === "commonobj" + ? [id, "Pattern", clonedBuffer] + : [id, this.pageIndex, "Pattern", clonedBuffer], + clonedTransfers + ); } + + const transfers = patternBuffer ? [patternBuffer] : []; + this.handler.send( + action, + action === "commonobj" + ? [id, "Pattern", patternBuffer] + : [id, this.pageIndex, "Pattern", patternBuffer], + transfers + ); return id; } @@ -4728,7 +4770,13 @@ class PartialEvaluator { return new Font(fontName.name, fontFile, newProperties, this.options); } - static buildFontPaths(font, glyphs, handler, evaluatorOptions) { + static buildFontPaths( + font, + glyphs, + handler, + evaluatorOptions, + rendererHandler = null + ) { function buildPath(fontChar) { const glyphName = `${font.loadedName}_path_${fontChar}`; try { @@ -4736,6 +4784,14 @@ class PartialEvaluator { return; } const buffer = FontPathInfo.write(font.renderer.getPathJs(fontChar)); + if (rendererHandler) { + const clonedBuffer = buffer.slice(0); + rendererHandler.send( + "commonobj", + [glyphName, "FontPath", clonedBuffer], + [clonedBuffer] + ); + } handler.send("commonobj", [glyphName, "FontPath", buffer], [buffer]); } catch (reason) { if (evaluatorOptions.ignoreErrors) { @@ -4781,7 +4837,7 @@ class TranslatedFont { this.type3Dependencies = font.isType3Font ? new Set() : null; } - send(handler) { + send(handler, rendererHandler = null) { if (this.#sent) { return; } @@ -4795,6 +4851,18 @@ class TranslatedFont { fontData.data = FontInfo.write(fontData.data); transfer.push(fontData.data); } + if (rendererHandler) { + const clonedFontData = structuredClone(fontData); + const clonedTransfer = []; + if (clonedFontData.data) { + clonedTransfer.push(clonedFontData.data); + } + rendererHandler.send( + "commonobj", + [this.loadedName, "Font", clonedFontData], + clonedTransfer + ); + } handler.send("commonobj", [this.loadedName, "Font", fontData], transfer); // future path: switch to a SharedArrayBuffer // const sab = new SharedArrayBuffer(data.byteLength); @@ -4803,7 +4871,7 @@ class TranslatedFont { // handler.send("commonobj", [this.loadedName, "Font", sab]); } - fallback(handler, evaluatorOptions) { + fallback(handler, evaluatorOptions, rendererHandler = null) { if (!this.font.data) { return; } @@ -4819,7 +4887,8 @@ class TranslatedFont { this.font, /* glyphs = */ this.font.glyphCacheValues, handler, - evaluatorOptions + evaluatorOptions, + rendererHandler ); } diff --git a/src/core/pdf_manager.js b/src/core/pdf_manager.js index 83ea377bc47a2..1c9547119322d 100644 --- a/src/core/pdf_manager.js +++ b/src/core/pdf_manager.js @@ -113,8 +113,8 @@ class BasePdfManager { return this.pdfDocument.getPage(pageIndex); } - fontFallback(id, handler) { - return this.pdfDocument.fontFallback(id, handler); + fontFallback(id, handler, rendererHandler = null) { + return this.pdfDocument.fontFallback(id, handler, rendererHandler); } cleanup(manuallyTriggered = false) { diff --git a/src/core/worker.js b/src/core/worker.js index 33a214ab8b652..c55814202067a 100644 --- a/src/core/worker.js +++ b/src/core/worker.js @@ -83,7 +83,6 @@ class WorkerMessageHandler { static setup(handler, port) { let testMessageProcessed = false; - let rendererHandler = null; handler.on("test", data => { if (testMessageProcessed) { @@ -97,19 +96,21 @@ class WorkerMessageHandler { handler.on("configure", data => { setVerbosityLevel(data.verbosity); - rendererHandler = new MessageHandler( - "worker-channel", - "renderer-channel", - data.channelPort - ); }); - handler.on("GetDocRequest", data => - this.createDocumentHandler(data, port, rendererHandler) - ); + handler.on("GetDocRequest", data => { + const { channelPort } = data; + if (channelPort) { + data.channelPort = null; + } + const rendererHandler = channelPort + ? new MessageHandler("worker-channel", "renderer-channel", channelPort) + : null; + return this.createDocumentHandler(data, port, rendererHandler); + }); } - static createDocumentHandler(docParams, port, rendererHandler) { + static createDocumentHandler(docParams, port, rendererHandler = null) { // This context is actually holds references on pdfManager and handler, // until the latter is destroyed. let pdfManager; @@ -495,7 +496,8 @@ class WorkerMessageHandler { task, types, annotationPromises, - annotationGlobals + annotationGlobals, + rendererHandler ) || [] ); }) @@ -545,16 +547,18 @@ class WorkerMessageHandler { const task = new WorkerTask(`GetAnnotations: page ${pageIndex}`); startWorkerTask(task); - return page.getAnnotationsData(handler, task, intent).then( - data => { - finishWorkerTask(task); - return data; - }, - reason => { - finishWorkerTask(task); - throw reason; - } - ); + return page + .getAnnotationsData(handler, task, intent, rendererHandler) + .then( + data => { + finishWorkerTask(task); + return data; + }, + reason => { + finishWorkerTask(task); + throw reason; + } + ); }); }); @@ -730,7 +734,8 @@ class WorkerMessageHandler { task, annotations, imagePromises, - changes + changes, + rendererHandler ) .finally(function () { finishWorkerTask(task); @@ -776,7 +781,13 @@ class WorkerMessageHandler { startWorkerTask(task); return page - .save(handler, task, annotationStorage, changes) + .save( + handler, + task, + annotationStorage, + changes, + rendererHandler + ) .finally(function () { finishWorkerTask(task); }); @@ -927,6 +938,7 @@ class WorkerMessageHandler { page .extractTextContent({ handler, + rendererHandler, task, sink, includeMarkedContent, @@ -965,11 +977,11 @@ class WorkerMessageHandler { }); handler.on("FontFallback", function (data) { - return pdfManager.fontFallback(data.id, handler); + return pdfManager.fontFallback(data.id, handler, rendererHandler); }); - rendererHandler.on("FontFallback", function (data) { - return pdfManager.fontFallback(data.id, rendererHandler); + rendererHandler?.on("FontFallback", function (data) { + return pdfManager.fontFallback(data.id, handler, rendererHandler); }); handler.on("Cleanup", function (data) { @@ -1000,6 +1012,9 @@ class WorkerMessageHandler { return Promise.all(waitOn).then(function () { // Notice that even if we destroying handler, resolved response promise // must be sent back. + rendererHandler?.destroy(); + rendererHandler?.comObj?.close?.(); + rendererHandler = null; handler.destroy(); handler = null; }); diff --git a/src/display/api.js b/src/display/api.js index dd7badf9f38b1..0fd6a91209e06 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -43,7 +43,6 @@ import { isValidFetchUrl, PagesMapper, PageViewport, - PDFObjects, RenderingCancelledException, StatTimer, } from "./display_utils.js"; @@ -77,6 +76,7 @@ import { PDFDataTransportStream } from "./transport_stream.js"; import { PDFFetchStream } from "display-fetch_stream"; import { PDFNetworkStream } from "display-network"; import { PDFNodeStream } from "display-node_stream"; +import { PDFObjects } from "./pdf_objects.js"; import { setupHandler } from "../shared/handle_objs.js"; import { TextLayer } from "./text_layer.js"; import { XfaText } from "./xfa_text.js"; @@ -229,6 +229,8 @@ const RENDERING_CANCELLED_TIMEOUT = 100; // ms * The default value is {DOMFilterFactory}. * @property {boolean} [enableHWA] - Enables hardware acceleration for * rendering. The default value is `false`. + * @property {boolean} [disableWorkerRendering] - Disables rendering of pages in + * a worker thread. The default value is `false`. */ /** @@ -337,6 +339,10 @@ function getDocument(src = {}) { ? NodeFilterFactory : DOMFilterFactory); const enableHWA = src.enableHWA === true; + const disableWorkerRendering = + src.disableWorkerRendering === true || + typeof Worker === "undefined" || + typeof MessageChannel === "undefined"; const useWasm = src.useWasm !== false; // Parameters whose default values depend on other parameters. @@ -392,7 +398,12 @@ function getDocument(src = {}) { : new WasmFactory({ baseUrl: wasmUrl }), }; - const workerChannel = new MessageChannel(); + let rendererHandler = null; + let workerChannel = null; + + if (!disableWorkerRendering) { + workerChannel = new MessageChannel(); + } if (!worker) { // Worker was not provided -- creating and owning our own. If message port @@ -400,14 +411,15 @@ function getDocument(src = {}) { worker = PDFWorker.create({ verbosity, port: GlobalWorkerOptions.workerPort, - channelPort: workerChannel.port1, }); task._worker = worker; } - const renderer = new RendererWorker(workerChannel.port2, enableHWA); - const rendererHandler = renderer.handler; - task.renderer = renderer; + if (workerChannel) { + const renderer = new RendererWorker(workerChannel.port2, enableHWA); + rendererHandler = renderer.handler; + task.renderer = renderer; + } const docParams = { docId, @@ -422,6 +434,7 @@ function getDocument(src = {}) { length, docBaseUrl, enableXfa, + channelPort: workerChannel?.port1, evaluatorOptions: { maxImageSize, disableFontFace, @@ -460,10 +473,17 @@ function getDocument(src = {}) { throw new Error("Worker was destroyed"); } + const transfers = []; + if (data) { + transfers.push(data.buffer); + } + if (workerChannel) { + transfers.push(workerChannel.port1); + } const workerIdPromise = worker.messageHandler.sendWithPromise( "GetDocRequest", docParams, - data ? [data.buffer] : null + transfers.length > 0 ? transfers : null ); let networkStream; @@ -520,7 +540,6 @@ function getDocument(src = {}) { ); task._transport = transport; messageHandler.send("Ready", null); - rendererHandler.send("Ready", null); }); }) .catch(task._capability.reject); @@ -620,7 +639,7 @@ class PDFDocumentLoadingTask { this._worker?.destroy(); this._worker = null; - this.#renderer.destroy(); + this.#renderer?.destroy(); this.#renderer = null; } @@ -1479,8 +1498,8 @@ class PDFPageProxy { * resolved when the page finishes rendering. */ render({ - canvasContext, - canvas = canvasContext.canvas, + canvasContext = null, + canvas = canvasContext?.canvas || null, viewport, intent = "display", annotationMode = AnnotationMode.ENABLE, @@ -1507,9 +1526,6 @@ class PDFPageProxy { // this call to render. this.#pendingCleanup = false; - optionalContentConfigPromise ||= - this._transport.getOptionalContentConfig(renderingIntent); - let intentState = this._intentStates.get(cacheKey); if (!intentState) { intentState = Object.create(null); @@ -1533,6 +1549,7 @@ class PDFPageProxy { argsArray: [], lastChunk: false, separateAnnots: null, + hasFilterOps: false, }; this._stats?.time("Page Request"); @@ -1592,6 +1609,18 @@ class PDFPageProxy { } }; + // Rendering in the worker cannot safely use the DOM/SVG `url(...)` filter + // path, hence we must fall back to main-thread rendering when such + // operations are present. + // Debugger rendering must also run on the main-thread. + const renderInWorker = + canvasContext === null && + this._transport.rendererHandler !== null && + !shouldRecordOperations && + !recordForDebugger && + pageColors === null && + intentState.operatorList.hasFilterOps === false; + const internalRenderTask = new InternalRenderTask({ callback: complete, // Only include the required properties, and *not* the entire object. @@ -1619,36 +1648,67 @@ class PDFPageProxy { useRequestAnimationFrame: !intentPrint, pdfBug: this._pdfBug, pageColors, + enableHWA: this._transport.enableHWA, rendererHandler: this._transport.rendererHandler, operationsFilter, + renderInWorker, }); (intentState.renderTasks ||= new Set()).add(internalRenderTask); const renderTask = internalRenderTask.task; + let optionalContentConfigDataPromise = null; + + if (renderInWorker) { + optionalContentConfigDataPromise = + this._transport.getOptionalContentConfigData(); + } else { + optionalContentConfigPromise ||= + this._transport.getOptionalContentConfig(renderingIntent); + } + Promise.all([ intentState.displayReadyCapability.promise, optionalContentConfigPromise, + optionalContentConfigDataPromise, ]) - .then(([transparency, optionalContentConfig]) => { - if (this.destroyed) { - complete(); - return; - } - this._stats?.time("Rendering"); + .then( + ([transparency, optionalContentConfig, optionalContentConfigData]) => { + if (this.destroyed) { + complete(); + return; + } + this._stats?.time("Rendering"); + + if ( + optionalContentConfig && + !(optionalContentConfig.renderingIntent & renderingIntent) + ) { + throw new Error( + "Must use the same `intent`-argument when calling the `PDFPageProxy.render` " + + "and `PDFDocumentProxy.getOptionalContentConfig` methods." + ); + } - if (!(optionalContentConfig.renderingIntent & renderingIntent)) { - throw new Error( - "Must use the same `intent`-argument when calling the `PDFPageProxy.render` " + - "and `PDFDocumentProxy.getOptionalContentConfig` methods." - ); + const optionalContentConfigState = + renderInWorker && + optionalContentConfig && + !optionalContentConfig.hasInitialVisibility + ? Array.from(optionalContentConfig, ([id, group]) => [ + id, + group.visible, + ]) + : null; + internalRenderTask.initializeGraphics({ + transparency, + renderingIntent, + optionalContentConfig, + optionalContentConfigData, + optionalContentConfigState, + }); + internalRenderTask.operatorListChanged(); } - internalRenderTask.initializeGraphics({ - transparency, - optionalContentConfig, - }); - internalRenderTask.operatorListChanged(); - }) + ) .catch(complete); return renderTask; @@ -1701,6 +1761,7 @@ class PDFPageProxy { argsArray: [], lastChunk: false, separateAnnots: null, + hasFilterOps: false, }; this._stats?.time("Page Request"); @@ -1857,13 +1918,15 @@ class PDFPageProxy { /** * @private */ - _startRenderPage(transparency, cacheKey) { + _startRenderPage(transparency, cacheKey, hasFilterOps = false) { const intentState = this._intentStates.get(cacheKey); if (!intentState) { return; // Rendering was cancelled. } this._stats?.timeEnd("Page Request"); + intentState.operatorList.hasFilterOps ||= hasFilterOps === true; + // TODO Refactor RenderPageRequest to separate rendering // and operator list logic intentState.displayReadyCapability?.resolve(transparency); @@ -2039,12 +2102,6 @@ class PDFPageProxy { get stats() { return this._stats; } - - resetCanvas(taskID) { - this._transport.rendererHandler.send("resetCanvas", { - taskID, - }); - } } /** @@ -2053,8 +2110,6 @@ class PDFPageProxy { * @property {Worker} [port] - The `workerPort` object. * @property {number} [verbosity] - Controls the logging level; * the constants from {@link VerbosityLevel} should be used. - * @property {MessagePort} [channelPort] - The channel port to use for - * communication with the renderer thread. */ /** @@ -2136,12 +2191,10 @@ class PDFWorker { name = null, port = null, verbosity = getVerbosityLevel(), - channelPort = null, } = {}) { this.name = name; this.destroyed = false; this.verbosity = verbosity; - this.channelPort = channelPort; if (port) { if (PDFWorker.#workerPorts.has(port)) { @@ -2174,14 +2227,7 @@ class PDFWorker { #resolve() { this.#capability.resolve(); // Send global setting, e.g. verbosity level. - this.#messageHandler.send( - "configure", - { - verbosity: this.verbosity, - channelPort: this.channelPort, - }, - [this.channelPort] - ); + this.#messageHandler.send("configure", { verbosity: this.verbosity }); } /** @@ -2422,22 +2468,37 @@ class RendererWorker { #handler; + #capability = Promise.withResolvers(); + constructor(channelPort, enableHWA) { const src = + GlobalWorkerOptions.rendererSrc || // eslint-disable-next-line no-nested-ternary - typeof PDFJSDev === "undefined" - ? "../src/pdf.worker.js" + (typeof PDFJSDev === "undefined" + ? new URL("../pdf.renderer.js", import.meta.url) : PDFJSDev.test("MOZCENTRAL") - ? "resource://pdf.js/build/pdf.worker.mjs" - : "../build/pdf.worker.mjs"; + ? "resource://pdf.js/build/pdf.renderer.mjs" + : "../build/pdf.renderer.mjs"); this.#worker = new Worker(src, { type: "module" }); this.#handler = new MessageHandler("main", "renderer", this.#worker); - this.#handler.send("configure", { channelPort, enableHWA }, [channelPort]); + this.#handler.send( + "configure", + { channelPort, enableHWA }, + channelPort ? [channelPort] : undefined + ); this.#handler.on("ready", () => { - // DO NOTHING + this.#capability.resolve(); }); } + /** + * Promise for worker initialization completion. + * @type {Promise} + */ + get promise() { + return this.#capability.promise; + } + get handler() { return this.#handler; } @@ -2480,7 +2541,7 @@ class WorkerTransport { params, factory, enableHWA, - rendererHandler + rendererHandler = null ) { this.messageHandler = messageHandler; this.rendererHandler = rendererHandler; @@ -2674,13 +2735,16 @@ class WorkerTransport { const terminated = this.messageHandler.sendWithPromise("Terminate", null); waitOn.push(terminated); - const terminatedRenderer = this.rendererHandler.sendWithPromise( - "Terminate", - null - ); - waitOn.push(terminatedRenderer); + if (this.rendererHandler) { + const terminatedRenderer = this.rendererHandler.sendWithPromise( + "Terminate", + null + ); + waitOn.push(terminatedRenderer); + } Promise.all(waitOn).then(() => { + this.commonObjs.clear(); this.fontLoader.clear(); this.#methodPromises.clear(); this.filterFactory.destroy(); @@ -2700,11 +2764,8 @@ class WorkerTransport { setupMessageHandler() { const { messageHandler, loadingTask, rendererHandler } = this; - - rendererHandler.on("continue", ({ taskID, arg }) => { - const continueFn = InternalRenderTask.continueFnMap.get(taskID); - assert(continueFn, `No continue function for taskID: ${taskID}`); - continueFn.call(arg); + rendererHandler?.on("continue", ({ taskID }) => { + InternalRenderTask.continueFnMap.get(taskID)?.(); }); messageHandler.on("GetReader", (data, sink) => { @@ -2866,15 +2927,20 @@ class WorkerTransport { } const page = this.#pageCache.get(data.pageIndex); - page._startRenderPage(data.transparency, data.cacheKey); + page._startRenderPage( + data.transparency, + data.cacheKey, + data.hasFilterOps + ); }); setupHandler( messageHandler, - this.destroyed, + () => this.destroyed, this.commonObjs, this.#pageCache, - this.fontLoader + this.fontLoader, + { pdfBug: this._params.pdfBug } ); messageHandler.on("DocProgress", data => { @@ -3075,6 +3141,10 @@ class WorkerTransport { ); } + getOptionalContentConfigData() { + return this.#cacheSimpleMethod("GetOptionalContentConfig"); + } + getPermissions() { return this.messageHandler.sendWithPromise("GetPermissions", null); } @@ -3208,8 +3278,20 @@ class RenderTask { ); } + /** + * The unique ID for this rendering task. + * @type {bigint} + */ get taskID() { - return this.#internalRenderTask.taskID; + return this._internalRenderTask.taskID; + } + + /** + * Whether rendering is happening in a worker thread. + * @type {boolean} + */ + get renderInWorker() { + return this._internalRenderTask._renderInWorker; } } @@ -3239,8 +3321,10 @@ class InternalRenderTask { useRequestAnimationFrame = false, pdfBug = false, pageColors = null, - rendererHandler, + enableHWA = false, + rendererHandler = null, operationsFilter = null, + renderInWorker = true, }) { this.taskID = InternalRenderTask.#taskCounter++; this.callback = callback; @@ -3255,7 +3339,7 @@ class InternalRenderTask { this.filterFactory = filterFactory; this._pdfBug = pdfBug; this.pageColors = pageColors; - + this._enableHWA = enableHWA; this.running = false; this.graphicsReadyCallback = null; this.graphicsReady = false; @@ -3270,12 +3354,16 @@ class InternalRenderTask { this._scheduleNextBound = this._scheduleNext.bind(this); this._nextBound = this._next.bind(this); this._canvas = params.canvas; - this._canvasContext = params.canvas ? null : params.canvasContext; - this._renderInWorker = this._canvasContext === null; + this._canvasContext = params.canvasContext ?? null; + this._dependencyTracker = params.dependencyTracker; + this._renderInWorker = renderInWorker; this.rendererHandler = rendererHandler; InternalRenderTask.continueFnMap.set(this.taskID, this._continueBound); - this._dependencyTracker = params.dependencyTracker; this._operationsFilter = operationsFilter; + this._sentOperatorListLength = 0; + this._executingInWorker = false; + this._pendingContinue = false; + this._endDrawingPromise = null; } get completed() { @@ -3285,7 +3373,20 @@ class InternalRenderTask { }); } - initializeGraphics({ transparency = false, optionalContentConfig }) { + #endDrawing() { + this._endDrawingPromise ||= this.rendererHandler.sendWithPromise("end", { + taskID: this.taskID, + }); + return this._endDrawingPromise; + } + + initializeGraphics({ + transparency = false, + renderingIntent, + optionalContentConfig = null, + optionalContentConfigData = null, + optionalContentConfigState = null, + }) { if (this.cancelled) { return; } @@ -3300,43 +3401,111 @@ class InternalRenderTask { InternalRenderTask.#canvasInUse.add(this._canvas); } - if (this._pdfBug && globalThis.StepperManager?.enabled) { - this.stepper = globalThis.StepperManager.create(this._pageIndex); - this.stepper.init(this.operatorList); - this.stepper.nextBreakPoint = this.stepper.getNextBreakPoint(); - } const { viewport, transform, background, dependencyTracker } = this.params; - - // When printing in Firefox, we get a specific context in mozPrintCallback - // which cannot be created from the canvas itself. In this case, we don't - // render in the worker and use the context directly. if (this._renderInWorker) { - const offscreen = this._canvas.transferControlToOffscreen(); - this.rendererHandler.send( - "init", - { - pageIndex: this._pageIndex, - canvas: offscreen, - map: this.annotationCanvasMap, - colors: this.pageColors, - taskID: this.taskID, - transform, - viewport, - transparency, - background, - optionalContentConfig, - dependencyTracker, - }, - [offscreen] - ); - } else { + try { + const offscreen = this._canvas.transferControlToOffscreen(); + const taskID = this.taskID; + const rendererHandler = this.rendererHandler; + Object.defineProperty(this._canvas, "resetWorkerCanvas", { + value() { + try { + rendererHandler.send("resetCanvas", { taskID }); + } catch { + // Ignore errors if the renderer worker has been destroyed. + } + }, + configurable: true, + }); + Object.defineProperty(this._canvas, "getImageData", { + value(x, y, width, height) { + return rendererHandler.sendWithPromise("getImageData", { + taskID, + x, + y, + width, + height, + }); + }, + configurable: true, + }); + Object.defineProperty(this._canvas, "isCanvasMonochrome", { + value(x, y, width, height, color) { + return rendererHandler.sendWithPromise("isCanvasMonochrome", { + taskID, + x, + y, + width, + height, + color, + }); + }, + configurable: true, + }); + this.rendererHandler.send( + "init", + { + pageIndex: this._pageIndex, + canvas: offscreen, + map: this.annotationCanvasMap, + colors: this.pageColors, + taskID: this.taskID, + transform, + viewport, + transparency, + background, + renderingIntent, + optionalContentConfig: optionalContentConfigData, + optionalContentConfigState, + dependencyTracker, + }, + [offscreen] + ); + } catch (ex) { + // If transferControlToOffscreen fails (e.g., canvas already + // has a context), fall back to main thread rendering + warn( + `Failed to transfer canvas control to worker: ${ex.message}. ` + + `Falling back to main thread rendering.` + ); + this._renderInWorker = false; + } + } + + if (!this._renderInWorker) { + let config = optionalContentConfig; + if (!config) { + config = new OptionalContentConfig( + optionalContentConfigData, + renderingIntent + ); + if (optionalContentConfigState) { + for (const [id, visible] of optionalContentConfigState) { + config.setVisibility(id, visible, /* preserveRB = */ false); + } + } + } + // When printing in Firefox, we get a specific context in mozPrintCallback + // which cannot be created from the canvas itself. In this case, we don't + // render in the worker and use the context directly. + if (this._pdfBug && globalThis.StepperManager?.enabled) { + this.stepper = globalThis.StepperManager.create(this._pageIndex); + this.stepper.init(this.operatorList); + this.stepper.nextBreakPoint = this.stepper.getNextBreakPoint(); + } + const canvasContext = + this._canvasContext || + this._canvas.getContext("2d", { + alpha: false, + willReadFrequently: !this._enableHWA, + }); this.gfx = new CanvasGraphics( - this._canvasContext, + canvasContext, this.commonObjs, this.objs, this.canvasFactory, this.filterFactory, - { optionalContentConfig }, + { optionalContentConfig: config }, this.annotationCanvasMap, this.pageColors, dependencyTracker @@ -3357,10 +3526,19 @@ class InternalRenderTask { cancel(error = null, extraDelay = 0) { this.running = false; this.cancelled = true; - if (this._renderInWorker) { - this.rendererHandler.send("end", { taskID: this.taskID }); - } else { - this.gfx.endDrawing(); + + if (this.graphicsReady) { + if (this._renderInWorker) { + try { + if (!this._endDrawingPromise) { + this.rendererHandler?.send("end", { taskID: this.taskID }); + } + } catch { + // Ignore errors if the renderer worker has been destroyed. + } + } else { + this.gfx?.endDrawing(); + } } InternalRenderTask.continueFnMap.delete(this.taskID); if (this.#rAF) { @@ -3383,18 +3561,34 @@ class InternalRenderTask { this.graphicsReadyCallback ||= this._continueBound; return; } - this.gfx.dependencyTracker?.growOperationsCount( - this.operatorList.fnArray.length - ); - this.stepper?.updateOperatorList(this.operatorList); - + if (!this._renderInWorker) { + this.gfx.dependencyTracker?.growOperationsCount( + this.operatorList.fnArray.length + ); + this.stepper?.updateOperatorList(this.operatorList); + } if (this.running) { return; } + + // For worker mode, only continue if there are operations to process + // OR if this is the last chunk (need to complete even if empty) + if ( + this._renderInWorker && + this.operatorListIdx >= this.operatorList.argsArray.length && + !this.operatorList.lastChunk + ) { + return; + } + this._continue(); } _continue() { + if (this._renderInWorker && this._executingInWorker) { + this._pendingContinue = true; + return; + } this.running = true; if (this.cancelled) { return; @@ -3421,17 +3615,59 @@ class InternalRenderTask { if (this.cancelled) { return; } + const { operatorList, operatorListIdx, taskID } = this; if (this._renderInWorker) { - this.operatorListIdx = await this.rendererHandler.sendWithPromise( - "execute", - { - operatorList, - operatorListIdx, - taskID, - operationsFilter: this._operationsFilter, - } + const operatorListArgsArrayLen = operatorList.argsArray.length; + + const sentLength = Math.min( + this._sentOperatorListLength, + operatorListArgsArrayLen ); + const fnArray = + sentLength < operatorListArgsArrayLen + ? operatorList.fnArray.slice(sentLength, operatorListArgsArrayLen) + : null; + const argsArray = + sentLength < operatorListArgsArrayLen + ? operatorList.argsArray.slice(sentLength, operatorListArgsArrayLen) + : null; + this._executingInWorker = true; + try { + this.operatorListIdx = await this.rendererHandler.sendWithPromise( + "execute", + { + fnArray, + argsArray, + operatorListIdx, + taskID, + operationsFilter: this._operationsFilter, + } + ); + this._sentOperatorListLength = operatorListArgsArrayLen; + } finally { + this._executingInWorker = false; + } + if (this.cancelled) { + return; + } + + if (this._pendingContinue) { + this._pendingContinue = false; + this._continue(); + return; + } + + // The operatorList can grow on the main thread while we're waiting for + // the renderer worker. If the worker reached the end of what it was + // given, make sure that rendering continues for the new operations. + if ( + this.operatorListIdx === operatorListArgsArrayLen && + operatorListArgsArrayLen < operatorList.argsArray.length + ) { + this._continue(); + return; + } } else { this.operatorListIdx = this.gfx.executeOperatorList( this.operatorList, @@ -3441,15 +3677,20 @@ class InternalRenderTask { this._operationsFilter ); } + if (this.operatorListIdx === this.operatorList.argsArray.length) { this.running = false; if (this.operatorList.lastChunk) { if (this._renderInWorker) { - this.rendererHandler.send("end", { taskID }); + await this.#endDrawing(); + if (this.cancelled) { + return; + } } else { this.gfx.endDrawing(); } InternalRenderTask.#canvasInUse.delete(this._canvas); + InternalRenderTask.continueFnMap.delete(this.taskID); this.callback(); } } diff --git a/src/display/display_utils.js b/src/display/display_utils.js index 9774a975307a3..a529acef42816 100644 --- a/src/display/display_utils.js +++ b/src/display/display_utils.js @@ -1034,114 +1034,6 @@ function makePathFromDrawOPS(data) { } return path; } -const INITIAL_DATA = Symbol("INITIAL_DATA"); - -/** - * A PDF document and page is built of many objects. E.g. there are objects for - * fonts, images, rendering code, etc. These objects may get processed inside of - * a worker. This class implements some basic methods to manage these objects. - */ -class PDFObjects { - #objs = Object.create(null); - - /** - * Ensures there is an object defined for `objId`. - * - * @param {string} objId - * @returns {Object} - */ - #ensureObj(objId) { - return (this.#objs[objId] ||= { - ...Promise.withResolvers(), - data: INITIAL_DATA, - }); - } - - /** - * If called *without* callback, this returns the data of `objId` but the - * object needs to be resolved. If it isn't, this method throws. - * - * If called *with* a callback, the callback is called with the data of the - * object once the object is resolved. That means, if you call this method - * and the object is already resolved, the callback gets called right away. - * - * @param {string} objId - * @param {function} [callback] - * @returns {any} - */ - get(objId, callback = 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.#ensureObj(objId); - obj.promise.then(() => callback(obj.data)); - return null; - } - // If there isn't a callback, the user expects to get the resolved data - // directly. - const obj = this.#objs[objId]; - // If there isn't an object yet or the object isn't resolved, then the - // data isn't ready yet! - if (!obj || obj.data === INITIAL_DATA) { - throw new Error(`Requesting object that isn't resolved yet ${objId}.`); - } - return obj.data; - } - - /** - * @param {string} objId - * @returns {boolean} - */ - has(objId) { - const obj = this.#objs[objId]; - return !!obj && obj.data !== INITIAL_DATA; - } - - /** - * @param {string} objId - * @returns {boolean} - */ - delete(objId) { - const obj = this.#objs[objId]; - if (!obj || obj.data === INITIAL_DATA) { - // Only allow removing the object *after* it's been resolved. - return false; - } - delete this.#objs[objId]; - return true; - } - - /** - * Resolves the object `objId` with optional `data`. - * - * @param {string} objId - * @param {any} [data] - */ - resolve(objId, data = null) { - const obj = this.#ensureObj(objId); - obj.data = data; - obj.resolve(); - } - - clear() { - for (const objId in this.#objs) { - const { data } = this.#objs[objId]; - data?.bitmap?.close(); // Release any `ImageBitmap` data. - } - this.#objs = Object.create(null); - } - - *[Symbol.iterator]() { - for (const objId in this.#objs) { - const { data } = this.#objs[objId]; - - if (data === INITIAL_DATA) { - continue; - } - yield [objId, data]; - } - } -} /** * Maps between page IDs and page numbers, allowing bidirectional conversion @@ -1386,7 +1278,6 @@ export { PagesMapper, PageViewport, PDFDateString, - PDFObjects, PixelsPerInch, RenderingCancelledException, renderRichText, diff --git a/src/display/filter_factory.js b/src/display/filter_factory.js index acec5d4093da4..e2a45ece958b0 100644 --- a/src/display/filter_factory.js +++ b/src/display/filter_factory.js @@ -49,6 +49,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. @@ -506,4 +508,4 @@ class DOMFilterFactory extends BaseFilterFactory { } } -export { BaseFilterFactory, DOMFilterFactory }; +export { BaseFilterFactory, DOMFilterFactory, WorkerFilterFactory }; diff --git a/src/display/renderer_worker.js b/src/display/renderer_worker.js index bb2464bd436c2..9400e56097f24 100644 --- a/src/display/renderer_worker.js +++ b/src/display/renderer_worker.js @@ -1,11 +1,12 @@ import { assert } from "../shared/util.js"; import { CanvasGraphics } from "./canvas.js"; -import { DOMFilterFactory } from "./filter_factory.js"; import { FontLoader } from "./font_loader.js"; import { MessageHandler } from "../shared/message_handler.js"; import { OffscreenCanvasFactory } from "./canvas_factory.js"; -import { PDFObjects } from "./display_utils.js"; +import { OptionalContentConfig } from "./optional_content_config.js"; +import { PDFObjects } from "./pdf_objects.js"; import { setupHandler } from "../shared/handle_objs.js"; +import { WorkerFilterFactory } from "./filter_factory.js"; class RendererMessageHandler { static #commonObjs = new PDFObjects(); @@ -40,10 +41,6 @@ class RendererMessageHandler { static initializeFromPort(port) { let terminated = false; let mainHandler = new MessageHandler("renderer", "main", port); - mainHandler.send("ready", null); - mainHandler.on("Ready", function () { - // DO NOTHING - }); mainHandler.on("configure", ({ channelPort, enableHWA }) => { this.#enableHWA = enableHWA; @@ -55,14 +52,15 @@ class RendererMessageHandler { this.#canvasFactory = new OffscreenCanvasFactory({ enableHWA, }); - this.#filterFactory = new DOMFilterFactory({}); + this.#filterFactory = new WorkerFilterFactory(); setupHandler( workerHandler, - terminated, + () => terminated, this.#commonObjs, this.#objsMap, - this.#fontLoader + this.#fontLoader, + { renderInWorker: true } ); }); @@ -78,14 +76,25 @@ class RendererMessageHandler { viewport, transparency, background, - optionalContentConfig, + renderingIntent, + optionalContentConfig: optionalContentConfigData, + optionalContentConfigState = null, }) => { assert(!this.#tasks.has(taskID), "Task already initialized"); const ctx = canvas.getContext("2d", { alpha: false, - willReadFrequently: this.#enableHWA, + willReadFrequently: !this.#enableHWA, }); const objs = this.pageObjs(pageIndex); + const optionalContentConfig = new OptionalContentConfig( + optionalContentConfigData, + renderingIntent + ); + if (optionalContentConfigState) { + for (const [id, visible] of optionalContentConfigState) { + optionalContentConfig.setVisibility(id, visible, false); + } + } const gfx = new CanvasGraphics( ctx, this.#commonObjs, @@ -97,23 +106,86 @@ class RendererMessageHandler { colors ); gfx.beginDrawing({ transform, viewport, transparency, background }); - this.#tasks.set(taskID, { canvas, gfx }); + const operatorList = { + fnArray: [], + argsArray: [], + }; + this.#tasks.set(taskID, { + canvas, + ctx, + gfx, + pageIndex, + operatorList, + ended: false, + cleanupRequested: false, + }); + } + ); + + mainHandler.on("getImageData", ({ taskID, x, y, width, height }) => { + if (terminated) { + throw new Error("Renderer worker has been terminated."); + } + const task = this.#tasks.get(taskID); + assert(task !== undefined, "Task not initialized"); + return task.ctx.getImageData(x, y, width, height).data; + }); + + mainHandler.on( + "isCanvasMonochrome", + ({ taskID, x, y, width, height, color }) => { + if (terminated) { + throw new Error("Renderer worker has been terminated."); + } + const task = this.#tasks.get(taskID); + assert(task !== undefined, "Task not initialized"); + + const { data } = task.ctx.getImageData(x, y, width, height); + const view = new Uint32Array(data.buffer); + for (let i = 0, ii = view.length; i < ii; i++) { + if (view[i] !== color) { + return false; + } + } + return true; } ); mainHandler.on( "execute", - async ({ operatorList, operatorListIdx, taskID }) => { + ({ fnArray, argsArray, operatorListIdx, taskID, operationsFilter }) => { if (terminated) { throw new Error("Renderer worker has been terminated."); } const task = this.#tasks.get(taskID); assert(task !== undefined, "Task not initialized"); - return task.gfx.executeOperatorList( - operatorList, + + if (fnArray) { + const { operatorList } = task; + const ii = fnArray.length; + for (let i = 0; i < ii; i++) { + operatorList.fnArray.push(fnArray[i]); + operatorList.argsArray.push(argsArray[i]); + } + } + + const continueFn = () => { + mainHandler.send("continue", { taskID }); + }; + + const newOperatorListIdx = task.gfx.executeOperatorList( + task.operatorList, operatorListIdx, - arg => mainHandler.send("continue", { taskID, arg }) + continueFn, + undefined, + operationsFilter ); + try { + task.ctx.commit?.(); + } catch { + // `commit` isn't supported in all environments. + } + return newOperatorListIdx; } ); @@ -124,6 +196,19 @@ class RendererMessageHandler { const task = this.#tasks.get(taskID); assert(task !== undefined, "Task not initialized"); task.gfx.endDrawing(); + try { + task.ctx.commit?.(); + } catch { + // `commit` isn't supported in all environments. + } + task.ended = true; + task.gfx = null; + task.operatorList = null; + + if (task.cleanupRequested) { + task.canvas.width = task.canvas.height = 0; + this.#tasks.delete(taskID); + } }); mainHandler.on("resetCanvas", ({ taskID }) => { @@ -131,9 +216,15 @@ class RendererMessageHandler { throw new Error("Renderer worker has been terminated."); } const task = this.#tasks.get(taskID); - assert(task !== undefined, "Task not initialized"); - const canvas = task.canvas; - canvas.width = canvas.height = 0; + if (!task) { + return; + } + task.cleanupRequested = true; + + if (task.ended) { + task.canvas.width = task.canvas.height = 0; + this.#tasks.delete(taskID); + } }); mainHandler.on("Terminate", async () => { @@ -148,6 +239,8 @@ class RendererMessageHandler { mainHandler.destroy(); mainHandler = null; }); + + mainHandler.send("ready", null); } } diff --git a/src/display/worker_options.js b/src/display/worker_options.js index e4bbb81a6ea45..215e8ffd18fc1 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,27 @@ 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. + * + * NOTE: The `rendererSrc` option should always be set, in order to prevent + * any issues when using the PDF.js library with worker rendering. + */ + static set rendererSrc(val) { + if (typeof val !== "string") { + throw new Error("Invalid `rendererSrc` type."); + } + this.#rendererSrc = val; + } } export { GlobalWorkerOptions }; diff --git a/src/shared/handle_objs.js b/src/shared/handle_objs.js index 029b6d49f9695..9c2ebba5b0cb3 100644 --- a/src/shared/handle_objs.js +++ b/src/shared/handle_objs.js @@ -5,10 +5,23 @@ import { PatternInfo, } from "../shared/obj-bin-transform.js"; import { FontFaceObject } from "../display/font_loader.js"; +import { PDFObjects } from "../display/pdf_objects.js"; + +function setupHandler( + handler, + isDestroyed, + commonObjs, + pages, + fontLoader, + options = null +) { + const { pdfBug = false, renderInWorker = false } = + typeof options === "boolean" ? { renderInWorker: options } : options || {}; + const destroyed = + typeof isDestroyed === "function" ? isDestroyed : () => isDestroyed; -function setupHandler(handler, destroyed, commonObjs, pages, fontLoader) { handler.on("commonobj", ([id, type, exportedData]) => { - if (destroyed) { + if (destroyed()) { return null; // Ignore any pending requests if the worker was terminated. } @@ -27,7 +40,7 @@ function setupHandler(handler, destroyed, commonObjs, pages, fontLoader) { const fontData = new FontInfo(exportedData); const inspectFont = - this._params.pdfBug && globalThis.FontInspector?.enabled + pdfBug && globalThis.FontInspector?.enabled ? (font, url) => globalThis.FontInspector.fontAdded(font, url) : null; const font = new FontFaceObject( @@ -39,7 +52,9 @@ function setupHandler(handler, destroyed, commonObjs, pages, fontLoader) { fontLoader .bind(font) - .catch(() => handler.sendWithPromise("FontFallback", { id })) + .catch(() => + handler.sendWithPromise("FontFallback", { id, renderInWorker }) + ) .finally(() => { if (!font.fontExtraProperties && font.data) { // Immediately release the `font.data` property once the font @@ -56,8 +71,9 @@ function setupHandler(handler, destroyed, commonObjs, pages, fontLoader) { const { imageRef } = exportedData; assert(imageRef, "The imageRef must be defined."); - for (const page of pages.values()) { - for (const [, data] of page.objs) { + for (const pageOrObjs of pages.values()) { + const objs = pageOrObjs.objs || pageOrObjs; + for (const [, data] of objs) { if (data?.ref !== imageRef) { continue; } @@ -87,25 +103,41 @@ function setupHandler(handler, destroyed, commonObjs, pages, fontLoader) { }); handler.on("obj", ([id, pageIndex, type, imageData]) => { - if (destroyed) { + if (destroyed()) { // Ignore any pending requests if the worker was terminated. return; } - const page = pages.get(pageIndex); - if (page.objs.has(id)) { + let pageOrObjs = pages.get(pageIndex); + if (!pageOrObjs) { + // Do not discard the objects if we're rendering in the worker. + if (renderInWorker) { + pageOrObjs = new PDFObjects(); + pages.set(pageIndex, pageOrObjs); + } else { + return; + } + } + + const objs = pageOrObjs.objs || pageOrObjs; + + if (objs.has(id)) { return; } // Don't store data *after* cleanup has successfully run, see bug 1854145. - if (page._intentStates.size === 0) { + // Only check _intentStates if this is a page object + if (pageOrObjs._intentStates?.size === 0) { imageData?.bitmap?.close(); // Release any `ImageBitmap` data. return; } switch (type) { case "Image": + objs.resolve(id, imageData); + break; case "Pattern": - page.objs.resolve(id, imageData); + const pattern = new PatternInfo(imageData); + objs.resolve(id, pattern.getIR()); break; default: throw new Error(`Got unknown object type ${type}`); diff --git a/test/driver.js b/test/driver.js index 612832b0951c1..b640f5d08712e 100644 --- a/test/driver.js +++ b/test/driver.js @@ -38,11 +38,14 @@ 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"; const md5FileMap = new Map(); +GlobalWorkerOptions.rendererSrc = RENDERER_SRC; + function loadStyles(styles) { const promises = []; @@ -487,6 +490,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`. @@ -510,6 +514,9 @@ class Driver { // Create a working canvas this.canvas = document.createElement("canvas"); + // Used as the render-target when testing `renderInWorker`, since a canvas + // can only be transferred once using `transferControlToOffscreen`. + this.renderCanvas = null; } run() { @@ -1066,8 +1073,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, @@ -1087,6 +1109,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") { @@ -1127,6 +1157,9 @@ class Driver { await renderTask.promise; if (partialCrop) { + // Since the renderer worker isn't used for the partial tests, + // it's safe to obtain a 2D context here. + ctx = this.canvas.getContext("2d", { alpha: false }); const clearOutsidePartial = () => { const { width, height } = ctx.canvas; // Everything above the partial area @@ -1161,7 +1194,7 @@ class Driver { clearOutsidePartial(); const baseline = ctx.canvas.toDataURL("image/png"); - this._clearCanvas(); + ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); const recordedBBoxes = page.recordedBBoxes; diff --git a/test/integration/test_utils.mjs b/test/integration/test_utils.mjs index f7d0785daf07f..d2051001a0ac0 100644 --- a/test/integration/test_utils.mjs +++ b/test/integration/test_utils.mjs @@ -874,20 +874,27 @@ function waitForNoElement(page, selector) { 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` ); const canvasRect = canvas.getBoundingClientRect(); - const ctx = canvas.getContext("2d"); rect ||= canvasRect; - const { data } = ctx.getImageData( - rect.x - canvasRect.x, - rect.y - canvasRect.y, - rect.width, - rect.height - ); - return new Uint32Array(data.buffer).every(x => x === col); + const x = rect.x - canvasRect.x; + const y = rect.y - canvasRect.y; + const { width, height } = rect; + + try { + const ctx = canvas.getContext("2d"); + const { data } = ctx.getImageData(x, y, width, height); + return new Uint32Array(data.buffer).every(v => v === col); + } catch (ex) { + const isMonochrome = canvas.isCanvasMonochrome; + if (typeof isMonochrome !== "function") { + throw ex; + } + return isMonochrome(x, y, width, height, col); + } }, rectangle, pageNumber, diff --git a/test/integration/viewer_spec.mjs b/test/integration/viewer_spec.mjs index 68f5e69c703ff..ff2752d5fabd1 100644 --- a/test/integration/viewer_spec.mjs +++ b/test/integration/viewer_spec.mjs @@ -480,23 +480,35 @@ 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 => { - 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 { - size: width * height, - width, - height, - topLeft: globalThis.pdfjsLib.Util.makeHexColor(...topLeft), - bottomRight: globalThis.pdfjsLib.Util.makeHexColor(...bottomRight), - }; - }); + return Promise.all( + Array.from(pageOne.querySelectorAll("canvas"), async canvas => { + const { width, height } = canvas; + let topLeft, bottomRight; + try { + const ctx = canvas.getContext("2d"); + topLeft = ctx.getImageData(2, 2, 1, 1).data; + bottomRight = ctx.getImageData(width - 3, height - 3, 1, 1).data; + } catch (ex) { + const getImageData = canvas.getImageData; + if (typeof getImageData !== "function") { + throw ex; + } + topLeft = await getImageData(2, 2, 1, 1); + bottomRight = await getImageData(width - 3, height - 3, 1, 1); + } + return { + size: width * height, + width, + height, + topLeft: globalThis.pdfjsLib.Util.makeHexColor(...topLeft), + bottomRight: globalThis.pdfjsLib.Util.makeHexColor(...bottomRight), + }; + }) + ); } function waitForDetailRendered(page) { diff --git a/test/unit/jasmine-boot.js b/test/unit/jasmine-boot.js index 73abbf9f0d6bd..f37769e8ae5be 100644 --- a/test/unit/jasmine-boot.js +++ b/test/unit/jasmine-boot.js @@ -111,6 +111,8 @@ async function initializePDFJS(callback) { } // Configure the worker. GlobalWorkerOptions.workerSrc = "../../build/generic/build/pdf.worker.mjs"; + GlobalWorkerOptions.rendererSrc = + "../../build/generic/build/pdf.renderer.mjs"; callback(); } diff --git a/web/base_pdf_page_view.js b/web/base_pdf_page_view.js index 3165c0189696e..27c37a33a3da9 100644 --- a/web/base_pdf_page_view.js +++ b/web/base_pdf_page_view.js @@ -122,12 +122,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?.renderInWorker + ) { // 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; @@ -151,7 +157,6 @@ class BasePDFPageView extends RenderableView { } return; } - // Don't add the canvas until the first draw callback, or until // drawing is complete when `!this.renderingQueue`, to prevent black // flickering. @@ -165,7 +170,12 @@ class BasePDFPageView extends RenderableView { if (prevCanvas) { prevCanvas.replaceWith(canvas); - this.pdfPage.resetCanvas(this.renderTaskID); + const resetWorkerCanvas = prevCanvas.resetWorkerCanvas; + if (typeof resetWorkerCanvas === "function") { + resetWorkerCanvas(); + } else { + prevCanvas.width = prevCanvas.height = 0; + } } else { onShow(canvas); } @@ -193,7 +203,12 @@ class BasePDFPageView extends RenderableView { return; } canvas.remove(); - this.pdfPage.resetCanvas(this.renderTaskID); + const resetWorkerCanvas = canvas.resetWorkerCanvas; + if (typeof resetWorkerCanvas === "function") { + resetWorkerCanvas(); + } else { + canvas.width = canvas.height = 0; + } this.canvas = null; this.#resetTempCanvas(); } diff --git a/web/pdf_page_detail_view.js b/web/pdf_page_detail_view.js index eda8d5ceacf18..948b2a66da796 100644 --- a/web/pdf_page_detail_view.js +++ b/web/pdf_page_detail_view.js @@ -293,7 +293,12 @@ class PDFPageDetailView extends BasePDFPageView { this._getRenderingContext(canvas, transform), () => { // If the rendering is cancelled, keep the old canvas visible. - this.canvas?.remove(); + const discardedCanvas = this.canvas; + const resetWorkerCanvas = discardedCanvas?.resetWorkerCanvas; + if (typeof resetWorkerCanvas === "function") { + resetWorkerCanvas(); + } + discardedCanvas?.remove(); this.canvas = prevCanvas; }, () => { diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index 37e888ffb6f91..0ea1f4570b182 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -1091,6 +1091,10 @@ class PDFPageView extends BasePDFPageView { const resultPromise = this._drawCanvas( this._getRenderingContext(canvas, transform, recordBBoxes), () => { + const resetWorkerCanvas = prevCanvas?.resetWorkerCanvas; + if (typeof resetWorkerCanvas === "function") { + resetWorkerCanvas(); + } prevCanvas?.remove(); this._resetCanvas(); }, diff --git a/web/pdf_thumbnail_view.js b/web/pdf_thumbnail_view.js index 6730a9287a21c..444d8a7a986ef 100644 --- a/web/pdf_thumbnail_view.js +++ b/web/pdf_thumbnail_view.js @@ -305,6 +305,7 @@ class PDFThumbnailView extends RenderableView { }; const renderContext = { + canvasContext: canvas.getContext("2d", { alpha: false }), canvas, transform, viewport: drawViewport, @@ -319,7 +320,12 @@ class PDFThumbnailView extends RenderableView { await renderTask.promise; } catch (e) { if (e instanceof RenderingCancelledException) { - pdfPage.resetCanvas(renderTask.taskID); + const resetWorkerCanvas = canvas.resetWorkerCanvas; + if (typeof resetWorkerCanvas === "function") { + resetWorkerCanvas(); + } else { + canvas.width = canvas.height = 0; + } return; } error = e; @@ -333,8 +339,13 @@ class PDFThumbnailView extends RenderableView { } this.renderingState = RenderingStates.FINISHED; - this.#convertCanvasToImage(canvas); - pdfPage.resetCanvas(renderTask.taskID); + await this.#convertCanvasToImage(canvas); + const resetWorkerCanvas = canvas.resetWorkerCanvas; + if (typeof resetWorkerCanvas === "function") { + resetWorkerCanvas(); + } else { + canvas.width = canvas.height = 0; + } this.eventBus.dispatch("thumbnailrendered", { source: this, From f3b36654e2cefb961ecde8732c812ed363ff382b Mon Sep 17 00:00:00 2001 From: Aditi Date: Tue, 10 Feb 2026 13:02:47 +0530 Subject: [PATCH 5/6] Fix up annotations --- src/display/annotation_layer.js | 3 ++ src/display/api.js | 66 +++++++++++++++++++++++---------- src/display/renderer_worker.js | 17 ++++++++- 3 files changed, 65 insertions(+), 21 deletions(-) diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index d289fbd1852dc..7a2d427e91827 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -1267,6 +1267,9 @@ class TextAnnotationElement extends AnnotationElement { "data-l10n-args", JSON.stringify({ type: this.data.name }) ); + if (this.data.name !== "NoIcon" || this.data.hasOwnCanvas) { + this.hasOwnCommentButton = true; + } if (!this.data.popupRef && this.hasPopupData) { this.hasOwnCommentButton = true; diff --git a/src/display/api.js b/src/display/api.js index 0fd6a91209e06..a484b908209ef 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -1912,6 +1912,16 @@ class PDFPageProxy { this._intentStates.clear(); this.objs.clear(); this.#pendingCleanup = false; + + if (this._transport.rendererHandler) { + try { + this._transport.rendererHandler.send("cleanupPage", { + pageIndex: this._pageIndex, + }); + } catch { + // Ignore errors if the renderer worker has been destroyed. + } + } return true; } @@ -3442,25 +3452,43 @@ class InternalRenderTask { }, configurable: true, }); - this.rendererHandler.send( - "init", - { - pageIndex: this._pageIndex, - canvas: offscreen, - map: this.annotationCanvasMap, - colors: this.pageColors, - taskID: this.taskID, - transform, - viewport, - transparency, - background, - renderingIntent, - optionalContentConfig: optionalContentConfigData, - optionalContentConfigState, - dependencyTracker, - }, - [offscreen] - ); + const initTransfers = [offscreen]; + const initParams = { + pageIndex: this._pageIndex, + canvas: offscreen, + colors: this.pageColors, + taskID: this.taskID, + transform, + viewport, + transparency, + background, + renderingIntent, + optionalContentConfig: optionalContentConfigData, + optionalContentConfigState, + dependencyTracker, + }; + + if (this.annotationCanvasMap) { + const annotationCanvases = []; + for (const [id, canvas] of this.annotationCanvasMap) { + try { + const annotationCanvas = canvas.transferControlToOffscreen(); + annotationCanvases.push([id, annotationCanvas]); + initTransfers.push(annotationCanvas); + } catch (ex) { + warn( + `Failed to transfer annotation canvas to worker: ${ex.message}. ` + ); + } + } + if (annotationCanvases.length > 0) { + initParams.annotationCanvases = annotationCanvases; + } else { + initParams.map = new Map(); + } + } + + this.rendererHandler.send("init", initParams, initTransfers); } catch (ex) { // If transferControlToOffscreen fails (e.g., canvas already // has a context), fall back to main thread rendering diff --git a/src/display/renderer_worker.js b/src/display/renderer_worker.js index 9400e56097f24..ea1f2ee858340 100644 --- a/src/display/renderer_worker.js +++ b/src/display/renderer_worker.js @@ -69,7 +69,7 @@ class RendererMessageHandler { ({ pageIndex, canvas, - map, + annotationCanvasMap, colors, taskID, transform, @@ -102,7 +102,7 @@ class RendererMessageHandler { this.#canvasFactory, this.#filterFactory, { optionalContentConfig }, - map, + annotationCanvasMap, colors ); gfx.beginDrawing({ transform, viewport, transparency, background }); @@ -189,6 +189,19 @@ class RendererMessageHandler { } ); + mainHandler.on("cleanupPage", ({ pageIndex }) => { + if (terminated) { + throw new Error("Renderer worker has been terminated."); + } + + const objs = this.#objsMap.get(pageIndex); + if (!objs) { + return; + } + objs.clear(); + this.#objsMap.delete(pageIndex); + }); + mainHandler.on("end", ({ taskID }) => { if (terminated) { throw new Error("Renderer worker has been terminated."); From 53fcda47c5bef6f2522bd4f3dcd3181347135259 Mon Sep 17 00:00:00 2001 From: Aditi Date: Wed, 11 Feb 2026 14:26:31 +0530 Subject: [PATCH 6/6] Add dependency for patterns --- src/core/evaluator.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/core/evaluator.js b/src/core/evaluator.js index 2d1ac31c56374..6d013a04d71c5 100644 --- a/src/core/evaluator.js +++ b/src/core/evaluator.js @@ -1636,6 +1636,8 @@ class PartialEvaluator { localShadingPatternCache, }); if (objId) { + // Ensure that the Pattern is resolved before it's used + operatorList.addDependency(objId); const matrix = lookupMatrix(dict.getArray("Matrix"), null); operatorList.addOp(fn, ["Shading", objId, matrix]); } @@ -2190,6 +2192,7 @@ class PartialEvaluator { if (!patternId) { continue; } + operatorList.addDependency(patternId); args = [patternId]; fn = OPS.shadingFill; break;