Set a pages mapper per loaded document

It fixes #20629.
This commit is contained in:
calixteman 2026-02-08 19:40:17 +01:00
parent 2b95a8eb38
commit 4b4ab10c54
No known key found for this signature in database
GPG Key ID: 0C5442631EE0691F
9 changed files with 104 additions and 80 deletions

View File

@ -233,6 +233,9 @@ const RENDERING_CANCELLED_TIMEOUT = 100; // ms
* The default value is {DOMFilterFactory}. * The default value is {DOMFilterFactory}.
* @property {boolean} [enableHWA] - Enables hardware acceleration for * @property {boolean} [enableHWA] - Enables hardware acceleration for
* rendering. The default value is `false`. * rendering. The default value is `false`.
* @property {Object} [pagesMapper] - The pages mapper that will be used to map
* page ids and page numbers. It's used when the page order is changed or some
* pages are removed, cloned, etc.
*/ */
/** /**
@ -342,6 +345,7 @@ function getDocument(src = {}) {
: DOMFilterFactory); : DOMFilterFactory);
const enableHWA = src.enableHWA === true; const enableHWA = src.enableHWA === true;
const useWasm = src.useWasm !== false; const useWasm = src.useWasm !== false;
const pagesMapper = src.pagesMapper || new PagesMapper();
// Parameters whose default values depend on other parameters. // Parameters whose default values depend on other parameters.
const length = rangeTransport ? rangeTransport.length : (src.length ?? NaN); const length = rangeTransport ? rangeTransport.length : (src.length ?? NaN);
@ -511,7 +515,8 @@ function getDocument(src = {}) {
task, task,
networkStream, networkStream,
transportParams, transportParams,
transportFactory transportFactory,
pagesMapper
); );
task._transport = transport; task._transport = transport;
messageHandler.send("Ready", null); messageHandler.send("Ready", null);
@ -761,6 +766,13 @@ class PDFDocumentProxy {
} }
} }
/**
* @type {PagesMapper} The pages mapper instance.
*/
get pagesMapper() {
return this._transport.pagesMapper;
}
/** /**
* @type {AnnotationStorage} Storage for annotation data in forms. * @type {AnnotationStorage} Storage for annotation data in forms.
*/ */
@ -1324,9 +1336,9 @@ class PDFDocumentProxy {
class PDFPageProxy { class PDFPageProxy {
#pendingCleanup = false; #pendingCleanup = false;
#pagesMapper = PagesMapper.instance; #pagesMapper = null;
constructor(pageIndex, pageInfo, transport, pdfBug = false) { constructor(pageIndex, pageInfo, transport, pagesMapper, pdfBug = false) {
this._pageIndex = pageIndex; this._pageIndex = pageIndex;
this._pageInfo = pageInfo; this._pageInfo = pageInfo;
this._transport = transport; this._transport = transport;
@ -1339,6 +1351,7 @@ class PDFPageProxy {
this._intentStates = new Map(); this._intentStates = new Map();
this.destroyed = false; this.destroyed = false;
this.recordedBBoxes = null; this.recordedBBoxes = null;
this.#pagesMapper = pagesMapper;
} }
/** /**
@ -2402,9 +2415,14 @@ class WorkerTransport {
#passwordCapability = null; #passwordCapability = null;
#pagesMapper = PagesMapper.instance; constructor(
messageHandler,
constructor(messageHandler, loadingTask, networkStream, params, factory) { loadingTask,
networkStream,
params,
factory,
pagesMapper
) {
this.messageHandler = messageHandler; this.messageHandler = messageHandler;
this.loadingTask = loadingTask; this.loadingTask = loadingTask;
this.#networkStream = networkStream; this.#networkStream = networkStream;
@ -2429,7 +2447,8 @@ class WorkerTransport {
this.setupMessageHandler(); this.setupMessageHandler();
this.#pagesMapper.addListener(this.#updateCaches.bind(this)); this.pagesMapper = pagesMapper;
this.pagesMapper.addListener(this.#updateCaches.bind(this));
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) { if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
// For testing purposes. // For testing purposes.
@ -2458,8 +2477,8 @@ class WorkerTransport {
#updateCaches() { #updateCaches() {
const newPageCache = new Map(); const newPageCache = new Map();
const newPromiseCache = new Map(); const newPromiseCache = new Map();
for (let i = 0, ii = this.#pagesMapper.pagesNumber; i < ii; i++) { for (let i = 0, ii = this.pagesMapper.pagesNumber; i < ii; i++) {
const prevPageIndex = this.#pagesMapper.getPrevPageNumber(i + 1) - 1; const prevPageIndex = this.pagesMapper.getPrevPageNumber(i + 1) - 1;
const page = this.#pageCache.get(prevPageIndex); const page = this.#pageCache.get(prevPageIndex);
if (page) { if (page) {
newPageCache.set(i, page); newPageCache.set(i, page);
@ -2730,7 +2749,7 @@ class WorkerTransport {
}); });
messageHandler.on("GetDoc", ({ pdfInfo }) => { messageHandler.on("GetDoc", ({ pdfInfo }) => {
this.#pagesMapper.pagesNumber = pdfInfo.numPages; this.pagesMapper.pagesNumber = pdfInfo.numPages;
this._numPages = pdfInfo.numPages; this._numPages = pdfInfo.numPages;
this._htmlForXfa = pdfInfo.htmlForXfa; this._htmlForXfa = pdfInfo.htmlForXfa;
delete pdfInfo.htmlForXfa; delete pdfInfo.htmlForXfa;
@ -2947,12 +2966,12 @@ class WorkerTransport {
if ( if (
!Number.isInteger(pageNumber) || !Number.isInteger(pageNumber) ||
pageNumber <= 0 || pageNumber <= 0 ||
pageNumber > this.#pagesMapper.pagesNumber pageNumber > this.pagesMapper.pagesNumber
) { ) {
return Promise.reject(new Error("Invalid page request.")); return Promise.reject(new Error("Invalid page request."));
} }
const pageIndex = pageNumber - 1; const pageIndex = pageNumber - 1;
const newPageIndex = this.#pagesMapper.getPageId(pageNumber) - 1; const newPageIndex = this.pagesMapper.getPageId(pageNumber) - 1;
const cachedPromise = this.#pagePromises.get(pageIndex); const cachedPromise = this.#pagePromises.get(pageIndex);
if (cachedPromise) { if (cachedPromise) {
@ -2974,6 +2993,7 @@ class WorkerTransport {
pageIndex, pageIndex,
pageInfo, pageInfo,
this, this,
this.pagesMapper,
this._params.pdfBug this._params.pdfBug
); );
this.#pageCache.set(pageIndex, page); this.#pageCache.set(pageIndex, page);
@ -2991,12 +3011,12 @@ class WorkerTransport {
num: ref.num, num: ref.num,
gen: ref.gen, gen: ref.gen,
}); });
return this.#pagesMapper.getPageNumber(index + 1) - 1; return this.pagesMapper.getPageNumber(index + 1) - 1;
} }
getAnnotations(pageIndex, intent) { getAnnotations(pageIndex, intent) {
return this.messageHandler.sendWithPromise("GetAnnotations", { return this.messageHandler.sendWithPromise("GetAnnotations", {
pageIndex: this.#pagesMapper.getPageId(pageIndex + 1) - 1, pageIndex: this.pagesMapper.getPageId(pageIndex + 1) - 1,
intent, intent,
}); });
} }
@ -3063,13 +3083,13 @@ class WorkerTransport {
getPageJSActions(pageIndex) { getPageJSActions(pageIndex) {
return this.messageHandler.sendWithPromise("GetPageJSActions", { return this.messageHandler.sendWithPromise("GetPageJSActions", {
pageIndex: this.#pagesMapper.getPageId(pageIndex + 1) - 1, pageIndex: this.pagesMapper.getPageId(pageIndex + 1) - 1,
}); });
} }
getStructTree(pageIndex) { getStructTree(pageIndex) {
return this.messageHandler.sendWithPromise("GetStructTree", { return this.messageHandler.sendWithPromise("GetStructTree", {
pageIndex: this.#pagesMapper.getPageId(pageIndex + 1) - 1, pageIndex: this.pagesMapper.getPageId(pageIndex + 1) - 1,
}); });
} }
@ -3141,7 +3161,7 @@ class WorkerTransport {
const refStr = ref.gen === 0 ? `${ref.num}R` : `${ref.num}R${ref.gen}`; const refStr = ref.gen === 0 ? `${ref.num}R` : `${ref.num}R${ref.gen}`;
const pageIndex = this.#pageRefCache.get(refStr); const pageIndex = this.#pageRefCache.get(refStr);
return pageIndex >= 0 return pageIndex >= 0
? this.#pagesMapper.getPageNumber(pageIndex + 1) ? this.pagesMapper.getPageNumber(pageIndex + 1)
: null; : null;
} }
} }

View File

@ -1045,38 +1045,38 @@ class PagesMapper {
* Maps page IDs to their corresponding page numbers. * Maps page IDs to their corresponding page numbers.
* @type {Uint32Array|null} * @type {Uint32Array|null}
*/ */
static #idToPageNumber = null; #idToPageNumber = null;
/** /**
* Maps page numbers to their corresponding page IDs. * Maps page numbers to their corresponding page IDs.
* @type {Uint32Array|null} * @type {Uint32Array|null}
*/ */
static #pageNumberToId = null; #pageNumberToId = null;
/** /**
* Previous mapping of page IDs to page numbers. * Previous mapping of page IDs to page numbers.
* @type {Uint32Array|null} * @type {Uint32Array|null}
*/ */
static #prevIdToPageNumber = null; #prevIdToPageNumber = null;
/** /**
* The total number of pages. * The total number of pages.
* @type {number} * @type {number}
*/ */
static #pagesNumber = 0; #pagesNumber = 0;
/** /**
* Listeners for page changes. * Listeners for page changes.
* @type {Array<function>} * @type {Array<function>}
*/ */
static #listeners = []; #listeners = [];
/** /**
* Gets the total number of pages. * Gets the total number of pages.
* @returns {number} The number of pages. * @returns {number} The number of pages.
*/ */
get pagesNumber() { get pagesNumber() {
return PagesMapper.#pagesNumber; return this.#pagesNumber;
} }
/** /**
@ -1085,52 +1085,49 @@ class PagesMapper {
* @param {number} n - The total number of pages. * @param {number} n - The total number of pages.
*/ */
set pagesNumber(n) { set pagesNumber(n) {
if (PagesMapper.#pagesNumber === n) { if (this.#pagesNumber === n) {
return; return;
} }
PagesMapper.#pagesNumber = n; this.#pagesNumber = n;
if (n === 0) { if (n === 0) {
PagesMapper.#pageNumberToId = null; this.#pageNumberToId = null;
PagesMapper.#idToPageNumber = null; this.#idToPageNumber = null;
} }
} }
addListener(listener) { addListener(listener) {
PagesMapper.#listeners.push(listener); this.#listeners.push(listener);
} }
removeListener(listener) { removeListener(listener) {
const index = PagesMapper.#listeners.indexOf(listener); const index = this.#listeners.indexOf(listener);
if (index >= 0) { if (index >= 0) {
PagesMapper.#listeners.splice(index, 1); this.#listeners.splice(index, 1);
} }
} }
#updateListeners() { #updateListeners() {
for (const listener of PagesMapper.#listeners) { for (const listener of this.#listeners) {
listener(); listener();
} }
} }
#init(mustInit) { #init(mustInit) {
if (PagesMapper.#pageNumberToId) { if (this.#pageNumberToId) {
return; return;
} }
const n = PagesMapper.#pagesNumber; const n = this.#pagesNumber;
// Allocate a single array for better memory locality. // Allocate a single array for better memory locality.
const array = new Uint32Array(3 * n); const array = new Uint32Array(3 * n);
const pageNumberToId = (PagesMapper.#pageNumberToId = array.subarray(0, n)); const pageNumberToId = (this.#pageNumberToId = array.subarray(0, n));
const idToPageNumber = (PagesMapper.#idToPageNumber = array.subarray( const idToPageNumber = (this.#idToPageNumber = array.subarray(n, 2 * n));
n,
2 * n
));
if (mustInit) { if (mustInit) {
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
pageNumberToId[i] = idToPageNumber[i] = i + 1; pageNumberToId[i] = idToPageNumber[i] = i + 1;
} }
} }
PagesMapper.#prevIdToPageNumber = array.subarray(2 * n); this.#prevIdToPageNumber = array.subarray(2 * n);
} }
/** /**
@ -1143,9 +1140,9 @@ class PagesMapper {
*/ */
movePages(selectedPages, pagesToMove, index) { movePages(selectedPages, pagesToMove, index) {
this.#init(true); this.#init(true);
const pageNumberToId = PagesMapper.#pageNumberToId; const pageNumberToId = this.#pageNumberToId;
const idToPageNumber = PagesMapper.#idToPageNumber; const idToPageNumber = this.#idToPageNumber;
PagesMapper.#prevIdToPageNumber.set(idToPageNumber); this.#prevIdToPageNumber.set(idToPageNumber);
const movedCount = pagesToMove.length; const movedCount = pagesToMove.length;
const mappedPagesToMove = new Uint32Array(movedCount); const mappedPagesToMove = new Uint32Array(movedCount);
let removedBeforeTarget = 0; let removedBeforeTarget = 0;
@ -1158,7 +1155,7 @@ class PagesMapper {
} }
} }
const pagesNumber = PagesMapper.#pagesNumber; const pagesNumber = this.#pagesNumber;
// target index after removing elements that were before it // target index after removing elements that were before it
let adjustedTarget = index - removedBeforeTarget; let adjustedTarget = index - removedBeforeTarget;
const remainingLen = pagesNumber - movedCount; const remainingLen = pagesNumber - movedCount;
@ -1201,7 +1198,7 @@ class PagesMapper {
* @returns {boolean} True if the mappings have been altered, false otherwise. * @returns {boolean} True if the mappings have been altered, false otherwise.
*/ */
hasBeenAltered() { hasBeenAltered() {
return PagesMapper.#pageNumberToId !== null; return this.#pageNumberToId !== null;
} }
/** /**
@ -1211,16 +1208,14 @@ class PagesMapper {
getPageMappingForSaving() { getPageMappingForSaving() {
// Saving is index-based. // Saving is index-based.
return { return {
pageIndices: PagesMapper.#idToPageNumber pageIndices: this.#idToPageNumber
? PagesMapper.#idToPageNumber.map(x => x - 1) ? this.#idToPageNumber.map(x => x - 1)
: null, : null,
}; };
} }
getPrevPageNumber(pageNumber) { getPrevPageNumber(pageNumber) {
return PagesMapper.#prevIdToPageNumber[ return this.#prevIdToPageNumber[this.#pageNumberToId[pageNumber - 1] - 1];
PagesMapper.#pageNumberToId[pageNumber - 1] - 1
];
} }
/** /**
@ -1229,7 +1224,7 @@ class PagesMapper {
* @returns {number} The page number, or the ID itself if no mapping exists. * @returns {number} The page number, or the ID itself if no mapping exists.
*/ */
getPageNumber(id) { getPageNumber(id) {
return PagesMapper.#idToPageNumber?.[id - 1] ?? id; return this.#idToPageNumber?.[id - 1] ?? id;
} }
/** /**
@ -1239,19 +1234,11 @@ class PagesMapper {
* exists. * exists.
*/ */
getPageId(pageNumber) { getPageId(pageNumber) {
return PagesMapper.#pageNumberToId?.[pageNumber - 1] ?? pageNumber; return this.#pageNumberToId?.[pageNumber - 1] ?? pageNumber;
}
/**
* Gets or creates a singleton instance of PagesMapper.
* @returns {PagesMapper} The singleton instance.
*/
static get instance() {
return shadow(this, "instance", new PagesMapper());
} }
getMapping() { getMapping() {
return PagesMapper.#pageNumberToId.subarray(0, this.pagesNumber); return this.#pageNumberToId.subarray(0, this.pagesNumber);
} }
} }

View File

@ -57,7 +57,6 @@ import {
isPdfFile, isPdfFile,
noContextMenu, noContextMenu,
OutputScale, OutputScale,
PagesMapper,
PDFDateString, PDFDateString,
PixelsPerInch, PixelsPerInch,
RenderingCancelledException, RenderingCancelledException,
@ -129,7 +128,6 @@ globalThis.pdfjsLib = {
normalizeUnicode, normalizeUnicode,
OPS, OPS,
OutputScale, OutputScale,
PagesMapper,
PasswordResponses, PasswordResponses,
PDFDataRangeTransport, PDFDataRangeTransport,
PDFDateString, PDFDateString,
@ -189,7 +187,6 @@ export {
normalizeUnicode, normalizeUnicode,
OPS, OPS,
OutputScale, OutputScale,
PagesMapper,
PasswordResponses, PasswordResponses,
PDFDataRangeTransport, PDFDataRangeTransport,
PDFDateString, PDFDateString,

View File

@ -104,12 +104,12 @@ function movePages(page, selectedPages, atIndex) {
return page.evaluate( return page.evaluate(
(selected, index) => { (selected, index) => {
const viewer = window.PDFViewerApplication.pdfViewer; const viewer = window.PDFViewerApplication.pdfViewer;
const pagesMapper = viewer.pdfDocument.pagesMapper;
const pagesToMove = Array.from(selected).sort((a, b) => a - b); const pagesToMove = Array.from(selected).sort((a, b) => a - b);
viewer.pagesMapper.pagesNumber = pagesMapper.pagesNumber = document.querySelectorAll(".page").length;
document.querySelectorAll(".page").length; pagesMapper.movePages(new Set(pagesToMove), pagesToMove, index);
viewer.pagesMapper.movePages(new Set(pagesToMove), pagesToMove, index);
window.PDFViewerApplication.eventBus.dispatch("pagesedited", { window.PDFViewerApplication.eventBus.dispatch("pagesedited", {
pagesMapper: viewer.pagesMapper, pagesMapper,
index, index,
pagesToMove, pagesToMove,
}); });

View File

@ -5403,6 +5403,36 @@ small scripts as well as for`);
}); });
}); });
describe("Multiple documents and pages mapper", function () {
it("should load multiple documents in parallel", async function () {
const loadingTask1 = getDocument(buildGetDocumentParams("pdkids.pdf"));
const loadingTask2 = getDocument(
buildGetDocumentParams("page_with_number.pdf")
);
const loadingTask3 = getDocument(buildGetDocumentParams("empty.pdf"));
const [pdfDoc1, pdfDoc2, pdfDoc3] = await Promise.all([
loadingTask1.promise,
loadingTask2.promise,
loadingTask3.promise,
]);
// Each document has its own pages mapper, so the number of pages
// should be correct for each document.
expect(pdfDoc1.numPages).toEqual(55);
expect(pdfDoc1.pagesMapper.pagesNumber).toEqual(55);
expect(pdfDoc2.numPages).toEqual(17);
expect(pdfDoc2.pagesMapper.pagesNumber).toEqual(17);
expect(pdfDoc3.numPages).toEqual(1);
expect(pdfDoc3.pagesMapper.pagesNumber).toEqual(1);
await Promise.all([
loadingTask1.destroy(),
loadingTask2.destroy(),
loadingTask3.destroy(),
]);
});
});
describe("PDF page editing", function () { describe("PDF page editing", function () {
const getPageRefs = async pdfDoc => { const getPageRefs = async pdfDoc => {
const refs = []; const refs = [];

View File

@ -48,7 +48,6 @@ import {
isPdfFile, isPdfFile,
noContextMenu, noContextMenu,
OutputScale, OutputScale,
PagesMapper,
PDFDateString, PDFDateString,
PixelsPerInch, PixelsPerInch,
RenderingCancelledException, RenderingCancelledException,
@ -113,7 +112,6 @@ const expectedAPI = Object.freeze({
normalizeUnicode, normalizeUnicode,
OPS, OPS,
OutputScale, OutputScale,
PagesMapper,
PasswordResponses, PasswordResponses,
PDFDataRangeTransport, PDFDataRangeTransport,
PDFDateString, PDFDateString,

View File

@ -25,7 +25,7 @@ import {
isValidRotation, isValidRotation,
watchScroll, watchScroll,
} from "./ui_utils.js"; } from "./ui_utils.js";
import { MathClamp, noContextMenu, PagesMapper, stopEvent } from "pdfjs-lib"; import { MathClamp, noContextMenu, stopEvent } from "pdfjs-lib";
import { Menu } from "./menu.js"; import { Menu } from "./menu.js";
import { PDFThumbnailView } from "./pdf_thumbnail_view.js"; import { PDFThumbnailView } from "./pdf_thumbnail_view.js";
import { RenderingStates } from "./renderable_view.js"; import { RenderingStates } from "./renderable_view.js";
@ -109,7 +109,7 @@ class PDFThumbnailViewer {
#currentScrollTop = 0; #currentScrollTop = 0;
#pagesMapper = PagesMapper.instance; #pagesMapper = null;
#manageSaveAsButton = null; #manageSaveAsButton = null;
@ -275,6 +275,7 @@ class PDFThumbnailViewer {
if (!pdfDocument) { if (!pdfDocument) {
return; return;
} }
this.#pagesMapper = pdfDocument.pagesMapper;
const firstPagePromise = pdfDocument.getPage(1); const firstPagePromise = pdfDocument.getPage(1);
const optionalContentConfigPromise = pdfDocument.getOptionalContentConfig({ const optionalContentConfigPromise = pdfDocument.getOptionalContentConfig({
intent: "display", intent: "display",

View File

@ -30,7 +30,6 @@ import {
AnnotationEditorUIManager, AnnotationEditorUIManager,
AnnotationMode, AnnotationMode,
MathClamp, MathClamp,
PagesMapper,
PermissionFlag, PermissionFlag,
PixelsPerInch, PixelsPerInch,
shadow, shadow,
@ -286,8 +285,6 @@ class PDFViewer {
#viewerAlert = null; #viewerAlert = null;
#pagesMapper = PagesMapper.instance;
/** /**
* @param {PDFViewerOptions} options * @param {PDFViewerOptions} options
*/ */
@ -299,9 +296,6 @@ class PDFViewer {
`The API version "${version}" does not match the Viewer version "${viewerVersion}".` `The API version "${version}" does not match the Viewer version "${viewerVersion}".`
); );
} }
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) {
this.pagesMapper = PagesMapper.instance;
}
this.container = options.container; this.container = options.container;
this.viewer = options.viewer || options.container.firstElementChild; this.viewer = options.viewer || options.container.firstElementChild;
@ -880,7 +874,6 @@ class PDFViewer {
this.#annotationEditorMode = AnnotationEditorType.NONE; this.#annotationEditorMode = AnnotationEditorType.NONE;
this.#printingAllowed = true; this.#printingAllowed = true;
this.#pagesMapper.pagesNumber = 0;
} }
this.pdfDocument = pdfDocument; this.pdfDocument = pdfDocument;

View File

@ -49,7 +49,6 @@ const {
normalizeUnicode, normalizeUnicode,
OPS, OPS,
OutputScale, OutputScale,
PagesMapper,
PasswordResponses, PasswordResponses,
PDFDataRangeTransport, PDFDataRangeTransport,
PDFDateString, PDFDateString,
@ -109,7 +108,6 @@ export {
normalizeUnicode, normalizeUnicode,
OPS, OPS,
OutputScale, OutputScale,
PagesMapper,
PasswordResponses, PasswordResponses,
PDFDataRangeTransport, PDFDataRangeTransport,
PDFDateString, PDFDateString,