diff --git a/src/display/api.js b/src/display/api.js index b55b1282f..63b01b5e6 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -3232,17 +3232,27 @@ class PDFObjects { class RenderTask { #internalRenderTask = null; + /** + * Callback for incremental rendering -- a function that will be called + * each time the rendering is paused. To continue rendering call the + * function that is the first argument to the callback. + * @type {function} + */ + onContinue = null; + + /** + * A function that will be synchronously called when the rendering tasks + * finishes with an error (either because of an actual error, or because the + * rendering is cancelled). + * + * @type {function} + * @param {Error} error + */ + onError = null; + constructor(internalRenderTask) { this.#internalRenderTask = internalRenderTask; - /** - * Callback for incremental rendering -- a function that will be called - * each time the rendering is paused. To continue rendering call the - * function that is the first argument to the callback. - * @type {function} - */ - this.onContinue = null; - if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) { // For testing purposes. Object.defineProperty(this, "getOperatorList", { @@ -3399,13 +3409,13 @@ class InternalRenderTask { } InternalRenderTask.#canvasInUse.delete(this._canvas); - this.callback( - error || - new RenderingCancelledException( - `Rendering cancelled, page ${this._pageIndex + 1}`, - extraDelay - ) + error ||= new RenderingCancelledException( + `Rendering cancelled, page ${this._pageIndex + 1}`, + extraDelay ); + this.callback(error); + + this.task.onError?.(error); } operatorListChanged() { diff --git a/test/integration/test_utils.mjs b/test/integration/test_utils.mjs index d41035ab4..5d4396d30 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(() => { @@ -566,8 +570,12 @@ function waitForAnnotationModeChanged(page) { function waitForPageRendered(page) { return createPromise(page, resolve => { - window.PDFViewerApplication.eventBus.on("pagerendered", resolve, { - once: true, + const { eventBus } = window.PDFViewerApplication; + eventBus.on("pagerendered", function handler(e) { + if (!e.isDetailView) { + resolve(); + eventBus.off("pagerendered", handler); + } }); }); } diff --git a/test/integration/viewer_spec.mjs b/test/integration/viewer_spec.mjs index ac9af9569..e5988d272 100644 --- a/test/integration/viewer_spec.mjs +++ b/test/integration/viewer_spec.mjs @@ -454,4 +454,646 @@ describe("PDF viewer", () => { ); }); }); + + describe("Detail view on zoom", () => { + const BASE_MAX_CANVAS_PIXELS = 1e6; + + function setupPages(zoom, devicePixelRatio, setups = {}) { + 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}; + }`, + ...setups, + }, + { 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", + ({ isDetailView }) => { + if (isDetailView) { + 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({ isDetailView }) { + if (!isDetailView) { + 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 + }) + ); + }); + }); + }); + + describe("pagerendered event", () => { + const forEachPage = setupPages("100%", pixelRatio, { + eventBusSetup: eventBus => { + globalThis.__pageRenderedEvents = []; + + eventBus.on( + "pagerendered", + ({ pageNumber, isDetailView, cssTransform }) => { + globalThis.__pageRenderedEvents.push({ + pageNumber, + isDetailView, + cssTransform, + }); + } + ); + }, + }); + + it("is dispatched properly", async () => { + await forEachPage(async (browserName, page) => { + const getPageRenderedEvents = () => + page.evaluate(() => { + const events = globalThis.__pageRenderedEvents; + globalThis.__pageRenderedEvents = []; + return events; + }); + const waitForPageRenderedEvent = filter => + page.waitForFunction( + filterStr => + // eslint-disable-next-line no-eval + globalThis.__pageRenderedEvents.some(eval(filterStr)), + { polling: 50 }, + String(filter) + ); + + // Initial render + await waitForPageRenderedEvent(e => e.pageNumber === 2); + expect(await getPageRenderedEvents()) + .withContext(`In ${browserName}, initial render`) + .toEqual([ + { pageNumber: 1, isDetailView: false, cssTransform: false }, + { pageNumber: 2, isDetailView: false, cssTransform: false }, + ]); + + // Zoom-in without triggering the detail view + await page.evaluate(() => { + window.PDFViewerApplication.pdfViewer.updateScale({ + drawingDelay: -1, + scaleFactor: 1.05, + }); + }); + await waitForPageRenderedEvent(e => e.pageNumber === 2); + expect(await getPageRenderedEvents()) + .withContext(`In ${browserName}, first zoom`) + .toEqual([ + { pageNumber: 1, isDetailView: false, cssTransform: false }, + { pageNumber: 2, isDetailView: false, cssTransform: false }, + ]); + + // Zoom-in on the first page, triggering the detail view + await page.evaluate(() => { + window.PDFViewerApplication.pdfViewer.updateScale({ + drawingDelay: -1, + scaleFactor: 2, + }); + }); + await Promise.all([ + waitForPageRenderedEvent( + e => e.isDetailView && e.pageNumber === 1 + ), + waitForPageRenderedEvent(e => e.pageNumber === 2), + ]); + expect(await getPageRenderedEvents()) + .withContext(`In ${browserName}, second zoom`) + .toEqual([ + { pageNumber: 1, isDetailView: false, cssTransform: false }, + { pageNumber: 1, isDetailView: true, cssTransform: false }, + { pageNumber: 2, isDetailView: false, cssTransform: false }, + ]); + + // Zoom-in more + await page.evaluate(() => { + window.PDFViewerApplication.pdfViewer.updateScale({ + drawingDelay: -1, + scaleFactor: 2, + }); + }); + await Promise.all([ + waitForPageRenderedEvent( + e => e.isDetailView && e.pageNumber === 1 + ), + waitForPageRenderedEvent(e => e.pageNumber === 2), + ]); + expect(await getPageRenderedEvents()) + .withContext(`In ${browserName}, third zoom`) + .toEqual([ + { pageNumber: 1, isDetailView: false, cssTransform: true }, + { pageNumber: 2, isDetailView: false, cssTransform: true }, + { pageNumber: 1, isDetailView: true, cssTransform: false }, + ]); + + // Scroll to another area of the first page + await page.evaluate(() => { + const container = document.getElementById("viewerContainer"); + container.scrollTop += 1600; + container.scrollLeft += 1100; + }); + await waitForPageRenderedEvent(e => e.isDetailView); + expect(await getPageRenderedEvents()) + .withContext(`In ${browserName}, first scroll`) + .toEqual([ + { pageNumber: 1, isDetailView: true, cssTransform: false }, + ]); + + // Scroll to the second page + await page.evaluate(() => { + const container = document.getElementById("viewerContainer"); + const pageElement = document.querySelector(".page"); + container.scrollTop += + pageElement.getBoundingClientRect().height; + }); + await waitForPageRenderedEvent(e => e.isDetailView); + expect(await getPageRenderedEvents()) + .withContext(`In ${browserName}, second scroll`) + .toEqual([ + { pageNumber: 2, isDetailView: true, cssTransform: false }, + ]); + + // Zoom-out, to not have the detail view anymore + await page.evaluate(() => { + window.PDFViewerApplication.pdfViewer.updateScale({ + drawingDelay: -1, + scaleFactor: 0.25, + }); + }); + await Promise.all([ + waitForPageRenderedEvent(e => e.pageNumber === 1), + waitForPageRenderedEvent(e => e.pageNumber === 2), + ]); + expect(await getPageRenderedEvents()) + .withContext(`In ${browserName}, second zoom`) + .toEqual([ + { pageNumber: 2, isDetailView: false, cssTransform: false }, + { pageNumber: 1, isDetailView: false, cssTransform: false }, + ]); + + const canvasesPerPage = await page.evaluate(() => + Array.from( + document.querySelectorAll(".canvasWrapper"), + wrapper => wrapper.childElementCount + ) + ); + expect(canvasesPerPage) + .withContext(`In ${browserName}, number of canvases`) + .toEqual([1, 1]); + }); + }); + }); + }); + } + + describe("when immediately cancelled and re-rendered", () => { + const forEachPage = setupPages("100%", 1, { + eventBusSetup: eventBus => { + globalThis.__pageRenderedEvents = []; + eventBus.on("pagerendered", ({ pageNumber, isDetailView }) => { + globalThis.__pageRenderedEvents.push({ pageNumber, isDetailView }); + }); + }, + }); + + it("propely cleans up old canvases from the dom", async () => { + await forEachPage(async (browserName, page) => { + const waitForPageRenderedEvent = filter => + page.waitForFunction( + filterStr => { + // eslint-disable-next-line no-eval + if (globalThis.__pageRenderedEvents.some(eval(filterStr))) { + globalThis.__pageRenderedEvents = []; + return true; + } + return false; + }, + { polling: 50 }, + String(filter) + ); + const getCanvasCount = () => + page.evaluate( + () => + document.querySelector("[data-page-number='1'] .canvasWrapper") + .childElementCount + ); + + await waitForPageRenderedEvent(e => e.pageNumber === 1); + + await page.evaluate(() => { + window.PDFViewerApplication.pdfViewer.updateScale({ + drawingDelay: -1, + scaleFactor: 4, + }); + }); + await waitForPageRenderedEvent( + e => e.pageNumber === 1 && e.isDetailView + ); + expect(await getCanvasCount()) + .withContext(`In ${browserName}, after the first zoom-in`) + .toBe(2); + + await page.evaluate(() => { + window.PDFViewerApplication.pdfViewer.updateScale({ + drawingDelay: -1, + scaleFactor: 0.75, + }); + window.PDFViewerApplication.pdfViewer.updateScale({ + drawingDelay: -1, + scaleFactor: 0.25, + }); + }); + await waitForPageRenderedEvent(e => e.pageNumber === 1); + expect(await getCanvasCount()) + .withContext(`In ${browserName}, after the two zoom-out`) + .toBe(1); + }); + }); + }); + + describe("when cancelled and re-rendered after 1 microtick", () => { + const forEachPage = setupPages("100%", 1, { + eventBusSetup: eventBus => { + globalThis.__pageRenderedEvents = []; + eventBus.on("pagerendered", ({ pageNumber, isDetailView }) => { + globalThis.__pageRenderedEvents.push({ pageNumber, isDetailView }); + }); + }, + }); + + it("propely cleans up old canvases from the dom", async () => { + await forEachPage(async (browserName, page) => { + const waitForPageRenderedEvent = filter => + page.waitForFunction( + filterStr => { + // eslint-disable-next-line no-eval + if (globalThis.__pageRenderedEvents.some(eval(filterStr))) { + globalThis.__pageRenderedEvents = []; + return true; + } + return false; + }, + { polling: 50 }, + String(filter) + ); + const getCanvasCount = () => + page.evaluate( + () => + document.querySelector("[data-page-number='1'] .canvasWrapper") + .childElementCount + ); + + await waitForPageRenderedEvent(e => e.pageNumber === 1); + + await page.evaluate(() => { + window.PDFViewerApplication.pdfViewer.updateScale({ + drawingDelay: -1, + scaleFactor: 4, + }); + }); + await waitForPageRenderedEvent( + e => e.pageNumber === 1 && e.isDetailView + ); + expect(await getCanvasCount()) + .withContext(`In ${browserName}, after the first zoom-in`) + .toBe(2); + + await page.evaluate(() => { + window.PDFViewerApplication.pdfViewer.updateScale({ + drawingDelay: -1, + scaleFactor: 0.75, + }); + Promise.resolve().then(() => { + window.PDFViewerApplication.pdfViewer.updateScale({ + drawingDelay: -1, + scaleFactor: 0.25, + }); + }); + }); + await waitForPageRenderedEvent(e => e.pageNumber === 1); + expect(await getCanvasCount()) + .withContext(`In ${browserName}, after the two zoom-out`) + .toBe(1); + }); + }); + }); + }); }); diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index 27fc194af..c3ff64116 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -706,3 +706,4 @@ !bug1019475_1.pdf !bug1019475_2.pdf !issue19505.pdf +!colors.pdf diff --git a/test/pdfs/colors.pdf b/test/pdfs/colors.pdf new file mode 100644 index 000000000..4a6a8d663 Binary files /dev/null and b/test/pdfs/colors.pdf differ diff --git a/test/unit/ui_utils_spec.js b/test/unit/ui_utils_spec.js index 0ead19043..41af41e54 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 9647cd63f..7aa7596c6 100644 --- a/web/app.js +++ b/web/app.js @@ -507,6 +507,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, @@ -2331,7 +2332,7 @@ function onPageRender({ pageNumber }) { } } -function onPageRendered({ pageNumber, error }) { +function onPageRendered({ pageNumber, isDetailView, 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) { @@ -2339,7 +2340,7 @@ function onPageRendered({ pageNumber, error }) { } // Use the rendered page to set the corresponding thumbnail image. - if (this.pdfSidebar?.visibleView === SidebarView.THUMBS) { + if (!isDetailView && this.pdfSidebar?.visibleView === SidebarView.THUMBS) { const pageView = this.pdfViewer.getPageView(/* index = */ pageNumber - 1); const thumbnailView = this.pdfThumbnailViewer?.getThumbnail( /* index = */ pageNumber - 1 diff --git a/web/app_options.js b/web/app_options.js index a81492d5f..0ccb5c3dd 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -200,6 +200,11 @@ const defaultOptions = { value: typeof PDFJSDev === "undefined" || PDFJSDev.test("MOZCENTRAL"), kind: OptionKind.VIEWER + OptionKind.PREFERENCE, }, + enableDetailCanvas: { + /** @type {boolean} */ + value: true, + kind: OptionKind.VIEWER, + }, enableGuessAltText: { /** @type {boolean} */ value: true, diff --git a/web/base_pdf_page_view.js b/web/base_pdf_page_view.js new file mode 100644 index 000000000..42546f83d --- /dev/null +++ b/web/base_pdf_page_view.js @@ -0,0 +1,227 @@ +/* Copyright 2012 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RenderingCancelledException } from "pdfjs-lib"; +import { RenderingStates } from "./ui_utils.js"; + +class BasePDFPageView { + #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; + } + } + + _createCanvas(onShow, hideUntilComplete = false) { + 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 && !hideUntilComplete; + + const canvas = (this.canvas = document.createElement("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, onCancel, onFinish) { + const renderTask = (this.renderTask = this.pdfPage.render(options)); + renderTask.onContinue = this.#renderContinueCallback; + renderTask.onError = error => { + if (error instanceof RenderingCancelledException) { + onCancel(); + this.#renderError = null; + } + }; + + let error = null; + try { + await renderTask.promise; + this.#showCanvas?.(true); + } 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) { + return; + } + + this.#showCanvas?.(true); + } finally { + // 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; + } + } + 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, isDetailView) { + this.eventBus.dispatch("pagerendered", { + source: this, + pageNumber: this.id, + cssTransform, + isDetailView, + timestamp: performance.now(), + error: this.#renderError, + }); + } +} + +export { BasePDFPageView }; diff --git a/web/pdf_page_detail_view.js b/web/pdf_page_detail_view.js new file mode 100644 index 000000000..72d3eba60 --- /dev/null +++ b/web/pdf_page_detail_view.js @@ -0,0 +1,272 @@ +/* Copyright 2012 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BasePDFPageView } from "./base_pdf_page_view.js"; +import { RenderingStates } from "./ui_utils.js"; + +/** @typedef {import("./interfaces").IRenderableView} IRenderableView */ + +/** + * @implements {IRenderableView} + */ +class PDFPageDetailView extends BasePDFPageView { + #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); + + this.pageView = pageView; + this.renderingId = "detail" + this.id; + + this.div = pageView.div; + } + + setPdfPage(pdfPage) { + this.pageView.setPdfPage(pdfPage); + } + + get pdfPage() { + 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(); + } + } + + #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_THRESHOLD 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_THRESHOLD = 0.5; + const ratio = (1 + MOVEMENT_THRESHOLD) / MOVEMENT_THRESHOLD; + + 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() { + // The PDFPageView might have already dropped this PDFPageDetailView. In + // that case, simply do nothing. + if (this.pageView.detailView !== this) { + return undefined; + } + + // 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; + const { style } = canvas; + style.width = `${(area.width * 100) / width}%`; + style.height = `${(area.height * 100) / height}%`; + style.top = `${(area.minY * 100) / height}%`; + 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( + /* cssTransform */ false, + /* isDetailView */ true + ); + } + ); + + div.setAttribute("data-loaded", true); + + this.dispatchPageRender(); + + return renderingPromise; + } +} + +export { PDFPageDetailView }; diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index 602ddc189..b0395530f 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -28,7 +28,6 @@ import { AnnotationMode, OutputScale, PixelsPerInch, - RenderingCancelledException, setLayerDimensions, shadow, } from "pdfjs-lib"; @@ -44,8 +43,10 @@ import { AnnotationEditorLayerBuilder } from "./annotation_editor_layer_builder. import { AnnotationLayerBuilder } from "./annotation_layer_builder.js"; import { AppOptions } from "./app_options.js"; import { Autolinker } from "./autolinker.js"; +import { BasePDFPageView } from "./base_pdf_page_view.js"; import { DrawLayerBuilder } from "./draw_layer_builder.js"; import { GenericL10n } from "web-null_l10n"; +import { PDFPageDetailView } from "./pdf_page_detail_view.js"; import { SimpleLinkService } from "./pdf_link_service.js"; import { StructTreeLayerBuilder } from "./struct_tree_layer_builder.js"; import { TextAccessibilityManager } from "./text_accessibility.js"; @@ -77,6 +78,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. @@ -116,13 +123,11 @@ const LAYERS_ORDER = new Map([ /** * @implements {IRenderableView} */ -class PDFPageView { +class PDFPageView extends BasePDFPageView { #annotationMode = AnnotationMode.ENABLE_FORMS; #canvasWrapper = null; - #enableHWA = false; - #enableAutoLinking = false; #hasRestrictedScaling = false; @@ -131,7 +136,7 @@ class PDFPageView { #layerProperties = null; - #loadingId = null; + #needsRestrictedScaling = false; #originalViewport = null; @@ -141,10 +146,6 @@ class PDFPageView { #scaleRoundY = 1; - #renderError = null; - - #renderingState = RenderingStates.INITIAL; - #textLayerMode = TextLayerMode.ENABLE; #userUnit = 1; @@ -161,10 +162,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; @@ -180,21 +182,16 @@ class PDFPageView { this.#annotationMode = options.annotationMode ?? AnnotationMode.ENABLE_FORMS; this.imageResourcesPath = options.imageResourcesPath || ""; + this.enableDetailCanvas = options.enableDetailCanvas ?? true; this.maxCanvasPixels = options.maxCanvasPixels ?? AppOptions.get("maxCanvasPixels"); - this.pageColors = options.pageColors || null; - this.#enableHWA = options.enableHWA || false; this.#enableAutoLinking = options.enableAutoLinking || 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; @@ -209,6 +206,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); @@ -278,43 +277,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 { div, viewport } = this; @@ -558,14 +520,8 @@ class PDFPageView { } } - #resetCanvas() { - const { canvas } = this; - if (!canvas) { - return; - } - canvas.remove(); - canvas.width = canvas.height = 0; - this.canvas = null; + _resetCanvas() { + super._resetCanvas(); this.#originalViewport = null; } @@ -575,6 +531,7 @@ class PDFPageView { keepXfaLayer = false, keepTextLayer = false, keepCanvasWrapper = false, + preserveDetailViewState = false, } = {}) { this.cancelRendering({ keepAnnotationLayer, @@ -632,7 +589,18 @@ class PDFPageView { if (!keepCanvasWrapper && this.#canvasWrapper) { this.#canvasWrapper = null; - this.#resetCanvas(); + this._resetCanvas(); + } + + if (!preserveDetailViewState) { + this.detailView?.reset({ keepCanvas: keepCanvasWrapper }); + + // 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; + } } } @@ -654,6 +622,22 @@ class PDFPageView { }); } + updateVisibleArea(visibleArea) { + if (this.enableDetailCanvas) { + if ( + this.#needsRestrictedScaling && + 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. @@ -709,22 +693,11 @@ class PDFPageView { this._container?.style.setProperty("--scale-factor", this.viewport.scale); } + this.#computeScale(); + if (this.canvas) { - let onlyCssZoom = false; - if (this.#hasRestrictedScaling) { - if ( - (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) && - this.maxCanvasPixels === 0 - ) { - onlyCssZoom = true; - } else if (this.maxCanvasPixels > 0) { - const { width, height } = this.viewport; - const { sx, sy } = this.outputScale; - onlyCssZoom = - ((Math.floor(width) * sx) | 0) * ((Math.floor(height) * sy) | 0) > - this.maxCanvasPixels; - } - } + const onlyCssZoom = + this.#hasRestrictedScaling && this.#needsRestrictedScaling; const postponeDrawing = drawingDelay >= 0 && drawingDelay < 1000; if (postponeDrawing || onlyCssZoom) { @@ -758,18 +731,16 @@ 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.detailView?.update({ underlyingViewUpdated: true }); + + this.dispatchPageRendered( + /* cssTransform */ true, + /* isDetailView */ false + ); } - this.eventBus.dispatch("pagerendered", { - source: this, - pageNumber: this.id, - cssTransform: true, - timestamp: performance.now(), - error: this.#renderError, - }); return; } } @@ -780,7 +751,38 @@ class PDFPageView { 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.#needsRestrictedScaling = 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.#needsRestrictedScaling = true; + } else { + this.#needsRestrictedScaling = false; + } + } } /** @@ -794,11 +796,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(); @@ -897,37 +895,29 @@ 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; + // 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; + } - 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; - } + _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() { @@ -935,7 +925,7 @@ class PDFPageView { 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; @@ -944,14 +934,7 @@ 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(); if ( !this.textLayer && @@ -1009,84 +992,21 @@ 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); }); - const outputScale = (this.outputScale = new OutputScale()); + canvas.setAttribute("role", "presentation"); - 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; + this.#hasRestrictedScaling = this.#needsRestrictedScaling; + const sfx = approximateFraction(outputScale.sx); const sfy = approximateFraction(outputScale.sy); @@ -1116,78 +1036,65 @@ class PDFPageView { 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 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 - ); - - const textLayerPromise = this.#renderTextLayer(); - - if (this.annotationLayer) { - await this.#renderAnnotationLayer(); - - if (this.#enableAutoLinking && this.annotationLayer) { - await this.#injectLinkAnnotations(textLayerPromise); - } - } - - const { annotationEditorUIManager } = this.#layerProperties; - - 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(); + const resultPromise = this._drawCanvas( + this._getRenderingContext(ctx, transform), + () => { + prevCanvas?.remove(); + this._resetCanvas(); }, - 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); + 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( + /* cssTransform */ false, + /* isDetailView */ false + ); } - ); + ).then(async () => { + this.structTreeLayer ||= new StructTreeLayerBuilder( + pdfPage, + viewport.rawDims + ); + + const textLayerPromise = this.#renderTextLayer(); + + if (this.annotationLayer) { + await this.#renderAnnotationLayer(); + + if (this.#enableAutoLinking && this.annotationLayer) { + await this.#injectLinkAnnotations(textLayerPromise); + } + } + + const { annotationEditorUIManager } = this.#layerProperties; + + 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) { @@ -1204,10 +1111,8 @@ class PDFPageView { div.setAttribute("data-loaded", true); - this.eventBus.dispatch("pagerender", { - source: this, - pageNumber: this.id, - }); + this.dispatchPageRender(); + return resultPromise; } diff --git a/web/pdf_rendering_queue.js b/web/pdf_rendering_queue.js index 620eae6c1..bbcb51fc3 100644 --- a/web/pdf_rendering_queue.js +++ b/web/pdf_rendering_queue.js @@ -102,15 +102,23 @@ class PDFRenderingQueue { * @param {Array} views * @param {boolean} scrolledDown * @param {boolean} [preRenderExtra] + * @param {boolean} [ignoreDetailViews] */ - getHighestPriority(visible, views, scrolledDown, preRenderExtra = false) { + getHighestPriority( + visible, + views, + scrolledDown, + preRenderExtra = false, + ignoreDetailViews = false + ) { /** * The state has changed. Figure out which page has the highest priority to * render next (if any). * * Priority: * 1. visible pages - * 2. if last scrolled down, the page after the visible pages, or + * 2. zoomed-in partial views of visible pages, unless `ignoreDetailViews` + * 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 +133,16 @@ class PDFRenderingQueue { return view; } } + + if (!ignoreDetailViews) { + 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_thumbnail_viewer.js b/web/pdf_thumbnail_viewer.js index 7ec1b9837..b6bdf36f3 100644 --- a/web/pdf_thumbnail_viewer.js +++ b/web/pdf_thumbnail_viewer.js @@ -292,7 +292,9 @@ class PDFThumbnailViewer { const thumbView = this.renderingQueue.getHighestPriority( visibleThumbs, this._thumbnails, - scrollAhead + scrollAhead, + /* preRenderExtra */ false, + /* ignoreDetailViews */ true ); if (thumbView) { this.#ensurePdfPageLoaded(thumbView).then(() => { diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index a52e81fda..5cd548577 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`. @@ -236,6 +241,8 @@ class PDFViewer { #mlManager = null; + #scrollTimeoutId = null; + #switchAnnotationEditorModeAC = null; #switchAnnotationEditorModeTimeoutId = null; @@ -319,6 +326,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(); @@ -873,7 +881,7 @@ class PDFViewer { eventBus._on("pagerender", onBeforeDraw, { signal }); const onAfterDraw = evt => { - if (evt.cssTransform) { + if (evt.cssTransform || evt.isDetailView) { return; } this._onePageRenderedCapability.resolve({ timestamp: evt.timestamp }); @@ -993,6 +1001,7 @@ class PDFViewer { annotationMode, imageResourcesPath: this.imageResourcesPath, maxCanvasPixels: this.maxCanvasPixels, + enableDetailCanvas: this.enableDetailCanvas, pageColors, l10n: this.l10n, layerProperties: this._layerProperties, @@ -1234,6 +1243,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(); } @@ -1672,6 +1690,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 = @@ -1835,11 +1862,21 @@ class PDFViewer { this._spreadMode !== SpreadMode.NONE && this._scrollMode !== ScrollMode.HORIZONTAL; + // If we are scrolling and the rendering of a 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-render it: this will be done once the user + // stops scrolling. + const ignoreDetailViews = + this.#scrollTimeoutId !== null && + visiblePages.views.some(page => page.detailView?.renderingCancelled); + const pageView = this.renderingQueue.getHighestPriority( visiblePages, this._pages, scrollAhead, - preRenderExtra + preRenderExtra, + ignoreDetailViews ); if (pageView) { @@ -2433,6 +2470,10 @@ class PDFViewer { clearTimeout(this.#scaleTimeoutId); this.#scaleTimeoutId = null; } + if (this.#scrollTimeoutId !== null) { + clearTimeout(this.#scrollTimeoutId); + this.#scrollTimeoutId = null; + } if (!noUpdate) { this.update(); } diff --git a/web/ui_utils.js b/web/ui_utils.js index 07dc55c7a..dd2f250ea 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,