mirror of
https://github.com/mozilla/pdf.js.git
synced 2026-06-05 01:31:00 +02:00
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:
parent
19046a6949
commit
4db9e45b8c
@ -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");
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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])
|
||||
);
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -400,6 +400,10 @@ function warn(msg) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} msg
|
||||
* @returns {never}
|
||||
*/
|
||||
function unreachable(msg) {
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
1
test/pdfs/.gitignore
vendored
1
test/pdfs/.gitignore
vendored
@ -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
|
||||
|
||||
BIN
test/pdfs/auth-event-ef-open.pdf
Normal file
BIN
test/pdfs/auth-event-ef-open.pdf
Normal file
Binary file not shown.
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -756,6 +756,7 @@ const PDFViewerApplication = {
|
||||
eventBus,
|
||||
l10n,
|
||||
downloadManager,
|
||||
linkService,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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++;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -52,6 +52,7 @@ const {
|
||||
normalizeUnicode,
|
||||
OPS,
|
||||
OutputScale,
|
||||
PasswordException,
|
||||
PasswordResponses,
|
||||
PDFDataRangeTransport,
|
||||
PDFDateString,
|
||||
@ -115,6 +116,7 @@ export {
|
||||
normalizeUnicode,
|
||||
OPS,
|
||||
OutputScale,
|
||||
PasswordException,
|
||||
PasswordResponses,
|
||||
PDFDataRangeTransport,
|
||||
PDFDateString,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user