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 = 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/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/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/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 }; 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/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 0000000000000..b376735932d86 Binary files /dev/null and b/test/pdfs/large_jpeg_downscale.pdf differ 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, 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); + }); + }); +});