From 9d9fb06d7f60f11ce671224782a5c7be9b544052 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Wed, 17 Jun 2026 14:49:25 +0200 Subject: [PATCH 1/4] Re-derive annotation attachment content from the xref after cleanup Annotation-local attachments (those not in the catalog `/Names` tree) were resolved through a dictionary cache that `Catalog.cleanup` clears, so their content became unreachable once the idle cleanup had run. Encode the reference of the embedded content in the attachment id and re-fetch it from the xref on demand instead of caching the dictionary, so the content stays reachable without anything having to survive cleanup. It fixes a regression introduced by #21351. --- src/core/annotation.js | 37 ++++++------ src/core/catalog.js | 105 ++++++++++++++++++++++------------- src/core/file_spec.js | 70 +++++++++++++---------- test/unit/annotation_spec.js | 105 +++++++++++++++++++++++++++-------- test/unit/api_spec.js | 28 ++++++++++ 5 files changed, 233 insertions(+), 112 deletions(-) diff --git a/src/core/annotation.js b/src/core/annotation.js index b2f4cb82efab2..0fd275c745998 100644 --- a/src/core/annotation.js +++ b/src/core/annotation.js @@ -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; diff --git a/src/core/catalog.js b/src/core/catalog.js index 7c2acbcf676d3..d66ecb943d3fc 100644 --- a/src/core/catalog.js +++ b/src/core/catalog.js @@ -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} - */ - attachmentDictById = new Map(); + #catDict = null; builtInCMapCache = new Map(); @@ -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() { @@ -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. * @@ -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; } @@ -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(); diff --git a/src/core/file_spec.js b/src/core/file_spec.js index ee602f5168f23..6409dc504b009 100644 --- a/src/core/file_spec.js +++ b/src/core/file_spec.js @@ -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. @@ -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. @@ -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. * @@ -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(); } } diff --git a/test/unit/annotation_spec.js b/test/unit/annotation_spec.js index 2744ca9158012..99fe01e78f617 100644 --- a/test/unit/annotation_spec.js +++ b/test/unit/annotation_spec.js @@ -26,6 +26,7 @@ import { AnnotationFieldFlag, AnnotationFlag, AnnotationType, + bytesToString, DrawOPS, OPS, RenderingIntentFlag, @@ -41,6 +42,7 @@ import { } from "./test_utils.js"; import { Dict, Name, Ref, RefSetCache } from "../../src/core/primitives.js"; import { Lexer, Parser } from "../../src/core/parser.js"; +import { Catalog } from "../../src/core/catalog.js"; import { FlateStream } from "../../src/core/flate_stream.js"; import { PartialEvaluator } from "../../src/core/evaluator.js"; import { StringStream } from "../../src/core/stream.js"; @@ -52,9 +54,10 @@ describe("annotation", function () { constructor(params) { this.pdfDocument = { catalog: { - attachmentDictById: new Map(), - attachmentIdByRef: new RefSetCache(), baseUrl: params.docBaseUrl || null, + getAttachmentIdForAnnotation(ref) { + return `attachmentRef:${ref.toString()}`; + }, }, }; this.evaluatorOptions = { @@ -4403,20 +4406,17 @@ describe("annotation", function () { idFactoryMock ); expect(data.annotationType).toEqual(AnnotationType.FILEATTACHMENT); - expect(data.fileId.startsWith("annotation:")).toEqual(true); + // The file-spec is an indirect object, so its reference is encoded in the + // id and re-fetched on demand. + expect(data.fileId).toEqual("attachmentRef:19R"); expect(data.file).toEqual({ rawFilename: "Test.txt", filename: "Test.txt", 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 () { + it("should re-derive an inline file attachment from its embedded stream", async function () { const fileStream = new StringStream( "<<\n" + "/Type /EmbeddedFile\n" + @@ -4432,41 +4432,36 @@ describe("annotation", function () { allowStreams: true, }); - const fileStreamRef = Ref.get(28, 0); + const fileStreamRef = Ref.get(18, 0); const fileStreamDict = parser.getObj(); const embeddedFileDict = new Dict(); embeddedFileDict.set("F", fileStreamRef); - const fileSpecRef = Ref.get(29, 0); + // The file-spec is inline (not an indirect object), so the embedded-file + // stream's reference is encoded in the id instead. 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 fileAttachmentRef = Ref.get(20, 0); const fileAttachmentDict = new Dict(); fileAttachmentDict.set("Type", Name.get("Annot")); fileAttachmentDict.set("Subtype", Name.get("FileAttachment")); - fileAttachmentDict.set("FS", fileSpecRef); + fileAttachmentDict.set("FS", fileSpecDict); 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, @@ -4474,17 +4469,81 @@ describe("annotation", function () { idFactoryMock ); expect(data.annotationType).toEqual(AnnotationType.FILEATTACHMENT); - expect(data.fileId).toEqual("Test.txt"); + expect(data.fileId).toEqual("attachmentRef:18R"); expect(data.file).toEqual({ rawFilename: "Test.txt", filename: "Test.txt", description: "abc", }); + }); + + it("should keep named attachment ids distinct from annotation attachment ids", function () { + const annotationStreamRef = Ref.get(18, 0); + const annotationStreamDict = new Dict(); + annotationStreamDict.set("Type", Name.get("EmbeddedFile")); + const annotationStream = new StringStream( + "Annotation attachment", + annotationStreamDict + ); - // File should not be added as it’s already referenced in the `NameTree`. + const namedStreamRef = Ref.get(21, 0); + const namedStreamDict = new Dict(); + namedStreamDict.set("Type", Name.get("EmbeddedFile")); + const namedStream = new StringStream("Named attachment", namedStreamDict); + + const namedEmbeddedFileDict = new Dict(); + namedEmbeddedFileDict.set("F", namedStreamRef); + + const namedFileSpecRef = Ref.get(22, 0); + const namedFileSpecDict = new Dict(); + namedFileSpecDict.set("Type", Name.get("Filespec")); + namedFileSpecDict.set("EF", namedEmbeddedFileDict); + namedFileSpecDict.set("F", "Named.txt"); + + const pagesDict = new Dict(); + const embeddedFilesDict = new Dict(); + embeddedFilesDict.set("Names", ["attachmentRef:18R", namedFileSpecRef]); + + const namesDict = new Dict(); + namesDict.set("EmbeddedFiles", embeddedFilesDict); + + const catalogDict = new Dict(); + catalogDict.set("Pages", pagesDict); + catalogDict.set("Names", namesDict); + + const xref = new XRefMock([ + { ref: annotationStreamRef, data: annotationStream }, + { ref: namedStreamRef, data: namedStream }, + { ref: namedFileSpecRef, data: namedFileSpecDict }, + ]); + xref.getCatalogObj = () => catalogDict; + + for (const dict of [ + annotationStreamDict, + namedStreamDict, + namedEmbeddedFileDict, + namedFileSpecDict, + pagesDict, + embeddedFilesDict, + namesDict, + catalogDict, + ]) { + dict.assignXref(xref); + } + + const catalog = new Catalog(pdfManagerMock, xref); + const annotationId = + catalog.getAttachmentIdForAnnotation(annotationStreamRef); + + expect(annotationId).toEqual("attachmentRef:18R-1"); expect( - pdfManagerMock.pdfDocument.catalog.attachmentDictById.has(data.fileId) - ).toEqual(false); + bytesToString(catalog.attachmentContent("attachmentRef:18R")) + ).toEqual("Named attachment"); + expect(bytesToString(catalog.attachmentContent(annotationId))).toEqual( + "Annotation attachment" + ); + // An unknown id resolves to no content. + expect(catalog.attachmentContent("nonexistent")).toEqual(null); }); }); diff --git a/test/unit/api_spec.js b/test/unit/api_spec.js index 86b9e2c9da093..fa8542691f105 100644 --- a/test/unit/api_spec.js +++ b/test/unit/api_spec.js @@ -3915,6 +3915,34 @@ describe("api", function () { await loadingTask.destroy(); }); + it("gets FileAttachment annotation content that stays readable after cleanup", async function () { + // The embedded files are reachable only via the annotations (no catalog + // `/Names` tree), so their content must survive `cleanup` by being + // re-derivable from the xref. + const loadingTask = getDocument(buildGetDocumentParams("bug1230933.pdf")); + const pdfDoc = await loadingTask.promise; + const pdfPage = await pdfDoc.getPage(1); + const annotations = await pdfPage.getAnnotations(); + + const fileAnnotation = annotations.find( + a => a.annotationType === AnnotationType.FILEATTACHMENT + ); + const { fileId } = fileAnnotation; + expect(fileId.startsWith("attachmentRef:")).toEqual(true); + + const before = await pdfDoc.getAttachmentContent(fileId); + expect(before).toBeInstanceOf(Uint8Array); + expect(before.length).toEqual(234414); + + await pdfDoc.cleanup(); + + const after = await pdfDoc.getAttachmentContent(fileId); + expect(after).toBeInstanceOf(Uint8Array); + expect(after.length).toEqual(234414); + + await loadingTask.destroy(); + }); + it("gets annotations containing /Launch action with /FileSpec dictionary (issue 17846)", async function () { const loadingTask = getDocument(buildGetDocumentParams("issue17846.pdf")); const pdfDoc = await loadingTask.promise; From a443a635a12bb30d14c74bf85cdc0051d3f6595c Mon Sep 17 00:00:00 2001 From: Jonas Jenwald Date: Thu, 18 Jun 2026 17:47:26 +0200 Subject: [PATCH 2/4] Remove the unused `HTMLElement` branch in the `paintInlineImageXObject` method This branch isn't covered by any tests, and as far as I can tell it's been unused ever since PR 11601 which simplified the JPEG image handling. Prior to that we'd create an `Image` instance in one case, see [this code](https://github.com/mozilla/pdf.js/pull/11601/changes#diff-082d6b37ad01db7ac97cc07c6ddb0dc52040484c5ef91b110b072f50144d9f39L2312-L2314), which is why that branch was necessary since `new Image()` creates a `HTMLImageElement` instance which in itself is an instance of `HTMLElement`; note [this](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/Image) respectively [this](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement). --- src/display/canvas.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/display/canvas.js b/src/display/canvas.js index dc6eb165a21f6..f77fe8a498896 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -3996,12 +3996,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); From b4b0a3fa0460664949d9e3dc3f95166a6b9c3e35 Mon Sep 17 00:00:00 2001 From: Jonas Jenwald Date: Thu, 18 Jun 2026 17:59:57 +0200 Subject: [PATCH 3/4] Remove the unused `ImageData` branch in the `putBinaryImageData` function This branch isn't covered by any tests, and looking at the two existing call-sites we only ever pass in a `CanvasRenderingContext2D` interface to this function. Based on the git history this branch was added in PR 3312, however as far as I can tell it doesn't actually appear to have been necessary even back then!? --- src/display/canvas.js | 5 ----- src/display/node_utils.js | 7 ------- 2 files changed, 12 deletions(-) diff --git a/src/display/canvas.js b/src/display/canvas.js index f77fe8a498896..47d2a296f4fff 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -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 diff --git a/src/display/node_utils.js b/src/display/node_utils.js index 156832c9f6d10..470f2c4809449 100644 --- a/src/display/node_utils.js +++ b/src/display/node_utils.js @@ -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; From 07d4c1018afd3ca1b9302d62812197242d2015b2 Mon Sep 17 00:00:00 2001 From: calixteman Date: Thu, 18 Jun 2026 18:52:04 +0200 Subject: [PATCH 4/4] Avoid too long BlueScale value when rewriting a CFF font It fixes #21466. --- src/core/cff_parser.js | 8 ++++++-- test/unit/cff_parser_spec.js | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/core/cff_parser.js b/src/core/cff_parser.js index 9fbd853059033..2a78c79e54872 100644 --- a/src/core/cff_parser.js +++ b/src/core/cff_parser.js @@ -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); diff --git a/test/unit/cff_parser_spec.js b/test/unit/cff_parser_spec.js index 80ee917d5ce09..f368d5fa8ac8f 100644 --- a/test/unit/cff_parser_spec.js +++ b/test/unit/cff_parser_spec.js @@ -389,6 +389,29 @@ describe("CFFParser", function () { ); }); + it("clamps BlueScale to a short decimal so the recompiled operand stays compact", function () { + // maxZoneHeight = 13 gives lower bound (0.5 / 13 = 0.038461538461538464) + // which is too long (issue 21466). + cff.topDict.privateDict = new CFFPrivateDict(cff.strings); + cff.topDict.privateDict.setByName( + "BlueValues", + [-13, 13, 530, 13, 220, 13, 30, 13] + ); + cff.topDict.privateDict.setByName("BlueScale", 0.01); + cff.topDict.setByName("Private", [0, 0]); + const fontDataShortBlueScale = new CFFCompiler(cff).compile(); + + const reparsedCff = new CFFParser( + new Stream(fontDataShortBlueScale), + {}, + SEAC_ANALYSIS_ENABLED + ).parse(); + + const blueScale = reparsedCff.topDict.privateDict.getByName("BlueScale"); + expect(blueScale).toEqual(0.03847); + expect(new CFFCompiler(cff).encodeFloat(blueScale).length).toBeLessThan(6); + }); + it("refuses to add topDict key with invalid value (bug 1068432)", function () { const topDict = cff.topDict; const defaultValue = topDict.getByName("UnderlinePosition");