Add support for /AuthEvent, on-demand decryption

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.
This commit is contained in:
Titus Wormer 2026-05-28 11:02:48 +02:00
parent 19046a6949
commit 4db9e45b8c
No known key found for this signature in database
GPG Key ID: E6E581152ED04E2E
23 changed files with 781 additions and 99 deletions

View File

@ -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 thats 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");

View File

@ -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");
}

View File

@ -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<string, Dict>}
*/
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<string, CatalogAttachment> | null}
* Attachments.
*/
get attachments() {
const obj = this.#catDict.get("Names");
/** @type {Record<string, CatalogAttachment> | 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<string, CatalogAttachment> | 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);

View File

@ -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<string, CipherConstructors>} */
#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

View File

@ -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 dont 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 };

View File

@ -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])
);
}

View File

@ -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) {

View File

@ -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");
});

View File

@ -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);
}
}
}

View File

@ -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<any>} A promise that is resolved with a lookup table
* for mapping named attachments to their content.
* @returns {Promise<Record<string, CatalogAttachment> | 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<CatalogAttachmentContent>}
* Promise that resolves to attachment content.
*/
getAttachmentContent(id) {
return this._transport.getAttachmentContent(id);
}
/**
* @param {Set<number>} types - The annotation types to retrieve.
* @param {Set<number>} pageIndexesToSkip
@ -3058,10 +3076,25 @@ class WorkerTransport {
return this.messageHandler.sendWithPromise("GetOpenAction", null);
}
/**
* @returns {Promise<Record<string, CatalogAttachment> | 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<CatalogAttachmentContent>}
* Promise that resolves to attachment content.
*/
getAttachmentContent(id) {
return this.messageHandler.sendWithPromise("GetAttachmentContent", id);
}
getAnnotationsByType(types, pageIndexesToSkip) {
return this.messageHandler.sendWithPromise("GetAnnotationsByType", {
types,

View File

@ -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,

View File

@ -400,6 +400,10 @@ function warn(msg) {
}
}
/**
* @param {string} msg
* @returns {never}
*/
function unreachable(msg) {
throw new Error(msg);
}

View File

@ -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;

View File

@ -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

Binary file not shown.

View File

@ -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 its already referenced in the `NameTree`.
expect(
pdfManagerMock.pdfDocument.catalog.attachmentDictById.has(data.fileId)
).toEqual(false);
});
});

View File

@ -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<unknown>} */
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();
});
});

View File

@ -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,

View File

@ -756,6 +756,7 @@ const PDFViewerApplication = {
eventBus,
l10n,
downloadManager,
linkService,
});
}

View File

@ -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<string, CatalogAttachment> | 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++;

View File

@ -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<CatalogAttachmentContent>}
* 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

View File

@ -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;

View File

@ -52,6 +52,7 @@ const {
normalizeUnicode,
OPS,
OutputScale,
PasswordException,
PasswordResponses,
PDFDataRangeTransport,
PDFDateString,
@ -115,6 +116,7 @@ export {
normalizeUnicode,
OPS,
OutputScale,
PasswordException,
PasswordResponses,
PDFDataRangeTransport,
PDFDateString,