Extract PDFPageViewBase class out of PDFPageView

This base class contains the generic logic for:
- Creating a canvas and showing when appropriate
- Rendering in the canvas
- Keeping track of the rendering state
This commit is contained in:
Nicolò Ribaudo 2024-12-11 13:54:50 +01:00
parent e3ea92603d
commit 06257f782e
No known key found for this signature in database
GPG Key ID: AAFDA9101C58F338
2 changed files with 298 additions and 227 deletions

228
web/base_pdf_page_view.js Normal file
View File

@ -0,0 +1,228 @@
/* 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) {
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;
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, prevCanvas, onFinish) {
const renderTask = (this.renderTask = this.pdfPage.render(options));
renderTask.onContinue = this.#renderContinueCallback;
try {
await renderTask.promise;
this.#showCanvas?.(true);
this.#finishRenderTask(renderTask, null, onFinish);
} catch (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)) {
this.#showCanvas?.(true);
} else {
prevCanvas?.remove();
this._resetCanvas();
}
this.#finishRenderTask(renderTask, error, onFinish);
}
}
async #finishRenderTask(renderTask, error, onFinish) {
// 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;
}
if (error instanceof RenderingCancelledException) {
this.#renderError = null;
return;
}
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) {
this.eventBus.dispatch("pagerendered", {
source: this,
pageNumber: this.id,
cssTransform,
timestamp: performance.now(),
error: this.#renderError,
});
}
}
export { BasePDFPageView };

View File

@ -28,7 +28,6 @@ import {
AnnotationMode, AnnotationMode,
OutputScale, OutputScale,
PixelsPerInch, PixelsPerInch,
RenderingCancelledException,
setLayerDimensions, setLayerDimensions,
shadow, shadow,
} from "pdfjs-lib"; } from "pdfjs-lib";
@ -44,6 +43,7 @@ import { AnnotationEditorLayerBuilder } from "./annotation_editor_layer_builder.
import { AnnotationLayerBuilder } from "./annotation_layer_builder.js"; import { AnnotationLayerBuilder } from "./annotation_layer_builder.js";
import { AppOptions } from "./app_options.js"; import { AppOptions } from "./app_options.js";
import { Autolinker } from "./autolinker.js"; import { Autolinker } from "./autolinker.js";
import { BasePDFPageView } from "./base_pdf_page_view.js";
import { DrawLayerBuilder } from "./draw_layer_builder.js"; import { DrawLayerBuilder } from "./draw_layer_builder.js";
import { GenericL10n } from "web-null_l10n"; import { GenericL10n } from "web-null_l10n";
import { SimpleLinkService } from "./pdf_link_service.js"; import { SimpleLinkService } from "./pdf_link_service.js";
@ -116,13 +116,11 @@ const LAYERS_ORDER = new Map([
/** /**
* @implements {IRenderableView} * @implements {IRenderableView}
*/ */
class PDFPageView { class PDFPageView extends BasePDFPageView {
#annotationMode = AnnotationMode.ENABLE_FORMS; #annotationMode = AnnotationMode.ENABLE_FORMS;
#canvasWrapper = null; #canvasWrapper = null;
#enableHWA = false;
#enableAutoLinking = false; #enableAutoLinking = false;
#hasRestrictedScaling = false; #hasRestrictedScaling = false;
@ -131,8 +129,6 @@ class PDFPageView {
#layerProperties = null; #layerProperties = null;
#loadingId = null;
#originalViewport = null; #originalViewport = null;
#previousRotation = null; #previousRotation = null;
@ -141,10 +137,6 @@ class PDFPageView {
#scaleRoundY = 1; #scaleRoundY = 1;
#renderError = null;
#renderingState = RenderingStates.INITIAL;
#textLayerMode = TextLayerMode.ENABLE; #textLayerMode = TextLayerMode.ENABLE;
#userUnit = 1; #userUnit = 1;
@ -161,10 +153,11 @@ class PDFPageView {
* @param {PDFPageViewOptions} options * @param {PDFPageViewOptions} options
*/ */
constructor(options) { constructor(options) {
super(options);
const container = options.container; const container = options.container;
const defaultViewport = options.defaultViewport; const defaultViewport = options.defaultViewport;
this.id = options.id;
this.renderingId = "page" + this.id; this.renderingId = "page" + this.id;
this.#layerProperties = options.layerProperties || DEFAULT_LAYER_PROPERTIES; this.#layerProperties = options.layerProperties || DEFAULT_LAYER_PROPERTIES;
@ -182,19 +175,13 @@ class PDFPageView {
this.imageResourcesPath = options.imageResourcesPath || ""; this.imageResourcesPath = options.imageResourcesPath || "";
this.maxCanvasPixels = this.maxCanvasPixels =
options.maxCanvasPixels ?? AppOptions.get("maxCanvasPixels"); options.maxCanvasPixels ?? AppOptions.get("maxCanvasPixels");
this.pageColors = options.pageColors || null;
this.#enableHWA = options.enableHWA || false;
this.#enableAutoLinking = options.enableAutoLinking || false; this.#enableAutoLinking = options.enableAutoLinking || false;
this.eventBus = options.eventBus;
this.renderingQueue = options.renderingQueue;
this.l10n = options.l10n; this.l10n = options.l10n;
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) {
this.l10n ||= new GenericL10n(); this.l10n ||= new GenericL10n();
} }
this.renderTask = null;
this.resume = null;
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) {
this._isStandalone = !this.renderingQueue?.hasViewer(); this._isStandalone = !this.renderingQueue?.hasViewer();
this._container = container; this._container = container;
@ -278,43 +265,6 @@ class PDFPageView {
this.div.prepend(div); 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() { #setDimensions() {
const { div, viewport } = this; const { div, viewport } = this;
@ -558,14 +508,8 @@ class PDFPageView {
} }
} }
#resetCanvas() { _resetCanvas() {
const { canvas } = this; super._resetCanvas();
if (!canvas) {
return;
}
canvas.remove();
canvas.width = canvas.height = 0;
this.canvas = null;
this.#originalViewport = null; this.#originalViewport = null;
} }
@ -632,7 +576,7 @@ class PDFPageView {
if (!keepCanvasWrapper && this.#canvasWrapper) { if (!keepCanvasWrapper && this.#canvasWrapper) {
this.#canvasWrapper = null; this.#canvasWrapper = null;
this.#resetCanvas(); this._resetCanvas();
} }
} }
@ -758,18 +702,11 @@ class PDFPageView {
hideTextLayer: postponeDrawing, hideTextLayer: postponeDrawing,
}); });
if (postponeDrawing) {
// The "pagerendered"-event will be dispatched once the actual // The "pagerendered"-event will be dispatched once the actual
// rendering is done, hence don't dispatch it here as well. // rendering is done, hence don't dispatch it here as well.
return; if (!postponeDrawing) {
this.dispatchPageRendered(true);
} }
this.eventBus.dispatch("pagerendered", {
source: this,
pageNumber: this.id,
cssTransform: true,
timestamp: performance.now(),
error: this.#renderError,
});
return; return;
} }
} }
@ -794,11 +731,7 @@ class PDFPageView {
keepTextLayer = false, keepTextLayer = false,
cancelExtraDelay = 0, cancelExtraDelay = 0,
} = {}) { } = {}) {
if (this.renderTask) { super.cancelRendering({ cancelExtraDelay });
this.renderTask.cancel(cancelExtraDelay);
this.renderTask = null;
}
this.resume = null;
if (this.textLayer && (!keepTextLayer || !this.textLayer.div)) { if (this.textLayer && (!keepTextLayer || !this.textLayer.div)) {
this.textLayer.cancel(); this.textLayer.cancel();
@ -897,39 +830,6 @@ class PDFPageView {
return this.viewport.convertToPdfPoint(x, y); 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;
}
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;
}
}
async draw() { async draw() {
if (this.renderingState !== RenderingStates.INITIAL) { if (this.renderingState !== RenderingStates.INITIAL) {
console.error("Must be in new state before drawing"); console.error("Must be in new state before drawing");
@ -1009,61 +909,15 @@ 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 { 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; this.#originalViewport = viewport;
let showCanvas = isLastShow => { const { canvas, prevCanvas, ctx } = this._createCanvas(newCanvas => {
if (updateOnFirstShow) { // Always inject the canvas as the first element in the wrapper.
// Don't add the canvas until the first draw callback, or until canvasWrapper.prepend(newCanvas);
// 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,
}); });
canvas.setAttribute("role", "presentation");
const outputScale = (this.outputScale = new OutputScale()); const outputScale = (this.outputScale = new OutputScale());
if ( if (
@ -1126,14 +980,18 @@ class PDFPageView {
pageColors, pageColors,
isEditing: this.#isEditing, isEditing: this.#isEditing,
}; };
const renderTask = (this.renderTask = pdfPage.render(renderContext)); const resultPromise = this._drawCanvas(
renderTask.onContinue = renderContinueCallback; renderContext,
prevCanvas,
const resultPromise = renderTask.promise.then( renderTask => {
async () => { // Ensure that the thumbnails won't become partially (or fully) blank,
showCanvas?.(true); // for documents that contain interactive form elements.
await this.#finishRenderTask(renderTask); this.#useThumbnailCanvas.regularAnnotations =
!renderTask.separateAnnots;
this.dispatchPageRendered(false);
}
).then(async () => {
this.structTreeLayer ||= new StructTreeLayerBuilder( this.structTreeLayer ||= new StructTreeLayerBuilder(
pdfPage, pdfPage,
viewport.rawDims viewport.rawDims
@ -1174,20 +1032,7 @@ class PDFPageView {
}, },
}); });
this.#renderAnnotationEditorLayer(); this.#renderAnnotationEditorLayer();
}, });
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);
}
);
if (pdfPage.isPureXfa) { if (pdfPage.isPureXfa) {
if (!this.xfaLayer) { if (!this.xfaLayer) {
@ -1204,10 +1049,8 @@ class PDFPageView {
div.setAttribute("data-loaded", true); div.setAttribute("data-loaded", true);
this.eventBus.dispatch("pagerender", { this.dispatchPageRender();
source: this,
pageNumber: this.id,
});
return resultPromise; return resultPromise;
} }