From f43d7d52216e6a7a3a37c12607d2ae8d412d9abe Mon Sep 17 00:00:00 2001 From: calixteman Date: Mon, 17 Aug 2026 21:31:52 +0200 Subject: [PATCH 1/4] Fix Firefox-only tests in Chrome CI --- test/integration/text_layer_spec.mjs | 46 +++++++++++++++++++++------- test/test.mjs | 8 ++++- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/test/integration/text_layer_spec.mjs b/test/integration/text_layer_spec.mjs index 0a59514937bf1..c9d2869ecd224 100644 --- a/test/integration/text_layer_spec.mjs +++ b/test/integration/text_layer_spec.mjs @@ -14,7 +14,7 @@ */ /** - * @import { Page } from "puppeteer" + * @import { Browser, Page } from "puppeteer" */ import { @@ -50,6 +50,32 @@ import { startBrowser } from "../test.mjs"; * @property {string} text */ +// These suites require Firefox profile preferences and a dedicated browser, so +// exclude them when Firefox is disabled. +const describeFirefoxOnly = global.integrationSessions.some( + session => session.browserType === "firefox" +) + ? describe + : xdescribe; + +// Dedicated browser setup can exceed Jasmine's 30-second default. Keep this +// above `startBrowser`'s protocol timeout so protocol errors surface first. +const BROWSER_HOOK_TIMEOUT = 60000; + +/** + * @param {Browser} [browser] + * @param {Page} [page] + */ +async function closeDedicatedBrowser(browser, page) { + try { + if (page) { + await closeSinglePage(page); + } + } finally { + await browser?.close(); + } +} + describe("Text layer", () => { describe("Text layout", () => { let pages; @@ -1240,7 +1266,7 @@ describe("Text layer", () => { }); }); - describe("using selection carets", () => { + describeFirefoxOnly("using selection carets", () => { let browser; let page; @@ -1264,12 +1290,11 @@ describe("Text layer", () => { `.page[data-page-number = "1"] .endOfContent`, { timeout: 0 } ); - }); + }, BROWSER_HOOK_TIMEOUT); afterEach(async () => { - await closeSinglePage(page); - await browser.close(); - }); + await closeDedicatedBrowser(browser, page); + }, BROWSER_HOOK_TIMEOUT); it("doesn't jump when moving selection", async () => { const [initialStart, initialEnd, finalEnd] = await Promise.all([ @@ -1468,7 +1493,7 @@ describe("Text layer", () => { }); }); - describe("when the browser enforces a minimum font size", () => { + describeFirefoxOnly("when the browser enforces a minimum font size", () => { let browser; let page; @@ -1489,12 +1514,11 @@ describe("Text layer", () => { `.page[data-page-number = "1"] .endOfContent`, { timeout: 0 } ); - }); + }, BROWSER_HOOK_TIMEOUT); afterEach(async () => { - await closeSinglePage(page); - await browser.close(); - }); + await closeDedicatedBrowser(browser, page); + }, BROWSER_HOOK_TIMEOUT); it("renders spans with the right size", async () => { const rect = await getSpanRectFromText( diff --git a/test/test.mjs b/test/test.mjs index b38ead75551c5..1595a41818f0e 100644 --- a/test/test.mjs +++ b/test/test.mjs @@ -1068,8 +1068,14 @@ async function startBrowser({ "browser.newtabpage.enabled": false, // Disable network connections to Contile. "browser.topsites.contile.enabled": false, - // Disable logging for remote settings. + // Disable logging for remote settings and the messaging system. "services.settings.loglevel": "off", + "messaging-system.log": "off", + // Prevent remote settings from using the network. + "services.settings.server": "data:,#remote-settings-dummy/v1", + // Disable Nimbus rollouts and studies. + "nimbus.rollouts.enabled": false, + "app.shield.optoutstudies.enabled": false, // Disable AI/ML functionality. "browser.ai.control.default": "blocked", "privacy.baselineFingerprintingProtection": false, From 7e66cd399421ceab02b35c76ae24acc5ef655e54 Mon Sep 17 00:00:00 2001 From: calixteman Date: Sun, 16 Aug 2026 22:03:31 +0200 Subject: [PATCH 2/4] Downscale large JPEG images with ImageDecoder (bug 2063570) Read dimensions from supported JPEG SOF markers and request a reduced ImageDecoder frame when the source exceeds canvas limits. Use the returned frame size because requested dimensions are best-effort. Try ImageDecoder before the pixel-buffer fallback and add unit tests for dimension parsing and resize requests. --- src/core/base_stream.js | 6 +- src/core/image.js | 83 +++++------ src/core/image_resizer.js | 32 ++-- src/core/jpeg_stream.js | 26 +++- src/core/jpg.js | 9 +- test/pdfs/.gitignore | 1 + test/pdfs/large_jpeg_downscale.pdf | Bin 0 -> 121794 bytes test/test_manifest.json | 8 + test/unit/clitests.json | 2 + test/unit/image_resizer_spec.js | 93 ++++++++++++ test/unit/jasmine-boot.js | 2 + test/unit/jpeg_stream_spec.js | 232 +++++++++++++++++++++++++++++ 12 files changed, 433 insertions(+), 61 deletions(-) create mode 100644 test/pdfs/large_jpeg_downscale.pdf create mode 100644 test/unit/image_resizer_spec.js create mode 100644 test/unit/jpeg_stream_spec.js diff --git a/src/core/base_stream.js b/src/core/base_stream.js index 20a31f1f79797..47abf74760ddf 100644 --- a/src/core/base_stream.js +++ b/src/core/base_stream.js @@ -86,7 +86,11 @@ class BaseStream { return false; } - async getTransferableImage() { + /** + * @param {number} width - The width from the image dictionary. + * @param {number} height - The height from the image dictionary. + */ + async getTransferableImage(width, height) { return null; } diff --git a/src/core/image.js b/src/core/image.js index fe3883a2f6fa4..49c9edbcdfb47 100644 --- a/src/core/image.js +++ b/src/core/image.js @@ -837,30 +837,34 @@ class PDFImage { !this.mask && !this.needsDecode ) { - let imageLength = originalHeight * rowBytes; - if (isOffscreenCanvasSupported && !mustBeResized) { - let isHandled = false; - switch (this.colorSpace.name) { - case "DeviceGray": - // Avoid truncating the image, since `JpegImage.getData` - // will expand the image data when `forceRGB === true`. - imageLength *= 4; - isHandled = true; - break; - case "DeviceRGB": - imageLength = (imageLength / 3) * 4; - isHandled = true; - break; - case "DeviceCMYK": - isHandled = true; - break; - } - - if (isHandled) { + let isHandled = false; + switch (this.colorSpace.name) { + case "DeviceGray": + case "DeviceRGB": + case "DeviceCMYK": + isHandled = true; + break; + } + if (isHandled) { + if (isOffscreenCanvasSupported) { + // Try ImageDecoder before the pixel-buffer fallback. const image = await this.#getImage(drawWidth, drawHeight); if (image) { return image; } + } + let imageLength = originalHeight * rowBytes; + + if (isOffscreenCanvasSupported && !mustBeResized) { + switch (this.colorSpace.name) { + case "DeviceGray": + // Account for the DeviceGray-to-RGBA expansion. + imageLength *= 4; + break; + case "DeviceRGB": + imageLength = (imageLength / 3) * 4; + break; + } const rgba = await this.getImageBytes(imageLength, { drawWidth, drawHeight, @@ -874,26 +878,20 @@ class PDFImage { rgba ); } - } else { - switch (this.colorSpace.name) { - case "DeviceGray": - imageLength *= 3; - /* falls through */ - case "DeviceRGB": - case "DeviceCMYK": - imgData.kind = ImageKind.RGB_24BPP; - imgData.data = await this.getImageBytes(imageLength, { - drawWidth, - drawHeight, - forceRGB: true, - internal: mustBeResized, - }); - if (mustBeResized) { - // The image is too big so we resize it. - return ImageResizer.createImage(imgData); - } - return imgData; + if (this.colorSpace.name === "DeviceGray") { + imageLength *= 3; + } + imgData.kind = ImageKind.RGB_24BPP; + imgData.data = await this.getImageBytes(imageLength, { + drawWidth, + drawHeight, + forceRGB: true, + internal: mustBeResized, + }); + if (mustBeResized) { + return ImageResizer.createImage(imgData); } + return imgData; } } } @@ -1148,14 +1146,15 @@ class PDFImage { } async #getImage(width, height) { - const bitmap = await this.image.getTransferableImage(); + const bitmap = await this.image.getTransferableImage(width, height); if (!bitmap) { return null; } + // ImageDecoder may ignore the requested dimensions. return { data: null, - width, - height, + width: bitmap.displayWidth ?? width, + height: bitmap.displayHeight ?? height, bitmap, interpolate: this.interpolate, }; diff --git a/src/core/image_resizer.js b/src/core/image_resizer.js index 5966b945faf84..fd72f03c4a829 100644 --- a/src/core/image_resizer.js +++ b/src/core/image_resizer.js @@ -96,18 +96,20 @@ class ImageResizer { return area > maxArea; } - static getReducePowerForJPX(width, height, componentsCount) { + // Return the power-of-two reduction exponent for canvas and area limits. + static getReducePower(width, height, maxArea = Infinity) { + if ( + !Number.isInteger(width) || + width <= 0 || + !Number.isInteger(height) || + height <= 0 + ) { + return 0; + } const area = width * height; - // The maximum memory we've in the wasm runtime is 2GB. - // Each component is 4 bytes and we can't allocate all the memory just for - // the buffers so we limit the size to 1GB / (componentsCount * 4). - // We could use more than 2GB by setting MAXIMUM_MEMORY but it would take - // too much time to decode a big image. - const maxJPXArea = 2 ** 30 / (componentsCount * 4); if (!this.needsToBeResized(width, height)) { - if (area > maxJPXArea) { - // The image is too large, we need to rescale it. - return Math.ceil(Math.log2(area / maxJPXArea)); + if (area > maxArea) { + return Math.ceil(Math.log2(area / maxArea)); } return 0; } @@ -115,9 +117,15 @@ class ImageResizer { const minFactor = Math.max( width / MAX_DIM, height / MAX_DIM, - Math.sqrt(area / Math.min(maxJPXArea, MAX_AREA)) + Math.sqrt(area / Math.min(maxArea, MAX_AREA)) ); - return Math.ceil(Math.log2(minFactor)); + return Math.max(0, Math.ceil(Math.log2(minFactor))); + } + + static getReducePowerForJPX(width, height, componentsCount) { + // Budget 1 GiB of OpenJPEG's 2 GiB Wasm heap for four-byte component + // samples. + return this.getReducePower(width, height, 2 ** 30 / (componentsCount * 4)); } static get MAX_DIM() { diff --git a/src/core/jpeg_stream.js b/src/core/jpeg_stream.js index 6d579e29f2e2e..0ef04ae2ccbec 100644 --- a/src/core/jpeg_stream.js +++ b/src/core/jpeg_stream.js @@ -16,6 +16,7 @@ import { FeatureTest, shadow, warn } from "../shared/util.js"; import { DecodeStream } from "./decode_stream.js"; import { Dict } from "./primitives.js"; +import { ImageResizer } from "./image_resizer.js"; import { JpegImage } from "./jpg.js"; /** @@ -140,7 +141,7 @@ class JpegStream extends DecodeStream { return this.stream.isAsync; } - async getTransferableImage() { + async getTransferableImage(width, height) { if (!(await JpegStream.canUseImageDecoder)) { return null; } @@ -170,6 +171,17 @@ class JpegStream extends DecodeStream { if (!useImageDecoder) { return null; } + if ( + useImageDecoder.width !== width || + useImageDecoder.height !== height + ) { + // The SOF dimensions disagree with the image dictionary, e.g. because + // the height is only known from a DNL marker or because the scan simply + // ends early (issue15492.pdf). `ImageDecoder` reports and scales the + // frame according to the SOF, so let our own decoder, which honours the + // dictionary, handle the image instead. + return null; + } if (useImageDecoder.exifStart) { // Replace the entire EXIF-block with dummy data, to ensure that a // non-default EXIF orientation won't cause the image to be rotated @@ -179,11 +191,19 @@ class JpegStream extends DecodeStream { data = data.slice(); data.fill(0x00, useImageDecoder.exifStart, useImageDecoder.exifEnd); } - decoder = new ImageDecoder({ + const init = { data, type: "image/jpeg", preferAnimation: false, - }); + }; + // Request reduced dimensions; ImageDecoder treats them as best-effort. + const reducePower = ImageResizer.getReducePower(width, height); + if (reducePower) { + const factor = 2 ** reducePower; + init.desiredWidth = Math.ceil(width / factor); + init.desiredHeight = Math.ceil(height / factor); + } + decoder = new ImageDecoder(init); return (await decoder.decode()).image; } catch (reason) { diff --git a/src/core/jpg.js b/src/core/jpg.js index 71b75bed16c1a..3c51ea30d080a 100644 --- a/src/core/jpg.js +++ b/src/core/jpg.js @@ -814,6 +814,8 @@ class JpegImage { let exifOffsets = null; let offset = 0; let numComponents = null; + let scanLines = 0, + samplesPerLine = 0; let fileMarker = view.getUint16(offset); offset += 2; if (fileMarker !== /* SOI (Start of Image) = */ 0xffd8) { @@ -858,8 +860,8 @@ class JpegImage { case 0xffc2: // SOF2 (Start of Frame, Progressive DCT) // Skip marker length. // Skip precision. - // Skip scanLines. - // Skip samplesPerLine. + scanLines = view.getUint16(offset + (2 + 1)); + samplesPerLine = view.getUint16(offset + (2 + 1 + 2)); numComponents = data[offset + (2 + 1 + 2 + 2)]; break markerLoop; case 0xffff: // Fill bytes @@ -879,7 +881,8 @@ class JpegImage { if (numComponents === 3 && colorTransform === 0) { return null; } - return exifOffsets || {}; + // A zero SOF height means that a later DNL marker defines it. + return { width: samplesPerLine, height: scanLines, ...exifOffsets }; } parse(data, { dnlScanLines = null } = {}) { diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index 74fba7171e67c..8fc80157baa3a 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -950,3 +950,4 @@ !nonisolated_blend_smask.pdf !signed_verified.pdf !function_based_shading_cmyk.pdf +!large_jpeg_downscale.pdf diff --git a/test/pdfs/large_jpeg_downscale.pdf b/test/pdfs/large_jpeg_downscale.pdf new file mode 100644 index 0000000000000000000000000000000000000000..b376735932d86b58a88f2a0c1591223910a46c0e GIT binary patch literal 121794 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 literal 0 HcmV?d00001 diff --git a/test/test_manifest.json b/test/test_manifest.json index 20cbae7126f0b..05dea0a73d430 100644 --- a/test/test_manifest.json +++ b/test/test_manifest.json @@ -14516,5 +14516,13 @@ "rounds": 1, "lastPage": 1, "type": "eq" + }, + { + "id": "large_jpeg_downscale", + "file": "pdfs/large_jpeg_downscale.pdf", + "md5": "cfa0f34c0a615caaa267d70d7c91fd51", + "rounds": 1, + "lastPage": 1, + "type": "eq" } ] diff --git a/test/unit/clitests.json b/test/unit/clitests.json index 8559fae7fd4a8..6270fad6236f1 100644 --- a/test/unit/clitests.json +++ b/test/unit/clitests.json @@ -29,7 +29,9 @@ "font_loader_spec.js", "font_substitutions_spec.js", "fonts_spec.js", + "image_resizer_spec.js", "image_utils_spec.js", + "jpeg_stream_spec.js", "message_handler_spec.js", "metadata_spec.js", "murmurhash3_spec.js", diff --git a/test/unit/image_resizer_spec.js b/test/unit/image_resizer_spec.js new file mode 100644 index 0000000000000..edaf41d6b3ef2 --- /dev/null +++ b/test/unit/image_resizer_spec.js @@ -0,0 +1,93 @@ +/* Copyright 2026 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ImageResizer } from "../../src/core/image_resizer.js"; + +describe("image_resizer", function () { + describe("getReducePower", function () { + // Canvas limits depend on the runtime, so derive expectations from them. + + it("should not reduce images that fit within the limits", function () { + const { MAX_DIM } = ImageResizer; + + expect(ImageResizer.getReducePower(1, 1)).toEqual(0); + expect(ImageResizer.getReducePower(MAX_DIM, 1)).toEqual(0); + expect(ImageResizer.getReducePower(1, MAX_DIM)).toEqual(0); + }); + + it("should ignore invalid dimensions", function () { + // A JPEG SOF height can be zero until a later DNL marker defines it. + expect(ImageResizer.getReducePower(40000, 0)).toEqual(0); + expect(ImageResizer.getReducePower(0, 40000)).toEqual(0); + expect(ImageResizer.getReducePower(-40000, 4000)).toEqual(0); + expect(ImageResizer.getReducePower(40000.5, 4000)).toEqual(0); + expect(ImageResizer.getReducePower(NaN, 4000)).toEqual(0); + }); + + it("should reduce images exceeding the maximum dimension", function () { + const { MAX_DIM } = ImageResizer; + + expect(ImageResizer.getReducePower(MAX_DIM + 1, 1)).toEqual(1); + expect(ImageResizer.getReducePower(2 * MAX_DIM, 1)).toEqual(1); + expect(ImageResizer.getReducePower(2 * MAX_DIM + 1, 1)).toEqual(2); + expect(ImageResizer.getReducePower(4 * MAX_DIM, 1)).toEqual(2); + expect(ImageResizer.getReducePower(1, 4 * MAX_DIM)).toEqual(2); + }); + + it("should reduce images exceeding the maximum area", function () { + const side = Math.floor(Math.sqrt(ImageResizer.MAX_AREA)); + + expect(ImageResizer.getReducePower(side, side)).toEqual(0); + expect(ImageResizer.getReducePower(2 * side, 2 * side)).toEqual(1); + expect(ImageResizer.getReducePower(4 * side, 4 * side)).toEqual(2); + }); + + it("should honour the given maximum area", function () { + // 1024² bypasses the runtime canvas limits, isolating `maxArea`. + expect(ImageResizer.getReducePower(1024, 1024, 2 ** 20)).toEqual(0); + expect(ImageResizer.getReducePower(1024, 1024, 2 ** 19)).toEqual(1); + expect(ImageResizer.getReducePower(1024, 1024, 2 ** 18)).toEqual(2); + expect(ImageResizer.getReducePower(1024, 1024, 2 ** 14)).toEqual(6); + }); + + it("should reduce representative dimensions to fit the limits", function () { + const { MAX_DIM, MAX_AREA } = ImageResizer; + + for (const [width, height] of [ + [MAX_DIM + 1, 1], + [3 * MAX_DIM, 7], + [2 * MAX_DIM, 2 * MAX_DIM], + [7 * MAX_DIM, 5 * MAX_DIM], + [40000, 4000], + [40000, 10000], + ]) { + const factor = 2 ** ImageResizer.getReducePower(width, height); + // Match `JpegStream`'s rounding. + const newWidth = Math.ceil(width / factor); + const newHeight = Math.ceil(height / factor); + const context = `${width}x${height} reduced to ${newWidth}x${newHeight}`; + + expect(newWidth).withContext(context).toBeLessThanOrEqual(MAX_DIM); + expect(newHeight).withContext(context).toBeLessThanOrEqual(MAX_DIM); + expect(newWidth * newHeight) + .withContext(context) + .toBeLessThanOrEqual(MAX_AREA); + expect(ImageResizer.needsToBeResized(newWidth, newHeight)) + .withContext(context) + .toEqual(false); + } + }); + }); +}); diff --git a/test/unit/jasmine-boot.js b/test/unit/jasmine-boot.js index 507002f8d6518..21a9a5d8256cf 100644 --- a/test/unit/jasmine-boot.js +++ b/test/unit/jasmine-boot.js @@ -75,7 +75,9 @@ async function initializePDFJS(callback) { "pdfjs-test/unit/font_loader_spec.js", "pdfjs-test/unit/font_substitutions_spec.js", "pdfjs-test/unit/fonts_spec.js", + "pdfjs-test/unit/image_resizer_spec.js", "pdfjs-test/unit/image_utils_spec.js", + "pdfjs-test/unit/jpeg_stream_spec.js", "pdfjs-test/unit/message_handler_spec.js", "pdfjs-test/unit/metadata_spec.js", "pdfjs-test/unit/murmurhash3_spec.js", diff --git a/test/unit/jpeg_stream_spec.js b/test/unit/jpeg_stream_spec.js new file mode 100644 index 0000000000000..39d6e2ae7d66e --- /dev/null +++ b/test/unit/jpeg_stream_spec.js @@ -0,0 +1,232 @@ +/* Copyright 2026 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Dict } from "../../src/core/primitives.js"; +import { ImageResizer } from "../../src/core/image_resizer.js"; +import { JpegImage } from "../../src/core/jpg.js"; +import { JpegStream } from "../../src/core/jpeg_stream.js"; +import { Stream } from "../../src/core/stream.js"; + +// Only a JPEG header is needed: `canUseImageDecoder` stops at the SOF marker. +function createJpeg({ + width = 1, + height = 1, + numComponents = 3, + sofMarker = /* SOF0 (Start of Frame, Baseline DCT) = */ 0xffc0, + appData = null, +} = {}) { + const bytes = [0xff, 0xd8]; // SOI + if (appData) { + const length = appData.length + 2; + bytes.push(0xff, 0xe1, length >> 8, length & 0xff, ...appData); // APP1 + } + const sofLength = 8 + 3 * numComponents; + bytes.push( + sofMarker >> 8, + sofMarker & 0xff, + sofLength >> 8, + sofLength & 0xff, + 8, // sample precision + height >> 8, + height & 0xff, + width >> 8, + width & 0xff, + numComponents + ); + for (let i = 0; i < numComponents; i++) { + bytes.push(i + 1, 0x11, 0); // component id, H=1 V=1, quantization table 0 + } + bytes.push(0xff, 0xd9); // EOI + return new Uint8Array(bytes); +} + +describe("jpeg_stream", function () { + describe("JpegImage.canUseImageDecoder", function () { + it("should report the frame dimensions", function () { + expect( + JpegImage.canUseImageDecoder(createJpeg({ width: 40000, height: 4000 })) + ).toEqual({ width: 40000, height: 4000 }); + + expect( + JpegImage.canUseImageDecoder( + createJpeg({ width: 123, height: 45, numComponents: 1 }) + ) + ).toEqual({ width: 123, height: 45 }); + }); + + it("should report dimensions for each supported SOF marker", function () { + for (const sofMarker of [ + 0xffc0, // Baseline DCT. + 0xffc1, // Extended sequential DCT. + 0xffc2, // Progressive DCT. + ]) { + expect( + JpegImage.canUseImageDecoder( + createJpeg({ width: 40000, height: 4000, sofMarker }) + ) + ) + .withContext(sofMarker.toString(16)) + .toEqual({ width: 40000, height: 4000 }); + } + }); + + it("should report a zero SOF height", function () { + expect( + JpegImage.canUseImageDecoder(createJpeg({ width: 40000, height: 0 })) + ).toEqual({ width: 40000, height: 0 }); + }); + + it("should report the frame dimensions together with the EXIF-offsets", function () { + const payload = [1, 2, 3, 4]; + const appData = [...new TextEncoder().encode("Exif\x00\x00"), ...payload]; + + // SOI (2) + APP1-marker (2) + length (2) + "Exif\x00\x00" (6) = 12. + expect( + JpegImage.canUseImageDecoder( + createJpeg({ width: 40000, height: 4000, appData }) + ) + ).toEqual({ + width: 40000, + height: 4000, + exifStart: 12, + exifEnd: 12 + payload.length, + }); + }); + + it("should reject images that cannot be handled", function () { + // Four-component JPEGs. + expect( + JpegImage.canUseImageDecoder(createJpeg({ numComponents: 4 })) + ).toBeNull(); + // Three components with ColorTransform = 0. + expect( + JpegImage.canUseImageDecoder(createJpeg({ numComponents: 3 }), 0) + ).toBeNull(); + expect( + JpegImage.canUseImageDecoder(createJpeg({ numComponents: 3 }), 1) + ).not.toBeNull(); + }); + }); + + describe("getTransferableImage", function () { + let decoderInits, savedDescriptor, hadImageDecoder, savedImageDecoder; + + beforeEach(function () { + decoderInits = []; + hadImageDecoder = "ImageDecoder" in globalThis; + savedImageDecoder = globalThis.ImageDecoder; + globalThis.ImageDecoder = class { + constructor(init) { + decoderInits.push(init); + } + + decode() { + // Simulate a decoder that honours the requested dimensions. + const { desiredWidth, desiredHeight } = decoderInits.at(-1); + return Promise.resolve({ + image: { displayWidth: desiredWidth, displayHeight: desiredHeight }, + }); + } + + close() {} + }; + // Override cached feature detection and restore it after each test. + savedDescriptor = Object.getOwnPropertyDescriptor( + JpegStream, + "canUseImageDecoder" + ); + Object.defineProperty(JpegStream, "canUseImageDecoder", { + value: Promise.resolve(true), + enumerable: true, + configurable: true, + writable: false, + }); + }); + + afterEach(function () { + Object.defineProperty(JpegStream, "canUseImageDecoder", savedDescriptor); + if (hadImageDecoder) { + globalThis.ImageDecoder = savedImageDecoder; + } else { + delete globalThis.ImageDecoder; + } + }); + + function createStream(data) { + return new JpegStream( + new Stream(data, 0, data.length, Dict.empty), + data.length, + null + ); + } + + it("should not pass any hint for an image that fits", async function () { + const data = createJpeg({ width: 1024, height: 1024 }); + const image = await createStream(data).getTransferableImage(1024, 1024); + + expect(decoderInits.length).toEqual(1); + expect(decoderInits[0].desiredWidth).toBeUndefined(); + expect(decoderInits[0].desiredHeight).toBeUndefined(); + expect(image).not.toBeNull(); + }); + + it("should request a smaller frame for an oversized image", async function () { + const width = 40000, + height = 4000; + const factor = 2 ** ImageResizer.getReducePower(width, height); + expect(factor).toBeGreaterThan(1); + + const data = createJpeg({ width, height }); + const image = await createStream(data).getTransferableImage( + width, + height + ); + + expect(decoderInits.length).toEqual(1); + expect(decoderInits[0].desiredWidth).toEqual(Math.ceil(width / factor)); + expect(decoderInits[0].desiredHeight).toEqual(Math.ceil(height / factor)); + expect(image.displayWidth).toEqual(decoderInits[0].desiredWidth); + expect(image.displayHeight).toEqual(decoderInits[0].desiredHeight); + expect( + ImageResizer.needsToBeResized(image.displayWidth, image.displayHeight) + ).toEqual(false); + }); + + it("should not use `ImageDecoder` when the SOF dimensions disagree with the image dictionary", async function () { + // A zero SOF height means that a later DNL marker defines it. + const dnl = createJpeg({ width: 40000, height: 0 }); + expect( + await createStream(dnl).getTransferableImage(40000, 4000) + ).toBeNull(); + + // The scan may also simply end before the SOF height (issue15492.pdf). + const truncated = createJpeg({ width: 10800, height: 65000 }); + expect( + await createStream(truncated).getTransferableImage(10800, 10320) + ).toBeNull(); + + expect(decoderInits.length).toEqual(0); + }); + + it("should not use `ImageDecoder` for images it cannot handle", async function () { + const data = createJpeg({ width: 40000, height: 4000, numComponents: 4 }); + + expect( + await createStream(data).getTransferableImage(40000, 4000) + ).toBeNull(); + expect(decoderInits.length).toEqual(0); + }); + }); +}); From 69551bd0648af237a8e5005fa371e0717c1be2f4 Mon Sep 17 00:00:00 2001 From: Jonas Jenwald Date: Tue, 18 Aug 2026 12:57:12 +0200 Subject: [PATCH 3/4] Avoid creating a couple of intermediate Arrays --- src/core/crypto.js | 2 +- src/core/xfa/utils.js | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/core/crypto.js b/src/core/crypto.js index ad199f6cf60c8..7461b649102e6 100644 --- a/src/core/crypto.js +++ b/src/core/crypto.js @@ -207,7 +207,7 @@ class AESBaseCipher { 0x9f5d80be, 0x91548db5, 0x834f9aa8, 0x8d4697a3, ]); - _mixCol = new Uint8Array(256).map((_, i) => + _mixCol = Uint8Array.from({ length: 256 }, (_, i) => i < 128 ? i << 1 : (i << 1) ^ 0x1b ); diff --git a/src/core/xfa/utils.js b/src/core/xfa/utils.js index 2a9f8f55038cb..e203ea10bb6af 100644 --- a/src/core/xfa/utils.js +++ b/src/core/xfa/utils.js @@ -140,10 +140,10 @@ function getColor(data, def = [0, 0, 0]) { if (!data) { return { r, g, b }; } - const color = data - .split(",", 3) - .map(c => MathClamp(parseInt(c.trim(), 10), 0, 255)) - .map(c => (isNaN(c) ? 0 : c)); + const color = data.split(",", 3).map(c => { + c = parseInt(c.trim(), 10); + return isNaN(c) ? 0 : MathClamp(c, 0, 255); + }); if (color.length < 3) { return { r, g, b }; From 6e36bc7e7c14da00ab857d7f632afa53a061fdc3 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Tue, 18 Aug 2026 13:22:08 +0200 Subject: [PATCH 4/4] Remove unused l10n IDs It fixes #21270 --- l10n/en-US/viewer.ftl | 7 ------- 1 file changed, 7 deletions(-) diff --git a/l10n/en-US/viewer.ftl b/l10n/en-US/viewer.ftl index 46f376891c5d0..1d0e9ed02a4bc 100644 --- a/l10n/en-US/viewer.ftl +++ b/l10n/en-US/viewer.ftl @@ -758,13 +758,6 @@ pdfjs-views-manager-pages-status-undo-delete-label = *[other] { $count } pages deleted } -pdfjs-views-manager-pages-status-waiting-ready-label = Getting your file ready… -pdfjs-views-manager-pages-status-waiting-uploading-label = Uploading file… - -pdfjs-views-manager-status-warning-cut-label = Couldn’t cut. Refresh page and try again. -pdfjs-views-manager-status-warning-copy-label = Couldn’t copy. Refresh page and try again. -pdfjs-views-manager-status-warning-delete-label = Couldn’t delete. Refresh page and try again. -pdfjs-views-manager-status-warning-save-label = Couldn’t save. Refresh page and try again. pdfjs-views-manager-status-undo-button-label = Undo pdfjs-views-manager-status-done-button-label = Done pdfjs-views-manager-status-close-button =