pdf.js.mirror/src/display/pages_mapper.js
Jonas Jenwald 68366e31e4 Move the MathClamp helper function to its own file
This allows using it in the `src/scripting_api/` folder, without increasing the size of the scripting-bundle by also importing a bunch of unused code.
2026-04-02 11:22:28 +02:00

427 lines
13 KiB
JavaScript

/* Copyright 2026 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 { makeArr } from "../shared/util.js";
import { MathClamp } from "../shared/math_clamp.js";
/**
* Maps between page IDs and page numbers, allowing bidirectional conversion
* between the two representations. This is useful when the page numbering
* in the PDF document doesn't match the default sequential ordering.
*
* When #pageNumberToId is null the mapping is the identity (page N has ID N).
*/
class PagesMapper {
/**
* Maps page positions (0-indexed) to their page IDs (1-indexed).
* Null when the mapping is the identity.
* @type {Uint32Array|null}
*/
#pageNumberToId = null;
/**
* Previous page number for each position, used to track what happened to
* each page after a mutation. Negative values indicate copied pages.
* @type {Int32Array|null}
*/
#prevPageNumbers = null;
/** @type {number} */
#pagesNumber = 0;
/**
* Clipboard state for copy/paste operations.
* @type {{pageNumbers: Uint32Array, pageIds: Uint32Array}|null}
*/
#clipboard = null;
/** Saved state for undoing a delete. */
#savedData = null;
get pagesNumber() {
return this.#pagesNumber;
}
set pagesNumber(n) {
if (this.#pagesNumber === n) {
return;
}
this.#pagesNumber = n;
this.#pageNumberToId = null;
this.#prevPageNumbers = null;
}
/**
* Ensures the identity mapping is initialized.
* Must be called before any mutation or before reading #pageNumberToId.
*/
#ensureInit() {
if (this.#pageNumberToId) {
return;
}
const n = this.#pagesNumber;
const pageNumberToId = (this.#pageNumberToId = new Uint32Array(n));
for (let i = 0; i < n; i++) {
pageNumberToId[i] = i + 1;
}
this.#prevPageNumbers = new Int32Array(pageNumberToId);
}
/**
* Builds and returns the inverse map (id to page numbers) from
* #pageNumberToId.
* Since a page ID can appear multiple times (after a copy), the value is an
* array of all page numbers that share that ID.
* @returns {Map<number, Array<number>>}
*/
#buildIdToPageNumber() {
const idToPageNumber = new Map();
const pageNumberToId = this.#pageNumberToId;
for (let i = 0, ii = this.#pagesNumber; i < ii; i++) {
const id = pageNumberToId[i];
const pageNumbers = idToPageNumber.get(id);
if (pageNumbers) {
pageNumbers.push(i + 1);
} else {
idToPageNumber.set(id, [i + 1]);
}
}
return idToPageNumber;
}
/**
* Move a set of pages to a new position.
*
* @param {Set<number>} selectedPages - Page numbers being moved (1-indexed).
* @param {number[]} pagesToMove - Ordered list of page numbers to move.
* @param {number} index - Zero-based insertion index in the page-number list.
*/
movePages(selectedPages, pagesToMove, index) {
this.#ensureInit();
const pageNumberToId = this.#pageNumberToId;
const prevIdToPageNumber = this.#buildIdToPageNumber();
const movedCount = pagesToMove.length;
const mappedPagesToMove = new Uint32Array(movedCount);
let removedBeforeTarget = 0;
for (let i = 0; i < movedCount; i++) {
const pageIndex = pagesToMove[i] - 1;
mappedPagesToMove[i] = pageNumberToId[pageIndex];
if (pageIndex < index) {
removedBeforeTarget++;
}
}
const pagesNumber = this.#pagesNumber;
const remainingLen = pagesNumber - movedCount;
const adjustedTarget = MathClamp(
index - removedBeforeTarget,
0,
remainingLen
);
// Compact: keep only non-moved pages.
for (let i = 0, r = 0; i < pagesNumber; i++) {
if (!selectedPages.has(i + 1)) {
pageNumberToId[r++] = pageNumberToId[i];
}
}
// Make room at the target and insert.
pageNumberToId.copyWithin(
adjustedTarget + movedCount,
adjustedTarget,
remainingLen
);
pageNumberToId.set(mappedPagesToMove, adjustedTarget);
this.#updatePrevPageNumbers(prevIdToPageNumber);
if (pageNumberToId.every((id, i) => id === i + 1)) {
this.#pageNumberToId = null;
}
}
/**
* Deletes a set of pages.
* @param {Array<number>} pagesToDelete - Page numbers to delete (1-indexed),
* unique and sorted ascending.
*/
deletePages(pagesToDelete) {
this.#ensureInit();
const pageNumberToId = this.#pageNumberToId;
const prevIdToPageNumber = this.#buildIdToPageNumber();
this.#savedData = {
pageNumberToId: pageNumberToId.slice(),
pagesNumber: this.#pagesNumber,
prevPageNumbers: this.#prevPageNumbers.slice(),
};
const newN = this.#pagesNumber - pagesToDelete.length;
this.#pagesNumber = newN;
const newPageNumberToId = (this.#pageNumberToId = new Uint32Array(newN));
this.#prevPageNumbers = new Int32Array(newN);
let sourceIndex = 0;
let destIndex = 0;
for (const pageNumber of pagesToDelete) {
const pageIndex = pageNumber - 1;
if (pageIndex !== sourceIndex) {
newPageNumberToId.set(
pageNumberToId.subarray(sourceIndex, pageIndex),
destIndex
);
destIndex += pageIndex - sourceIndex;
}
sourceIndex = pageIndex + 1;
}
if (sourceIndex < pageNumberToId.length) {
newPageNumberToId.set(pageNumberToId.subarray(sourceIndex), destIndex);
}
this.#updatePrevPageNumbers(prevIdToPageNumber, new Set(pagesToDelete));
}
cancelDelete() {
if (this.#savedData) {
this.#pageNumberToId = this.#savedData.pageNumberToId;
this.#pagesNumber = this.#savedData.pagesNumber;
this.#prevPageNumbers = this.#savedData.prevPageNumbers;
this.#savedData = null;
}
}
cleanSavedData() {
this.#savedData = null;
}
/**
* Records which pages are being copied so that pastePages can insert them.
* @param {Uint32Array} pagesToCopy - Page numbers to copy (1-indexed).
*/
copyPages(pagesToCopy) {
this.#ensureInit();
this.#clipboard = {
pageNumbers: pagesToCopy,
pageIds: pagesToCopy.map(n => this.#pageNumberToId[n - 1]),
};
}
cancelCopy() {
this.#clipboard = null;
}
/**
* Inserts the previously copied pages at the given position.
* @param {number} index - Zero-based insertion index in the page-number list.
*/
pastePages(index) {
this.#ensureInit();
const pageNumberToId = this.#pageNumberToId;
const prevIdToPageNumber = this.#buildIdToPageNumber();
const { pageNumbers: copiedPageNumbers, pageIds: copiedPageIds } =
this.#clipboard;
const newN = this.#pagesNumber + copiedPageNumbers.length;
this.#pagesNumber = newN;
const newPageNumberToId = (this.#pageNumberToId = new Uint32Array(newN));
this.#prevPageNumbers = new Int32Array(newN);
newPageNumberToId.set(pageNumberToId.subarray(0, index), 0);
newPageNumberToId.set(copiedPageIds, index);
newPageNumberToId.set(
pageNumberToId.subarray(index),
index + copiedPageNumbers.length
);
this.#updatePrevPageNumbers(
prevIdToPageNumber,
null,
index,
copiedPageNumbers
);
this.#clipboard = null;
}
/**
* Recomputes #prevPageNumbers after a mutation, using the pre-mutation
* id to pageNumbers map to track where each page came from.
*
* @param {Map<number, Array<number>>} prevIdToPageNumber - Id to pageNumbers
* before the mutation.
* @param {Set<number>|null} [deletedPageNumbers] - Page numbers that were
* deleted (so their old positions are skipped).
* @param {number} [pasteIndex] - If this is a paste, the zero-based
* insertion index; paired with copiedPageNumbers.
* @param {Uint32Array} [copiedPageNumbers] - Source page numbers of the
* pasted pages; paired with pasteIndex.
*/
#updatePrevPageNumbers(
prevIdToPageNumber,
deletedPageNumbers = null,
pasteIndex = -1,
copiedPageNumbers = null
) {
const prevPageNumbers = this.#prevPageNumbers;
const newPageNumberToId = this.#pageNumberToId;
const pasteEnd = pasteIndex + (copiedPageNumbers?.length ?? 0);
const idsIndices = new Map();
for (let i = 0, ii = this.#pagesNumber; i < ii; i++) {
if (i >= pasteIndex && i < pasteEnd) {
// Negative value signals this page is a copy; encodes its source.
prevPageNumbers[i] = -copiedPageNumbers[i - pasteIndex];
continue;
}
const id = newPageNumberToId[i];
const oldPositions = prevIdToPageNumber.get(id);
let j = idsIndices.get(id) || 0;
if (deletedPageNumbers && oldPositions) {
while (
j < oldPositions.length &&
deletedPageNumbers.has(oldPositions[j])
) {
j++;
}
}
prevPageNumbers[i] = oldPositions?.[j];
idsIndices.set(id, j + 1);
}
}
/**
* Checks if the page mappings have been altered from their initial state.
* @returns {boolean}
*/
hasBeenAltered() {
return this.#pageNumberToId !== null;
}
/**
* Gets the current page mapping suitable for saving.
* @param {Map<number, Array<number>>} [idToPageNumber]
* @returns {Array<Object>}
*/
getPageMappingForSaving(idToPageNumber = null) {
idToPageNumber ??= this.#buildIdToPageNumber();
// idToPageNumber maps used 1-based IDs to 1-based page numbers.
// For example if the final pdf contains page 3 twice and they are moved at
// page 1 and 4, then it contains:
// pageNumberToId = [3, ., ., 3, ...,]
// idToPageNumber = {3: [1, 4], ...}
// In such a case we need to take a page 3 from the original pdf and take
// page 3 from a "copy".
// So we need to pass to the api something like:
// [ {
// document: null // this pdf
// includePages: [ 2, ... ], // page 3 is at index 2
// pageIndices: [0, ...], // page 3 will be at index 0 in the new pdf
// }, {
// document: null // this pdf
// includePages: [ 2, ... ], // page 3 is at index 2
// pageIndices: [3, ...], // page 3 will be at index 3 in the new pdf
// }
// ]
let nCopy = 0;
for (const pageNumbers of idToPageNumber.values()) {
nCopy = Math.max(nCopy, pageNumbers.length);
}
const extractParams = new Array(nCopy);
for (let i = 0; i < nCopy; i++) {
extractParams[i] = {
document: null,
pageIndices: [],
includePages: [],
};
}
for (const [id, pageNumbers] of idToPageNumber) {
for (let i = 0, ii = pageNumbers.length; i < ii; i++) {
extractParams[i].includePages.push([id - 1, pageNumbers[i] - 1]);
}
}
for (const { includePages, pageIndices } of extractParams) {
includePages.sort((a, b) => a[0] - b[0]);
for (let i = 0, ii = includePages.length; i < ii; i++) {
pageIndices.push(includePages[i][1]);
includePages[i] = includePages[i][0];
}
}
return extractParams;
}
extractPages(extractedPageNumbers) {
extractedPageNumbers = Array.from(extractedPageNumbers).sort(
(a, b) => a - b
);
const usedIds = new Map();
for (let i = 0, ii = extractedPageNumbers.length; i < ii; i++) {
const id = this.getPageId(extractedPageNumbers[i]);
const usedPageNumbers = usedIds.getOrInsertComputed(id, makeArr);
usedPageNumbers.push(i + 1);
}
return this.getPageMappingForSaving(usedIds);
}
/**
* Gets the previous page number for a given page number.
* Negative values indicate a copied page (the absolute value is the source).
* @param {number} pageNumber
* @returns {number}
*/
getPrevPageNumber(pageNumber) {
return this.#prevPageNumbers?.[pageNumber - 1] ?? 0;
}
/**
* Gets the first page number that currently maps to the given page ID.
* @param {number} id - The page ID (1-indexed).
* @returns {number} The page number, or 0 if not found.
*/
getPageNumber(id) {
if (!this.#pageNumberToId) {
return id; // identity mapping
}
const pageNumberToId = this.#pageNumberToId;
for (let i = 0, ii = this.#pagesNumber; i < ii; i++) {
if (pageNumberToId[i] === id) {
return i + 1;
}
}
return 0;
}
/**
* Gets the page ID for a given page number.
* @param {number} pageNumber - The page number (1-indexed).
* @returns {number} The page ID, or the page number itself if no mapping
* exists.
*/
getPageId(pageNumber) {
return this.#pageNumberToId?.[pageNumber - 1] ?? pageNumber;
}
getMapping() {
return this.#pageNumberToId?.subarray(0, this.pagesNumber);
}
}
export { PagesMapper };