Skip to content
Merged
37 changes: 16 additions & 21 deletions src/core/annotation.js
Original file line number Diff line number Diff line change
Expand Up @@ -5408,33 +5408,28 @@ class FileAttachmentAnnotation extends MarkupAnnotation {
super(params);

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++}`;
// Encode the embedded content's reference in the id so it can be
// re-fetched from the xref on demand (see `Catalog.attachmentContent`)
// instead of being cached where `cleanup` would wipe it. The file-spec is
// usually indirect; when it's inline its embedded-file stream still isn't
// (streams are always indirect), so fall back to that ref.
let fileId;
if (fsDict instanceof Dict) {
let contentRef = dict.getRaw("FS");
if (!(contentRef instanceof Ref)) {
contentRef = FileSpec.pickPlatformItem(
fsDict.get("EF"),
/* raw = */ true
);
}
if (contentRef instanceof Ref) {
fileId = catalog?.getAttachmentIdForAnnotation(contentRef);
}

// Cache only fallbacks.
catalog.attachmentDictById.set(fileId, fsDict);
}

this.data.hasOwnCanvas = this.data.noRotate;
Expand Down
105 changes: 65 additions & 40 deletions src/core/catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -119,17 +119,11 @@ function fetchRemoteDest(action) {
class Catalog {
#actualNumPages = null;

/** @type {RefSetCache | null} */
#attachmentIdByRef = null;
#annotationAttachmentIdByRef = new RefSetCache();

#catDict = null;
#annotationAttachmentRefById = new Map();

/**
* Attachment dictionaries keyed by attachment id.
*
* @type {Map<string, Dict>}
*/
attachmentDictById = new Map();
#catDict = null;

builtInCMapCache = new Map();

Expand Down Expand Up @@ -164,31 +158,42 @@ class Catalog {
this.toplevelPagesDict; // eslint-disable-line no-unused-expressions
}

cloneDict() {
return this.#catDict.clone();
}

/**
* Attachment ids keyed by embedded-file reference.
* Create an id for an attachment from a FileAttachment annotation.
*
* The id is registered here rather than parsed from a public string prefix in
* `attachmentContent`, since catalog attachment names can be arbitrary PDF
* strings and may otherwise collide with annotation-local ids.
*
* @type {RefSetCache}
* @param {Ref} ref
* File-spec or embedded-file stream reference.
* @returns {string}
* Attachment id.
*/
get attachmentIdByRef() {
if (this.#attachmentIdByRef) {
return this.#attachmentIdByRef;
getAttachmentIdForAnnotation(ref) {
let id = this.#annotationAttachmentIdByRef.get(ref);
if (id) {
return id;
}

const attachmentIdByRef = new RefSetCache();
for (const [name, ref] of this.rawEmbeddedFiles || []) {
if (!(ref instanceof Ref)) {
continue;
}
attachmentIdByRef.put(
ref,
stringToPDFString(name, /* keepEscapeSequence = */ true)
);
const baseId = `attachmentRef:${ref.toString()}`;
id = baseId;

let i = 1;
while (
this.#annotationAttachmentRefById.has(id) ||
this.attachments?.has(id)
) {
id = `${baseId}-${i++}`;
}
return (this.#attachmentIdByRef = attachmentIdByRef);
}

cloneDict() {
return this.#catDict.clone();
this.#annotationAttachmentIdByRef.put(ref, id);
this.#annotationAttachmentRefById.set(id, ref);
return id;
}

get version() {
Expand Down Expand Up @@ -1156,6 +1161,25 @@ class Catalog {
return shadow(this, "attachments", attachments);
}

/**
* @param {string} id
* Unique attachment identifier.
* @returns {CatalogAttachmentContent | undefined}
* Content, or `undefined` when no named attachment exists for the id.
*/
#attachmentContentByName(id) {
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 undefined;
}

/**
* Get content for an attachment.
*
Expand All @@ -1165,19 +1189,23 @@ class Catalog {
* Content.
*/
attachmentContent(id) {
const dict = this.attachmentDictById.get(id);
if (dict) {
return FileSpec.readContent(dict);
const namedContent = this.#attachmentContentByName(id);
if (namedContent !== undefined) {
return namedContent;
}

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);
}
// Annotation-local attachments register the reference of their embedded
// content in the catalog, so it's re-fetched from the xref on demand
// instead of being cached (which would then need to survive `cleanup`).
// The reference points either at the file-spec dictionary or, for an inline
// file-spec, straight at the embedded-file stream.
const ref = this.#annotationAttachmentRefById.get(id);
if (ref) {
const target = this.xref.fetch(ref);
if (target instanceof BaseStream) {
return FileSpec.readStreamContent(target);
}
return target instanceof Dict ? FileSpec.readContent(target) : null;
}
return null;
}
Expand Down Expand Up @@ -1280,9 +1308,6 @@ 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();
Expand Down
8 changes: 6 additions & 2 deletions src/core/cff_parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -902,10 +902,14 @@ class CFFParser {
// BlueScale up only misaligns/collapses overshooting glyphs (notably
// with macOS's Core Text rasterizer). Only apply the lower clamp when
// its target does not exceed the default.
// Round the bound in order to avoid too long operand (issue 21466).
const PRECISION = 1e5;
const lowerBound = 0.5 / maxZoneHeight;
const minBlueScale =
lowerBound <= DEFAULT_BLUE_SCALE ? lowerBound : -Infinity;
const maxBlueScale = 1 / maxZoneHeight;
lowerBound <= DEFAULT_BLUE_SCALE
? Math.ceil(lowerBound * PRECISION) / PRECISION
: -Infinity;
const maxBlueScale = Math.floor(PRECISION / maxZoneHeight) / PRECISION;
const clamped = MathClamp(blueScale, minBlueScale, maxBlueScale);
if (clamped !== blueScale) {
privateDict.setByName("BlueScale", clamped);
Expand Down
70 changes: 42 additions & 28 deletions src/core/file_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,29 +27,6 @@ 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
for (const key of ["UF", "F", "Unix", "Mac", "DOS"]) {
if (dict.has(key)) {
return dict.get(key);
}
}
}
return null;
}

/**
* "A PDF file can refer to the contents of another file by using a File
* Specification (PDF 1.1)", see the spec (7.11) for more details.
Expand All @@ -76,7 +53,7 @@ class FileSpec {
}

get filename() {
const item = pickPlatformItem(this.root);
const item = FileSpec.pickPlatformItem(this.root);
if (item && typeof item === "string") {
// NOTE: The following replacement order is INTENTIONAL, regardless of
// what some static code analysers (e.g. CodeQL) may claim.
Expand Down Expand Up @@ -105,6 +82,31 @@ class FileSpec {
};
}

/**
* 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.
* @param {boolean} [raw]
* Return the raw (possibly indirect) value rather than the resolved one.
* @returns {unknown}
* Matching dictionary value or `null` when no key is found.
*/
static pickPlatformItem(dict, raw = false) {
if (dict instanceof Dict) {
// Look for the filename in this order: UF, F, Unix, Mac, DOS
for (const key of ["UF", "F", "Unix", "Mac", "DOS"]) {
if (dict.has(key)) {
return raw ? dict.getRaw(key) : dict.get(key);
}
}
}
return null;
}

/**
* Read attachment bytes from a file-spec dictionary.
*
Expand All @@ -119,24 +121,36 @@ class FileSpec {
if (!(dict instanceof Dict)) {
return null;
}
const ef = pickPlatformItem(dict.get("EF"));
const ef = this.pickPlatformItem(dict.get("EF"));
if (!(ef instanceof BaseStream)) {
warn(
"Embedded file specification points to non-existing/invalid content"
);
return null;
}
return this.readStreamContent(ef);
}

/**
* Read the bytes of an embedded-file stream.
*
* @param {BaseStream} stream
* Embedded-file stream.
* @returns {CatalogAttachmentContent}
* Attachment bytes.
* @throws {PasswordException}
* When the bytes are encrypted and no key is available.
*/
static readStreamContent(stream) {
// Throw if we need a password but don’t have one.
const encrypt = dict.xref?.encrypt;
const encrypt = stream.dict?.xref?.encrypt;
if (encrypt?.encryptionKey === null) {
throw new PasswordException(
"No password given",
PasswordResponses.NEED_PASSWORD
);
}

return ef.getBytes();
return stream.getBytes();
}
}

Expand Down
11 changes: 0 additions & 11 deletions src/display/canvas.js
Original file line number Diff line number Diff line change
Expand Up @@ -320,11 +320,6 @@ class CanvasExtraState {
}

function putBinaryImageData(ctx, imgData) {
if (imgData instanceof ImageData) {
ctx.putImageData(imgData, 0, 0);
return;
}

// Put the image data to the canvas in chunks, rather than putting the
// whole image at once. This saves JS memory, because the ImageData object
// is smaller. It also possibly saves C++ memory within the implementation
Expand Down Expand Up @@ -3996,12 +3991,6 @@ class CanvasGraphics {
const result = this.applyTransferMapsToBitmap(imgData);
imgToPaint = result.img;
inlineImgCanvas = result.canvasEntry;
} else if (
(typeof HTMLElement === "function" && imgData instanceof HTMLElement) ||
!imgData.data
) {
// typeof check is needed due to node.js support, see issue #8489
imgToPaint = imgData;
} else {
const tmpCanvas = this.canvasFactory.create(width, height);
putBinaryImageData(tmpCanvas.context, imgData);
Expand Down
7 changes: 0 additions & 7 deletions src/display/node_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,6 @@ if (isNodeJS) {
warn("Cannot polyfill `DOMMatrix`, rendering may be broken.");
}
}
if (!globalThis.ImageData) {
if (canvas?.ImageData) {
globalThis.ImageData = canvas.ImageData;
} else {
warn("Cannot polyfill `ImageData`, rendering may be broken.");
}
}
if (!globalThis.Path2D) {
if (canvas?.Path2D) {
globalThis.Path2D = canvas.Path2D;
Expand Down
Loading
Loading