From 04272de41d768fa66953bb9cdb7aba2c3bb61445 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Tue, 17 Mar 2026 16:28:47 +0100 Subject: [PATCH] Add the possibility to save added annotations when reorganizing a pdf (bug 2023086) --- src/core/annotation.js | 2 +- src/core/document.js | 5 + src/core/editor/pdf_editor.js | 68 ++++++- src/core/worker.js | 175 ++++++++++-------- src/display/api.js | 15 +- src/display/editor/annotation_editor_layer.js | 59 +----- src/display/editor/editor.js | 8 +- src/display/editor/highlight.js | 6 +- src/display/editor/tools.js | 62 +++++++ test/integration/reorganize_pages_spec.mjs | 87 +++++++++ test/unit/annotation_spec.js | 9 + test/unit/api_spec.js | 73 ++++++++ web/annotation_editor_layer_builder.js | 11 +- web/pdf_page_view.js | 14 +- web/pdf_thumbnail_view.js | 3 - web/pdf_viewer.js | 11 ++ 16 files changed, 444 insertions(+), 164 deletions(-) diff --git a/src/core/annotation.js b/src/core/annotation.js index 29273fc01..bc31efeff 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -354,12 +354,12 @@ class AnnotationFactory { static async saveNewAnnotations( evaluator, + xref, task, annotations, imagePromises, changes ) { - const xref = evaluator.xref; let baseFontRef; const promises = []; const { isOffscreenCanvasSupported } = evaluator.options; diff --git a/src/core/document.js b/src/core/document.js index 1dd6345ad..74cfdaefd 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -151,6 +151,10 @@ class Page { }); } + createAnnotationEvaluator(handler) { + return this.#createPartialEvaluator(handler); + } + #getInheritableProperty(key, getArray = false) { const value = getInheritableProperty({ dict: this.pageDict, @@ -386,6 +390,7 @@ class Page { ); const newData = await AnnotationFactory.saveNewAnnotations( partialEvaluator, + this.xref, task, annotations, imagePromises, diff --git a/src/core/editor/pdf_editor.js b/src/core/editor/pdf_editor.js index 1b64907ef..ff31910df 100644 --- a/src/core/editor/pdf_editor.js +++ b/src/core/editor/pdf_editor.js @@ -16,16 +16,21 @@ /** @typedef {import("../document.js").PDFDocument} PDFDocument */ /** @typedef {import("../document.js").Page} Page */ /** @typedef {import("../xref.js").XRef} XRef */ +/** @typedef {import("../worker.js").WorkerTask} WorkerTask */ +// eslint-disable-next-line max-len +/** @typedef {import("../../shared/message_handler.js").MessageHandler} MessageHandler */ import { deepCompare, getInheritableProperty, + getNewAnnotationsMap, stringToAsciiOrUTF16BE, } from "../core_utils.js"; import { Dict, isName, Name, Ref, RefSet, RefSetCache } from "../primitives.js"; import { getModificationDate, stringToPDFString } from "../../shared/util.js"; import { incrementalUpdate, writeValue } from "../writer.js"; import { NameTree, NumberTree } from "../name_number_tree.js"; +import { AnnotationFactory } from "../annotation.js"; import { BaseStream } from "../base_stream.js"; import { StringStream } from "../stream.js"; @@ -75,8 +80,9 @@ class DocumentData { } class XRefWrapper { - constructor(entries) { + constructor(entries, getNewRef) { this.entries = entries; + this._getNewRef = getNewRef; } fetch(ref) { @@ -94,11 +100,17 @@ class XRefWrapper { fetchAsync(ref) { return Promise.resolve(this.fetch(ref)); } + + getNewTemporaryRef() { + return this._getNewRef(); + } } class PDFEditor { hasSingleFile = false; + #newAnnotationsParams = null; + currentDocument = null; oldPages = []; @@ -107,7 +119,7 @@ class PDFEditor { xref = [null]; - xrefWrapper = new XRefWrapper(this.xref); + xrefWrapper = new XRefWrapper(this.xref, () => this.newRef); newRefCount = 1; @@ -535,13 +547,33 @@ class PDFEditor { /** * Extract pages from the given documents. * @param {Array} pageInfos + * @param {Object} annotationStorage - The annotation storage containing the + * annotations to be merged into the new document. + * @param {MessageHandler} handler - The message handler to use for processing + * the annotations. + * @param {WorkerTask} task - The worker task to use for reporting progress + * and cancellation. * @return {Promise} */ - async extractPages(pageInfos) { + async extractPages(pageInfos, annotationStorage, handler, task) { const promises = []; let newIndex = 0; this.hasSingleFile = pageInfos.length === 1; const allDocumentData = []; + + if (annotationStorage) { + this.#newAnnotationsParams = { + handler, + task, + newAnnotationsByPage: getNewAnnotationsMap(annotationStorage), + imagesPromises: AnnotationFactory.generateImages( + annotationStorage.values(), + this.xrefWrapper, + true + ), + }; + } + for (const { document, includePages, @@ -1930,6 +1962,8 @@ class PDFEditor { await this.#collectDependencies(resources, true, xref) ); + let newAnnots = null; + if (annotations) { const newAnnotations = await this.#collectDependencies( annotations, @@ -1937,9 +1971,35 @@ class PDFEditor { xref ); this.#fixNamedDestinations(newAnnotations, dedupNamedDestinations); - pageDict.setIfArray("Annots", newAnnotations); + if (Array.isArray(newAnnotations) && newAnnotations.length > 0) { + newAnnots = newAnnotations; + } } + const newAnnotations = + this.#newAnnotationsParams?.newAnnotationsByPage.get(pageIndex); + if (newAnnotations) { + const { handler, task, imagesPromises } = this.#newAnnotationsParams; + const changes = new RefSetCache(); + const newData = await AnnotationFactory.saveNewAnnotations( + page.createAnnotationEvaluator(handler), + this.xrefWrapper, + task, + newAnnotations, + imagesPromises, + changes + ); + for (const [ref, { data }] of changes.items()) { + this.xref[ref.num] = data; + } + newAnnots ||= []; + for (const { ref } of newData.annotations) { + newAnnots.push(ref); + } + } + + pageDict.setIfArray("Annots", newAnnots); + if (this.useObjectStreams) { const newLastRef = this.newRefCount; const pageObjectRefs = []; diff --git a/src/core/worker.js b/src/core/worker.js index 4aef9e996..1d1f6f968 100644 --- a/src/core/worker.js +++ b/src/core/worker.js @@ -551,96 +551,111 @@ class WorkerMessageHandler { return pdfManager.ensureDoc("calculationOrderIds"); }); - handler.on("ExtractPages", async function ({ pageInfos }) { - if (!pageInfos) { - warn("extractPages: nothing to extract."); - return null; - } - if (!Array.isArray(pageInfos)) { - pageInfos = [pageInfos]; - } - let newDocumentId = 0; - for (const pageInfo of pageInfos) { - if (pageInfo.document === null) { - pageInfo.document = pdfManager.pdfDocument; - } else if (ArrayBuffer.isView(pageInfo.document)) { - const manager = new LocalPdfManager({ - source: pageInfo.document, - docId: `${docId}_extractPages_${newDocumentId++}`, - handler, - password: pageInfo.password ?? null, - evaluatorOptions: Object.assign({}, pdfManager.evaluatorOptions), - }); - let recoveryMode = false; - let isValid = true; - while (true) { - try { - await manager.requestLoadedStream(); - await manager.ensureDoc("checkHeader"); - await manager.ensureDoc("parseStartXRef"); - await manager.ensureDoc("parse", [recoveryMode]); - break; - } catch (e) { - if (e instanceof XRefParseException) { - if (recoveryMode === false) { - recoveryMode = true; - continue; + handler.on( + "ExtractPages", + async function ({ pageInfos, annotationStorage }) { + if (!pageInfos) { + warn("extractPages: nothing to extract."); + return null; + } + if (!Array.isArray(pageInfos)) { + pageInfos = [pageInfos]; + } + let newDocumentId = 0; + for (const pageInfo of pageInfos) { + if (pageInfo.document === null) { + pageInfo.document = pdfManager.pdfDocument; + } else if (ArrayBuffer.isView(pageInfo.document)) { + const manager = new LocalPdfManager({ + source: pageInfo.document, + docId: `${docId}_extractPages_${newDocumentId++}`, + handler, + password: pageInfo.password ?? null, + evaluatorOptions: Object.assign({}, pdfManager.evaluatorOptions), + }); + let recoveryMode = false; + let isValid = true; + while (true) { + try { + await manager.requestLoadedStream(); + await manager.ensureDoc("checkHeader"); + await manager.ensureDoc("parseStartXRef"); + await manager.ensureDoc("parse", [recoveryMode]); + break; + } catch (e) { + if (e instanceof XRefParseException) { + if (recoveryMode === false) { + recoveryMode = true; + continue; + } else { + isValid = false; + warn("extractPages: XRefParseException."); + } + } else if (e instanceof PasswordException) { + const task = new WorkerTask( + `PasswordException: response ${e.code}` + ); + + startWorkerTask(task); + + try { + const { password } = await handler.sendWithPromise( + "PasswordRequest", + e + ); + manager.updatePassword(password); + } catch { + isValid = false; + warn("extractPages: invalid password."); + } finally { + finishWorkerTask(task); + } } else { isValid = false; - warn("extractPages: XRefParseException."); + warn("extractPages: invalid document."); } - } else if (e instanceof PasswordException) { - const task = new WorkerTask( - `PasswordException: response ${e.code}` - ); - - startWorkerTask(task); - - try { - const { password } = await handler.sendWithPromise( - "PasswordRequest", - e - ); - manager.updatePassword(password); - } catch { - isValid = false; - warn("extractPages: invalid password."); - } finally { - finishWorkerTask(task); + if (!isValid) { + break; } - } else { - isValid = false; - warn("extractPages: invalid document."); - } - if (!isValid) { - break; } } - } - if (!isValid) { - pageInfo.document = null; - } - const isPureXfa = await manager.ensureDoc("isPureXfa"); - if (isPureXfa) { - pageInfo.document = null; - warn("extractPages does not support pure XFA documents."); + if (!isValid) { + pageInfo.document = null; + } + const isPureXfa = await manager.ensureDoc("isPureXfa"); + if (isPureXfa) { + pageInfo.document = null; + warn("extractPages does not support pure XFA documents."); + } else { + pageInfo.document = manager.pdfDocument; + } } else { - pageInfo.document = manager.pdfDocument; + warn("extractPages: invalid document."); + } + } + let task; + try { + const pdfEditor = new PDFEditor(); + task = new WorkerTask(`ExtractPages: ${pageInfos.length} page(s)`); + startWorkerTask(task); + const buffer = await pdfEditor.extractPages( + pageInfos, + annotationStorage, + handler, + task + ); + return buffer; + } catch (reason) { + // eslint-disable-next-line no-console + console.error(reason); + return null; + } finally { + if (task) { + finishWorkerTask(task); } - } else { - warn("extractPages: invalid document."); } } - try { - const pdfEditor = new PDFEditor(); - const buffer = await pdfEditor.extractPages(pageInfos); - return buffer; - } catch (reason) { - // eslint-disable-next-line no-console - console.error(reason); - return null; - } - }); + ); handler.on( "SaveDocument", diff --git a/src/display/api.js b/src/display/api.js index 9c2152884..b2751431d 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -2973,7 +2973,20 @@ class WorkerTransport { } extractPages(pageInfos) { - return this.messageHandler.sendWithPromise("ExtractPages", { pageInfos }); + const params = { + pageInfos, + }; + let transfer; + if (this.annotationStorage.size > 0) { + const { map, transfer: t } = this.annotationStorage.serializable; + params.annotationStorage = map; + transfer = t; + } + return this.messageHandler + .sendWithPromise("ExtractPages", params, transfer) + .finally(() => { + this.annotationStorage.resetModified(); + }); } getPage(pageNumber) { diff --git a/src/display/editor/annotation_editor_layer.js b/src/display/editor/annotation_editor_layer.js index 11c982fbf..22d867cca 100644 --- a/src/display/editor/annotation_editor_layer.js +++ b/src/display/editor/annotation_editor_layer.js @@ -144,47 +144,6 @@ class AnnotationEditorLayer { this.#uiManager.addLayer(this); } - updatePageIndex(newPageIndex) { - for (const editor of this.#allEditorsIterator) { - editor.updatePageIndex(newPageIndex); - } - - this.pageIndex = newPageIndex; - this.#uiManager.addLayer(this); - } - - /** - * Clones all annotation editors from another layer into this layer. - * This is typically used when duplicating a page - the editors from the - * source page are serialized and then deserialized into the new page's layer. - * - * @param {AnnotationEditorLayer} clonedFrom - The source annotation editor - * layer to clone editors from. If null or undefined, no action is taken. - * @returns {Promise} A promise that resolves when all editors have been - * cloned and added to this layer. - */ - async setClonedFrom(clonedFrom) { - if (!clonedFrom) { - return; - } - const promises = []; - for (const editor of clonedFrom.#allEditorsIterator) { - const serialized = editor.serialize(/* isForCopying = */ true); - if (!serialized) { - continue; - } - serialized.isCopy = false; - promises.push( - this.deserialize(serialized).then(deserialized => { - if (deserialized) { - this.addOrRebuild(deserialized); - } - }) - ); - } - await Promise.all(promises); - } - get isEmpty() { return this.#editors.size === 0; } @@ -713,14 +672,6 @@ class AnnotationEditorLayer { return null; } - /** - * Get an id for an editor. - * @returns {string} - */ - getNextId() { - return this.#uiManager.getId(); - } - get #currentEditorType() { return AnnotationEditorLayer.#editorTypes.get(this.#uiManager.getMode()); } @@ -753,7 +704,7 @@ class AnnotationEditorLayer { await this.#uiManager.updateMode(options.mode); const { offsetX, offsetY } = this.#getCenterPoint(); - const id = this.getNextId(); + const id = this.#uiManager.getId(); const editor = this.#createNewEditor({ parent: this, id, @@ -789,7 +740,7 @@ class AnnotationEditorLayer { * @returns {AnnotationEditor} */ createAndAddNewEditor(event, isCentered, data = {}) { - const id = this.getNextId(); + const id = this.#uiManager.getId(); const editor = this.#createNewEditor({ parent: this, id, @@ -1073,13 +1024,17 @@ class AnnotationEditorLayer { * Render the main editor. * @param {RenderEditorLayerOptions} parameters */ - render({ viewport }) { + async render({ viewport }) { this.viewport = viewport; setLayerDimensions(this.div, viewport); for (const editor of this.#uiManager.getEditors(this.pageIndex)) { this.add(editor); editor.rebuild(); } + + await this.#uiManager.findClonesForPage(this); + this.div.hidden = this.isEmpty; + // We're maybe rendering a layer which was invisible when we started to edit // so we must set the different callbacks for it. this.updateMode(); diff --git a/src/display/editor/editor.js b/src/display/editor/editor.js index e958bbb83..a175690d7 100644 --- a/src/display/editor/editor.js +++ b/src/display/editor/editor.js @@ -235,7 +235,7 @@ class AnnotationEditor { static deleteAnnotationElement(editor) { const fakeEditor = new FakeEditor({ - id: editor.parent.getNextId(), + id: editor._uiManager.getId(), parent: editor.parent, uiManager: editor._uiManager, }); @@ -480,6 +480,10 @@ class AnnotationEditor { } _moveAfterPaste(baseX, baseY) { + if (this.isClone) { + delete this.isClone; + return; + } const [parentWidth, parentHeight] = this.parentDimensions; this.setAt( baseX * parentWidth, @@ -1862,7 +1866,7 @@ class AnnotationEditor { static async deserialize(data, parent, uiManager) { const editor = new this.prototype.constructor({ parent, - id: parent.getNextId(), + id: uiManager.getId(), uiManager, annotationElementId: data.annotationElementId, creationDate: data.creationDate, diff --git a/src/display/editor/highlight.js b/src/display/editor/highlight.js index e0bdeefd5..c54c93360 100644 --- a/src/display/editor/highlight.js +++ b/src/display/editor/highlight.js @@ -959,7 +959,7 @@ class HighlightEditor extends AnnotationEditor { }; } - const { color, quadPoints, inkLists, opacity } = data; + const { color, quadPoints, inkLists, outlines, opacity } = data; const editor = await super.deserialize(data, parent, uiManager); editor.color = Util.makeHexColor(...color); @@ -988,9 +988,9 @@ class HighlightEditor extends AnnotationEditor { editor.#createOutlines(); editor.#addToDrawLayer(); editor.rotate(editor.rotation); - } else if (inkLists) { + } else if (inkLists || outlines) { editor.#isFreeHighlight = true; - const points = inkLists[0]; + const points = (inkLists || outlines.points)[0]; const point = { x: points[0] - pageX, y: pageHeight - (points[1] - pageY), diff --git a/src/display/editor/tools.js b/src/display/editor/tools.js index fec9e8bb4..331d9ab43 100644 --- a/src/display/editor/tools.js +++ b/src/display/editor/tools.js @@ -675,6 +675,8 @@ class AnnotationEditorUIManager { #allLayers = new Map(); + #savedAllLayers = null; + #altTextManager = null; #annotationStorage = null; @@ -1841,6 +1843,66 @@ class AnnotationEditorUIManager { } } + updatePageIndex(oldPageIndex, newPageIndex) { + for (const editor of this.getEditors(oldPageIndex)) { + editor.pageIndex = newPageIndex; + } + const layer = this.#savedAllLayers.get(oldPageIndex); + if (layer) { + layer.pageIndex = newPageIndex; + this.#allLayers.set(newPageIndex, layer); + if (this.#isEnabled) { + layer.enable(); + } else { + layer.disable(); + } + } + } + + startUpdatePages() { + this.#savedAllLayers = new Map(this.#allLayers); + this.#allLayers.clear(); + } + + endUpdatePages() { + this.#savedAllLayers = null; + } + + clonePage(pageIndex, newPageIndex) { + for (const editor of this.getEditors(pageIndex)) { + const serialized = editor.serialize( + editor.mode !== AnnotationEditorType.HIGHLIGHT + ); + if (!serialized) { + continue; + } + serialized.pageIndex = newPageIndex; + serialized.id = this.getId(); + serialized.isClone = true; + delete serialized.popupRef; + this.#annotationStorage.setValue(serialized.id, serialized); + } + } + + findClonesForPage(layer) { + const promises = []; + const { pageIndex } = layer; + for (const [id, editor] of this.#annotationStorage) { + if (editor.pageIndex === pageIndex && editor.isClone) { + this.#annotationStorage.remove(id); + promises.push( + layer.deserialize(editor).then(deserializedEditor => { + if (deserializedEditor) { + deserializedEditor.isClone = true; + layer.addOrRebuild(deserializedEditor); + } + }) + ); + } + } + return Promise.all(promises); + } + /** * Update the different possible states of this manager, e.g. is there * something to undo, redo, ... diff --git a/test/integration/reorganize_pages_spec.mjs b/test/integration/reorganize_pages_spec.mjs index 2ee980053..b66af4856 100644 --- a/test/integration/reorganize_pages_spec.mjs +++ b/test/integration/reorganize_pages_spec.mjs @@ -31,9 +31,13 @@ import { PDI, scrollIntoView, showViewsManager, + switchToEditor, waitAndClick, waitForBrowserTrip, waitForDOMMutation, + waitForPointerUp, + waitForSerialized, + waitForStorageEntries, waitForTextToBe, waitForTooltipToBe, } from "./test_utils.mjs"; @@ -2563,4 +2567,87 @@ describe("Reorganize Pages View", () => { ); }); }); + + describe("Copy page with an ink annotation and paste it", () => { + let pages; + + beforeEach(async () => { + pages = await loadAndWait( + "page_with_number.pdf", + ".annotationEditorLayer", + "50", + null, + { enableSplitMerge: true } + ); + }); + + afterEach(async () => { + await closePages(pages); + }); + + it("should check that the pasted page has an ink annotation in the DOM", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + // Enable ink editor mode and draw a line on page 1. + await switchToEditor("Ink", page); + const rect = await getRect( + page, + ".page[data-page-number='1'] .annotationEditorLayer" + ); + const x = rect.x + rect.width * 0.3; + const y = rect.y + rect.height * 0.3; + const clickHandle = await waitForPointerUp(page); + await page.mouse.move(x, y); + await page.mouse.down(); + await page.mouse.move(x + 50, y + 50); + await page.mouse.up(); + await awaitPromise(clickHandle); + + // Commit the drawing and wait for it to be serialized. + await page.keyboard.press("Escape"); + await waitForSerialized(page, 1); + + await waitForThumbnailVisible(page, 1); + + // Select page 1 and copy it. + await page.waitForSelector("#viewsManagerStatusActionButton", { + visible: true, + }); + await waitAndClick( + page, + `.thumbnail:has(${getThumbnailSelector(1)}) input` + ); + let handlePagesEdited = await waitForPagesEdited(page); + await waitAndClick(page, "#viewsManagerStatusActionButton"); + await waitAndClick(page, "#viewsManagerStatusActionCopy"); + await awaitPromise(handlePagesEdited); + + // Paste after page 2 so the copy lands at position 3. + handlePagesEdited = await waitForPagesEdited(page); + await waitAndClick(page, `${getThumbnailSelector(2)}+button`); + await awaitPromise(handlePagesEdited); + + // Both the original and the cloned annotation must now be in storage. + await waitForStorageEntries(page, 2); + + // Close the reorganize view and navigate to page 3 (the pasted copy) + // to trigger rendering of its annotation editor layer. + await page.click("#viewsManagerToggleButton"); + await page.waitForSelector("#viewsManager", { hidden: true }); + await page.evaluate(() => { + window.PDFViewerApplication.pdfViewer.currentPageNumber = 3; + }); + + // The cloned ink annotation must appear in the DOM of page 3. + await page.waitForSelector(`.page[data-page-number="3"] .inkEditor`, { + visible: true, + }); + const inkEditors = await page.$$( + `.page[data-page-number="3"] .inkEditor` + ); + expect(inkEditors.length).withContext(`In ${browserName}`).toBe(1); + }) + ); + }); + }); }); diff --git a/test/unit/annotation_spec.js b/test/unit/annotation_spec.js index 056ce715f..d6e4105bd 100644 --- a/test/unit/annotation_spec.js +++ b/test/unit/annotation_spec.js @@ -4203,6 +4203,7 @@ describe("annotation", function () { const changes = new RefSetCache(); await AnnotationFactory.saveNewAnnotations( partialEvaluator, + xref, task, [ { @@ -4320,6 +4321,7 @@ describe("annotation", function () { const task = new WorkerTask("test FreeText update"); await AnnotationFactory.saveNewAnnotations( partialEvaluator, + xref, task, [ { @@ -4435,6 +4437,7 @@ describe("annotation", function () { const task = new WorkerTask("test Ink creation"); await AnnotationFactory.saveNewAnnotations( partialEvaluator, + xref, task, [ { @@ -4532,6 +4535,7 @@ describe("annotation", function () { const task = new WorkerTask("test Ink creation"); await AnnotationFactory.saveNewAnnotations( partialEvaluator, + xref, task, [ { @@ -4764,6 +4768,7 @@ describe("annotation", function () { const task = new WorkerTask("test Highlight creation"); await AnnotationFactory.saveNewAnnotations( partialEvaluator, + xref, task, [ { @@ -4857,6 +4862,7 @@ describe("annotation", function () { const task = new WorkerTask("test free Highlight creation"); await AnnotationFactory.saveNewAnnotations( partialEvaluator, + xref, task, [ { @@ -4988,6 +4994,7 @@ describe("annotation", function () { const task = new WorkerTask("test Highlight update"); await AnnotationFactory.saveNewAnnotations( partialEvaluator, + xref, task, [ { @@ -5051,6 +5058,7 @@ describe("annotation", function () { const task = new WorkerTask("test Highlight update"); await AnnotationFactory.saveNewAnnotations( partialEvaluator, + xref, task, [ { @@ -5215,6 +5223,7 @@ describe("annotation", function () { const task = new WorkerTask("test Stamp creation"); await AnnotationFactory.saveNewAnnotations( partialEvaluator, + xref, task, [ { diff --git a/test/unit/api_spec.js b/test/unit/api_spec.js index 7ce81129f..c56b46d52 100644 --- a/test/unit/api_spec.js +++ b/test/unit/api_spec.js @@ -6254,6 +6254,79 @@ small scripts as well as for`); await loadingTask.destroy(); }); + it("save an ink annotation on a cloned page", async function () { + let loadingTask = getDocument(buildGetDocumentParams("empty.pdf")); + let pdfDoc = await loadingTask.promise; + + // Simulate what clonePage() puts in annotationStorage when a page is + // copied: the original annotation stays on pageIndex 0 and the clone + // is placed on pageIndex 1 (the new position of the pasted copy). + const inkAnnotation = { + annotationType: AnnotationEditorType.INK, + rect: [50, 50, 200, 200], + rotation: 0, + structTreeParentId: null, + popupRef: "", + color: [0, 0, 255], + opacity: 1, + thickness: 2, + paths: { + lines: [ + new Float32Array([ + 0, + 0, + 0, + 0, + 50, + 200, + NaN, + NaN, + NaN, + NaN, + 200, + 50, + ]), + ], + points: [[50, 200, 100, 100, 200, 50]], + }, + isCopy: true, + }; + + pdfDoc.annotationStorage.setValue("pdfjs_internal_editor_0", { + ...inkAnnotation, + pageIndex: 0, + }); + pdfDoc.annotationStorage.setValue("pdfjs_internal_editor_1", { + ...inkAnnotation, + pageIndex: 1, + }); + + // Extract page 0 twice: once at output position 0 (original) and once + // at output position 1 (clone), mirroring copy+paste in the UI. + const data = await pdfDoc.extractPages([ + { document: null, includePages: [0], pageIndices: [0] }, + { document: null, includePages: [0], pageIndices: [1] }, + ]); + await loadingTask.destroy(); + + loadingTask = getDocument(data); + pdfDoc = await loadingTask.promise; + + expect(pdfDoc.numPages).toEqual(2); + + // Both pages should carry the ink annotation. + for (let i = 1; i <= 2; i++) { + const pdfPage = await pdfDoc.getPage(i); + const annotations = await pdfPage.getAnnotations(); + expect(annotations.length).withContext(`Page ${i}`).toEqual(1); + expect(annotations[0].annotationType) + .withContext(`Page ${i}`) + .toEqual(AnnotationType.INK); + } + + await loadingTask.destroy(); + }); + it("fills missing pageIndices with the first free slots", async function () { let loadingTask = getDocument( buildGetDocumentParams("tracemonkey.pdf") diff --git a/web/annotation_editor_layer_builder.js b/web/annotation_editor_layer_builder.js index 0cbe58820..456987a3d 100644 --- a/web/annotation_editor_layer_builder.js +++ b/web/annotation_editor_layer_builder.js @@ -39,7 +39,6 @@ import { GenericL10n } from "web-null_l10n"; * @property {TextLayer} [textLayer] * @property {DrawLayer} [drawLayer] * @property {function} [onAppend] - * @property {AnnotationEditorLayer} [clonedFrom] */ /** @@ -61,8 +60,6 @@ class AnnotationEditorLayerBuilder { #uiManager; - #clonedFrom = null; - /** * @param {AnnotationEditorLayerBuilderOptions} options */ @@ -82,7 +79,6 @@ class AnnotationEditorLayerBuilder { this.#drawLayer = options.drawLayer || null; this.#onAppend = options.onAppend || null; this.#structTreeLayer = options.structTreeLayer || null; - this.#clonedFrom = options.clonedFrom || null; } updatePageIndex(newPageIndex) { @@ -130,11 +126,6 @@ class AnnotationEditorLayerBuilder { drawLayer: this.#drawLayer, }); - this.annotationEditorLayer.setClonedFrom( - this.#clonedFrom?.annotationEditorLayer - ); - this.#clonedFrom = null; - const parameters = { viewport: clonedViewport, div, @@ -142,7 +133,7 @@ class AnnotationEditorLayerBuilder { intent, }; - this.annotationEditorLayer.render(parameters); + await this.annotationEditorLayer.render(parameters); this.show(); } diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index 17ba9ad72..5cd05661c 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -107,7 +107,6 @@ import { XfaLayerBuilder } from "./xfa_layer_builder.js"; * @property {boolean} [enableAutoLinking] - Enable creation of hyperlinks from * text that look like URLs. The default value is `true`. * @property {CommentManager} [commentManager] - The comment manager instance. - * @property {PDFPageView} [clonedFrom] - The page view that is cloned * to. */ @@ -172,8 +171,6 @@ class PDFPageView extends BasePDFPageView { #layers = [null, null, null, null]; - #clonedFrom = null; - /** * @param {PDFPageViewOptions} options */ @@ -205,7 +202,6 @@ class PDFPageView extends BasePDFPageView { options.capCanvasAreaFactor ?? AppOptions.get("capCanvasAreaFactor"); this.#enableAutoLinking = options.enableAutoLinking !== false; this.#commentManager = options.commentManager || null; - this.#clonedFrom = options.clonedFrom || null; this.l10n = options.l10n; if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { @@ -301,7 +297,6 @@ class PDFPageView extends BasePDFPageView { enableAutoLinking: this.#enableAutoLinking, commentManager: this.#commentManager, l10n: this.l10n, - clonedFrom: this, }); clone.setPdfPage(this.pdfPage.clone(id - 1)); return clone; @@ -355,6 +350,7 @@ class PDFPageView extends BasePDFPageView { if (this.id === newPageNumber) { return; } + const oldPageNumber = this.id; this.id = newPageNumber; this.renderingId = `page${newPageNumber}`; if (this.pdfPage) { @@ -368,7 +364,11 @@ class PDFPageView extends BasePDFPageView { this._textHighlighter.pageIdx = newPageNumber - 1; // Don't update the page index for the draw layer, since it's just used as // an identifier. - this.annotationEditorLayer?.updatePageIndex(newPageNumber - 1); + + this.#layerProperties.annotationEditorUIManager?.updatePageIndex( + oldPageNumber - 1, + newPageNumber - 1 + ); } setPdfPage(pdfPage) { @@ -1207,12 +1207,10 @@ class PDFPageView extends BasePDFPageView { annotationLayer: this.annotationLayer?.annotationLayer, textLayer: this.textLayer, drawLayer: this.drawLayer.getDrawLayer(), - clonedFrom: this.#clonedFrom?.annotationEditorLayer, onAppend: annotationEditorLayerDiv => { this.#addLayer(annotationEditorLayerDiv, "annotationEditorLayer"); }, }); - this.#clonedFrom = null; this.#renderAnnotationEditorLayer(); } }); diff --git a/web/pdf_thumbnail_view.js b/web/pdf_thumbnail_view.js index 46d4ad37c..260e049c4 100644 --- a/web/pdf_thumbnail_view.js +++ b/web/pdf_thumbnail_view.js @@ -80,8 +80,6 @@ class TempImageFactory { class PDFThumbnailView extends RenderableView { #renderingState = RenderingStates.INITIAL; - static foo = 0; - /** * @param {PDFThumbnailViewOptions} options */ @@ -99,7 +97,6 @@ class PDFThumbnailView extends RenderableView { enableSplitMerge = false, }) { super(); - this.foo = PDFThumbnailView.foo++; this.id = id; this.renderingId = `thumbnail${id}`; this.pageLabel = null; diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index c82121490..5e271e962 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -1209,6 +1209,7 @@ class PDFViewer { const viewerElement = this._scrollMode === ScrollMode.PAGE ? null : this.viewer; if (viewerElement) { + this.#annotationEditorUIManager?.startUpdatePages(); const fragment = document.createDocumentFragment(); for (let i = 0, ii = this.#savedPageViews.length; i < ii; i++) { const page = this.#savedPageViews[i]; @@ -1216,6 +1217,7 @@ class PDFViewer { fragment.append(page.div); } viewerElement.replaceChildren(fragment); + this.#annotationEditorUIManager?.endUpdatePages(); } this._pages = this.#savedPageViews; this.#savedPageViews = null; @@ -1238,6 +1240,9 @@ class PDFViewer { this._currentPageNumber = 0; const prevPages = this._pages; const newPages = (this._pages = []); + + this.#annotationEditorUIManager?.startUpdatePages(); + for (let i = 1, ii = pagesMapper.pagesNumber; i <= ii; i++) { const prevPageNumber = pagesMapper.getPrevPageNumber(i); if (prevPageNumber < 0) { @@ -1245,6 +1250,10 @@ class PDFViewer { if (hasBeenCut) { page.updatePageNumber(i); } else { + this.#annotationEditorUIManager?.clonePage( + -prevPageNumber - 1, + i - 1 + ); page = page.clone(i); } newPages.push(page); @@ -1255,6 +1264,8 @@ class PDFViewer { page.updatePageNumber(i); } + this.#annotationEditorUIManager?.endUpdatePages(); + if (type === "paste") { this.#copiedPageViews = null; }