From 9aa1ce8f140ac998a338c07deba1282ed889d54f Mon Sep 17 00:00:00 2001 From: Jonas Jenwald Date: Wed, 11 Mar 2026 12:19:42 +0100 Subject: [PATCH 1/3] Move the `PagesMapper` class into its own file The `PagesMapper` class currently makes up one third of the `src/display/display_utils.js` file size, and since its introduction it's grown (a fair bit) in size. Note that the intention with files such as `src/display/display_utils.js` was to have somewhere to place functionality too small/simple to deserve its own file. --- src/display/api.js | 2 +- src/display/display_utils.js | 460 --------------------------------- src/display/pages_mapper.js | 476 +++++++++++++++++++++++++++++++++++ 3 files changed, 477 insertions(+), 461 deletions(-) create mode 100644 src/display/pages_mapper.js diff --git a/src/display/api.js b/src/display/api.js index 2cea1c1e27e30..ea460d182d04d 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -47,7 +47,6 @@ import { deprecated, isDataScheme, isValidFetchUrl, - PagesMapper, PageViewport, RenderingCancelledException, StatTimer, @@ -82,6 +81,7 @@ import { DOMWasmFactory } from "display-wasm_factory"; import { GlobalWorkerOptions } from "./worker_options.js"; import { Metadata } from "./metadata.js"; import { OptionalContentConfig } from "./optional_content_config.js"; +import { PagesMapper } from "./pages_mapper.js"; import { PDFDataTransportStream } from "./transport_stream.js"; import { PDFFetchStream } from "display-fetch_stream"; import { PDFNetworkStream } from "display-network"; diff --git a/src/display/display_utils.js b/src/display/display_utils.js index 083399d111fc8..3cf8e7b6fd20a 100644 --- a/src/display/display_utils.js +++ b/src/display/display_utils.js @@ -17,7 +17,6 @@ import { BaseException, DrawOPS, FeatureTest, - makeArr, MathClamp, shadow, stripPath, @@ -1033,464 +1032,6 @@ function makePathFromDrawOPS(data) { return path; } -/** - * 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. - */ -class PagesMapper { - /** - * Maps page IDs to their corresponding page numbers. - * @type {Map>|null} - */ - #idToPageNumber = null; - - /** - * Maps page numbers to their corresponding page IDs. - * @type {Uint32Array|null} - */ - #pageNumberToId = null; - - /** - * Previous mapping of page IDs to page numbers. - * @type {Int32Array|null} - */ - #prevPageNumbers = null; - - /** - * The total number of pages. - * @type {number} - */ - #pagesNumber = 0; - - /** - * Listeners for page changes. - * @type {Array} - */ - #listeners = []; - - /** - * Maps page numbers to their corresponding page IDs (used in copy - * operations). - * @type {Uint32Array|null} - */ - #copiedPageIds = null; - - /** - * Maps page IDs to their corresponding page numbers, used in copy operations. - * @type {Uint32Array|null} - */ - #copiedPageNumbers = null; - - #savedData = null; - - /** - * Gets the total number of pages. - * @returns {number} The number of pages. - */ - get pagesNumber() { - return this.#pagesNumber; - } - - /** - * Sets the total number of pages and initializes default mappings - * where page IDs equal page numbers (1-indexed). - * @param {number} n - The total number of pages. - */ - set pagesNumber(n) { - if (this.#pagesNumber === n) { - return; - } - this.#pagesNumber = n; - this.#reset(); - } - - /** - * Resets the page mappings to their default state, where page IDs equal page - * numbers (1-indexed). This is called when the number of pages changes, or - * when the current mapping matches the default mapping after a move - * operation. - */ - #reset() { - this.#pageNumberToId = null; - this.#idToPageNumber = null; - } - - /** - * Adds a listener function that will be called whenever the page mappings - * are updated. - * @param {function} listener - */ - addListener(listener) { - this.#listeners.push(listener); - } - - /** - * Removes a previously added listener function. - * @param {function} listener - */ - removeListener(listener) { - const index = this.#listeners.indexOf(listener); - if (index >= 0) { - this.#listeners.splice(index, 1); - } - } - - /** - * Calls all registered listener functions to notify them of changes to the - * page mappings. - * @param {Object} data - An object containing information about the update. - */ - #updateListeners(data) { - for (const listener of this.#listeners) { - listener(data); - } - } - - /** - * Initializes the page mappings if they haven't been initialized yet. - * @param {boolean} mustInit - */ - #init(mustInit) { - if (this.#pageNumberToId) { - return; - } - const n = this.#pagesNumber; - - const pageNumberToId = (this.#pageNumberToId = new Uint32Array(n)); - this.#prevPageNumbers = new Int32Array(pageNumberToId); - const idToPageNumber = (this.#idToPageNumber = new Map()); - if (mustInit) { - for (let i = 1; i <= n; i++) { - pageNumberToId[i - 1] = i; - idToPageNumber.set(i, [i]); - } - } - } - - /** - * Updates the mapping from page IDs to page numbers based on the current - * mapping from page numbers to page IDs. This should be called after any - * changes to the page-number-to-ID mapping to keep the two mappings in sync. - */ - #updateIdToPageNumber() { - const idToPageNumber = this.#idToPageNumber; - const pageNumberToId = this.#pageNumberToId; - idToPageNumber.clear(); - 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]); - } - } - } - - /** - * Move a set of pages to a new position while keeping ID→number mappings in - * sync. - * - * @param {Set} 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.#init(true); - const pageNumberToId = this.#pageNumberToId; - const idToPageNumber = this.#idToPageNumber; - 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 += 1; - } - } - - const pagesNumber = this.#pagesNumber; - // target index after removing elements that were before it - let adjustedTarget = index - removedBeforeTarget; - const remainingLen = pagesNumber - movedCount; - adjustedTarget = MathClamp(adjustedTarget, 0, remainingLen); - - // Create the new mapping. - // First copy over the pages that are not being moved. - // Then insert the moved pages at the target position. - for (let i = 0, r = 0; i < pagesNumber; i++) { - if (!selectedPages.has(i + 1)) { - pageNumberToId[r++] = pageNumberToId[i]; - } - } - - // Shift the pages after the target position. - pageNumberToId.copyWithin( - adjustedTarget + movedCount, - adjustedTarget, - remainingLen - ); - // Finally insert the moved pages. - pageNumberToId.set(mappedPagesToMove, adjustedTarget); - - this.#setPrevPageNumbers(idToPageNumber, null); - this.#updateIdToPageNumber(); - this.#updateListeners({ type: "move" }); - - if (pageNumberToId.every((id, i) => id === i + 1)) { - this.#reset(); - } - } - - /** - * Deletes a set of pages while keeping ID→number mappings in sync. - * @param {Array} pagesToDelete - Page numbers to delete (1-indexed). - * These must be unique and sorted in ascending order. - */ - deletePages(pagesToDelete) { - this.#init(true); - const pageNumberToId = this.#pageNumberToId; - const prevIdToPageNumber = this.#idToPageNumber; - - this.#savedData = { - pageNumberToId: pageNumberToId.slice(), - idToPageNumber: new Map(prevIdToPageNumber), - pageNumber: this.#pagesNumber, - prevPageNumbers: this.#prevPageNumbers.slice(), - }; - - this.pagesNumber -= pagesToDelete.length; - this.#init(false); - const newPageNumberToId = this.#pageNumberToId; - - 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.#setPrevPageNumbers(prevIdToPageNumber, null); - this.#updateIdToPageNumber(); - this.#updateListeners({ type: "delete", pageNumbers: pagesToDelete }); - } - - cancelDelete() { - if (this.#savedData) { - this.#pageNumberToId = this.#savedData.pageNumberToId; - this.#idToPageNumber = this.#savedData.idToPageNumber; - this.pagesNumber = this.#savedData.pageNumber; - this.#prevPageNumbers = this.#savedData.prevPageNumbers; - this.#savedData = null; - this.#updateListeners({ type: "cancelDelete" }); - } - } - - cleanSavedData() { - this.#savedData = null; - this.#updateListeners({ type: "cleanSavedData" }); - } - - /** - * Copies a set of pages while keeping ID→number mappings in sync. - * @param {Uint32Array} pagesToCopy - Page numbers to copy (1-indexed). - */ - copyPages(pagesToCopy) { - this.#init(true); - this.#copiedPageNumbers = pagesToCopy; - this.#copiedPageIds = pagesToCopy.map( - pageNumber => this.#pageNumberToId[pageNumber - 1] - ); - this.#updateListeners({ type: "copy", pageNumbers: pagesToCopy }); - } - - cancelCopy() { - this.#copiedPageIds = null; - this.#copiedPageNumbers = null; - this.#updateListeners({ type: "cancelCopy" }); - } - - /** - * Pastes a set of pages while keeping ID→number mappings in sync. - * @param {number} index - Zero-based insertion index in the page-number list. - */ - pastePages(index) { - this.#init(true); - const pageNumberToId = this.#pageNumberToId; - const prevIdToPageNumber = this.#idToPageNumber; - const copiedPageNumbers = this.#copiedPageNumbers; - - const copiedPageMapping = new Map(); - let base = index; - for (const pageNumber of copiedPageNumbers) { - copiedPageMapping.set(++base, pageNumber); - } - this.pagesNumber += copiedPageNumbers.length; - this.#init(false); - const newPageNumberToId = this.#pageNumberToId; - - newPageNumberToId.set(pageNumberToId.subarray(0, index), 0); - newPageNumberToId.set(this.#copiedPageIds, index); - newPageNumberToId.set( - pageNumberToId.subarray(index), - index + copiedPageNumbers.length - ); - - this.#setPrevPageNumbers(prevIdToPageNumber, copiedPageMapping); - this.#updateIdToPageNumber(); - this.#updateListeners({ type: "paste" }); - - this.#copiedPageIds = null; - this.#copiedPageNumbers = null; - } - - /** - * Updates the previous page numbers based on the current page-number-to-ID - * mapping and the provided previous ID-to-page-number mapping. - * This is used to keep track of the original page numbers for each page ID. - * @param {Map} prevIdToPageNumber - * @param {Map|null} copiedPageMapping - */ - #setPrevPageNumbers(prevIdToPageNumber, copiedPageMapping) { - const prevPageNumbers = this.#prevPageNumbers; - const newPageNumberToId = this.#pageNumberToId; - const idsIndices = new Map(); - for (let i = 0, ii = this.#pagesNumber; i < ii; i++) { - const oldPageNumber = copiedPageMapping?.get(i + 1); - if (oldPageNumber) { - prevPageNumbers[i] = -oldPageNumber; - continue; - } - const id = newPageNumberToId[i]; - const j = idsIndices.get(id) || 0; - prevPageNumbers[i] = prevIdToPageNumber.get(id)?.[j]; - idsIndices.set(id, j + 1); - } - } - - /** - * Checks if the page mappings have been altered from their initial state. - * @returns {boolean} True if the mappings have been altered, false otherwise. - */ - hasBeenAltered() { - return this.#pageNumberToId !== null; - } - - /** - * Gets the current page mapping suitable for saving. - * @returns {Object} An object containing the page indices. - */ - getPageMappingForSaving(idToPageNumber = this.#idToPageNumber) { - // 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. - * @param {number} pageNumber - * @returns {number} The previous page number for the given page number, or 0 - * if no mapping exists. - */ - getPrevPageNumber(pageNumber) { - return this.#prevPageNumbers[pageNumber - 1] ?? 0; - } - - /** - * Gets the page number for a given page ID. - * @param {number} id - The page ID (1-indexed). - * @returns {number} The page number, or 0 if no mapping exists. - */ - getPageNumber(id) { - return this.#idToPageNumber ? (this.#idToPageNumber.get(id)?.[0] ?? 0) : id; - } - - /** - * 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 { applyOpacity, ColorScheme, @@ -1511,7 +1052,6 @@ export { makePathFromDrawOPS, noContextMenu, OutputScale, - PagesMapper, PageViewport, PDFDateString, PixelsPerInch, diff --git a/src/display/pages_mapper.js b/src/display/pages_mapper.js new file mode 100644 index 0000000000000..2ada560d6d5c7 --- /dev/null +++ b/src/display/pages_mapper.js @@ -0,0 +1,476 @@ +/* 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, MathClamp } from "../shared/util.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. + */ +class PagesMapper { + /** + * Maps page IDs to their corresponding page numbers. + * @type {Map>|null} + */ + #idToPageNumber = null; + + /** + * Maps page numbers to their corresponding page IDs. + * @type {Uint32Array|null} + */ + #pageNumberToId = null; + + /** + * Previous mapping of page IDs to page numbers. + * @type {Int32Array|null} + */ + #prevPageNumbers = null; + + /** + * The total number of pages. + * @type {number} + */ + #pagesNumber = 0; + + /** + * Listeners for page changes. + * @type {Array} + */ + #listeners = []; + + /** + * Maps page numbers to their corresponding page IDs (used in copy + * operations). + * @type {Uint32Array|null} + */ + #copiedPageIds = null; + + /** + * Maps page IDs to their corresponding page numbers, used in copy operations. + * @type {Uint32Array|null} + */ + #copiedPageNumbers = null; + + #savedData = null; + + /** + * Gets the total number of pages. + * @returns {number} The number of pages. + */ + get pagesNumber() { + return this.#pagesNumber; + } + + /** + * Sets the total number of pages and initializes default mappings + * where page IDs equal page numbers (1-indexed). + * @param {number} n - The total number of pages. + */ + set pagesNumber(n) { + if (this.#pagesNumber === n) { + return; + } + this.#pagesNumber = n; + this.#reset(); + } + + /** + * Resets the page mappings to their default state, where page IDs equal page + * numbers (1-indexed). This is called when the number of pages changes, or + * when the current mapping matches the default mapping after a move + * operation. + */ + #reset() { + this.#pageNumberToId = null; + this.#idToPageNumber = null; + } + + /** + * Adds a listener function that will be called whenever the page mappings + * are updated. + * @param {function} listener + */ + addListener(listener) { + this.#listeners.push(listener); + } + + /** + * Removes a previously added listener function. + * @param {function} listener + */ + removeListener(listener) { + const index = this.#listeners.indexOf(listener); + if (index >= 0) { + this.#listeners.splice(index, 1); + } + } + + /** + * Calls all registered listener functions to notify them of changes to the + * page mappings. + * @param {Object} data - An object containing information about the update. + */ + #updateListeners(data) { + for (const listener of this.#listeners) { + listener(data); + } + } + + /** + * Initializes the page mappings if they haven't been initialized yet. + * @param {boolean} mustInit + */ + #init(mustInit) { + if (this.#pageNumberToId) { + return; + } + const n = this.#pagesNumber; + + const pageNumberToId = (this.#pageNumberToId = new Uint32Array(n)); + this.#prevPageNumbers = new Int32Array(pageNumberToId); + const idToPageNumber = (this.#idToPageNumber = new Map()); + if (mustInit) { + for (let i = 1; i <= n; i++) { + pageNumberToId[i - 1] = i; + idToPageNumber.set(i, [i]); + } + } + } + + /** + * Updates the mapping from page IDs to page numbers based on the current + * mapping from page numbers to page IDs. This should be called after any + * changes to the page-number-to-ID mapping to keep the two mappings in sync. + */ + #updateIdToPageNumber() { + const idToPageNumber = this.#idToPageNumber; + const pageNumberToId = this.#pageNumberToId; + idToPageNumber.clear(); + 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]); + } + } + } + + /** + * Move a set of pages to a new position while keeping ID→number mappings in + * sync. + * + * @param {Set} 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.#init(true); + const pageNumberToId = this.#pageNumberToId; + const idToPageNumber = this.#idToPageNumber; + 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 += 1; + } + } + + const pagesNumber = this.#pagesNumber; + // target index after removing elements that were before it + let adjustedTarget = index - removedBeforeTarget; + const remainingLen = pagesNumber - movedCount; + adjustedTarget = MathClamp(adjustedTarget, 0, remainingLen); + + // Create the new mapping. + // First copy over the pages that are not being moved. + // Then insert the moved pages at the target position. + for (let i = 0, r = 0; i < pagesNumber; i++) { + if (!selectedPages.has(i + 1)) { + pageNumberToId[r++] = pageNumberToId[i]; + } + } + + // Shift the pages after the target position. + pageNumberToId.copyWithin( + adjustedTarget + movedCount, + adjustedTarget, + remainingLen + ); + // Finally insert the moved pages. + pageNumberToId.set(mappedPagesToMove, adjustedTarget); + + this.#setPrevPageNumbers(idToPageNumber, null); + this.#updateIdToPageNumber(); + this.#updateListeners({ type: "move" }); + + if (pageNumberToId.every((id, i) => id === i + 1)) { + this.#reset(); + } + } + + /** + * Deletes a set of pages while keeping ID→number mappings in sync. + * @param {Array} pagesToDelete - Page numbers to delete (1-indexed). + * These must be unique and sorted in ascending order. + */ + deletePages(pagesToDelete) { + this.#init(true); + const pageNumberToId = this.#pageNumberToId; + const prevIdToPageNumber = this.#idToPageNumber; + + this.#savedData = { + pageNumberToId: pageNumberToId.slice(), + idToPageNumber: new Map(prevIdToPageNumber), + pageNumber: this.#pagesNumber, + prevPageNumbers: this.#prevPageNumbers.slice(), + }; + + this.pagesNumber -= pagesToDelete.length; + this.#init(false); + const newPageNumberToId = this.#pageNumberToId; + + 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.#setPrevPageNumbers(prevIdToPageNumber, null); + this.#updateIdToPageNumber(); + this.#updateListeners({ type: "delete", pageNumbers: pagesToDelete }); + } + + cancelDelete() { + if (this.#savedData) { + this.#pageNumberToId = this.#savedData.pageNumberToId; + this.#idToPageNumber = this.#savedData.idToPageNumber; + this.pagesNumber = this.#savedData.pageNumber; + this.#prevPageNumbers = this.#savedData.prevPageNumbers; + this.#savedData = null; + this.#updateListeners({ type: "cancelDelete" }); + } + } + + cleanSavedData() { + this.#savedData = null; + this.#updateListeners({ type: "cleanSavedData" }); + } + + /** + * Copies a set of pages while keeping ID→number mappings in sync. + * @param {Uint32Array} pagesToCopy - Page numbers to copy (1-indexed). + */ + copyPages(pagesToCopy) { + this.#init(true); + this.#copiedPageNumbers = pagesToCopy; + this.#copiedPageIds = pagesToCopy.map( + pageNumber => this.#pageNumberToId[pageNumber - 1] + ); + this.#updateListeners({ type: "copy", pageNumbers: pagesToCopy }); + } + + cancelCopy() { + this.#copiedPageIds = null; + this.#copiedPageNumbers = null; + this.#updateListeners({ type: "cancelCopy" }); + } + + /** + * Pastes a set of pages while keeping ID→number mappings in sync. + * @param {number} index - Zero-based insertion index in the page-number list. + */ + pastePages(index) { + this.#init(true); + const pageNumberToId = this.#pageNumberToId; + const prevIdToPageNumber = this.#idToPageNumber; + const copiedPageNumbers = this.#copiedPageNumbers; + + const copiedPageMapping = new Map(); + let base = index; + for (const pageNumber of copiedPageNumbers) { + copiedPageMapping.set(++base, pageNumber); + } + this.pagesNumber += copiedPageNumbers.length; + this.#init(false); + const newPageNumberToId = this.#pageNumberToId; + + newPageNumberToId.set(pageNumberToId.subarray(0, index), 0); + newPageNumberToId.set(this.#copiedPageIds, index); + newPageNumberToId.set( + pageNumberToId.subarray(index), + index + copiedPageNumbers.length + ); + + this.#setPrevPageNumbers(prevIdToPageNumber, copiedPageMapping); + this.#updateIdToPageNumber(); + this.#updateListeners({ type: "paste" }); + + this.#copiedPageIds = null; + this.#copiedPageNumbers = null; + } + + /** + * Updates the previous page numbers based on the current page-number-to-ID + * mapping and the provided previous ID-to-page-number mapping. + * This is used to keep track of the original page numbers for each page ID. + * @param {Map} prevIdToPageNumber + * @param {Map|null} copiedPageMapping + */ + #setPrevPageNumbers(prevIdToPageNumber, copiedPageMapping) { + const prevPageNumbers = this.#prevPageNumbers; + const newPageNumberToId = this.#pageNumberToId; + const idsIndices = new Map(); + for (let i = 0, ii = this.#pagesNumber; i < ii; i++) { + const oldPageNumber = copiedPageMapping?.get(i + 1); + if (oldPageNumber) { + prevPageNumbers[i] = -oldPageNumber; + continue; + } + const id = newPageNumberToId[i]; + const j = idsIndices.get(id) || 0; + prevPageNumbers[i] = prevIdToPageNumber.get(id)?.[j]; + idsIndices.set(id, j + 1); + } + } + + /** + * Checks if the page mappings have been altered from their initial state. + * @returns {boolean} True if the mappings have been altered, false otherwise. + */ + hasBeenAltered() { + return this.#pageNumberToId !== null; + } + + /** + * Gets the current page mapping suitable for saving. + * @returns {Object} An object containing the page indices. + */ + getPageMappingForSaving(idToPageNumber = this.#idToPageNumber) { + // 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. + * @param {number} pageNumber + * @returns {number} The previous page number for the given page number, or 0 + * if no mapping exists. + */ + getPrevPageNumber(pageNumber) { + return this.#prevPageNumbers[pageNumber - 1] ?? 0; + } + + /** + * Gets the page number for a given page ID. + * @param {number} id - The page ID (1-indexed). + * @returns {number} The page number, or 0 if no mapping exists. + */ + getPageNumber(id) { + return this.#idToPageNumber ? (this.#idToPageNumber.get(id)?.[0] ?? 0) : id; + } + + /** + * 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 }; From 63b4874b39b2e362102984d4da2cbd11dabb3d30 Mon Sep 17 00:00:00 2001 From: Jonas Jenwald Date: Wed, 11 Mar 2026 15:22:55 +0100 Subject: [PATCH 2/3] Remove the `enableHWA` option from viewer components (PR 20016 follow-up) In PR 20016 the actual uses of the `enableHWA` option was removed from the viewer, but for some reason it's still being provided when initializing `PDFViewer` and `PDFThumbnailViewer` despite the fact that it's now dead code. --- web/app.js | 5 +---- web/app_options.js | 2 +- web/pdf_thumbnail_viewer.js | 5 ----- web/pdf_viewer.js | 6 ------ 4 files changed, 2 insertions(+), 16 deletions(-) diff --git a/web/app.js b/web/app.js index 67cf5e942f8ac..211dc0ab6c17c 100644 --- a/web/app.js +++ b/web/app.js @@ -535,8 +535,7 @@ const PDFViewerApplication = { ) : null; - const enableHWA = AppOptions.get("enableHWA"), - maxCanvasPixels = AppOptions.get("maxCanvasPixels"), + const maxCanvasPixels = AppOptions.get("maxCanvasPixels"), maxCanvasDim = AppOptions.get("maxCanvasDim"), capCanvasAreaFactor = AppOptions.get("capCanvasAreaFactor"); const pdfViewer = (this.pdfViewer = new PDFViewer({ @@ -580,7 +579,6 @@ const PDFViewerApplication = { pageColors, mlManager, abortSignal, - enableHWA, supportsPinchToZoom: this.supportsPinchToZoom, enableAutoLinking: AppOptions.get("enableAutoLinking"), minDurationToUpdateCanvas: AppOptions.get("minDurationToUpdateCanvas"), @@ -601,7 +599,6 @@ const PDFViewerApplication = { maxCanvasDim, pageColors, abortSignal, - enableHWA, enableSplitMerge, statusBar: viewsManager.viewsManagerStatusBar, undoBar: viewsManager.viewsManagerUndoBar, diff --git a/web/app_options.js b/web/app_options.js index 186bcd018755b..63278d0da720b 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -453,7 +453,7 @@ const defaultOptions = { enableHWA: { /** @type {boolean} */ value: typeof PDFJSDev !== "undefined" && !PDFJSDev.test("MOZCENTRAL"), - kind: OptionKind.API + OptionKind.VIEWER + OptionKind.PREFERENCE, + kind: OptionKind.API + OptionKind.PREFERENCE, }, enableXfa: { /** @type {boolean} */ diff --git a/web/pdf_thumbnail_viewer.js b/web/pdf_thumbnail_viewer.js index 1e11acb94b7f7..f2d821cd1460a 100644 --- a/web/pdf_thumbnail_viewer.js +++ b/web/pdf_thumbnail_viewer.js @@ -64,8 +64,6 @@ const SPACE_FOR_DRAG_MARKER_WHEN_NO_NEXT_ELEMENT = 15; * mode. * @property {AbortSignal} [abortSignal] - The AbortSignal for the window * events. - * @property {boolean} [enableHWA] - Enables hardware acceleration for - * rendering. The default value is `false`. * @property {boolean} [enableSplitMerge] - Enables split and merge features. * The default value is `false`. * @property {Object} [statusBar] - The status bar elements to manage the status @@ -173,7 +171,6 @@ class PDFThumbnailViewer { maxCanvasDim, pageColors, abortSignal, - enableHWA, enableSplitMerge, statusBar, undoBar, @@ -188,7 +185,6 @@ class PDFThumbnailViewer { this.maxCanvasPixels = maxCanvasPixels; this.maxCanvasDim = maxCanvasDim; this.pageColors = pageColors || null; - this.enableHWA = enableHWA || false; this.#enableSplitMerge = enableSplitMerge || false; this.#statusLabel = statusBar?.viewsManagerStatusActionLabel || null; this.#statusBar = statusBar?.viewsManagerStatusAction || null; @@ -415,7 +411,6 @@ class PDFThumbnailViewer { maxCanvasPixels: this.maxCanvasPixels, maxCanvasDim: this.maxCanvasDim, pageColors: this.pageColors, - enableHWA: this.enableHWA, enableSplitMerge: this.#enableSplitMerge, }); this._thumbnails.push(thumbnail); diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index 3d547667b1b2b..68b568b9856f3 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -145,8 +145,6 @@ function isValidAnnotationEditorMode(mode) { * @property {Object} [pageColors] - Overwrites background and foreground colors * with user defined ones in order to improve readability in high contrast * mode. - * @property {boolean} [enableHWA] - Enables hardware acceleration for - * rendering. The default value is `false`. * @property {boolean} [supportsPinchToZoom] - Enable zooming on pinch gesture. * The default value is `true`. * @property {boolean} [enableAutoLinking] - Enable creation of hyperlinks from @@ -245,8 +243,6 @@ class PDFViewer { #editorUndoBar = null; - #enableHWA = false; - #enableHighlightFloatingButton = false; #enablePermissions = false; @@ -372,7 +368,6 @@ class PDFViewer { this.#enablePermissions = options.enablePermissions || false; this.pageColors = options.pageColors || null; this.#mlManager = options.mlManager || null; - this.#enableHWA = options.enableHWA || false; this.#supportsPinchToZoom = options.supportsPinchToZoom !== false; this.#enableAutoLinking = options.enableAutoLinking !== false; this.#minDurationToUpdateCanvas = options.minDurationToUpdateCanvas ?? 500; @@ -1070,7 +1065,6 @@ class PDFViewer { pageColors, l10n: this.l10n, layerProperties: this._layerProperties, - enableHWA: this.#enableHWA, enableAutoLinking: this.#enableAutoLinking, minDurationToUpdateCanvas: this.#minDurationToUpdateCanvas, commentManager: this.#commentManager, From e88a5652de17d53819117086b097b588a9f84e67 Mon Sep 17 00:00:00 2001 From: Jonas Jenwald Date: Thu, 12 Mar 2026 16:58:32 +0100 Subject: [PATCH 3/3] Fix the `FontInfo.prototype.clearData` method to actually remove the data as intended (PR 20197 follow-up) The purpose of PR 11844 was to reduce memory usage once fonts have been attached to the DOM, since the font-data can be quite large in many cases. Unfortunately the new `clearData` method added in PR 20197 doesn't actually remove *anything*, it just replaces the font-data with zeros which doesn't help when the underlying `ArrayBuffer` itself isn't modified. The method does include a commented-out `resize` call[1], but uncommenting that just breaks rendering completely. To address this regression, without having to make large or possibly complex changes, this patch simply changes the `clearData` method to replace the internal buffer/view with its contents *before* the font-data. While this does lead to a data copy, the size of this data is usually orders of magnitude smaller than the font-data that we're removing. --- [1] Slightly off-topic, but I don't think that patches should include commented-out code since there's a very real risk that those things never get found/fixed. At the very least such cases should be clearly marked with `// TODO: ...` comments, and should possibly also have an issue filed about fixing the TODO. --- src/display/api.js | 2 +- src/shared/obj-bin-transform.js | 55 ++++++++++++++++----------------- 2 files changed, 28 insertions(+), 29 deletions(-) diff --git a/src/display/api.js b/src/display/api.js index 2cea1c1e27e30..9cd87f489ca93 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -2903,7 +2903,7 @@ class WorkerTransport { .bind(font) .catch(() => messageHandler.sendWithPromise("FontFallback", { id })) .finally(() => { - if (!font.fontExtraProperties && font.data) { + if (!font.fontExtraProperties) { // Immediately release the `font.data` property once the font // has been attached to the DOM, since it's no longer needed, // rather than waiting for a `PDFDocumentProxy.cleanup` call. diff --git a/src/shared/obj-bin-transform.js b/src/shared/obj-bin-transform.js index 7e0f4b14178c7..7c549b470d9dc 100644 --- a/src/shared/obj-bin-transform.js +++ b/src/shared/obj-bin-transform.js @@ -18,9 +18,9 @@ import { assert, FeatureTest, MeshFigureType } from "./util.js"; class CssFontInfo { #buffer; - #view; + #decoder = new TextDecoder(); - #decoder; + #view; static strings = ["fontFamily", "fontWeight", "italicAngle"]; @@ -53,7 +53,6 @@ class CssFontInfo { constructor(buffer) { this.#buffer = buffer; this.#view = new DataView(this.#buffer); - this.#decoder = new TextDecoder(); } #readString(index) { @@ -84,9 +83,9 @@ class CssFontInfo { class SystemFontInfo { #buffer; - #view; + #decoder = new TextDecoder(); - #decoder; + #view; static strings = ["css", "loadedName", "baseFontName", "src"]; @@ -147,7 +146,6 @@ class SystemFontInfo { constructor(buffer) { this.#buffer = buffer; this.#view = new DataView(this.#buffer); - this.#decoder = new TextDecoder(); } get guessFallback() { @@ -228,13 +226,12 @@ class FontInfo { #buffer; - #decoder; + #decoder = new TextDecoder(); #view; constructor({ data, extra }) { this.#buffer = data; - this.#decoder = new TextDecoder(); this.#view = new DataView(this.#buffer); if (extra) { Object.assign(this, extra); @@ -379,7 +376,7 @@ class FontInfo { return this.#readString(3); } - get data() { + #getDataOffsets() { let offset = FontInfo.#OFFSET_STRINGS; const stringsLength = this.#view.getUint32(offset); offset += 4 + stringsLength; @@ -388,25 +385,27 @@ class FontInfo { const cssFontInfoLength = this.#view.getUint32(offset); offset += 4 + cssFontInfoLength; const length = this.#view.getUint32(offset); - if (length === 0) { - return undefined; - } - return new Uint8Array(this.#buffer, offset + 4, length); + + return { offset, length }; + } + + get data() { + const { offset, length } = this.#getDataOffsets(); + return length === 0 + ? undefined + : new Uint8Array(this.#buffer, offset + 4, length); } clearData() { - let offset = FontInfo.#OFFSET_STRINGS; - const stringsLength = this.#view.getUint32(offset); - offset += 4 + stringsLength; - const systemFontInfoLength = this.#view.getUint32(offset); - offset += 4 + systemFontInfoLength; - const cssFontInfoLength = this.#view.getUint32(offset); - offset += 4 + cssFontInfoLength; - const length = this.#view.getUint32(offset); - const data = new Uint8Array(this.#buffer, offset + 4, length); - data.fill(0); - this.#view.setUint32(offset, 0); - // this.#buffer.resize(offset); + const { offset, length } = this.#getDataOffsets(); + if (length === 0) { + return; // The data is either not present, or it was previously cleared. + } + this.#view.setUint32(offset, 0); // Zero the data-length. + + // Replace the buffer/view with only its contents *before* the data-block. + this.#buffer = new Uint8Array(this.#buffer, 0, offset + 4).slice().buffer; + this.#view = new DataView(this.#buffer); } get cssFontInfo() { @@ -462,11 +461,11 @@ class FontInfo { 4 + stringsLength + 4 + - (systemFontInfoBuffer ? systemFontInfoBuffer.byteLength : 0) + + (systemFontInfoBuffer?.byteLength ?? 0) + 4 + - (cssFontInfoBuffer ? cssFontInfoBuffer.byteLength : 0) + + (cssFontInfoBuffer?.byteLength ?? 0) + 4 + - (font.data ? font.data.length : 0); + (font.data?.length ?? 0); const buffer = new ArrayBuffer(lengthEstimate); const data = new Uint8Array(buffer);