mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-08-06 14:27:23 +02:00
When a viewer page was copied, each copy got its own annotationStorage entry, but newAnnotationsByPage was keyed only by source page. As a result, every output copy received all entries and reused their memoized references. Tag each entry with its rank among output copies of the same source page. The display and worker compute this rank independently; inserted documents preserve the order of copies. Keep unextracted entries at rank -1 so shared stamp bitmaps remain available without being written. Without ranks, new annotations are applied only to the first copy.
472 lines
14 KiB
JavaScript
472 lines
14 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 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 prevPageNumbers = new Int32Array(pagesNumber);
|
|
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];
|
|
prevPageNumbers[r++] = i + 1;
|
|
}
|
|
}
|
|
|
|
// Make room at the target and insert.
|
|
pageNumberToId.copyWithin(
|
|
adjustedTarget + movedCount,
|
|
adjustedTarget,
|
|
remainingLen
|
|
);
|
|
pageNumberToId.set(mappedPagesToMove, adjustedTarget);
|
|
prevPageNumbers.copyWithin(
|
|
adjustedTarget + movedCount,
|
|
adjustedTarget,
|
|
remainingLen
|
|
);
|
|
prevPageNumbers.set(pagesToMove, adjustedTarget);
|
|
this.#prevPageNumbers = prevPageNumbers;
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Builds the copy level of every page. Extracted pages are ranked, in page
|
|
* order, among the extracted pages sharing their page ID. Pages which aren't
|
|
* extracted get -1.
|
|
* @param {Array<number>} [extractedPageNumbers] - Sorted 1-based page
|
|
* numbers to extract, or null to extract everything.
|
|
* @returns {Int32Array|null} null when the page mapping is the identity.
|
|
*/
|
|
#buildCopyLevels(extractedPageNumbers = null) {
|
|
if (!this.#pageNumberToId) {
|
|
return null;
|
|
}
|
|
const copyLevels = new Int32Array(this.#pagesNumber).fill(-1);
|
|
const counts = new Map();
|
|
if (extractedPageNumbers) {
|
|
for (const pageNumber of extractedPageNumbers) {
|
|
const id = this.getPageId(pageNumber);
|
|
const level = counts.get(id) ?? 0;
|
|
counts.set(id, level + 1);
|
|
copyLevels[pageNumber - 1] = level;
|
|
}
|
|
} else {
|
|
for (let i = 0, ii = this.#pagesNumber; i < ii; i++) {
|
|
const id = this.#pageNumberToId[i];
|
|
const level = counts.get(id) ?? 0;
|
|
counts.set(id, level + 1);
|
|
copyLevels[i] = level;
|
|
}
|
|
}
|
|
return copyLevels;
|
|
}
|
|
|
|
/**
|
|
* Gets the current page mapping suitable for saving.
|
|
* @param {Map<number, Array<number>>} [idToPageNumber]
|
|
* @param {Int32Array} [copyLevels]
|
|
* @returns {{pageInfos: Array<Object>, copyLevels: Int32Array|null}}
|
|
*/
|
|
getPageMappingForSaving(
|
|
idToPageNumber = null,
|
|
copyLevels = this.#buildCopyLevels()
|
|
) {
|
|
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 { pageInfos: extractParams, copyLevels };
|
|
}
|
|
|
|
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,
|
|
this.#buildCopyLevels(extractedPageNumbers)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 };
|