From bc87f4e8d6402f45afd7b161cc6683f4e414acc7 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Thu, 30 Oct 2025 18:25:18 +0100 Subject: [PATCH] Add the possibility to create a pdf from different ones (bug 1997379) For now it's just possible to create a single pdf in selecting some pages in different pdf sources. The merge is for now pretty basic (it's why it's still a WIP) none of these data are merged for now: - the struct trees - the page labels - the outlines - named destinations For there are 2 new ref tests where some new pdfs are created: one with some extracted pages and an other one (encrypted) which is just rewritten. The ref images are generated from the original pdfs in selecting the page we want and the new images are taken from the generated pdfs. --- src/core/decode_stream.js | 13 + src/core/decrypt_stream.js | 4 + src/core/document.js | 6 +- src/core/editor/pdf_editor.js | 594 ++++++++++++++++++++++++++++++++++ src/core/primitives.js | 10 + src/core/stream.js | 9 + src/core/worker.js | 92 ++++++ src/core/writer.js | 83 ++--- src/display/api.js | 22 ++ test/driver.js | 44 ++- test/pdfs/.gitignore | 3 + test/pdfs/doc_1_3_pages.pdf | Bin 0 -> 12091 bytes test/pdfs/doc_2_3_pages.pdf | Bin 0 -> 39598 bytes test/pdfs/doc_3_3_pages.pdf | Bin 0 -> 40746 bytes test/test.mjs | 7 +- test/test_manifest.json | 18 ++ test/unit/api_spec.js | 208 ++++++++++++ test/unit/primitives_spec.js | 16 + test/unit/writer_spec.js | 4 +- 19 files changed, 1089 insertions(+), 44 deletions(-) create mode 100644 src/core/editor/pdf_editor.js create mode 100755 test/pdfs/doc_1_3_pages.pdf create mode 100755 test/pdfs/doc_2_3_pages.pdf create mode 100755 test/pdfs/doc_3_3_pages.pdf diff --git a/src/core/decode_stream.js b/src/core/decode_stream.js index 80bdcebd0448d..b541ed8983077 100644 --- a/src/core/decode_stream.js +++ b/src/core/decode_stream.js @@ -131,6 +131,19 @@ class DecodeStream extends BaseStream { getBaseStreams() { return this.stream ? this.stream.getBaseStreams() : null; } + + clone() { + // Make sure it has been fully read. + while (!this.eof) { + this.readBlock(); + } + return new Stream( + this.buffer, + this.start, + this.end - this.start, + this.dict.clone() + ); + } } class StreamsSequenceStream extends DecodeStream { diff --git a/src/core/decrypt_stream.js b/src/core/decrypt_stream.js index 8e93b9f86226e..78fbc5ae5aac9 100644 --- a/src/core/decrypt_stream.js +++ b/src/core/decrypt_stream.js @@ -52,6 +52,10 @@ class DecryptStream extends DecodeStream { buffer.set(chunk, bufferLength); this.bufferLength = newLength; } + + getOriginalStream() { + return this; + } } export { DecryptStream }; diff --git a/src/core/document.js b/src/core/document.js index 7bc738bb55e90..f624458cb24ca 100644 --- a/src/core/document.js +++ b/src/core/document.js @@ -178,7 +178,7 @@ class Page { ); } - #getBoundingBox(name) { + getBoundingBox(name) { if (this.xfaData) { return this.xfaData.bbox; } @@ -201,7 +201,7 @@ class Page { return shadow( this, "mediaBox", - this.#getBoundingBox("MediaBox") || LETTER_SIZE_MEDIABOX + this.getBoundingBox("MediaBox") || LETTER_SIZE_MEDIABOX ); } @@ -210,7 +210,7 @@ class Page { return shadow( this, "cropBox", - this.#getBoundingBox("CropBox") || this.mediaBox + this.getBoundingBox("CropBox") || this.mediaBox ); } diff --git a/src/core/editor/pdf_editor.js b/src/core/editor/pdf_editor.js new file mode 100644 index 0000000000000..7df909156c30e --- /dev/null +++ b/src/core/editor/pdf_editor.js @@ -0,0 +1,594 @@ +/* Copyright 2025 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. + */ + +/** @typedef {import("../document.js").PDFDocument} PDFDocument */ +/** @typedef {import("../document.js").Page} Page */ +/** @typedef {import("../xref.js").XRef} XRef */ + +import { Dict, isName, Ref, RefSetCache } from "../primitives.js"; +import { getModificationDate, stringToPDFString } from "../../shared/util.js"; +import { incrementalUpdate, writeValue } from "../writer.js"; +import { BaseStream } from "../base_stream.js"; +import { StringStream } from "../stream.js"; +import { stringToAsciiOrUTF16BE } from "../core_utils.js"; + +const MAX_LEAVES_PER_PAGES_NODE = 16; + +class PageData { + constructor(page, documentData) { + this.page = page; + this.documentData = documentData; + this.annotations = null; + + documentData.pagesMap.put(page.ref, this); + } +} + +class DocumentData { + constructor(document) { + this.document = document; + this.pagesMap = new RefSetCache(); + this.oldRefMapping = new RefSetCache(); + } +} + +class PDFEditor { + constructor({ useObjectStreams = true, title = "", author = "" } = {}) { + this.hasSingleFile = false; + this.currentDocument = null; + this.oldPages = []; + this.newPages = []; + this.xref = [null]; + this.newRefCount = 1; + [this.rootRef, this.rootDict] = this.newDict; + [this.infoRef, this.infoDict] = this.newDict; + [this.pagesRef, this.pagesDict] = this.newDict; + this.namesDict = null; + this.useObjectStreams = useObjectStreams; + this.objStreamRefs = useObjectStreams ? new Set() : null; + this.version = "1.7"; + this.title = title; + this.author = author; + } + + /** + * Get a new reference for an object in the PDF. + * @returns {Ref} + */ + get newRef() { + const ref = Ref.get(this.newRefCount++, 0); + return ref; + } + + /** + * Create a new dictionary and its reference. + * @returns {[Ref, Dict]} + */ + get newDict() { + const ref = this.newRef; + const dict = (this.xref[ref.num] = new Dict()); + return [ref, dict]; + } + + /** + * Clone an object in the PDF. + * @param {*} obj + * @param {XRef} xref + * @returns {Promise} + */ + async #cloneObject(obj, xref) { + const ref = this.newRef; + this.xref[ref.num] = await this.#collectDependencies(obj, true, xref); + return ref; + } + + /** + * Collect the dependencies of an object and create new references for each + * dependency. + * @param {*} obj + * @param {boolean} mustClone + * @param {XRef} xref + * @returns {Promise<*>} + */ + async #collectDependencies(obj, mustClone, xref) { + if (obj instanceof Ref) { + const { + currentDocument: { oldRefMapping }, + } = this; + let newRef = oldRefMapping.get(obj); + if (newRef) { + return newRef; + } + newRef = this.newRef; + oldRefMapping.put(obj, newRef); + obj = await xref.fetchAsync(obj); + + if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) { + if ( + obj instanceof Dict && + isName(obj.get("Type"), "Page") && + !this.currentDocument.pagesMap.has(obj) + ) { + throw new Error( + "Add a deleted page to the document is not supported." + ); + } + } + + this.xref[newRef.num] = await this.#collectDependencies(obj, true, xref); + return newRef; + } + const promises = []; + if (Array.isArray(obj)) { + if (mustClone) { + obj = obj.slice(); + } + for (let i = 0, ii = obj.length; i < ii; i++) { + promises.push( + this.#collectDependencies(obj[i], true, xref).then( + newObj => (obj[i] = newObj) + ) + ); + } + await Promise.all(promises); + return obj; + } + let dict; + if (obj instanceof BaseStream) { + ({ dict } = obj = obj.getOriginalStream().clone()); + } else if (obj instanceof Dict) { + if (mustClone) { + obj = obj.clone(); + } + dict = obj; + } + if (dict) { + for (const [key, rawObj] of dict.getRawEntries()) { + promises.push( + this.#collectDependencies(rawObj, true, xref).then(newObj => + dict.set(key, newObj) + ) + ); + } + await Promise.all(promises); + } + + return obj; + } + + /** + * @typedef {Object} PageInfo + * @property {PDFDocument} document + * @property {Array|number>} [includePages] + * included ranges (inclusive) or indices. + * @property {Array|number>} [excludePages] + * excluded ranges (inclusive) or indices. + */ + + /** + * Extract pages from the given documents. + * @param {Array} pageInfos + * @return {Promise} + */ + async extractPages(pageInfos) { + const promises = []; + let newIndex = 0; + this.hasSingleFile = pageInfos.length === 1; + for (const { document, includePages, excludePages } of pageInfos) { + if (!document) { + continue; + } + const documentData = new DocumentData(document); + promises.push(this.#collectDocumentData(documentData)); + let keptIndices, keptRanges, deletedIndices, deletedRanges; + for (const page of includePages || []) { + if (Array.isArray(page)) { + (keptRanges ||= []).push(page); + } else { + (keptIndices ||= new Set()).add(page); + } + } + for (const page of excludePages || []) { + if (Array.isArray(page)) { + (deletedRanges ||= []).push(page); + } else { + (deletedIndices ||= new Set()).add(page); + } + } + for (let i = 0, ii = document.numPages; i < ii; i++) { + if (deletedIndices?.has(i)) { + continue; + } + if (deletedRanges) { + let isDeleted = false; + for (const [start, end] of deletedRanges) { + if (i >= start && i <= end) { + isDeleted = true; + break; + } + } + if (isDeleted) { + continue; + } + } + + let takePage = false; + if (keptIndices) { + takePage = keptIndices.has(i); + } + if (!takePage && keptRanges) { + for (const [start, end] of keptRanges) { + if (i >= start && i <= end) { + takePage = true; + break; + } + } + } + if (!takePage && !keptIndices && !keptRanges) { + takePage = true; + } + if (!takePage) { + continue; + } + const newPageIndex = newIndex++; + promises.push( + document.getPage(i).then(page => { + this.oldPages[newPageIndex] = new PageData(page, documentData); + }) + ); + } + } + await Promise.all(promises); + promises.length = 0; + + for (const page of this.oldPages) { + promises.push(this.#postCollectPageData(page)); + } + await Promise.all(promises); + + for (let i = 0, ii = this.oldPages.length; i < ii; i++) { + this.newPages[i] = await this.#makePageCopy(i, null); + } + + return this.writePDF(); + } + + /** + * Collect the document data. + * @param {DocumentData} documentData + * @return {Promise} + */ + async #collectDocumentData(documentData) {} + + /** + * Post process the collected page data. + * @param {PageData} pageData + * @returns {Promise} + */ + async #postCollectPageData(pageData) { + const { + page: { xref, annotations }, + } = pageData; + + if (!annotations) { + return; + } + + const promises = []; + let newAnnotations = []; + let newIndex = 0; + + // TODO: remove only links to deleted pages. + for (const annotationRef of annotations) { + const newAnnotationIndex = newIndex++; + promises.push( + xref.fetchIfRefAsync(annotationRef).then(async annotationDict => { + if (!isName(annotationDict.get("Subtype"), "Link")) { + newAnnotations[newAnnotationIndex] = annotationRef; + } + }) + ); + } + await Promise.all(promises); + newAnnotations = newAnnotations.filter(annot => !!annot); + pageData.annotations = newAnnotations.length > 0 ? newAnnotations : null; + } + + /** + * Create a copy of a page. + * @param {number} pageIndex + * @returns {Promise} the page reference in the new PDF document. + */ + async #makePageCopy(pageIndex) { + const { page, documentData, annotations } = this.oldPages[pageIndex]; + this.currentDocument = documentData; + const { oldRefMapping } = documentData; + const { xref, rotate, mediaBox, resources, ref: oldPageRef } = page; + const pageRef = this.newRef; + const pageDict = (this.xref[pageRef.num] = page.pageDict.clone()); + oldRefMapping.put(oldPageRef, pageRef); + + // No need to keep these entries as we'll set them again later. + for (const key of [ + "Rotate", + "MediaBox", + "CropBox", + "BleedBox", + "TrimBox", + "ArtBox", + "Resources", + "Annots", + "Parent", + "UserUnit", + ]) { + pageDict.delete(key); + } + + const lastRef = this.newRefCount; + await this.#collectDependencies(pageDict, false, xref); + + pageDict.set("Rotate", rotate); + pageDict.set("MediaBox", mediaBox); + for (const boxName of ["CropBox", "BleedBox", "TrimBox", "ArtBox"]) { + const box = page.getBoundingBox(boxName); + if (box?.some((value, index) => value !== mediaBox[index])) { + // These boxes are optional and their default value is the MediaBox. + pageDict.set(boxName, box); + } + } + const userUnit = page.userUnit; + if (userUnit !== 1) { + pageDict.set("UserUnit", userUnit); + } + pageDict.setIfDict( + "Resources", + await this.#collectDependencies(resources, true, xref) + ); + pageDict.setIfArray( + "Annots", + await this.#collectDependencies(annotations, true, xref) + ); + + if (this.useObjectStreams) { + const newLastRef = this.newRefCount; + const pageObjectRefs = []; + for (let i = lastRef; i < newLastRef; i++) { + const obj = this.xref[i]; + if (obj instanceof BaseStream) { + continue; + } + pageObjectRefs.push(Ref.get(i, 0)); + } + for (let i = 0; i < pageObjectRefs.length; i += 0xffff) { + const objStreamRef = this.newRef; + this.objStreamRefs.add(objStreamRef.num); + this.xref[objStreamRef.num] = pageObjectRefs.slice(i, i + 0xffff); + } + } + + this.currentDocument = null; + + return pageRef; + } + + /** + * Create the page tree structure. + */ + #makePageTree() { + const { newPages: pages, rootDict, pagesRef, pagesDict } = this; + rootDict.set("Pages", pagesRef); + pagesDict.setIfName("Type", "Pages"); + pagesDict.set("Count", pages.length); + + const maxLeaves = + MAX_LEAVES_PER_PAGES_NODE <= 1 ? pages.length : MAX_LEAVES_PER_PAGES_NODE; + const stack = [{ dict: pagesDict, kids: pages, parentRef: pagesRef }]; + + while (stack.length > 0) { + const { dict, kids, parentRef } = stack.pop(); + if (kids.length <= maxLeaves) { + dict.set("Kids", kids); + for (const ref of kids) { + this.xref[ref.num].set("Parent", parentRef); + } + continue; + } + const chunkSize = Math.max(maxLeaves, Math.ceil(kids.length / maxLeaves)); + const kidsChunks = []; + for (let i = 0; i < kids.length; i += chunkSize) { + kidsChunks.push(kids.slice(i, i + chunkSize)); + } + const kidsRefs = []; + dict.set("Kids", kidsRefs); + for (const chunk of kidsChunks) { + const [kidRef, kidDict] = this.newDict; + kidsRefs.push(kidRef); + kidDict.setIfName("Type", "Pages"); + kidDict.set("Parent", parentRef); + kidDict.set("Count", chunk.length); + stack.push({ dict: kidDict, kids: chunk, parentRef: kidRef }); + } + } + } + + /** + * Create the root dictionary. + * @returns {Promise} + */ + async #makeRoot() { + const { rootDict } = this; + rootDict.setIfName("Type", "Catalog"); + rootDict.set("Version", this.version); + this.#makePageTree(); + } + + /** + * Create the info dictionary. + * @returns {Map} infoMap + */ + #makeInfo() { + const infoMap = new Map(); + if (this.hasSingleFile) { + const { + xref: { trailer }, + } = this.oldPages[0].documentData.document; + const oldInfoDict = trailer.get("Info"); + for (const [key, value] of oldInfoDict || []) { + if (typeof value === "string") { + infoMap.set(key, stringToPDFString(value)); + } + } + } + infoMap.delete("ModDate"); + infoMap.set("CreationDate", getModificationDate()); + infoMap.set("Creator", "PDF.js"); + infoMap.set("Producer", "Firefox"); + + if (this.author) { + infoMap.set("Author", this.author); + } + if (this.title) { + infoMap.set("Title", this.title); + } + for (const [key, value] of infoMap) { + this.infoDict.set(key, stringToAsciiOrUTF16BE(value)); + } + return infoMap; + } + + /** + * Create the encryption dictionary if required. + * @returns {Promise<[Dict|null, CipherTransformFactory|null, Array|null]>} + */ + async #makeEncrypt() { + if (!this.hasSingleFile) { + return [null, null, null]; + } + const { documentData } = this.oldPages[0]; + const { + document: { + xref: { trailer, encrypt }, + }, + } = documentData; + if (!trailer.has("Encrypt")) { + return [null, null, null]; + } + const encryptDict = trailer.get("Encrypt"); + if (!(encryptDict instanceof Dict)) { + return [null, null, null]; + } + this.currentDocument = documentData; + const result = [ + await this.#cloneObject(encryptDict, trailer.xref), + encrypt, + trailer.get("ID"), + ]; + this.currentDocument = null; + return result; + } + + /** + * Create the changes required to write the new PDF document. + * @returns {Promise<[RefSetCache, Ref]>} + */ + async #createChanges() { + const changes = new RefSetCache(); + changes.put(Ref.get(0, 0xffff), { data: null }); + for (let i = 1, ii = this.xref.length; i < ii; i++) { + if (this.objStreamRefs?.has(i)) { + await this.#createObjectStream(Ref.get(i, 0), this.xref[i], changes); + } else { + changes.put(Ref.get(i, 0), { data: this.xref[i] }); + } + } + + return [changes, this.newRef]; + } + + /** + * Create an object stream containing the given objects. + * @param {Ref} objStreamRef + * @param {Array} objRefs + * @param {RefSetCache} changes + */ + async #createObjectStream(objStreamRef, objRefs, changes) { + const streamBuffer = [""]; + const objOffsets = []; + let offset = 0; + const buffer = []; + for (let i = 0, ii = objRefs.length; i < ii; i++) { + const objRef = objRefs[i]; + changes.put(objRef, { data: null, objStreamRef, index: i }); + objOffsets.push(`${objRef.num} ${offset}`); + const data = this.xref[objRef.num]; + await writeValue(data, buffer, /* transform = */ null); + const obj = buffer.join(""); + buffer.length = 0; + streamBuffer.push(obj); + offset += obj.length + 1; + } + streamBuffer[0] = objOffsets.join("\n"); + const objStream = new StringStream(streamBuffer.join("\n")); + const objStreamDict = (objStream.dict = new Dict()); + objStreamDict.setIfName("Type", "ObjStm"); + objStreamDict.set("N", objRefs.length); + objStreamDict.set("First", streamBuffer[0].length + 1); + + changes.put(objStreamRef, { data: objStream }); + } + + /** + * Write the new PDF document to a Uint8Array. + * @returns {Promise} + */ + async writePDF() { + await this.#makeRoot(); + const infoMap = this.#makeInfo(); + const [encryptRef, encrypt, fileIds] = await this.#makeEncrypt(); + const [changes, xrefTableRef] = await this.#createChanges(); + + // Create the PDF header in order to help sniffers. + // PDF version must be in the range 1.0 to 1.7 inclusive. + // We add a binary comment line to ensure that the file is treated + // as a binary file by applications that open it. + const header = [ + ...`%PDF-${this.version}\n%`.split("").map(c => c.charCodeAt(0)), + 0xfa, + 0xde, + 0xfa, + 0xce, + ]; + return incrementalUpdate({ + originalData: new Uint8Array(header), + changes, + xrefInfo: { + startXRef: null, + rootRef: this.rootRef, + infoRef: this.infoRef, + encryptRef, + newRef: xrefTableRef, + fileIds: fileIds || [null, null], + infoMap, + }, + useXrefStream: this.useObjectStreams, + xref: { + encrypt, + encryptRef, + }, + }); + } +} + +export { PDFEditor }; diff --git a/src/core/primitives.js b/src/core/primitives.js index decd4338cc832..22cdd25270f88 100644 --- a/src/core/primitives.js +++ b/src/core/primitives.js @@ -188,6 +188,10 @@ class Dict { return [...this._map.values()]; } + getRawEntries() { + return this._map.entries(); + } + set(key, value) { if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) { if (typeof key !== "string") { @@ -231,6 +235,12 @@ class Dict { } } + setIfDict(key, value) { + if (value instanceof Dict) { + this.set(key, value); + } + } + has(key) { return this._map.has(key); } diff --git a/src/core/stream.js b/src/core/stream.js index 7bc9791ed9888..710b92f8c7bf8 100644 --- a/src/core/stream.js +++ b/src/core/stream.js @@ -82,6 +82,15 @@ class Stream extends BaseStream { makeSubStream(start, length, dict = null) { return new Stream(this.bytes.buffer, start, length, dict); } + + clone() { + return new Stream( + this.bytes.buffer, + this.start, + this.end - this.start, + this.dict.clone() + ); + } } class StringStream extends Stream { diff --git a/src/core/worker.js b/src/core/worker.js index 578ea2bdb2d8a..8ef7b10f4c934 100644 --- a/src/core/worker.js +++ b/src/core/worker.js @@ -36,6 +36,7 @@ import { MessageHandler, wrapReason } from "../shared/message_handler.js"; import { AnnotationFactory } from "./annotation.js"; import { clearGlobalCaches } from "./cleanup_helper.js"; import { incrementalUpdate } from "./writer.js"; +import { PDFEditor } from "./editor/pdf_editor.js"; import { PDFWorkerStream } from "./worker_stream.js"; import { StructTreeRoot } from "./struct_tree.js"; @@ -557,6 +558,97 @@ 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; + } 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: 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."); + } else { + pageInfo.document = manager.pdfDocument; + } + } 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", async function ({ isPureXfa, numPages, annotationStorage, filename }) { diff --git a/src/core/writer.js b/src/core/writer.js index bf66226a2c248..921936b3be2bb 100644 --- a/src/core/writer.js +++ b/src/core/writer.js @@ -19,7 +19,6 @@ import { escapePDFName, escapeString, getSizeInBytes, - numberToString, parseXFAPath, } from "./core_utils.js"; import { SimpleDOMNode, SimpleXMLParser } from "./xml_parser.js"; @@ -27,29 +26,34 @@ import { Stream, StringStream } from "./stream.js"; import { BaseStream } from "./base_stream.js"; import { calculateMD5 } from "./calculate_md5.js"; -async function writeObject(ref, obj, buffer, { encrypt = null }) { - const transform = encrypt?.createCipherTransform(ref.num, ref.gen); +async function writeObject( + ref, + obj, + buffer, + { encrypt = null, encryptRef = null } +) { + // Avoid to encrypt the encrypt dictionary. + const transform = + encrypt && encryptRef !== ref + ? encrypt.createCipherTransform(ref.num, ref.gen) + : null; buffer.push(`${ref.num} ${ref.gen} obj\n`); - if (obj instanceof Dict) { - await writeDict(obj, buffer, transform); - } else if (obj instanceof BaseStream) { - await writeStream(obj, buffer, transform); - } else if (Array.isArray(obj) || ArrayBuffer.isView(obj)) { - await writeArray(obj, buffer, transform); - } + await writeValue(obj, buffer, transform); buffer.push("\nendobj\n"); } async function writeDict(dict, buffer, transform) { buffer.push("<<"); - for (const key of dict.getKeys()) { + for (const [key, rawObj] of dict.getRawEntries()) { buffer.push(` /${escapePDFName(key)} `); - await writeValue(dict.getRaw(key), buffer, transform); + await writeValue(rawObj, buffer, transform); } buffer.push(">>"); } async function writeStream(stream, buffer, transform) { + stream = stream.getOriginalStream(); + stream.reset(); let bytes = stream.getBytes(); const { dict } = stream; @@ -67,7 +71,7 @@ async function writeStream(stream, buffer, transform) { // The number 256 is arbitrary, but it should be reasonable. const MIN_LENGTH_FOR_COMPRESSING = 256; - if (bytes.length >= MIN_LENGTH_FOR_COMPRESSING || isFilterZeroFlateDecode) { + if (bytes.length >= MIN_LENGTH_FOR_COMPRESSING && !isFilterZeroFlateDecode) { try { const cs = new CompressionStream("deflate"); const writer = cs.writable.getWriter(); @@ -120,14 +124,11 @@ async function writeStream(stream, buffer, transform) { async function writeArray(array, buffer, transform) { buffer.push("["); - let first = true; - for (const val of array) { - if (!first) { + for (let i = 0, ii = array.length; i < ii; i++) { + await writeValue(array[i], buffer, transform); + if (i < ii - 1) { buffer.push(" "); - } else { - first = false; } - await writeValue(val, buffer, transform); } buffer.push("]"); } @@ -145,7 +146,11 @@ async function writeValue(value, buffer, transform) { } buffer.push(`(${escapeString(value)})`); } else if (typeof value === "number") { - buffer.push(numberToString(value)); + // Don't try to round numbers in general, it could lead to have degenerate + // matrices (e.g. [0.000008 0 0 0.000008 0 0]). + // The numbers must be "rounded" only when pdf.js is producing them and the + // current transformation matrix is well known. + buffer.push(value.toString()); } else if (typeof value === "boolean") { buffer.push(value.toString()); } else if (value instanceof Dict) { @@ -306,7 +311,7 @@ async function getXRefTable(xrefInfo, baseOffset, newRefs, newXref, buffer) { } computeIDs(baseOffset, xrefInfo, newXref); buffer.push("trailer\n"); - await writeDict(newXref, buffer); + await writeDict(newXref, buffer, null); buffer.push("\nstartxref\n", baseOffset.toString(), "\n%%EOF\n"); } @@ -332,10 +337,17 @@ async function getXRefStreamTable( const xrefTableData = []; let maxOffset = 0; let maxGen = 0; - for (const { ref, data } of newRefs) { + for (const { ref, data, objStreamRef, index } of newRefs) { let gen; maxOffset = Math.max(maxOffset, baseOffset); - if (data !== null) { + // The first number in each entry is the type (see 7.5.8.3): + // 0: free object + // 1: in-use object + // 2: compressed object + if (objStreamRef) { + gen = index; + xrefTableData.push([2, objStreamRef.num, gen]); + } else if (data !== null) { gen = Math.min(ref.gen, 0xffff); xrefTableData.push([1, baseOffset, gen]); baseOffset += data.length; @@ -371,13 +383,13 @@ async function getXRefStreamTable( function computeIDs(baseOffset, xrefInfo, newXref) { if (Array.isArray(xrefInfo.fileIds) && xrefInfo.fileIds.length > 0) { const md5 = computeMD5(baseOffset, xrefInfo); - newXref.set("ID", [xrefInfo.fileIds[0], md5]); + newXref.set("ID", [xrefInfo.fileIds[0] || md5, md5]); } } function getTrailerDict(xrefInfo, changes, useXrefStream) { const newXref = new Dict(null); - newXref.set("Prev", xrefInfo.startXRef); + newXref.setIfDefined("Prev", xrefInfo?.startXRef); const refForXrefTable = xrefInfo.newRef; if (useXrefStream) { changes.put(refForXrefTable, { data: "" }); @@ -386,21 +398,20 @@ function getTrailerDict(xrefInfo, changes, useXrefStream) { } else { newXref.set("Size", refForXrefTable.num); } - if (xrefInfo.rootRef !== null) { - newXref.set("Root", xrefInfo.rootRef); - } - if (xrefInfo.infoRef !== null) { - newXref.set("Info", xrefInfo.infoRef); - } - if (xrefInfo.encryptRef !== null) { - newXref.set("Encrypt", xrefInfo.encryptRef); - } + newXref.setIfDefined("Root", xrefInfo?.rootRef); + newXref.setIfDefined("Info", xrefInfo?.infoRef); + newXref.setIfDefined("Encrypt", xrefInfo?.encryptRef); + return newXref; } async function writeChanges(changes, xref, buffer = []) { const newRefs = []; - for (const [ref, { data }] of changes.items()) { + for (const [ref, { data, objStreamRef, index }] of changes.items()) { + if (objStreamRef) { + newRefs.push({ ref, data, objStreamRef, index }); + continue; + } if (data === null || typeof data === "string") { newRefs.push({ ref, data }); continue; @@ -483,4 +494,4 @@ async function incrementalUpdate({ return array; } -export { incrementalUpdate, writeChanges, writeDict, writeObject }; +export { incrementalUpdate, writeChanges, writeDict, writeObject, writeValue }; diff --git a/src/display/api.js b/src/display/api.js index 149ceb2375cd7..b279df99426dc 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -1025,6 +1025,24 @@ class PDFDocumentProxy { return this._transport.saveDocument(); } + /** + * @typedef {Object} PageInfo + * @property {null|Uint8Array} document + * @property {Array|number>} [includePages] + * included ranges or indices. + * @property {Array|number>} [excludePages] + * excluded ranges or indices. + */ + + /** + * @param {Array} pageInfos - The pages to extract. + * @returns {Promise} A promise that is resolved with a + * {Uint8Array} containing the full data of the saved document. + */ + extractPages(pageInfos) { + return this._transport.extractPages(pageInfos); + } + /** * @returns {Promise<{ length: number }>} A promise that is resolved when the * document's data is loaded. It is resolved with an {Object} that contains @@ -2900,6 +2918,10 @@ class WorkerTransport { }); } + extractPages(pageInfos) { + return this.messageHandler.sendWithPromise("ExtractPages", { pageInfos }); + } + getPage(pageNumber) { if ( !Number.isInteger(pageNumber) || diff --git a/test/driver.js b/test/driver.js index a21a4a61078a0..212a0e85eea71 100644 --- a/test/driver.js +++ b/test/driver.js @@ -506,6 +506,7 @@ class Driver { this.inFlightRequests = 0; this.testFilter = JSON.parse(params.get("testfilter") || "[]"); this.xfaOnly = params.get("xfaonly") === "true"; + this.masterMode = params.get("mastermode") === "true"; // Create a working canvas this.canvas = document.createElement("canvas"); @@ -591,6 +592,25 @@ class Driver { task.stats = { times: [] }; task.enableXfa = task.enableXfa === true; + if (task.includePages && task.type === "extract") { + if (this.masterMode) { + const includePages = []; + for (const page of task.includePages) { + if (Array.isArray(page)) { + for (let i = page[0]; i <= page[1]; i++) { + includePages.push(i); + } + } else { + includePages.push(page); + } + } + task.numberOfTasks = includePages.length; + task.includePages = includePages; + } else { + delete task.pageMapping; + } + } + const prevFile = md5FileMap.get(task.md5); if (prevFile) { if (task.file !== prevFile) { @@ -658,6 +678,20 @@ class Driver { }); let promise = loadingTask.promise; + if (!this.masterMode && task.type === "extract") { + promise = promise.then(async doc => { + const data = await doc.extractPages([ + { + document: null, + includePages: task.includePages, + }, + ]); + await loadingTask.destroy(); + delete task.includePages; + return getDocument(data).promise; + }); + } + if (task.annotationStorage) { for (const annotation of Object.values(task.annotationStorage)) { const { bitmapName, quadPoints, paths, outlines } = annotation; @@ -862,7 +896,12 @@ class Driver { } } - if (task.skipPages?.includes(task.pageNum)) { + if ( + task.skipPages?.includes(task.pageNum) || + (this.masterMode && + task.includePages && + !task.includePages.includes(task.pageNum - 1)) + ) { this._log( ` Skipping page ${task.pageNum}/${task.pdfDoc.numPages}...\n` ); @@ -1274,10 +1313,11 @@ class Driver { id: task.id, numPages: task.pdfDoc ? task.lastPage || task.pdfDoc.numPages : 0, lastPageNum: this._getLastPageNumber(task), + numberOfTasks: task.numberOfTasks ?? -1, failure, file: task.file, round: task.round, - page: task.pageNum, + page: task.pageMapping?.[task.pageNum] ?? task.pageNum, snapshot, baselineSnapshot, stats: task.stats.times, diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index 1eb2dce1b8857..91091a44ea8ed 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -754,3 +754,6 @@ !bug1937438_from_word.pdf !bug1937438_mml_from_latex.pdf !bug1997343.pdf +!doc_1_3_pages.pdf +!doc_2_3_pages.pdf +!doc_3_3_pages.pdf diff --git a/test/pdfs/doc_1_3_pages.pdf b/test/pdfs/doc_1_3_pages.pdf new file mode 100755 index 0000000000000000000000000000000000000000..f71ed36ab7403f8a074747e39ef6b6c486978bad GIT binary patch literal 12091 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

lrr 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 M;7XAue~;_`0oyRn_y7O^ literal 0 HcmV?d00001 diff --git a/test/pdfs/doc_3_3_pages.pdf b/test/pdfs/doc_3_3_pages.pdf new file mode 100755 index 0000000000000000000000000000000000000000..63222e8991d42d4305e0ed35b4478733cf8ba230 GIT binary patch literal 40746 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 !!result).length === numberOfTasks; if (isDone) { checkRefTestResults(browser, id, taskResults); session.remaining--; diff --git a/test/test_manifest.json b/test/test_manifest.json index 03d1f1d71b4fe..f864c2be9e22e 100644 --- a/test/test_manifest.json +++ b/test/test_manifest.json @@ -13049,5 +13049,23 @@ "rotation": 0 } } + }, + { + "id": "tracemonkey-extract_0_2_12", + "file": "pdfs/tracemonkey.pdf", + "md5": "9a192d8b1a7dc652a19835f6f08098bd", + "rounds": 1, + "type": "extract", + "includePages": [0, 2, 12], + "pageMapping": { "1": 1, "3": 2, "13": 3 } + }, + { + "id": "bug900822-encrypted-extract_0", + "file": "pdfs/bug900822.pdf", + "md5": "70e2a3c5922574eeda169c955cf9d084", + "rounds": 1, + "type": "extract", + "includePages": [0], + "pageMapping": { "1": 1 } } ] diff --git a/test/unit/api_spec.js b/test/unit/api_spec.js index a8e0fbc07e68c..0729875b6ecde 100644 --- a/test/unit/api_spec.js +++ b/test/unit/api_spec.js @@ -5335,4 +5335,212 @@ deployment as easy as distributing a source file. They are used for small scripts as well as for`); }); }); + + describe("PDF page editing", function () { + describe("Merge pdfs", function () { + it("should merge three PDFs", async function () { + const loadingTask = getDocument( + buildGetDocumentParams("doc_1_3_pages.pdf") + ); + const pdfDoc = await loadingTask.promise; + const pdfData2 = await DefaultFileReaderFactory.fetch({ + path: TEST_PDFS_PATH + "doc_2_3_pages.pdf", + }); + const pdfData3 = await DefaultFileReaderFactory.fetch({ + path: TEST_PDFS_PATH + "doc_3_3_pages.pdf", + }); + + let data = await pdfDoc.extractPages([ + { document: null }, + { document: pdfData2 }, + { document: pdfData3 }, + ]); + let newLoadingTask = getDocument(data); + let newPdfDoc = await newLoadingTask.promise; + expect(newPdfDoc.numPages).toEqual(9); + + for (let i = 1; i <= 9; i++) { + const pdfPage = await newPdfDoc.getPage(i); + const { items: textItems } = await pdfPage.getTextContent(); + expect(mergeText(textItems)).toEqual( + `Document ${Math.ceil(i / 3)}:Page ${((i - 1) % 3) + 1}` + ); + } + await newLoadingTask.destroy(); + + data = await pdfDoc.extractPages([ + { document: pdfData3 }, + { document: pdfData2 }, + { document: null }, + ]); + newLoadingTask = getDocument(data); + newPdfDoc = await newLoadingTask.promise; + expect(newPdfDoc.numPages).toEqual(9); + for (let i = 1; i <= 9; i++) { + const pdfPage = await newPdfDoc.getPage(i); + const { items: textItems } = await pdfPage.getTextContent(); + expect(mergeText(textItems)).toEqual( + `Document ${Math.ceil((10 - i) / 3)}:Page ${((i - 1) % 3) + 1}` + ); + } + await newLoadingTask.destroy(); + + data = await pdfDoc.extractPages([ + { document: null, includePages: [0] }, + { document: pdfData2, includePages: [0] }, + { document: pdfData3, includePages: [0] }, + ]); + newLoadingTask = getDocument(data); + newPdfDoc = await newLoadingTask.promise; + expect(newPdfDoc.numPages).toEqual(3); + for (let i = 1; i <= 3; i++) { + const pdfPage = await newPdfDoc.getPage(i); + const { items: textItems } = await pdfPage.getTextContent(); + expect(mergeText(textItems)).toEqual(`Document ${i}:Page 1`); + } + await newLoadingTask.destroy(); + + data = await pdfDoc.extractPages([ + { document: null, excludePages: [0] }, + { document: pdfData2, excludePages: [0] }, + { document: pdfData3, excludePages: [0] }, + ]); + newLoadingTask = getDocument(data); + newPdfDoc = await newLoadingTask.promise; + expect(newPdfDoc.numPages).toEqual(6); + for (let i = 1; i <= 6; i++) { + const pdfPage = await newPdfDoc.getPage(i); + const { items: textItems } = await pdfPage.getTextContent(); + expect(mergeText(textItems)).toEqual( + `Document ${Math.ceil(i / 2)}:Page ${((i - 1) % 2) + 2}` + ); + } + await newLoadingTask.destroy(); + + await loadingTask.destroy(); + }); + + it("should merge two PDFs with page included ranges", async function () { + const loadingTask = getDocument( + buildGetDocumentParams("tracemonkey.pdf") + ); + const pdfDoc = await loadingTask.promise; + const pdfData1 = await DefaultFileReaderFactory.fetch({ + path: TEST_PDFS_PATH + "doc_1_3_pages.pdf", + }); + + const data = await pdfDoc.extractPages([ + { document: pdfData1, includePages: [[0, 0], 2] }, + { document: null, includePages: [[2, 4], 7] }, + ]); + const newLoadingTask = getDocument(data); + const newPdfDoc = await newLoadingTask.promise; + expect(newPdfDoc.numPages).toEqual(6); + + for (let i = 1; i <= 2; i++) { + const pdfPage = await newPdfDoc.getPage(i); + const { items: textItems } = await pdfPage.getTextContent(); + expect(mergeText(textItems)).toEqual(`Document 1:Page ${2 * i - 1}`); + } + + const expectedPagesText = [ + "v0 := ld s", + "i=4. On th", + "resentatio", + "5.1 Optimi", + ]; + for (let i = 3; i <= 6; i++) { + const pdfPage = await newPdfDoc.getPage(i); + const { items: textItems } = await pdfPage.getTextContent(); + const text = mergeText(textItems); + expect(text.substring(0, 10)).toEqual(expectedPagesText[i - 3]); + } + + await newLoadingTask.destroy(); + await loadingTask.destroy(); + }); + + it("should merge two PDFs with page excluded ranges", async function () { + const loadingTask = getDocument( + buildGetDocumentParams("tracemonkey.pdf") + ); + const pdfDoc = await loadingTask.promise; + const pdfData1 = await DefaultFileReaderFactory.fetch({ + path: TEST_PDFS_PATH + "doc_1_3_pages.pdf", + }); + + const data = await pdfDoc.extractPages([ + { document: pdfData1, excludePages: [[1, 1]] }, + { + document: null, + excludePages: [ + [0, 1], + [5, 6], + [8, 13], + ], + }, + ]); + const newLoadingTask = getDocument(data); + const newPdfDoc = await newLoadingTask.promise; + expect(newPdfDoc.numPages).toEqual(6); + + for (let i = 1; i <= 2; i++) { + const pdfPage = await newPdfDoc.getPage(i); + const { items: textItems } = await pdfPage.getTextContent(); + expect(mergeText(textItems)).toEqual(`Document 1:Page ${2 * i - 1}`); + } + + const expectedPagesText = [ + "v0 := ld s", + "i=4. On th", + "resentatio", + "5.1 Optimi", + ]; + for (let i = 3; i <= 6; i++) { + const pdfPage = await newPdfDoc.getPage(i); + const { items: textItems } = await pdfPage.getTextContent(); + const text = mergeText(textItems); + expect(text.substring(0, 10)).toEqual(expectedPagesText[i - 3]); + } + + await newLoadingTask.destroy(); + await loadingTask.destroy(); + }); + + it("should merge two PDFs with one with a password", async function () { + const loadingTask = getDocument( + buildGetDocumentParams("doc_1_3_pages.pdf") + ); + const pdfDoc = await loadingTask.promise; + const pdfData1 = await DefaultFileReaderFactory.fetch({ + path: TEST_PDFS_PATH + "pr6531_2.pdf", + }); + + const data = await pdfDoc.extractPages([ + { document: null, includePages: [0] }, + { document: pdfData1, password: "asdfasdf" }, + ]); + const newLoadingTask = getDocument(data); + const newPdfDoc = await newLoadingTask.promise; + expect(newPdfDoc.numPages).toEqual(2); + + const expectedPagesText = ["Document 1:Page 1", ""]; + for (let i = 1; i <= 2; i++) { + const pdfPage = await newPdfDoc.getPage(i); + const { items: textItems } = await pdfPage.getTextContent(); + expect(mergeText(textItems)).toEqual(expectedPagesText[i - 1]); + } + + const page2 = await newPdfDoc.getPage(2); + const annots = await page2.getAnnotations(); + expect(annots.length).toEqual(1); + expect(annots[0].contentsObj.str).toEqual( + "Bluebeam should be encrypting this." + ); + + await newLoadingTask.destroy(); + await loadingTask.destroy(); + }); + }); + }); }); diff --git a/test/unit/primitives_spec.js b/test/unit/primitives_spec.js index 04c92009b67b3..b71df0b83adfc 100644 --- a/test/unit/primitives_spec.js +++ b/test/unit/primitives_spec.js @@ -310,6 +310,16 @@ describe("primitives", function () { expect(rawValues2.sort()).toEqual(expectedRawValues2); }); + it("should get all raw entries", function () { + const expectedRawEntries = [ + ["FontFile", testFontFile], + ["FontFile2", testFontFile2], + ["FontFile3", testFontFile3], + ]; + const rawEntries = Array.from(dictWithManyKeys.getRawEntries()); + expect(rawEntries.sort()).toEqual(expectedRawEntries); + }); + it("should create only one object for Dict.empty", function () { const firstDictEmpty = Dict.empty; const secondDictEmpty = Dict.empty; @@ -423,6 +433,12 @@ describe("primitives", function () { dict.setIfName("k", 1234); expect(dict.has("k")).toBeFalse(); + + dict.setIfDict("l", new Dict()); + expect(dict.get("l")).toEqual(new Dict()); + + dict.setIfDict("m", "not a dict"); + expect(dict.has("m")).toBeFalse(); }); }); diff --git a/test/unit/writer_spec.js b/test/unit/writer_spec.js index 15866ee14f553..394c74429cde6 100644 --- a/test/unit/writer_spec.js +++ b/test/unit/writer_spec.js @@ -170,8 +170,8 @@ describe("Writer", function () { const expected = "<< /A /B /B 123 456 R /C 789 /D (hello world) " + - "/E (\\(hello\\\\world\\)) /F [1.23 4.5 6] " + - "/G << /H 123 /I << /Length 8>> stream\n" + + "/E (\\(hello\\\\world\\)) /F [1.23001 4.50001 6] " + + "/G << /H 123.00001 /I << /Length 8>> stream\n" + "a stream\n" + "endstream>> /J true /K false " + "/NullArr [null 10] /NullVal null>>";