Commit inicial - WordPress Análisis de Precios Unitarios

- WordPress core y plugins
- Tema Twenty Twenty-Four configurado
- Plugin allow-unfiltered-html.php simplificado
- .gitignore configurado para excluir wp-config.php y uploads

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
root
2025-11-03 21:04:30 -06:00
commit a22573bf0b
24068 changed files with 4993111 additions and 0 deletions

View File

@@ -0,0 +1,135 @@
/* Copyright 2022 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/display_utils").PageViewport} PageViewport */
/** @typedef {import("./interfaces").IPDFLinkService} IPDFLinkService */
// eslint-disable-next-line max-len
/** @typedef {import("../src/display/editor/tools.js").AnnotationEditorUIManager} AnnotationEditorUIManager */
// eslint-disable-next-line max-len
/** @typedef {import("../annotation_storage.js").AnnotationStorage} AnnotationStorage */
// eslint-disable-next-line max-len
/** @typedef {import("./text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */
/** @typedef {import("./interfaces").IL10n} IL10n */
import { AnnotationEditorLayer } from 'pdfjs-dist';
import { NullL10n } from './l10n_utils.js';
/**
* @typedef {Object} AnnotationEditorLayerBuilderOptions
* @property {number} mode - Editor mode
* @property {HTMLDivElement} pageDiv
* @property {PDFPageProxy} pdfPage
* @property {TextAccessibilityManager} accessibilityManager
* @property {AnnotationStorage} annotationStorage
* @property {IL10n} l10n - Localization service.
* @property {AnnotationEditorUIManager} uiManager
*/
class AnnotationEditorLayerBuilder {
#uiManager;
/**
* @param {AnnotationEditorLayerBuilderOptions} options
*/
constructor(options) {
this.pageDiv = options.pageDiv;
this.pdfPage = options.pdfPage;
this.annotationStorage = options.annotationStorage || null;
this.accessibilityManager = options.accessibilityManager;
this.l10n = options.l10n || NullL10n;
this.annotationEditorLayer = null;
this.div = null;
this._cancelled = false;
this.#uiManager = options.uiManager;
}
/**
* @param {PageViewport} viewport
* @param {string} intent (default value is 'display')
*/
async render(viewport, intent = 'display') {
if (intent !== 'display') {
return;
}
if (this._cancelled) {
return;
}
const clonedViewport = viewport.clone({ dontFlip: true });
if (this.div) {
this.annotationEditorLayer.update({ viewport: clonedViewport });
this.show();
return;
}
// Create an AnnotationEditor layer div
this.div = document.createElement('div');
this.div.className = 'annotationEditorLayer';
this.div.tabIndex = 0;
this.pageDiv.append(this.div);
this.annotationEditorLayer = new AnnotationEditorLayer({
uiManager: this.#uiManager,
div: this.div,
annotationStorage: this.annotationStorage,
accessibilityManager: this.accessibilityManager,
pageIndex: this.pdfPage._pageIndex,
l10n: this.l10n,
viewport: clonedViewport,
});
const parameters = {
viewport: clonedViewport,
div: this.div,
annotations: null,
intent,
};
this.annotationEditorLayer.render(parameters);
}
cancel() {
this._cancelled = true;
this.destroy();
}
hide() {
if (!this.div) {
return;
}
this.div.hidden = true;
}
show() {
if (!this.div) {
return;
}
this.div.hidden = false;
}
destroy() {
if (!this.div) {
return;
}
this.pageDiv = null;
this.annotationEditorLayer.destroy();
this.div.remove();
}
}
export { AnnotationEditorLayerBuilder };

View File

@@ -0,0 +1,152 @@
/* 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/display_utils").PageViewport} PageViewport */
/** @typedef {import("./interfaces").IDownloadManager} IDownloadManager */
/** @typedef {import("./interfaces").IL10n} IL10n */
/** @typedef {import("./interfaces").IPDFLinkService} IPDFLinkService */
// eslint-disable-next-line max-len
/** @typedef {import("./textaccessibility.js").TextAccessibilityManager} TextAccessibilityManager */
import { AnnotationLayer } from 'pdfjs-dist';
import { NullL10n } from './l10n_utils.js';
/**
* @typedef {Object} AnnotationLayerBuilderOptions
* @property {HTMLDivElement} pageDiv
* @property {PDFPageProxy} pdfPage
* @property {AnnotationStorage} [annotationStorage]
* @property {string} [imageResourcesPath] - Path for image resources, mainly
* for annotation icons. Include trailing slash.
* @property {boolean} renderForms
* @property {IPDFLinkService} linkService
* @property {IDownloadManager} downloadManager
* @property {IL10n} l10n - Localization service.
* @property {boolean} [enableScripting]
* @property {Promise<boolean>} [hasJSActionsPromise]
* @property {Promise<Object<string, Array<Object>> | null>}
* [fieldObjectsPromise]
* @property {Object} [mouseState]
* @property {Map<string, HTMLCanvasElement>} [annotationCanvasMap]
* @property {TextAccessibilityManager} accessibilityManager
*/
class AnnotationLayerBuilder {
/**
* @param {AnnotationLayerBuilderOptions} options
*/
constructor({
pageDiv,
pdfPage,
linkService,
downloadManager,
annotationStorage = null,
imageResourcesPath = '',
renderForms = true,
l10n = NullL10n,
enableScripting = false,
hasJSActionsPromise = null,
fieldObjectsPromise = null,
mouseState = null,
annotationCanvasMap = null,
accessibilityManager = null,
}) {
this.pageDiv = pageDiv;
this.pdfPage = pdfPage;
this.linkService = linkService;
this.downloadManager = downloadManager;
this.imageResourcesPath = imageResourcesPath;
this.renderForms = renderForms;
this.l10n = l10n;
this.annotationStorage = annotationStorage;
this.enableScripting = enableScripting;
this._hasJSActionsPromise = hasJSActionsPromise;
this._fieldObjectsPromise = fieldObjectsPromise;
this._mouseState = mouseState;
this._annotationCanvasMap = annotationCanvasMap;
this._accessibilityManager = accessibilityManager;
this.div = null;
this._cancelled = false;
}
/**
* @param {PageViewport} viewport
* @param {string} intent (default value is 'display')
* @returns {Promise<void>} A promise that is resolved when rendering of the
* annotations is complete.
*/
async render(viewport, intent = 'display') {
const [annotations, hasJSActions = false, fieldObjects = null] =
await Promise.all([
this.pdfPage.getAnnotations({ intent }),
this._hasJSActionsPromise,
this._fieldObjectsPromise,
]);
if (this._cancelled || annotations.length === 0) {
return;
}
const parameters = {
viewport: viewport.clone({ dontFlip: true }),
div: this.div,
annotations,
page: this.pdfPage,
imageResourcesPath: this.imageResourcesPath,
renderForms: this.renderForms,
linkService: this.linkService,
downloadManager: this.downloadManager,
annotationStorage: this.annotationStorage,
enableScripting: this.enableScripting,
hasJSActions,
fieldObjects,
mouseState: this._mouseState,
annotationCanvasMap: this._annotationCanvasMap,
accessibilityManager: this._accessibilityManager,
};
if (this.div) {
// If an annotationLayer already exists, refresh its children's
// transformation matrices.
AnnotationLayer.update(parameters);
} else {
// Create an annotation layer div and render the annotations
// if there is at least one annotation.
this.div = document.createElement('div');
this.div.className = 'annotationLayer';
this.pageDiv.append(this.div);
parameters.div = this.div;
AnnotationLayer.render(parameters);
this.l10n.translate(this.div);
}
}
cancel() {
this._cancelled = true;
}
hide() {
if (!this.div) {
return;
}
this.div.hidden = true;
}
}
export { AnnotationLayerBuilder };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,411 @@
/* Copyright 2018 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.
*/
const compatibilityParams = Object.create(null);
if (typeof PDFJSDev === 'undefined' || PDFJSDev.test('GENERIC')) {
if (
typeof PDFJSDev !== 'undefined' &&
PDFJSDev.test('LIB') &&
typeof navigator === 'undefined'
) {
globalThis.navigator = Object.create(null);
}
const userAgent = navigator.userAgent || '';
const platform = navigator.platform || '';
const maxTouchPoints = navigator.maxTouchPoints || 1;
const isAndroid = /Android/.test(userAgent);
const isIOS =
/\b(iPad|iPhone|iPod)(?=;)/.test(userAgent) ||
(platform === 'MacIntel' && maxTouchPoints > 1);
// Limit canvas size to 5 mega-pixels on mobile.
// Support: Android, iOS
(function checkCanvasSizeLimitation() {
if (isIOS || isAndroid) {
compatibilityParams.maxCanvasPixels = 5242880;
}
})();
}
const OptionKind = {
VIEWER: 0x02,
API: 0x04,
WORKER: 0x08,
PREFERENCE: 0x80,
};
/**
* NOTE: These options are used to generate the `default_preferences.json` file,
* see `OptionKind.PREFERENCE`, hence the values below must use only
* primitive types and cannot rely on any imported types.
*/
const defaultOptions = {
annotationEditorMode: {
/** @type {boolean} */
value:
typeof PDFJSDev === 'undefined' ||
PDFJSDev.test('!PRODUCTION || TESTING')
? 0
: -1,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
annotationMode: {
/** @type {number} */
value: 2,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
cursorToolOnLoad: {
/** @type {number} */
value: 1,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
defaultZoomValue: {
/** @type {string} */
value: '',
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
disableHistory: {
/** @type {boolean} */
value: false,
kind: OptionKind.VIEWER,
},
disablePageLabels: {
/** @type {boolean} */
value: false,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
enablePermissions: {
/** @type {boolean} */
value: false,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
enablePrintAutoRotate: {
/** @type {boolean} */
value: true,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
enableScripting: {
/** @type {boolean} */
value: typeof PDFJSDev === 'undefined' || !PDFJSDev.test('CHROME'),
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
externalLinkRel: {
/** @type {string} */
value: 'noopener noreferrer nofollow',
kind: OptionKind.VIEWER,
},
externalLinkTarget: {
/** @type {number} */
value: 2,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
historyUpdateUrl: {
/** @type {boolean} */
value: false,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
ignoreDestinationZoom: {
/** @type {boolean} */
value: false,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
imageResourcesPath: {
/** @type {string} */
value: './images/',
kind: OptionKind.VIEWER,
},
maxCanvasPixels: {
/** @type {number} */
value: 16777216,
kind: OptionKind.VIEWER,
},
forcePageColors: {
/** @type {boolean} */
value: false,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
pageColorsBackground: {
/** @type {string} */
value: 'Canvas',
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
pageColorsForeground: {
/** @type {string} */
value: 'CanvasText',
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
pdfBugEnabled: {
/** @type {boolean} */
value: typeof PDFJSDev === 'undefined' || !PDFJSDev.test('PRODUCTION'),
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
printResolution: {
/** @type {number} */
value: 150,
kind: OptionKind.VIEWER,
},
sidebarViewOnLoad: {
/** @type {number} */
value: -1,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
scrollModeOnLoad: {
/** @type {number} */
value: -1,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
spreadModeOnLoad: {
/** @type {number} */
value: -1,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
textLayerMode: {
/** @type {number} */
value: 1,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
useOnlyCssZoom: {
/** @type {boolean} */
value: false,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
viewerCssTheme: {
/** @type {number} */
value:
typeof PDFJSDev !== 'undefined' && PDFJSDev.test('CHROME') ? 2 : 0,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
viewOnLoad: {
/** @type {boolean} */
value: 0,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
cMapPacked: {
/** @type {boolean} */
value: true,
kind: OptionKind.API,
},
cMapUrl: {
/** @type {string} */
value:
typeof PDFJSDev === 'undefined' || !PDFJSDev.test('PRODUCTION')
? '../external/bcmaps/'
: '../web/cmaps/',
kind: OptionKind.API,
},
disableAutoFetch: {
/** @type {boolean} */
value: false,
kind: OptionKind.API + OptionKind.PREFERENCE,
},
disableFontFace: {
/** @type {boolean} */
value: false,
kind: OptionKind.API + OptionKind.PREFERENCE,
},
disableRange: {
/** @type {boolean} */
value: false,
kind: OptionKind.API + OptionKind.PREFERENCE,
},
disableStream: {
/** @type {boolean} */
value: false,
kind: OptionKind.API + OptionKind.PREFERENCE,
},
docBaseUrl: {
/** @type {string} */
value: '',
kind: OptionKind.API,
},
enableXfa: {
/** @type {boolean} */
value: true,
kind: OptionKind.API + OptionKind.PREFERENCE,
},
fontExtraProperties: {
/** @type {boolean} */
value: false,
kind: OptionKind.API,
},
isEvalSupported: {
/** @type {boolean} */
value: true,
kind: OptionKind.API,
},
maxImageSize: {
/** @type {number} */
value: -1,
kind: OptionKind.API,
},
pdfBug: {
/** @type {boolean} */
value: false,
kind: OptionKind.API,
},
standardFontDataUrl: {
/** @type {string} */
value:
typeof PDFJSDev === 'undefined' || !PDFJSDev.test('PRODUCTION')
? '../external/standard_fonts/'
: '../web/standard_fonts/',
kind: OptionKind.API,
},
verbosity: {
/** @type {number} */
value: 1,
kind: OptionKind.API,
},
workerPort: {
/** @type {Object} */
value: null,
kind: OptionKind.WORKER,
},
workerSrc: {
/** @type {string} */
value:
typeof PDFJSDev === 'undefined' || !PDFJSDev.test('PRODUCTION')
? '../src/worker_loader.js'
: '../build/pdf.worker.js',
kind: OptionKind.WORKER,
},
};
if (
typeof PDFJSDev === 'undefined' ||
PDFJSDev.test('!PRODUCTION || GENERIC')
) {
defaultOptions.defaultUrl = {
/** @type {string} */
value: 'compressed.tracemonkey-pldi-09.pdf',
kind: OptionKind.VIEWER,
};
defaultOptions.disablePreferences = {
/** @type {boolean} */
value: typeof PDFJSDev !== 'undefined' && PDFJSDev.test('TESTING'),
kind: OptionKind.VIEWER,
};
defaultOptions.locale = {
/** @type {string} */
value: navigator.language || 'en-US',
kind: OptionKind.VIEWER,
};
defaultOptions.renderer = {
/** @type {string} */
value: 'canvas',
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
};
defaultOptions.sandboxBundleSrc = {
/** @type {string} */
value:
typeof PDFJSDev === 'undefined' || !PDFJSDev.test('PRODUCTION')
? '../build/dev-sandbox/pdf.sandbox.js'
: '../build/pdf.sandbox.js',
kind: OptionKind.VIEWER,
};
} else if (PDFJSDev.test('CHROME')) {
defaultOptions.defaultUrl = {
/** @type {string} */
value: '',
kind: OptionKind.VIEWER,
};
defaultOptions.disableTelemetry = {
/** @type {boolean} */
value: false,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
};
defaultOptions.sandboxBundleSrc = {
/** @type {string} */
value: '../build/pdf.sandbox.js',
kind: OptionKind.VIEWER,
};
}
const userOptions = Object.create(null);
class AppOptions {
constructor() {
throw new Error('Cannot initialize AppOptions.');
}
static get(name) {
const userOption = userOptions[name];
if (userOption !== undefined) {
return userOption;
}
const defaultOption = defaultOptions[name];
if (defaultOption !== undefined) {
return compatibilityParams[name] ?? defaultOption.value;
}
return undefined;
}
static getAll(kind = null) {
const options = Object.create(null);
for (const name in defaultOptions) {
const defaultOption = defaultOptions[name];
if (kind) {
if ((kind & defaultOption.kind) === 0) {
continue;
}
if (kind === OptionKind.PREFERENCE) {
const value = defaultOption.value,
valueType = typeof value;
if (
valueType === 'boolean' ||
valueType === 'string' ||
(valueType === 'number' && Number.isInteger(value))
) {
options[name] = value;
continue;
}
throw new Error(`Invalid type for preference: ${name}`);
}
}
const userOption = userOptions[name];
options[name] =
userOption !== undefined
? userOption
: compatibilityParams[name] ?? defaultOption.value;
}
return options;
}
static set(name, value) {
userOptions[name] = value;
}
static setAll(options) {
for (const name in options) {
userOptions[name] = options[name];
}
}
static remove(name) {
delete userOptions[name];
}
/**
* @ignore
*/
static _hasUserOptions() {
return Object.keys(userOptions).length > 0;
}
}
export { AppOptions, compatibilityParams, OptionKind };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,173 @@
/* Copyright 2013 Rob Wu <rob@robwu.nl>
* https://github.com/Rob--W/grab-to-pan.js
*
* 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.
*/
// Class name of element which can be grabbed.
const CSS_CLASS_GRAB = 'grab-to-pan-grab';
class GrabToPan {
/**
* Construct a GrabToPan instance for a given HTML element.
* @param {Element} options.element
* @param {function} [options.ignoreTarget] - See `ignoreTarget(node)`.
* @param {function(boolean)} [options.onActiveChanged] - Called when
* grab-to-pan is (de)activated. The first argument is a boolean that
* shows whether grab-to-pan is activated.
*/
constructor(options) {
this.element = options.element;
this.document = options.element.ownerDocument;
if (typeof options.ignoreTarget === 'function') {
this.ignoreTarget = options.ignoreTarget;
}
this.onActiveChanged = options.onActiveChanged;
// Bind the contexts to ensure that `this` always points to
// the GrabToPan instance.
this.activate = this.activate.bind(this);
this.deactivate = this.deactivate.bind(this);
this.toggle = this.toggle.bind(this);
this._onMouseDown = this.#onMouseDown.bind(this);
this._onMouseMove = this.#onMouseMove.bind(this);
this._endPan = this.#endPan.bind(this);
// This overlay will be inserted in the document when the mouse moves during
// a grab operation, to ensure that the cursor has the desired appearance.
const overlay = (this.overlay = document.createElement('div'));
overlay.className = 'grab-to-pan-grabbing';
}
/**
* Bind a mousedown event to the element to enable grab-detection.
*/
activate() {
if (!this.active) {
this.active = true;
this.element.addEventListener('mousedown', this._onMouseDown, true);
this.element.classList.add(CSS_CLASS_GRAB);
this.onActiveChanged?.(true);
}
}
/**
* Removes all events. Any pending pan session is immediately stopped.
*/
deactivate() {
if (this.active) {
this.active = false;
this.element.removeEventListener(
'mousedown',
this._onMouseDown,
true,
);
this._endPan();
this.element.classList.remove(CSS_CLASS_GRAB);
this.onActiveChanged?.(false);
}
}
toggle() {
if (this.active) {
this.deactivate();
} else {
this.activate();
}
}
/**
* Whether to not pan if the target element is clicked.
* Override this method to change the default behaviour.
*
* @param {Element} node - The target of the event.
* @returns {boolean} Whether to not react to the click event.
*/
ignoreTarget(node) {
// Check whether the clicked element is, a child of, an input element/link.
return node.matches(
'a[href], a[href] *, input, textarea, button, button *, select, option',
);
}
#onMouseDown(event) {
if (event.button !== 0 || this.ignoreTarget(event.target)) {
return;
}
if (event.originalTarget) {
try {
// eslint-disable-next-line no-unused-expressions
event.originalTarget.tagName;
} catch (e) {
// Mozilla-specific: element is a scrollbar (XUL element)
return;
}
}
this.scrollLeftStart = this.element.scrollLeft;
this.scrollTopStart = this.element.scrollTop;
this.clientXStart = event.clientX;
this.clientYStart = event.clientY;
this.document.addEventListener('mousemove', this._onMouseMove, true);
this.document.addEventListener('mouseup', this._endPan, true);
// When a scroll event occurs before a mousemove, assume that the user
// dragged a scrollbar (necessary for Opera Presto, Safari and IE)
// (not needed for Chrome/Firefox)
this.element.addEventListener('scroll', this._endPan, true);
event.preventDefault();
event.stopPropagation();
const focusedElement = document.activeElement;
if (focusedElement && !focusedElement.contains(event.target)) {
focusedElement.blur();
}
}
#onMouseMove(event) {
this.element.removeEventListener('scroll', this._endPan, true);
if (!(event.buttons & 1)) {
// The left mouse button is released.
this._endPan();
return;
}
const xDiff = event.clientX - this.clientXStart;
const yDiff = event.clientY - this.clientYStart;
const scrollTop = this.scrollTopStart - yDiff;
const scrollLeft = this.scrollLeftStart - xDiff;
if (this.element.scrollTo) {
this.element.scrollTo({
top: scrollTop,
left: scrollLeft,
behavior: 'instant',
});
} else {
this.element.scrollTop = scrollTop;
this.element.scrollLeft = scrollLeft;
}
if (!this.overlay.parentNode) {
document.body.append(this.overlay);
}
}
#endPan() {
this.element.removeEventListener('scroll', this._endPan, true);
this.document.removeEventListener('mousemove', this._onMouseMove, true);
this.document.removeEventListener('mouseup', this._endPan, true);
// Note: ChildNode.remove doesn't throw if the parentNode is undefined.
this.overlay.remove();
}
}
export { GrabToPan };

View File

@@ -0,0 +1,157 @@
/* Copyright 2021 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.
*/
/**
* A subset of the l10n strings in the `l10n/en-US/viewer.properties` file.
*/
const DEFAULT_L10N_STRINGS = {
of_pages: 'of {{pagesCount}}',
page_of_pages: '({{pageNumber}} of {{pagesCount}})',
document_properties_kb: '{{size_kb}} KB ({{size_b}} bytes)',
document_properties_mb: '{{size_mb}} MB ({{size_b}} bytes)',
document_properties_date_string: '{{date}}, {{time}}',
document_properties_page_size_unit_inches: 'in',
document_properties_page_size_unit_millimeters: 'mm',
document_properties_page_size_orientation_portrait: 'portrait',
document_properties_page_size_orientation_landscape: 'landscape',
document_properties_page_size_name_a3: 'A3',
document_properties_page_size_name_a4: 'A4',
document_properties_page_size_name_letter: 'Letter',
document_properties_page_size_name_legal: 'Legal',
document_properties_page_size_dimension_string:
'{{width}} × {{height}} {{unit}} ({{orientation}})',
document_properties_page_size_dimension_name_string:
'{{width}} × {{height}} {{unit}} ({{name}}, {{orientation}})',
document_properties_linearized_yes: 'Yes',
document_properties_linearized_no: 'No',
print_progress_percent: '{{progress}}%',
'toggle_sidebar.title': 'Toggle Sidebar',
'toggle_sidebar_notification2.title':
'Toggle Sidebar (document contains outline/attachments/layers)',
additional_layers: 'Additional Layers',
page_landmark: 'Page {{page}}',
thumb_page_title: 'Page {{page}}',
thumb_page_canvas: 'Thumbnail of Page {{page}}',
find_reached_top: 'Reached top of document, continued from bottom',
find_reached_bottom: 'Reached end of document, continued from top',
'find_match_count[one]': '{{current}} of {{total}} match',
'find_match_count[other]': '{{current}} of {{total}} matches',
'find_match_count_limit[one]': 'More than {{limit}} match',
'find_match_count_limit[other]': 'More than {{limit}} matches',
find_not_found: 'Phrase not found',
error_version_info: 'PDF.js v{{version}} (build: {{build}})',
error_message: 'Message: {{message}}',
error_stack: 'Stack: {{stack}}',
error_file: 'File: {{file}}',
error_line: 'Line: {{line}}',
rendering_error: 'An error occurred while rendering the page.',
page_scale_width: 'Page Width',
page_scale_fit: 'Page Fit',
page_scale_auto: 'Automatic Zoom',
page_scale_actual: 'Actual Size',
page_scale_percent: '{{scale}}%',
loading: 'Loading…',
loading_error: 'An error occurred while loading the PDF.',
invalid_file_error: 'Invalid or corrupted PDF file.',
missing_file_error: 'Missing PDF file.',
unexpected_response_error: 'Unexpected server response.',
printing_not_supported:
'Warning: Printing is not fully supported by this browser.',
printing_not_ready: 'Warning: The PDF is not fully loaded for printing.',
web_fonts_disabled:
'Web fonts are disabled: unable to use embedded PDF fonts.',
free_text_default_content: 'Enter text…',
editor_free_text_aria_label: 'FreeText Editor',
editor_ink_aria_label: 'Ink Editor',
editor_ink_canvas_aria_label: 'User-created image',
};
function getL10nFallback(key, args) {
switch (key) {
case 'find_match_count':
key = `find_match_count[${args.total === 1 ? 'one' : 'other'}]`;
break;
case 'find_match_count_limit':
key = `find_match_count_limit[${
args.limit === 1 ? 'one' : 'other'
}]`;
break;
}
return DEFAULT_L10N_STRINGS[key] || '';
}
const PARTIAL_LANG_CODES = {
en: 'en-US',
es: 'es-ES',
fy: 'fy-NL',
ga: 'ga-IE',
gu: 'gu-IN',
hi: 'hi-IN',
hy: 'hy-AM',
nb: 'nb-NO',
ne: 'ne-NP',
nn: 'nn-NO',
pa: 'pa-IN',
pt: 'pt-PT',
sv: 'sv-SE',
zh: 'zh-CN',
};
// Try to support "incompletely" specified language codes (see issue 13689).
function fixupLangCode(langCode) {
return PARTIAL_LANG_CODES[langCode?.toLowerCase()] || langCode;
}
// Replaces {{arguments}} with their values.
function formatL10nValue(text, args) {
if (!args) {
return text;
}
return text.replace(/\{\{\s*(\w+)\s*\}\}/g, (all, name) => {
return name in args ? args[name] : '{{' + name + '}}';
});
}
/**
* No-op implementation of the localization service.
* @implements {IL10n}
*/
const NullL10n = {
async getLanguage() {
return 'en-us';
},
async getDirection() {
return 'ltr';
},
async get(key, args = null, fallback = getL10nFallback(key, args)) {
return formatL10nValue(fallback, args);
},
async translate(element) {},
};
export { fixupLangCode, getL10nFallback, NullL10n };

View File

@@ -0,0 +1,118 @@
/* 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.
*/
class OverlayManager {
#overlays = new WeakMap();
#active = null;
get active() {
return this.#active;
}
/**
* @param {HTMLDialogElement} dialog - The overlay's DOM element.
* @param {boolean} [canForceClose] - Indicates if opening the overlay closes
* an active overlay. The default is `false`.
* @returns {Promise} A promise that is resolved when the overlay has been
* registered.
*/
async register(dialog, canForceClose = false) {
if (typeof dialog !== 'object') {
throw new Error('Not enough parameters.');
} else if (this.#overlays.has(dialog)) {
throw new Error('The overlay is already registered.');
}
this.#overlays.set(dialog, { canForceClose });
if (
typeof PDFJSDev !== 'undefined' &&
PDFJSDev.test('GENERIC && !SKIP_BABEL') &&
!dialog.showModal
) {
const dialogPolyfill = require('dialog-polyfill/dist/dialog-polyfill.js');
dialogPolyfill.registerDialog(dialog);
if (!this._dialogPolyfillCSS) {
this._dialogPolyfillCSS = true;
const style = document.createElement('style');
style.textContent = PDFJSDev.eval('DIALOG_POLYFILL_CSS');
document.head.prepend(style);
}
}
dialog.addEventListener('cancel', (evt) => {
this.#active = null;
});
}
/**
* @param {HTMLDialogElement} dialog - The overlay's DOM element.
* @returns {Promise} A promise that is resolved when the overlay has been
* unregistered.
*/
async unregister(dialog) {
if (!this.#overlays.has(dialog)) {
throw new Error('The overlay does not exist.');
} else if (this.#active === dialog) {
throw new Error(
'The overlay cannot be removed while it is active.',
);
}
this.#overlays.delete(dialog);
}
/**
* @param {HTMLDialogElement} dialog - The overlay's DOM element.
* @returns {Promise} A promise that is resolved when the overlay has been
* opened.
*/
async open(dialog) {
if (!this.#overlays.has(dialog)) {
throw new Error('The overlay does not exist.');
} else if (this.#active) {
if (this.#active === dialog) {
throw new Error('The overlay is already active.');
} else if (this.#overlays.get(dialog).canForceClose) {
await this.close();
} else {
throw new Error('Another overlay is currently active.');
}
}
this.#active = dialog;
dialog.showModal();
}
/**
* @param {HTMLDialogElement} dialog - The overlay's DOM element.
* @returns {Promise} A promise that is resolved when the overlay has been
* closed.
*/
async close(dialog = this.#active) {
if (!this.#overlays.has(dialog)) {
throw new Error('The overlay does not exist.');
} else if (!this.#active) {
throw new Error('The overlay is currently not active.');
} else if (this.#active !== dialog) {
throw new Error('Another overlay is currently active.');
}
dialog.close();
this.#active = null;
}
}
export { OverlayManager };

View File

@@ -0,0 +1,145 @@
/* Copyright 2017 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 { GrabToPan } from './grab_to_pan.js';
import { PresentationModeState } from './ui_utils.js';
const CursorTool = {
SELECT: 0, // The default value.
HAND: 1,
ZOOM: 2,
};
/**
* @typedef {Object} PDFCursorToolsOptions
* @property {HTMLDivElement} container - The document container.
* @property {EventBus} eventBus - The application event bus.
* @property {number} [cursorToolOnLoad] - The cursor tool that will be enabled
* on load; the constants from {CursorTool} should be used. The default value
* is `CursorTool.SELECT`.
*/
class PDFCursorTools {
/**
* @param {PDFCursorToolsOptions} options
*/
constructor({ container, eventBus, cursorToolOnLoad = CursorTool.SELECT }) {
this.container = container;
this.eventBus = eventBus;
this.active = CursorTool.SELECT;
this.activeBeforePresentationMode = null;
this.handTool = new GrabToPan({
element: this.container,
});
this.#addEventListeners();
// Defer the initial `switchTool` call, to give other viewer components
// time to initialize *and* register 'cursortoolchanged' event listeners.
Promise.resolve().then(() => {
this.switchTool(cursorToolOnLoad);
});
}
/**
* @type {number} One of the values in {CursorTool}.
*/
get activeTool() {
return this.active;
}
/**
* NOTE: This method is ignored while Presentation Mode is active.
* @param {number} tool - The cursor mode that should be switched to,
* must be one of the values in {CursorTool}.
*/
switchTool(tool) {
if (this.activeBeforePresentationMode !== null) {
return; // Cursor tools cannot be used in Presentation Mode.
}
if (tool === this.active) {
return; // The requested tool is already active.
}
const disableActiveTool = () => {
switch (this.active) {
case CursorTool.SELECT:
break;
case CursorTool.HAND:
this.handTool.deactivate();
break;
case CursorTool.ZOOM:
/* falls through */
}
};
// Enable the new cursor tool.
switch (tool) {
case CursorTool.SELECT:
disableActiveTool();
break;
case CursorTool.HAND:
disableActiveTool();
this.handTool.activate();
break;
case CursorTool.ZOOM:
/* falls through */
default:
console.error(`switchTool: "${tool}" is an unsupported value.`);
return;
}
// Update the active tool *after* it has been validated above,
// in order to prevent setting it to an invalid state.
this.active = tool;
this.#dispatchEvent();
}
#dispatchEvent() {
this.eventBus.dispatch('cursortoolchanged', {
source: this,
tool: this.active,
});
}
#addEventListeners() {
this.eventBus._on('switchcursortool', (evt) => {
this.switchTool(evt.tool);
});
this.eventBus._on('presentationmodechanged', (evt) => {
switch (evt.state) {
case PresentationModeState.FULLSCREEN: {
const previouslyActive = this.active;
this.switchTool(CursorTool.SELECT);
this.activeBeforePresentationMode = previouslyActive;
break;
}
case PresentationModeState.NORMAL: {
const previouslyActive = this.activeBeforePresentationMode;
this.activeBeforePresentationMode = null;
this.switchTool(previouslyActive);
break;
}
}
});
}
}
export { CursorTool, PDFCursorTools };

View File

@@ -0,0 +1,263 @@
/* 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 { FindState } from './pdf_find_controller.js';
const MATCHES_COUNT_LIMIT = 1000;
/**
* Creates a "search bar" given a set of DOM elements that act as controls
* for searching or for setting search preferences in the UI. This object
* also sets up the appropriate events for the controls. Actual searching
* is done by PDFFindController.
*/
class PDFFindBar {
constructor(options, eventBus, l10n) {
this.opened = false;
this.bar = options.bar;
this.toggleButton = options.toggleButton;
this.findField = options.findField;
this.highlightAll = options.highlightAllCheckbox;
this.caseSensitive = options.caseSensitiveCheckbox;
this.matchDiacritics = options.matchDiacriticsCheckbox;
this.entireWord = options.entireWordCheckbox;
this.findMsg = options.findMsg;
this.findResultsCount = options.findResultsCount;
this.findPreviousButton = options.findPreviousButton;
this.findNextButton = options.findNextButton;
this.eventBus = eventBus;
this.l10n = l10n;
// Add event listeners to the DOM elements
for (let i = 0; i < this.toggleButton.length; i++) {
this.toggleButton[i].addEventListener('click', () => {
this.toggle(i);
});
}
for (let i = 0; i < this.findField.length; i++) {
this.findField[i].addEventListener('input', () => {
this.dispatchEvent([i], '');
});
}
for (let i = 0; i < this.bar.length; i++) {
window.parent.document.addEventListener('keydown', (e) => {
switch (e.keyCode) {
case 27: // Escape
this.close(i);
break;
}
});
document.addEventListener('keydown', (e) => {
switch (e.keyCode) {
case 13: // Enter
if (e.target === this.findField[i]) {
this.dispatchEvent([i], 'again', e.shiftKey);
}
break;
case 27: // Escape
this.close(i);
break;
}
});
}
for (let i = 0; i < this.findPreviousButton.length; i++) {
this.findPreviousButton[i].addEventListener('click', () => {
this.dispatchEvent([i], 'again', true);
});
}
for (let i = 0; i < this.findNextButton.length; i++) {
this.findNextButton[i].addEventListener('click', () => {
this.dispatchEvent([i], 'again', false);
});
}
for (let i = 0; i < this.highlightAll.length; i++) {
this.highlightAll[i].addEventListener('click', () => {
this.dispatchEvent([i], 'highlightallchange');
});
}
for (let i = 0; i < this.caseSensitive.length; i++) {
this.caseSensitive[i].addEventListener('click', () => {
this.dispatchEvent([i], 'casesensitivitychange');
});
}
for (let i = 0; i < this.entireWord.length; i++) {
this.entireWord[i].addEventListener('click', () => {
this.dispatchEvent([i], 'entirewordchange');
});
}
for (let i = 0; i < this.matchDiacritics.length; i++) {
this.matchDiacritics[i].addEventListener('click', () => {
this.dispatchEvent([i], 'diacriticmatchingchange');
});
}
this.eventBus._on('resize', this.#adjustWidth.bind(this));
}
reset() {
this.updateUIState();
}
dispatchEvent(index, type, findPrev = false) {
this.eventBus.dispatch('find', {
source: this,
type,
query: this.findField[index].value,
phraseSearch: true,
caseSensitive: this.caseSensitive[index].checked,
entireWord: this.entireWord[index].checked,
highlightAll: this.highlightAll[index].checked,
findPrevious: findPrev[index],
matchDiacritics: this.matchDiacritics[index].checked,
});
}
updateUIState(state, previous, matchesCount) {
let findMsg = Promise.resolve('');
let status = '';
switch (state) {
case FindState.FOUND:
break;
case FindState.PENDING:
status = 'pending';
break;
case FindState.NOT_FOUND:
findMsg = this.l10n.get('find_not_found');
status = 'notFound';
break;
case FindState.WRAPPED:
findMsg = this.l10n.get(
`find_reached_${previous ? 'top' : 'bottom'}`,
);
break;
}
for (let i = 0; i < this.findField.length; i++) {
this.findField[i].setAttribute('data-status', status);
this.findField[i].setAttribute(
'aria-invalid',
state === FindState.NOT_FOUND,
);
}
for (let i = 0; i < this.findMsg.length; i++) {
findMsg.then((msg) => {
this.findMsg[i].textContent = msg;
this.#adjustWidth();
});
this.updateResultsCount(matchesCount);
}
}
updateResultsCount({ current = 0, total = 0 } = {}) {
const limit = MATCHES_COUNT_LIMIT;
let matchCountMsg = Promise.resolve('');
if (total > 0) {
if (total > limit) {
let key = 'find_match_count_limit';
if (
typeof PDFJSDev !== 'undefined' &&
PDFJSDev.test('MOZCENTRAL')
) {
// TODO: Remove this hard-coded `[other]` form once plural support has
// been implemented in the mozilla-central specific `l10n.js` file.
key += '[other]';
}
matchCountMsg = this.l10n.get(key, { limit });
} else {
let key = 'find_match_count';
if (
typeof PDFJSDev !== 'undefined' &&
PDFJSDev.test('MOZCENTRAL')
) {
// TODO: Remove this hard-coded `[other]` form once plural support has
// been implemented in the mozilla-central specific `l10n.js` file.
key += '[other]';
}
matchCountMsg = this.l10n.get(key, { current, total });
}
}
matchCountMsg.then((msg) => {
for (let i = 0; i < this.findResultsCount.length; i++) {
this.findResultsCount[i].textContent = msg;
}
// Since `updateResultsCount` may be called from `PDFFindController`,
// ensure that the width of the findbar is always updated correctly.
this.#adjustWidth();
});
}
open(i) {
if (!this.opened) {
this.opened = true;
this.toggleButton[i].classList.add('toggled');
this.toggleButton[i].setAttribute('aria-expanded', 'true');
this.bar[i].classList.remove('hidden');
this.findField[i].select();
this.findField[i].focus();
}
this.#adjustWidth();
}
close(i) {
if (!this.opened) {
return;
}
this.opened = false;
this.toggleButton[i].classList.remove('toggled');
this.toggleButton[i].setAttribute('aria-expanded', 'false');
this.bar[i].classList.add('hidden');
this.eventBus.dispatch('findbarclose', { source: this });
}
toggle(i) {
if (this.opened) {
this.close(i);
} else {
this.open(i);
}
}
#adjustWidth() {
if (!this.opened) {
return;
}
for (let i = 0; i < this.bar.length; i++) {
// The find bar has an absolute position and thus the browser extends
// its width to the maximum possible width once the find bar does not fit
// entirely within the window anymore (and its elements are automatically
// wrapped). Here we detect and fix that.
this.bar[i].classList.remove('wrapContainers');
const findbarHeight = this.bar[i].clientHeight;
const inputContainerHeight =
this.bar[i].firstElementChild.clientHeight;
if (findbarHeight > inputContainerHeight) {
// The findbar is taller than the input container, which means that
// the browser wrapped some of the elements. For a consistent look,
// wrap all of them to adjust the width of the find bar.
this.bar[i].classList.add('wrapContainers');
}
}
}
}
export { PDFFindBar };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,115 @@
/* Copyright 2018 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.
*/
const CharacterType = {
SPACE: 0,
ALPHA_LETTER: 1,
PUNCT: 2,
HAN_LETTER: 3,
KATAKANA_LETTER: 4,
HIRAGANA_LETTER: 5,
HALFWIDTH_KATAKANA_LETTER: 6,
THAI_LETTER: 7,
};
function isAlphabeticalScript(charCode) {
return charCode < 0x2e80;
}
function isAscii(charCode) {
return (charCode & 0xff80) === 0;
}
function isAsciiAlpha(charCode) {
return (
(charCode >= /* a = */ 0x61 && charCode <= /* z = */ 0x7a) ||
(charCode >= /* A = */ 0x41 && charCode <= /* Z = */ 0x5a)
);
}
function isAsciiDigit(charCode) {
return charCode >= /* 0 = */ 0x30 && charCode <= /* 9 = */ 0x39;
}
function isAsciiSpace(charCode) {
return (
charCode === /* SPACE = */ 0x20 ||
charCode === /* TAB = */ 0x09 ||
charCode === /* CR = */ 0x0d ||
charCode === /* LF = */ 0x0a
);
}
function isHan(charCode) {
return (
(charCode >= 0x3400 && charCode <= 0x9fff) ||
(charCode >= 0xf900 && charCode <= 0xfaff)
);
}
function isKatakana(charCode) {
return charCode >= 0x30a0 && charCode <= 0x30ff;
}
function isHiragana(charCode) {
return charCode >= 0x3040 && charCode <= 0x309f;
}
function isHalfwidthKatakana(charCode) {
return charCode >= 0xff60 && charCode <= 0xff9f;
}
function isThai(charCode) {
return (charCode & 0xff80) === 0x0e00;
}
/**
* This function is based on the word-break detection implemented in:
* https://hg.mozilla.org/mozilla-central/file/tip/intl/lwbrk/WordBreaker.cpp
*/
function getCharacterType(charCode) {
if (isAlphabeticalScript(charCode)) {
if (isAscii(charCode)) {
if (isAsciiSpace(charCode)) {
return CharacterType.SPACE;
} else if (
isAsciiAlpha(charCode) ||
isAsciiDigit(charCode) ||
charCode === /* UNDERSCORE = */ 0x5f
) {
return CharacterType.ALPHA_LETTER;
}
return CharacterType.PUNCT;
} else if (isThai(charCode)) {
return CharacterType.THAI_LETTER;
} else if (charCode === /* NBSP = */ 0xa0) {
return CharacterType.SPACE;
}
return CharacterType.ALPHA_LETTER;
}
if (isHan(charCode)) {
return CharacterType.HAN_LETTER;
} else if (isKatakana(charCode)) {
return CharacterType.KATAKANA_LETTER;
} else if (isHiragana(charCode)) {
return CharacterType.HIRAGANA_LETTER;
} else if (isHalfwidthKatakana(charCode)) {
return CharacterType.HALFWIDTH_KATAKANA_LETTER;
}
return CharacterType.ALPHA_LETTER;
}
export { CharacterType, getCharacterType };

View File

@@ -0,0 +1,711 @@
/* Copyright 2015 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("./event_utils").EventBus} EventBus */
/** @typedef {import("./interfaces").IPDFLinkService} IPDFLinkService */
import { parseQueryString, removeNullCharacters } from './ui_utils.js';
const DEFAULT_LINK_REL = 'noopener noreferrer nofollow';
const LinkTarget = {
NONE: 0, // Default value.
SELF: 1,
BLANK: 2,
PARENT: 3,
TOP: 4,
};
/**
* @typedef {Object} ExternalLinkParameters
* @property {string} url - An absolute URL.
* @property {LinkTarget} [target] - The link target. The default value is
* `LinkTarget.NONE`.
* @property {string} [rel] - The link relationship. The default value is
* `DEFAULT_LINK_REL`.
* @property {boolean} [enabled] - Whether the link should be enabled. The
* default value is true.
*/
/**
* Adds various attributes (href, title, target, rel) to hyperlinks.
* @param {HTMLAnchorElement} link - The link element.
* @param {ExternalLinkParameters} params
*/
function addLinkAttributes(link, { url, target, rel, enabled = true } = {}) {
if (!url || typeof url !== 'string') {
throw new Error('A valid "url" parameter must provided.');
}
const urlNullRemoved = removeNullCharacters(url);
if (enabled) {
link.href = link.title = urlNullRemoved;
} else {
link.href = '';
link.title = `Disabled: ${urlNullRemoved}`;
link.onclick = () => {
return false;
};
}
let targetStr = ''; // LinkTarget.NONE
switch (target) {
case LinkTarget.NONE:
break;
case LinkTarget.SELF:
targetStr = '_self';
break;
case LinkTarget.BLANK:
targetStr = '_blank';
break;
case LinkTarget.PARENT:
targetStr = '_parent';
break;
case LinkTarget.TOP:
targetStr = '_top';
break;
}
link.target = targetStr;
link.rel = typeof rel === 'string' ? rel : DEFAULT_LINK_REL;
}
/**
* @typedef {Object} PDFLinkServiceOptions
* @property {EventBus} eventBus - The application event bus.
* @property {number} [externalLinkTarget] - Specifies the `target` attribute
* for external links. Must use one of the values from {LinkTarget}.
* Defaults to using no target.
* @property {string} [externalLinkRel] - Specifies the `rel` attribute for
* external links. Defaults to stripping the referrer.
* @property {boolean} [ignoreDestinationZoom] - Ignores the zoom argument,
* thus preserving the current zoom level in the viewer, when navigating
* to internal destinations. The default value is `false`.
*/
/**
* Performs navigation functions inside PDF, such as opening specified page,
* or destination.
* @implements {IPDFLinkService}
*/
class PDFLinkService {
#pagesRefCache = new Map();
/**
* @param {PDFLinkServiceOptions} options
*/
constructor({
eventBus,
externalLinkTarget = null,
externalLinkRel = null,
ignoreDestinationZoom = false,
} = {}) {
this.eventBus = eventBus;
this.externalLinkTarget = externalLinkTarget;
this.externalLinkRel = externalLinkRel;
this.externalLinkEnabled = true;
this._ignoreDestinationZoom = ignoreDestinationZoom;
this.baseUrl = null;
this.pdfDocument = null;
this.pdfViewer = null;
this.pdfHistory = null;
}
setDocument(pdfDocument, baseUrl = null) {
this.baseUrl = baseUrl;
this.pdfDocument = pdfDocument;
this.#pagesRefCache.clear();
}
setViewer(pdfViewer) {
this.pdfViewer = pdfViewer;
}
setHistory(pdfHistory) {
this.pdfHistory = pdfHistory;
}
/**
* @type {number}
*/
get pagesCount() {
return this.pdfDocument ? this.pdfDocument.numPages : 0;
}
/**
* @type {number}
*/
get page() {
return this.pdfViewer.currentPageNumber;
}
/**
* @param {number} value
*/
set page(value) {
this.pdfViewer.currentPageNumber = value;
}
/**
* @type {number}
*/
get rotation() {
return this.pdfViewer.pagesRotation;
}
/**
* @param {number} value
*/
set rotation(value) {
this.pdfViewer.pagesRotation = value;
}
#goToDestinationHelper(rawDest, namedDest = null, explicitDest) {
// Dest array looks like that: <page-ref> </XYZ|/FitXXX> <args..>
const destRef = explicitDest[0];
let pageNumber;
if (typeof destRef === 'object' && destRef !== null) {
pageNumber = this._cachedPageNumber(destRef);
if (!pageNumber) {
// Fetch the page reference if it's not yet available. This could
// only occur during loading, before all pages have been resolved.
this.pdfDocument
.getPageIndex(destRef)
.then((pageIndex) => {
this.cachePageRef(pageIndex + 1, destRef);
this.#goToDestinationHelper(
rawDest,
namedDest,
explicitDest,
);
})
.catch(() => {
console.error(
`PDFLinkService.#goToDestinationHelper: "${destRef}" is not ` +
`a valid page reference, for dest="${rawDest}".`,
);
});
return;
}
} else if (Number.isInteger(destRef)) {
pageNumber = destRef + 1;
} else {
console.error(
`PDFLinkService.#goToDestinationHelper: "${destRef}" is not ` +
`a valid destination reference, for dest="${rawDest}".`,
);
return;
}
if (!pageNumber || pageNumber < 1 || pageNumber > this.pagesCount) {
console.error(
`PDFLinkService.#goToDestinationHelper: "${pageNumber}" is not ` +
`a valid page number, for dest="${rawDest}".`,
);
return;
}
if (this.pdfHistory) {
// Update the browser history before scrolling the new destination into
// view, to be able to accurately capture the current document position.
this.pdfHistory.pushCurrentPosition();
this.pdfHistory.push({ namedDest, explicitDest, pageNumber });
}
this.pdfViewer.scrollPageIntoView({
pageNumber,
destArray: explicitDest,
ignoreDestinationZoom: this._ignoreDestinationZoom,
});
}
/**
* This method will, when available, also update the browser history.
*
* @param {string|Array} dest - The named, or explicit, PDF destination.
*/
async goToDestination(dest) {
if (!this.pdfDocument) {
return;
}
let namedDest, explicitDest;
if (typeof dest === 'string') {
namedDest = dest;
explicitDest = await this.pdfDocument.getDestination(dest);
} else {
namedDest = null;
explicitDest = await dest;
}
if (!Array.isArray(explicitDest)) {
console.error(
`PDFLinkService.goToDestination: "${explicitDest}" is not ` +
`a valid destination array, for dest="${dest}".`,
);
return;
}
this.#goToDestinationHelper(dest, namedDest, explicitDest);
}
/**
* This method will, when available, also update the browser history.
*
* @param {number|string} val - The page number, or page label.
*/
goToPage(val) {
if (!this.pdfDocument) {
return;
}
const pageNumber =
(typeof val === 'string' &&
this.pdfViewer.pageLabelToPageNumber(val)) ||
val | 0;
if (
!(
Number.isInteger(pageNumber) &&
pageNumber > 0 &&
pageNumber <= this.pagesCount
)
) {
console.error(
`PDFLinkService.goToPage: "${val}" is not a valid page.`,
);
return;
}
if (this.pdfHistory) {
// Update the browser history before scrolling the new page into view,
// to be able to accurately capture the current document position.
this.pdfHistory.pushCurrentPosition();
this.pdfHistory.pushPage(pageNumber);
}
this.pdfViewer.scrollPageIntoView({ pageNumber });
}
/**
* Wrapper around the `addLinkAttributes` helper function.
* @param {HTMLAnchorElement} link
* @param {string} url
* @param {boolean} [newWindow]
*/
addLinkAttributes(link, url, newWindow = false) {
addLinkAttributes(link, {
url,
target: newWindow ? LinkTarget.BLANK : this.externalLinkTarget,
rel: this.externalLinkRel,
enabled: this.externalLinkEnabled,
});
}
/**
* @param {string|Array} dest - The PDF destination object.
* @returns {string} The hyperlink to the PDF object.
*/
getDestinationHash(dest) {
if (typeof dest === 'string') {
if (dest.length > 0) {
return this.getAnchorUrl('#' + escape(dest));
}
} else if (Array.isArray(dest)) {
const str = JSON.stringify(dest);
if (str.length > 0) {
return this.getAnchorUrl('#' + escape(str));
}
}
return this.getAnchorUrl('');
}
/**
* Prefix the full url on anchor links to make sure that links are resolved
* relative to the current URL instead of the one defined in <base href>.
* @param {string} anchor - The anchor hash, including the #.
* @returns {string} The hyperlink to the PDF object.
*/
getAnchorUrl(anchor) {
return (this.baseUrl || '') + anchor;
}
/**
* @param {string} hash
*/
setHash(hash) {
if (!this.pdfDocument) {
return;
}
let pageNumber, dest;
if (hash.includes('=')) {
const params = parseQueryString(hash);
if (params.has('search')) {
this.eventBus.dispatch('findfromurlhash', {
source: this,
query: params.get('search').replace(/"/g, ''),
phraseSearch: params.get('phrase') === 'true',
});
}
// borrowing syntax from "Parameters for Opening PDF Files"
if (params.has('page')) {
pageNumber = params.get('page') | 0 || 1;
}
if (params.has('zoom')) {
// Build the destination array.
const zoomArgs = params.get('zoom').split(','); // scale,left,top
const zoomArg = zoomArgs[0];
const zoomArgNumber = parseFloat(zoomArg);
if (!zoomArg.includes('Fit')) {
// If the zoomArg is a number, it has to get divided by 100. If it's
// a string, it should stay as it is.
dest = [
null,
{ name: 'XYZ' },
zoomArgs.length > 1 ? zoomArgs[1] | 0 : null,
zoomArgs.length > 2 ? zoomArgs[2] | 0 : null,
zoomArgNumber ? zoomArgNumber / 100 : zoomArg,
];
} else {
if (zoomArg === 'Fit' || zoomArg === 'FitB') {
dest = [null, { name: zoomArg }];
} else if (
zoomArg === 'FitH' ||
zoomArg === 'FitBH' ||
zoomArg === 'FitV' ||
zoomArg === 'FitBV'
) {
dest = [
null,
{ name: zoomArg },
zoomArgs.length > 1 ? zoomArgs[1] | 0 : null,
];
} else if (zoomArg === 'FitR') {
if (zoomArgs.length !== 5) {
console.error(
'PDFLinkService.setHash: Not enough parameters for "FitR".',
);
} else {
dest = [
null,
{ name: zoomArg },
zoomArgs[1] | 0,
zoomArgs[2] | 0,
zoomArgs[3] | 0,
zoomArgs[4] | 0,
];
}
} else {
console.error(
`PDFLinkService.setHash: "${zoomArg}" is not a valid zoom value.`,
);
}
}
}
if (dest) {
this.pdfViewer.scrollPageIntoView({
pageNumber: pageNumber || this.page,
destArray: dest,
allowNegativeOffset: true,
});
} else if (pageNumber) {
this.page = pageNumber; // simple page
}
if (params.has('pagemode')) {
this.eventBus.dispatch('pagemode', {
source: this,
mode: params.get('pagemode'),
});
}
// Ensure that this parameter is *always* handled last, in order to
// guarantee that it won't be overridden (e.g. by the "page" parameter).
if (params.has('nameddest')) {
this.goToDestination(params.get('nameddest'));
}
} else {
// Named (or explicit) destination.
dest = unescape(hash);
try {
dest = JSON.parse(dest);
if (!Array.isArray(dest)) {
// Avoid incorrectly rejecting a valid named destination, such as
// e.g. "4.3" or "true", because `JSON.parse` converted its type.
dest = dest.toString();
}
} catch (ex) {}
if (
typeof dest === 'string' ||
PDFLinkService.#isValidExplicitDestination(dest)
) {
this.goToDestination(dest);
return;
}
console.error(
`PDFLinkService.setHash: "${unescape(
hash,
)}" is not a valid destination.`,
);
}
}
/**
* @param {string} action
*/
executeNamedAction(action) {
// See PDF reference, table 8.45 - Named action
switch (action) {
case 'GoBack':
this.pdfHistory?.back();
break;
case 'GoForward':
this.pdfHistory?.forward();
break;
case 'NextPage':
this.pdfViewer.nextPage();
break;
case 'PrevPage':
this.pdfViewer.previousPage();
break;
case 'LastPage':
this.page = this.pagesCount;
break;
case 'FirstPage':
this.page = 1;
break;
default:
break; // No action according to spec
}
this.eventBus.dispatch('namedaction', {
source: this,
action,
});
}
/**
* @param {number} pageNum - page number.
* @param {Object} pageRef - reference to the page.
*/
cachePageRef(pageNum, pageRef) {
if (!pageRef) {
return;
}
const refStr =
pageRef.gen === 0
? `${pageRef.num}R`
: `${pageRef.num}R${pageRef.gen}`;
this.#pagesRefCache.set(refStr, pageNum);
}
/**
* @ignore
*/
_cachedPageNumber(pageRef) {
if (!pageRef) {
return null;
}
const refStr =
pageRef.gen === 0
? `${pageRef.num}R`
: `${pageRef.num}R${pageRef.gen}`;
return this.#pagesRefCache.get(refStr) || null;
}
/**
* @param {number} pageNumber
*/
isPageVisible(pageNumber) {
return this.pdfViewer.isPageVisible(pageNumber);
}
/**
* @param {number} pageNumber
*/
isPageCached(pageNumber) {
return this.pdfViewer.isPageCached(pageNumber);
}
static #isValidExplicitDestination(dest) {
if (!Array.isArray(dest)) {
return false;
}
const destLength = dest.length;
if (destLength < 2) {
return false;
}
const page = dest[0];
if (
!(
typeof page === 'object' &&
Number.isInteger(page.num) &&
Number.isInteger(page.gen)
) &&
!(Number.isInteger(page) && page >= 0)
) {
return false;
}
const zoom = dest[1];
if (!(typeof zoom === 'object' && typeof zoom.name === 'string')) {
return false;
}
let allowNull = true;
switch (zoom.name) {
case 'XYZ':
if (destLength !== 5) {
return false;
}
break;
case 'Fit':
case 'FitB':
return destLength === 2;
case 'FitH':
case 'FitBH':
case 'FitV':
case 'FitBV':
if (destLength !== 3) {
return false;
}
break;
case 'FitR':
if (destLength !== 6) {
return false;
}
allowNull = false;
break;
default:
return false;
}
for (let i = 2; i < destLength; i++) {
const param = dest[i];
if (!(typeof param === 'number' || (allowNull && param === null))) {
return false;
}
}
return true;
}
}
/**
* @implements {IPDFLinkService}
*/
class SimpleLinkService {
constructor() {
this.externalLinkEnabled = true;
}
/**
* @type {number}
*/
get pagesCount() {
return 0;
}
/**
* @type {number}
*/
get page() {
return 0;
}
/**
* @param {number} value
*/
set page(value) {}
/**
* @type {number}
*/
get rotation() {
return 0;
}
/**
* @param {number} value
*/
set rotation(value) {}
/**
* @param {string|Array} dest - The named, or explicit, PDF destination.
*/
async goToDestination(dest) {}
/**
* @param {number|string} val - The page number, or page label.
*/
goToPage(val) {}
/**
* @param {HTMLAnchorElement} link
* @param {string} url
* @param {boolean} [newWindow]
*/
addLinkAttributes(link, url, newWindow = false) {
addLinkAttributes(link, { url, enabled: this.externalLinkEnabled });
}
/**
* @param dest - The PDF destination object.
* @returns {string} The hyperlink to the PDF object.
*/
getDestinationHash(dest) {
return '#';
}
/**
* @param hash - The PDF parameters/hash.
* @returns {string} The hyperlink to the PDF object.
*/
getAnchorUrl(hash) {
return '#';
}
/**
* @param {string} hash
*/
setHash(hash) {}
/**
* @param {string} action
*/
executeNamedAction(action) {}
/**
* @param {number} pageNum - page number.
* @param {Object} pageRef - reference to the page.
*/
cachePageRef(pageNum, pageRef) {}
/**
* @param {number} pageNumber
*/
isPageVisible(pageNumber) {
return true;
}
/**
* @param {number} pageNumber
*/
isPageCached(pageNumber) {
return true;
}
}
export { LinkTarget, PDFLinkService, SimpleLinkService };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,215 @@
/* 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.
*/
/** @typedef {import("./interfaces").IRenderableView} IRenderableView */
/** @typedef {import("./pdf_viewer").PDFViewer} PDFViewer */
// eslint-disable-next-line max-len
/** @typedef {import("./pdf_thumbnail_viewer").PDFThumbnailViewer} PDFThumbnailViewer */
import { RenderingCancelledException } from 'pdfjs-dist';
import { RenderingStates } from './ui_utils.js';
const CLEANUP_TIMEOUT = 30000;
/**
* Controls rendering of the views for pages and thumbnails.
*/
class PDFRenderingQueue {
constructor() {
this.pdfViewer = null;
this.pdfThumbnailViewer = null;
this.onIdle = null;
this.highestPriorityPage = null;
/** @type {number} */
this.idleTimeout = null;
this.printing = false;
this.isThumbnailViewEnabled = false;
}
/**
* @param {PDFViewer} pdfViewer
*/
setViewer(pdfViewer) {
this.pdfViewer = pdfViewer;
}
/**
* @param {PDFThumbnailViewer} pdfThumbnailViewer
*/
setThumbnailViewer(pdfThumbnailViewer) {
this.pdfThumbnailViewer = pdfThumbnailViewer;
}
/**
* @param {IRenderableView} view
* @returns {boolean}
*/
isHighestPriority(view) {
return this.highestPriorityPage === view.renderingId;
}
/**
* @returns {boolean}
*/
hasViewer() {
return !!this.pdfViewer;
}
/**
* @param {Object} currentlyVisiblePages
*/
renderHighestPriority(currentlyVisiblePages) {
if (this.idleTimeout) {
clearTimeout(this.idleTimeout);
this.idleTimeout = null;
}
// Pages have a higher priority than thumbnails, so check them first.
if (this.pdfViewer.forceRendering(currentlyVisiblePages)) {
return;
}
// No pages needed rendering, so check thumbnails.
if (
this.isThumbnailViewEnabled &&
this.pdfThumbnailViewer?.forceRendering()
) {
return;
}
if (this.printing) {
// If printing is currently ongoing do not reschedule cleanup.
return;
}
if (this.onIdle) {
this.idleTimeout = setTimeout(
this.onIdle.bind(this),
CLEANUP_TIMEOUT,
);
}
}
/**
* @param {Object} visible
* @param {Array} views
* @param {boolean} scrolledDown
* @param {boolean} [preRenderExtra]
*/
getHighestPriority(visible, views, scrolledDown, preRenderExtra = 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
* if last scrolled up, the page before the visible pages
*/
const visibleViews = visible.views,
numVisible = visibleViews.length;
if (numVisible === 0) {
return null;
}
for (let i = 0; i < numVisible; i++) {
const view = visibleViews[i].view;
if (!this.isViewFinished(view)) {
return view;
}
}
const firstId = visible.first.id,
lastId = visible.last.id;
// All the visible views have rendered; try to handle any "holes" in the
// page layout (can happen e.g. with spreadModes at higher zoom levels).
if (lastId - firstId + 1 > numVisible) {
const visibleIds = visible.ids;
for (let i = 1, ii = lastId - firstId; i < ii; i++) {
const holeId = scrolledDown ? firstId + i : lastId - i;
if (visibleIds.has(holeId)) {
continue;
}
const holeView = views[holeId - 1];
if (!this.isViewFinished(holeView)) {
return holeView;
}
}
}
// All the visible views have rendered; try to render next/previous page.
// (IDs start at 1, so no need to add 1 when `scrolledDown === true`.)
let preRenderIndex = scrolledDown ? lastId : firstId - 2;
let preRenderView = views[preRenderIndex];
if (preRenderView && !this.isViewFinished(preRenderView)) {
return preRenderView;
}
if (preRenderExtra) {
preRenderIndex += scrolledDown ? 1 : -1;
preRenderView = views[preRenderIndex];
if (preRenderView && !this.isViewFinished(preRenderView)) {
return preRenderView;
}
}
// Everything that needs to be rendered has been.
return null;
}
/**
* @param {IRenderableView} view
* @returns {boolean}
*/
isViewFinished(view) {
return view.renderingState === RenderingStates.FINISHED;
}
/**
* Render a page or thumbnail view. This calls the appropriate function
* based on the views state. If the view is already rendered it will return
* `false`.
*
* @param {IRenderableView} view
*/
renderView(view) {
switch (view.renderingState) {
case RenderingStates.FINISHED:
return false;
case RenderingStates.PAUSED:
this.highestPriorityPage = view.renderingId;
view.resume();
break;
case RenderingStates.RUNNING:
this.highestPriorityPage = view.renderingId;
break;
case RenderingStates.INITIAL:
this.highestPriorityPage = view.renderingId;
view.draw()
.finally(() => {
this.renderHighestPriority();
})
.catch((reason) => {
if (reason instanceof RenderingCancelledException) {
return;
}
console.error(`renderView: "${reason}"`);
});
break;
}
return true;
}
}
export { PDFRenderingQueue };

View File

@@ -0,0 +1,354 @@
/* 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 { ScrollMode, SpreadMode } from './ui_utils.js';
import { CursorTool } from './pdf_cursor_tools.js';
import { PagesCountLimit } from './base_viewer.js';
/**
* @typedef {Object} SecondaryToolbarOptions
* @property {HTMLDivElement} toolbar - Container for the secondary toolbar.
* @property {HTMLButtonElement} toggleButton - Button to toggle the visibility
* of the secondary toolbar.
* @property {HTMLButtonElement} presentationModeButton - Button for entering
* presentation mode.
* @property {HTMLButtonElement} openFileButton - Button to open a file.
* @property {HTMLButtonElement} printButton - Button to print the document.
* @property {HTMLButtonElement} downloadButton - Button to download the
* document.
* @property {HTMLAnchorElement} viewBookmarkButton - Button to obtain a
* bookmark link to the current location in the document.
* @property {HTMLButtonElement} firstPageButton - Button to go to the first
* page in the document.
* @property {HTMLButtonElement} lastPageButton - Button to go to the last page
* in the document.
* @property {HTMLButtonElement} pageRotateCwButton - Button to rotate the pages
* clockwise.
* @property {HTMLButtonElement} pageRotateCcwButton - Button to rotate the
* pages counterclockwise.
* @property {HTMLButtonElement} cursorSelectToolButton - Button to enable the
* select tool.
* @property {HTMLButtonElement} cursorHandToolButton - Button to enable the
* hand tool.
* @property {HTMLButtonElement} documentPropertiesButton - Button for opening
* the document properties dialog.
*/
class SecondaryToolbar {
/**
* @param {SecondaryToolbarOptions} options
* @param {EventBus} eventBus
*/
constructor(options, eventBus) {
this.toolbar = options.toolbar;
this.toggleButton = options.toggleButton;
this.buttons = [
{
element: options.presentationModeButton,
eventName: 'presentationmode',
close: true,
},
{ element: options.printButton, eventName: 'print', close: true },
{
element: options.downloadButton,
eventName: 'download',
close: true,
},
{
element: options.viewBookmarkButton,
eventName: null,
close: true,
},
{
element: options.firstPageButton,
eventName: 'firstpage',
close: true,
},
{
element: options.lastPageButton,
eventName: 'lastpage',
close: true,
},
{
element: options.pageRotateCwButton,
eventName: 'rotatecw',
close: false,
},
{
element: options.pageRotateCcwButton,
eventName: 'rotateccw',
close: false,
},
{
element: options.cursorSelectToolButton,
eventName: 'switchcursortool',
eventDetails: { tool: CursorTool.SELECT },
close: true,
},
{
element: options.cursorHandToolButton,
eventName: 'switchcursortool',
eventDetails: { tool: CursorTool.HAND },
close: true,
},
{
element: options.scrollPageButton,
eventName: 'switchscrollmode',
eventDetails: { mode: ScrollMode.PAGE },
close: true,
},
{
element: options.scrollVerticalButton,
eventName: 'switchscrollmode',
eventDetails: { mode: ScrollMode.VERTICAL },
close: true,
},
{
element: options.scrollHorizontalButton,
eventName: 'switchscrollmode',
eventDetails: { mode: ScrollMode.HORIZONTAL },
close: true,
},
{
element: options.scrollWrappedButton,
eventName: 'switchscrollmode',
eventDetails: { mode: ScrollMode.WRAPPED },
close: true,
},
{
element: options.spreadNoneButton,
eventName: 'switchspreadmode',
eventDetails: { mode: SpreadMode.NONE },
close: true,
},
{
element: options.spreadOddButton,
eventName: 'switchspreadmode',
eventDetails: { mode: SpreadMode.ODD },
close: true,
},
{
element: options.spreadEvenButton,
eventName: 'switchspreadmode',
eventDetails: { mode: SpreadMode.EVEN },
close: true,
},
{
element: options.documentPropertiesButton,
eventName: 'documentproperties',
close: true,
},
];
if (typeof PDFJSDev === 'undefined' || PDFJSDev.test('GENERIC')) {
this.buttons.push({
element: options.openFileButton,
eventName: 'openfile',
close: true,
});
}
this.items = {
firstPage: options.firstPageButton,
lastPage: options.lastPageButton,
pageRotateCw: options.pageRotateCwButton,
pageRotateCcw: options.pageRotateCcwButton,
};
this.eventBus = eventBus;
this.opened = false;
// Bind the event listeners for click, cursor tool, and scroll/spread mode
// actions.
this.#bindClickListeners();
this.#bindCursorToolsListener(options);
this.#bindScrollModeListener(options);
this.#bindSpreadModeListener(options);
this.reset();
}
/**
* @type {boolean}
*/
get isOpen() {
return this.opened;
}
setPageNumber(pageNumber) {
this.pageNumber = pageNumber;
this.#updateUIState();
}
setPagesCount(pagesCount) {
this.pagesCount = pagesCount;
this.#updateUIState();
}
reset() {
this.pageNumber = 0;
this.pagesCount = 0;
this.#updateUIState();
// Reset the Scroll/Spread buttons too, since they're document specific.
this.eventBus.dispatch('secondarytoolbarreset', { source: this });
}
#updateUIState() {
this.items.firstPage.disabled = this.pageNumber <= 1;
this.items.lastPage.disabled = this.pageNumber >= this.pagesCount;
this.items.pageRotateCw.disabled = this.pagesCount === 0;
this.items.pageRotateCcw.disabled = this.pagesCount === 0;
}
#bindClickListeners() {
// Button to toggle the visibility of the secondary toolbar.
this.toggleButton.addEventListener('click', this.toggle.bind(this));
// All items within the secondary toolbar.
for (const { element, eventName, close, eventDetails } of this
.buttons) {
element.addEventListener('click', (evt) => {
if (eventName !== null) {
const details = { source: this };
for (const property in eventDetails) {
details[property] = eventDetails[property];
}
this.eventBus.dispatch(eventName, details);
}
if (close) {
this.close();
}
});
}
}
#bindCursorToolsListener({ cursorSelectToolButton, cursorHandToolButton }) {
this.eventBus._on('cursortoolchanged', function ({ tool }) {
const isSelect = tool === CursorTool.SELECT,
isHand = tool === CursorTool.HAND;
cursorSelectToolButton.classList.toggle('toggled', isSelect);
cursorHandToolButton.classList.toggle('toggled', isHand);
cursorSelectToolButton.setAttribute('aria-checked', isSelect);
cursorHandToolButton.setAttribute('aria-checked', isHand);
});
}
#bindScrollModeListener({
scrollPageButton,
scrollVerticalButton,
scrollHorizontalButton,
scrollWrappedButton,
spreadNoneButton,
spreadOddButton,
spreadEvenButton,
}) {
const scrollModeChanged = ({ mode }) => {
const isPage = mode === ScrollMode.PAGE,
isVertical = mode === ScrollMode.VERTICAL,
isHorizontal = mode === ScrollMode.HORIZONTAL,
isWrapped = mode === ScrollMode.WRAPPED;
scrollPageButton.classList.toggle('toggled', isPage);
scrollVerticalButton.classList.toggle('toggled', isVertical);
scrollHorizontalButton.classList.toggle('toggled', isHorizontal);
scrollWrappedButton.classList.toggle('toggled', isWrapped);
scrollPageButton.setAttribute('aria-checked', isPage);
scrollVerticalButton.setAttribute('aria-checked', isVertical);
scrollHorizontalButton.setAttribute('aria-checked', isHorizontal);
scrollWrappedButton.setAttribute('aria-checked', isWrapped);
// Permanently *disable* the Scroll buttons when PAGE-scrolling is being
// enforced for *very* long/large documents; please see the `BaseViewer`.
const forceScrollModePage =
this.pagesCount > PagesCountLimit.FORCE_SCROLL_MODE_PAGE;
scrollPageButton.disabled = forceScrollModePage;
scrollVerticalButton.disabled = forceScrollModePage;
scrollHorizontalButton.disabled = forceScrollModePage;
scrollWrappedButton.disabled = forceScrollModePage;
// Temporarily *disable* the Spread buttons when horizontal scrolling is
// enabled, since the non-default Spread modes doesn't affect the layout.
spreadNoneButton.disabled = isHorizontal;
spreadOddButton.disabled = isHorizontal;
spreadEvenButton.disabled = isHorizontal;
};
this.eventBus._on('scrollmodechanged', scrollModeChanged);
this.eventBus._on('secondarytoolbarreset', (evt) => {
if (evt.source === this) {
scrollModeChanged({ mode: ScrollMode.VERTICAL });
}
});
}
#bindSpreadModeListener({
spreadNoneButton,
spreadOddButton,
spreadEvenButton,
}) {
function spreadModeChanged({ mode }) {
const isNone = mode === SpreadMode.NONE,
isOdd = mode === SpreadMode.ODD,
isEven = mode === SpreadMode.EVEN;
spreadNoneButton.classList.toggle('toggled', isNone);
spreadOddButton.classList.toggle('toggled', isOdd);
spreadEvenButton.classList.toggle('toggled', isEven);
spreadNoneButton.setAttribute('aria-checked', isNone);
spreadOddButton.setAttribute('aria-checked', isOdd);
spreadEvenButton.setAttribute('aria-checked', isEven);
}
this.eventBus._on('spreadmodechanged', spreadModeChanged);
this.eventBus._on('secondarytoolbarreset', (evt) => {
if (evt.source === this) {
spreadModeChanged({ mode: SpreadMode.NONE });
}
});
}
open() {
if (this.opened) {
return;
}
this.opened = true;
this.toggleButton.classList.add('toggled');
this.toggleButton.setAttribute('aria-expanded', 'true');
this.toolbar.classList.remove('hidden');
}
close() {
if (!this.opened) {
return;
}
this.opened = false;
this.toolbar.classList.add('hidden');
this.toggleButton.classList.remove('toggled');
this.toggleButton.setAttribute('aria-expanded', 'false');
}
toggle() {
if (this.opened) {
this.close();
} else {
this.open();
}
}
}
export { SecondaryToolbar };

View File

@@ -0,0 +1,139 @@
/* Copyright 2021 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 */
const PDF_ROLE_TO_HTML_ROLE = {
// Document level structure types
Document: null, // There's a "document" role, but it doesn't make sense here.
DocumentFragment: null,
// Grouping level structure types
Part: 'group',
Sect: 'group', // XXX: There's a "section" role, but it's abstract.
Div: 'group',
Aside: 'note',
NonStruct: 'none',
// Block level structure types
P: null,
// H<n>,
H: 'heading',
Title: null,
FENote: 'note',
// Sub-block level structure type
Sub: 'group',
// General inline level structure types
Lbl: null,
Span: null,
Em: null,
Strong: null,
Link: 'link',
Annot: 'note',
Form: 'form',
// Ruby and Warichu structure types
Ruby: null,
RB: null,
RT: null,
RP: null,
Warichu: null,
WT: null,
WP: null,
// List standard structure types
L: 'list',
LI: 'listitem',
LBody: null,
// Table standard structure types
Table: 'table',
TR: 'row',
TH: 'columnheader',
TD: 'cell',
THead: 'columnheader',
TBody: null,
TFoot: null,
// Standard structure type Caption
Caption: null,
// Standard structure type Figure
Figure: 'figure',
// Standard structure type Formula
Formula: null,
// standard structure type Artifact
Artifact: null,
};
const HEADING_PATTERN = /^H(\d+)$/;
/**
* @typedef {Object} StructTreeLayerBuilderOptions
* @property {PDFPageProxy} pdfPage
*/
class StructTreeLayerBuilder {
/**
* @param {StructTreeLayerBuilderOptions} options
*/
constructor({ pdfPage }) {
this.pdfPage = pdfPage;
}
render(structTree) {
return this._walk(structTree);
}
_setAttributes(structElement, htmlElement) {
if (structElement.alt !== undefined) {
htmlElement.setAttribute('aria-label', structElement.alt);
}
if (structElement.id !== undefined) {
htmlElement.setAttribute('aria-owns', structElement.id);
}
if (structElement.lang !== undefined) {
htmlElement.setAttribute('lang', structElement.lang);
}
}
_walk(node) {
if (!node) {
return null;
}
const element = document.createElement('span');
if ('role' in node) {
const { role } = node;
const match = role.match(HEADING_PATTERN);
if (match) {
element.setAttribute('role', 'heading');
element.setAttribute('aria-level', match[1]);
} else if (PDF_ROLE_TO_HTML_ROLE[role]) {
element.setAttribute('role', PDF_ROLE_TO_HTML_ROLE[role]);
}
}
this._setAttributes(node, element);
if (node.children) {
if (node.children.length === 1 && 'id' in node.children[0]) {
// Often there is only one content node so just set the values on the
// parent node to avoid creating an extra span.
this._setAttributes(node.children[0], element);
} else {
for (const kid of node.children) {
element.append(this._walk(kid));
}
}
}
return element;
}
}
export { StructTreeLayerBuilder };

View File

@@ -0,0 +1,258 @@
/* Copyright 2022 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 { binarySearchFirstItem } from './ui_utils.js';
/**
* This class aims to provide some methods:
* - to reorder elements in the DOM with respect to the visual order;
* - to create a link, using aria-owns, between spans in the textLayer and
* annotations in the annotationLayer. The goal is to help to know
* where the annotations are in the text flow.
*/
class TextAccessibilityManager {
#enabled = false;
#textChildren = null;
#textNodes = new Map();
#waitingElements = new Map();
setTextMapping(textDivs) {
this.#textChildren = textDivs;
}
/**
* Compare the positions of two elements, it must correspond to
* the visual ordering.
*
* @param {HTMLElement} e1
* @param {HTMLElement} e2
* @returns {number}
*/
static #compareElementPositions(e1, e2) {
const rect1 = e1.getBoundingClientRect();
const rect2 = e2.getBoundingClientRect();
if (rect1.width === 0 && rect1.height === 0) {
return +1;
}
if (rect2.width === 0 && rect2.height === 0) {
return -1;
}
const top1 = rect1.y;
const bot1 = rect1.y + rect1.height;
const mid1 = rect1.y + rect1.height / 2;
const top2 = rect2.y;
const bot2 = rect2.y + rect2.height;
const mid2 = rect2.y + rect2.height / 2;
if (mid1 <= top2 && mid2 >= bot1) {
return -1;
}
if (mid2 <= top1 && mid1 >= bot2) {
return +1;
}
const centerX1 = rect1.x + rect1.width / 2;
const centerX2 = rect2.x + rect2.width / 2;
return centerX1 - centerX2;
}
/**
* Function called when the text layer has finished rendering.
*/
enable() {
if (this.#enabled) {
throw new Error('TextAccessibilityManager is already enabled.');
}
if (!this.#textChildren) {
throw new Error('Text divs and strings have not been set.');
}
this.#enabled = true;
this.#textChildren = this.#textChildren.slice();
this.#textChildren.sort(
TextAccessibilityManager.#compareElementPositions,
);
if (this.#textNodes.size > 0) {
// Some links have been made before this manager has been disabled, hence
// we restore them.
const textChildren = this.#textChildren;
for (const [id, nodeIndex] of this.#textNodes) {
const element = document.getElementById(id);
if (!element) {
// If the page was *fully* reset the element no longer exists, and it
// will be re-inserted later (i.e. when the annotationLayer renders).
this.#textNodes.delete(id);
continue;
}
this.#addIdToAriaOwns(id, textChildren[nodeIndex]);
}
}
for (const [element, isRemovable] of this.#waitingElements) {
this.addPointerInTextLayer(element, isRemovable);
}
this.#waitingElements.clear();
}
disable() {
if (!this.#enabled) {
return;
}
// Don't clear this.#textNodes which is used to rebuild the aria-owns
// in case it's re-enabled at some point.
this.#waitingElements.clear();
this.#textChildren = null;
this.#enabled = false;
}
/**
* Remove an aria-owns id from a node in the text layer.
* @param {HTMLElement} element
*/
removePointerInTextLayer(element) {
if (!this.#enabled) {
this.#waitingElements.delete(element);
return;
}
const children = this.#textChildren;
if (!children || children.length === 0) {
return;
}
const { id } = element;
const nodeIndex = this.#textNodes.get(id);
if (nodeIndex === undefined) {
return;
}
const node = children[nodeIndex];
this.#textNodes.delete(id);
let owns = node.getAttribute('aria-owns');
if (owns?.includes(id)) {
owns = owns
.split(' ')
.filter((x) => x !== id)
.join(' ');
if (owns) {
node.setAttribute('aria-owns', owns);
} else {
node.removeAttribute('aria-owns');
node.setAttribute('role', 'presentation');
}
}
}
#addIdToAriaOwns(id, node) {
const owns = node.getAttribute('aria-owns');
if (!owns?.includes(id)) {
node.setAttribute('aria-owns', owns ? `${owns} ${id}` : id);
}
node.removeAttribute('role');
}
/**
* Find the text node which is the nearest and add an aria-owns attribute
* in order to correctly position this editor in the text flow.
* @param {HTMLElement} element
* @param {boolean} isRemovable
*/
addPointerInTextLayer(element, isRemovable) {
const { id } = element;
if (!id) {
return;
}
if (!this.#enabled) {
// The text layer needs to be there, so we postpone the association.
this.#waitingElements.set(element, isRemovable);
return;
}
if (isRemovable) {
this.removePointerInTextLayer(element);
}
const children = this.#textChildren;
if (!children || children.length === 0) {
return;
}
const index = binarySearchFirstItem(
children,
(node) =>
TextAccessibilityManager.#compareElementPositions(
element,
node,
) < 0,
);
const nodeIndex = Math.max(0, index - 1);
this.#addIdToAriaOwns(id, children[nodeIndex]);
this.#textNodes.set(id, nodeIndex);
}
/**
* Move a div in the DOM in order to respect the visual order.
* @param {HTMLDivElement} element
*/
moveElementInDOM(container, element, contentElement, isRemovable) {
this.addPointerInTextLayer(contentElement, isRemovable);
if (!container.hasChildNodes()) {
container.append(element);
return;
}
const children = Array.from(container.childNodes).filter(
(node) => node !== element,
);
if (children.length === 0) {
return;
}
const elementToCompare = contentElement || element;
const index = binarySearchFirstItem(
children,
(node) =>
TextAccessibilityManager.#compareElementPositions(
elementToCompare,
node,
) < 0,
);
if (index === 0) {
children[0].before(element);
} else {
children[index - 1].after(element);
}
}
}
export { TextAccessibilityManager };

View File

@@ -0,0 +1,316 @@
/* Copyright 2021 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("./event_utils").EventBus} EventBus */
// eslint-disable-next-line max-len
/** @typedef {import("./pdf_find_controller").PDFFindController} PDFFindController */
/**
* @typedef {Object} TextHighlighterOptions
* @property {PDFFindController} findController
* @property {EventBus} eventBus - The application event bus.
* @property {number} pageIndex - The page index.
*/
/**
* TextHighlighter handles highlighting matches from the FindController in
* either the text layer or XFA layer depending on the type of document.
*/
class TextHighlighter {
/**
* @param {TextHighlighterOptions} options
*/
constructor({ findController, eventBus, pageIndex }) {
this.findController = findController;
this.matches = [];
this.eventBus = eventBus;
this.pageIdx = pageIndex;
this._onUpdateTextLayerMatches = null;
this.textDivs = null;
this.textContentItemsStr = null;
this.enabled = false;
}
/**
* Store two arrays that will map DOM nodes to text they should contain.
* The arrays should be of equal length and the array element at each index
* should correspond to the other. e.g.
* `items[0] = "<span>Item 0</span>" and texts[0] = "Item 0";
*
* @param {Array<Node>} divs
* @param {Array<string>} texts
*/
setTextMapping(divs, texts) {
this.textDivs = divs;
this.textContentItemsStr = texts;
}
/**
* Start listening for events to update the highlighter and check if there are
* any current matches that need be highlighted.
*/
enable() {
if (!this.textDivs || !this.textContentItemsStr) {
throw new Error('Text divs and strings have not been set.');
}
if (this.enabled) {
throw new Error('TextHighlighter is already enabled.');
}
this.enabled = true;
if (!this._onUpdateTextLayerMatches) {
this._onUpdateTextLayerMatches = (evt) => {
if (evt.pageIndex === this.pageIdx || evt.pageIndex === -1) {
this._updateMatches();
}
};
this.eventBus._on(
'updatetextlayermatches',
this._onUpdateTextLayerMatches,
);
}
this._updateMatches();
}
disable() {
if (!this.enabled) {
return;
}
this.enabled = false;
if (this._onUpdateTextLayerMatches) {
this.eventBus._off(
'updatetextlayermatches',
this._onUpdateTextLayerMatches,
);
this._onUpdateTextLayerMatches = null;
}
}
_convertMatches(matches, matchesLength) {
// Early exit if there is nothing to convert.
if (!matches) {
return [];
}
const { textContentItemsStr } = this;
let i = 0,
iIndex = 0;
const end = textContentItemsStr.length - 1;
const result = [];
for (let m = 0, mm = matches.length; m < mm; m++) {
// Calculate the start position.
let matchIdx = matches[m];
// Loop over the divIdxs.
while (
i !== end &&
matchIdx >= iIndex + textContentItemsStr[i].length
) {
iIndex += textContentItemsStr[i].length;
i++;
}
if (i === textContentItemsStr.length) {
console.error('Could not find a matching mapping');
}
const match = {
begin: {
divIdx: i,
offset: matchIdx - iIndex,
},
};
// Calculate the end position.
matchIdx += matchesLength[m];
// Somewhat the same array as above, but use > instead of >= to get
// the end position right.
while (
i !== end &&
matchIdx > iIndex + textContentItemsStr[i].length
) {
iIndex += textContentItemsStr[i].length;
i++;
}
match.end = {
divIdx: i,
offset: matchIdx - iIndex,
};
result.push(match);
}
return result;
}
_renderMatches(matches) {
// Early exit if there is nothing to render.
if (matches.length === 0) {
return;
}
const { findController, pageIdx } = this;
const { textContentItemsStr, textDivs } = this;
const isSelectedPage = pageIdx === findController.selected.pageIdx;
const selectedMatchIdx = findController.selected.matchIdx;
const highlightAll = findController.state.highlightAll;
let prevEnd = null;
const infinity = {
divIdx: -1,
offset: undefined,
};
function beginText(begin, className) {
const divIdx = begin.divIdx;
textDivs[divIdx].textContent = '';
return appendTextToDiv(divIdx, 0, begin.offset, className);
}
function appendTextToDiv(divIdx, fromOffset, toOffset, className) {
let div = textDivs[divIdx];
if (div.nodeType === Node.TEXT_NODE) {
const span = document.createElement('span');
div.before(span);
span.append(div);
textDivs[divIdx] = span;
div = span;
}
const content = textContentItemsStr[divIdx].substring(
fromOffset,
toOffset,
);
const node = document.createTextNode(content);
if (className) {
const span = document.createElement('span');
span.className = `${className} appended`;
span.append(node);
div.append(span);
return className.includes('selected') ? span.offsetLeft : 0;
}
div.append(node);
return 0;
}
let i0 = selectedMatchIdx,
i1 = i0 + 1;
if (highlightAll) {
i0 = 0;
i1 = matches.length;
} else if (!isSelectedPage) {
// Not highlighting all and this isn't the selected page, so do nothing.
return;
}
for (let i = i0; i < i1; i++) {
const match = matches[i];
const begin = match.begin;
const end = match.end;
const isSelected = isSelectedPage && i === selectedMatchIdx;
const highlightSuffix = isSelected ? ' selected' : '';
let selectedLeft = 0;
// Match inside new div.
if (!prevEnd || begin.divIdx !== prevEnd.divIdx) {
// If there was a previous div, then add the text at the end.
if (prevEnd !== null) {
appendTextToDiv(
prevEnd.divIdx,
prevEnd.offset,
infinity.offset,
);
}
// Clear the divs and set the content until the starting point.
beginText(begin);
} else {
appendTextToDiv(prevEnd.divIdx, prevEnd.offset, begin.offset);
}
if (begin.divIdx === end.divIdx) {
selectedLeft = appendTextToDiv(
begin.divIdx,
begin.offset,
end.offset,
'highlight' + highlightSuffix,
);
} else {
selectedLeft = appendTextToDiv(
begin.divIdx,
begin.offset,
infinity.offset,
'highlight begin' + highlightSuffix,
);
for (
let n0 = begin.divIdx + 1, n1 = end.divIdx;
n0 < n1;
n0++
) {
textDivs[n0].className =
'highlight middle' + highlightSuffix;
}
beginText(end, 'highlight end' + highlightSuffix);
}
prevEnd = end;
if (isSelected) {
// Attempt to scroll the selected match into view.
findController.scrollMatchIntoView({
element: textDivs[begin.divIdx],
selectedLeft,
pageIndex: pageIdx,
matchIndex: selectedMatchIdx,
});
}
}
if (prevEnd) {
appendTextToDiv(prevEnd.divIdx, prevEnd.offset, infinity.offset);
}
}
_updateMatches() {
if (!this.enabled) {
return;
}
const { findController, matches, pageIdx } = this;
const { textContentItemsStr, textDivs } = this;
let clearedUntilDivIdx = -1;
// Clear all current matches.
for (let i = 0, ii = matches.length; i < ii; i++) {
const match = matches[i];
const begin = Math.max(clearedUntilDivIdx, match.begin.divIdx);
for (let n = begin, end = match.end.divIdx; n <= end; n++) {
const div = textDivs[n];
div.textContent = textContentItemsStr[n];
div.className = '';
}
clearedUntilDivIdx = match.end.divIdx + 1;
}
if (!findController?.highlightMatches) {
return;
}
// Convert the matches on the `findController` into the match format
// used for the textLayer.
const pageMatches = findController.pageMatches[pageIdx] || null;
const pageMatchesLength =
findController.pageMatchesLength[pageIdx] || null;
this.matches = this._convertMatches(pageMatches, pageMatchesLength);
this._renderMatches(this.matches);
}
}
export { TextHighlighter };

View File

@@ -0,0 +1,252 @@
/* 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.
*/
// eslint-disable-next-line max-len
/** @typedef {import("../src/display/display_utils").PageViewport} PageViewport */
/** @typedef {import("./event_utils").EventBus} EventBus */
/** @typedef {import("./text_highlighter").TextHighlighter} TextHighlighter */
// eslint-disable-next-line max-len
/** @typedef {import("./text_accessibility.js").TextAccessibilityManager} TextAccessibilityManager */
import { renderTextLayer } from 'pdfjs-dist';
const EXPAND_DIVS_TIMEOUT = 300; // ms
/**
* @typedef {Object} TextLayerBuilderOptions
* @property {HTMLDivElement} textLayerDiv - The text layer container.
* @property {EventBus} eventBus - The application event bus.
* @property {number} pageIndex - The page index.
* @property {PageViewport} viewport - The viewport of the text layer.
* @property {TextHighlighter} highlighter - Optional object that will handle
* highlighting text from the find controller.
* @property {boolean} enhanceTextSelection - Option to turn on improved
* text selection.
* @property {TextAccessibilityManager} [accessibilityManager]
*/
/**
* The text layer builder provides text selection functionality for the PDF.
* It does this by creating overlay divs over the PDF's text. These divs
* contain text that matches the PDF text they are overlaying.
*/
class TextLayerBuilder {
constructor({
textLayerDiv,
eventBus,
pageIndex,
viewport,
highlighter = null,
enhanceTextSelection = false,
accessibilityManager = null,
}) {
this.textLayerDiv = textLayerDiv;
this.eventBus = eventBus;
this.textContent = null;
this.textContentItemsStr = [];
this.textContentStream = null;
this.renderingDone = false;
this.pageNumber = pageIndex + 1;
this.viewport = viewport;
this.textDivs = [];
this.textLayerRenderTask = null;
this.highlighter = highlighter;
this.enhanceTextSelection = enhanceTextSelection;
this.accessibilityManager = accessibilityManager;
this._bindMouse();
}
/**
* @private
*/
_finishRendering() {
this.renderingDone = true;
if (!this.enhanceTextSelection) {
const endOfContent = document.createElement('div');
endOfContent.className = 'endOfContent';
this.textLayerDiv.append(endOfContent);
}
this.eventBus.dispatch('textlayerrendered', {
source: this,
pageNumber: this.pageNumber,
numTextDivs: this.textDivs.length,
});
}
/**
* Renders the text layer.
*
* @param {number} [timeout] - Wait for a specified amount of milliseconds
* before rendering.
*/
render(timeout = 0) {
if (
!(this.textContent || this.textContentStream) ||
this.renderingDone
) {
return;
}
this.cancel();
this.textDivs.length = 0;
this.highlighter?.setTextMapping(
this.textDivs,
this.textContentItemsStr,
);
this.accessibilityManager?.setTextMapping(this.textDivs);
const textLayerFrag = document.createDocumentFragment();
this.textLayerRenderTask = renderTextLayer({
textContent: this.textContent,
textContentStream: this.textContentStream,
container: textLayerFrag,
viewport: this.viewport,
textDivs: this.textDivs,
textContentItemsStr: this.textContentItemsStr,
timeout,
enhanceTextSelection: this.enhanceTextSelection,
});
this.textLayerRenderTask.promise.then(
() => {
this.textLayerDiv.append(textLayerFrag);
this._finishRendering();
this.highlighter?.enable();
this.accessibilityManager?.enable();
},
function (reason) {
// Cancelled or failed to render text layer; skipping errors.
},
);
}
/**
* Cancel rendering of the text layer.
*/
cancel() {
if (this.textLayerRenderTask) {
this.textLayerRenderTask.cancel();
this.textLayerRenderTask = null;
}
this.highlighter?.disable();
this.accessibilityManager?.disable();
}
setTextContentStream(readableStream) {
this.cancel();
this.textContentStream = readableStream;
}
setTextContent(textContent) {
this.cancel();
this.textContent = textContent;
}
/**
* Improves text selection by adding an additional div where the mouse was
* clicked. This reduces flickering of the content if the mouse is slowly
* dragged up or down.
*
* @private
*/
_bindMouse() {
const div = this.textLayerDiv;
let expandDivsTimer = null;
div.addEventListener('mousedown', (evt) => {
if (this.enhanceTextSelection && this.textLayerRenderTask) {
this.textLayerRenderTask.expandTextDivs(true);
if (
(typeof PDFJSDev === 'undefined' ||
!PDFJSDev.test('MOZCENTRAL')) &&
expandDivsTimer
) {
clearTimeout(expandDivsTimer);
expandDivsTimer = null;
}
return;
}
const end = div.querySelector('.endOfContent');
if (!end) {
return;
}
if (
typeof PDFJSDev === 'undefined' ||
!PDFJSDev.test('MOZCENTRAL')
) {
// On non-Firefox browsers, the selection will feel better if the height
// of the `endOfContent` div is adjusted to start at mouse click
// location. This avoids flickering when the selection moves up.
// However it does not work when selection is started on empty space.
let adjustTop = evt.target !== div;
if (
typeof PDFJSDev === 'undefined' ||
PDFJSDev.test('GENERIC')
) {
adjustTop =
adjustTop &&
window
.getComputedStyle(end)
.getPropertyValue('-moz-user-select') !== 'none';
}
if (adjustTop) {
const divBounds = div.getBoundingClientRect();
const r = Math.max(
0,
(evt.pageY - divBounds.top) / divBounds.height,
);
end.style.top = (r * 100).toFixed(2) + '%';
}
}
end.classList.add('active');
});
div.addEventListener('mouseup', () => {
if (this.enhanceTextSelection && this.textLayerRenderTask) {
if (
typeof PDFJSDev === 'undefined' ||
!PDFJSDev.test('MOZCENTRAL')
) {
expandDivsTimer = setTimeout(() => {
if (this.textLayerRenderTask) {
this.textLayerRenderTask.expandTextDivs(false);
}
expandDivsTimer = null;
}, EXPAND_DIVS_TIMEOUT);
} else {
this.textLayerRenderTask.expandTextDivs(false);
}
return;
}
const end = div.querySelector('.endOfContent');
if (!end) {
return;
}
if (
typeof PDFJSDev === 'undefined' ||
!PDFJSDev.test('MOZCENTRAL')
) {
end.style.top = '';
}
end.classList.remove('active');
});
}
}
export { TextLayerBuilder };

View File

@@ -0,0 +1,424 @@
/* Copyright 2016 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 {
animationStarted,
DEFAULT_SCALE,
DEFAULT_SCALE_VALUE,
docStyle,
MAX_SCALE,
MIN_SCALE,
noContextMenuHandler,
} from './ui_utils.js';
import { AnnotationEditorType } from 'pdfjs-dist';
const PAGE_NUMBER_LOADING_INDICATOR = 'visiblePageIsLoading';
/**
* @typedef {Object} ToolbarOptions
* @property {HTMLDivElement} container - Container for the secondary toolbar.
* @property {HTMLSpanElement} numPages - Label that contains number of pages.
* @property {HTMLInputElement} pageNumber - Control for display and user input
* of the current page number.
* @property {HTMLSelectElement} scaleSelect - Scale selection control.
* Its width is adjusted, when necessary, on UI localization.
* @property {HTMLOptionElement} customScaleOption - The item used to display
* a non-predefined scale.
* @property {HTMLButtonElement} previous - Button to go to the previous page.
* @property {HTMLButtonElement} next - Button to go to the next page.
* @property {HTMLButtonElement} zoomIn - Button to zoom in the pages.
* @property {HTMLButtonElement} zoomOut - Button to zoom out the pages.
* @property {HTMLButtonElement} viewFind - Button to open find bar.
* @property {HTMLButtonElement} openFile - Button to open a new document.
* @property {HTMLButtonElement} presentationModeButton - Button to switch to
* presentation mode.
* @property {HTMLButtonElement} editorNoneButton - Button to disable editing.
* @property {HTMLButtonElement} editorFreeTextButton - Button to switch to
* FreeText editing.
* @property {HTMLButtonElement} download - Button to download the document.
* @property {HTMLAnchorElement} viewBookmark - Button to obtain a bookmark link
* to the current location in the document.
*/
class Toolbar {
#wasLocalized = false;
/**
* @param {ToolbarOptions} options
* @param {EventBus} eventBus
* @param {IL10n} l10n - Localization service.
*/
constructor(options, eventBus, l10n) {
this.toolbar = options.container;
this.eventBus = eventBus;
this.l10n = l10n;
this.buttons = [
{ element: options.previous, eventName: 'previouspage' },
{ element: options.next, eventName: 'nextpage' },
{ element: options.zoomIn, eventName: 'zoomin' },
{ element: options.zoomOut, eventName: 'zoomout' },
{ element: options.print, eventName: 'print' },
{ element: options.download, eventName: 'download' },
];
this.items = {
numPages: options.numPages,
pageNumber: options.pageNumber,
scaleSelect: options.scaleSelect,
customScaleOption: options.customScaleOption,
previous: options.previous,
next: options.next,
zoomIn: options.zoomIn,
zoomOut: options.zoomOut,
fullscreen: options.fullscreen,
};
// Bind the event listeners for click and various other actions.
this.#bindListeners(options);
this.reset();
}
setPageNumber(pageNumber, pageLabel) {
this.pageNumber = pageNumber;
this.pageLabel = pageLabel;
this.#updateUIState(false);
}
setPagesCount(pagesCount, hasPageLabels) {
this.pagesCount = pagesCount;
this.hasPageLabels = hasPageLabels;
this.#updateUIState(true);
}
setPageScale(pageScaleValue, pageScale) {
this.pageScaleValue = (pageScaleValue || pageScale).toString();
this.pageScale = pageScale;
this.#updateUIState(false);
}
reset() {
this.pageNumber = 0;
this.pageLabel = null;
this.hasPageLabels = false;
this.pagesCount = 0;
this.pageScaleValue = DEFAULT_SCALE_VALUE;
this.pageScale = DEFAULT_SCALE;
this.#updateUIState(true);
this.updateLoadingIndicatorState();
// Reset the Editor buttons too, since they're document specific.
this.eventBus.dispatch('toolbarreset', { source: this });
}
#bindListeners(options) {
const { pageNumber, scaleSelect, fullscreen } = this.items;
const self = this;
// The buttons within the toolbar.
for (const { element, eventName, eventDetails } of this.buttons) {
for (let i = 0; i < element.length; i++) {
element[i].addEventListener('click', (evt) => {
if (eventName !== null) {
const details = { source: this };
if (eventDetails) {
for (const property in eventDetails) {
details[property] = eventDetails[property];
}
}
this.eventBus.dispatch(eventName, details);
}
});
}
}
for (let i = 0; i < pageNumber.length; i++) {
pageNumber[i].addEventListener('click', function () {
this.select();
});
pageNumber[i].addEventListener('change', function () {
self.eventBus.dispatch('pagenumberchanged', {
source: self,
value: this.value,
});
});
}
for (let i = 0; i < scaleSelect.length; i++) {
scaleSelect[i].addEventListener('change', function () {
if (this.value === 'custom') {
return;
}
self.eventBus.dispatch('scalechanged', {
source: self,
value: this.value,
});
});
}
for (let i = 0; i < fullscreen.length; i++) {
fullscreen[i].addEventListener('click', function (evt) {
const target = evt.target;
let pdf_target_id = target.getAttribute('data-pdf-id');
let frame = window.parent.document.querySelector(
'#wppdf-emb-iframe-' + pdf_target_id,
);
let container = window.parent.document.querySelector(
'#wppdfemb-frame-container-' + pdf_target_id,
);
if (frame.classList.contains('wppdf-fullscreen')) {
container.append(frame);
frame.classList.remove('wppdf-fullscreen');
} else {
frame.classList.add('wppdf-fullscreen');
parent.document.body.append(frame);
}
});
let deboune;
window.parent.document.addEventListener('keyup', function (e) {
switch (e.keyCode) {
case 27: // Escape
let pdf_target_id =
fullscreen[i].getAttribute('data-pdf-id');
let frame = window.parent.document.getElementById(
'wppdf-emb-iframe-' + pdf_target_id,
);
let container = window.parent.document.querySelector(
'#wppdfemb-frame-container-' + pdf_target_id,
);
if (frame.classList.contains('wppdf-fullscreen')) {
frame.classList.remove('wppdf-fullscreen');
container.appendChild(frame);
}
break;
}
});
document.addEventListener('keyup', function (e) {
switch (e.keyCode) {
case 27: // Escape
let pdf_target_id =
fullscreen[i].getAttribute('data-pdf-id');
let frame = window.parent.document.getElementById(
'wppdf-emb-iframe-' + pdf_target_id,
);
let container = window.parent.document.querySelector(
'#wppdfemb-frame-container-' + pdf_target_id,
);
if (frame.classList.contains('wppdf-fullscreen')) {
frame.classList.remove('wppdf-fullscreen');
container.appendChild(frame);
}
break;
}
});
}
// Here we depend on browsers dispatching the "click" event *after* the
// "change" event, when the <select>-element changes.
for (let i = 0; i < scaleSelect.length; i++) {
scaleSelect[i].addEventListener('click', function (evt) {
const target = evt.target;
// Remove focus when an <option>-element was *clicked*, to improve the UX
// for mouse users (fixes bug 1300525 and issue 4923).
if (
this.value === self.pageScaleValue &&
target.tagName.toUpperCase() === 'OPTION'
) {
this.blur();
}
});
// Suppress context menus for some controls.
scaleSelect[i].oncontextmenu = noContextMenuHandler;
}
this.eventBus._on('localized', () => {
this.#wasLocalized = true;
this.#adjustScaleWidth();
this.#updateUIState(true);
});
}
#bindEditorToolsListener({
editorNoneButton,
editorFreeTextButton,
editorFreeTextParamsToolbar,
editorInkButton,
editorInkParamsToolbar,
}) {
const editorModeChanged = (evt, disableButtons = false) => {
const editorButtons = [
{ mode: AnnotationEditorType.NONE, button: editorNoneButton },
{
mode: AnnotationEditorType.FREETEXT,
button: editorFreeTextButton,
toolbar: editorFreeTextParamsToolbar,
},
{
mode: AnnotationEditorType.INK,
button: editorInkButton,
toolbar: editorInkParamsToolbar,
},
];
for (const { mode, button, toolbar } of editorButtons) {
const checked = mode === evt.mode;
button.classList.toggle('toggled', checked);
button.setAttribute('aria-checked', checked);
button.disabled = disableButtons;
if (toolbar) {
toolbar.classList.toggle('hidden', !checked);
}
}
};
this.eventBus._on('annotationeditormodechanged', editorModeChanged);
this.eventBus._on('toolbarreset', (evt) => {
if (evt.source === this) {
editorModeChanged(
{ mode: AnnotationEditorType.NONE },
/* disableButtons = */ true,
);
}
});
}
#updateUIState(resetNumPages = false) {
if (!this.#wasLocalized) {
// Don't update the UI state until we localize the toolbar.
return;
}
const { pageNumber, pagesCount, pageScaleValue, pageScale, items } =
this;
if (resetNumPages) {
if (this.hasPageLabels) {
items.pageNumber.type = 'text';
} else {
items.pageNumber.type = 'number';
this.l10n.get('of_pages', { pagesCount }).then((msg) => {
items.numPages.textContent = msg;
});
}
items.pageNumber.max = pagesCount;
}
if (this.hasPageLabels) {
items.pageNumber.value = this.pageLabel;
this.l10n
.get('page_of_pages', { pageNumber, pagesCount })
.then((msg) => {
items.numPages.textContent = msg;
});
} else {
items.pageNumber.value = pageNumber;
}
items.previous.disabled = pageNumber <= 1;
items.next.disabled = pageNumber >= pagesCount;
items.zoomOut.disabled = pageScale <= MIN_SCALE;
items.zoomIn.disabled = pageScale >= MAX_SCALE;
this.l10n
.get('page_scale_percent', {
scale: Math.round(pageScale * 10000) / 100,
})
.then((msg) => {
let predefinedValueFound = false;
for (const option of items.scaleSelect.options) {
if (option.value !== pageScaleValue) {
option.selected = false;
continue;
}
option.selected = true;
predefinedValueFound = true;
}
if (!predefinedValueFound) {
items.customScaleOption.textContent = msg;
items.customScaleOption.selected = true;
}
});
}
updateLoadingIndicatorState(loading = false) {
const { pageNumber } = this.items;
for (let i = 0; i < pageNumber.length; i++) {
let pageNumberID = document.getElementById(pageNumber[i].id);
pageNumberID.classList.toggle(
PAGE_NUMBER_LOADING_INDICATOR,
loading,
);
}
}
/**
* Increase the width of the zoom dropdown DOM element if, and only if, it's
* too narrow to fit the *longest* of the localized strings.
*/
async #adjustScaleWidth() {
const { items, l10n } = this;
const predefinedValuesPromise = Promise.all([
l10n.get('page_scale_auto'),
l10n.get('page_scale_actual'),
l10n.get('page_scale_fit'),
l10n.get('page_scale_width'),
]);
await animationStarted;
const style = getComputedStyle(items.scaleSelect),
scaleSelectContainerWidth = parseInt(
style.getPropertyValue('--scale-select-container-width'),
10,
),
scaleSelectOverflow = parseInt(
style.getPropertyValue('--scale-select-overflow'),
10,
);
// The temporary canvas is used to measure text length in the DOM.
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { alpha: false });
ctx.font = `${style.fontSize} ${style.fontFamily}`;
let maxWidth = 0;
for (const predefinedValue of await predefinedValuesPromise) {
const { width } = ctx.measureText(predefinedValue);
if (width > maxWidth) {
maxWidth = width;
}
}
maxWidth += 2 * scaleSelectOverflow;
if (maxWidth > scaleSelectContainerWidth) {
docStyle.setProperty(
'--scale-select-container-width',
`${maxWidth}px`,
);
}
// Zeroing the width and height cause Firefox to release graphics resources
// immediately, which can greatly reduce memory consumption.
canvas.width = 0;
canvas.height = 0;
}
}
export { Toolbar };

View File

@@ -0,0 +1,884 @@
/* 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.
*/
const DEFAULT_SCALE_VALUE = 'page-fit';
const DEFAULT_SCALE = 1.0;
const DEFAULT_SCALE_DELTA = 1.1;
const MIN_SCALE = 0.1;
const MAX_SCALE = 10.0;
const UNKNOWN_SCALE = 0;
const MAX_AUTO_SCALE = 1.25;
const SCROLLBAR_PADDING = 40;
const VERTICAL_PADDING = 5;
const RenderingStates = {
INITIAL: 0,
RUNNING: 1,
PAUSED: 2,
FINISHED: 3,
};
const PresentationModeState = {
UNKNOWN: 0,
NORMAL: 1,
CHANGING: 2,
FULLSCREEN: 3,
};
const SidebarView = {
UNKNOWN: -1,
NONE: 0,
THUMBS: 1, // Default value.
OUTLINE: 2,
ATTACHMENTS: 3,
LAYERS: 4,
};
const RendererType =
typeof PDFJSDev === 'undefined' || PDFJSDev.test('!PRODUCTION || GENERIC')
? {
CANVAS: 'canvas',
SVG: 'svg',
}
: null;
const TextLayerMode = {
DISABLE: 0,
ENABLE: 1,
ENABLE_ENHANCE: 2,
};
const ScrollMode = {
UNKNOWN: -1,
VERTICAL: 0, // Default value.
HORIZONTAL: 1,
WRAPPED: 2,
PAGE: 3,
};
const SpreadMode = {
UNKNOWN: -1,
NONE: 0, // Default value.
ODD: 1,
EVEN: 2,
};
// Used by `PDFViewerApplication`, and by the API unit-tests.
const AutoPrintRegExp = /\bprint\s*\(/;
/**
* Scale factors for the canvas, necessary with HiDPI displays.
*/
class OutputScale {
constructor() {
const pixelRatio = window.devicePixelRatio || 1;
/**
* @type {number} Horizontal scale.
*/
this.sx = pixelRatio;
/**
* @type {number} Vertical scale.
*/
this.sy = pixelRatio;
}
/**
* @type {boolean} Returns `true` when scaling is required, `false` otherwise.
*/
get scaled() {
return this.sx !== 1 || this.sy !== 1;
}
}
/**
* Scrolls specified element into view of its parent.
* @param {Object} element - The element to be visible.
* @param {Object} spot - An object with optional top and left properties,
* specifying the offset from the top left edge.
* @param {boolean} [scrollMatches] - When scrolling search results into view,
* ignore elements that either: Contains marked content identifiers,
* or have the CSS-rule `overflow: hidden;` set. The default value is `false`.
*/
function scrollIntoView(element, spot, scrollMatches = false) {
// Assuming offsetParent is available (it's not available when viewer is in
// hidden iframe or object). We have to scroll: if the offsetParent is not set
// producing the error. See also animationStarted.
let parent = element.offsetParent;
if (!parent) {
console.error('offsetParent is not set -- cannot scroll');
return;
}
let offsetY = element.offsetTop + element.clientTop;
let offsetX = element.offsetLeft + element.clientLeft;
while (
(parent.clientHeight === parent.scrollHeight &&
parent.clientWidth === parent.scrollWidth) ||
(scrollMatches &&
(parent.classList.contains('markedContent') ||
getComputedStyle(parent).overflow === 'hidden'))
) {
offsetY += parent.offsetTop;
offsetX += parent.offsetLeft;
parent = parent.offsetParent;
if (!parent) {
return; // no need to scroll
}
}
if (spot) {
if (spot.top !== undefined) {
offsetY += spot.top;
}
if (spot.left !== undefined) {
offsetX += spot.left;
parent.scrollLeft = offsetX;
}
}
parent.scrollTop = offsetY;
}
/**
* Helper function to start monitoring the scroll event and converting them into
* PDF.js friendly one: with scroll debounce and scroll direction.
*/
function watchScroll(viewAreaElement, callback) {
const debounceScroll = function (evt) {
if (rAF) {
return;
}
// schedule an invocation of scroll for next animation frame.
rAF = window.requestAnimationFrame(function viewAreaElementScrolled() {
rAF = null;
const currentX = viewAreaElement.scrollLeft;
const lastX = state.lastX;
if (currentX !== lastX) {
state.right = currentX > lastX;
}
state.lastX = currentX;
const currentY = viewAreaElement.scrollTop;
const lastY = state.lastY;
if (currentY !== lastY) {
state.down = currentY > lastY;
}
state.lastY = currentY;
callback(state);
});
};
const state = {
right: true,
down: true,
lastX: viewAreaElement.scrollLeft,
lastY: viewAreaElement.scrollTop,
_eventHandler: debounceScroll,
};
let rAF = null;
viewAreaElement.addEventListener('scroll', debounceScroll, true);
return state;
}
/**
* Helper function to parse query string (e.g. ?param1=value&param2=...).
* @param {string}
* @returns {Map}
*/
function parseQueryString(query) {
const params = new Map();
for (const [key, value] of new URLSearchParams(query)) {
params.set(key.toLowerCase(), value);
}
return params;
}
const NullCharactersRegExp = /\x00/g;
const InvisibleCharactersRegExp = /[\x01-\x1F]/g;
/**
* @param {string} str
* @param {boolean} [replaceInvisible]
*/
function removeNullCharacters(str, replaceInvisible = false) {
if (typeof str !== 'string') {
console.error(`The argument must be a string.`);
return str;
}
if (replaceInvisible) {
str = str.replace(InvisibleCharactersRegExp, ' ');
}
return str.replace(NullCharactersRegExp, '');
}
/**
* Use binary search to find the index of the first item in a given array which
* passes a given condition. The items are expected to be sorted in the sense
* that if the condition is true for one item in the array, then it is also true
* for all following items.
*
* @returns {number} Index of the first array element to pass the test,
* or |items.length| if no such element exists.
*/
function binarySearchFirstItem(items, condition, start = 0) {
let minIndex = start;
let maxIndex = items.length - 1;
if (maxIndex < 0 || !condition(items[maxIndex])) {
return items.length;
}
if (condition(items[minIndex])) {
return minIndex;
}
while (minIndex < maxIndex) {
const currentIndex = (minIndex + maxIndex) >> 1;
const currentItem = items[currentIndex];
if (condition(currentItem)) {
maxIndex = currentIndex;
} else {
minIndex = currentIndex + 1;
}
}
return minIndex; /* === maxIndex */
}
/**
* Approximates float number as a fraction using Farey sequence (max order
* of 8).
* @param {number} x - Positive float number.
* @returns {Array} Estimated fraction: the first array item is a numerator,
* the second one is a denominator.
*/
function approximateFraction(x) {
// Fast paths for int numbers or their inversions.
if (Math.floor(x) === x) {
return [x, 1];
}
const xinv = 1 / x;
const limit = 8;
if (xinv > limit) {
return [1, limit];
} else if (Math.floor(xinv) === xinv) {
return [1, xinv];
}
const x_ = x > 1 ? xinv : x;
// a/b and c/d are neighbours in Farey sequence.
let a = 0,
b = 1,
c = 1,
d = 1;
// Limiting search to order 8.
while (true) {
// Generating next term in sequence (order of q).
const p = a + c,
q = b + d;
if (q > limit) {
break;
}
if (x_ <= p / q) {
c = p;
d = q;
} else {
a = p;
b = q;
}
}
let result;
// Select closest of the neighbours to x.
if (x_ - a / b < c / d - x_) {
result = x_ === x ? [a, b] : [b, a];
} else {
result = x_ === x ? [c, d] : [d, c];
}
return result;
}
function roundToDivide(x, div) {
const r = x % div;
return r === 0 ? x : Math.round(x - r + div);
}
/**
* @typedef {Object} GetPageSizeInchesParameters
* @property {number[]} view
* @property {number} userUnit
* @property {number} rotate
*/
/**
* @typedef {Object} PageSize
* @property {number} width - In inches.
* @property {number} height - In inches.
*/
/**
* Gets the size of the specified page, converted from PDF units to inches.
* @param {GetPageSizeInchesParameters} params
* @returns {PageSize}
*/
function getPageSizeInches({ view, userUnit, rotate }) {
const [x1, y1, x2, y2] = view;
// We need to take the page rotation into account as well.
const changeOrientation = rotate % 180 !== 0;
const width = ((x2 - x1) / 72) * userUnit;
const height = ((y2 - y1) / 72) * userUnit;
return {
width: changeOrientation ? height : width,
height: changeOrientation ? width : height,
};
}
/**
* Helper function for getVisibleElements.
*
* @param {number} index - initial guess at the first visible element
* @param {Array} views - array of pages, into which `index` is an index
* @param {number} top - the top of the scroll pane
* @returns {number} less than or equal to `index` that is definitely at or
* before the first visible element in `views`, but not by too much. (Usually,
* this will be the first element in the first partially visible row in
* `views`, although sometimes it goes back one row further.)
*/
function backtrackBeforeAllVisibleElements(index, views, top) {
// binarySearchFirstItem's assumption is that the input is ordered, with only
// one index where the conditions flips from false to true: [false ...,
// true...]. With vertical scrolling and spreads, it is possible to have
// [false ..., true, false, true ...]. With wrapped scrolling we can have a
// similar sequence, with many more mixed true and false in the middle.
//
// So there is no guarantee that the binary search yields the index of the
// first visible element. It could have been any of the other visible elements
// that were preceded by a hidden element.
// Of course, if either this element or the previous (hidden) element is also
// the first element, there's nothing to worry about.
if (index < 2) {
return index;
}
// That aside, the possible cases are represented below.
//
// **** = fully hidden
// A*B* = mix of partially visible and/or hidden pages
// CDEF = fully visible
//
// (1) Binary search could have returned A, in which case we can stop.
// (2) Binary search could also have returned B, in which case we need to
// check the whole row.
// (3) Binary search could also have returned C, in which case we need to
// check the whole previous row.
//
// There's one other possibility:
//
// **** = fully hidden
// ABCD = mix of fully and/or partially visible pages
//
// (4) Binary search could only have returned A.
// Initially assume that we need to find the beginning of the current row
// (case 1, 2, or 4), which means finding a page that is above the current
// page's top. If the found page is partially visible, we're definitely not in
// case 3, and this assumption is correct.
let elt = views[index].div;
let pageTop = elt.offsetTop + elt.clientTop;
if (pageTop >= top) {
// The found page is fully visible, so we're actually either in case 3 or 4,
// and unfortunately we can't tell the difference between them without
// scanning the entire previous row, so we just conservatively assume that
// we do need to backtrack to that row. In both cases, the previous page is
// in the previous row, so use its top instead.
elt = views[index - 1].div;
pageTop = elt.offsetTop + elt.clientTop;
}
// Now we backtrack to the first page that still has its bottom below
// `pageTop`, which is the top of a page in the first visible row (unless
// we're in case 4, in which case it's the row before that).
// `index` is found by binary search, so the page at `index - 1` is
// invisible and we can start looking for potentially visible pages from
// `index - 2`. (However, if this loop terminates on its first iteration,
// which is the case when pages are stacked vertically, `index` should remain
// unchanged, so we use a distinct loop variable.)
for (let i = index - 2; i >= 0; --i) {
elt = views[i].div;
if (elt.offsetTop + elt.clientTop + elt.clientHeight <= pageTop) {
// We have reached the previous row, so stop now.
// This loop is expected to terminate relatively quickly because the
// number of pages per row is expected to be small.
break;
}
index = i;
}
return index;
}
/**
* @typedef {Object} GetVisibleElementsParameters
* @property {HTMLElement} scrollEl - A container that can possibly scroll.
* @property {Array} views - Objects with a `div` property that contains an
* HTMLElement, which should all be descendants of `scrollEl` satisfying the
* relevant layout assumptions.
* @property {boolean} sortByVisibility - If `true`, the returned elements are
* sorted in descending order of the percent of their padding box that is
* visible. The default value is `false`.
* @property {boolean} horizontal - If `true`, the elements are assumed to be
* laid out horizontally instead of vertically. The default value is `false`.
* @property {boolean} rtl - If `true`, the `scrollEl` container is assumed to
* be in right-to-left mode. The default value is `false`.
*/
/**
* Generic helper to find out what elements are visible within a scroll pane.
*
* Well, pretty generic. There are some assumptions placed on the elements
* referenced by `views`:
* - If `horizontal`, no left of any earlier element is to the right of the
* left of any later element.
* - Otherwise, `views` can be split into contiguous rows where, within a row,
* no top of any element is below the bottom of any other element, and
* between rows, no bottom of any element in an earlier row is below the
* top of any element in a later row.
*
* (Here, top, left, etc. all refer to the padding edge of the element in
* question. For pages, that ends up being equivalent to the bounding box of the
* rendering canvas. Earlier and later refer to index in `views`, not page
* layout.)
*
* @param {GetVisibleElementsParameters}
* @returns {Object} `{ first, last, views: [{ id, x, y, view, percent }] }`
*/
function getVisibleElements({
scrollEl,
views,
sortByVisibility = false,
horizontal = false,
rtl = false,
}) {
const top = scrollEl.scrollTop,
bottom = top + scrollEl.clientHeight;
const left = scrollEl.scrollLeft,
right = left + scrollEl.clientWidth;
// Throughout this "generic" function, comments will assume we're working with
// PDF document pages, which is the most important and complex case. In this
// case, the visible elements we're actually interested is the page canvas,
// which is contained in a wrapper which adds no padding/border/margin, which
// is itself contained in `view.div` which adds no padding (but does add a
// border). So, as specified in this function's doc comment, this function
// does all of its work on the padding edge of the provided views, starting at
// offsetLeft/Top (which includes margin) and adding clientLeft/Top (which is
// the border). Adding clientWidth/Height gets us the bottom-right corner of
// the padding edge.
function isElementBottomAfterViewTop(view) {
const element = view.div;
const elementBottom =
element.offsetTop + element.clientTop + element.clientHeight;
return elementBottom > top;
}
function isElementNextAfterViewHorizontally(view) {
const element = view.div;
const elementLeft = element.offsetLeft + element.clientLeft;
const elementRight = elementLeft + element.clientWidth;
return rtl ? elementLeft < right : elementRight > left;
}
const visible = [],
ids = new Set(),
numViews = views.length;
let firstVisibleElementInd = binarySearchFirstItem(
views,
horizontal
? isElementNextAfterViewHorizontally
: isElementBottomAfterViewTop,
);
// Please note the return value of the `binarySearchFirstItem` function when
// no valid element is found (hence the `firstVisibleElementInd` check below).
if (
firstVisibleElementInd > 0 &&
firstVisibleElementInd < numViews &&
!horizontal
) {
// In wrapped scrolling (or vertical scrolling with spreads), with some page
// sizes, isElementBottomAfterViewTop doesn't satisfy the binary search
// condition: there can be pages with bottoms above the view top between
// pages with bottoms below. This function detects and corrects that error;
// see it for more comments.
firstVisibleElementInd = backtrackBeforeAllVisibleElements(
firstVisibleElementInd,
views,
top,
);
}
// lastEdge acts as a cutoff for us to stop looping, because we know all
// subsequent pages will be hidden.
//
// When using wrapped scrolling or vertical scrolling with spreads, we can't
// simply stop the first time we reach a page below the bottom of the view;
// the tops of subsequent pages on the same row could still be visible. In
// horizontal scrolling, we don't have that issue, so we can stop as soon as
// we pass `right`, without needing the code below that handles the -1 case.
let lastEdge = horizontal ? right : -1;
for (let i = firstVisibleElementInd; i < numViews; i++) {
const view = views[i],
element = view.div;
const currentWidth = element.offsetLeft + element.clientLeft;
const currentHeight = element.offsetTop + element.clientTop;
const viewWidth = element.clientWidth,
viewHeight = element.clientHeight;
const viewRight = currentWidth + viewWidth;
const viewBottom = currentHeight + viewHeight;
if (lastEdge === -1) {
// As commented above, this is only needed in non-horizontal cases.
// Setting lastEdge to the bottom of the first page that is partially
// visible ensures that the next page fully below lastEdge is on the
// next row, which has to be fully hidden along with all subsequent rows.
if (viewBottom >= bottom) {
lastEdge = viewBottom;
}
} else if ((horizontal ? currentWidth : currentHeight) > lastEdge) {
break;
}
if (
viewBottom <= top ||
currentHeight >= bottom ||
viewRight <= left ||
currentWidth >= right
) {
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 fractionHeight = (viewHeight - hiddenHeight) / viewHeight,
fractionWidth = (viewWidth - hiddenWidth) / viewWidth;
const percent = (fractionHeight * fractionWidth * 100) | 0;
visible.push({
id: view.id,
x: currentWidth,
y: currentHeight,
view,
percent,
widthPercent: (fractionWidth * 100) | 0,
});
ids.add(view.id);
}
const first = visible[0],
last = visible.at(-1);
if (sortByVisibility) {
visible.sort(function (a, b) {
const pc = a.percent - b.percent;
if (Math.abs(pc) > 0.001) {
return -pc;
}
return a.id - b.id; // ensure stability
});
}
return { first, last, views: visible, ids };
}
/**
* Event handler to suppress context menu.
*/
function noContextMenuHandler(evt) {
evt.preventDefault();
}
function normalizeWheelEventDirection(evt) {
let delta = Math.hypot(evt.deltaX, evt.deltaY);
const angle = Math.atan2(evt.deltaY, evt.deltaX);
if (-0.25 * Math.PI < angle && angle < 0.75 * Math.PI) {
// All that is left-up oriented has to change the sign.
delta = -delta;
}
return delta;
}
function normalizeWheelEventDelta(evt) {
let delta = normalizeWheelEventDirection(evt);
const MOUSE_DOM_DELTA_PIXEL_MODE = 0;
const MOUSE_DOM_DELTA_LINE_MODE = 1;
const MOUSE_PIXELS_PER_LINE = 30;
const MOUSE_LINES_PER_PAGE = 30;
// Converts delta to per-page units
if (evt.deltaMode === MOUSE_DOM_DELTA_PIXEL_MODE) {
delta /= MOUSE_PIXELS_PER_LINE * MOUSE_LINES_PER_PAGE;
} else if (evt.deltaMode === MOUSE_DOM_DELTA_LINE_MODE) {
delta /= MOUSE_LINES_PER_PAGE;
}
return delta;
}
function isValidRotation(angle) {
return Number.isInteger(angle) && angle % 90 === 0;
}
function isValidScrollMode(mode) {
return (
Number.isInteger(mode) &&
Object.values(ScrollMode).includes(mode) &&
mode !== ScrollMode.UNKNOWN
);
}
function isValidSpreadMode(mode) {
return (
Number.isInteger(mode) &&
Object.values(SpreadMode).includes(mode) &&
mode !== SpreadMode.UNKNOWN
);
}
function isPortraitOrientation(size) {
return size.width <= size.height;
}
/**
* Promise that is resolved when DOM window becomes visible.
*/
const animationStarted = new Promise(function (resolve) {
if (
typeof PDFJSDev !== 'undefined' &&
PDFJSDev.test('LIB') &&
typeof window === 'undefined'
) {
// Prevent "ReferenceError: window is not defined" errors when running the
// unit-tests in Node.js environments.
setTimeout(resolve, 20);
return;
}
window.requestAnimationFrame(resolve);
});
const docStyle =
typeof PDFJSDev !== 'undefined' &&
PDFJSDev.test('LIB') &&
typeof document === 'undefined'
? null
: document.documentElement.style;
function clamp(v, min, max) {
return Math.min(Math.max(v, min), max);
}
class ProgressBar {
#classList = null;
#percent = 0;
#visible = true;
constructor(id) {
if (
(typeof PDFJSDev === 'undefined' || PDFJSDev.test('GENERIC')) &&
arguments.length > 1
) {
throw new Error(
'ProgressBar no longer accepts any additional options, ' +
'please use CSS rules to modify its appearance instead.',
);
}
const bar = document.getElementById(id);
this.#classList = bar.classList;
}
get percent() {
return this.#percent;
}
set percent(val) {
this.#percent = clamp(val, 0, 100);
if (isNaN(val)) {
this.#classList.add('indeterminate');
return;
}
this.#classList.remove('indeterminate');
docStyle.setProperty('--progressBar-percent', `${this.#percent}%`);
}
setWidth(viewer) {
if (!viewer) {
return;
}
const container = viewer.parentNode;
const scrollbarWidth = container.offsetWidth - viewer.offsetWidth;
if (scrollbarWidth > 0) {
docStyle.setProperty(
'--progressBar-end-offset',
`${scrollbarWidth}px`,
);
}
}
hide() {
if (!this.#visible) {
return;
}
this.#visible = false;
this.#classList.add('hidden');
}
show() {
if (this.#visible) {
return;
}
this.#visible = true;
this.#classList.remove('hidden');
}
}
/**
* Get the active or focused element in current DOM.
*
* Recursively search for the truly active or focused element in case there are
* shadow DOMs.
*
* @returns {Element} the truly active or focused element.
*/
function getActiveOrFocusedElement() {
let curRoot = document;
let curActiveOrFocused =
curRoot.activeElement || curRoot.querySelector(':focus');
while (curActiveOrFocused?.shadowRoot) {
curRoot = curActiveOrFocused.shadowRoot;
curActiveOrFocused =
curRoot.activeElement || curRoot.querySelector(':focus');
}
return curActiveOrFocused;
}
/**
* Converts API PageLayout values to the format used by `BaseViewer`.
* NOTE: This is supported to the extent that the viewer implements the
* necessary Scroll/Spread modes (since SinglePage, TwoPageLeft,
* and TwoPageRight all suggests using non-continuous scrolling).
* @param {string} mode - The API PageLayout value.
* @returns {Object}
*/
function apiPageLayoutToViewerModes(layout) {
let scrollMode = ScrollMode.VERTICAL,
spreadMode = SpreadMode.NONE;
switch (layout) {
case 'SinglePage':
scrollMode = ScrollMode.PAGE;
break;
case 'OneColumn':
break;
case 'TwoPageLeft':
scrollMode = ScrollMode.PAGE;
/* falls through */
case 'TwoColumnLeft':
spreadMode = SpreadMode.ODD;
break;
case 'TwoPageRight':
scrollMode = ScrollMode.PAGE;
/* falls through */
case 'TwoColumnRight':
spreadMode = SpreadMode.EVEN;
break;
}
return { scrollMode, spreadMode };
}
/**
* Converts API PageMode values to the format used by `PDFSidebar`.
* NOTE: There's also a "FullScreen" parameter which is not possible to support,
* since the Fullscreen API used in browsers requires that entering
* fullscreen mode only occurs as a result of a user-initiated event.
* @param {string} mode - The API PageMode value.
* @returns {number} A value from {SidebarView}.
*/
function apiPageModeToSidebarView(mode) {
switch (mode) {
case 'UseNone':
return SidebarView.NONE;
case 'UseThumbs':
return SidebarView.THUMBS;
case 'UseOutlines':
return SidebarView.OUTLINE;
case 'UseAttachments':
return SidebarView.ATTACHMENTS;
case 'UseOC':
return SidebarView.LAYERS;
}
return SidebarView.NONE; // Default value.
}
export {
animationStarted,
apiPageLayoutToViewerModes,
apiPageModeToSidebarView,
approximateFraction,
AutoPrintRegExp,
backtrackBeforeAllVisibleElements, // only exported for testing
binarySearchFirstItem,
DEFAULT_SCALE,
DEFAULT_SCALE_DELTA,
DEFAULT_SCALE_VALUE,
docStyle,
getActiveOrFocusedElement,
getPageSizeInches,
getVisibleElements,
isPortraitOrientation,
isValidRotation,
isValidScrollMode,
isValidSpreadMode,
MAX_AUTO_SCALE,
MAX_SCALE,
MIN_SCALE,
noContextMenuHandler,
normalizeWheelEventDelta,
normalizeWheelEventDirection,
OutputScale,
parseQueryString,
PresentationModeState,
ProgressBar,
removeNullCharacters,
RendererType,
RenderingStates,
roundToDivide,
SCROLLBAR_PADDING,
scrollIntoView,
ScrollMode,
SidebarView,
SpreadMode,
TextLayerMode,
UNKNOWN_SCALE,
VERTICAL_PADDING,
watchScroll,
};

View File

@@ -0,0 +1,123 @@
/* Copyright 2021 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/display_utils").PageViewport} PageViewport */
/** @typedef {import("./interfaces").IPDFLinkService} IPDFLinkService */
import { XfaLayer } from 'pdfjs-dist';
/**
* @typedef {Object} XfaLayerBuilderOptions
* @property {HTMLDivElement} pageDiv
* @property {PDFPageProxy} pdfPage
* @property {AnnotationStorage} [annotationStorage]
* @property {IPDFLinkService} linkService
* @property {Object} [xfaHtml]
*/
class XfaLayerBuilder {
/**
* @param {XfaLayerBuilderOptions} options
*/
constructor({
pageDiv,
pdfPage,
annotationStorage = null,
linkService,
xfaHtml = null,
}) {
this.pageDiv = pageDiv;
this.pdfPage = pdfPage;
this.annotationStorage = annotationStorage;
this.linkService = linkService;
this.xfaHtml = xfaHtml;
this.div = null;
this._cancelled = false;
}
/**
* @param {PageViewport} viewport
* @param {string} intent (default value is 'display')
* @returns {Promise<Object | void>} A promise that is resolved when rendering
* of the XFA layer is complete. The first rendering will return an object
* with a `textDivs` property that can be used with the TextHighlighter.
*/
render(viewport, intent = 'display') {
if (intent === 'print') {
const parameters = {
viewport: viewport.clone({ dontFlip: true }),
div: this.div,
xfaHtml: this.xfaHtml,
annotationStorage: this.annotationStorage,
linkService: this.linkService,
intent,
};
// Create an xfa layer div and render the form
const div = document.createElement('div');
this.pageDiv.append(div);
parameters.div = div;
const result = XfaLayer.render(parameters);
return Promise.resolve(result);
}
// intent === "display"
return this.pdfPage
.getXfa()
.then((xfaHtml) => {
if (this._cancelled || !xfaHtml) {
return { textDivs: [] };
}
const parameters = {
viewport: viewport.clone({ dontFlip: true }),
div: this.div,
xfaHtml,
annotationStorage: this.annotationStorage,
linkService: this.linkService,
intent,
};
if (this.div) {
return XfaLayer.update(parameters);
}
// Create an xfa layer div and render the form
this.div = document.createElement('div');
this.pageDiv.append(this.div);
parameters.div = this.div;
return XfaLayer.render(parameters);
})
.catch((error) => {
console.error(error);
});
}
cancel() {
this._cancelled = true;
}
hide() {
if (!this.div) {
return;
}
this.div.hidden = true;
}
}
export { XfaLayerBuilder };