Merge pull request #19128 from nicolo-ribaudo/draw-page-portion

[api-minor] Render high-res partial page views when falling back to CSS zoom (bug 1492303)
This commit is contained in:
Jonas Jenwald 2025-02-21 22:24:41 +01:00 committed by GitHub
commit 553ec7babc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1466 additions and 311 deletions

View File

@ -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() {

View File

@ -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);
}
});
});
}

View File

@ -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);
});
});
});
});
});

View File

@ -706,3 +706,4 @@
!bug1019475_1.pdf
!bug1019475_2.pdf
!issue19505.pdf
!colors.pdf

BIN
test/pdfs/colors.pdf Normal file

Binary file not shown.

View File

@ -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);

View File

@ -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

View File

@ -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,

227
web/base_pdf_page_view.js Normal file
View File

@ -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 };

272
web/pdf_page_detail_view.js Normal file
View File

@ -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 };

View File

@ -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;
}

View File

@ -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;

View File

@ -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(() => {

View File

@ -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();
}

View File

@ -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,