diff --git a/src/display/canvas.js b/src/display/canvas.js index c43bc6a6aa83e..e9484f1ced656 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -1818,7 +1818,12 @@ class CanvasGraphics { } const intersect = this.current.getClippedPathBoundingBox(); - if (this.contentVisible && intersect !== null) { + if ( + this.contentVisible && + intersect !== null && + intersect[2] - intersect[0] > 0 && + intersect[3] - intersect[1] > 0 + ) { if (this.pendingEOFill) { ctx.fill("evenodd"); this.pendingEOFill = false; diff --git a/web/app.js b/web/app.js index 3bb2eb6e1560b..426cda8dd3e1a 100644 --- a/web/app.js +++ b/web/app.js @@ -446,6 +446,7 @@ const PDFViewerApplication = { enablePrintAutoRotate: AppOptions.get("enablePrintAutoRotate"), isOffscreenCanvasSupported, maxCanvasPixels: AppOptions.get("maxCanvasPixels"), + maxTiles: AppOptions.get("maxTiles"), enablePermissions: AppOptions.get("enablePermissions"), pageColors, }); diff --git a/web/app_options.js b/web/app_options.js index caf4dcd76aba6..126e11dc4a173 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -181,6 +181,11 @@ const defaultOptions = { value: 16777216, kind: OptionKind.VIEWER, }, + maxTiles: { + /** @type {number} */ + value: 512, + kind: OptionKind.VIEWER, + }, forcePageColors: { /** @type {boolean} */ value: false, diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index d31f50b392440..8d7f446dd6344 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -39,6 +39,7 @@ import { roundToDivide, TextLayerMode, } from "./ui_utils.js"; + import { AnnotationEditorLayerBuilder } from "./annotation_editor_layer_builder.js"; import { AnnotationLayerBuilder } from "./annotation_layer_builder.js"; import { compatibilityParams } from "./app_options.js"; @@ -49,6 +50,7 @@ import { StructTreeLayerBuilder } from "./struct_tree_layer_builder.js"; import { TextAccessibilityManager } from "./text_accessibility.js"; import { TextHighlighter } from "./text_highlighter.js"; import { TextLayerBuilder } from "./text_layer_builder.js"; +import { TileLayer } from "./tile_canvas.js"; import { XfaLayerBuilder } from "./xfa_layer_builder.js"; /** @@ -77,6 +79,8 @@ 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 * 4096 (16 mega-pixels). + * @property {number} [maxTiles] - The maximum supported tiles, + * default is 1. * @property {Object} [pageColors] - Overwrites background and foreground colors * with user defined ones in order to improve readability in high contrast * mode. @@ -86,6 +90,7 @@ import { XfaLayerBuilder } from "./xfa_layer_builder.js"; */ const MAX_CANVAS_PIXELS = compatibilityParams.maxCanvasPixels || 16777216; +const MAX_TILE_SIZE = compatibilityParams.maxTiles || 1; const DEFAULT_LAYER_PROPERTIES = typeof PDFJSDev === "undefined" || !PDFJSDev.test("COMPONENTS") @@ -157,6 +162,7 @@ class PDFPageView { this.isOffscreenCanvasSupported = options.isOffscreenCanvasSupported ?? true; this.maxCanvasPixels = options.maxCanvasPixels ?? MAX_CANVAS_PIXELS; + this.maxTiles = options.maxTiles ?? MAX_TILE_SIZE; this.pageColors = options.pageColors || null; this.eventBus = options.eventBus; @@ -448,7 +454,7 @@ class PDFPageView { if (treeDom) { // Pause translation when inserting the structTree in the DOM. this.l10n.pause(); - this.canvas?.append(treeDom); + this.tileLayer?.element.append(treeDom); this.l10n.resume(); } this.structTreeLayer?.show(); @@ -471,16 +477,16 @@ class PDFPageView { if (!this.zoomLayer) { return; } - const zoomLayerCanvas = this.zoomLayer.firstChild; - this.#viewportMap.delete(zoomLayerCanvas); + const zoomLayerDiv = this.zoomLayer.element; + this.#viewportMap.delete(zoomLayerDiv); + // Zeroing the width and height causes Firefox to release graphics // resources immediately, which can greatly reduce memory consumption. - zoomLayerCanvas.width = 0; - zoomLayerCanvas.height = 0; + this.zoomLayer.clear(); if (removeFromDOM) { // Note: `ChildNode.remove` doesn't throw if the parent node is undefined. - this.zoomLayer.remove(); + this.zoomLayer.element.remove(); } this.zoomLayer = null; } @@ -543,13 +549,12 @@ class PDFPageView { this.structTreeLayer?.hide(); if (!zoomLayerNode) { - if (this.canvas) { - this.#viewportMap.delete(this.canvas); + if (this.tileLayer) { + this.#viewportMap.delete(this.tileLayer.element); // Zeroing the width and height causes Firefox to release graphics // resources immediately, which can greatly reduce memory consumption. - this.canvas.width = 0; - this.canvas.height = 0; - delete this.canvas; + this.tileLayer.clear(); + delete this.tileLayer; } this._resetZoomLayer(); } @@ -610,7 +615,7 @@ class PDFPageView { this._container?.style.setProperty("--scale-factor", this.viewport.scale); } - if (this.canvas) { + if (this.tileLayer) { let onlyCssZoom = false; if (this.#hasRestrictedScaling) { if ( @@ -653,7 +658,7 @@ class PDFPageView { } this.cssTransform({ - target: this.canvas, + target: this.tileLayer.element, redrawAnnotationLayer: true, redrawAnnotationEditorLayer: true, redrawXfaLayer: true, @@ -675,13 +680,13 @@ class PDFPageView { }); return; } - if (!this.zoomLayer && !this.canvas.hidden) { - this.zoomLayer = this.canvas.parentNode; - this.zoomLayer.style.position = "absolute"; + if (!this.zoomLayer && !this.tileLayer.hidden) { + this.zoomLayer = this.tileLayer; + this.zoomLayer.element.parentNode.style.position = "absolute"; } } if (this.zoomLayer) { - this.cssTransform({ target: this.zoomLayer.firstChild }); + this.cssTransform({ target: this.zoomLayer.element }); } this.reset({ keepZoomLayer: true, @@ -753,9 +758,9 @@ class PDFPageView { // Scale target (canvas), its wrapper and page container. if ( (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) && - !(target instanceof HTMLCanvasElement) + !(target instanceof HTMLDivElement) ) { - throw new Error("Expected `target` to be a canvas."); + throw new Error("Expected `target` to be a HTMLDivElement."); } if (!target.hasAttribute("zooming")) { target.setAttribute("zooming", true); @@ -777,6 +782,7 @@ class PDFPageView { scaleX = height / width; scaleY = width / height; } + target.style.transform = `rotate(${relativeRotation}deg) scale(${scaleX}, ${scaleY})`; } @@ -935,27 +941,7 @@ class PDFPageView { }; const { width, height } = viewport; - const canvas = document.createElement("canvas"); - canvas.setAttribute("role", "presentation"); - - // Keep the canvas hidden until the first draw callback, or until drawing - // is complete when `!this.renderingQueue`, to prevent black flickering. - canvas.hidden = true; - const hasHCM = !!(pageColors?.background && pageColors?.foreground); - - let showCanvas = isLastShow => { - // 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. - if (!hasHCM || isLastShow) { - canvas.hidden = false; - showCanvas = null; // Only invoke the function once. - } - }; - canvasWrapper.append(canvas); - this.canvas = canvas; - const ctx = canvas.getContext("2d", { alpha: false }); const outputScale = (this.outputScale = new OutputScale()); if ( @@ -969,8 +955,11 @@ class PDFPageView { outputScale.sy *= invScale; this.#hasRestrictedScaling = true; } else if (this.maxCanvasPixels > 0) { + const maxTiledCanvasPixels = this.maxCanvasPixels * this.maxTiles; + + console.log(this.maxCanvasPixels, this.maxTiles); const pixelsInViewport = width * height; - const maxScale = Math.sqrt(this.maxCanvasPixels / pixelsInViewport); + const maxScale = Math.sqrt(maxTiledCanvasPixels / pixelsInViewport); if (outputScale.sx > maxScale || outputScale.sy > maxScale) { outputScale.sx = maxScale; outputScale.sy = maxScale; @@ -982,21 +971,49 @@ class PDFPageView { const sfx = approximateFraction(outputScale.sx); const sfy = approximateFraction(outputScale.sy); - canvas.width = roundToDivide(width * outputScale.sx, sfx[0]); - canvas.height = roundToDivide(height * outputScale.sy, sfy[0]); - const { style } = canvas; + const canvasWidth = roundToDivide(width * outputScale.sx, sfx[0]); + const canvasHeight = roundToDivide(height * outputScale.sy, sfy[0]); + + const tileLayer = new TileLayer( + canvasWidth, + canvasHeight, + this.maxCanvasPixels, + sfx, + sfy + ); + + tileLayer.setAttribute("role", "presentation"); + + // Keep the canvas hidden until the first draw callback, or until drawing + // is complete when `!this.renderingQueue`, to prevent black flickering. + tileLayer.hidden = true; + const hasHCM = !!(pageColors?.background && pageColors?.foreground); + + let showCanvas = isLastShow => { + // 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. + if (!hasHCM || isLastShow) { + tileLayer.hidden = false; + showCanvas = null; // Only invoke the function once. + } + }; + canvasWrapper.append(tileLayer.element); + this.tileLayer = tileLayer; + + const { style } = tileLayer.element; style.width = roundToDivide(width, sfx[1]) + "px"; style.height = roundToDivide(height, sfy[1]) + "px"; // Add the viewport so it's known what it was originally drawn with. - this.#viewportMap.set(canvas, viewport); + this.#viewportMap.set(tileLayer.element, viewport); // Rendering area const transform = outputScale.scaled ? [outputScale.sx, 0, 0, outputScale.sy, 0, 0] : null; + const renderContext = { - canvasContext: ctx, transform, viewport, annotationMode: this.#annotationMode, @@ -1004,7 +1021,10 @@ class PDFPageView { annotationCanvasMap: this._annotationCanvasMap, pageColors, }; - const renderTask = (this.renderTask = this.pdfPage.render(renderContext)); + const renderTask = (this.renderTask = tileLayer.render( + this.pdfPage, + renderContext + )); renderTask.onContinue = renderContinueCallback; const resultPromise = renderTask.promise.then( @@ -1102,8 +1122,12 @@ class PDFPageView { get thumbnailCanvas() { const { directDrawing, initialOptionalContent, regularAnnotations } = this.#useThumbnailCanvas; + const canvas = + this.tileLayer?.tiles?.length === 1 + ? this.tileLayer.tiles[0].canvas + : null; return directDrawing && initialOptionalContent && regularAnnotations - ? this.canvas + ? canvas : null; } } diff --git a/web/pdf_viewer.css b/web/pdf_viewer.css index 0aa7cb5cb402e..c016300739951 100644 --- a/web/pdf_viewer.css +++ b/web/pdf_viewer.css @@ -146,20 +146,20 @@ } /*#endif*/ -.pdfViewer .page canvas { +.pdfViewer .page .tileLayer { margin: 0; display: block; } -.pdfViewer .page canvas .structTree { +.pdfViewer .page .tileLayer .structTree { contain: strict; } -.pdfViewer .page canvas[hidden] { +.pdfViewer .page .tileLayer[hidden] { display: none; } -.pdfViewer .page canvas[zooming] { +.pdfViewer .page .tileLayer[zooming] { width: 100%; height: 100%; } diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index b9ad52f196788..a2e561bce5f3a 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -284,6 +284,7 @@ class PDFViewer { this.isOffscreenCanvasSupported = options.isOffscreenCanvasSupported ?? true; this.maxCanvasPixels = options.maxCanvasPixels; + this.maxTiles = options.maxTiles; this.l10n = options.l10n || NullL10n; this.#enablePermissions = options.enablePermissions || false; this.pageColors = options.pageColors || null; @@ -910,6 +911,7 @@ class PDFViewer { imageResourcesPath: this.imageResourcesPath, isOffscreenCanvasSupported: this.isOffscreenCanvasSupported, maxCanvasPixels: this.maxCanvasPixels, + maxTiles: this.maxTiles, pageColors: this.pageColors, l10n: this.l10n, layerProperties: this._layerProperties, diff --git a/web/tile_canvas.js b/web/tile_canvas.js new file mode 100644 index 0000000000000..13b5ddbebb60f --- /dev/null +++ b/web/tile_canvas.js @@ -0,0 +1,147 @@ +import { clamp, roundToDivide } from "./ui_utils.js"; +import { Util } from "../src/shared/util.js"; + +class TileRenderTask { + constructor(pdfPage, renderContext, tiles) { + this.onContinue = null; + const self = this; + + this.promise = new Promise((resolve, reject) => { + const remainingTiles = [...tiles]; + + function getIntersectionArea(element) { + const bound = element.getBoundingClientRect(); + + const x = clamp(bound.x, 0, window.innerWidth); + const y = clamp(bound.y, 0, window.innerHeight); + + const x1 = clamp(bound.x + bound.width, 0, window.innerWidth); + const y1 = clamp(bound.y + bound.height, 0, window.innerHeight); + + return (x1 - x) * (y1 - y); + } + + function popHighestPriorityTile() { + let maxTile; + let maxIntersectArea = 0; + for (const tile of remainingTiles) { + const intersectArea = getIntersectionArea(tile.canvas); + if (intersectArea >= maxIntersectArea) { + maxTile = tile; + maxIntersectArea = intersectArea; + } + } + remainingTiles.splice(remainingTiles.indexOf(maxTile), 1); + return maxTile; + } + + async function runRemainTasks() { + if (remainingTiles.length === 0) { + resolve(); + } else { + const tile = popHighestPriorityTile(); + const canvasContext = tile.canvas.getContext("2d", { alpha: false }); + + const translate = [1, 0, 0, 1, -tile.x, -tile.y]; + const transform = renderContext.transform + ? Util.transform(translate, renderContext.transform) + : translate; + + const renderTask = pdfPage.render({ + ...renderContext, + canvasContext, + transform, + }); + + self.cancel = delay => { + renderTask.cancel(delay); + remainingTiles.splice(0); + }; + + renderTask.onContinue = self.onContinue; + renderTask.promise.then(() => { + if (!self.separateAnnots) { + self.separateAnnots = renderTask.separateAnnots; + } + runRemainTasks(remainingTiles, resolve, reject); + }, reject); + } + } + this.cancel = () => remainingTiles.splice(0); + runRemainTasks(remainingTiles, resolve, reject); + }); + } +} + +class TileLayer { + constructor(width, height, maxCanvasSize, sfx, sfy) { + const styleWidth = (width / sfx[0]) * sfx[1]; + const styleHeight = (height / sfy[0]) * sfy[1]; + + const element = (this.element = document.createElement("div")); + element.className = "tileLayer"; + const tiles = []; + function createTiles(x, y, tileWidth, tileHeight) { + if (tileWidth * tileHeight < maxCanvasSize) { + const canvas = document.createElement("canvas"); + canvas.width = tileWidth; + canvas.height = tileHeight; + canvas.style.position = "absolute"; + canvas.style.left = `calc(100% * ${ + (x / sfx[0]) * sfx[1] + } / ${styleWidth} )`; + canvas.style.top = `calc(100% * ${ + (y / sfy[0]) * sfy[1] + }/ ${styleHeight} )`; + canvas.style.width = `calc(100% * ${ + (tileWidth / sfx[0]) * sfx[1] + } / ${styleWidth})`; + canvas.style.height = `calc(100% * ${ + (tileHeight / sfy[0]) * sfy[1] + }/ ${styleHeight} )`; + + element.append(canvas); + tiles.push({ + canvas, + x, + y, + }); + } else if (tileWidth >= tileHeight) { + const halfWidth = roundToDivide(tileWidth / 2, sfx[0]); + createTiles(x, y, halfWidth, tileHeight); + createTiles(x + halfWidth, y, tileWidth - halfWidth, tileHeight); + } else { + const halfHeight = roundToDivide(tileHeight / 2, sfy[0]); + createTiles(x, y, tileWidth, halfHeight); + createTiles(x, y + halfHeight, tileWidth, tileHeight - halfHeight); + } + } + createTiles(0, 0, width, height); + this.tiles = tiles; + } + + setAttribute(name, value) { + this.tiles.forEach(tile => tile.canvas.setAttribute(name, value)); + } + + get hidden() { + return this.element.style.hidden; + } + + set hidden(isHidden) { + this.element.style.hidden = isHidden; + } + + clear() { + this.tiles.forEach(tile => { + tile.canvas.width = 0; + tile.canvas.height = 0; + }); + } + + render(pdfPage, renderContext) { + return new TileRenderTask(pdfPage, renderContext, this.tiles); + } +} + +export { TileLayer }; diff --git a/web/ui_utils.js b/web/ui_utils.js index a2d58993efd09..deb6118aad1b8 100644 --- a/web/ui_utils.js +++ b/web/ui_utils.js @@ -860,6 +860,7 @@ export { AutoPrintRegExp, backtrackBeforeAllVisibleElements, // only exported for testing binarySearchFirstItem, + clamp, CursorTool, DEFAULT_SCALE, DEFAULT_SCALE_DELTA,