From fdc0bde6bccbe934678dcdf7bf0dba2d0c9c057b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Ribaudo?= Date: Wed, 11 Dec 2024 13:54:50 +0100 Subject: [PATCH 1/4] Extract `PDFPageViewBase` class out of `PDFPageView` This base class contains the generic logic for: - Creating a canvas and showing when appropriate - Rendering in the canvas - Keeping track of the rendering state --- web/pdf_page_view.js | 495 ++++++++++++++++++++++++------------------- 1 file changed, 276 insertions(+), 219 deletions(-) diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index 963dd644b7d25..a1ee6460787f2 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -110,24 +110,234 @@ const LAYERS_ORDER = new Map([ ["xfaLayer", 3], ]); +class PDFPageViewBase { + #enableHWA = false; + + #loadingId = null; + + #renderError = null; + + #renderingState = RenderingStates.INITIAL; + + #showCanvas = null; + + canvas = null; + + /** @type {null | HTMLDivElement} */ + div = null; + + eventBus = null; + + id = null; + + pageColors = null; + + renderingQueue = null; + + renderTask = null; + + resume = null; + + constructor(options) { + this.#enableHWA = + #enableHWA in options ? options.#enableHWA : options.enableHWA || false; + this.eventBus = options.eventBus; + this.id = options.id; + this.pageColors = options.pageColors || null; + this.renderingQueue = options.renderingQueue; + } + + get renderingState() { + return this.#renderingState; + } + + set renderingState(state) { + if (state === this.#renderingState) { + return; + } + this.#renderingState = state; + + if (this.#loadingId) { + clearTimeout(this.#loadingId); + this.#loadingId = null; + } + + switch (state) { + case RenderingStates.PAUSED: + this.div.classList.remove("loading"); + break; + case RenderingStates.RUNNING: + this.div.classList.add("loadingIcon"); + this.#loadingId = setTimeout(() => { + // Adding the loading class is slightly postponed in order to not have + // it with loadingIcon. + // If we don't do that the visibility of the background is changed but + // the transition isn't triggered. + this.div.classList.add("loading"); + this.#loadingId = null; + }, 0); + break; + case RenderingStates.INITIAL: + case RenderingStates.FINISHED: + this.div.classList.remove("loadingIcon", "loading"); + break; + } + } + + get _renderError() { + return this.#renderError; + } + + _createCanvas(onShow) { + const { pageColors } = this; + const hasHCM = !!(pageColors?.background && pageColors?.foreground); + const prevCanvas = this.canvas; + + // In HCM, a final filter is applied on the canvas which means that + // before it's applied we've normal colors. Consequently, to avoid to + // have a final flash we just display it once all the drawing is done. + const updateOnFirstShow = !prevCanvas && !hasHCM; + + const canvas = document.createElement("canvas"); + this.canvas = canvas; + + this.#showCanvas = isLastShow => { + if (updateOnFirstShow) { + // Don't add the canvas until the first draw callback, or until + // drawing is complete when `!this.renderingQueue`, to prevent black + // flickering. + onShow(canvas); + this.#showCanvas = null; + return; + } + if (!isLastShow) { + return; + } + + if (prevCanvas) { + prevCanvas.replaceWith(canvas); + prevCanvas.width = prevCanvas.height = 0; + } else { + onShow(canvas); + } + }; + + const ctx = canvas.getContext("2d", { + alpha: false, + willReadFrequently: !this.#enableHWA, + }); + + return { canvas, prevCanvas, ctx }; + } + + #renderContinueCallback = cont => { + this.#showCanvas?.(false); + if (this.renderingQueue && !this.renderingQueue.isHighestPriority(this)) { + this.renderingState = RenderingStates.PAUSED; + this.resume = () => { + this.renderingState = RenderingStates.RUNNING; + cont(); + }; + return; + } + cont(); + }; + + _resetCanvas() { + const { canvas } = this; + if (!canvas) { + return; + } + canvas.remove(); + canvas.width = canvas.height = 0; + this.canvas = null; + } + + async _drawCanvas(options, prevCanvas, onFinish) { + const renderTask = (this.renderTask = this.pdfPage.render(options)); + renderTask.onContinue = this.#renderContinueCallback; + + try { + await renderTask.promise; + this.#showCanvas?.(true); + this.#finishRenderTask(renderTask, null, onFinish); + } catch (error) { + // When zooming with a `drawingDelay` set, avoid temporarily showing + // a black canvas if rendering was cancelled before the `onContinue`- + // callback had been invoked at least once. + if (!(error instanceof RenderingCancelledException)) { + this.#showCanvas?.(true); + } else { + prevCanvas?.remove(); + this._resetCanvas(); + } + this.#finishRenderTask(renderTask, error, onFinish); + } + } + + async #finishRenderTask(renderTask, error, onFinish) { + // The renderTask may have been replaced by a new one, so only remove + // the reference to the renderTask if it matches the one that is + // triggering this callback. + if (renderTask === this.renderTask) { + this.renderTask = null; + } + + if (error instanceof RenderingCancelledException) { + this.#renderError = null; + return; + } + this.#renderError = error; + + this.renderingState = RenderingStates.FINISHED; + + onFinish(renderTask); + + if (error) { + throw error; + } + } + + cancelRendering({ cancelExtraDelay = 0 } = {}) { + if (this.renderTask) { + this.renderTask.cancel(cancelExtraDelay); + this.renderTask = null; + } + this.resume = null; + } + + dispatchPageRender() { + this.eventBus.dispatch("pagerender", { + source: this, + pageNumber: this.id, + }); + } + + dispatchPageRendered(cssTransform) { + this.eventBus.dispatch("pagerendered", { + source: this, + pageNumber: this.id, + cssTransform, + timestamp: performance.now(), + error: this._renderError, + }); + } +} + /** * @implements {IRenderableView} */ -class PDFPageView { +class PDFPageView extends PDFPageViewBase { #annotationMode = AnnotationMode.ENABLE_FORMS; #canvasWrapper = null; - #enableHWA = false; - #hasRestrictedScaling = false; #isEditing = false; #layerProperties = null; - #loadingId = null; - #originalViewport = null; #previousRotation = null; @@ -136,10 +346,6 @@ class PDFPageView { #scaleRoundY = 1; - #renderError = null; - - #renderingState = RenderingStates.INITIAL; - #textLayerMode = TextLayerMode.ENABLE; #useThumbnailCanvas = { @@ -154,10 +360,11 @@ class PDFPageView { * @param {PDFPageViewOptions} options */ constructor(options) { + super(options); + const container = options.container; const defaultViewport = options.defaultViewport; - this.id = options.id; this.renderingId = "page" + this.id; this.#layerProperties = options.layerProperties || DEFAULT_LAYER_PROPERTIES; @@ -175,18 +382,12 @@ class PDFPageView { this.imageResourcesPath = options.imageResourcesPath || ""; this.maxCanvasPixels = options.maxCanvasPixels ?? AppOptions.get("maxCanvasPixels"); - this.pageColors = options.pageColors || null; - this.#enableHWA = options.enableHWA || false; - this.eventBus = options.eventBus; - this.renderingQueue = options.renderingQueue; this.l10n = options.l10n; if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { this.l10n ||= new GenericL10n(); } - this.renderTask = null; - this.resume = null; if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { this._isStandalone = !this.renderingQueue?.hasViewer(); this._container = container; @@ -270,43 +471,6 @@ class PDFPageView { this.div.prepend(div); } - get renderingState() { - return this.#renderingState; - } - - set renderingState(state) { - if (state === this.#renderingState) { - return; - } - this.#renderingState = state; - - if (this.#loadingId) { - clearTimeout(this.#loadingId); - this.#loadingId = null; - } - - switch (state) { - case RenderingStates.PAUSED: - this.div.classList.remove("loading"); - break; - case RenderingStates.RUNNING: - this.div.classList.add("loadingIcon"); - this.#loadingId = setTimeout(() => { - // Adding the loading class is slightly postponed in order to not have - // it with loadingIcon. - // If we don't do that the visibility of the background is changed but - // the transition isn't triggered. - this.div.classList.add("loading"); - this.#loadingId = null; - }, 0); - break; - case RenderingStates.INITIAL: - case RenderingStates.FINISHED: - this.div.classList.remove("loadingIcon", "loading"); - break; - } - } - #setDimensions() { const { viewport } = this; if (this.pdfPage) { @@ -509,14 +673,8 @@ class PDFPageView { this._textHighlighter.enable(); } - #resetCanvas() { - const { canvas } = this; - if (!canvas) { - return; - } - canvas.remove(); - canvas.width = canvas.height = 0; - this.canvas = null; + _resetCanvas() { + super._resetCanvas(); this.#originalViewport = null; } @@ -583,7 +741,7 @@ class PDFPageView { if (!keepCanvasWrapper && this.#canvasWrapper) { this.#canvasWrapper = null; - this.#resetCanvas(); + this._resetCanvas(); } } @@ -709,18 +867,11 @@ class PDFPageView { hideTextLayer: postponeDrawing, }); - if (postponeDrawing) { - // The "pagerendered"-event will be dispatched once the actual - // rendering is done, hence don't dispatch it here as well. - return; + // The "pagerendered"-event will be dispatched once the actual + // rendering is done, hence don't dispatch it here as well. + if (!postponeDrawing) { + this.dispatchPageRendered(true); } - this.eventBus.dispatch("pagerendered", { - source: this, - pageNumber: this.id, - cssTransform: true, - timestamp: performance.now(), - error: this.#renderError, - }); return; } } @@ -745,11 +896,7 @@ class PDFPageView { keepTextLayer = false, cancelExtraDelay = 0, } = {}) { - if (this.renderTask) { - this.renderTask.cancel(cancelExtraDelay); - this.renderTask = null; - } - this.resume = null; + super.cancelRendering({ cancelExtraDelay }); if (this.textLayer && (!keepTextLayer || !this.textLayer.div)) { this.textLayer.cancel(); @@ -848,39 +995,6 @@ class PDFPageView { return this.viewport.convertToPdfPoint(x, y); } - async #finishRenderTask(renderTask, error = null) { - // The renderTask may have been replaced by a new one, so only remove - // the reference to the renderTask if it matches the one that is - // triggering this callback. - if (renderTask === this.renderTask) { - this.renderTask = null; - } - - if (error instanceof RenderingCancelledException) { - this.#renderError = null; - return; - } - this.#renderError = error; - - this.renderingState = RenderingStates.FINISHED; - - // Ensure that the thumbnails won't become partially (or fully) blank, - // for documents that contain interactive form elements. - this.#useThumbnailCanvas.regularAnnotations = !renderTask.separateAnnots; - - this.eventBus.dispatch("pagerendered", { - source: this, - pageNumber: this.id, - cssTransform: false, - timestamp: performance.now(), - error: this.#renderError, - }); - - if (error) { - throw error; - } - } - async draw() { if (this.renderingState !== RenderingStates.INITIAL) { console.error("Must be in new state before drawing"); @@ -960,61 +1074,15 @@ class PDFPageView { }); } - const renderContinueCallback = cont => { - showCanvas?.(false); - if (this.renderingQueue && !this.renderingQueue.isHighestPriority(this)) { - this.renderingState = RenderingStates.PAUSED; - this.resume = () => { - this.renderingState = RenderingStates.RUNNING; - cont(); - }; - return; - } - cont(); - }; - const { width, height } = viewport; - const canvas = document.createElement("canvas"); - canvas.setAttribute("role", "presentation"); - - const hasHCM = !!(pageColors?.background && pageColors?.foreground); - const prevCanvas = this.canvas; - - // In HCM, a final filter is applied on the canvas which means that - // before it's applied we've normal colors. Consequently, to avoid to - // have a final flash we just display it once all the drawing is done. - const updateOnFirstShow = !prevCanvas && !hasHCM; - this.canvas = canvas; this.#originalViewport = viewport; - let showCanvas = isLastShow => { - if (updateOnFirstShow) { - // Don't add the canvas until the first draw callback, or until - // drawing is complete when `!this.renderingQueue`, to prevent black - // flickering. - // In whatever case, the canvas must be the first child. - canvasWrapper.prepend(canvas); - showCanvas = null; - return; - } - if (!isLastShow) { - return; - } - - if (prevCanvas) { - prevCanvas.replaceWith(canvas); - prevCanvas.width = prevCanvas.height = 0; - } else { - canvasWrapper.prepend(canvas); - } - - showCanvas = null; - }; - - const ctx = canvas.getContext("2d", { - alpha: false, - willReadFrequently: !this.#enableHWA, + const { canvas, prevCanvas, ctx } = this._createCanvas(newCanvas => { + // Always inject the canvas as the first element in the wrapper. + canvasWrapper.prepend(newCanvas); }); + canvas.setAttribute("role", "presentation"); + const outputScale = (this.outputScale = new OutputScale()); if ( @@ -1077,64 +1145,55 @@ class PDFPageView { pageColors, isEditing: this.#isEditing, }; - const renderTask = (this.renderTask = pdfPage.render(renderContext)); - renderTask.onContinue = renderContinueCallback; - - const resultPromise = renderTask.promise.then( - async () => { - showCanvas?.(true); - await this.#finishRenderTask(renderTask); - - this.structTreeLayer ||= new StructTreeLayerBuilder( - pdfPage, - viewport.rawDims - ); - - this.#renderTextLayer(); + const resultPromise = this._drawCanvas( + renderContext, + prevCanvas, + renderTask => { + // Ensure that the thumbnails won't become partially (or fully) blank, + // for documents that contain interactive form elements. + this.#useThumbnailCanvas.regularAnnotations = + !renderTask.separateAnnots; + + this.dispatchPageRendered(false); + } + ).then(async () => { + this.structTreeLayer ||= new StructTreeLayerBuilder( + pdfPage, + viewport.rawDims + ); - if (this.annotationLayer) { - await this.#renderAnnotationLayer(); - } + this.#renderTextLayer(); - const { annotationEditorUIManager } = this.#layerProperties; + if (this.annotationLayer) { + await this.#renderAnnotationLayer(); + } - if (!annotationEditorUIManager) { - return; - } - this.drawLayer ||= new DrawLayerBuilder({ - pageIndex: this.id, - }); - await this.#renderDrawLayer(); - this.drawLayer.setParent(canvasWrapper); + const { annotationEditorUIManager } = this.#layerProperties; - this.annotationEditorLayer ||= new AnnotationEditorLayerBuilder({ - uiManager: annotationEditorUIManager, - pdfPage, - l10n, - structTreeLayer: this.structTreeLayer, - accessibilityManager: this._accessibilityManager, - annotationLayer: this.annotationLayer?.annotationLayer, - textLayer: this.textLayer, - drawLayer: this.drawLayer.getDrawLayer(), - onAppend: annotationEditorLayerDiv => { - this.#addLayer(annotationEditorLayerDiv, "annotationEditorLayer"); - }, - }); - this.#renderAnnotationEditorLayer(); - }, - error => { - // When zooming with a `drawingDelay` set, avoid temporarily showing - // a black canvas if rendering was cancelled before the `onContinue`- - // callback had been invoked at least once. - if (!(error instanceof RenderingCancelledException)) { - showCanvas?.(true); - } else { - prevCanvas?.remove(); - this.#resetCanvas(); - } - return this.#finishRenderTask(renderTask, error); + if (!annotationEditorUIManager) { + return; } - ); + this.drawLayer ||= new DrawLayerBuilder({ + pageIndex: this.id, + }); + await this.#renderDrawLayer(); + this.drawLayer.setParent(canvasWrapper); + + this.annotationEditorLayer ||= new AnnotationEditorLayerBuilder({ + uiManager: annotationEditorUIManager, + pdfPage, + l10n, + structTreeLayer: this.structTreeLayer, + accessibilityManager: this._accessibilityManager, + annotationLayer: this.annotationLayer?.annotationLayer, + textLayer: this.textLayer, + drawLayer: this.drawLayer.getDrawLayer(), + onAppend: annotationEditorLayerDiv => { + this.#addLayer(annotationEditorLayerDiv, "annotationEditorLayer"); + }, + }); + this.#renderAnnotationEditorLayer(); + }); if (pdfPage.isPureXfa) { if (!this.xfaLayer) { @@ -1151,10 +1210,8 @@ class PDFPageView { div.setAttribute("data-loaded", true); - this.eventBus.dispatch("pagerender", { - source: this, - pageNumber: this.id, - }); + this.dispatchPageRender(); + return resultPromise; } From b4ee0d139f8075d1ce2076532580a28081d6585e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Ribaudo?= Date: Wed, 11 Dec 2024 13:58:52 +0100 Subject: [PATCH 2/4] Render high-res partial page views when falling back to CSS zoom (bug 1492303) When rendering big PDF pages at high zoom levels, we currently fall back to CSS zoom to avoid rendering canvases with too many pixels. This causes zoomed in PDF to look blurry, and the text to be potentially unreadable. This commit adds support for rendering _part_ of a page (called `PDFPageDetailView` in the code), so that we can render portion of a page in a smaller canvas without hiting the maximun canvas size limit. Specifically, we render an area of that page that is slightly larger than the area that is visible on the screen (100% larger in each direction, unless we have to limit it due to the maximum canvas size). As the user scrolls around the page, we re-render a new area centered around what is currently visible. --- test/integration/test_utils.mjs | 6 +- test/integration/viewer_spec.mjs | 350 +++++++++++++++++++++++++++ test/pdfs/.gitignore | 1 + test/pdfs/colors.pdf | Bin 0 -> 2591 bytes test/unit/ui_utils_spec.js | 22 +- web/app.js | 1 + web/app_options.js | 5 + web/pdf_page_view.js | 400 ++++++++++++++++++++++++++----- web/pdf_rendering_queue.js | 11 +- web/pdf_viewer.js | 16 ++ web/ui_utils.js | 21 +- 11 files changed, 758 insertions(+), 75 deletions(-) create mode 100644 test/pdfs/colors.pdf diff --git a/test/integration/test_utils.mjs b/test/integration/test_utils.mjs index e414b2a4380e9..75897089b5f83 100644 --- a/test/integration/test_utils.mjs +++ b/test/integration/test_utils.mjs @@ -17,11 +17,15 @@ import os from "os"; const isMac = os.platform() === "darwin"; -function loadAndWait(filename, selector, zoom, setups, options) { +function loadAndWait(filename, selector, zoom, setups, options, viewport) { return Promise.all( global.integrationSessions.map(async session => { const page = await session.browser.newPage(); + if (viewport) { + await page.setViewport(viewport); + } + // In order to avoid errors because of checks which depend on // a locale. await page.evaluateOnNewDocument(() => { diff --git a/test/integration/viewer_spec.mjs b/test/integration/viewer_spec.mjs index ac9af95693cda..23205c7949c33 100644 --- a/test/integration/viewer_spec.mjs +++ b/test/integration/viewer_spec.mjs @@ -454,4 +454,354 @@ describe("PDF viewer", () => { ); }); }); + + describe("Detail view on zoom", () => { + const BASE_MAX_CANVAS_PIXELS = 1e6; + + function setupPages(zoom, devicePixelRatio) { + let pages; + + beforeAll(async () => { + pages = await loadAndWait( + "colors.pdf", + null, + zoom, + { + // When running Firefox with Puppeteer, setting the + // devicePixelRatio Puppeteer option does not properly set + // the `window.devicePixelRatio` value. Set it manually. + earlySetup: `() => { + window.devicePixelRatio = ${devicePixelRatio}; + }`, + }, + { maxCanvasPixels: BASE_MAX_CANVAS_PIXELS * devicePixelRatio ** 2 }, + { height: 600, width: 800, devicePixelRatio } + ); + }); + + afterAll(async () => { + await closePages(pages); + }); + + return function forEachPage(fn) { + return Promise.all( + pages.map(([browserName, page]) => fn(browserName, page)) + ); + }; + } + + 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, + topLeft: globalThis.pdfjsLib.Util.makeHexColor(...topLeft), + bottomRight: globalThis.pdfjsLib.Util.makeHexColor(...bottomRight), + }; + }); + } + + function waitForDetailRendered(page) { + return createPromise(page, resolve => { + const controller = new AbortController(); + window.PDFViewerApplication.eventBus.on( + "pagerendered", + ({ source }) => { + if (source.constructor.name === "PDFPageDetailView") { + resolve(); + controller.abort(); + } + }, + { signal: controller.signal } + ); + }); + } + + for (const pixelRatio of [1, 2]) { + describe(`with pixel ratio ${pixelRatio}`, () => { + describe("setupPages()", () => { + const forEachPage = setupPages("100%", pixelRatio); + + it("sets the proper devicePixelRatio", async () => { + await forEachPage(async (browserName, page) => { + const devicePixelRatio = await page.evaluate( + () => window.devicePixelRatio + ); + + expect(devicePixelRatio) + .withContext(`In ${browserName}`) + .toBe(pixelRatio); + }); + }); + }); + + describe("when zooming in past max canvas size", () => { + const forEachPage = setupPages("100%", pixelRatio); + + it("must render the detail view", async () => { + await forEachPage(async (browserName, page) => { + await page.waitForSelector( + ".page[data-page-number='1'] .textLayer" + ); + + const before = await page.evaluate(extractCanvases, 1); + + expect(before.length) + .withContext(`In ${browserName}, before`) + .toBe(1); + expect(before[0].size) + .withContext(`In ${browserName}, before`) + .toBeLessThan(BASE_MAX_CANVAS_PIXELS * pixelRatio ** 2); + expect(before[0]) + .withContext(`In ${browserName}, before`) + .toEqual( + jasmine.objectContaining({ + topLeft: "#85200c", // dark berry + bottomRight: "#b6d7a8", // light green + }) + ); + + const factor = 3; + // Check that we are going to trigger CSS zoom. + expect(before[0].size * factor ** 2) + .withContext(`In ${browserName}`) + .toBeGreaterThan(BASE_MAX_CANVAS_PIXELS * pixelRatio ** 2); + + const handle = await waitForDetailRendered(page); + await page.evaluate(scaleFactor => { + window.PDFViewerApplication.pdfViewer.updateScale({ + drawingDelay: 0, + scaleFactor, + }); + }, factor); + await awaitPromise(handle); + + const after = await page.evaluate(extractCanvases, 1); + + expect(after.length) + .withContext(`In ${browserName}, after`) + .toBe(2); + expect(after[0].size) + .withContext(`In ${browserName}, after (first)`) + .toBeLessThan(4e6); + expect(after[0]) + .withContext(`In ${browserName}, after (first)`) + .toEqual( + jasmine.objectContaining({ + topLeft: "#85200c", // dark berry + bottomRight: "#b6d7a8", // light green + }) + ); + expect(after[1].size) + .withContext(`In ${browserName}, after (second)`) + .toBeLessThan(4e6); + expect(after[1]) + .withContext(`In ${browserName}, after (second)`) + .toEqual( + jasmine.objectContaining({ + topLeft: "#85200c", // dark berry + bottomRight: "#ff0000", // bright red + }) + ); + }); + }); + }); + + describe("when starting already zoomed in past max canvas size", () => { + const forEachPage = setupPages("300%", pixelRatio); + + it("must render the detail view", async () => { + await forEachPage(async (browserName, page) => { + await page.waitForSelector( + ".page[data-page-number='1'] canvas:nth-child(2)" + ); + + const canvases = await page.evaluate(extractCanvases, 1); + + expect(canvases.length).withContext(`In ${browserName}`).toBe(2); + expect(canvases[0].size) + .withContext(`In ${browserName} (first)`) + .toBeLessThan(4e6); + expect(canvases[0]) + .withContext(`In ${browserName} (first)`) + .toEqual( + jasmine.objectContaining({ + topLeft: "#85200c", // dark berry + bottomRight: "#b6d7a8", // light green + }) + ); + expect(canvases[1].size) + .withContext(`In ${browserName} (second)`) + .toBeLessThan(4e6); + expect(canvases[1]) + .withContext(`In ${browserName} (second)`) + .toEqual( + jasmine.objectContaining({ + topLeft: "#85200c", // dark berry + bottomRight: "#ff0000", // bright red + }) + ); + }); + }); + }); + + describe("when scrolling", () => { + const forEachPage = setupPages("300%", pixelRatio); + + it("must update the detail view", async () => { + await forEachPage(async (browserName, page) => { + await page.waitForSelector( + ".page[data-page-number='1'] canvas:nth-child(2)" + ); + + const handle = await waitForDetailRendered(page); + await page.evaluate(() => { + const container = document.getElementById("viewerContainer"); + container.scrollTop += 1600; + container.scrollLeft += 1100; + }); + await awaitPromise(handle); + + const canvases = await page.evaluate(extractCanvases, 1); + + expect(canvases.length).withContext(`In ${browserName}`).toBe(2); + expect(canvases[1].size) + .withContext(`In ${browserName}`) + .toBeLessThan(4e6); + expect(canvases[1]) + .withContext(`In ${browserName}`) + .toEqual( + jasmine.objectContaining({ + topLeft: "#ff9900", // bright orange + bottomRight: "#ffe599", // light yellow + }) + ); + }); + }); + }); + + describe("when scrolling little enough that the existing detail covers the new viewport", () => { + const forEachPage = setupPages("300%", pixelRatio); + + it("must not re-create the detail canvas", async () => { + await forEachPage(async (browserName, page) => { + const detailCanvasSelector = + ".page[data-page-number='1'] canvas:nth-child(2)"; + + await page.waitForSelector(detailCanvasSelector); + + const detailCanvasHandle = await page.$(detailCanvasSelector); + + let rendered = false; + const handle = await waitForDetailRendered(page); + await page.evaluate(() => { + const container = document.getElementById("viewerContainer"); + container.scrollTop += 10; + container.scrollLeft += 10; + }); + awaitPromise(handle) + .then(() => { + rendered = true; + }) + .catch(() => {}); + + // Give some time to the page to re-render. If it re-renders it's + // a bug, but without waiting we would never catch it. + await new Promise(resolve => { + setTimeout(resolve, 100); + }); + + const isSame = await page.evaluate( + (prev, selector) => prev === document.querySelector(selector), + detailCanvasHandle, + detailCanvasSelector + ); + + expect(isSame).withContext(`In ${browserName}`).toBe(true); + expect(rendered).withContext(`In ${browserName}`).toBe(false); + }); + }); + }); + + describe("when scrolling to have two visible pages", () => { + const forEachPage = setupPages("300%", pixelRatio); + + it("must update the detail view", async () => { + await forEachPage(async (browserName, page) => { + await page.waitForSelector( + ".page[data-page-number='1'] canvas:nth-child(2)" + ); + + const handle = await createPromise(page, resolve => { + // wait for two 'pagerendered' events for detail views + let second = false; + const { eventBus } = window.PDFViewerApplication; + eventBus.on( + "pagerendered", + function onPageRendered({ source }) { + if (source.constructor.name !== "PDFPageDetailView") { + return; + } + if (!second) { + second = true; + return; + } + eventBus.off("pagerendered", onPageRendered); + resolve(); + } + ); + }); + await page.evaluate(() => { + const container = document.getElementById("viewerContainer"); + container.scrollLeft += 600; + container.scrollTop += 3000; + }); + await awaitPromise(handle); + + const [canvases1, canvases2] = await Promise.all([ + page.evaluate(extractCanvases, 1), + page.evaluate(extractCanvases, 2), + ]); + + expect(canvases1.length) + .withContext(`In ${browserName}, first page`) + .toBe(2); + expect(canvases1[1].size) + .withContext(`In ${browserName}, first page`) + .toBeLessThan(4e6); + expect(canvases1[1]) + .withContext(`In ${browserName}, first page`) + .toEqual( + jasmine.objectContaining({ + topLeft: "#38761d", // dark green + bottomRight: "#b6d7a8", // light green + }) + ); + + expect(canvases2.length) + .withContext(`In ${browserName}, second page`) + .toBe(2); + expect(canvases2[1].size) + .withContext(`In ${browserName}, second page`) + .toBeLessThan(4e6); + expect(canvases2[1]) + .withContext(`In ${browserName}, second page`) + .toEqual( + jasmine.objectContaining({ + topLeft: "#134f5c", // dark cyan + bottomRight: "#a2c4c9", // light cyan + }) + ); + }); + }); + }); + }); + } + }); }); diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index f9e0cd705c705..f169ef5441a5f 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -697,3 +697,4 @@ !issue19207.pdf !issue19239.pdf !issue19360.pdf +!colors.pdf diff --git a/test/pdfs/colors.pdf b/test/pdfs/colors.pdf new file mode 100644 index 0000000000000000000000000000000000000000..4a6a8d663b710516f5457fac494601080caba4a8 GIT binary patch literal 2591 zcmY!laB)>cm@68n7o;zkOWy@ZmT(z@l*WS9`u^Cj&)u zfVhaESQE(@QzT;`o&Y%)WNA@q9>`f>C-|nOWF|W0SAgAZz-4M_%4K0<2z0N$bADb4 zP+>8b8AN4JYH@yPQ8Li=<`4nLyu5s%v?YY+UX)*20P}fBQDR50%Abp_>zeRrMM(vLisv3Bw} zd0NA#_)DYF87sv)K0D!GS|y$?!C%5&*=C&j+9l7QyFFTMU+eF&@Vz7e`n5n@ta^(oG0Z|0}n6MC`bF2|MyN2NAdiQh?nyDO)& zc1gn~=QZ;kr|wf(#Cg+eKfCYQ)#XzY>aA1_1nxchdUZ{OZiZl+iXPkADH0)dvr6M_ z++!1_t;lU&dRcqlCCgtwERPpk9ZY_3#EYq$@udYr?!J&;2lk$pw+VZDVLjXa_xb|a z_kW&zwsZTB=DF`yvY&XK5j5-1BEOjyt7dd-`ImmH+PSm5@||qmaVdkW54-*`dK65$ z1j-xGsE6hfP>KNO7E3c&#sQ^7kWe%*`9f708XzSwXGbnWJp(TNfN2$+>K$_mG7=5Az!rjYvwlEPesXYX3D7zE z!14u{V^b?ixb!`9fw|BLN;^YoPe=g)b2KP#!rVhq)-{9{G>E)w2rAovMGrLd8iGm* zAeX${3pO6+P<+|f5LBjtLj{q4Ll3UIqrkK0v*y&etOIOCZ`8kBDGptk&+mCP_kaAV z+)YP5c<}jdk^Zo0-p3wJ|L5WbMfarg2;rU%zUI0C|ZisMxJ@49kuEU3SK zj>_57k?Le*X9Wr~Vb#+wWXo!@u(9^&Xb& zk2XDjT6VZes-0j>UQvyGB>B%QoYn36o_GJpUndUCboyZY;z6sySko#r_M{ zpZ1w}sB4~XRKye!BQ?osLQ1ZRhE7tg(w$DE!(FZEeWM| z&CBjNaB^gAm>=uh9y%)|W|_kdrw13K{=U=Mpc9ol&6PXqj^6 zU3iZB^s>(Un_CvJ&I{U8=z8-*)W)Tq&Pu)(W)7y)E8itwFU*m?a+-UQRdV`u>4%Ta z3^JUnHKW#_n^^Dl@L1!5-;TN`qKe;0KT`f+w{8EVm$8-w%X#vvf~y_AFI&%Zgkec> zahP^tY{kWU<3X345wR^2!BW=`tll(SPRW zNy}LTppOpaV%yA6W;i#7WebLo3$rhpmYZ7HzK zF+!>qD~eLnxC|5wfz=}n7%7;Uni`ubq$xnfj0}y;6~M9zc`z{$7b*ruJYdP^nU|IiZgN43r^KQXaOfKuS{iey Ks=E5SaRC6tI)9b` literal 0 HcmV?d00001 diff --git a/test/unit/ui_utils_spec.js b/test/unit/ui_utils_spec.js index 0ead190432d90..41af41e542030 100644 --- a/test/unit/ui_utils_spec.js +++ b/test/unit/ui_utils_spec.js @@ -279,12 +279,11 @@ describe("ui_utils", function () { viewTop < scrollBottom && viewBottom > scrollTop ) { - const hiddenHeight = - Math.max(0, scrollTop - viewTop) + - Math.max(0, viewBottom - scrollBottom); - const hiddenWidth = - Math.max(0, scrollLeft - viewLeft) + - Math.max(0, viewRight - scrollRight); + const minY = Math.max(0, scrollTop - viewTop); + const minX = Math.max(0, scrollLeft - viewLeft); + + const hiddenHeight = minY + Math.max(0, viewBottom - scrollBottom); + const hiddenWidth = minX + Math.max(0, viewRight - scrollRight); const fractionHeight = (div.clientHeight - hiddenHeight) / div.clientHeight; @@ -292,12 +291,23 @@ describe("ui_utils", function () { (div.clientWidth - hiddenWidth) / div.clientWidth; const percent = (fractionHeight * fractionWidth * 100) | 0; + let visibleArea = null; + if (percent < 100) { + visibleArea = { + minX, + minY, + maxX: Math.min(viewRight, scrollRight) - viewLeft, + maxY: Math.min(viewBottom, scrollBottom) - viewTop, + }; + } + views.push({ id: view.id, x: viewLeft, y: viewTop, view, percent, + visibleArea, widthPercent: (fractionWidth * 100) | 0, }); ids.add(view.id); diff --git a/web/app.js b/web/app.js index a935e8eb1f658..2f5e570407206 100644 --- a/web/app.js +++ b/web/app.js @@ -486,6 +486,7 @@ const PDFViewerApplication = { imageResourcesPath: AppOptions.get("imageResourcesPath"), enablePrintAutoRotate: AppOptions.get("enablePrintAutoRotate"), maxCanvasPixels: AppOptions.get("maxCanvasPixels"), + enableDetailCanvas: AppOptions.get("enableDetailCanvas"), enablePermissions: AppOptions.get("enablePermissions"), pageColors, mlManager: this.mlManager, diff --git a/web/app_options.js b/web/app_options.js index 31e47c40f13db..e25bbc1499723 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -195,6 +195,11 @@ const defaultOptions = { value: true, kind: OptionKind.VIEWER + OptionKind.PREFERENCE + OptionKind.EVENT_DISPATCH, }, + enableDetailCanvas: { + /** @type {boolean} */ + value: true, + kind: OptionKind.VIEWER, + }, enableGuessAltText: { /** @type {boolean} */ value: true, diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index a1ee6460787f2..5a00b46b7c030 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -76,6 +76,12 @@ import { XfaLayerBuilder } from "./xfa_layer_builder.js"; * @property {number} [maxCanvasPixels] - The maximum supported canvas size in * total pixels, i.e. width * height. Use `-1` for no limit, or `0` for * CSS-only zooming. The default value is 4096 * 8192 (32 mega-pixels). + * @property {boolean} [enableDetailCanvas] - When enabled, if the rendered + * pages would need a canvas that is larger than `maxCanvasPixels`, it will + * draw a second canvas on top of the CSS-zoomed one, that only renders the + * part of the page that is close to the viewport. The default value is + * `true`. + * @property {Object} [pageColors] - Overwrites background and foreground colors * with user defined ones in order to improve readability in high contrast * mode. @@ -184,11 +190,7 @@ class PDFPageViewBase { } } - get _renderError() { - return this.#renderError; - } - - _createCanvas(onShow) { + _createCanvas(onShow, hideUntilComplete = false) { const { pageColors } = this; const hasHCM = !!(pageColors?.background && pageColors?.foreground); const prevCanvas = this.canvas; @@ -196,7 +198,7 @@ class PDFPageViewBase { // In HCM, a final filter is applied on the canvas which means that // before it's applied we've normal colors. Consequently, to avoid to // have a final flash we just display it once all the drawing is done. - const updateOnFirstShow = !prevCanvas && !hasHCM; + const updateOnFirstShow = !prevCanvas && !hasHCM && !hideUntilComplete; const canvas = document.createElement("canvas"); this.canvas = canvas; @@ -253,29 +255,26 @@ class PDFPageViewBase { this.canvas = null; } - async _drawCanvas(options, prevCanvas, onFinish) { + async _drawCanvas(options, onCancel, onFinish) { const renderTask = (this.renderTask = this.pdfPage.render(options)); renderTask.onContinue = this.#renderContinueCallback; + let error = null; try { await renderTask.promise; this.#showCanvas?.(true); - this.#finishRenderTask(renderTask, null, onFinish); - } catch (error) { + } catch (e) { + error = e; // When zooming with a `drawingDelay` set, avoid temporarily showing // a black canvas if rendering was cancelled before the `onContinue`- // callback had been invoked at least once. - if (!(error instanceof RenderingCancelledException)) { - this.#showCanvas?.(true); + if (error instanceof RenderingCancelledException) { + onCancel(); } else { - prevCanvas?.remove(); - this._resetCanvas(); + this.#showCanvas?.(true); } - this.#finishRenderTask(renderTask, error, onFinish); } - } - async #finishRenderTask(renderTask, error, onFinish) { // The renderTask may have been replaced by a new one, so only remove // the reference to the renderTask if it matches the one that is // triggering this callback. @@ -319,7 +318,7 @@ class PDFPageViewBase { pageNumber: this.id, cssTransform, timestamp: performance.now(), - error: this._renderError, + error: this.#renderError, }); } } @@ -380,6 +379,7 @@ class PDFPageView extends PDFPageViewBase { this.#annotationMode = options.annotationMode ?? AnnotationMode.ENABLE_FORMS; this.imageResourcesPath = options.imageResourcesPath || ""; + this.enableDetailCanvas = options.enableDetailCanvas ?? true; this.maxCanvasPixels = options.maxCanvasPixels ?? AppOptions.get("maxCanvasPixels"); @@ -402,6 +402,8 @@ class PDFPageView extends PDFPageViewBase { this.structTreeLayer = null; this.drawLayer = null; + this.detailView = null; + const div = document.createElement("div"); div.className = "page"; div.setAttribute("data-page-number", this.id); @@ -684,6 +686,7 @@ class PDFPageView extends PDFPageViewBase { keepXfaLayer = false, keepTextLayer = false, keepCanvasWrapper = false, + preserveDetailViewState = false, } = {}) { this.cancelRendering({ keepAnnotationLayer, @@ -743,6 +746,11 @@ class PDFPageView extends PDFPageViewBase { this.#canvasWrapper = null; this._resetCanvas(); } + + if (!preserveDetailViewState) { + this.detailView?.reset({ keepCanvas: keepCanvasWrapper }); + this.detailView = null; + } } toggleEditingMode(isEditing) { @@ -763,6 +771,16 @@ class PDFPageView extends PDFPageViewBase { }); } + updateVisibleArea(visibleArea) { + if (this.#hasRestrictedScaling && this.maxCanvasPixels > 0 && visibleArea) { + this.detailView ??= new PDFPageDetailView({ pageView: this }); + this.detailView.update({ visibleArea }); + } else if (this.detailView) { + this.detailView.reset(); + this.detailView = null; + } + } + /** * @typedef {Object} PDFPageViewUpdateParameters * @property {number} [scale] The new scale, if specified. @@ -818,6 +836,8 @@ class PDFPageView extends PDFPageViewBase { this._container?.style.setProperty("--scale-factor", this.viewport.scale); } + this.#computeScale(); + if (this.canvas) { let onlyCssZoom = false; if (this.#hasRestrictedScaling) { @@ -826,7 +846,7 @@ class PDFPageView extends PDFPageViewBase { this.maxCanvasPixels === 0 ) { onlyCssZoom = true; - } else if (this.maxCanvasPixels > 0) { + } else if (!this.enableDetailCanvas && this.maxCanvasPixels > 0) { const { width, height } = this.viewport; const { sx, sy } = this.outputScale; onlyCssZoom = @@ -870,6 +890,8 @@ class PDFPageView extends PDFPageViewBase { // The "pagerendered"-event will be dispatched once the actual // rendering is done, hence don't dispatch it here as well. if (!postponeDrawing) { + this.detailView?.update({ underlyingViewUpdated: true }); + this.dispatchPageRendered(true); } return; @@ -882,7 +904,38 @@ class PDFPageView extends PDFPageViewBase { keepXfaLayer: true, keepTextLayer: true, keepCanvasWrapper: true, + // It will be reset by the .update call below + preserveDetailViewState: true, }); + + this.detailView?.update({ underlyingViewUpdated: true }); + } + + #computeScale() { + const { width, height } = this.viewport; + const outputScale = (this.outputScale = new OutputScale()); + + if ( + (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) && + this.maxCanvasPixels === 0 + ) { + const invScale = 1 / this.scale; + // Use a scale that makes the canvas have the originally intended size + // of the page. + outputScale.sx *= invScale; + outputScale.sy *= invScale; + this.#hasRestrictedScaling = true; + } else if (this.maxCanvasPixels > 0) { + const pixelsInViewport = width * height; + const maxScale = Math.sqrt(this.maxCanvasPixels / pixelsInViewport); + if (outputScale.sx > maxScale || outputScale.sy > maxScale) { + outputScale.sx = maxScale; + outputScale.sy = maxScale; + this.#hasRestrictedScaling = true; + } else { + this.#hasRestrictedScaling = false; + } + } } /** @@ -995,12 +1048,37 @@ class PDFPageView extends PDFPageViewBase { return this.viewport.convertToPdfPoint(x, y); } + // Wrap the canvas so that if it has a CSS transform for high DPI the + // overflow will be hidden in Firefox. + _ensureCanvasWrapper() { + let canvasWrapper = this.#canvasWrapper; + if (!canvasWrapper) { + canvasWrapper = this.#canvasWrapper = document.createElement("div"); + canvasWrapper.classList.add("canvasWrapper"); + this.#addLayer(canvasWrapper, "canvasWrapper"); + } + return canvasWrapper; + } + + _getRenderingContext(canvasContext, transform) { + return { + canvasContext, + transform, + viewport: this.viewport, + annotationMode: this.#annotationMode, + optionalContentConfigPromise: this._optionalContentConfigPromise, + annotationCanvasMap: this._annotationCanvasMap, + pageColors: this.pageColors, + isEditing: this.#isEditing, + }; + } + async draw() { if (this.renderingState !== RenderingStates.INITIAL) { console.error("Must be in new state before drawing"); this.reset(); // Ensure that we reset all state to prevent issues. } - const { div, l10n, pageColors, pdfPage, viewport } = this; + const { div, l10n, pdfPage, viewport } = this; if (!pdfPage) { this.renderingState = RenderingStates.FINISHED; @@ -1009,14 +1087,7 @@ class PDFPageView extends PDFPageViewBase { this.renderingState = RenderingStates.RUNNING; - // Wrap the canvas so that if it has a CSS transform for high DPI the - // overflow will be hidden in Firefox. - let canvasWrapper = this.#canvasWrapper; - if (!canvasWrapper) { - canvasWrapper = this.#canvasWrapper = document.createElement("div"); - canvasWrapper.classList.add("canvasWrapper"); - this.#addLayer(canvasWrapper, "canvasWrapper"); - } + const canvasWrapper = this._ensureCanvasWrapper(); if ( !this.textLayer && @@ -1083,29 +1154,11 @@ class PDFPageView extends PDFPageViewBase { }); canvas.setAttribute("role", "presentation"); - const outputScale = (this.outputScale = new OutputScale()); - - if ( - (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) && - this.maxCanvasPixels === 0 - ) { - const invScale = 1 / this.scale; - // Use a scale that makes the canvas have the originally intended size - // of the page. - outputScale.sx *= invScale; - outputScale.sy *= invScale; - this.#hasRestrictedScaling = true; - } else if (this.maxCanvasPixels > 0) { - const pixelsInViewport = width * height; - const maxScale = Math.sqrt(this.maxCanvasPixels / pixelsInViewport); - if (outputScale.sx > maxScale || outputScale.sy > maxScale) { - outputScale.sx = maxScale; - outputScale.sy = maxScale; - this.#hasRestrictedScaling = true; - } else { - this.#hasRestrictedScaling = false; - } + if (!this.outputScale) { + this.#computeScale(); } + const { outputScale } = this; + const sfx = approximateFraction(outputScale.sx); const sfy = approximateFraction(outputScale.sy); @@ -1135,26 +1188,26 @@ class PDFPageView extends PDFPageViewBase { const transform = outputScale.scaled ? [outputScale.sx, 0, 0, outputScale.sy, 0, 0] : null; - const renderContext = { - canvasContext: ctx, - transform, - viewport, - annotationMode: this.#annotationMode, - optionalContentConfigPromise: this._optionalContentConfigPromise, - annotationCanvasMap: this._annotationCanvasMap, - pageColors, - isEditing: this.#isEditing, - }; const resultPromise = this._drawCanvas( - renderContext, - prevCanvas, + this._getRenderingContext(ctx, transform), + () => { + prevCanvas?.remove(); + this._resetCanvas(); + }, renderTask => { // Ensure that the thumbnails won't become partially (or fully) blank, // for documents that contain interactive form elements. this.#useThumbnailCanvas.regularAnnotations = !renderTask.separateAnnots; - this.dispatchPageRendered(false); + // If there is a `.detailView` that still needs to be rendered, it will + // dispatch the pagerendered event once it's done. + if ( + !this.detailView || + this.detailView.renderingState === RenderingStates.FINISHED + ) { + this.dispatchPageRendered(false); + } } ).then(async () => { this.structTreeLayer ||= new StructTreeLayerBuilder( @@ -1246,4 +1299,225 @@ class PDFPageView extends PDFPageViewBase { } } -export { PDFPageView }; +/** + * @implements {IRenderableView} + */ +class PDFPageDetailView extends PDFPageViewBase { + #detailArea = null; + + constructor({ pageView }) { + super(pageView); + + this.pageView = pageView; + this.renderingId = "detail" + this.id; + + this.div = pageView.div; + } + + setPdfPage(pdfPage) { + this.pageView.setPdfPage(pdfPage); + } + + get pdfPage() { + return this.pageView.pdfPage; + } + + reset({ keepCanvas = false } = {}) { + this.cancelRendering(); + this.renderingState = RenderingStates.INITIAL; + + if (!keepCanvas) { + this._resetCanvas(); + } + } + + #shouldRenderDifferentArea(visibleArea) { + if (!this.#detailArea) { + return true; + } + + const minDetailX = this.#detailArea.minX; + const minDetailY = this.#detailArea.minY; + const maxDetailX = this.#detailArea.width + minDetailX; + const maxDetailY = this.#detailArea.height + minDetailY; + + if ( + visibleArea.minX < minDetailX || + visibleArea.minY < minDetailY || + visibleArea.maxX > maxDetailX || + visibleArea.maxY > maxDetailY + ) { + return true; + } + + const { + width: maxWidth, + height: maxHeight, + scale, + } = this.pageView.viewport; + + if (this.#detailArea.scale !== scale) { + return true; + } + + const paddingLeftSize = visibleArea.minX - minDetailX; + const paddingRightSize = maxDetailX - visibleArea.maxX; + const paddingTopSize = visibleArea.minY - minDetailY; + const paddingBottomSize = maxDetailY - visibleArea.maxY; + + // If the user is moving in any direction such that the remaining area + // rendered outside of the screen is less than MOVEMENT_TRESHOLD of the + // padding we render on each side, trigger a re-render. This is so that if + // the user then keeps scrolling in that direction, we have a chance of + // finishing rendering the new detail before they get past the rendered + // area. + + const MOVEMENT_TRESHOLD = 0.5; + const ratio = (1 + MOVEMENT_TRESHOLD) / MOVEMENT_TRESHOLD; + + if ( + (minDetailX > 0 && paddingRightSize / paddingLeftSize > ratio) || + (maxDetailX < maxWidth && paddingLeftSize / paddingRightSize > ratio) || + (minDetailY > 0 && paddingBottomSize / paddingTopSize > ratio) || + (maxDetailY < maxHeight && paddingTopSize / paddingBottomSize > ratio) + ) { + return true; + } + + return false; + } + + update({ visibleArea = null, underlyingViewUpdated = false } = {}) { + if (underlyingViewUpdated) { + this.cancelRendering(); + this.renderingState = RenderingStates.INITIAL; + return; + } + + if (!this.#shouldRenderDifferentArea(visibleArea)) { + return; + } + + const { viewport, maxCanvasPixels } = this.pageView; + + const visibleWidth = visibleArea.maxX - visibleArea.minX; + const visibleHeight = visibleArea.maxY - visibleArea.minY; + + // "overflowScale" represents which percentage of the width and of the + // height the detail area extends outside of the visible area. We want to + // draw a larger area so that we don't have to constantly re-draw while + // scrolling. The detail area's dimensions thus become + // visibleLength * (2 * overflowScale + 1). + // We default to adding a whole height/length of detail area on each side, + // but we can reduce it to make sure that we stay within the maxCanvasPixels + // limit. + const visiblePixels = + visibleWidth * visibleHeight * (window.devicePixelRatio || 1) ** 2; + const maxDetailToVisibleLinearRatio = Math.sqrt( + maxCanvasPixels / visiblePixels + ); + const maxOverflowScale = (maxDetailToVisibleLinearRatio - 1) / 2; + let overflowScale = Math.min(1, maxOverflowScale); + if (overflowScale < 0) { + overflowScale = 0; + // In this case, we render a detail view that is exactly as big as the + // visible area, but we ignore the .maxCanvasPixels limit. + // TODO: We should probably instead give up and not render the detail view + // in this case. It's quite rare to hit it though, because usually + // .maxCanvasPixels will at least have enough pixels to cover the visible + // screen. + } + + const overflowWidth = visibleWidth * overflowScale; + const overflowHeight = visibleHeight * overflowScale; + + const minX = Math.max(0, visibleArea.minX - overflowWidth); + const maxX = Math.min(viewport.width, visibleArea.maxX + overflowWidth); + const minY = Math.max(0, visibleArea.minY - overflowHeight); + const maxY = Math.min(viewport.height, visibleArea.maxY + overflowHeight); + const width = maxX - minX; + const height = maxY - minY; + + this.#detailArea = { minX, minY, width, height, scale: viewport.scale }; + + this.reset({ keepCanvas: true }); + } + + async draw() { + // If there is already the lower resolution canvas behind, + // we don't show the new one until when it's fully ready. + const hideUntilComplete = + this.pageView.renderingState === RenderingStates.FINISHED || + this.renderingState === RenderingStates.FINISHED; + + if (this.renderingState !== RenderingStates.INITIAL) { + console.error("Must be in new state before drawing"); + this.reset(); // Ensure that we reset all state to prevent issues. + } + const { div, pdfPage, viewport } = this.pageView; + + if (!pdfPage) { + this.renderingState = RenderingStates.FINISHED; + throw new Error("pdfPage is not loaded"); + } + + this.renderingState = RenderingStates.RUNNING; + + const canvasWrapper = this.pageView._ensureCanvasWrapper(); + + const { canvas, prevCanvas, ctx } = this._createCanvas(newCanvas => { + // If there is already the background canvas, inject this new canvas + // after it. We cannot simply use .append because all canvases must + // be before the SVG elements used for drawings. + if (canvasWrapper.firstElementChild?.tagName === "CANVAS") { + canvasWrapper.firstElementChild.after(newCanvas); + } else { + canvasWrapper.prepend(newCanvas); + } + }, hideUntilComplete); + canvas.setAttribute("aria-hidden", "true"); + + const { width, height } = viewport; + + const area = this.#detailArea; + + const { devicePixelRatio = 1 } = window; + const transform = [ + devicePixelRatio, + 0, + 0, + devicePixelRatio, + -area.minX * devicePixelRatio, + -area.minY * devicePixelRatio, + ]; + + canvas.width = area.width * devicePixelRatio; + canvas.height = area.height * devicePixelRatio; + canvas.style.position = "absolute"; + canvas.style.display = "block"; + canvas.style.width = `${(area.width * 100) / width}%`; + canvas.style.height = `${(area.height * 100) / height}%`; + canvas.style.top = `${(area.minY * 100) / height}%`; + canvas.style.left = `${(area.minX * 100) / width}%`; + + const renderingPromise = this._drawCanvas( + this.pageView._getRenderingContext(ctx, transform), + () => { + // If the rendering is cancelled, keep the old canvas visible. + this.canvas?.remove(); + this.canvas = prevCanvas; + }, + () => { + this.dispatchPageRendered(false); + } + ); + + div.setAttribute("data-loaded", true); + + this.dispatchPageRender(); + + return renderingPromise; + } +} + +export { PDFPageDetailView, PDFPageView }; diff --git a/web/pdf_rendering_queue.js b/web/pdf_rendering_queue.js index 620eae6c104da..0020518f98809 100644 --- a/web/pdf_rendering_queue.js +++ b/web/pdf_rendering_queue.js @@ -110,7 +110,8 @@ class PDFRenderingQueue { * * Priority: * 1. visible pages - * 2. if last scrolled down, the page after the visible pages, or + * 2. zoomed-in partial views of visible pages + * 3. if last scrolled down, the page after the visible pages, or * if last scrolled up, the page before the visible pages */ const visibleViews = visible.views, @@ -125,6 +126,14 @@ class PDFRenderingQueue { return view; } } + + for (let i = 0; i < numVisible; i++) { + const { detailView } = visibleViews[i].view; + if (detailView && !this.isViewFinished(detailView)) { + return detailView; + } + } + const firstId = visible.first.id, lastId = visible.last.id; diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index 59621ba5e9e9f..efda5b8b2633d 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -118,6 +118,11 @@ function isValidAnnotationEditorMode(mode) { * @property {number} [maxCanvasPixels] - The maximum supported canvas size in * total pixels, i.e. width * height. Use `-1` for no limit, or `0` for * CSS-only zooming. The default value is 4096 * 8192 (32 mega-pixels). + * @property {boolean} [enableDetailCanvas] - When enabled, if the rendered + * pages would need a canvas that is larger than `maxCanvasPixels`, it will + * draw a second canvas on top of the CSS-zoomed one, that only renders the + * part of the page that is close to the viewport. The default value is + * `true`. * @property {IL10n} [l10n] - Localization service. * @property {boolean} [enablePermissions] - Enables PDF document permissions, * when they exist. The default value is `false`. @@ -312,6 +317,7 @@ class PDFViewer { this.removePageBorders = options.removePageBorders || false; } this.maxCanvasPixels = options.maxCanvasPixels; + this.enableDetailCanvas = options.enableDetailCanvas ?? true; this.l10n = options.l10n; if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { this.l10n ||= new GenericL10n(); @@ -986,6 +992,7 @@ class PDFViewer { annotationMode, imageResourcesPath: this.imageResourcesPath, maxCanvasPixels: this.maxCanvasPixels, + enableDetailCanvas: this.enableDetailCanvas, pageColors, l10n: this.l10n, layerProperties: this._layerProperties, @@ -1664,6 +1671,15 @@ class PDFViewer { const newCacheSize = Math.max(DEFAULT_CACHE_SIZE, 2 * numVisiblePages + 1); this.#buffer.resize(newCacheSize, visible.ids); + for (const { view, visibleArea } of visiblePages) { + view.updateVisibleArea(visibleArea); + } + for (const view of this.#buffer) { + if (!visible.ids.has(view.id)) { + view.updateVisibleArea(null); + } + } + this.renderingQueue.renderHighestPriority(visible); const isSimpleLayout = diff --git a/web/ui_utils.js b/web/ui_utils.js index 07dc55c7a7221..dd2f250eae2f1 100644 --- a/web/ui_utils.js +++ b/web/ui_utils.js @@ -554,10 +554,11 @@ function getVisibleElements({ continue; } - const hiddenHeight = - Math.max(0, top - currentHeight) + Math.max(0, viewBottom - bottom); - const hiddenWidth = - Math.max(0, left - currentWidth) + Math.max(0, viewRight - right); + const minY = Math.max(0, top - currentHeight); + const minX = Math.max(0, left - currentWidth); + + const hiddenHeight = minY + Math.max(0, viewBottom - bottom); + const hiddenWidth = minX + Math.max(0, viewRight - right); const fractionHeight = (viewHeight - hiddenHeight) / viewHeight, fractionWidth = (viewWidth - hiddenWidth) / viewWidth; @@ -567,6 +568,18 @@ function getVisibleElements({ id: view.id, x: currentWidth, y: currentHeight, + visibleArea: + // We only specify which part of the page is visible when it's not + // the full page, as there is no point in handling a partial page + // rendering otherwise. + percent === 100 + ? null + : { + minX, + minY, + maxX: Math.min(viewRight, right) - currentWidth, + maxY: Math.min(viewBottom, bottom) - currentHeight, + }, view, percent, widthPercent: (fractionWidth * 100) | 0, From 9165845d314af57a430a530038eec06db953a9e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Ribaudo?= Date: Thu, 16 Jan 2025 11:26:19 +0100 Subject: [PATCH 3/4] Avoid degrading scroll performance due to the detail view When scrolling quickly, the constant re-rendering of the detail view significantly affects rendering performance, causing Firefox to not render even the _background canvas_, which is just a static canvas not being re-drawn by JavaScript. This commit changes the viewer to only render the detail view while scrolling if its rendering hasn't just been cancelled. This means that: - when the user is scrolling slowly, we have enough time to render the detail view before that we need to change its area, so the user always sees the full screen as high resolution. - when the user is scrolling quickly, as soon as we have to cancel a rendering we just give up, and the user will see the lower resolution canvas. When then the user stops scrolling, we render the detail view for the new visible area. --- web/pdf_page_view.js | 22 ++++++++++++++++++++++ web/pdf_viewer.js | 29 ++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index 5a00b46b7c030..386ad0d2ab163 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -1305,6 +1305,14 @@ class PDFPageView extends PDFPageViewBase { class PDFPageDetailView extends PDFPageViewBase { #detailArea = null; + /** + * @type {boolean} True when the last rendering attempt of the view was + * cancelled due to a `.reset()` call. This will happen when + * the visible area changes so much during the rendering that + * we need to cancel the rendering and start over. + */ + renderingCancelled = false; + constructor({ pageView }) { super(pageView); @@ -1322,9 +1330,23 @@ class PDFPageDetailView extends PDFPageViewBase { return this.pageView.pdfPage; } + get renderingState() { + return super.renderingState; + } + + set renderingState(value) { + this.renderingCancelled = false; + super.renderingState = value; + } + reset({ keepCanvas = false } = {}) { + const renderingCancelled = + this.renderingCancelled || + this.renderingState === RenderingStates.RUNNING || + this.renderingState === RenderingStates.PAUSED; this.cancelRendering(); this.renderingState = RenderingStates.INITIAL; + this.renderingCancelled = renderingCancelled; if (!keepCanvas) { this._resetCanvas(); diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index efda5b8b2633d..2036eb6db185e 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -63,8 +63,8 @@ import { VERTICAL_PADDING, watchScroll, } from "./ui_utils.js"; +import { PDFPageDetailView, PDFPageView } from "./pdf_page_view.js"; import { GenericL10n } from "web-null_l10n"; -import { PDFPageView } from "./pdf_page_view.js"; import { PDFRenderingQueue } from "./pdf_rendering_queue.js"; import { SimpleLinkService } from "./pdf_link_service.js"; @@ -237,6 +237,8 @@ class PDFViewer { #mlManager = null; + #scrollTimeoutId = null; + #switchAnnotationEditorModeAC = null; #switchAnnotationEditorModeTimeoutId = null; @@ -1233,6 +1235,15 @@ class PDFViewer { if (this.pagesCount === 0) { return; } + + if (this.#scrollTimeoutId) { + clearTimeout(this.#scrollTimeoutId); + } + this.#scrollTimeoutId = setTimeout(() => { + this.#scrollTimeoutId = null; + this.update(); + }, 100); + this.update(); } @@ -1851,6 +1862,18 @@ class PDFViewer { ); if (pageView) { + if ( + this.#scrollTimeoutId !== null && + pageView instanceof PDFPageDetailView && + pageView.renderingCancelled + ) { + // If we are scrolling and the rendering of the detail view was just + // cancelled, it's because the user is scrolling too quickly and so + // we constantly need to re-render a different area. + // Don't attempt to re-rendering it: this will be done once the user + // stops scrolling. + return true; + } this.#ensurePdfPageLoaded(pageView).then(() => { this.renderingQueue.renderView(pageView); }); @@ -2420,6 +2443,10 @@ class PDFViewer { clearTimeout(this.#scaleTimeoutId); this.#scaleTimeoutId = null; } + if (this.#scrollTimeoutId !== null) { + clearTimeout(this.#scrollTimeoutId); + this.#scrollTimeoutId = null; + } if (!noUpdate) { this.update(); } From f413386dd9b703802cbc190d7d3f52af6e7a3972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Ribaudo?= Date: Fri, 24 Jan 2025 13:14:55 +0100 Subject: [PATCH 4/4] fixup! Render high-res partial page views when falling back to CSS zoom (bug 1492303) Keep the `.detailView` object when preserving the detail canvas --- web/pdf_page_view.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index 386ad0d2ab163..b6cf2b7b037c9 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -749,7 +749,13 @@ class PDFPageView extends PDFPageViewBase { if (!preserveDetailViewState) { this.detailView?.reset({ keepCanvas: keepCanvasWrapper }); - this.detailView = null; + + // If we are keeping the canvas around we must also keep the `detailView` + // object, so that next time we need a detail view we'll update the + // existing canvas rather than creating a new one. + if (!keepCanvasWrapper) { + this.detailView = null; + } } }