pdf.js/web/pdf_page_detail_view.js
Nicolò Ribaudo 6a22da9c2e
Add logic to track rendering area of various PDF ops
This commit is a first step towards #6419, and it can also help with
first compute which ops can affect what is visible in that part of
the page.

This commit adds logic to track operations with their respective
bounding boxes. Only operations that actually cause something to
be rendered have a bounding box and dependencies.

Consider the following example:
```
0. setFillRGBColor
1. beginText
2. showText "Hello"
3. endText
4. constructPath [...] -> eoFill
```
here we have three rendering operations: the showText op (2) and the
path (4). (2) depends on (0), (1) and (3), while (4) only depends on
(0). Both (2) and (4) have a bounding box.

This tracking happens when first rendering a PDF: we then use the
recorded information to optimize future partial renderings of a PDF, so
that we can skip operations that do not affected the PDF area on the
canvas.

All this logic only runs when the new `enableOptimizedPartialRendering`
preference, disabled by default, is enabled.

The bounding boxes and dependencies are also shown in the pdfBug
stepper. When hovering over a step now:
- it highlights the steps that they depend on
- it highlights on the PDF itself the bounding box
2025-08-22 18:26:59 +02:00

324 lines
10 KiB
JavaScript

/* 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 { OutputScale } from "pdfjs-lib";
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, capCanvasAreaFactor } = 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 * OutputScale.pixelRatio ** 2;
const maxDetailToVisibleLinearRatio = Math.sqrt(
OutputScale.capPixels(maxCanvasPixels, capCanvasAreaFactor) /
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 });
}
_getRenderingContext(canvas, transform) {
const baseContext = this.pageView._getRenderingContext(canvas, transform);
const recordedGroups = this.pdfPage.recordedGroups;
if (!recordedGroups || !this.enableOptimizedPartialRendering) {
return { ...baseContext, recordOperations: false };
}
// TODO: There is probably a better data structure for this.
// The indexes are always checked in increasing order, so we can just try
// to build a pre-sorted array which should have faster lookups.
// Needs benchmarking.
const filteredIndexes = new Set();
const {
viewport: { width: vWidth, height: vHeight },
} = this.pageView;
const {
width: aWidth,
height: aHeight,
minX: aMinX,
minY: aMinY,
} = this.#detailArea;
const detailMinX = aMinX / vWidth;
const detailMinY = aMinY / vHeight;
const detailMaxX = (aMinX + aWidth) / vWidth;
const detailMaxY = (aMinY + aHeight) / vHeight;
for (let i = 0, ii = recordedGroups.length; i < ii; i++) {
const group = recordedGroups[i];
if (
group.minX <= detailMaxX &&
group.maxX >= detailMinX &&
group.minY <= detailMaxY &&
group.maxY >= detailMinY
) {
filteredIndexes.add(group.idx);
group.dependencies.forEach(filteredIndexes.add, filteredIndexes);
}
}
return {
...baseContext,
recordOperations: false,
filteredOperationIndexes: filteredIndexes,
};
}
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 } = 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 { pixelRatio } = OutputScale;
const transform = [
pixelRatio,
0,
0,
pixelRatio,
-area.minX * pixelRatio,
-area.minY * pixelRatio,
];
canvas.width = area.width * pixelRatio;
canvas.height = area.height * pixelRatio;
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._getRenderingContext(canvas, 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 };