From 106202510b24b04d34fa1a4f3a54646c538cb033 Mon Sep 17 00:00:00 2001 From: Jonas Jenwald Date: Mon, 13 Jun 2022 13:35:58 +0200 Subject: [PATCH] [api-minor] Introduce a `PrintAnnotationStorage` with *frozen* serializable data Given that printing is triggered *synchronously* in browsers, it's thus possible for scripting (in PDF documents) to modify the Annotation-data while printing is currently ongoing. To work-around that we add a new printing-specific `AnnotationStorage`, where the serializable data is *frozen* upon initialization, which the viewer can thus create/utilize during printing. --- src/display/annotation_storage.js | 64 +++++++++++++++++++++------- src/display/api.js | 36 +++++++++++----- test/unit/api_spec.js | 70 +++++++++++++++++++++++++++++++ web/app.js | 22 +++++++--- web/firefox_print_service.js | 28 +++++++++---- web/pdf_print_service.js | 17 ++++++-- 6 files changed, 196 insertions(+), 41 deletions(-) diff --git a/src/display/annotation_storage.js b/src/display/annotation_storage.js index f3a4098ba2a87e..3729192eea8380 100644 --- a/src/display/annotation_storage.js +++ b/src/display/annotation_storage.js @@ -13,9 +13,9 @@ * limitations under the License. */ +import { objectFromMap, unreachable } from "../shared/util.js"; import { AnnotationEditor } from "./editor/editor.js"; import { MurmurHash3_64 } from "../shared/murmurhash3.js"; -import { objectFromMap } from "../shared/util.js"; /** * Key/value storage for annotation data in forms. @@ -98,7 +98,7 @@ class AnnotationStorage { this._storage.set(key, value); } if (modified) { - this._setModified(); + this.#setModified(); } } @@ -110,10 +110,7 @@ class AnnotationStorage { return this._storage.size; } - /** - * @private - */ - _setModified() { + #setModified() { if (!this._modified) { this._modified = true; if (typeof this.onSetModified === "function") { @@ -131,6 +128,13 @@ class AnnotationStorage { } } + /** + * @returns {PrintAnnotationStorage} + */ + get print() { + return new PrintAnnotationStorage(this); + } + /** * PLEASE NOTE: Only intended for usage within the API itself. * @ignore @@ -139,11 +143,10 @@ class AnnotationStorage { if (this._storage.size === 0) { return null; } - const clone = new Map(); - for (const [key, value] of this._storage) { - const val = value instanceof AnnotationEditor ? value.serialize() : value; - clone.set(key, val); + + for (const [key, val] of this._storage) { + clone.set(key, val instanceof AnnotationEditor ? val.serialize() : val); } return clone; } @@ -152,15 +155,48 @@ class AnnotationStorage { * PLEASE NOTE: Only intended for usage within the API itself. * @ignore */ - get hash() { + static getHash(map) { + if (!map) { + return ""; + } const hash = new MurmurHash3_64(); - for (const [key, value] of this._storage) { - const val = value instanceof AnnotationEditor ? value.serialize() : value; + for (const [key, val] of map) { hash.update(`${key}:${JSON.stringify(val)}`); } return hash.hexdigest(); } } -export { AnnotationStorage }; +/** + * A special `AnnotationStorage` for use during printing, where the serializable + * data is *frozen* upon initialization, to prevent scripting from modifying its + * contents. (Necessary since printing is triggered synchronously in browsers.) + */ +class PrintAnnotationStorage extends AnnotationStorage { + #serializable = null; + + constructor(parent) { + super(); + // Create a *copy* of the data, since Objects are passed by reference in JS. + this.#serializable = structuredClone(parent.serializable); + } + + /** + * @returns {PrintAnnotationStorage} + */ + // eslint-disable-next-line getter-return + get print() { + unreachable("Should not call PrintAnnotationStorage.print"); + } + + /** + * PLEASE NOTE: Only intended for usage within the API itself. + * @ignore + */ + get serializable() { + return this.#serializable; + } +} + +export { AnnotationStorage, PrintAnnotationStorage }; diff --git a/src/display/api.js b/src/display/api.js index fba542a8769def..3e0bcdcfdc32da 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -37,6 +37,10 @@ import { unreachable, warn, } from "../shared/util.js"; +import { + AnnotationStorage, + PrintAnnotationStorage, +} from "./annotation_storage.js"; import { deprecated, DOMCanvasFactory, @@ -49,7 +53,6 @@ import { StatTimer, } from "./display_utils.js"; import { FontFaceObject, FontLoader } from "./font_loader.js"; -import { AnnotationStorage } from "./annotation_storage.js"; import { CanvasGraphics } from "./canvas.js"; import { GlobalWorkerOptions } from "./worker_options.js"; import { isNodeJS } from "../shared/is_node.js"; @@ -1181,6 +1184,7 @@ class PDFDocumentProxy { * states set. * @property {Map} [annotationCanvasMap] - Map some * annotation ids with canvases used to render them. + * @property {PrintAnnotationStorage} [printAnnotationStorage] */ /** @@ -1201,6 +1205,7 @@ class PDFDocumentProxy { * (as above) but where interactive form elements are updated with data * from the {@link AnnotationStorage}-instance; useful e.g. for printing. * The default value is `AnnotationMode.ENABLE`. + * @property {PrintAnnotationStorage} [printAnnotationStorage] */ /** @@ -1399,6 +1404,7 @@ class PDFPageProxy { optionalContentConfigPromise = null, annotationCanvasMap = null, pageColors = null, + printAnnotationStorage = null, }) { if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("GENERIC")) { if (arguments[0]?.renderInteractiveForms !== undefined) { @@ -1433,7 +1439,8 @@ class PDFPageProxy { const intentArgs = this._transport.getRenderingIntent( intent, - annotationMode + annotationMode, + printAnnotationStorage ); // If there was a pending destroy, cancel it so no cleanup happens during // this call to render. @@ -1560,6 +1567,7 @@ class PDFPageProxy { getOperatorList({ intent = "display", annotationMode = AnnotationMode.ENABLE, + printAnnotationStorage = null, } = {}) { function operatorListChanged() { if (intentState.operatorList.lastChunk) { @@ -1572,6 +1580,7 @@ class PDFPageProxy { const intentArgs = this._transport.getRenderingIntent( intent, annotationMode, + printAnnotationStorage, /* isOpList = */ true ); let intentState = this._intentStates.get(intentArgs.cacheKey); @@ -1800,7 +1809,7 @@ class PDFPageProxy { /** * @private */ - _pumpOperatorList({ renderingIntent, cacheKey }) { + _pumpOperatorList({ renderingIntent, cacheKey, annotationStorageMap }) { if ( typeof PDFJSDev === "undefined" || PDFJSDev.test("!PRODUCTION || TESTING") @@ -1817,10 +1826,7 @@ class PDFPageProxy { pageIndex: this._pageIndex, intent: renderingIntent, cacheKey, - annotationStorage: - renderingIntent & RenderingIntentFlag.ANNOTATIONS_STORAGE - ? this._transport.annotationStorage.serializable - : null, + annotationStorage: annotationStorageMap, } ); const reader = readableStream.getReader(); @@ -2406,10 +2412,11 @@ class WorkerTransport { getRenderingIntent( intent, annotationMode = AnnotationMode.ENABLE, + printAnnotationStorage = null, isOpList = false ) { let renderingIntent = RenderingIntentFlag.DISPLAY; // Default value. - let annotationHash = ""; + let annotationMap = null; switch (intent) { case "any": @@ -2436,7 +2443,13 @@ class WorkerTransport { case AnnotationMode.ENABLE_STORAGE: renderingIntent += RenderingIntentFlag.ANNOTATIONS_STORAGE; - annotationHash = this.annotationStorage.hash; + const annotationStorage = + renderingIntent & RenderingIntentFlag.PRINT && + printAnnotationStorage instanceof PrintAnnotationStorage + ? printAnnotationStorage + : this.annotationStorage; + + annotationMap = annotationStorage.serializable; break; default: warn(`getRenderingIntent - invalid annotationMode: ${annotationMode}`); @@ -2448,7 +2461,10 @@ class WorkerTransport { return { renderingIntent, - cacheKey: `${renderingIntent}_${annotationHash}`, + cacheKey: `${renderingIntent}_${AnnotationStorage.getHash( + annotationMap + )}`, + annotationStorageMap: annotationMap, }; } diff --git a/test/unit/api_spec.js b/test/unit/api_spec.js index 1523e83ef25424..40ffdfcd810c3b 100644 --- a/test/unit/api_spec.js +++ b/test/unit/api_spec.js @@ -48,6 +48,7 @@ import { RenderingCancelledException, StatTimer, } from "../../src/display/display_utils.js"; +import { AnnotationStorage } from "../../src/display/annotation_storage.js"; import { AutoPrintRegExp } from "../../web/ui_utils.js"; import { GlobalImageCache } from "../../src/core/image_utils.js"; import { GlobalWorkerOptions } from "../../src/display/worker_options.js"; @@ -2826,6 +2827,75 @@ Caron Broadcasting, Inc., an Ohio corporation (“Lessee”).`) await loadingTask.destroy(); firstImgData = null; }); + + it("render for printing, with `printAnnotationStorage` set", async function () { + async function getPrintData(printAnnotationStorage = null) { + const canvasAndCtx = CanvasFactory.create( + viewport.width, + viewport.height + ); + const renderTask = pdfPage.render({ + canvasContext: canvasAndCtx.context, + canvasFactory: CanvasFactory, + viewport, + intent: "print", + annotationMode: AnnotationMode.ENABLE_STORAGE, + printAnnotationStorage, + }); + + await renderTask.promise; + const printData = canvasAndCtx.canvas.toDataURL(); + CanvasFactory.destroy(canvasAndCtx); + + return printData; + } + + const loadingTask = getDocument( + buildGetDocumentParams("annotation-tx.pdf") + ); + const pdfDoc = await loadingTask.promise; + const pdfPage = await pdfDoc.getPage(1); + const viewport = pdfPage.getViewport({ scale: 1 }); + + // Update the contents of the form-field. + const { annotationStorage } = pdfDoc; + annotationStorage.setValue("22R", { value: "Hello World" }); + + // Render for printing, with default parameters. + const printOriginalData = await getPrintData(); + + // Get the *frozen* print-storage for use during printing. + const printAnnotationStorage = annotationStorage.print; + // Update the contents of the form-field again. + annotationStorage.setValue("22R", { value: "Printing again..." }); + + const annotationHash = AnnotationStorage.getHash( + annotationStorage.serializable + ); + const printAnnotationHash = AnnotationStorage.getHash( + printAnnotationStorage.serializable + ); + // Sanity check to ensure that the print-storage didn't change, + // after the form-field was updated. + expect(printAnnotationHash).not.toEqual(annotationHash); + + // Render for printing again, after updating the form-field, + // with default parameters. + const printAgainData = await getPrintData(); + + // Render for printing again, after updating the form-field, + // with `printAnnotationStorage` set. + const printStorageData = await getPrintData(printAnnotationStorage); + + // Ensure that printing again, with default parameters, + // actually uses the "new" form-field data. + expect(printAgainData).not.toEqual(printOriginalData); + // Finally ensure that printing, with `printAnnotationStorage` set, + // still uses the "previous" form-field data. + expect(printStorageData).toEqual(printOriginalData); + + await loadingTask.destroy(); + }); }); describe("Multiple `getDocument` instances", function () { diff --git a/web/app.js b/web/app.js index 2342b04e0885e4..a87f2856df3a68 100644 --- a/web/app.js +++ b/web/app.js @@ -254,6 +254,7 @@ const PDFViewerApplication = { _wheelUnusedTicks: 0, _idleCallbacks: new Set(), _PDFBug: null, + _printAnnotationStoragePromise: null, // Called once when the document is loaded. async initialize(appConfig) { @@ -1790,9 +1791,14 @@ const PDFViewerApplication = { }, beforePrint() { - // Given that the "beforeprint" browser event is synchronous, we - // unfortunately cannot await the scripting event dispatching here. - this.pdfScriptingManager.dispatchWillPrint(); + this._printAnnotationStoragePromise = this.pdfScriptingManager + .dispatchWillPrint() + .catch(() => { + /* Avoid breaking printing; ignoring errors. */ + }) + .then(() => { + return this.pdfDocument?.annotationStorage.print; + }); if (this.printService) { // There is no way to suppress beforePrint/afterPrint events, @@ -1830,6 +1836,7 @@ const PDFViewerApplication = { printContainer, printResolution, optionalContentConfigPromise, + this._printAnnotationStoragePromise, this.l10n ); this.printService = printService; @@ -1843,9 +1850,12 @@ const PDFViewerApplication = { }, afterPrint() { - // Given that the "afterprint" browser event is synchronous, we - // unfortunately cannot await the scripting event dispatching here. - this.pdfScriptingManager.dispatchDidPrint(); + if (this._printAnnotationStoragePromise) { + this._printAnnotationStoragePromise.then(() => { + this.pdfScriptingManager.dispatchDidPrint(); + }); + this._printAnnotationStoragePromise = null; + } if (this.printService) { this.printService.destroy(); diff --git a/web/firefox_print_service.js b/web/firefox_print_service.js index 772e5b34f1eac0..06560291c0ff3f 100644 --- a/web/firefox_print_service.js +++ b/web/firefox_print_service.js @@ -29,7 +29,8 @@ function composePage( size, printContainer, printResolution, - optionalContentConfigPromise + optionalContentConfigPromise, + printAnnotationStoragePromise ) { const canvas = document.createElement("canvas"); @@ -61,9 +62,12 @@ function composePage( ctx.restore(); let thisRenderTask = null; - pdfDocument - .getPage(pageNumber) - .then(function (pdfPage) { + + Promise.all([ + pdfDocument.getPage(pageNumber), + printAnnotationStoragePromise, + ]) + .then(function ([pdfPage, printAnnotationStorage]) { if (currentRenderTask) { currentRenderTask.cancel(); currentRenderTask = null; @@ -75,6 +79,7 @@ function composePage( intent: "print", annotationMode: AnnotationMode.ENABLE_STORAGE, optionalContentConfigPromise, + printAnnotationStorage, }; currentRenderTask = thisRenderTask = pdfPage.render(renderContext); return thisRenderTask.promise; @@ -114,7 +119,8 @@ function FirefoxPrintService( pagesOverview, printContainer, printResolution, - optionalContentConfigPromise = null + optionalContentConfigPromise = null, + printAnnotationStoragePromise = null ) { this.pdfDocument = pdfDocument; this.pagesOverview = pagesOverview; @@ -122,6 +128,8 @@ function FirefoxPrintService( this._printResolution = printResolution || 150; this._optionalContentConfigPromise = optionalContentConfigPromise || pdfDocument.getOptionalContentConfig(); + this._optionalContentConfigPromise = + printAnnotationStoragePromise || Promise.resolve(); } FirefoxPrintService.prototype = { @@ -132,6 +140,7 @@ FirefoxPrintService.prototype = { printContainer, _printResolution, _optionalContentConfigPromise, + _printAnnotationStoragePromise, } = this; const body = document.querySelector("body"); @@ -149,7 +158,8 @@ FirefoxPrintService.prototype = { pagesOverview[i], printContainer, _printResolution, - _optionalContentConfigPromise + _optionalContentConfigPromise, + _printAnnotationStoragePromise ); } }, @@ -175,14 +185,16 @@ PDFPrintServiceFactory.instance = { pagesOverview, printContainer, printResolution, - optionalContentConfigPromise + optionalContentConfigPromise, + printAnnotationStoragePromise ) { return new FirefoxPrintService( pdfDocument, pagesOverview, printContainer, printResolution, - optionalContentConfigPromise + optionalContentConfigPromise, + printAnnotationStoragePromise ); }, }; diff --git a/web/pdf_print_service.js b/web/pdf_print_service.js index b5730d78d13ad8..4d6bdf976c8513 100644 --- a/web/pdf_print_service.js +++ b/web/pdf_print_service.js @@ -29,7 +29,8 @@ function renderPage( pageNumber, size, printResolution, - optionalContentConfigPromise + optionalContentConfigPromise, + printAnnotationStoragePromise ) { const scratchCanvas = activeService.scratchCanvas; @@ -44,7 +45,10 @@ function renderPage( ctx.fillRect(0, 0, scratchCanvas.width, scratchCanvas.height); ctx.restore(); - return pdfDocument.getPage(pageNumber).then(function (pdfPage) { + return Promise.all([ + pdfDocument.getPage(pageNumber), + printAnnotationStoragePromise, + ]).then(function ([pdfPage, printAnnotationStorage]) { const renderContext = { canvasContext: ctx, transform: [PRINT_UNITS, 0, 0, PRINT_UNITS, 0, 0], @@ -52,6 +56,7 @@ function renderPage( intent: "print", annotationMode: AnnotationMode.ENABLE_STORAGE, optionalContentConfigPromise, + printAnnotationStorage, }; return pdfPage.render(renderContext).promise; }); @@ -63,6 +68,7 @@ function PDFPrintService( printContainer, printResolution, optionalContentConfigPromise = null, + printAnnotationStoragePromise = null, l10n ) { this.pdfDocument = pdfDocument; @@ -71,6 +77,8 @@ function PDFPrintService( this._printResolution = printResolution || 150; this._optionalContentConfigPromise = optionalContentConfigPromise || pdfDocument.getOptionalContentConfig(); + this._printAnnotationStoragePromise = + printAnnotationStoragePromise || Promise.resolve(); this.l10n = l10n; this.currentPage = -1; // The temporary canvas where renderPage paints one page at a time. @@ -160,7 +168,8 @@ PDFPrintService.prototype = { /* pageNumber = */ index + 1, this.pagesOverview[index], this._printResolution, - this._optionalContentConfigPromise + this._optionalContentConfigPromise, + this._printAnnotationStoragePromise ) .then(this.useRenderedPage.bind(this)) .then(function () { @@ -359,6 +368,7 @@ PDFPrintServiceFactory.instance = { printContainer, printResolution, optionalContentConfigPromise, + printAnnotationStoragePromise, l10n ) { if (activeService) { @@ -370,6 +380,7 @@ PDFPrintServiceFactory.instance = { printContainer, printResolution, optionalContentConfigPromise, + printAnnotationStoragePromise, l10n ); return activeService;