From 4db9e45b8c414a81dea1ae02648e7f082f9784d7 Mon Sep 17 00:00:00 2001 From: Titus Wormer Date: Thu, 28 May 2026 11:02:48 +0200 Subject: [PATCH] Add support for `/AuthEvent`, on-demand decryption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normally entire PDFs are encrypted (or not). But it is also possible to only encrypt attachments. It is then also possible to *only* prompt for a password when the user opens them. In the existing flow, prompting for passwords happens because things are decrypted. A specific error is thrown, caught, and the user is prompted. To keep this flow working, this PR changes to decrypting attachments on demand, instead of eagerly. This sounds logical: to not read attachments on startup. I’ve extensively tested this, not only with regular attachments, but also with outline items and attachments in annotations. This PR builds on GH-21234. It’s an alternative to the naïve GH-20732. Closes GH-20049. --- src/core/annotation.js | 36 +++++++- src/core/base_stream.js | 10 +++ src/core/catalog.js | 123 +++++++++++++++++++++++++--- src/core/crypto.js | 100 +++++++++++++++++++---- src/core/file_spec.js | 87 ++++++++++++++------ src/core/name_number_tree.js | 2 +- src/core/pdf_manager.js | 13 +++ src/core/worker.js | 42 ++++++++++ src/display/annotation_layer.js | 51 +++++++++--- src/display/api.js | 37 ++++++++- src/pdf.js | 3 + src/shared/util.js | 4 + test/integration/viewer_spec.mjs | 59 ++++++++++++++ test/pdfs/.gitignore | 1 + test/pdfs/auth-event-ef-open.pdf | Bin 0 -> 2738 bytes test/unit/annotation_spec.js | 81 +++++++++++++++++- test/unit/api_spec.js | 136 +++++++++++++++++++++++++++---- test/unit/pdf_spec.js | 2 + web/app.js | 1 + web/pdf_attachment_viewer.js | 44 ++++++++-- web/pdf_link_service.js | 23 +++++- web/pdf_outline_viewer.js | 23 ++++-- web/pdfjs.js | 2 + 23 files changed, 781 insertions(+), 99 deletions(-) create mode 100644 test/pdfs/auth-event-ef-open.pdf diff --git a/src/core/annotation.js b/src/core/annotation.js index 8e4c94ec4..562a7da5c 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -79,6 +79,10 @@ import { parseMarkedContentProps } from "./evaluator_utils.js"; import { StringStream } from "./stream.js"; import { XFAFactory } from "./xfa/factory.js"; +/** + * @import { Catalog } from "./catalog.js"; + */ + class AnnotationFactory { static createGlobals(pdfManager) { return Promise.all([ @@ -5207,11 +5211,39 @@ class FileAttachmentAnnotation extends MarkupAnnotation { constructor(params) { super(params); - const { dict } = params; - const file = new FileSpec(dict.get("FS")); + const { annotationGlobals, dict } = params; + const fileSpecRef = dict.getRaw("FS"); + const fsDict = dict.get("FS"); + const file = new FileSpec(fsDict); + /** @type {{catalog?: Catalog}} */ + const { catalog } = annotationGlobals.pdfManager.pdfDocument; + + // When this annotation references an embedded file that’s already in the + // catalog `NameTree` (such as `EFOpen`), reuse that `NameTree` id so the + // sidebar and annotation paths resolve the same attachment identity. + let fileId = + fileSpecRef instanceof Ref + ? catalog?.attachmentIdByRef.get(fileSpecRef) + : undefined; + + // Fallback ids are namespaced to keep annotation-local ids distinct from + // `NameTree` ids (which are filename-based). + if (catalog && fsDict instanceof Dict && typeof fileId !== "string") { + const baseFileId = `annotation:${this.data.id}`; + fileId = baseFileId; + + let i = 1; + while (catalog.attachmentDictById.has(fileId)) { + fileId = `${baseFileId}-${i++}`; + } + + // Cache only fallbacks. + catalog.attachmentDictById.set(fileId, fsDict); + } this.data.hasOwnCanvas = this.data.noRotate; this.data.noHTML = false; + this.data.fileId = fileId; this.data.file = file.serializable; const name = dict.get("Name"); diff --git a/src/core/base_stream.js b/src/core/base_stream.js index d26d9acd8..20a31f1f7 100644 --- a/src/core/base_stream.js +++ b/src/core/base_stream.js @@ -25,11 +25,17 @@ class BaseStream { } } + /** + * @returns {number} + */ // eslint-disable-next-line getter-return get length() { unreachable("Abstract getter `length` accessed"); } + /** + * @returns {boolean} + */ // eslint-disable-next-line getter-return get isEmpty() { unreachable("Abstract getter `isEmpty` accessed"); @@ -43,6 +49,10 @@ class BaseStream { unreachable("Abstract method `getByte` called"); } + /** + * @param {number | undefined} [length] + * @returns {Uint8Array} + */ getBytes(length) { unreachable("Abstract method `getBytes` called"); } diff --git a/src/core/catalog.js b/src/core/catalog.js index ee367e823..8dfae5d7b 100644 --- a/src/core/catalog.js +++ b/src/core/catalog.js @@ -55,6 +55,37 @@ import { MetadataParser } from "./metadata_parser.js"; import { stringToPDFString } from "./string_utils.js"; import { StructTreeRoot } from "./struct_tree.js"; +/** + * @import {XRef} from "./xref.js"; + */ + +/** + * @callback GetAttachmentContent + * Callback used to lazily fetch attachment content. + * @param {string} id + * Unique attachment identifier. + * @returns {CatalogAttachmentContent} + * Result. + */ + +/** + * @typedef {Uint8Array | null} CatalogAttachmentContent + * Attachment value. + */ + +/** + * @typedef CatalogAttachment + * Attachment metadata. + * @property {CatalogAttachmentContent | undefined} [content] + * Value, when already available. + * @property {string} description + * Description. + * @property {string} filename + * Filename (just the basename) for display. + * @property {string} rawFilename + * File path. + */ + const isRef = v => v instanceof Ref; const isValidExplicitDest = _isValidExplicitDest.bind( @@ -88,8 +119,18 @@ function fetchRemoteDest(action) { class Catalog { #actualNumPages = null; + /** @type {RefSetCache | null} */ + #attachmentIdByRef = null; + #catDict = null; + /** + * Attachment dictionaries keyed by attachment id. + * + * @type {Map} + */ + attachmentDictById = new Map(); + builtInCMapCache = new Map(); fontCache = new RefSetCache(); @@ -123,6 +164,29 @@ class Catalog { this.toplevelPagesDict; // eslint-disable-line no-unused-expressions } + /** + * Attachment ids keyed by embedded-file reference. + * + * @type {RefSetCache} + */ + get attachmentIdByRef() { + if (this.#attachmentIdByRef) { + return this.#attachmentIdByRef; + } + + const attachmentIdByRef = new RefSetCache(); + for (const [name, ref] of this.rawEmbeddedFiles || []) { + if (!(ref instanceof Ref)) { + continue; + } + attachmentIdByRef.put( + ref, + stringToPDFString(name, /* keepEscapeSequence = */ true) + ); + } + return (this.#attachmentIdByRef = attachmentIdByRef); + } + cloneDict() { return this.#catDict.clone(); } @@ -369,6 +433,7 @@ class Catalog { const outlineItem = { action: data.action, + attachmentId: data.attachmentId, attachment: data.attachment, dest: data.dest, url: data.url, @@ -1068,8 +1133,15 @@ class Catalog { ); } + /** + * Get attachments. + * + * @returns {Record | null} + * Attachments. + */ get attachments() { const obj = this.#catDict.get("Names"); + /** @type {Record | null} */ let attachments = null; if (obj instanceof Dict && obj.has("EmbeddedFiles")) { @@ -1084,6 +1156,32 @@ class Catalog { return shadow(this, "attachments", attachments); } + /** + * Get content for an attachment. + * + * @param {string} id + * Unique attachment identifier (required). + * @returns {CatalogAttachmentContent} + * Content. + */ + attachmentContent(id) { + const dict = this.attachmentDictById.get(id); + if (dict) { + return FileSpec.readContent(dict); + } + + const obj = this.#catDict.get("Names"); + if (obj instanceof Dict && obj.has("EmbeddedFiles")) { + const nameTree = new NameTree(obj.getRaw("EmbeddedFiles"), this.xref); + for (const [key, value] of nameTree.getAll()) { + if (stringToPDFString(key, /* keepEscapeSequence = */ true) === id) { + return FileSpec.readContent(value); + } + } + } + return null; + } + get rawEmbeddedFiles() { const obj = this.#catDict.get("Names"); if (!(obj instanceof Dict) || !obj.has("EmbeddedFiles")) { @@ -1182,6 +1280,9 @@ class Catalog { async cleanup(manuallyTriggered = false) { clearGlobalCaches(); + this.#attachmentIdByRef?.clear(); + this.#attachmentIdByRef = null; + this.attachmentDictById.clear(); this.globalColorSpaceCache.clear(); this.globalImageCache.clear(/* onlyData = */ manuallyTriggered); this.pageKidsCountCache.clear(); @@ -1556,8 +1657,8 @@ class Catalog { * properties will be placed. * @property {string} [docBaseUrl] - The document base URL that is used when * attempting to recover valid absolute URLs from relative ones. - * @property {Object} [docAttachments] - The document attachments (may not - * exist in most PDF documents). + * @property {Record | null} [docAttachments] - The + * document attachments (may not exist in most PDF documents). */ /** @@ -1740,8 +1841,7 @@ class Catalog { case "GoToR": const urlDict = action.get("F"); if (urlDict instanceof Dict) { - const fs = new FileSpec(urlDict, /* skipContent = */ true); - ({ rawFilename: url } = fs.serializable); + url = new FileSpec(urlDict).filename; } else if (typeof urlDict === "string") { url = urlDict; } else { @@ -1766,22 +1866,21 @@ class Catalog { case "GoToE": const target = action.get("T"); - let attachment; + /** @type {string | null} */ + let id = null; - if (docAttachments && target instanceof Dict) { + if (target instanceof Dict) { const relationship = target.get("R"); const name = target.get("N"); if (isName(relationship, "C") && typeof name === "string") { - attachment = - docAttachments[ - stringToPDFString(name, /* keepEscapeSequence = */ true) - ]; + id = stringToPDFString(name, /* keepEscapeSequence = */ true); } } - if (attachment) { - resultObj.attachment = attachment; + if (docAttachments && id) { + resultObj.attachmentId = id; + resultObj.attachment = docAttachments[id]; // NOTE: the destination is relative to the *attachment*. const attachmentDest = fetchRemoteDest(action); diff --git a/src/core/crypto.js b/src/core/crypto.js index ebd579649..53a7b7a42 100644 --- a/src/core/crypto.js +++ b/src/core/crypto.js @@ -13,6 +13,10 @@ * limitations under the License. */ +/** + * @import {BaseStream} from "./base_stream.js"; + */ + import { bytesToString, FormatError, @@ -26,7 +30,7 @@ import { warn, } from "../shared/util.js"; import { calculateSHA384, calculateSHA512 } from "./calculate_sha_other.js"; -import { Dict, isName, Name } from "./primitives.js"; +import { Dict, isDict, isName, Name } from "./primitives.js"; import { calculateMD5 } from "./calculate_md5.js"; import { calculateSHA256 } from "./calculate_sha256.js"; import { DecryptStream } from "./decrypt_stream.js"; @@ -754,6 +758,9 @@ class CipherTransform { /** @type {Map} */ #cipherCache = new Map(); + /** @type {Name | null} */ + embeddedFilterName = null; + /** * @param {ResolveCipher} resolveCipher * Resolve a cipher constructor from a crypt filter name. @@ -789,7 +796,11 @@ class CipherTransform { * @returns {DecryptStream} */ createStream(stream, length, cryptFilterName = null) { - const Cipher = this.#getCipher(cryptFilterName || this.streamFilterName); + const defaultFilterName = + this.embeddedFilterName && isDict(stream.dict, "EmbeddedFile") + ? this.embeddedFilterName + : this.streamFilterName; + const Cipher = this.#getCipher(cryptFilterName || defaultFilterName); const cipher = new Cipher(); return new DecryptStream( stream, @@ -843,6 +854,8 @@ class CipherTransform { } class CipherTransformFactory { + #fileId; + static get _defaultPasswordBytes() { return shadow( this, @@ -1042,6 +1055,7 @@ class CipherTransformFactory { } this.filterName = filter.name; this.dict = dict; + this.#fileId = fileId; const algorithm = dict.get("V"); if ( !Number.isInteger(algorithm) || @@ -1077,6 +1091,29 @@ class CipherTransformFactory { throw new FormatError("invalid key length"); } + let cf = null; + let stmf = Name.get("Identity"); + let strf = Name.get("Identity"); + let eff = stmf; + + if (algorithm >= 4) { + cf = dict.get("CF"); + if (cf instanceof Dict) { + // The 'CF' dictionary itself should not be encrypted, and by setting + // `suppressEncryption` we can prevent an infinite loop inside of + // `XRef_fetchUncompressed` if the dictionary contains indirect + // objects (fixes issue7665.pdf). + cf.suppressEncryption = true; + } + stmf = dict.get("StmF") || Name.get("Identity"); + strf = dict.get("StrF") || Name.get("Identity"); + eff = dict.get("EFF") || stmf; + } + this.cf = cf; + this.stmf = stmf; + this.strf = strf; + this.eff = eff; + const ownerBytes = stringToBytes(dict.get("O")), userBytes = stringToBytes(dict.get("U")); // prepare keys @@ -1143,6 +1180,21 @@ class CipherTransformFactory { } if (!encryptionKey) { if (!password) { + if ( + this.algorithm >= 4 && + isName(this.stmf, "Identity") && + isName(this.strf, "Identity") + ) { + const effCF = this.cf?.get(this.eff.name); + const authEvent = effCF?.get("AuthEvent"); + + if (isName(authEvent, "EFOpen")) { + // For EFOpen with Identity as default stream/string filters, defer + // password prompting until an EmbeddedFile stream is actually read. + this.encryptionKey = null; + return; + } + } throw new PasswordException( "No password given", PasswordResponses.NEED_PASSWORD @@ -1182,21 +1234,23 @@ class CipherTransformFactory { } else { this.encryptionKey = encryptionKey; } + } - if (algorithm >= 4) { - const cf = dict.get("CF"); - if (cf instanceof Dict) { - // The 'CF' dictionary itself should not be encrypted, and by setting - // `suppressEncryption` we can prevent an infinite loop inside of - // `XRef_fetchUncompressed` if the dictionary contains indirect - // objects (fixes issue7665.pdf). - cf.suppressEncryption = true; - } - this.cf = cf; - this.stmf = dict.get("StmF") || Name.get("Identity"); - this.strf = dict.get("StrF") || Name.get("Identity"); - this.eff = dict.get("EFF") || this.stmf; - } + /** + * Set password. + * + * @param {string} password + * New password. + * @returns {undefined} + * Nothing. + */ + setPassword(password) { + const transform = new CipherTransformFactory( + this.dict, + this.#fileId, + password + ); + this.encryptionKey = transform.encryptionKey; } /** @@ -1220,6 +1274,12 @@ class CipherTransformFactory { if (!cfm || cfm.name === "None") { return NullCipher; } + if (!this.encryptionKey) { + throw new PasswordException( + "No password given", + PasswordResponses.NEED_PASSWORD + ); + } if (cfm.name === "V2") { return ARCFourCipher.bind( null, @@ -1248,7 +1308,13 @@ class CipherTransformFactory { throw new FormatError("Unknown crypto method"); }; - return new CipherTransform(resolveCipher, this.strf, this.stmf); + const transform = new CipherTransform( + resolveCipher, + this.strf, + this.stmf + ); + transform.embeddedFilterName = this.eff; + return transform; } // algorithms 1 and 2 diff --git a/src/core/file_spec.js b/src/core/file_spec.js index 06e9826f4..ee602f516 100644 --- a/src/core/file_spec.js +++ b/src/core/file_spec.js @@ -13,11 +13,31 @@ * limitations under the License. */ -import { stripPath, warn } from "../shared/util.js"; +import { + PasswordException, + PasswordResponses, + stripPath, + warn, +} from "../shared/util.js"; import { BaseStream } from "./base_stream.js"; import { Dict } from "./primitives.js"; import { stringToPDFString } from "./string_utils.js"; +/** + * @import { CatalogAttachmentContent } from "./catalog.js"; + */ + +/** + * Get a platform-specific item from a file-spec dictionary. + * + * Search order follows the PDF platform keys: `UF`, `F`, `Unix`, `Mac`, + * `DOS`. + * + * @param {Dict | null | undefined} dict + * Dictionary. + * @returns {unknown} + * Matching dictionary value or `null` when no key is found. + */ function pickPlatformItem(dict) { if (dict instanceof Dict) { // Look for the filename in this order: UF, F, Unix, Mac, DOS @@ -38,9 +58,11 @@ function pickPlatformItem(dict) { * collections attributes and related files (/RF) */ class FileSpec { - #contentAvailable = false; - - constructor(root, skipContent = false) { + /** + * @param {Dict | null | undefined} root + * File specification dictionary. + */ + constructor(root) { if (!(root instanceof Dict)) { return; } @@ -51,13 +73,6 @@ class FileSpec { if (root.has("RF")) { warn("Related file specifications are not supported"); } - if (!skipContent) { - if (root.has("EF")) { - this.#contentAvailable = true; - } else { - warn("Non-embedded file specifications are not supported"); - } - } } get filename() { @@ -73,19 +88,6 @@ class FileSpec { return ""; } - get content() { - if (!this.#contentAvailable) { - return null; - } - const ef = pickPlatformItem(this.root?.get("EF")); - - if (ef instanceof BaseStream) { - return ef.getBytes(); - } - warn("Embedded file specification points to non-existing/invalid content"); - return null; - } - get description() { const desc = this.root?.get("Desc"); if (desc && typeof desc === "string") { @@ -95,14 +97,47 @@ class FileSpec { } get serializable() { - const { filename, content, description } = this; + const { filename, description } = this; return { rawFilename: filename, filename: stripPath(filename) || "unnamed", - content, description, }; } + + /** + * Read attachment bytes from a file-spec dictionary. + * + * @param {Dict | null | undefined} dict + * File-spec dictionary containing an `EF` entry. + * @returns {CatalogAttachmentContent} + * Attachment bytes when available; otherwise `null`. + * @throws {PasswordException} + * When attachment bytes are encrypted and no key is available. + */ + static readContent(dict) { + if (!(dict instanceof Dict)) { + return null; + } + const ef = pickPlatformItem(dict.get("EF")); + if (!(ef instanceof BaseStream)) { + warn( + "Embedded file specification points to non-existing/invalid content" + ); + return null; + } + + // Throw if we need a password but don’t have one. + const encrypt = dict.xref?.encrypt; + if (encrypt?.encryptionKey === null) { + throw new PasswordException( + "No password given", + PasswordResponses.NEED_PASSWORD + ); + } + + return ef.getBytes(); + } } export { FileSpec }; diff --git a/src/core/name_number_tree.js b/src/core/name_number_tree.js index 952acafe0..1b7605907 100644 --- a/src/core/name_number_tree.js +++ b/src/core/name_number_tree.js @@ -73,7 +73,7 @@ class NameOrNumberTree { } for (let i = 0, ii = entries.length; i < ii; i += 2) { map.set( - xref.fetchIfRef(entries[i]), + isRaw ? entries[i] : xref.fetchIfRef(entries[i]), isRaw ? entries[i + 1] : xref.fetchIfRef(entries[i + 1]) ); } diff --git a/src/core/pdf_manager.js b/src/core/pdf_manager.js index 9496e724d..55ac40443 100644 --- a/src/core/pdf_manager.js +++ b/src/core/pdf_manager.js @@ -31,6 +31,10 @@ import { PDFFunctionFactory } from "./function.js"; import { Stream } from "./stream.js"; import { WasmImage } from "./wasm_image.js"; +/** + * @typedef { LocalPdfManager | NetworkPdfManager } PdfManager + */ + function parseDocBaseUrl(url) { if (url) { const absoluteUrl = createValidAbsoluteUrl(url); @@ -140,8 +144,17 @@ class BasePdfManager { unreachable("Abstract method `sendProgressiveData` called"); } + /** + * Set password. + * + * @param {string} password + * New password. + * @returns {undefined} + * Nothing. + */ updatePassword(password) { this._password = password; + this.pdfDocument.xref.encrypt?.setPassword(password); } terminate(reason) { diff --git a/src/core/worker.js b/src/core/worker.js index 5e75a1f04..b5a4a28be 100644 --- a/src/core/worker.js +++ b/src/core/worker.js @@ -436,6 +436,48 @@ class WorkerMessageHandler { return pdfManager.ensureCatalog("attachments"); }); + handler.on( + "GetAttachmentContent", + /** + * @param {string} id + * Unique attachment identifier (required). + */ + async function (id) { + // Loop to prompt again after an incorrect password. + while (true) { + try { + return await pdfManager.ensureCatalog("attachmentContent", [id]); + } catch (error) { + if (!(error instanceof PasswordException)) { + throw error; + } + + const task = new WorkerTask( + `PasswordException: response ${error.code}` + ); + startWorkerTask(task); + + try { + const { password } = await handler.sendWithPromise( + "PasswordRequest", + error + ); + try { + pdfManager.updatePassword(password); + } catch (exception) { + if (exception instanceof PasswordException) { + continue; + } + throw exception; + } + } finally { + finishWorkerTask(task); + } + } + } + } + ); + handler.on("GetDocJSActions", function (data) { return pdfManager.ensureCatalog("jsActions"); }); diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index c7bd5810f..bb96688d9 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -30,6 +30,10 @@ // eslint-disable-next-line max-len /** @typedef {import("../../web/base_download_manager.js").BaseDownloadManager} BaseDownloadManager */ +/** + * @import { CatalogAttachmentContent } from "../src/core/catalog.js"; + */ + import { AnnotationBorderStyleType, AnnotationEditorPrefix, @@ -984,6 +988,7 @@ class LinkAnnotationElement extends AnnotationElement { } else if (data.attachment) { this.#bindAttachment( link, + data.attachmentId, data.attachment, data.overlaidText, data.attachmentDest @@ -1079,23 +1084,40 @@ class LinkAnnotationElement extends AnnotationElement { /** * Bind attachments to the link element. * @param {Object} link - * @param {Object} attachment + * @param {string} attachmentId + * @param {CatalogAttachment} attachment * @param {string} [overlaidText] * @param {string} [dest] */ - #bindAttachment(link, attachment, overlaidText = "", dest = null) { + #bindAttachment( + link, + attachmentId, + attachment, + overlaidText = "", + dest = null + ) { link.href = this.linkService.getAnchorUrl(""); if (attachment.description) { link.title = attachment.description; } else if (overlaidText) { link.title = overlaidText; } + + const openAttachment = async () => { + /** @type {CatalogAttachmentContent} */ + const content = await this.linkService.getAttachmentContent(attachmentId); + + if (content) { + this.downloadManager?.openOrDownloadData( + content, + attachment.filename, + dest + ); + } + }; + link.onclick = () => { - this.downloadManager?.openOrDownloadData( - attachment.content, - attachment.filename, - dest - ); + openAttachment(); return false; }; this.#setInternalLink(); @@ -3672,12 +3694,14 @@ class FileAttachmentAnnotationElement extends AnnotationElement { constructor(parameters) { super(parameters, { isRenderable: true }); - const { file } = this.data; + const { fileId, file } = this.data; this.filename = file.filename; this.content = file.content; + this.fileId = fileId; this.linkService.eventBus?.dispatch("fileattachmentannotation", { source: this, + attachmentId: this.fileId, ...file, }); } @@ -3742,8 +3766,15 @@ class FileAttachmentAnnotationElement extends AnnotationElement { /** * Download the file attachment associated with this annotation. */ - #download() { - this.downloadManager?.openOrDownloadData(this.content, this.filename); + async #download() { + const { fileId, filename, content: fallbackContent } = this; + /** @type {CatalogAttachmentContent} */ + const content = + (await this.linkService.getAttachmentContent(fileId)) || fallbackContent; + + if (content) { + this.downloadManager?.openOrDownloadData(content, filename); + } } } diff --git a/src/display/api.js b/src/display/api.js index e4b22d12a..0d8f4f528 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -85,6 +85,13 @@ import { XfaText } from "./xfa_text.js"; const RENDERING_CANCELLED_TIMEOUT = 100; // ms +/** + * @import { + * CatalogAttachmentContent, + * CatalogAttachment + * } from "../core/catalog.js"; + */ + /** * @typedef { Int8Array | Uint8Array | Uint8ClampedArray | * Int16Array | Uint16Array | @@ -831,13 +838,24 @@ class PDFDocumentProxy { } /** - * @returns {Promise} A promise that is resolved with a lookup table - * for mapping named attachments to their content. + * @returns {Promise | null>} + * Promise that is resolved with a lookup table for mapping named + * attachments to their content. */ getAttachments() { return this._transport.getAttachments(); } + /** + * @param {string} id + * Unique attachment identifier (required). + * @returns {Promise} + * Promise that resolves to attachment content. + */ + getAttachmentContent(id) { + return this._transport.getAttachmentContent(id); + } + /** * @param {Set} types - The annotation types to retrieve. * @param {Set} pageIndexesToSkip @@ -3058,10 +3076,25 @@ class WorkerTransport { return this.messageHandler.sendWithPromise("GetOpenAction", null); } + /** + * @returns {Promise | null>} + * Promise that is resolved with a lookup table for mapping named + * attachments to their content. + */ getAttachments() { return this.messageHandler.sendWithPromise("GetAttachments", null); } + /** + * @param {string} id + * Unique attachment identifier (required). + * @returns {Promise} + * Promise that resolves to attachment content. + */ + getAttachmentContent(id) { + return this.messageHandler.sendWithPromise("GetAttachmentContent", id); + } + getAnnotationsByType(types, pageIndexesToSkip) { return this.messageHandler.sendWithPromise("GetAnnotationsByType", { types, diff --git a/src/pdf.js b/src/pdf.js index e024e8dc8..067649c4d 100644 --- a/src/pdf.js +++ b/src/pdf.js @@ -38,6 +38,7 @@ import { makeObj, normalizeUnicode, OPS, + PasswordException, PasswordResponses, PermissionFlag, ResponseException, @@ -135,6 +136,7 @@ globalThis.pdfjsLib = { normalizeUnicode, OPS, OutputScale, + PasswordException, PasswordResponses, PDFDataRangeTransport, PDFDateString, @@ -198,6 +200,7 @@ export { normalizeUnicode, OPS, OutputScale, + PasswordException, PasswordResponses, PDFDataRangeTransport, PDFDateString, diff --git a/src/shared/util.js b/src/shared/util.js index 1ae18fbee..972822107 100644 --- a/src/shared/util.js +++ b/src/shared/util.js @@ -400,6 +400,10 @@ function warn(msg) { } } +/** + * @param {string} msg + * @returns {never} + */ function unreachable(msg) { throw new Error(msg); } diff --git a/test/integration/viewer_spec.mjs b/test/integration/viewer_spec.mjs index a937e3cf8..9e75a59b5 100644 --- a/test/integration/viewer_spec.mjs +++ b/test/integration/viewer_spec.mjs @@ -32,6 +32,65 @@ import { PNG } from "pngjs"; const __dirname = import.meta.dirname; describe("PDF viewer", () => { + describe("EFOpen attachments", () => { + let pages; + + beforeEach(async () => { + pages = await loadAndWait( + "auth-event-ef-open.pdf", + ".textLayer .endOfContent", + "page-fit" + ); + }); + + afterEach(async () => { + if (pages) { + await closePages(pages); + } + }); + + it("keeps rendering after cancelling attachment password prompt", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + // Open the views manager sidebar. + await showViewsManager(page); + + // Open the view selector menu. + await page.click("#viewsManagerSelectorButton"); + + // Check that the attachments option is not disabled. + await page.waitForSelector("#attachmentsViewMenu", { visible: true }); + const attachmentsEnabled = await page.$eval( + "#attachmentsViewMenu", + el => !el.disabled + ); + expect(attachmentsEnabled) + .withContext(`In ${browserName}`) + .toBe(true); + + // Switch to the attachments view. + await page.click("#attachmentsViewMenu"); + await page.waitForSelector("#attachmentsView a", { timeout: 0 }); + + await page.click("#attachmentsView a"); + await page.waitForSelector("#passwordDialog[open]", { timeout: 0 }); + await waitAndClick(page, "#passwordCancel"); + + const stillRendered = await page.evaluate(() => { + const textLayer = document.querySelector( + ".page[data-page-number='1'] .textLayer .endOfContent" + ); + const canvas = document.querySelector( + ".page[data-page-number='1'] canvas" + ); + return !!textLayer && !!canvas; + }); + expect(stillRendered).withContext(`In ${browserName}`).toBe(true); + }) + ); + }); + }); + describe("Zoom origin", () => { let pages; diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index 1d9a61da6..232720cce 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -922,6 +922,7 @@ !knockout_groups_test.pdf !issue18032.pdf !encrypted-attachment.pdf +!auth-event-ef-open.pdf !Embedded_font.pdf !issue18548_reduced.pdf !issue_cff_unsigned_bbox.pdf diff --git a/test/pdfs/auth-event-ef-open.pdf b/test/pdfs/auth-event-ef-open.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d713950c3d16e2870f46ed7c40f5c070e8537aba GIT binary patch literal 2738 zcmb_edsG$28Mlo@&qAv8aZsc>qG&;JA2a)sM|j;EQ3-^LD2R{Q-C4MVd!hF(zDNkf zV51l%q_KHuZ4)b6joPNAV$}+06Ca69G_}$L^$@g1lhm3-DdyD9UY?Ph{?$GA+@1aQ zo8R}FZ}$6r6PcOnjN#`%CbHw>^A{K%aG*phV&dW$Hq%#Plo0{~0G1O&28I+iIffdknF9skg=275}2NfgK~VjS6aa(k!srr9QCqc5fTO?3h` zo2;?ul6v3T{Q6?|q{3#;u&y%A3haq(q+4NU(&oUh$=FWzH93UcJO{ zdkX-&*zHa7`rUnoL7S!w3FYu;#4w2AbQcqbrT-TFS}4yH-`Y}}Bb!PnETL)h*x!Mj z>K-00%SdrjXxpbZW3n-Q{@y_>h7i^=#|Yy?JJJ1$S+&n zc4X_(eZz9$s@jt^XOsSLQV&1==_4_2aR*B-v0Hbi01oS-p~6LcYN3R`Y&&-9}V2)=U=L?Tv#3o9y+*p6s|b1 zaCi8=ZTDN#V)N3!AGEBa>Z_)ZUF-9rzige}aAV1#pHH06WK6b{>XA!sG(8WKiXRV8 zJo)arf}61`SM0iUX#yNRZ`p!J;%77UCng-{hh6d1R9?SrL|4{L74x6oyYk?awzRO< zZY6iSC$+D0?$LtVyFS~T@aVMQc;_o$EZ!6|^VbdWi8aabpM6n2J|gkOb0^OHBqA^S zt))*~K6D0LGEV&>f}2!PaZcGiEB(~zgHgXdv|;lnt_A#mG^@w#Wg!x zNcoANcPcO59R2KH9Nw-Lva5n?dZTJu(7&IHx<7Z~2c5U|OWU6t@kUI?g{ztCUU=5k z>J{FazcSToJoO%qkaE%AO2C~jGcA9$DZ!! z2-$d``=izvCAap#{lpRZn|^0yZk!wX-s=(m;_~_|JbXj@Z(iRW{OFbW&OK-2l^vUM zHr(Cu<4Fm{)wfpXe$u+*^l(>EYG}^Umm8-B6~7n}8@=u0+@`HN-^@EQ{Lkax-FA8E zhhccjy|=4!6U-ZO-PqLT?I{hFU%eCh`TWFj|GL)M?9{X${mDOi^2+4zB^Nf-ZY*kh zb4&AgJ+qGO&u?`+6SU>z#j8VQK^;H zDD>_lO^b&%iZp}Ha9e9>Gq8N$c+PNuILsR+L5P<{8Oun~b%a%w-c-77P3MU&aI&VD zQ0ELJ5v*$_hh-gNj#D9JE!)|!VPr_#ER))!#HmEl2&Yg_hm$U&uXpry zz%@Q%G92I`!}VW)?iUn+3Hoh3ZPNAyyaP6oeu)p*c%Bx-gEobJ^$*w}ZRHQz=)#BE z&`_Hw4&lNhX{hbn{6uvKXI_#9*(@KXGT;jw;9Tx?#Lf$wRZ?OB`^-g;RB2wb1oV_K zo0e*KWvTaq9E4DX6i?#mU6LRjL#Rjy3X%X3ROp!M2^AIIw$OKo`l$*34+we?phu3t bxgnqi0g>@r*k=XyR}hfEL`FI?oXmd#u)=J& literal 0 HcmV?d00001 diff --git a/test/unit/annotation_spec.js b/test/unit/annotation_spec.js index f4520e1ea..4d272809a 100644 --- a/test/unit/annotation_spec.js +++ b/test/unit/annotation_spec.js @@ -29,7 +29,6 @@ import { DrawOPS, OPS, RenderingIntentFlag, - stringToBytes, stringToUTF8String, } from "../../src/shared/util.js"; import { @@ -53,6 +52,8 @@ describe("annotation", function () { constructor(params) { this.pdfDocument = { catalog: { + attachmentDictById: new Map(), + attachmentIdByRef: new RefSetCache(), baseUrl: params.docBaseUrl || null, }, }; @@ -4063,12 +4064,88 @@ describe("annotation", function () { idFactoryMock ); expect(data.annotationType).toEqual(AnnotationType.FILEATTACHMENT); + expect(data.fileId.startsWith("annotation:")).toEqual(true); expect(data.file).toEqual({ rawFilename: "Test.txt", filename: "Test.txt", - content: stringToBytes("Test attachment"), description: "abc", }); + + // Content lookup and reading requires a bigger mock than used here. + expect( + pdfManagerMock.pdfDocument.catalog.attachmentDictById.has(data.fileId) + ).toEqual(true); + }); + + it("should reuse the attachment NameTree id for referenced files", async function () { + const fileStream = new StringStream( + "<<\n" + + "/Type /EmbeddedFile\n" + + "/Subtype /text#2Fplain\n" + + ">>\n" + + "stream\n" + + "Test attachment" + + "endstream\n" + ); + const parser = new Parser({ + lexer: new Lexer(fileStream), + xref: null, + allowStreams: true, + }); + + const fileStreamRef = Ref.get(28, 0); + const fileStreamDict = parser.getObj(); + + const embeddedFileDict = new Dict(); + embeddedFileDict.set("F", fileStreamRef); + + const fileSpecRef = Ref.get(29, 0); + const fileSpecDict = new Dict(); + fileSpecDict.set("Type", Name.get("Filespec")); + fileSpecDict.set("Desc", "abc"); + fileSpecDict.set("EF", embeddedFileDict); + fileSpecDict.set("UF", "Test.txt"); + + const fileAttachmentRef = Ref.get(30, 0); + const fileAttachmentDict = new Dict(); + fileAttachmentDict.set("Type", Name.get("Annot")); + fileAttachmentDict.set("Subtype", Name.get("FileAttachment")); + fileAttachmentDict.set("FS", fileSpecRef); + fileAttachmentDict.set("T", "Topic"); + fileAttachmentDict.set("Contents", "Test.txt"); + + const xref = new XRefMock([ + { ref: fileStreamRef, data: fileStreamDict }, + { ref: fileSpecRef, data: fileSpecDict }, + { ref: fileAttachmentRef, data: fileAttachmentDict }, + ]); + embeddedFileDict.assignXref(xref); + fileSpecDict.assignXref(xref); + fileAttachmentDict.assignXref(xref); + + pdfManagerMock.pdfDocument.catalog.attachmentIdByRef.put( + fileSpecRef, + "Test.txt" + ); + + const { data } = await AnnotationFactory.create( + xref, + fileAttachmentRef, + annotationGlobalsMock, + idFactoryMock + ); + expect(data.annotationType).toEqual(AnnotationType.FILEATTACHMENT); + expect(data.fileId).toEqual("Test.txt"); + expect(data.file).toEqual({ + rawFilename: "Test.txt", + filename: "Test.txt", + description: "abc", + }); + + // File should not be added as it’s already referenced in the `NameTree`. + expect( + pdfManagerMock.pdfDocument.catalog.attachmentDictById.has(data.fileId) + ).toEqual(false); }); }); diff --git a/test/unit/api_spec.js b/test/unit/api_spec.js index 08d4e60a9..d2d2eb0ee 100644 --- a/test/unit/api_spec.js +++ b/test/unit/api_spec.js @@ -1664,10 +1664,14 @@ describe("api", function () { expect(attachments["foo.txt"]).toEqual({ rawFilename: "foo.txt", filename: "foo.txt", - content: new Uint8Array([98, 97, 114, 32, 98, 97, 122, 32, 10]), description: "", }); + const content = await pdfDoc.getAttachmentContent("foo.txt"); + expect(content).toEqual( + new Uint8Array([98, 97, 114, 32, 98, 97, 122, 32, 10]) + ); + await loadingTask.destroy(); }); @@ -1676,16 +1680,83 @@ describe("api", function () { const pdfDoc = await loadingTask.promise; const attachments = await pdfDoc.getAttachments(); - const { rawFilename, filename, content, description } = - attachments["empty.pdf"]; + const { rawFilename, filename, description } = attachments["empty.pdf"]; expect(rawFilename).toEqual("Empty page.pdf"); expect(filename).toEqual("Empty page.pdf"); - expect(content).toBeInstanceOf(Uint8Array); - expect(content.length).toEqual(2357); expect(description).toEqual( "SHA512: 06bec56808f93846f1d41ff0be4e54079c1291b860378c801c0f35f1d127a8680923ff6de59bd5a9692f01f0d97ca4f26da178ed03635fa4813d86c58a6c981a" ); + const content = await pdfDoc.getAttachmentContent("empty.pdf"); + expect(content).toBeInstanceOf(Uint8Array); + expect(content.length).toEqual(2357); + + await loadingTask.destroy(); + }); + + it("gets encrypted attachments when password is requested on demand", async function () { + const loadingTask = getDocument( + buildGetDocumentParams("encrypted-attachment.pdf") + ); + + let passwordRequests = 0; + loadingTask.onPassword = (updatePassword, reason) => { + passwordRequests++; + expect(reason).toEqual(PasswordResponses.NEED_PASSWORD); + updatePassword("000000"); + }; + + const pdfDoc = await loadingTask.promise; + + const attachments = await pdfDoc.getAttachments(); + const { description, filename, rawFilename } = + attachments["attachment.pdf"] || {}; + expect(rawFilename).toEqual("attachment.pdf"); + expect(filename).toEqual("attachment.pdf"); + expect(description).toEqual(""); + expect(attachments["attachment.pdf"].content).toBeUndefined(); + + const content = await pdfDoc.getAttachmentContent("attachment.pdf"); + expect(passwordRequests).toEqual(1); + expect(content).toBeInstanceOf(Uint8Array); + + await loadingTask.destroy(); + }); + + it("re-prompts for encrypted attachments after incorrect passwords", async function () { + const loadingTask = getDocument( + buildGetDocumentParams("encrypted-attachment.pdf") + ); + + /** @type {Array} */ + const reasons = []; + loadingTask.onPassword = (updatePassword, reason) => { + reasons.push(reason); + if (reason === PasswordResponses.NEED_PASSWORD) { + updatePassword("incorrect-password"); + return; + } + expect(reason).toEqual(PasswordResponses.INCORRECT_PASSWORD); + updatePassword("000000"); + }; + + const pdfDoc = await loadingTask.promise; + + const attachments = await pdfDoc.getAttachments(); + const { description, filename, rawFilename } = + attachments["attachment.pdf"] || {}; + expect(rawFilename).toEqual("attachment.pdf"); + expect(filename).toEqual("attachment.pdf"); + expect(description).toEqual(""); + expect(attachments["attachment.pdf"].content).toBeUndefined(); + + const content = await pdfDoc.getAttachmentContent("attachment.pdf"); + expect(reasons).toEqual([ + PasswordResponses.NEED_PASSWORD, + PasswordResponses.INCORRECT_PASSWORD, + ]); + expect(content).toBeInstanceOf(Uint8Array); + await loadingTask.destroy(); }); @@ -1705,7 +1776,10 @@ describe("api", function () { expect(attachment).toBeDefined(); expect(attachment.filename).toEqual("attachment.pdf"); - embeddedLoadingTask = getDocument({ data: attachment.content }); + const content = await pdfDoc.getAttachmentContent("attachment.pdf"); + expect(content).toBeInstanceOf(Uint8Array); + + embeddedLoadingTask = getDocument({ data: content }); const embeddedPdfDoc = await embeddedLoadingTask.promise; expect(embeddedPdfDoc.numPages).toBe(1); } finally { @@ -1983,6 +2057,7 @@ describe("api", function () { expect(outline[0]).toEqual({ action: null, + attachmentId: undefined, attachment: undefined, dest: "section.1", url: null, @@ -2010,6 +2085,7 @@ describe("api", function () { expect(outline[4]).toEqual({ action: null, + attachmentId: undefined, attachment: undefined, dest: "Händel -- Halle🎆lujah", url: null, @@ -2037,6 +2113,7 @@ describe("api", function () { expect(outline[1]).toEqual({ action: "PrevPage", + attachmentId: undefined, attachment: undefined, dest: null, url: null, @@ -2064,6 +2141,7 @@ describe("api", function () { expect(outline[0]).toEqual({ action: null, + attachmentId: undefined, attachment: undefined, dest: null, url: null, @@ -2104,6 +2182,7 @@ describe("api", function () { expect(outline).toEqual([ { action: null, + attachmentId: undefined, attachment: undefined, dest: [{ num: 14, gen: 0 }, { name: "XYZ" }, 65, 705], url: null, @@ -2119,6 +2198,7 @@ describe("api", function () { }, { action: null, + attachmentId: undefined, attachment: undefined, dest: [{ num: 13, gen: 0 }, { name: "XYZ" }, 60, 710], url: null, @@ -2147,6 +2227,7 @@ describe("api", function () { expect(outline).toEqual([ { action: null, + attachmentId: undefined, attachment: undefined, dest: [{ num: 14, gen: 0 }, { name: "FitH" }], url: null, @@ -2162,6 +2243,7 @@ describe("api", function () { }, { action: null, + attachmentId: undefined, attachment: undefined, dest: [{ num: 13, gen: 0 }, { name: "FitH" }], url: null, @@ -2190,6 +2272,7 @@ describe("api", function () { expect(outline).toEqual([ { action: null, + attachmentId: undefined, attachment: undefined, dest: null, url: null, @@ -2204,6 +2287,7 @@ describe("api", function () { items: [ { action: null, + attachmentId: undefined, attachment: undefined, dest: [{ num: 37, gen: 0 }, { name: "XYZ" }, null, null, null], url: null, @@ -2219,6 +2303,7 @@ describe("api", function () { }, { action: null, + attachmentId: undefined, attachment: undefined, dest: [{ num: 36, gen: 0 }, { name: "XYZ" }, null, null, null], url: null, @@ -3631,12 +3716,19 @@ describe("api", function () { expect(annotations.length).toEqual(1); expect(annotations[0].annotationType).toEqual(AnnotationType.LINK); - const { filename, content } = annotations[0].attachment; - expect(filename).toEqual("man.pdf"); + const { attachmentDest, attachmentId, attachment } = annotations[0]; + expect(attachment).toEqual({ + description: "", + filename: "man.pdf", + rawFilename: "man.pdf", + }); + + expect(attachmentId).toEqual("man.pdf"); + const content = await pdfDoc.getAttachmentContent(attachmentId); expect(content).toBeInstanceOf(Uint8Array); expect(content.length).toEqual(4508); - expect(annotations[0].attachmentDest).toEqual('[-1,{"name":"Fit"}]'); + expect(attachmentDest).toEqual('[-1,{"name":"Fit"}]'); await loadingTask.destroy(); }); @@ -3649,11 +3741,18 @@ describe("api", function () { const annotations = await pdfPage.getAnnotations(); expect(annotations.length).toEqual(30); - const { annotationType, attachment, attachmentDest } = annotations[0]; + const { annotationType, attachmentDest, attachmentId, attachment } = + annotations[0]; expect(annotationType).toEqual(AnnotationType.LINK); - const { filename, content } = attachment; - expect(filename).toEqual("destination-doc.pdf"); + expect(attachment).toEqual({ + description: "", + filename: "destination-doc.pdf", + rawFilename: "destination-doc.pdf", + }); + + expect(attachmentId).toEqual("destination-doc.pdf"); + const content = await pdfDoc.getAttachmentContent(attachmentId); expect(content).toBeInstanceOf(Uint8Array); expect(content.length).toEqual(10305); @@ -6730,10 +6829,14 @@ small scripts as well as for`); expect(attachments["foo.txt"]).toEqual({ rawFilename: "foo.txt", filename: "foo.txt", - content: new Uint8Array([98, 97, 114, 32, 98, 97, 122, 32, 10]), description: "", }); + const content = await pdfDoc.getAttachmentContent("foo.txt"); + expect(content).toEqual( + new Uint8Array([98, 97, 114, 32, 98, 97, 122, 32, 10]) + ); + await loadingTask.destroy(); }); @@ -6761,16 +6864,19 @@ small scripts as well as for`); expect(attachments["foo.txt"]).toEqual({ rawFilename: "foo.txt", filename: "foo.txt", - content: expectedContent, description: "", }); expect(attachments["foo.txt_1"]).toEqual({ rawFilename: "foo.txt", filename: "foo.txt", - content: expectedContent, description: "", }); + const content = await pdfDoc.getAttachmentContent("foo.txt"); + expect(content).toEqual(expectedContent); + const dedupedContent = await pdfDoc.getAttachmentContent("foo.txt_1"); + expect(dedupedContent).toEqual(expectedContent); + await loadingTask.destroy(); }); }); diff --git a/test/unit/pdf_spec.js b/test/unit/pdf_spec.js index ce40ea159..4140ca38f 100644 --- a/test/unit/pdf_spec.js +++ b/test/unit/pdf_spec.js @@ -29,6 +29,7 @@ import { makeObj, normalizeUnicode, OPS, + PasswordException, PasswordResponses, PermissionFlag, ResponseException, @@ -119,6 +120,7 @@ const expectedAPI = Object.freeze({ normalizeUnicode, OPS, OutputScale, + PasswordException, PasswordResponses, PDFDataRangeTransport, PDFDateString, diff --git a/web/app.js b/web/app.js index 3f308968c..52afe2be8 100644 --- a/web/app.js +++ b/web/app.js @@ -756,6 +756,7 @@ const PDFViewerApplication = { eventBus, l10n, downloadManager, + linkService, }); } diff --git a/web/pdf_attachment_viewer.js b/web/pdf_attachment_viewer.js index f515b75c2..1d1118975 100644 --- a/web/pdf_attachment_viewer.js +++ b/web/pdf_attachment_viewer.js @@ -17,6 +17,14 @@ // eslint-disable-next-line max-len /** @typedef {import("./download_manager.js").DownloadManager} DownloadManager */ +/** + * @import { + * CatalogAttachmentContent, + * CatalogAttachment, + * } from "../src/core/catalog.js"; + * @import { PDFLinkService } from "./pdf_link_service.js"; + */ + import { BaseTreeViewer } from "./base_tree_viewer.js"; import { internalOpt } from "./internal_evt.js"; import { waitOnEventOrTimeout } from "./event_utils.js"; @@ -26,11 +34,13 @@ import { waitOnEventOrTimeout } from "./event_utils.js"; * @property {HTMLDivElement} container - The viewer element. * @property {EventBus} eventBus - The application event bus. * @property {DownloadManager} downloadManager - The download manager. + * @property {PDFLinkService} linkService - Link service. */ /** - * @typedef {Object} PDFAttachmentViewerRenderParameters - * @property {Object|null} attachments - A lookup table of attachment objects. + * @typedef PDFAttachmentViewerRenderParameters + * @property {Record | null} attachments - A lookup + * table of attachment objects. * @property {boolean} [keepRenderedCapability] */ @@ -41,6 +51,7 @@ class PDFAttachmentViewer extends BaseTreeViewer { constructor(options) { super(options); this.downloadManager = options.downloadManager; + this.linkService = options.linkService; this.eventBus.on( "fileattachmentannotation", @@ -93,14 +104,34 @@ class PDFAttachmentViewer extends BaseTreeViewer { } /** + * @param {HTMLAnchorElement} element + * @param {CatalogAttachment & { attachmentId?: string }} item + * @returns {undefined} * @protected */ - _bindLink(element, { content, description, filename }) { + _bindLink( + element, + { attachmentId, content: fallbackContent, description, filename } + ) { if (description) { element.title = description; } + + const openAttachment = async () => { + /** @type {CatalogAttachmentContent | undefined} */ + // Prefer lazy loading when we have an attachment id; fallbackContent is + // only for the annotation-append path where bytes may already be present. + const content = attachmentId + ? await this.linkService.getAttachmentContent(attachmentId) + : fallbackContent; + + if (content) { + this.downloadManager.openOrDownloadData(content, filename); + } + }; + element.onclick = () => { - this.downloadManager.openOrDownloadData(content, filename); + openAttachment(); return false; }; } @@ -129,7 +160,10 @@ class PDFAttachmentViewer extends BaseTreeViewer { ul.append(li); const element = document.createElement("a"); li.append(element); - this._bindLink(element, item); + this._bindLink(element, { + ...item, + attachmentId: item.attachmentId ?? name, + }); element.textContent = this._normalizeTextContent(item.filename); attachmentsCount++; diff --git a/web/pdf_link_service.js b/web/pdf_link_service.js index 4db85534b..2ea42ec80 100644 --- a/web/pdf_link_service.js +++ b/web/pdf_link_service.js @@ -15,8 +15,12 @@ /** @typedef {import("./event_utils").EventBus} EventBus */ +/** + * @import { CatalogAttachmentContent } from "../src/core/catalog.js"; + */ + +import { isValidExplicitDest, PasswordException } from "pdfjs-lib"; import { internalOpt } from "./internal_evt.js"; -import { isValidExplicitDest } from "pdfjs-lib"; import { parseQueryString } from "./ui_utils.js"; const DEFAULT_LINK_REL = "noopener noreferrer nofollow"; @@ -254,6 +258,23 @@ class PDFLinkService { }); } + /** + * @param {string} id + * Unique attachment identifier (required). + * @returns {Promise} + * Content. + */ + async getAttachmentContent(id) { + try { + return await this.pdfDocument?.getAttachmentContent(id); + } catch (error) { + if (!(error instanceof PasswordException)) { + console.warn(`Unable to load attachment content: ${error}`); + } + } + return null; + } + /** * Adds various attributes (href, title, target, rel) to hyperlinks. * @param {HTMLAnchorElement} link diff --git a/web/pdf_outline_viewer.js b/web/pdf_outline_viewer.js index 9b2b6c8bb..bdbcb07b0 100644 --- a/web/pdf_outline_viewer.js +++ b/web/pdf_outline_viewer.js @@ -19,6 +19,10 @@ // eslint-disable-next-line max-len /** @typedef {import("../src/display/api.js").PDFDocumentProxy} PDFDocumentProxy */ +/** + * @import { CatalogAttachmentContent } from "../src/core/catalog.js"; + */ + import { BaseTreeViewer } from "./base_tree_viewer.js"; import { internalOpt } from "./internal_evt.js"; import { SidebarView } from "./ui_utils.js"; @@ -127,7 +131,7 @@ class PDFOutlineViewer extends BaseTreeViewer { */ _bindLink( element, - { url, newWindow, action, attachment, dest, setOCGState } + { url, newWindow, action, attachmentId, attachment, dest, setOCGState } ) { const { linkService } = this; @@ -143,13 +147,20 @@ class PDFOutlineViewer extends BaseTreeViewer { }; return; } - if (attachment) { + if (attachmentId && attachment) { element.href = linkService.getAnchorUrl(""); + + const openAttachment = async () => { + /** @type {CatalogAttachmentContent} */ + const content = await linkService.getAttachmentContent(attachmentId); + + if (content) { + this.downloadManager.openOrDownloadData(content, attachment.filename); + } + }; + element.onclick = () => { - this.downloadManager.openOrDownloadData( - attachment.content, - attachment.filename - ); + openAttachment(); return false; }; return; diff --git a/web/pdfjs.js b/web/pdfjs.js index 66bc9f76a..cbf203601 100644 --- a/web/pdfjs.js +++ b/web/pdfjs.js @@ -52,6 +52,7 @@ const { normalizeUnicode, OPS, OutputScale, + PasswordException, PasswordResponses, PDFDataRangeTransport, PDFDateString, @@ -115,6 +116,7 @@ export { normalizeUnicode, OPS, OutputScale, + PasswordException, PasswordResponses, PDFDataRangeTransport, PDFDateString,