diff --git a/.circleci/config.yml b/.circleci/config.yml index 1fdd255..6458269 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -18,11 +18,22 @@ executors: commands: install-build-tools: - description: Install yarn + cmake + C++ build deps on top of the emsdk image + description: Install node + yarn + cmake + C++ build deps on top of the emsdk image steps: - run: name: Install build tooling command: | + # The emsdk 3.1.74 image ships node 20.18.0, but vite 7 (pulled in + # transitively by vitest 3) requires "^20.19.0 || >=22.12.0", so + # `yarn install` fails its engine check. Install node 22 and put it + # first on PATH for all later steps (mirrors the setup-node@v4 step + # the GitHub Actions build job already uses). emcc keeps using its + # own configured node, so the wasm build is unaffected. + wget -qO- "https://nodejs.org/dist/v22.12.0/node-v22.12.0-linux-x64.tar.gz" \ + | tar --strip-components=1 -xz -C /usr/local + echo 'export PATH=/usr/local/bin:$PATH' >> "$BASH_ENV" + export PATH=/usr/local/bin:$PATH + node --version npm install --global yarn@1.22.22 apt-get update apt-get -y install build-essential diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 50c52bf..60a133a 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -97,7 +97,11 @@ jobs: # measured. A change here (e.g. an emsdk bump in this workflow, or # a vitest upgrade in the root lockfile) must run the full # pipeline with a full bench sweep — previously such PRs matched - # no package path and skipped CI entirely. + # no package path and skipped CI entirely. tools/ entries count + # too: browser-smoke drives the browser-smoke job, and the test + # suites import reference derivations from + # tools/fixture-verification/gen/ — a change to either must not + # land with CI skipped. TOOLCHAIN_PATHS=( ".github/workflows/" "package.json" @@ -106,6 +110,8 @@ jobs: "babel.config.json" "lerna.json" "tools/dist-size/" + "tools/browser-smoke/" + "tools/fixture-verification/" ) for p in "${TOOLCHAIN_PATHS[@]}"; do if ! git diff --quiet "$BASE"..HEAD -- "$p"; then diff --git a/packages/big-endian/CHANGELOG.md b/packages/big-endian/CHANGELOG.md index 3aa1d47..886f828 100644 --- a/packages/big-endian/CHANGELOG.md +++ b/packages/big-endian/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.1.1](https://github.com/cornerstonejs/codecs/compare/@cornerstonejs/codec-big-endian@0.1.0...@cornerstonejs/codec-big-endian@0.1.1) (2026-07-09) + +**Note:** Version bump only for package @cornerstonejs/codec-big-endian + + + + + # [0.1.0](https://github.com/cornerstonejs/codecs/compare/@cornerstonejs/codec-big-endian@0.0.5...@cornerstonejs/codec-big-endian@0.1.0) (2022-02-09) diff --git a/packages/big-endian/package.json b/packages/big-endian/package.json index c571952..d7e430e 100644 --- a/packages/big-endian/package.json +++ b/packages/big-endian/package.json @@ -1,6 +1,6 @@ { "name": "@cornerstonejs/codec-big-endian", - "version": "0.1.0", + "version": "0.1.1", "description": "", "main": "dist/index.js", "publishConfig": { diff --git a/packages/big-endian/src/index.js b/packages/big-endian/src/index.js index df60a6a..cb81b09 100644 --- a/packages/big-endian/src/index.js +++ b/packages/big-endian/src/index.js @@ -2,18 +2,29 @@ function swap16(val) { return ((val & 0xff) << 8) | ((val >> 8) & 0xff); } - + +function swap32(val) { + return ( + ((val & 0xff) << 24) | + ((val & 0xff00) << 8) | + ((val >> 8) & 0xff00) | + ((val >> 24) & 0xff) + ); +} + /** * Decodes the provided pixelData and sets the `pixelData` property * of the imageFrame object to the decoded representation. - * - * Set pixelData will be `Uint16Array` if `pixelRepresentation` is 0, - * otherwise it will be an `Int16Array` - * + * + * 16-bit and 32-bit data are byte-swapped and become unsigned + * (`pixelRepresentation` 0) or signed (`pixelRepresentation` 1) integer + * arrays. 32-bit data with no `pixelRepresentation` is treated as float + * (e.g. FloatPixelData), mirroring the little-endian package. + * * @param {object} imageFrame - * @param {number} imageFrame.bitsAllocated - 16 or 8 + * @param {number} imageFrame.bitsAllocated - 32, 16, 8 or 1 * @param {number} imageFrame.pixelRepresentation - 0 or 1 - * @param {*} pixelData + * @param {*} pixelData */ function decode(imageFrame, pixelData) { if (imageFrame.bitsAllocated === 16) { @@ -38,8 +49,42 @@ function decode(imageFrame, pixelData) { for (let i = 0; i < imageFrame.pixelData.length; i++) { imageFrame.pixelData[i] = swap16(imageFrame.pixelData[i]); } - } else if (imageFrame.bitsAllocated === 8) { + } else if (imageFrame.bitsAllocated === 8 || imageFrame.bitsAllocated === 1) { + // 1-bit data must already be extracted per frame by the caller: + // multi-frame 1-bit pixel data is bit-packed across frame boundaries, + // so frame extraction cannot happen at this level imageFrame.pixelData = pixelData; + } else if (imageFrame.bitsAllocated === 32) { + let arrayBuffer = pixelData.buffer; + + let offset = pixelData.byteOffset; + const length = pixelData.length; + // pixelData is typically a view into the full DICOM P10 buffer, so its + // byteOffset is even (DICOM guarantees even lengths) but not necessarily + // 4-byte aligned; 32-bit typed-array views require 4-byte alignment, + // so copy the bytes to a fresh, aligned buffer when needed + if (offset % 4) { + arrayBuffer = arrayBuffer.slice(offset); + offset = 0; + } + + // The swap is a pure byte permutation, so it is done through a + // Uint32Array view regardless of how the result is interpreted below + const swapView = new Uint32Array(arrayBuffer, offset, length / 4); + for (let i = 0; i < swapView.length; i++) { + swapView[i] = swap32(swapView[i]); + } + + // 32-bit PixelData is integer data (signed per pixelRepresentation); + // it is only float when pixelRepresentation is absent (e.g. the + // FloatPixelData element), matching cornerstone3D's decodeLittleEndian + if (imageFrame.pixelRepresentation === 0) { + imageFrame.pixelData = swapView; + } else if (imageFrame.pixelRepresentation === 1) { + imageFrame.pixelData = new Int32Array(arrayBuffer, offset, length / 4); + } else { + imageFrame.pixelData = new Float32Array(arrayBuffer, offset, length / 4); + } } return imageFrame; diff --git a/packages/big-endian/test/decode.test.js b/packages/big-endian/test/decode.test.js index e039105..9aa3a2b 100644 --- a/packages/big-endian/test/decode.test.js +++ b/packages/big-endian/test/decode.test.js @@ -43,6 +43,69 @@ describe("big-endian decode", () => { expect(Array.from(imageFrame.pixelData)).toEqual([1, 2]) }) + it("passes 1-bit pixel data through unchanged", () => { + const pixelData = new Uint8Array([0b10101010]) + const imageFrame = { bitsAllocated: 1 } + + decode(imageFrame, pixelData) + + expect(imageFrame.pixelData).toBe(pixelData) + }) + + it("byte-swaps 32-bit unsigned pixel data into Uint32Array", () => { + const source = [1, 2, 0xdeadbeef] + // Build the big-endian byte stream for those values + const bigEndianBytes = new Uint8Array(source.length * 4) + const view = new DataView(bigEndianBytes.buffer) + source.forEach((value, i) => view.setUint32(i * 4, value, false)) + const imageFrame = { bitsAllocated: 32, pixelRepresentation: 0 } + + decode(imageFrame, bigEndianBytes) + + expect(imageFrame.pixelData).toBeInstanceOf(Uint32Array) + expect(Array.from(imageFrame.pixelData)).toEqual([1, 2, 0xdeadbeef]) + }) + + it("byte-swaps 32-bit signed pixel data into Int32Array", () => { + const source = [-1, 2, -100000] + const bigEndianBytes = new Uint8Array(source.length * 4) + const view = new DataView(bigEndianBytes.buffer) + source.forEach((value, i) => view.setInt32(i * 4, value, false)) + const imageFrame = { bitsAllocated: 32, pixelRepresentation: 1 } + + decode(imageFrame, bigEndianBytes) + + expect(imageFrame.pixelData).toBeInstanceOf(Int32Array) + expect(Array.from(imageFrame.pixelData)).toEqual([-1, 2, -100000]) + }) + + it("byte-swaps 32-bit pixel data into Float32Array when pixelRepresentation is absent", () => { + const source = new Float32Array([1.5, -2.25, 3.75]) + const bigEndianBytes = new Uint8Array(source.length * 4) + const view = new DataView(bigEndianBytes.buffer) + source.forEach((value, i) => view.setFloat32(i * 4, value, false)) + const imageFrame = { bitsAllocated: 32 } + + decode(imageFrame, bigEndianBytes) + + expect(imageFrame.pixelData).toBeInstanceOf(Float32Array) + expect(Array.from(imageFrame.pixelData)).toEqual([1.5, -2.25, 3.75]) + }) + + it("realigns 32-bit pixel data when byteOffset is not 4-byte aligned", () => { + const source = new Float32Array([1.5, -2.25]) + const padded = new Uint8Array(2 + source.length * 4) + const view = new DataView(padded.buffer) + source.forEach((value, i) => view.setFloat32(2 + i * 4, value, false)) + const pixelData = new Uint8Array(padded.buffer, 2, source.length * 4) + const imageFrame = { bitsAllocated: 32 } + + decode(imageFrame, pixelData) + + expect(imageFrame.pixelData).toBeInstanceOf(Float32Array) + expect(Array.from(imageFrame.pixelData)).toEqual([1.5, -2.25]) + }) + it("returns the same imageFrame object", () => { const imageFrame = { bitsAllocated: 8 } const result = decode(imageFrame, new Uint8Array([0])) diff --git a/packages/charls/CHANGELOG.md b/packages/charls/CHANGELOG.md index 9b15dde..d6388fe 100644 --- a/packages/charls/CHANGELOG.md +++ b/packages/charls/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.2.4](https://github.com/chafey/charls-js/compare/@cornerstonejs/codec-charls@1.2.3...@cornerstonejs/codec-charls@1.2.4) (2026-07-09) + + +### Bug Fixes + +* **build:** try to build again ([#31](https://github.com/chafey/charls-js/issues/31)) ([a9bc1a9](https://github.com/chafey/charls-js/commit/a9bc1a918da268e88c550a44c626859450cdb7ae)) +* **build:** try to build again ([#32](https://github.com/chafey/charls-js/issues/32)) ([b81e87e](https://github.com/chafey/charls-js/commit/b81e87e64c792648ae267c2a090f888896bb5823)) + + + + + ## [1.2.3](https://github.com/chafey/charls-js/compare/@cornerstonejs/codec-charls@1.2.2...@cornerstonejs/codec-charls@1.2.3) (2023-02-13) diff --git a/packages/charls/package-lock.json b/packages/charls/package-lock.json index 1e39935..31dc390 100644 --- a/packages/charls/package-lock.json +++ b/packages/charls/package-lock.json @@ -1,5 +1,5 @@ { "name": "@cornerstonejs/codec-charls", - "version": "1.2.3", + "version": "1.2.4", "lockfileVersion": 1 -} +} \ No newline at end of file diff --git a/packages/charls/package.json b/packages/charls/package.json index 3fe96ae..4050ab7 100644 --- a/packages/charls/package.json +++ b/packages/charls/package.json @@ -1,6 +1,6 @@ { "name": "@cornerstonejs/codec-charls", - "version": "1.2.3", + "version": "1.2.4", "description": "WASM Build of CharLS", "main": "dist/charlsjs.js", "publishConfig": { diff --git a/packages/dicom-codec/CHANGELOG.md b/packages/dicom-codec/CHANGELOG.md index 6b04a3e..3cfc2e1 100644 --- a/packages/dicom-codec/CHANGELOG.md +++ b/packages/dicom-codec/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.0.10](https://github.com/cornerstonejs/codecs/compare/@cornerstonejs/dicom-codec@1.0.9...@cornerstonejs/dicom-codec@1.0.10) (2026-07-09) + + +### Bug Fixes + +* npm publish ([#56](https://github.com/cornerstonejs/codecs/issues/56)) ([1b03a10](https://github.com/cornerstonejs/codecs/commit/1b03a10d9f4ffa06fcd533208aa0ae5d31ce4428)) +* npm publish ([#57](https://github.com/cornerstonejs/codecs/issues/57)) ([30e9d4d](https://github.com/cornerstonejs/codecs/commit/30e9d4db0f94f9711fe541850ccaceaedffd0fed)) +* npm publish ([#59](https://github.com/cornerstonejs/codecs/issues/59)) ([d066ef2](https://github.com/cornerstonejs/codecs/commit/d066ef28dac987ca9c41a449e9330694fef474b4)) +* trigger ci ([#54](https://github.com/cornerstonejs/codecs/issues/54)) ([5afcb5d](https://github.com/cornerstonejs/codecs/commit/5afcb5d895a365a49fc986be0ec1b60ee69cf809)) + + + + + ## [1.0.9](https://github.com/cornerstonejs/codecs/compare/@cornerstonejs/dicom-codec@1.0.8...@cornerstonejs/dicom-codec@1.0.9) (2025-10-22) **Note:** Version bump only for package @cornerstonejs/dicom-codec diff --git a/packages/dicom-codec/package.json b/packages/dicom-codec/package.json index 1372eca..23c20ac 100644 --- a/packages/dicom-codec/package.json +++ b/packages/dicom-codec/package.json @@ -1,6 +1,6 @@ { "name": "@cornerstonejs/dicom-codec", - "version": "1.0.9", + "version": "1.0.10", "description": "", "main": "src/index.js", "publishConfig": { @@ -26,12 +26,12 @@ "webpack-merge": "^5.7.3" }, "dependencies": { - "@cornerstonejs/codec-big-endian": ">=0.1.0", - "@cornerstonejs/codec-charls": ">=1.2.3", - "@cornerstonejs/codec-libjpeg-turbo-8bit": ">=1.2.2", - "@cornerstonejs/codec-little-endian": ">=0.0.6", - "@cornerstonejs/codec-openjpeg": "^1.3.0", - "@cornerstonejs/codec-openjph": "^2.4.7", + "@cornerstonejs/codec-big-endian": "^0.1.1", + "@cornerstonejs/codec-charls": "^1.2.4", + "@cornerstonejs/codec-libjpeg-turbo-8bit": "^1.2.3", + "@cornerstonejs/codec-little-endian": "^0.0.7", + "@cornerstonejs/codec-openjpeg": "^1.3.1", + "@cornerstonejs/codec-openjph": "^2.4.8", "browser-or-node": "^2.0.0", "jpeg-lossless-decoder-js": "^2.0.4" } diff --git a/packages/dicom-codec/src/codecs/codecFactory.js b/packages/dicom-codec/src/codecs/codecFactory.js index 25fc2d5..c9060ba 100644 --- a/packages/dicom-codec/src/codecs/codecFactory.js +++ b/packages/dicom-codec/src/codecs/codecFactory.js @@ -203,38 +203,42 @@ function getImageFrame(typedArray) { function encode(context, codecConfig, imageFrame, imageInfo, options = {}) { const { iterations = 1 } = options; const encoderInstance = new codecConfig.Encoder(); - const decodedTypedArray = encoderInstance.getDecodedBuffer(imageInfo); - decodedTypedArray.set(imageFrame); + try { + const decodedTypedArray = encoderInstance.getDecodedBuffer(imageInfo); + decodedTypedArray.set(imageFrame); - const { beforeEncode = () => {} } = options; + const { beforeEncode = () => {} } = options; - beforeEncode(encoderInstance, codecConfig); + beforeEncode(encoderInstance, codecConfig); - context.timer.init("To encode length: " + imageFrame.length); - for (let i = 0; i < iterations; i++) { - encoderInstance.encode(); - } + context.timer.init("To encode length: " + imageFrame.length); + for (let i = 0; i < iterations; i++) { + encoderInstance.encode(); + } - context.timer.end(); + context.timer.end(); - const encodedTypedArray = encoderInstance.getEncodedBuffer(); - context.logger.log("Encoded length:" + encodedTypedArray.length); - context.logger.log( - "Encoded is a Typed array of: " + encodedTypedArray.constructor.name - ); + const encodedTypedArray = encoderInstance.getEncodedBuffer(); + context.logger.log("Encoded length:" + encodedTypedArray.length); + context.logger.log( + "Encoded is a Typed array of: " + encodedTypedArray.constructor.name + ); - // cleanup allocated memory - encoderInstance.delete(); + const imageFrameOut = getImageFrame(encodedTypedArray); - const processInfo = { - duration: context.timer.getDuration(), - }; + const processInfo = { + duration: context.timer.getDuration(), + }; - return { - imageFrame: getImageFrame(encodedTypedArray), - imageInfo: getTargetImageInfo(imageInfo, imageInfo), - processInfo, - }; + return { + imageFrame: imageFrameOut, + imageInfo: getTargetImageInfo(imageInfo, imageInfo), + processInfo, + }; + } finally { + // cleanup allocated memory + encoderInstance.delete(); + } } /** @@ -255,40 +259,44 @@ function decode(context, codecConfig, imageFrame, imageInfo) { } const decoderInstance = new codecConfig.Decoder(); - const { length } = imageFrame; - // get pointer to the source/encoded bit stream buffer in WASM memory - // that can hold the encoded bitstream - const encodedTypedArray = decoderInstance.getEncodedBuffer(length); - - // copy the encoded bitstream into WASM memory buffer - encodedTypedArray.set(imageFrame); - context.timer.init("To decode length: " + length); - // decode it - decoderInstance.decode(); - context.timer.end(); + try { + const { length } = imageFrame; + // get pointer to the source/encoded bit stream buffer in WASM memory + // that can hold the encoded bitstream + const encodedTypedArray = decoderInstance.getEncodedBuffer(length); + + // copy the encoded bitstream into WASM memory buffer + encodedTypedArray.set(imageFrame); + context.timer.init("To decode length: " + length); + // decode it + decoderInstance.decode(); + context.timer.end(); + + const decodedTypedArray = decoderInstance.getDecodedBuffer(); + + context.logger.log("Decoded length:" + decodedTypedArray.length); + context.logger.log( + "Decoded is a Typed array of: " + decodedTypedArray.constructor.name + ); - const decodedTypedArray = decoderInstance.getDecodedBuffer(); + // get information about the decoded image + const decodedImageInfo = decoderInstance.getFrameInfo(); - context.logger.log("Decoded length:" + decodedTypedArray.length); - context.logger.log( - "Decoded is a Typed array of: " + decodedTypedArray.constructor.name - ); + const imageFrameOut = getImageFrame(decodedTypedArray); - // get information about the decoded image - const decodedImageInfo = decoderInstance.getFrameInfo(); + const processInfo = { + duration: context.timer.getDuration(), + }; - // cleanup allocated memory - decoderInstance.delete(); - - const processInfo = { - duration: context.timer.getDuration(), - }; - - return { - imageFrame: getImageFrame(decodedTypedArray), - imageInfo: getTargetImageInfo(imageInfo, decodedImageInfo), - processInfo, - }; + return { + imageFrame: imageFrameOut, + imageInfo: getTargetImageInfo(imageInfo, decodedImageInfo), + processInfo, + }; + } finally { + // cleanup allocated memory + decoderInstance.delete(); + } } exports.runProcess = runProcess; diff --git a/packages/dicom-codec/src/codecs/index.js b/packages/dicom-codec/src/codecs/index.js index 1ab34a1..dffb180 100644 --- a/packages/dicom-codec/src/codecs/index.js +++ b/packages/dicom-codec/src/codecs/index.js @@ -90,12 +90,18 @@ function getCodec(transferSyntaxUID) { * @returns {ExtendedImageInfo} Adapted imageInfo to all codecs. */ function adaptImageInfo(imageInfo) { - const { rows, columns, bitsAllocated, signed, samplesPerPixel, pixelRepresentation } = imageInfo; + const { rows, columns, bitsAllocated, signed, samplesPerPixel, pixelRepresentation, planarConfiguration } = imageInfo; return { pixelRepresentation, bitsAllocated, samplesPerPixel, + // Must survive adaptation: rleLossless dispatches between interleaved + // (decode8) and plane-sequential (decode8Planar) output on this flag. + // It was previously dropped here, which made decode8Planar unreachable + // through the public decode() API — PlanarConfiguration=1 datasets + // silently produced interleaved output. + planarConfiguration, rows, // Number with the image rows/height columns, // Number with the image columns/width width: columns, diff --git a/packages/dicom-codec/src/codecs/libjpegTurbo12bit.js b/packages/dicom-codec/src/codecs/libjpegTurbo12bit.js index ef26caa..e99b96d 100644 --- a/packages/dicom-codec/src/codecs/libjpegTurbo12bit.js +++ b/packages/dicom-codec/src/codecs/libjpegTurbo12bit.js @@ -1,21 +1,41 @@ +const codecModule = require("@cornerstonejs/codec-libjpeg-turbo-12bit"); +const codecWasmModule = require("@cornerstonejs/codec-libjpeg-turbo-12bit/wasmjs"); +const codecFactory = require("./codecFactory"); + /** * @type {CodecWrapper} */ const codecWrapper = { - // assign it and prevent initialization codec: undefined, Decoder: undefined, Encoder: undefined, - decoderName: "codec libjpeg turbo 12bit", - encoderName: "codec libjpeg turbo 12bit", + encoderName: "JPEGEncoder", + decoderName: "JPEGDecoder", }; +/** + * Decode imageFrame using libjpegTurbo 12bit decoder. + * + * @param {TypedArray} imageFrame to decode. + * @param {ExtendedImageInfo} imageInfo image info options. + * @returns Object containing decoded image frame and imageInfo (current) data. + */ async function decode(imageFrame, imageInfo) { - throw Error("Decoder not found for codec:" + codecWrapper.encoderName); + return codecFactory.runProcess( + codecWrapper, + codecModule, + codecWasmModule, + codecWrapper.decoderName, + (context) => { + return codecFactory.decode(context, codecWrapper, imageFrame, imageInfo); + } + ); } /** - * <> Encode imageFrame to libjpegTurbo 12bits format. + * <> The libjpeg-turbo 12bit build does not expose an + * encoder (see src/jslib.cpp — the JPEGEncoder bindings are disabled), so + * encoding is not supported for this codec. * * @param {TypedArray} imageFrame to encode. * @param {ExtendedImageInfo} imageInfo image info options. @@ -23,13 +43,11 @@ async function decode(imageFrame, imageInfo) { * @returns Object containing encoded image frame and imageInfo (current) data */ async function encode(imageFrame, imageInfo, options = {}) { - throw Error("Encoder not found for codec:" + codecWrapper.encoderName); + throw Error("Encoder not supported for codec: libjpeg-turbo 12bit"); } function getPixelData(imageFrame, imageInfo) { - throw Error( - "GetPixel not found or not applied for codec:" + codecWrapper.encoderName - ); + return codecFactory.getPixelData(imageFrame, imageInfo); } exports.decode = decode; diff --git a/packages/dicom-codec/src/codecs/littleEndian.js b/packages/dicom-codec/src/codecs/littleEndian.js index bed2e05..c132843 100644 --- a/packages/dicom-codec/src/codecs/littleEndian.js +++ b/packages/dicom-codec/src/codecs/littleEndian.js @@ -82,13 +82,25 @@ function getPixelData(imageFrame, imageInfo) { } else if (bitsAllocated === 8 || bitsAllocated === 1) { result = imageFrame; } else if (bitsAllocated === 32) { - // if pixel data is not aligned on even boundary, shift it - if (offset % 2) { + // imageFrame is typically a view into the full DICOM P10 buffer, so its + // byteOffset is even (DICOM guarantees even lengths) but not necessarily + // 4-byte aligned; 32-bit typed-array views require 4-byte alignment, + // so copy the bytes to a fresh, aligned buffer when needed + if (offset % 4) { arrayBuffer = arrayBuffer.slice(offset); offset = 0; } - result = new Float32Array(arrayBuffer, offset, length / 4); + // 32-bit PixelData is integer data (signed per pixelRepresentation); + // it is only float when pixelRepresentation is absent (e.g. the + // FloatPixelData element), matching cornerstone3D's decodeLittleEndian + if (pixelRepresentation === 0) { + result = new Uint32Array(arrayBuffer, offset, length / 4); + } else if (pixelRepresentation === 1) { + result = new Int32Array(arrayBuffer, offset, length / 4); + } else { + result = new Float32Array(arrayBuffer, offset, length / 4); + } } return result; diff --git a/packages/dicom-codec/test/color-and-depth.test.js b/packages/dicom-codec/test/color-and-depth.test.js index 00d9c0d..fa20f67 100644 --- a/packages/dicom-codec/test/color-and-depth.test.js +++ b/packages/dicom-codec/test/color-and-depth.test.js @@ -61,6 +61,26 @@ describe.skipIf(!ALL_BUILT)("dicom-codec color and bit-depth dispatch", () => { expect(frameBytes(result.imageFrame).equals(us1)).toBe(true) }) + it("decodes color RLE plane-sequential when planarConfiguration is 1", async () => { + const rleBytes = readFileSync( + resolve(packagesRoot, "dicom-codec/test/fixtures/rle/US1-color.rle") + ) + const result = await dicomCodec.decode( + new Uint8Array(rleBytes), + { rows: 480, columns: 640, bitsAllocated: 8, samplesPerPixel: 3, planarConfiguration: 1 }, + "1.2.840.10008.1.2.5" + ) + const out = frameBytes(result.imageFrame) + expect(out.length).toBe(us1.length) + // expected layout: RRR...GGG...BBB (de-interleaved planes of US1) + const frameSize = 640 * 480 + const planar = Buffer.alloc(us1.length) + for (let s = 0; s < 3; s++) { + for (let i = 0; i < frameSize; i++) planar[s * frameSize + i] = us1[i * 3 + s] + } + expect(out.equals(planar)).toBe(true) + }) + it("decodes an 8-bit JPEG-LS (.80) through the dispatcher losslessly", async () => { const jls = readFileSync(resolve(packagesRoot, "charls/test/fixtures/CT2-gray8.jls")) const ct2 = readFileSync(resolve(packagesRoot, "charls/test/fixtures/CT2.RAW")) diff --git a/packages/dicom-codec/test/dispatch.test.js b/packages/dicom-codec/test/dispatch.test.js index 1169a57..d7ded5a 100644 --- a/packages/dicom-codec/test/dispatch.test.js +++ b/packages/dicom-codec/test/dispatch.test.js @@ -37,6 +37,47 @@ const SUPPORTED_UIDS = [ "1.2.840.10008.1.2.5", ] +describe("codecFactory instance cleanup", () => { + it("frees the decoder instance even when decode() throws", () => { + const codecFactory = require("../src/codecs/codecFactory") + + let deleted = false + + class FakeDecoder { + getEncodedBuffer() { + return { set: () => {} } + } + + decode() { + throw new Error("boom") + } + + delete() { + deleted = true + } + } + + const codecConfig = { Decoder: FakeDecoder } + const context = { + timer: { + init: () => {}, + end: () => {}, + getDuration: () => 0, + }, + logger: { + log: () => {}, + }, + } + const imageFrame = new Uint8Array([1, 2, 3]) + const imageInfo = {} + + expect(() => + codecFactory.decode(context, codecConfig, imageFrame, imageInfo) + ).toThrow("boom") + expect(deleted).toBe(true) + }) +}) + // In CI a missing sibling dist means the build/artifact pipeline broke; fail // loudly instead of letting describe.skipIf() silently skip the whole suite. it.runIf(process.env.CI)("required sibling builds are present in CI", () => { diff --git a/packages/dicom-codec/test/integration.test.js b/packages/dicom-codec/test/integration.test.js index 8f21b36..c1f9587 100644 --- a/packages/dicom-codec/test/integration.test.js +++ b/packages/dicom-codec/test/integration.test.js @@ -9,6 +9,9 @@ const packagesRoot = resolve(__dirname, "../..") const LIBJPEG_8BIT_BUILT = existsSync( resolve(packagesRoot, "libjpeg-turbo-8bit/dist/libjpegturbojs.js") ) +const LIBJPEG_12BIT_BUILT = existsSync( + resolve(packagesRoot, "libjpeg-turbo-12bit/dist/libjpegturbo12js.js") +) const CHARLS_BUILT = existsSync( resolve(packagesRoot, "charls/dist/charlsjs.js") ) @@ -20,7 +23,11 @@ const OPENJPH_BUILT = existsSync( ) const ALL_BUILT = - LIBJPEG_8BIT_BUILT && CHARLS_BUILT && OPENJPEG_BUILT && OPENJPH_BUILT + LIBJPEG_8BIT_BUILT && + LIBJPEG_12BIT_BUILT && + CHARLS_BUILT && + OPENJPEG_BUILT && + OPENJPH_BUILT // Byte view over a decoded imageFrame regardless of its typed-array flavor // (Uint8Array, Uint16Array, Int16Array, ...). @@ -81,6 +88,41 @@ describe.skipIf(!ALL_BUILT)("dicom-codec integration", () => { }) }) + describe("JPEG Baseline 12-bit (1.2.840.10008.1.2.4.51)", () => { + const jpeg12BitBytes = readFileSync( + resolve( + packagesRoot, + "libjpeg-turbo-12bit/test/fixtures/jpeg/CT-512x512-12bit.jpg" + ) + ) + const jpeg12BitRaw = readFileSync( + resolve(packagesRoot, "libjpeg-turbo-12bit/test/fixtures/raw/CT-512x512-12bit.raw") + ) + + it("decodes through the dispatcher to the exact reference pixels", async () => { + const imageInfo = { + rows: 512, + columns: 512, + bitsAllocated: 16, + samplesPerPixel: 1, + pixelRepresentation: 0, + signed: false, + } + + const result = await dicomCodec.decode( + jpeg12BitBytes, + imageInfo, + "1.2.840.10008.1.2.4.51" + ) + + expect(result.imageFrame.byteLength).toBe(512 * 512 * 2) + expect(frameBytes(result.imageFrame).equals(jpeg12BitRaw)).toBe(true) + expect(result.imageInfo.width).toBe(512) + expect(result.imageInfo.height).toBe(512) + expect(typeof result.processInfo.duration).toBe("number") + }) + }) + describe("JPEG-LS Lossless (1.2.840.10008.1.2.4.80)", () => { const jlsBytes = readFileSync( resolve(packagesRoot, "charls/test/fixtures/CT1.JLS") diff --git a/packages/dicom-codec/test/transcode-and-pixeldata.test.js b/packages/dicom-codec/test/transcode-and-pixeldata.test.js index e2fef74..b0bd7b5 100644 --- a/packages/dicom-codec/test/transcode-and-pixeldata.test.js +++ b/packages/dicom-codec/test/transcode-and-pixeldata.test.js @@ -8,6 +8,7 @@ const packagesRoot = resolve(__dirname, "../..") const REQUIRED = [ "charls/dist/charlsjs.js", + "openjpeg/dist/openjpegjs.js", ] const ALL_BUILT = REQUIRED.every((p) => existsSync(resolve(packagesRoot, p))) @@ -19,9 +20,10 @@ it.runIf(process.env.CI)("sibling codec dists are present in CI (transcode suite expect(ALL_BUILT, "codec dists missing — artifacts not replayed").toBe(true) }) -describe.skipIf(!ALL_BUILT)("dicom-codec encode", () => { +describe.skipIf(!ALL_BUILT)("dicom-codec transcode and encode", () => { let dicomCodec const ct1Raw = readFileSync(resolve(packagesRoot, "openjpeg/test/fixtures/raw/CT1.RAW")) + const ct1Jls = readFileSync(resolve(packagesRoot, "charls/test/fixtures/CT1.JLS")) const ctImageInfo = { rows: 512, columns: 512, @@ -36,11 +38,32 @@ describe.skipIf(!ALL_BUILT)("dicom-codec encode", () => { dicomCodec = mod.default ?? mod }) + it("encode() to J2K Lossless (.90) round-trips byte-exact", async () => { + const encoded = await dicomCodec.encode(new Uint8Array(ct1Raw), ctImageInfo, "1.2.840.10008.1.2.4.90") + expect(encoded.imageFrame.byteLength).toBeGreaterThan(0) + + const decoded = await dicomCodec.decode(encoded.imageFrame, ctImageInfo, "1.2.840.10008.1.2.4.90") + expect(frameBytes(decoded.imageFrame).equals(ct1Raw)).toBe(true) + }) + it("encode() to JPEG-LS Lossless (.80) round-trips byte-exact", async () => { const encoded = await dicomCodec.encode(new Uint8Array(ct1Raw), ctImageInfo, "1.2.840.10008.1.2.4.80") const decoded = await dicomCodec.decode(encoded.imageFrame, ctImageInfo, "1.2.840.10008.1.2.4.80") expect(frameBytes(decoded.imageFrame).equals(ct1Raw)).toBe(true) }) + + it("transcode() JPEG-LS -> J2K preserves the pixels exactly (both lossless)", async () => { + const transcoded = await dicomCodec.transcode( + ct1Jls, + ctImageInfo, + "1.2.840.10008.1.2.4.80", + "1.2.840.10008.1.2.4.90" + ) + expect(transcoded.imageFrame.byteLength).toBeGreaterThan(0) + + const decoded = await dicomCodec.decode(transcoded.imageFrame, ctImageInfo, "1.2.840.10008.1.2.4.90") + expect(frameBytes(decoded.imageFrame).equals(ct1Raw)).toBe(true) + }) }) describe.skipIf(!ALL_BUILT)("dicom-codec getPixelData typed-array contract", () => { diff --git a/packages/libjpeg-turbo-12bit/CHANGELOG.md b/packages/libjpeg-turbo-12bit/CHANGELOG.md index 73dae21..2fe1069 100644 --- a/packages/libjpeg-turbo-12bit/CHANGELOG.md +++ b/packages/libjpeg-turbo-12bit/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.4.2](https://github.com/cornerstonejs/codecs/compare/@cornerstonejs/codec-libjpeg-turbo-12bit@0.4.1...@cornerstonejs/codec-libjpeg-turbo-12bit@0.4.2) (2026-07-09) + +**Note:** Version bump only for package @cornerstonejs/codec-libjpeg-turbo-12bit + + + + + ## [0.4.1](https://github.com/cornerstonejs/codecs/compare/@cornerstonejs/codec-libjpeg-turbo-12bit@0.4.0...@cornerstonejs/codec-libjpeg-turbo-12bit@0.4.1) (2023-02-08) diff --git a/packages/libjpeg-turbo-12bit/bench/decode.bench.js b/packages/libjpeg-turbo-12bit/bench/decode.bench.js new file mode 100644 index 0000000..fe7cc5e --- /dev/null +++ b/packages/libjpeg-turbo-12bit/bench/decode.bench.js @@ -0,0 +1,55 @@ +// Cold vs warm decode benches for the 12-bit codec, mirroring the 8-bit +// package's bench shape (see libjpeg-turbo-8bit/bench/decode.bench.js for +// the cold/warm methodology notes). Without this file the 12-bit package +// was invisible to CodSpeed — a toolchain bump's full bench sweep measured +// nothing for it. +import { bench, describe } from "vitest" +import { existsSync, readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { dirname, resolve } from "node:path" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const distDir = resolve(__dirname, "../dist") +const fixturesDir = resolve(__dirname, "../test/fixtures") + +const distPath = resolve(distDir, "libjpegturbo12wasm.js") +const skip = !existsSync(distPath) + +const encoded = !skip + ? readFileSync(resolve(fixturesDir, "jpeg/CT-512x512-12bit.jpg")) + : null + +let codec +let warmDecoder + +async function loadCodec() { + const mod = await import(distPath) + const factory = mod.default ?? mod + // Silence wasm stdout/stderr so vitest's console interception never runs + // inside a measured bench body (see openjphjs bench for details). + return await factory({ print: () => {}, printErr: () => {} }) +} + +function decodeOnce(decoder) { + decoder.getEncodedBuffer(encoded.length).set(encoded) + decoder.decode() + return decoder.getDecodedBuffer() +} + +if (!skip) { + codec = await loadCodec() + warmDecoder = new codec.JPEGDecoder() + for (let i = 0; i < 5; i++) decodeOnce(warmDecoder) +} + +describe.skipIf(skip)("libjpeg-turbo-12bit (wasm)", () => { + bench("decode CT-512x512-12bit.jpg (512x512x12bit) — cold", () => { + const decoder = new codec.JPEGDecoder() + decodeOnce(decoder) + decoder.delete() + }) + + bench("decode CT-512x512-12bit.jpg (512x512x12bit) — warm", () => { + decodeOnce(warmDecoder) + }) +}) diff --git a/packages/libjpeg-turbo-12bit/package.json b/packages/libjpeg-turbo-12bit/package.json index 5096848..aac71d8 100644 --- a/packages/libjpeg-turbo-12bit/package.json +++ b/packages/libjpeg-turbo-12bit/package.json @@ -1,8 +1,12 @@ { "name": "@cornerstonejs/codec-libjpeg-turbo-12bit", - "version": "0.4.1", + "version": "0.4.2", "description": "JS/WASM Build of [libjpeg-turbo](https://github.com/libjpeg-turbo) WITH12BIT=ON", - "main": "dist/libjpeg-turbojs.js", + "main": "dist/libjpegturbo12js.js", + "exports": { + ".": "./dist/libjpegturbo12js.js", + "./wasmjs": "./dist/libjpegturbo12wasm.js" + }, "publishConfig": { "access": "public" }, @@ -20,9 +24,11 @@ "scripts": { "build": "bash build.sh", "build:ci": "yarn run build", - "test": "echo 'libjpeg-turbo-12bit tests skipped (.51 transfer syntax disabled)'", + "test": "vitest run", "test:ci": "yarn run test", - "prepublishOnly": "yarn run build" + "test:watch": "vitest", + "prepublishOnly": "yarn run build", + "bench": "vitest bench --run" }, "author": "", "license": "ISC" diff --git a/packages/libjpeg-turbo-12bit/src/JPEGDecoder.hpp b/packages/libjpeg-turbo-12bit/src/JPEGDecoder.hpp index 7a15c92..4d67eaf 100644 --- a/packages/libjpeg-turbo-12bit/src/JPEGDecoder.hpp +++ b/packages/libjpeg-turbo-12bit/src/JPEGDecoder.hpp @@ -3,7 +3,10 @@ #pragma once +#include #include +#include +#include #include // #include "config.h" #include "jpeglib.h" @@ -14,6 +17,7 @@ using namespace std; #include thread_local const emscripten::val Uint8ClampedArray = emscripten::val::global("Uint8ClampedArray"); +thread_local const emscripten::val Uint16Array = emscripten::val::global("Uint16Array"); #endif @@ -54,13 +58,16 @@ class JPEGDecoder { /// holds the decoded pixel data /// emscripten::val getDecodedBuffer() { - // Create a JavaScript-friendly result from the memory view - // instead of relying on the consumer to detach it from WASM memory - // See https://web.dev/webassembly-memory-debugging/ - emscripten::val js_result = Uint8ClampedArray.new_(emscripten::typed_memory_view( + // decoded_ holds one 12-bit grayscale sample per pixel in an int16_t + // (values 0..4095). Copy it into a JS-owned Uint16Array so the result is + // detached from WASM memory (see https://web.dev/webassembly-memory-debugging/). + // NOTE: must be a 16-bit typed array — wrapping in Uint8ClampedArray would + // run every sample through ToUint8Clamp and flatten anything above 255, + // destroying the 12-bit output. + emscripten::val js_result = Uint16Array.new_(emscripten::typed_memory_view( decoded_.size(), decoded_.data() )); - + return js_result; } #else @@ -119,28 +126,65 @@ class JPEGDecoder { jpeg_mem_src(&cinfo, encoded_.data(), encoded_.size()); // Read file header, set default decompression parameters jpeg_read_header(&cinfo, TRUE); - // Force RGBA decoding, even for grayscale images - cinfo.out_color_space = JCS_EXT_RGBA; + // Fail closed on multi-component images. This codec only supports + // single-component (grayscale) 12-bit JPEGs; forcing JCS_GRAYSCALE on a + // color image would make libjpeg silently discard the chroma channels + // and report componentCount=1, corrupting color data without any error. + if (cinfo.num_components != 1) { + jpeg_destroy_decompress(&cinfo); + throw std::runtime_error( + "Unsupported 12-bit JPEG: expected 1 component (grayscale), got " + + std::to_string(cinfo.num_components)); + } + // Decode as single-component grayscale. This is a 12-bit-per-sample + // codec: each output value is a 16-bit-wide JSAMPLE (holding 0..4095), + // not an 8-bit RGBA quad. Previously this forced a 4-samples-per-pixel + // RGBA colorspace while the output buffer below was sized for 1 + // sample/pixel, causing libjpeg to write ~2x past the end of the + // allocated buffer (heap overflow). + cinfo.out_color_space = JCS_GRAYSCALE; jpeg_start_decompress(&cinfo); frameInfo_.width = cinfo.output_width; frameInfo_.height = cinfo.output_height; - frameInfo_.bitsPerSample = 8; - frameInfo_.componentCount = 1; //inColorspace == 2 ? 1 : 3; - - // Prepare output buffer - // int pixelFormat = (frameInfo_.componentCount == 1) ? TJPF_GRAY : TJPF_RGB; + frameInfo_.bitsPerSample = 12; + frameInfo_.componentCount = 1; + + // Prepare output buffer. One JSAMPLE (short, holding 0..4095) per pixel + // since output is single-component grayscale. + const int pixelFormat = 1; + + // Compute the output size (in samples) using a checked 64-bit multiply + // capped at 512 MiB so a malformed/adversarial header cannot overflow + // the size computation or force an unbounded allocation. + constexpr uint64_t kMaxOutputSamples = 512ull * 1024ull * 1024ull; // 512 MiB worth of samples + const uint64_t width64 = static_cast(cinfo.output_width); + const uint64_t height64 = static_cast(cinfo.output_height); + const uint64_t pixelFormat64 = static_cast(pixelFormat); + + if (width64 == 0 || height64 == 0) { + jpeg_destroy_decompress(&cinfo); + throw std::runtime_error("Invalid JPEG dimensions (zero width or height)"); + } - // const size_t destinationSize = frameInfo_.width * frameInfo_.height * tjPixelSize[pixelFormat]; - int pixelFormat = 1; - size_t output_size = cinfo.output_width * cinfo.output_height * pixelFormat; + uint64_t output_size64 = width64 * height64; + if (output_size64 / width64 != height64) { + // width * height overflowed + jpeg_destroy_decompress(&cinfo); + throw std::runtime_error("Overflow computing decoded buffer size"); + } + output_size64 *= pixelFormat64; + if (output_size64 == 0 || output_size64 > kMaxOutputSamples) { + jpeg_destroy_decompress(&cinfo); + throw std::runtime_error("Decoded buffer size exceeds allowed maximum or is invalid"); + } - // std::vector output_buffer(output_size); + const size_t output_size = static_cast(output_size64); decoded_.resize(output_size); - auto stride = cinfo.output_width * pixelFormat; + const size_t stride = static_cast(cinfo.output_width) * static_cast(pixelFormat); // Process data while (cinfo.output_scanline < cinfo.output_height) { @@ -149,10 +193,6 @@ class JPEGDecoder { } jpeg_finish_decompress(&cinfo); - // Step 7: release JPEG compression object - - // auto data = Uint8ClampedArray.new_(typed_memory_view(output_size, &output_buffer[0])); - // This is an important step since it will release a good deal of memory. jpeg_destroy_decompress(&cinfo); } diff --git a/packages/libjpeg-turbo-12bit/test/decode.test.js b/packages/libjpeg-turbo-12bit/test/decode.test.js new file mode 100644 index 0000000..74a51f3 --- /dev/null +++ b/packages/libjpeg-turbo-12bit/test/decode.test.js @@ -0,0 +1,150 @@ +import { beforeAll, describe, expect, it } from "vitest" +import { existsSync, readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" +import { dirname, resolve } from "node:path" + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const fixturesDir = resolve(__dirname, "fixtures") + +// Genuine 12-bit baseline JPEG fixture (SOF marker 0xC1, precision=12, +// 512x512, 1 component — verified by inspecting the JPEG SOF segment). +const ct12bit = readFileSync(resolve(fixturesDir, "jpeg/CT-512x512-12bit.jpg")) +// Decoded reference for the fixture above: little-endian Uint16 samples, +// verified bit-identical against DCMTK's dcmdjpeg (independent reference +// decoder) and identical across the asm.js and wasm build variants. +const ct12bitRaw = readFileSync(resolve(fixturesDir, "raw/CT-512x512-12bit.raw")) + +async function loadModule(modulePath) { + const mod = await import(modulePath) + const factory = mod.default ?? mod + return await factory() +} + +const buildVariants = [ + { name: "asm.js (libjpegturbo12js)", path: "../dist/libjpegturbo12js.js" }, + { name: "wasm (libjpegturbo12wasm)", path: "../dist/libjpegturbo12wasm.js" }, +] + +describe.each(buildVariants)("libjpeg-turbo-12bit decode — $name", ({ path }) => { + const isBuilt = existsSync(resolve(__dirname, path)) + let codec + + beforeAll(async () => { + if (isBuilt) { + codec = await loadModule(path) + } + }) + + // In CI a missing dist means the build/artifact pipeline broke; fail loudly + // instead of letting every skipIf() below silently skip the suite. + it.runIf(process.env.CI)("dist is present in CI", () => { + expect(isBuilt, `${path} missing — build artifact was not replayed`).toBe(true) + }) + + it.skipIf(!isBuilt)( + "decodes the CT-512x512 12-bit fixture and reports correct dimensions/format", + () => { + const decoder = new codec.JPEGDecoder() + const encodedBuffer = decoder.getEncodedBuffer(ct12bit.length) + encodedBuffer.set(ct12bit) + + decoder.decode() + + const frameInfo = decoder.getFrameInfo() + expect(frameInfo.width).toBe(512) + expect(frameInfo.height).toBe(512) + expect(frameInfo.bitsPerSample).toBe(12) + expect(frameInfo.componentCount).toBe(1) + + const decoded = decoder.getDecodedBuffer() + // One 16-bit-wide sample per pixel (grayscale, 1 component/pixel). + expect(decoded.length).toBe(512 * 512) + + decoder.delete() + } + ) + + it.skipIf(!isBuilt)("decodes the CT-512x512 12-bit fixture and matches the RAW reference", () => { + const decoder = new codec.JPEGDecoder() + const encodedBuffer = decoder.getEncodedBuffer(ct12bit.length) + encodedBuffer.set(ct12bit) + + decoder.decode() + + const decoded = decoder.getDecodedBuffer() + // getDecodedBuffer() returns a Uint16Array (one entry per sample); + // compare its underlying bytes against the little-endian RAW reference. + const decodedBytes = Buffer.from( + decoded.buffer, + decoded.byteOffset, + decoded.byteLength + ) + expect(decodedBytes.length).toBe(ct12bitRaw.length) + expect(decodedBytes.equals(ct12bitRaw)).toBe(true) + + decoder.delete() + }) + + it.skipIf(!isBuilt)("rejects multi-component (color) 12-bit JPEGs instead of silently dropping chroma", () => { + // Splice the grayscale fixture into a syntactically valid 3-component + // JPEG: rewrite SOF1 (FFC1) from 1 to 3 components and SOS (FFDA) from + // 1 to 3 selectors. The decoder must fail closed on the header — a + // forced JCS_GRAYSCALE decode would silently discard the chroma + // channels — so the entropy data never being read is fine. + const findMarker = (buf, marker) => { + for (let i = 2; i < buf.length - 1; i++) { + if (buf[i] === 0xff && buf[i + 1] === marker) return i + } + throw new Error("marker not found") + } + const sofAt = findMarker(ct12bit, 0xc1) + const sosAt = findMarker(ct12bit, 0xda) + + const before = ct12bit.subarray(0, sofAt) + const sofBody = ct12bit.subarray(sofAt + 4, sofAt + 4 + 5) // P(1), Y(2), X(2) + const between = ct12bit.subarray(sofAt + 2 + 11, sosAt) // after 1-comp SOF + const after = ct12bit.subarray(sosAt + 2 + 8) // after 1-comp SOS: entropy data + + const sof3 = Buffer.concat([ + Buffer.from([0xff, 0xc1, 0x00, 17]), + sofBody, + Buffer.from([3]), // Nf = 3 + Buffer.from([1, 0x11, 0, 2, 0x11, 0, 3, 0x11, 0]), + ]) + const sos3 = Buffer.concat([ + Buffer.from([0xff, 0xda, 0x00, 12, 3]), // Ns = 3 + Buffer.from([1, 0x00, 2, 0x00, 3, 0x00]), + ct12bit.subarray(sosAt + 7, sosAt + 10), // Ss, Se, Ah/Al + ]) + const colorJpeg = Buffer.concat([before, sof3, between, sos3, after]) + + const decoder = new codec.JPEGDecoder() + decoder.getEncodedBuffer(colorJpeg.length).set(colorJpeg) + + expect(() => decoder.decode()).toThrow() + + decoder.delete() + }) + + it.skipIf(!isBuilt)("handles truncated input without crashing", () => { + // libjpeg treats a premature end-of-file as a recoverable warning (it + // fills the missing scanlines rather than aborting), so decode() may + // return normally instead of throwing. The meaningful guarantee here is + // that truncated input is handled gracefully — it either throws or + // returns, but never corrupts the process. + const truncated = ct12bit.subarray(0, Math.floor(ct12bit.length / 2)) + const decoder = new codec.JPEGDecoder() + const encodedBuffer = decoder.getEncodedBuffer(truncated.length) + encodedBuffer.set(truncated) + + expect(() => { + try { + decoder.decode() + } catch (e) { + // throwing is an acceptable outcome for malformed input + } + }).not.toThrow() + + decoder.delete() + }) +}) diff --git a/packages/libjpeg-turbo-12bit/vitest.config.mjs b/packages/libjpeg-turbo-12bit/vitest.config.mjs new file mode 100644 index 0000000..1622965 --- /dev/null +++ b/packages/libjpeg-turbo-12bit/vitest.config.mjs @@ -0,0 +1,26 @@ +import { defineConfig } from "vitest/config" +import codspeedPlugin from "@codspeed/vitest-plugin" + +export default defineConfig({ + plugins: [codspeedPlugin()], + test: { + // Under the CodSpeed simulation instrument the entire process runs ~60x + // slower under valgrind while vitest's hard-coded 60s worker-RPC timer + // counts real seconds, so large bench suites structurally hit "Timeout + // calling onTaskUpdate" AFTER their benches complete and upload. Ignore + // that exit-code noise in simulation only; walltime and test runs stay + // strict. + // (CODSPEED_ENV is set whenever the CodSpeed runner is active; the + // mode string is "instrumentation" on older runners and "simulation" + // on newer ones, so match anything except walltime.) + dangerouslyIgnoreUnhandledErrors: + process.env.CODSPEED_ENV !== undefined && + process.env.CODSPEED_RUNNER_MODE !== "walltime", + name: "libjpeg-turbo-12bit", + include: ["test/**/*.test.js"], + benchmark: { + include: ["bench/**/*.bench.{js,mjs}"], + }, + testTimeout: 30000, + }, +}) diff --git a/packages/libjpeg-turbo-8bit/CHANGELOG.md b/packages/libjpeg-turbo-8bit/CHANGELOG.md index c0a0b7c..3ed4b1b 100644 --- a/packages/libjpeg-turbo-8bit/CHANGELOG.md +++ b/packages/libjpeg-turbo-8bit/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.2.3](https://github.com/cornerstonejs/codecs/compare/@cornerstonejs/codec-libjpeg-turbo-8bit@1.2.2...@cornerstonejs/codec-libjpeg-turbo-8bit@1.2.3) (2026-07-09) + +**Note:** Version bump only for package @cornerstonejs/codec-libjpeg-turbo-8bit + + + + + ## [1.2.2](https://github.com/cornerstonejs/codecs/compare/@cornerstonejs/codec-libjpeg-turbo-8bit@1.2.1...@cornerstonejs/codec-libjpeg-turbo-8bit@1.2.2) (2023-02-08) diff --git a/packages/libjpeg-turbo-8bit/package.json b/packages/libjpeg-turbo-8bit/package.json index 780b2c3..e4c2cc4 100644 --- a/packages/libjpeg-turbo-8bit/package.json +++ b/packages/libjpeg-turbo-8bit/package.json @@ -1,6 +1,6 @@ { "name": "@cornerstonejs/codec-libjpeg-turbo-8bit", - "version": "1.2.2", + "version": "1.2.3", "description": "JS/WASM Build of [libjpeg-turbo](https://github.com/libjpeg-turbo)", "main": "dist/libjpegturbojs.js", "publishConfig": { diff --git a/packages/libjpeg-turbo-8bit/src/JPEGDecoder.hpp b/packages/libjpeg-turbo-8bit/src/JPEGDecoder.hpp index b3a92af..47ba282 100644 --- a/packages/libjpeg-turbo-8bit/src/JPEGDecoder.hpp +++ b/packages/libjpeg-turbo-8bit/src/JPEGDecoder.hpp @@ -3,7 +3,9 @@ #pragma once +#include #include +#include #include #include @@ -16,6 +18,22 @@ thread_local const emscripten::val Uint8ClampedArray = emscripten::val::global(" #include "FrameInfo.hpp" +/// +/// Computes width * height * components * bytesPerPixel while guarding +/// against 32-bit size_t overflow on the wasm32 target and rejecting +/// unreasonably large decoded buffer sizes. +/// +static inline size_t checkedDecodedSize(uint64_t width, uint64_t height, uint64_t components, uint64_t bytesPerPixel) { + const uint64_t kMaxBytes = 512ull * 1024ull * 1024ull; // 512 MiB + uint64_t total = width * height; + total *= components; + total *= bytesPerPixel; + if (total == 0 || total > kMaxBytes) { + throw std::runtime_error("decoded frame size out of range"); + } + return static_cast(total); +} + /// /// JavaScript API for decoding JPEG bistreams with libjpeg-turbo /// @@ -110,7 +128,7 @@ class JPEGDecoder { int pixelFormat = (frameInfo_.componentCount == 1) ? TJPF_GRAY : TJPF_RGB; - const size_t destinationSize = frameInfo_.width * frameInfo_.height * tjPixelSize[pixelFormat]; + const size_t destinationSize = checkedDecodedSize(frameInfo_.width, frameInfo_.height, 1, tjPixelSize[pixelFormat]); decoded_.resize(destinationSize); if (tjDecompress2(tjInstance, encoded_.data(), encoded_.size(), decoded_.data(), diff --git a/packages/little-endian/CHANGELOG.md b/packages/little-endian/CHANGELOG.md index 7e9ddf3..3c36328 100644 --- a/packages/little-endian/CHANGELOG.md +++ b/packages/little-endian/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [0.0.7](https://github.com/cornerstonejs/codecs/compare/@cornerstonejs/codec-little-endian@0.0.6...@cornerstonejs/codec-little-endian@0.0.7) (2026-07-09) + +**Note:** Version bump only for package @cornerstonejs/codec-little-endian + + + + + ## [0.0.6](https://github.com/cornerstonejs/codecs/compare/@cornerstonejs/codec-little-endian@0.0.4...@cornerstonejs/codec-little-endian@0.0.6) (2022-02-09) diff --git a/packages/little-endian/package.json b/packages/little-endian/package.json index 0a8e7fc..bbc70c2 100644 --- a/packages/little-endian/package.json +++ b/packages/little-endian/package.json @@ -1,6 +1,6 @@ { "name": "@cornerstonejs/codec-little-endian", - "version": "0.0.6", + "version": "0.0.7", "description": "", "main": "dist/index.js", "publishConfig": { diff --git a/packages/little-endian/src/index.js b/packages/little-endian/src/index.js index bf99e7c..f55be90 100644 --- a/packages/little-endian/src/index.js +++ b/packages/little-endian/src/index.js @@ -1,12 +1,16 @@ /** * Decodes the provided pixelData and sets the `pixelData` property * of the imageFrame object to the decoded representation. - * - * + * + * 16-bit and 32-bit data become unsigned (`pixelRepresentation` 0) or + * signed (`pixelRepresentation` 1) integer arrays. 32-bit data with no + * `pixelRepresentation` is treated as float (e.g. FloatPixelData), + * matching cornerstone3D's decodeLittleEndian. + * * @param {object} imageFrame - * @param {number} imageFrame.bitsAllocated - 32 or 16 or 8 + * @param {number} imageFrame.bitsAllocated - 32, 16, 8 or 1 * @param {number} imageFrame.pixelRepresentation - 0 or 1 - * @param {*} pixelData + * @param {*} pixelData */ function decode(imageFrame, pixelData) { let arrayBuffer = pixelData.buffer; @@ -28,15 +32,30 @@ function decode(imageFrame, pixelData) { imageFrame.pixelData = new Int16Array(arrayBuffer, offset, length / 2); } } else if (imageFrame.bitsAllocated === 8 || imageFrame.bitsAllocated === 1) { + // 1-bit data must already be extracted per frame by the caller: + // multi-frame 1-bit pixel data is bit-packed across frame boundaries, + // so frame extraction cannot happen at this level imageFrame.pixelData = pixelData; } else if (imageFrame.bitsAllocated === 32) { - // if pixel data is not aligned on even boundary, shift it - if (offset % 2) { + // pixelData is typically a view into the full DICOM P10 buffer, so its + // byteOffset is even (DICOM guarantees even lengths) but not necessarily + // 4-byte aligned; 32-bit typed-array views require 4-byte alignment, + // so copy the bytes to a fresh, aligned buffer when needed + if (offset % 4) { arrayBuffer = arrayBuffer.slice(offset); offset = 0; } - imageFrame.pixelData = new Float32Array(arrayBuffer, offset, length / 4); + // 32-bit PixelData is integer data (signed per pixelRepresentation); + // it is only float when pixelRepresentation is absent (e.g. the + // FloatPixelData element), matching cornerstone3D's decodeLittleEndian + if (imageFrame.pixelRepresentation === 0) { + imageFrame.pixelData = new Uint32Array(arrayBuffer, offset, length / 4); + } else if (imageFrame.pixelRepresentation === 1) { + imageFrame.pixelData = new Int32Array(arrayBuffer, offset, length / 4); + } else { + imageFrame.pixelData = new Float32Array(arrayBuffer, offset, length / 4); + } } return imageFrame; diff --git a/packages/little-endian/test/decode.test.js b/packages/little-endian/test/decode.test.js index 6e6b2d5..e83a58b 100644 --- a/packages/little-endian/test/decode.test.js +++ b/packages/little-endian/test/decode.test.js @@ -41,7 +41,29 @@ describe("little-endian decode", () => { expect(imageFrame.pixelData).toBe(pixelData) }) - it("decodes 32-bit pixel data into Float32Array", () => { + it("decodes 32-bit unsigned pixel data into Uint32Array", () => { + const source = new Uint32Array([1, 2, 0xffffffff]) + const pixelData = new Uint8Array(source.buffer) + const imageFrame = { bitsAllocated: 32, pixelRepresentation: 0 } + + decode(imageFrame, pixelData) + + expect(imageFrame.pixelData).toBeInstanceOf(Uint32Array) + expect(Array.from(imageFrame.pixelData)).toEqual([1, 2, 0xffffffff]) + }) + + it("decodes 32-bit signed pixel data into Int32Array", () => { + const source = new Int32Array([-1, 2, -100000]) + const pixelData = new Uint8Array(source.buffer) + const imageFrame = { bitsAllocated: 32, pixelRepresentation: 1 } + + decode(imageFrame, pixelData) + + expect(imageFrame.pixelData).toBeInstanceOf(Int32Array) + expect(Array.from(imageFrame.pixelData)).toEqual([-1, 2, -100000]) + }) + + it("decodes 32-bit pixel data into Float32Array when pixelRepresentation is absent", () => { const source = new Float32Array([1.5, -2.25, 3.75]) const pixelData = new Uint8Array(source.buffer) const imageFrame = { bitsAllocated: 32 } @@ -64,6 +86,19 @@ describe("little-endian decode", () => { expect(Array.from(imageFrame.pixelData)).toEqual([1, 2]) }) + it("realigns 32-bit pixel data when byteOffset is 2 (even but not 4-aligned)", () => { + const source = new Float32Array([1.5, -2.25]) + const padded = new Uint8Array(2 + source.length * 4) + padded.set(new Uint8Array(source.buffer), 2) + const pixelData = new Uint8Array(padded.buffer, 2, source.length * 4) + const imageFrame = { bitsAllocated: 32 } + + decode(imageFrame, pixelData) + + expect(imageFrame.pixelData).toBeInstanceOf(Float32Array) + expect(Array.from(imageFrame.pixelData)).toEqual([1.5, -2.25]) + }) + it("returns the same imageFrame object", () => { const imageFrame = { bitsAllocated: 8 } const result = decode(imageFrame, new Uint8Array([0])) diff --git a/packages/openjpeg/CHANGELOG.md b/packages/openjpeg/CHANGELOG.md index 5d92af8..0cf98ee 100644 --- a/packages/openjpeg/CHANGELOG.md +++ b/packages/openjpeg/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [1.3.1](https://localhost/compare/@cornerstonejs/codec-openjpeg@1.3.0...@cornerstonejs/codec-openjpeg@1.3.1) (2026-07-09) + + +### Bug Fixes + +* npm publish ([#56](https://localhost/issues/56)) ([1b03a10](https://localhost/commits/1b03a10d9f4ffa06fcd533208aa0ae5d31ce4428)) +* trigger ci ([#54](https://localhost/issues/54)) ([5afcb5d](https://localhost/commits/5afcb5d895a365a49fc986be0ec1b60ee69cf809)) + + + + + # [1.3.0](https://localhost/compare/@cornerstonejs/codec-openjpeg@1.2.4...@cornerstonejs/codec-openjpeg@1.3.0) (2025-10-22) diff --git a/packages/openjpeg/package.json b/packages/openjpeg/package.json index 7fa655e..53de692 100644 --- a/packages/openjpeg/package.json +++ b/packages/openjpeg/package.json @@ -1,6 +1,6 @@ { "name": "@cornerstonejs/codec-openjpeg", - "version": "1.3.0", + "version": "1.3.1", "description": "JS/WebAssembly build of [OpenJPEG](https://github.com/uclouvain/openjpeg)", "main": "dist/openjpegjs.js", "publishConfig": { diff --git a/packages/openjpeg/src/BufferStream.hpp b/packages/openjpeg/src/BufferStream.hpp index cda2e71..59116e4 100644 --- a/packages/openjpeg/src/BufferStream.hpp +++ b/packages/openjpeg/src/BufferStream.hpp @@ -33,15 +33,17 @@ static OPJ_SIZE_T opj_write_to_buffer (void* p_buffer, OPJ_SIZE_T p_nb_bytes, opj_buffer_info_t* p_source_buffer) { - OPJ_BYTE* pbuf = p_source_buffer->buf; - OPJ_BYTE* pcur = p_source_buffer->cur; + OPJ_SIZE_T remaining = p_source_buffer->buf + p_source_buffer->len - p_source_buffer->cur; - OPJ_SIZE_T len = p_source_buffer->len; + if (remaining == 0) + return (OPJ_SIZE_T)-1; - memcpy (p_source_buffer->cur, p_buffer, p_nb_bytes); - p_source_buffer->cur += p_nb_bytes; + OPJ_SIZE_T n = p_nb_bytes > remaining ? remaining : p_nb_bytes; - return p_nb_bytes; + memcpy (p_source_buffer->cur, p_buffer, n); + p_source_buffer->cur += n; + + return n; } static OPJ_SIZE_T @@ -53,7 +55,7 @@ opj_skip_from_buffer (OPJ_SIZE_T len, opj_buffer_info_t* psrc) if (n > len) n = len; - psrc->cur += len; + psrc->cur += n; } else n = (OPJ_SIZE_T)-1; @@ -64,12 +66,15 @@ opj_skip_from_buffer (OPJ_SIZE_T len, opj_buffer_info_t* psrc) static OPJ_BOOL opj_seek_from_buffer (OPJ_OFF_T len, opj_buffer_info_t* psrc) { - OPJ_SIZE_T n = psrc->len; + if (len < 0) + return OPJ_FALSE; + + OPJ_SIZE_T off = (OPJ_SIZE_T)len; - if (n > len) - n = len; + if (off > psrc->len) + off = psrc->len; - psrc->cur = psrc->buf + n; + psrc->cur = psrc->buf + off; return OPJ_TRUE; } diff --git a/packages/openjpeg/src/J2KDecoder.hpp b/packages/openjpeg/src/J2KDecoder.hpp index a226d0f..70fee26 100644 --- a/packages/openjpeg/src/J2KDecoder.hpp +++ b/packages/openjpeg/src/J2KDecoder.hpp @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include "openjpeg.h" #include "format_defs.h" @@ -15,6 +17,21 @@ #define EMSCRIPTEN_API __attribute__((used)) #define J2K_MAGIC_NUMBER 0x51FF4FFF +/// +/// Computes width * height * components * bytesPerPixel with overflow +/// checking, throwing if the result is zero or exceeds a sane upper bound. +/// +static inline size_t checkedDecodedSize(uint64_t width, uint64_t height, uint64_t components, uint64_t bytesPerPixel) { + const uint64_t kMaxBytes = 512ull * 1024ull * 1024ull; // 512 MiB + uint64_t total = width * height; + total *= components; + total *= bytesPerPixel; + if (total == 0 || total > kMaxBytes) { + throw std::runtime_error("decoded frame size out of range"); + } + return static_cast(total); +} + #ifdef __EMSCRIPTEN__ #include @@ -748,6 +765,9 @@ class J2KDecoder { // NOTE: DICOM only supports OPJ_CODEC_J2K, but not everyone follows this // and some DICOM images will have JP2 encoded bitstreams // http://dicom.nema.org/medical/dicom/2017e/output/chtml/part05/sect_A.4.4.html + if (encoded_.size() < 4) { + throw std::runtime_error("encoded J2K buffer too small"); + } if( ((OPJ_INT32*)encoded_.data())[0] == J2K_MAGIC_NUMBER ){ l_codec = opj_create_decompress(OPJ_CODEC_J2K); }else{ @@ -815,6 +835,12 @@ class J2KDecoder { frameInfo_.width = image->x1; frameInfo_.height = image->y1; frameInfo_.componentCount = image->numcomps; + if (frameInfo_.componentCount != 1 && frameInfo_.componentCount != 3) { + opj_destroy_codec(l_codec); + opj_stream_destroy(l_stream); + opj_image_destroy(image); + throw std::runtime_error("unsupported J2K component count"); + } frameInfo_.isSigned = image->comps[0].sgnd; frameInfo_.bitsPerSample = image->comps[0].prec; @@ -839,7 +865,7 @@ class J2KDecoder { // allocate destination buffer Size sizeAtDecompositionLevel = calculateSizeAtDecompositionLevel(decompositionLevel); const size_t bytesPerPixel = (frameInfo_.bitsPerSample + 8 - 1) / 8; - const size_t destinationSize = sizeAtDecompositionLevel.width * sizeAtDecompositionLevel.height * frameInfo_.componentCount * bytesPerPixel; + const size_t destinationSize = checkedDecodedSize(sizeAtDecompositionLevel.width, sizeAtDecompositionLevel.height, frameInfo_.componentCount, bytesPerPixel); decoded_.resize(destinationSize); // Convert from int32 to native size diff --git a/packages/openjpeg/src/J2KEncoder.hpp b/packages/openjpeg/src/J2KEncoder.hpp index edd3fe7..3f362c1 100644 --- a/packages/openjpeg/src/J2KEncoder.hpp +++ b/packages/openjpeg/src/J2KEncoder.hpp @@ -5,6 +5,7 @@ #include #include +#include #include "openjpeg.h" @@ -289,16 +290,29 @@ class J2KEncoder { // TODO: Add support for using tiles? + // Free everything on every exit path. A silent `return` here used to + // leak the codec/stream/image AND leave encoded_ at its full pre-sized + // allocation, so JS callers treated a failed encode as a successful one + // and read back garbage bytes. + const auto cleanup = [&]() { + if (l_stream) opj_stream_destroy(l_stream); + if (l_codec) opj_destroy_codec(l_codec); + if (image) opj_image_destroy(image); + }; + if (! opj_setup_encoder(l_codec, ¶meters, image)) { - fprintf(stderr, "failed to encode image: opj_setup_encoder\n"); - opj_destroy_codec(l_codec); - opj_image_destroy(image); - return; // TODO: implement error handling + cleanup(); + encoded_.resize(0); + throw std::runtime_error("failed to encode image: opj_setup_encoder"); } - // HACK: For now - make encoded buffer the same size as decoded so we can - // avoid messing with BufferStream malloc/free stuff - encoded_.resize(decoded_.size()); + // HACK: For now - make encoded buffer roughly the same size as decoded + // (plus headroom for worst-case expansion) so we can avoid messing with + // BufferStream malloc/free stuff. opj_write_to_buffer clamps writes to + // the buffer's remaining space, which is the hard safety net if this + // estimate is ever too small: the clamped write makes the compress call + // below return false, which now throws instead of returning silently. + encoded_.resize(decoded_.size() + (decoded_.size() / 2) + 1024); /* open a byte stream for writing and allocate memory for all tiles */ opj_buffer_info_t buffer_info; @@ -306,24 +320,33 @@ class J2KEncoder { buffer_info.cur = encoded_.data(); buffer_info.len = encoded_.size(); l_stream = opj_stream_create_buffer_stream(&buffer_info, OPJ_FALSE); + if (!l_stream) { + cleanup(); + encoded_.resize(0); + throw std::runtime_error("failed to encode image: could not create buffer stream"); + } /* encode the image */ if (!opj_start_compress(l_codec, image, l_stream)) { - fprintf(stderr, "failed to encode image: opj_start_compress\n"); - return; // todo: error handling + cleanup(); + encoded_.resize(0); + throw std::runtime_error("failed to encode image: opj_start_compress (encoded buffer too small?)"); } if(!opj_encode(l_codec, l_stream)) { - fprintf(stderr, "failed to encode image: opj_encode\n"); - return; // todo: error handling + cleanup(); + encoded_.resize(0); + throw std::runtime_error("failed to encode image: opj_encode (encoded buffer too small?)"); } if(!opj_end_compress(l_codec, l_stream)) { - fprintf(stderr, "failed to encode image: opj_end_compress\n"); - return; // todo: error handling + cleanup(); + encoded_.resize(0); + throw std::runtime_error("failed to encode image: opj_end_compress (encoded buffer too small?)"); } encoded_.resize(buffer_info.cur - buffer_info.buf); + cleanup(); } private: diff --git a/packages/openjpeg/test/decode.test.js b/packages/openjpeg/test/decode.test.js index 80a4af8..fbb44b7 100644 --- a/packages/openjpeg/test/decode.test.js +++ b/packages/openjpeg/test/decode.test.js @@ -107,6 +107,16 @@ describe.each(buildVariants)("openjpeg J2K decode robustness — $name", ({ path if (isBuilt) codec = await loadModule(path) }) + it.skipIf(!isBuilt)("throws when the encoded buffer is smaller than 4 bytes", () => { + const decoder = new codec.J2KDecoder() + const tooShort = new Uint8Array([0x00, 0x01, 0x02]) + decoder.getEncodedBuffer(tooShort.length).set(tooShort) + + expect(() => decoder.decode()).toThrow() + + decoder.delete() + }) + it.skipIf(!isBuilt)("does not crash the process on a malformed/garbage buffer", () => { const decoder = new codec.J2KDecoder() const garbage = new Uint8Array(64) @@ -140,6 +150,31 @@ describe.each(encoderVariants)( if (isBuilt) codec = await loadModule(path) }) + it.skipIf(!isBuilt)( + "throws when encoder setup fails instead of returning a garbage buffer", + () => { + const frameInfo = { + width: 512, + height: 512, + bitsPerSample: 16, + componentCount: 1, + isSigned: true, + } + const encoder = new codec.J2KEncoder() + encoder.getDecodedBuffer(frameInfo).set(ct1Raw) + // 40 decompositions -> numresolution 41, beyond OpenJPEG's maximum + // (33), so opj_setup_encoder fails. encode() used to swallow this + // and leave the full pre-sized allocation in the encoded buffer, + // which callers then read back as a "successful" encode. + encoder.setDecompositions(40) + + expect(() => encoder.encode()).toThrow() + expect(encoder.getEncodedBuffer().length).toBe(0) + + encoder.delete() + } + ) + it.skipIf(!isBuilt)( "encodes CT1.RAW losslessly and decodes back to original bytes", () => { diff --git a/packages/openjpeg/test/heap-stability.test.js b/packages/openjpeg/test/heap-stability.test.js index 7f3b6c2..4b3ea98 100644 --- a/packages/openjpeg/test/heap-stability.test.js +++ b/packages/openjpeg/test/heap-stability.test.js @@ -29,6 +29,7 @@ async function loadModule(path) { describe("openjpeg wasm heap stability", { timeout: 120000 }, () => { let codec const encoded = readFileSync(resolve(fixturesDir, "j2k/CT1.j2k")) + const raw = readFileSync(resolve(fixturesDir, "raw/CT1.RAW")) beforeAll(async () => { if (isBuilt) codec = await loadModule("../dist/openjpegwasm.js") @@ -47,6 +48,19 @@ describe("openjpeg wasm heap stability", { timeout: 120000 }, () => { expect(codec.HEAP8.length).toBe(settled) }) + it.skipIf(!isBuilt)("repeated encode/delete cycles do not grow the heap", () => { + const encodeOnce = () => { + const encoder = new codec.J2KEncoder() + encoder.getDecodedBuffer({ width: 512, height: 512, bitsPerSample: 16, componentCount: 1, isSigned: true }).set(raw) + encoder.encode() + encoder.delete() + } + for (let i = 0; i < 10; i++) encodeOnce() + const settled = codec.HEAP8.length + for (let i = 0; i < 60; i++) encodeOnce() + expect(codec.HEAP8.length).toBe(settled) + }) + it.skipIf(!isBuilt)("repeated failing decodes do not grow the heap", () => { // Garbage (not truncated-after-valid-header) input: fails fast at // header parse instead of spending seconds in sample recovery, so the diff --git a/packages/openjphjs/CHANGELOG.md b/packages/openjphjs/CHANGELOG.md index a0e6d10..a37e313 100644 --- a/packages/openjphjs/CHANGELOG.md +++ b/packages/openjphjs/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## [2.4.8](https://github.com/cornerstonejs/codecs/compare/@cornerstonejs/codec-openjph@2.4.7...@cornerstonejs/codec-openjph@2.4.8) (2026-07-09) + +**Note:** Version bump only for package @cornerstonejs/codec-openjph + + + + + ## [2.4.7](https://github.com/cornerstonejs/codecs/compare/@cornerstonejs/codec-openjph@2.4.6...@cornerstonejs/codec-openjph@2.4.7) (2024-09-18) diff --git a/packages/openjphjs/package.json b/packages/openjphjs/package.json index dfa061c..e569b42 100644 --- a/packages/openjphjs/package.json +++ b/packages/openjphjs/package.json @@ -1,6 +1,6 @@ { "name": "@cornerstonejs/codec-openjph", - "version": "2.4.7", + "version": "2.4.8", "description": "JS/WebAssembly build of OpenJPH", "main": "dist/openjphjs.js", "publishConfig": { diff --git a/packages/openjphjs/src/HTJ2KDecoder.hpp b/packages/openjphjs/src/HTJ2KDecoder.hpp index dcce11a..5230d2e 100644 --- a/packages/openjphjs/src/HTJ2KDecoder.hpp +++ b/packages/openjphjs/src/HTJ2KDecoder.hpp @@ -3,8 +3,10 @@ #pragma once +#include #include #include +#include #include #include @@ -22,6 +24,22 @@ #include "Point.hpp" #include "Size.hpp" +/// +/// Computes width * height * components * bytesPerPixel while guarding +/// against 32-bit size_t overflow on the wasm32 target and rejecting +/// unreasonably large decoded buffer sizes. +/// +static inline size_t checkedDecodedSize(uint64_t width, uint64_t height, uint64_t components, uint64_t bytesPerPixel) { + const uint64_t kMaxBytes = 512ull * 1024ull * 1024ull; // 512 MiB + uint64_t total = width * height; + total *= components; + total *= bytesPerPixel; + if (total == 0 || total > kMaxBytes) { + throw std::runtime_error("decoded frame size out of range"); + } + return static_cast(total); +} + /// /// JavaScript API for decoding HTJ2K bistreams with OpenJPH /// @@ -314,7 +332,7 @@ class HTJ2KDecoder Size sizeAtDecompositionLevel = calculateSizeAtDecompositionLevel(decompositionLevel); int resolutionLevel = numDecompositions_ - decompositionLevel; const size_t bytesPerPixel = (frameInfo_.bitsPerSample + 8 - 1) / 8; - const size_t destinationSize = sizeAtDecompositionLevel.width * sizeAtDecompositionLevel.height * frameInfo.componentCount * bytesPerPixel; + const size_t destinationSize = checkedDecodedSize(sizeAtDecompositionLevel.width, sizeAtDecompositionLevel.height, frameInfo.componentCount, bytesPerPixel); pDecoded_->resize(destinationSize); // set the level to read to and reconstruction level to the specified decompositionLevel diff --git a/packages/openjphjs/src/HTJ2KEncoder.hpp b/packages/openjphjs/src/HTJ2KEncoder.hpp index e4bb3a9..24a6435 100644 --- a/packages/openjphjs/src/HTJ2KEncoder.hpp +++ b/packages/openjphjs/src/HTJ2KEncoder.hpp @@ -268,7 +268,11 @@ class HTJ2KEncoder codestream.write_headers(&encoded_); // Encode the image - const size_t bytesPerPixel = frameInfo_.bitsPerSample / 8; + // Round UP like getDecodedBuffer() does (line 52): bitsPerSample / 8 + // truncates to 1 for 9..15-bit samples, halving the row stride so every + // row after the first was read from the wrong offset (found by the + // 12-bit round-trip test; 8- and 16-bit were unaffected). + const size_t bytesPerPixel = (frameInfo_.bitsPerSample + 8 - 1) / 8; ojph::ui32 next_comp; ojph::line_buf *cur_line = codestream.exchange(NULL, next_comp); siz = codestream.access_siz(); diff --git a/packages/openjphjs/test/matrix.test.js b/packages/openjphjs/test/matrix.test.js index 1ade60c..aa3409e 100644 --- a/packages/openjphjs/test/matrix.test.js +++ b/packages/openjphjs/test/matrix.test.js @@ -23,11 +23,13 @@ const asBuffer = (ta) => Buffer.from(ta.buffer, ta.byteOffset, ta.byteLength) // Fixture provenance (tools/fixture-verification/gen/generate-fixtures.mjs): // all four are lossless HTJ2K encodes of committed sources (US1.RAW RGB // frame; deterministic CT2.RAW transforms from derive.mjs), so each test's -// reference is re-derived from the source — a decoder regression on these -// paths breaks byte equality. +// reference is re-derived from the source — a decoder OR encoder regression +// on these paths breaks byte equality. // // The color pair covers both isUsingColorTransform settings — the RCT path -// was flagged "not been tested yet" in HTJ2KDecoder.hpp. +// was flagged "not been tested yet" in HTJ2KDecoder.hpp. The 12-bit case +// pins the row-stride fix in HTJ2KEncoder.hpp (bitsPerSample/8 truncated to +// 1 for 9..15-bit samples, corrupting every row after the first). describe("openjphjs HTJ2K decode matrix — color and bit depths", () => { let codec const us1 = readFileSync(resolve(__dirname, "../../openjpeg/test/fixtures/raw/US1.RAW")) @@ -71,9 +73,30 @@ describe("openjphjs HTJ2K decode matrix — color and bit depths", () => { expect(out.equals(asBuffer(gray8FromCT2(ct2)))).toBe(true) }) - it.skipIf(!isBuilt)("decodes 12-bit grayscale losslessly", () => { + it.skipIf(!isBuilt)("decodes 12-bit grayscale losslessly (encoder stride regression case)", () => { const { frameInfo, out } = decode("CT2-gray12.j2c") expect(frameInfo.bitsPerSample).toBe(12) expect(out.equals(asBuffer(gray12FromCT2(ct2)))).toBe(true) }) + + it.skipIf(!isBuilt)("round-trips 12-bit through the encoder (pins the stride fix)", () => { + // Re-encode the 12-bit source in-process: proves the ENCODER writes + // every row from the right offset, independent of the committed fixture. + const src = gray12FromCT2(ct2) + const encoder = new codec.HTJ2KEncoder() + encoder + .getDecodedBuffer({ width: 512, height: 512, bitsPerSample: 12, componentCount: 1, isSigned: false, isUsingColorTransform: false }) + .set(new Uint8Array(src.buffer, 0, src.byteLength)) + encoder.encode() + const encoded = Buffer.from(encoder.getEncodedBuffer()) + encoder.delete() + + const decoder = new codec.HTJ2KDecoder() + decoder.getEncodedBuffer(encoded.length).set(encoded) + decoder.decode() + const out = Buffer.from(decoder.getDecodedBuffer()) + decoder.delete() + + expect(out.equals(asBuffer(src))).toBe(true) + }) }) diff --git a/tools/browser-smoke/run.js b/tools/browser-smoke/run.js index 1a15175..98a1d1a 100644 --- a/tools/browser-smoke/run.js +++ b/tools/browser-smoke/run.js @@ -32,6 +32,8 @@ const VARIANTS = [ ["openjphjs", "openjphjs.js", "HTJ2KDecoder", "openjphjs/test/fixtures/j2c/CT1.j2c", "openjphjs/test/fixtures/raw/CT1.RAW"], ["libjpeg-turbo-8bit", "libjpegturbojs.js", "JPEGDecoder", "libjpeg-turbo-8bit/test/fixtures/jpeg/jpeg400jfif.jpg", "libjpeg-turbo-8bit/test/fixtures/raw/jpeg400jfif.raw"], ["libjpeg-turbo-8bit", "libjpegturbowasm.js", "JPEGDecoder", "libjpeg-turbo-8bit/test/fixtures/jpeg/jpeg400jfif.jpg", "libjpeg-turbo-8bit/test/fixtures/raw/jpeg400jfif.raw"], + ["libjpeg-turbo-12bit", "libjpegturbo12js.js", "JPEGDecoder", "libjpeg-turbo-12bit/test/fixtures/jpeg/CT-512x512-12bit.jpg", "libjpeg-turbo-12bit/test/fixtures/raw/CT-512x512-12bit.raw"], + ["libjpeg-turbo-12bit", "libjpegturbo12wasm.js", "JPEGDecoder", "libjpeg-turbo-12bit/test/fixtures/jpeg/CT-512x512-12bit.jpg", "libjpeg-turbo-12bit/test/fixtures/raw/CT-512x512-12bit.raw"], ]; const MIME = {