pdf.js.mirror/web/annotation_layer_builder.js
Jonas Jenwald 74db085794 Re-factor how "internal" EventBus listeners are handled in the viewer
Currently the viewer uses semi-private `EventBus.prototype.{_on, _off}` methods, to try and ensure that all internal viewer state is updated *before* any "external" listeners are invoked.
For all use-cases outside of the viewer, e.g in the integration-tests, the `EventBus.prototype.{on, off}` methods are supposed to be used instead.

Unfortunately this isn't currently enforced in any way, except (hopefully) during review, and generally speaking it's not really possible to prevent the semi-private methods being used (e.g. by third-party users).
Hence this patch adds a new `INTERNAL_EVT` property which is *not* exposed anywhere (neither in the API nor globally), and whose value is generated at build-time, that the viewer uses to mark its `EventBus` listeners are internal.
This allows us to remove the semi-private `EventBus` methods, which helps to simplify that class a little bit.
2026-05-29 22:11:58 +02:00

366 lines
11 KiB
JavaScript

/* Copyright 2014 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.
*/
/** @typedef {import("../src/display/api").PDFPageProxy} PDFPageProxy */
// eslint-disable-next-line max-len
/** @typedef {import("../src/display/page_viewport").PageViewport} PageViewport */
// eslint-disable-next-line max-len
/** @typedef {import("../src/display/annotation_storage").AnnotationStorage} AnnotationStorage */
// eslint-disable-next-line max-len
/** @typedef {import("./struct_tree_layer_builder.js").StructTreeLayerBuilder} StructTreeLayerBuilder */
// eslint-disable-next-line max-len
/** @typedef {import("./text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */
// eslint-disable-next-line max-len
/** @typedef {import("../src/display/editor/tools.js").AnnotationEditorUIManager} AnnotationEditorUIManager */
/** @typedef {import("./comment_manager.js").CommentManager} CommentManager */
/** @typedef {import("./pdf_link_service.js").PDFLinkService} PDFLinkService */
// eslint-disable-next-line max-len
/** @typedef {import("./base_download_manager.js").BaseDownloadManager} BaseDownloadManager */
import {
AnnotationLayer,
AnnotationType,
setLayerDimensions,
Util,
} from "pdfjs-lib";
import { internalOpt } from "./internal_evt.js";
import { PresentationModeState } from "./ui_utils.js";
/**
* @typedef {Object} AnnotationLayerBuilderOptions
* @property {PDFPageProxy} pdfPage
* @property {AnnotationStorage} [annotationStorage]
* @property {string} [imageResourcesPath] - Path for image resources, mainly
* for annotation icons. Include trailing slash.
* @property {boolean} renderForms
* @property {PDFLinkService} linkService
* @property {BaseDownloadManager} [downloadManager]
* @property {boolean} [enableComment]
* @property {boolean} [enableScripting]
* @property {Promise<boolean>} [hasJSActionsPromise]
* @property {Promise<Object<string, Array<Object>> | null>}
* [fieldObjectsPromise]
* @property {Map<string, HTMLCanvasElement>} [annotationCanvasMap]
* @property {TextAccessibilityManager} [accessibilityManager]
* @property {AnnotationEditorUIManager} [annotationEditorUIManager]
* @property {function} [onAppend]
* @property {CommentManager} [commentManager]
*/
/**
* @typedef {Object} AnnotationLayerBuilderRenderOptions
* @property {PageViewport} viewport
* @property {string} [intent] - The default value is "display".
* @property {StructTreeLayerBuilder} [structTreeLayer]
* @property {Promise} [optionalContentConfigPromise]
*/
class AnnotationLayerBuilder {
#annotations = null;
#commentManager = null;
#externalHide = false;
#onAppend = null;
#eventAC = null;
#linksInjected = false;
/**
* @param {AnnotationLayerBuilderOptions} options
*/
constructor({
pdfPage,
linkService,
downloadManager,
annotationStorage = null,
imageResourcesPath = "",
renderForms = true,
enableComment = false,
commentManager = null,
enableScripting = false,
hasJSActionsPromise = null,
fieldObjectsPromise = null,
annotationCanvasMap = null,
accessibilityManager = null,
annotationEditorUIManager = null,
onAppend = null,
}) {
this.pdfPage = pdfPage;
this.linkService = linkService;
this.downloadManager = downloadManager;
this.imageResourcesPath = imageResourcesPath;
this.renderForms = renderForms;
this.annotationStorage = annotationStorage;
this.enableComment = enableComment;
this.#commentManager = commentManager;
this.enableScripting = enableScripting;
this._hasJSActionsPromise = hasJSActionsPromise || Promise.resolve(false);
this._fieldObjectsPromise = fieldObjectsPromise || Promise.resolve(null);
this._annotationCanvasMap = annotationCanvasMap;
this._accessibilityManager = accessibilityManager;
this._annotationEditorUIManager = annotationEditorUIManager;
this.#onAppend = onAppend;
this.annotationLayer = null;
this.div = null;
this._cancelled = false;
this._eventBus = linkService.eventBus;
}
/**
* @param {AnnotationLayerBuilderRenderOptions} options
* @returns {Promise<void>} A promise that is resolved when rendering of the
* annotations is complete.
*/
async render({
viewport,
intent = "display",
structTreeLayer = null,
optionalContentConfigPromise = null,
}) {
if (this.div) {
const optionalContentConfig = await optionalContentConfigPromise;
if (this._cancelled || !this.annotationLayer) {
return;
}
// If an annotationLayer already exists, refresh its children's
// transformation matrices.
this.annotationLayer.update({
viewport: viewport.clone({ dontFlip: true }),
optionalContentConfig,
});
return;
}
const [annotations, hasJSActions, fieldObjects, optionalContentConfig] =
await Promise.all([
this.pdfPage.getAnnotations({ intent }),
this._hasJSActionsPromise,
this._fieldObjectsPromise,
optionalContentConfigPromise,
]);
if (this._cancelled) {
return;
}
// Create an annotation layer div and render the annotations
// if there is at least one annotation.
const div = (this.div = document.createElement("div"));
div.className = "annotationLayer";
this.#onAppend?.(div);
this.#initAnnotationLayer(viewport, structTreeLayer);
if (annotations.length === 0) {
this.#annotations = annotations;
setLayerDimensions(this.div, viewport);
return;
}
await this.annotationLayer.render({
annotations,
imageResourcesPath: this.imageResourcesPath,
renderForms: this.renderForms,
downloadManager: this.downloadManager,
enableComment: this.enableComment,
enableScripting: this.enableScripting,
hasJSActions,
fieldObjects,
optionalContentConfig,
});
this.#annotations = annotations;
// Ensure that interactive form elements in the annotationLayer are
// disabled while PresentationMode is active (see issue 12232).
if (this.linkService.isInPresentationMode) {
this.#updatePresentationModeState(PresentationModeState.FULLSCREEN);
}
if (!this.#eventAC) {
this.#eventAC = new AbortController();
this._eventBus?.on(
"presentationmodechanged",
evt => {
this.#updatePresentationModeState(evt.state);
},
{ signal: this.#eventAC.signal, ...internalOpt }
);
}
}
#initAnnotationLayer(viewport, structTreeLayer) {
this.annotationLayer = new AnnotationLayer({
div: this.div,
accessibilityManager: this._accessibilityManager,
annotationCanvasMap: this._annotationCanvasMap,
annotationEditorUIManager: this._annotationEditorUIManager,
annotationStorage: this.annotationStorage,
page: this.pdfPage,
viewport: viewport.clone({ dontFlip: true }),
structTreeLayer,
commentManager: this.#commentManager,
linkService: this.linkService,
});
}
cancel() {
this._cancelled = true;
this.#eventAC?.abort();
this.#eventAC = null;
}
hide(internal = false) {
this.#externalHide = !internal;
if (!this.div) {
return;
}
this.div.hidden = true;
}
hasEditableAnnotations() {
return !!this.annotationLayer?.hasEditableAnnotations();
}
/**
* @param {Array<Object>} inferredLinks
* @returns {Promise<void>} A promise that is resolved when the inferred links
* are added to the annotation layer.
*/
async injectLinkAnnotations(inferredLinks) {
if (this.#annotations === null) {
throw new Error(
"`render` method must be called before `injectLinkAnnotations`."
);
}
if (this._cancelled || this.#linksInjected) {
return;
}
this.#linksInjected = true;
const newLinks = this.#annotations.length
? this.#checkInferredLinks(inferredLinks)
: inferredLinks;
if (!newLinks.length) {
return;
}
await this.annotationLayer.addLinkAnnotations(newLinks);
// Don't show the annotation layer if it was explicitly hidden previously.
if (!this.#externalHide) {
this.div.hidden = false;
}
}
#updatePresentationModeState(state) {
if (!this.div) {
return;
}
let disableFormElements = false;
switch (state) {
case PresentationModeState.FULLSCREEN:
disableFormElements = true;
break;
case PresentationModeState.NORMAL:
break;
default:
return;
}
for (const section of this.div.childNodes) {
if (section.hasAttribute("data-internal-link")) {
continue;
}
section.inert = disableFormElements;
}
}
#checkInferredLinks(inferredLinks) {
function annotationRects(annot) {
if (!annot.quadPoints) {
return [annot.rect];
}
const rects = [];
for (let i = 2, ii = annot.quadPoints.length; i < ii; i += 8) {
const trX = annot.quadPoints[i];
const trY = annot.quadPoints[i + 1];
const blX = annot.quadPoints[i + 2];
const blY = annot.quadPoints[i + 3];
rects.push([blX, blY, trX, trY]);
}
return rects;
}
function intersectAnnotations(annot1, annot2) {
const intersections = [];
const annot1Rects = annotationRects(annot1);
const annot2Rects = annotationRects(annot2);
for (const rect1 of annot1Rects) {
for (const rect2 of annot2Rects) {
const intersection = Util.intersect(rect1, rect2);
if (intersection) {
intersections.push(intersection);
}
}
}
return intersections;
}
function areaRects(rects) {
let totalArea = 0;
for (const rect of rects) {
totalArea += Math.abs((rect[2] - rect[0]) * (rect[3] - rect[1]));
}
return totalArea;
}
return inferredLinks.filter(link => {
let linkAreaRects;
for (const annotation of this.#annotations) {
if (
annotation.annotationType !== AnnotationType.LINK ||
!annotation.url
) {
continue;
}
// TODO: Add a test case to verify that we can find the intersection
// between two annotations with quadPoints properly.
const intersections = intersectAnnotations(annotation, link);
if (intersections.length === 0) {
continue;
}
linkAreaRects ??= areaRects(annotationRects(link));
if (
areaRects(intersections) / linkAreaRects >
0.5 /* If the overlap is more than 50%. */
) {
return false;
}
}
return true;
});
}
}
export { AnnotationLayerBuilder };