From f68dd33d673f9a899bac0aa366f218bf78adbb75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Ribaudo?= Date: Fri, 29 Nov 2024 15:47:08 +0100 Subject: [PATCH] Render high-res partial page views when falling back to CSS zoom 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/unit/ui_utils_spec.js | 22 +- web/app.js | 8 +- web/pdf_page_view.js | 684 ++++++++++++++++++++++++++----------- web/pdf_rendering_queue.js | 11 +- web/pdf_viewer.js | 12 +- web/ui_utils.js | 21 +- 6 files changed, 538 insertions(+), 220 deletions(-) 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 5ea10fcfde157..e8ecf094b44b1 100644 --- a/web/app.js +++ b/web/app.js @@ -2280,18 +2280,18 @@ if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { }; } -function onPageRender({ pageNumber }) { +function onPageRender({ pageNumber, source }) { // If the page is (the most) visible when it starts rendering, // ensure that the page number input loading indicator is displayed. - if (pageNumber === this.page) { + if (pageNumber === this.page && !source.detailView) { this.toolbar?.updateLoadingIndicatorState(true); } } -function onPageRendered({ pageNumber, error }) { +function onPageRendered({ pageNumber, source, error }) { // If the page is still visible when it has finished rendering, // ensure that the page number input loading indicator is hidden. - if (pageNumber === this.page) { + if (pageNumber === this.page && !source.detailView) { this.toolbar?.updateLoadingIndicatorState(false); } diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index 13b18e433c26f..fc12f7da00b2f 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -52,6 +52,10 @@ import { TextHighlighter } from "./text_highlighter.js"; import { TextLayerBuilder } from "./text_layer_builder.js"; import { XfaLayerBuilder } from "./xfa_layer_builder.js"; +// TODO: Should we make this an app option, or just always +// enable the feature? +const ENABLE_ZOOM_DETAIL = true; + /** * @typedef {Object} PDFPageViewOptions * @property {HTMLDivElement} [container] - The viewer element. @@ -104,31 +108,256 @@ const DEFAULT_LAYER_PROPERTIES = const LAYERS_ORDER = new Map([ ["canvasWrapper", 0], - ["textLayer", 1], - ["annotationLayer", 2], - ["annotationEditorLayer", 3], - ["xfaLayer", 3], + ["detailLayer", 1], + ["textLayer", 2], + ["annotationLayer", 3], + ["annotationEditorLayer", 4], + ["xfaLayer", 4], ]); +class PDFPageViewBase { + #enableHWA = false; + + #loadingId = null; + + #renderError = null; + + #renderingState = RenderingStates.INITIAL; + + #showCanvas = null; + + canvas = null; + + canvasWrapper = 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; + } + + _ensureCanvasWrapper(onCreate) { + // 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"); + onCreate(canvasWrapper); + } + return canvasWrapper; + } + + _createCanvas(hideUntilComplete = false) { + const { canvasWrapper, pageColors } = this; + + const hasHCM = !!(pageColors?.background && pageColors?.foreground); + const prevCanvas = this.canvas; + const updateOnFirstShow = !prevCanvas && !hasHCM && !hideUntilComplete; + + const canvas = document.createElement("canvas"); + canvas.setAttribute("role", "presentation"); + 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. + canvasWrapper.append(canvas); + this.#showCanvas = null; + return; + } + if (!isLastShow) { + return; + } + + if (prevCanvas) { + prevCanvas.replaceWith(canvas); + prevCanvas.width = prevCanvas.height = 0; + } else { + // 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. + canvasWrapper.append(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); + + this.eventBus.dispatch("pagerendered", { + source: this, + pageNumber: this.id, + cssTransform: false, + timestamp: performance.now(), + error: this.#renderError, + }); + + if (error) { + throw error; + } + } + + cancelRendering({ cancelExtraDelay = 0 } = {}) { + if (this.renderTask) { + this.renderTask.cancel(cancelExtraDelay); + this.renderTask = null; + } + this.resume = null; + } +} + +/** + * @typedef {Object} PDFPageViewUpdateParameters + * @property {number} [scale] The new scale, if specified. + * @property {number} [rotation] The new rotation, if specified. + * @property {Promise} [optionalContentConfigPromise] + * A promise that is resolved with an {@link OptionalContentConfig} + * instance. The default value is `null`. + * @property {number} [drawingDelay] + */ + /** * @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; + _originalViewport = null; #previousRotation = null; @@ -136,10 +365,6 @@ class PDFPageView { #scaleRoundY = 1; - #renderError = null; - - #renderingState = RenderingStates.INITIAL; - #textLayerMode = TextLayerMode.ENABLE; #useThumbnailCanvas = { @@ -148,16 +373,19 @@ class PDFPageView { regularAnnotations: true, }; - #layers = [null, null, null, null]; + #originalViewport = null; + + #layers = [null, null, null, null, null]; /** * @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 +403,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; @@ -201,6 +423,8 @@ class PDFPageView { this.structTreeLayer = null; this.drawLayer = null; + this.detailView = null; + const div = document.createElement("div"); div.className = "page"; div.setAttribute("data-page-number", this.id); @@ -252,7 +476,7 @@ class PDFPageView { } } - #addLayer(div, name) { + _addLayer(div, name) { const pos = LAYERS_ORDER.get(name); const oldDiv = this.#layers[pos]; this.#layers[pos] = div; @@ -270,41 +494,8 @@ 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; - } + _forgetLayer(name) { + this.#layers[LAYERS_ORDER.get(name)] = null; } #setDimensions() { @@ -447,7 +638,7 @@ class PDFPageView { if (this.xfaLayer?.div) { // Pause translation when inserting the xfaLayer in the DOM. this.l10n.pause(); - this.#addLayer(this.xfaLayer.div, "xfaLayer"); + this._addLayer(this.xfaLayer.div, "xfaLayer"); this.l10n.resume(); } this.#dispatchLayerRendered("xfalayerrendered", error); @@ -509,14 +700,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; } @@ -544,7 +729,9 @@ class PDFPageView { (keepAnnotationEditorLayer && this.annotationEditorLayer?.div) || null, xfaLayerNode = (keepXfaLayer && this.xfaLayer?.div) || null, textLayerNode = (keepTextLayer && this.textLayer?.div) || null, - canvasWrapperNode = (keepCanvasWrapper && this.#canvasWrapper) || null; + canvasWrapperNode = (keepCanvasWrapper && this.canvasWrapper) || null, + detailLayerNode = + (keepCanvasWrapper && this.detailView?.canvasWrapper) || null; for (let i = childNodes.length - 1; i >= 0; i--) { const node = childNodes[i]; switch (node) { @@ -553,6 +740,7 @@ class PDFPageView { case xfaLayerNode: case textLayerNode: case canvasWrapperNode: + case detailLayerNode: continue; } node.remove(); @@ -581,9 +769,13 @@ class PDFPageView { } this.structTreeLayer?.hide(); - if (!keepCanvasWrapper && this.#canvasWrapper) { - this.#canvasWrapper = null; - this.#resetCanvas(); + if (!keepCanvasWrapper && this.canvasWrapper) { + this.canvasWrapper = null; + this._resetCanvas(); + } + if (!keepCanvasWrapper && this.detailView?.canvasWrapper) { + this.detailView.canvasWrapper = null; + this.detailView._resetCanvas(); } } @@ -601,15 +793,15 @@ class PDFPageView { }); } - /** - * @typedef {Object} PDFPageViewUpdateParameters - * @property {number} [scale] The new scale, if specified. - * @property {number} [rotation] The new rotation, if specified. - * @property {Promise} [optionalContentConfigPromise] - * A promise that is resolved with an {@link OptionalContentConfig} - * instance. The default value is `null`. - * @property {number} [drawingDelay] - */ + 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; + } + } /** * Update e.g. the scale and/or rotation of the page. @@ -664,7 +856,7 @@ class PDFPageView { this.maxCanvasPixels === 0 ) { onlyCssZoom = true; - } else if (this.maxCanvasPixels > 0) { + } else if (!ENABLE_ZOOM_DETAIL && this.maxCanvasPixels > 0) { const { width, height } = this.viewport; const { sx, sy } = this.outputScale; onlyCssZoom = @@ -710,12 +902,15 @@ class PDFPageView { // rendering is done, hence don't dispatch it here as well. return; } + + this.detailView?.update({ underlyingViewUpdated: true }); + this.eventBus.dispatch("pagerendered", { source: this, pageNumber: this.id, cssTransform: true, timestamp: performance.now(), - error: this.#renderError, + error: this._renderError, }); return; } @@ -728,6 +923,8 @@ class PDFPageView { keepTextLayer: true, keepCanvasWrapper: true, }); + + this.detailView?.update({ underlyingViewUpdated: true }); } /** @@ -741,11 +938,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(); @@ -844,39 +1037,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"); @@ -891,14 +1051,9 @@ class PDFPageView { 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(cw => { + this._addLayer(cw, "canvasWrapper"); + }); if ( !this.textLayer && @@ -916,7 +1071,7 @@ class PDFPageView { onAppend: textLayerDiv => { // Pause translation when inserting the textLayer in the DOM. this.l10n.pause(); - this.#addLayer(textLayerDiv, "textLayer"); + this._addLayer(textLayerDiv, "textLayer"); this.l10n.resume(); }, }); @@ -951,64 +1106,16 @@ class PDFPageView { accessibilityManager: this._accessibilityManager, annotationEditorUIManager, onAppend: annotationLayerDiv => { - this.#addLayer(annotationLayerDiv, "annotationLayer"); + this._addLayer(annotationLayerDiv, "annotationLayer"); }, }); } - 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; - 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. - canvasWrapper.append(canvas); - showCanvas = null; - return; - } - if (!isLastShow) { - return; - } - - if (prevCanvas) { - prevCanvas.replaceWith(canvas); - prevCanvas.width = prevCanvas.height = 0; - } else { - // 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. - canvasWrapper.append(canvas); - } - - showCanvas = null; - }; + const { canvas, prevCanvas, ctx } = this._createCanvas(); - const ctx = canvas.getContext("2d", { - alpha: false, - willReadFrequently: !this.#enableHWA, - }); const outputScale = (this.outputScale = new OutputScale()); if ( @@ -1071,14 +1178,17 @@ class PDFPageView { pageColors, isEditing: this.#isEditing, }; - const renderTask = (this.renderTask = pdfPage.render(renderContext)); - renderTask.onContinue = renderContinueCallback; - - const resultPromise = renderTask.promise.then( + 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; + } + ).then( async () => { - showCanvas?.(true); - await this.#finishRenderTask(renderTask); - this.structTreeLayer ||= new StructTreeLayerBuilder( pdfPage, viewport.rawDims @@ -1111,22 +1221,14 @@ class PDFPageView { textLayer: this.textLayer, drawLayer: this.drawLayer.getDrawLayer(), onAppend: annotationEditorLayerDiv => { - this.#addLayer(annotationEditorLayerDiv, "annotationEditorLayer"); + 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); + // TODO: To minimize diff due to autoformatting. Remove before merging. + e => { + throw e; } ); @@ -1183,4 +1285,184 @@ class PDFPageView { } } -export { PDFPageView }; +/** + * @implements {IRenderableView} + */ +class PDFPageDetailView extends PDFPageViewBase { + constructor({ pageView }) { + super(pageView); + + this.pageView = pageView; + this.renderingId = "detail" + this.id; + + this.detailArea = null; + + this.div = pageView.div; + } + + setPdfPage(pdfPage) { + this.pageView.setPdfPage(pdfPage); + } + + get pdfPage() { + return this.pageView.pdfPage; + } + + reset({ keepCanvasWrapper = false } = {}) { + this.cancelRendering(); + this.renderingState = RenderingStates.INITIAL; + + if (!keepCanvasWrapper && this.canvasWrapper) { + this.canvasWrapper.remove(); + this.canvasWrapper = null; + this._resetCanvas(); + } + } + + #covers(visibleArea) { + if (!this.detailArea) { + return false; + } + + const { minX, minY, width, height } = this.detailArea; + return ( + visibleArea.minX >= minX && + visibleArea.minY >= minY && + visibleArea.maxX <= minX + width && + visibleArea.maxY <= minY + height + ); + } + + update({ visibleArea = null, underlyingViewUpdated = false } = {}) { + if (underlyingViewUpdated) { + this.cancelRendering(); + this.renderingState = RenderingStates.INITIAL; + return; + } + + if (this.#covers(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 rate 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 }; + + this.reset({ keepCanvasWrapper: true }); + } + + async draw() { + const initialRenderingState = this.renderingState; + if (initialRenderingState !== 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; + + this._ensureCanvasWrapper(canvasWrapper => { + canvasWrapper.classList.add("detailLayer"); + this.pageView._addLayer(canvasWrapper, "detailLayer"); + }); + + const { canvas, prevCanvas, ctx } = this._createCanvas( + // If there is already the lower resolution canvas behind, + // we don't show the new one until when it's fully ready. + this.pageView.renderingState === RenderingStates.FINISHED || + initialRenderingState === RenderingStates.FINISHED + ); + const { width, height } = viewport; + + const area = this.detailArea; + + const { devicePixelRatio = 1 } = window; + const transform = + devicePixelRatio !== 1 + ? [ + devicePixelRatio, + 0, + 0, + devicePixelRatio, + -area.minX * devicePixelRatio, + -area.minY * devicePixelRatio, + ] + : null; + + 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( + { + canvasContext: ctx, + transform, + viewport, + pageColors: this.pageColors, + }, + prevCanvas + ); + + div.setAttribute("data-loaded", true); + + this.eventBus.dispatch("pagerender", { + source: this, + pageNumber: this.id, + }); + + return renderingPromise; + } +} + +/** + * @implements {IRenderableView} + */ +export { PDFPageDetailView, PDFPageView }; diff --git a/web/pdf_rendering_queue.js b/web/pdf_rendering_queue.js index 620eae6c104da..d314e410d8c19 100644 --- a/web/pdf_rendering_queue.js +++ b/web/pdf_rendering_queue.js @@ -109,8 +109,9 @@ class PDFRenderingQueue { * render next (if any). * * Priority: - * 1. visible pages - * 2. if last scrolled down, the page after the visible pages, or + * 1. zoomed-in partial views of visible pages + * 2. 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, @@ -119,6 +120,12 @@ class PDFRenderingQueue { if (numVisible === 0) { return null; } + for (let i = 0; i < numVisible; i++) { + const { detailView } = visibleViews[i].view; + if (detailView && !this.isViewFinished(detailView)) { + return detailView; + } + } for (let i = 0; i < numVisible; i++) { const view = visibleViews[i].view; if (!this.isViewFinished(view)) { diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index e968db3f14810..34a5e891f0377 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -1220,7 +1220,13 @@ class PDFViewer { if (this.pagesCount === 0) { return; } - this.update(); + + const visible = this._getVisiblePages(); + for (const { view, visibleArea } of visible.views) { + view.updateVisibleArea(visibleArea); + } + + this.update(visible); } #scrollIntoView(pageView, pageSpot = null) { @@ -1647,8 +1653,8 @@ class PDFViewer { }; } - update() { - const visible = this._getVisiblePages(); + update(currentlyVisible) { + const visible = currentlyVisible || this._getVisiblePages(); const visiblePages = visible.views, numVisiblePages = visiblePages.length; 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,