pdf.js/web/pdf_page_detail_view.js
Nicolò Ribaudo e4ea2e0c79
Store ops bboxes in a linear Uint8Array
This PR changes the way we store bounding boxes so that they use less
memory and can be more easily shared across threads in the future.

Instead of storing the bounding box and list of dependencies for each
operation that renders _something_, we now only store the bounding box
of _every_ operation and no dependencies list. The bounding box of
each operation covers the bounding box of all the operations affected
by it that render something. For example, the bounding box of a
`setFont` operation will be the bounding box of all the `showText`
operations that use that font.

This affects the debugging experience in pdfBug, since now the bounding
box of an operation may be larger than what it renders itself. To help
with this, now when hovering on an operation we also highlight (in red)
all its dependents. We highlight with white stripes operations that do
not affect any part of the page (i.e. with an empty bbox).

To save memory, we now save bounding box x/y coordinates as uint8
rather than float64. This effectively gives us a 256x256 uniform grid
that covers the page, which is high enough resolution for the usecase.
2025-09-09 10:24:48 +02:00

315 lines
9.9 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 recordedBBoxes = this.pdfPage.recordedBBoxes;
if (!recordedBBoxes || !this.enableOptimizedPartialRendering) {
return { ...baseContext, recordOperations: false };
}
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;
return {
...baseContext,
recordOperations: false,
operationsFilter(index) {
if (recordedBBoxes.isEmpty(index)) {
return false;
}
return (
recordedBBoxes.minX(index) <= detailMaxX &&
recordedBBoxes.maxX(index) >= detailMinX &&
recordedBBoxes.minY(index) <= detailMaxY &&
recordedBBoxes.maxY(index) >= detailMinY
);
},
};
}
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 };