From c879256d256c198843452571931187b2dd7e7701 Mon Sep 17 00:00:00 2001 From: raster Date: Wed, 27 May 2026 01:01:53 +0530 Subject: [PATCH] =?UTF-8?q?release:=20v10.19.0=20=E2=80=94=20J2KDICOMHelpe?= =?UTF-8?q?rs=20Phase=202=20(Pixel=20Data=20encapsulation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds DICOM Pixel Data encapsulation helpers to the J2KDICOMHelpers library (introduced in v10.17.0). Wraps J2K codestreams into DICOM Pixel Data Item bytes (PS3.5 §A.4) for the write side, and parses them back for the read side. New public surface: * J2KDICOMPixelDataEncapsulator.encapsulateItem(_:) — single J2K codestream → one DICOM Pixel Data Item (8-byte header FFFE,E000 + LE u32 length + payload + optional 0x00 pad per PS3.5 §6.4) * J2KDICOMPixelDataEncapsulator.encapsulateFrames(_:includeBOT:) — multiple frames → BOT (optional contents) + per-frame Items + Sequence Delimitation Item. BOT entries are little-endian u32 offsets measured from the start of the first frame Item per current DICOM Standard. * J2KDICOMPixelDataDecapsulator.extractFrames(_:) — parses an encapsulated sequence back into per-frame J2K codestreams. Strips trailing 0x00 pad bytes when detected via the EOC-then-pad pattern (0xFF 0xD9 0x00). * J2KDICOMPixelDataError — Sendable, Equatable error type with cases: truncated, itemTagExpected, itemLengthOverrun, malformedSequenceDelimitation. Write-side use case: encode an image via J2KSwift, wrap as Pixel Data via encapsulateFrames, hand the bytes off to your DICOM writer for insertion at (7FE0,0010) with VR OB + undefined length. Read-side use case: extract codestreams from your DICOM library's Pixel Data bytes via extractFrames, decode each frame via J2KDecoder. Phase 2 scope is narrower than the original v10.17 plan (which contemplated full DICOM file parsing extraction). Refined to the wire-format layer specifically: (a) Consumers should use their own DICOM library for file parsing (pydicom, DICOMKit, dcm4che — all do this correctly). (b) The J2K-specific wire format wasn't previously available — consumers had to hand-roll the byte layout (the pattern was test-scaffolding in J2KStrictCrossCodecValidationTests:170-194). ADR-004 compliant: no DICOM library dependency added anywhere; rules codified directly from PS3.5 §A.4 / §6.4. Pure additive surface on the existing J2KDICOMHelpers library; no signature changes elsewhere; codestream bytes byte-identical to v10.18.0. Validation: - V10_31_PixelDataEncapsulationTests 12/12 PASS release mode - swift test --filter J2KDICOMHelpers regression 38/38 PASS (26 V10_29 Phase 1 + 12 V10_31 Phase 2) - swift test --filter JP3D regression 532/532 PASS - Mandatory commit gate (release mode) 7/7 PASS MINOR per RELEASING.md — pure additive surface, no removal, no signature change. Also bumps getVersion() 10.18.0 → 10.19.0. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 8 +- RELEASE_NOTES_v10.19.0.md | 241 ++++++++++++++++ Sources/J2KCore/J2KCore.swift | 2 +- .../J2KDICOMPixelDataEncapsulation.swift | 262 ++++++++++++++++++ .../V10_31_PixelDataEncapsulationTests.swift | 228 +++++++++++++++ 5 files changed, 737 insertions(+), 4 deletions(-) create mode 100644 RELEASE_NOTES_v10.19.0.md create mode 100644 Sources/J2KDICOMHelpers/J2KDICOMPixelDataEncapsulation.swift create mode 100644 Tests/J2KDICOMHelpersTests/V10_31_PixelDataEncapsulationTests.swift diff --git a/README.md b/README.md index 085f1d2..403beb4 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,15 @@ A pure Swift 6.2 implementation of JPEG 2000 (ISO/IEC 15444) encoding and decoding with strict concurrency support. -**Current Version**: 10.18.0 -**Status**: Apple Silicon-first JPEG 2000 / HTJ2K (Part-15) implementation. v10.18.0 ships **JP3D AsyncSequence progress reporting** — `progressStream()` extensions on `JP3DDecoder` / `JP3DROIDecoder` / `JP3DEncoder` / `JP3DStreamWriter`, modern Swift-concurrency progress API alongside the existing `setProgressCallback(_:)` closure surface. Closes the deferred Phase 3 from v10.17.0's plan with a focused MINOR scope so the AsyncStream lifecycle (race-free setup via `async` method, documented overwrite semantics) is implemented cleanly. Consumers gain idiomatic `for await progress in decoder.progressStream() { … }` iteration without hand-rolling a `setProgressCallback` → `AsyncStream.makeStream` adapter. MINOR / additive — pure surface, no existing API change, codestream bytes byte-identical to v10.17.0. -**Previous Release**: 10.17.0 (J2KDICOMHelpers Phase 1 — new SwiftPM product) +**Current Version**: 10.19.0 +**Status**: Apple Silicon-first JPEG 2000 / HTJ2K (Part-15) implementation. v10.19.0 ships **`J2KDICOMHelpers` Phase 2 — DICOM Pixel Data encapsulation helpers**. Wrap J2K codestreams into DICOM Pixel Data Item bytes (PS3.5 §A.4) and round-trip them back: `J2KDICOMPixelDataEncapsulator.encapsulateItem(_:)` for single frames + `.encapsulateFrames(_:includeBOT:)` for multi-frame sequences with optional Basic Offset Table + `J2KDICOMPixelDataDecapsulator.extractFrames(_:)` for the reverse direction (with EOC-then-pad detection). Builds directly on v10.17.0's Phase 1 product. ADR-004 compliant — no DICOM library dependency added anywhere; the byte-layout rules are codified directly from PS3.5 §A.4 / §6.4. MINOR / additive — pure surface, codestream bytes byte-identical to v10.18.0. +**Previous Release**: 10.18.0 (JP3D AsyncSequence progress reporting) **Release process**: see [RELEASING.md](RELEASING.md). Every release MUST update this README (Current Version line + new Release Status paragraph) — see the Release artefacts checklist for the full requirements. ## 📦 Release Status +**v10.19.0** ships **`J2KDICOMHelpers` Phase 2 — DICOM Pixel Data encapsulation helpers**. Builds directly on v10.17.0's Phase 1 product (UID-and-config bridge) with the wire-format layer that lets consumers turn J2K codestreams into DICOM Pixel Data bytes and back. New public API on the `J2KDICOMHelpers` library: **`J2KDICOMPixelDataEncapsulator.encapsulateItem(_:)`** wraps one J2K codestream into a single DICOM Pixel Data Item (8-byte header `FFFE,E000` + LE u32 length + payload + optional 0x00 pad to maintain even Item length per PS3.5 §6.4); **`J2KDICOMPixelDataEncapsulator.encapsulateFrames(_:includeBOT:)`** wraps multiple frames into a complete Item sequence with optional Basic Offset Table prefix + Sequence Delimitation Item suffix (BOT entries are little-endian u32 offsets measured from the start of the first frame Item, per current DICOM Standard); **`J2KDICOMPixelDataDecapsulator.extractFrames(_:)`** parses an encapsulated sequence back into per-frame J2K codestreams, stripping trailing 0x00 pad bytes when detected via the EOC-then-pad pattern (`0xFF 0xD9 0x00`); **`J2KDICOMPixelDataError`** `Sendable, Equatable` error type with cases for `truncated`, `itemTagExpected`, `itemLengthOverrun`, `malformedSequenceDelimitation`. Write-side use case: encode an image via `J2KSwift`, wrap as Pixel Data, hand off to your DICOM writer for insertion at `(7FE0,0010)` with VR `OB` and undefined length. Read-side: extract codestreams from your DICOM library's Pixel Data bytes, decode via `J2KDecoder`. Phase 2 scope is **narrower than the original v10.17 plan** (which contemplated full DICOM file parsing extraction); refined to the wire-format layer specifically because (a) consumers should use their own DICOM library for file parsing (pydicom, DICOMKit, dcm4che), (b) the J2K-specific wire format wasn't previously available — consumers had to hand-roll the byte layout (the pattern was test-scaffolding in `J2KStrictCrossCodecValidationTests.swift:170-194`). ADR-004 compliant: no DICOM library dependency added anywhere; rules codified directly from PS3.5 §A.4 / §6.4. **Validation**: `V10_31_PixelDataEncapsulationTests` **12/12 PASS** release mode (encapsulateItem even+odd length + padding correctness; encapsulateFrames single+multi frame with empty and populated BOT; round-trip byte-exact single + multi frame; pad stripping via EOC-then-pad detection; truncated/invalid input rejection; error type Equatable); full `swift test --filter J2KDICOMHelpers` regression **38/38 PASS** (26 V10_29 Phase 1 + 12 V10_31 Phase 2); `swift test --filter JP3D` regression **532/532 PASS**; mandatory commit gate 7/7 PASS. Also bumps `getVersion()` 10.18.0 → 10.19.0. Codestream bytes byte-identical to v10.18.0. See [RELEASE_NOTES_v10.19.0.md](RELEASE_NOTES_v10.19.0.md). + **v10.18.0** ships **JP3D AsyncSequence progress reporting** — `progressStream()` extensions on `JP3DDecoder`, `JP3DROIDecoder`, `JP3DEncoder`, and `JP3DStreamWriter`. Modern Swift concurrency progress API alongside the existing `setProgressCallback(_:)` closure surface (both remain supported indefinitely). Consumers using structured concurrency previously had to hand-roll a `setProgressCallback` → `AsyncStream.makeStream` adapter themselves; v10.18.0 ships that adapter inside the JP3D module so it composes cleanly with the existing async-await encode/decode methods. The `progressStream()` method is `async` so the relay closure is installed on the actor BEFORE the stream is returned — no race window where early progress events get missed. Lifecycle behaviour is explicitly codified: calling `progressStream()` twice overwrites the first stream's writer (the first stream's continuation receives no further events but isn't explicitly finished); the stream doesn't auto-finish at operation completion (consumers `break` based on observed progress or rely on `Task` cancellation); `setProgressCallback(_:)` after `progressStream()` overwrites the relay (use one or the other, not both). **Validation**: `V10_30_ProgressStreamParityTests` **4/4 PASS** release mode — JP3DDecoder + JP3DEncoder both deliver progress events during real operations; JP3DROIDecoder stream surface is available + safe to consume (its decoder body doesn't currently fire `progressCallback` events — pre-existing upstream gap, documented in the test header); `setProgressCallback` overwrites of `progressStream`'s relay still fire correctly. Full `swift test --filter JP3D` regression sweep **532/532 PASS** (528 pre-existing + 4 new V10_30 + 1 pre-existing skip); mandatory commit gate 7/7 PASS. Closes the deferred Phase 3 from v10.17.0's plan with focused MINOR scope. Also bumps `getVersion()` 10.17.0 → 10.18.0. Codestream bytes byte-identical to v10.17.0. See [RELEASE_NOTES_v10.18.0.md](RELEASE_NOTES_v10.18.0.md). **v10.17.0** ships **`J2KDICOMHelpers` (Phase 1)** — a new public SwiftPM library closing a long-standing product-layer gap. Per [ADR-004](Documentation/ADR/ADR-004-no-dicom-dependency.md), J2KSwift core libraries don't depend on any DICOM library. The CLI ships a `DICOMSupport.swift` for `.dcm` file loading (uses a Python fallback for compressed transfer syntaxes), but the `DICOMTransferSyntax` / `DICOMTransferSyntaxInfo` types there are CLI-private — not surfaced to library consumers. Library consumers using their own DICOM parser (DICOMKit, pydicom-via-XPC, etc.) extract `(Transfer Syntax UID, Pixel Data bytes)` and previously had to hand-roll the UID-to-`J2KEncodingConfiguration` mapping. v10.17.0 lifts the UID-bridge layer into a new public `J2KDICOMHelpers` SwiftPM library: **`J2KDICOMTransferSyntax`** enum (`CaseIterable` over all seven DICOM Part 5 Annex A JPEG 2000 / HTJ2K UIDs — `j2kLossless`, `j2kLossy`, `j2kPart2MulticompLossless`, `j2kPart2Multicomp`, `htj2kLossless`, `htj2kLossyConstant`, `htj2kLossy`) with `init?(uid:)` / `var uid` round-trip (whitespace + trailing-NUL tolerant per PS3.5 §6.2), `isLossless` / `isHTJ2K` / `isPart2` classification flags, and `encodingConfiguration(bitDepth:psnrTarget:)` returning a matching `J2KEncodingConfiguration` (with `htj2kBlockFormat: .conformant` for DICOM interop); **`J2KDICOMCodestreamDetector.detect(_:)`** sniffs SOC + (optional) CAP markers in the first ~256 bytes to classify raw codestreams as `.j2kLossless` (Part 1, no CAP) or `.htj2kLossless` (Part 15, CAP present), returning nil for non-J2K bytes (JPEG SOI, PNG signature, random bytes all correctly reject); and **`J2KDICOMPhotometricInterpretation`** enum mirror (`MONOCHROME1/2`, `RGB`, `YBR_FULL/422`, `YBR_RCT/ICT`) with bidirectional `J2KColorSpace` mapping (greyscale ↔ MONOCHROME2, sRGB ↔ RGB, yCbCr ↔ YBR_FULL; `hdr`/`hdrLinear`/`iccProfile`/`unknown` return nil reverse). ADR-004 compliant: **no DICOM library dependency anywhere in J2KSwift** — the new product depends only on J2KCore + J2KCodec; consumers not importing `J2KDICOMHelpers` are completely unaffected. **Validation**: `V10_29_*` test suites **26/26 PASS** release mode (9 TransferSyntaxRoundTrip + 5 EncodingConfigurationParity + 6 CodestreamDetection + 6 PhotometricInterpretation); end-to-end bit-exact lossless round-trip confirmed for `.j2kLossless` + `.htj2kLossless` via the helpers-built configurations. Full `swift test --filter JP3D` regression sweep **528/528 PASS**; mandatory commit gate 7/7 PASS. Also bumps `getVersion()` 10.16.0 → 10.17.0. Phase 1 deliberately ships the smallest viable shape — UID-and-config bridge only, no DICOM file parsing (Phase 2 territory: extract more of `J2KCLI/DICOMSupport.swift` or ship a DICOMKit adapter as a sibling opt-in product). AsyncSequence progress streams (originally Phase 3) deferred to a dedicated MINOR. See [RELEASE_NOTES_v10.17.0.md](RELEASE_NOTES_v10.17.0.md). diff --git a/RELEASE_NOTES_v10.19.0.md b/RELEASE_NOTES_v10.19.0.md new file mode 100644 index 0000000..d5dae6d --- /dev/null +++ b/RELEASE_NOTES_v10.19.0.md @@ -0,0 +1,241 @@ +# J2KSwift v10.19.0 + +**`J2KDICOMHelpers` Phase 2 — DICOM Pixel Data encapsulation helpers.** +Wrap J2K codestreams into DICOM Pixel Data Item bytes (PS3.5 §A.4) +and round-trip them back. Builds directly on v10.17.0's Phase 1 product +without introducing any DICOM library dependency. + +MINOR per RELEASING.md — pure additive surface on the existing +`J2KDICOMHelpers` library: 2 new public enums, no signature changes +elsewhere, codestream bytes byte-identical to v10.18.0. + +## Summary + +v10.17.0 (Phase 1) shipped the UID-and-config bridge: +`J2KDICOMTransferSyntax` + `encodingConfiguration()` + +`J2KDICOMCodestreamDetector` + `J2KDICOMPhotometricInterpretation`. +The natural follow-on is wire-format helpers — turning a J2K codestream +into DICOM Pixel Data bytes and back. v10.19.0 ships exactly that: + +```swift +import J2KDICOMHelpers +import J2KCodec + +// 1. Encode an image via J2KSwift +let ts = J2KDICOMTransferSyntax.htj2kLossless +let cfg = ts.encodingConfiguration(bitDepth: 16) +let codestream = try await J2KEncoder(encodingConfiguration: cfg).encode(image) + +// 2. Wrap it as DICOM Pixel Data +let pixelDataBytes = J2KDICOMPixelDataEncapsulator + .encapsulateFrames([codestream], includeBOT: false) + +// 3. Hand pixelDataBytes off to your DICOM writer for insertion at +// (7FE0,0010) with VR "OB" and undefined length. + +// On the round-trip: +let extracted = try J2KDICOMPixelDataDecapsulator.extractFrames(pixelDataBytes) +assert(extracted.count == 1) +assert(extracted[0] == codestream) // bit-exact +``` + +For multi-frame data (e.g., a CT volume encoded as one J2K codestream +per slice), pass the frame array and optionally request a populated +Basic Offset Table: + +```swift +let frames: [Data] = sliceCodestreams // one J2K codestream per Z-slice +let pixelDataBytes = J2KDICOMPixelDataEncapsulator + .encapsulateFrames(frames, includeBOT: true) +``` + +The BOT contains one little-endian u32 per frame, giving the offset +of that frame's Item header measured from the start of the FIRST +FRAME ITEM (per PS3.5 §A.4) — populated BOTs let downstream consumers +seek directly to frame N without scanning. + +## What's New — production-default + +| Public API | v10.18.0 | v10.19.0 | +|---|---|---| +| `J2KDICOMPixelDataEncapsulator.encapsulateItem(_:)` | _not present_ | **NEW** — wraps one J2K codestream into a single DICOM Pixel Data Item (8-byte header + payload + optional pad) | +| `J2KDICOMPixelDataEncapsulator.encapsulateFrames(_:includeBOT:)` | _not present_ | **NEW** — wraps multiple frames + (optional) BOT + Sequence Delimitation Item | +| `J2KDICOMPixelDataDecapsulator.extractFrames(_:)` | _not present_ | **NEW** — parses a DICOM Pixel Data sequence back into one `Data` per frame; strips trailing pad bytes | +| `J2KDICOMPixelDataError` | _not present_ | **NEW** — `Sendable, Equatable` error type for decapsulator failures (truncated, itemTagExpected, itemLengthOverrun, malformedSequenceDelimitation) | +| `getVersion()` | 10.18.0 | 10.19.0 | +| Every other public API | unchanged | unchanged | + +The new surface is in the `J2KDICOMHelpers` SwiftPM library that +v10.17.0 introduced. Consumers not importing `J2KDICOMHelpers` are +unaffected. + +## Backward compatibility + +- **Codestream bytes**: byte-identical to v10.18.0 on every input. + Encoder unchanged. +- **Existing libraries**: zero behaviour change. All previously-shipped + `J2KDICOMHelpers` types (`J2KDICOMTransferSyntax`, + `J2KDICOMCodestreamDetector`, `J2KDICOMPhotometricInterpretation`) + unchanged. JP3D / J2KCodec / J2KCore unchanged. +- **API surface**: additive only. Two new public enums + one new error + type. No existing signatures changed. +- **ADR-004 compliant**: no DICOM library dependency added anywhere. + The encapsulator/decapsulator codify the byte-layout rules directly + from DICOM PS3.5 §A.4 / §6.4. + +## Why this is the right Phase 2 scope + +The original v10.17.0 plan called Phase 2 "DICOM file parser extraction +from `J2KCLI/DICOMSupport.swift`". On closer inspection, full file +parsing pulls in: + +- Group 0002 / dataset tag walking (~150 LOC) +- All the byte-reading helpers (`dcmReadU16LE`, `dcmReadString`, etc., + ~50 LOC) +- Multi-frame layout + photometric interpretation handling for the + full uncompressed pixel-data → `J2KImage` conversion (~200 LOC) +- Encapsulated pixel data parsing for the J2K-tagged case (~80 LOC) + +The first three are "consumer should use their own DICOM library +anyway" — pydicom, DICOMKit, dcm4che, etc. all do the file parsing +correctly. What consumers actually need from us is the **J2K-specific +wire format** for the Pixel Data element — exactly what v10.19.0 +ships. Full file parsing stays in `Sources/J2KCLI/DICOMSupport.swift` +where it's been working since v8. + +This narrower Phase 2 is also immediately useful for the **write +side**: a consumer who encoded an image via `J2KSwift` and wants to +embed it in a DICOM file uses `encapsulateFrames(_:)`. That use case +didn't exist before v10.19.0; consumers had to hand-roll the byte +layout (the pattern was demonstrated in +`Tests/J2KCodecTests/J2KStrictCrossCodecValidationTests.swift:170-194` +as test scaffolding). + +## Test Suite Results + +| Suite | Tests | Result | Coverage | +|---|---:|---|---| +| `V10_31_PixelDataEncapsulationTests` | 12/12 | PASS | encapsulateItem even+odd length, padding correctness; encapsulateFrames single+multi frame with empty and populated BOT; round-trip byte-exact single + multi frame; pad stripping; truncated/invalid input rejection; error type Equatable | +| `swift test --filter J2KDICOMHelpers` (full regression) | 38/38 | PASS | 26 V10_29 (Phase 1) + 12 V10_31 (Phase 2) | +| `swift test --filter JP3D` (full regression) | 532/532 | PASS | 1 pre-existing skip | +| Mandatory commit gate (release mode) | 7/7 | PASS | `J2KMedicalCorpusEncodePerformanceTests` 2/2 + `J2KMedicalCorpusPerformanceTests` 2/2 + `J2KStrictCrossCodecValidationTests` 3/3 | + +## API surface — additions only + +```swift +public enum J2KDICOMPixelDataEncapsulator { + /// Wrap one J2K codestream into a single DICOM Pixel Data Item. + /// 8-byte header (FFFE,E000 tag + LE u32 length) + payload + optional 0x00 pad. + public static func encapsulateItem(_ codestream: Data) -> Data + + /// Wrap multiple J2K codestreams into a DICOM Pixel Data Item sequence: + /// BOT (optional contents) + per-frame Items + Sequence Delimitation Item. + public static func encapsulateFrames( + _ frames: [Data], + includeBOT: Bool = false + ) -> Data +} + +public enum J2KDICOMPixelDataDecapsulator { + /// Parse a DICOM Pixel Data Item sequence into per-frame J2K codestreams. + /// Strips trailing 0x00 pad bytes when detected via EOC-then-pad pattern. + public static func extractFrames(_ encapsulated: Data) throws -> [Data] +} + +public enum J2KDICOMPixelDataError: Error, Sendable, Equatable { + case truncated(needed: Int, got: Int) + case itemTagExpected(offset: Int) + case itemLengthOverrun(itemOffset: Int, declaredLength: Int) + case malformedSequenceDelimitation(actualLength: UInt32) +} +``` + +No removals. No existing signatures changed. + +## Recommended usage + +```swift +import J2KDICOMHelpers +import J2KCodec +import J2K3D + +// === Encode-then-embed (write side) === +let ts = J2KDICOMTransferSyntax.htj2kLossless +let cfg = ts.encodingConfiguration(bitDepth: 16) + +// Single frame: 2D image +let codestream = try await J2KEncoder(encodingConfiguration: cfg).encode(image) +let pixelData = J2KDICOMPixelDataEncapsulator.encapsulateFrames([codestream]) +// → hand pixelData off to your DICOM writer for (7FE0,0010) + +// Multi-frame: JP3D volume as one J2K codestream per slice +let sliceData: [Data] = try await encodeJP3DPerSlice(volume) // your own +let multiFramePixelData = J2KDICOMPixelDataEncapsulator + .encapsulateFrames(sliceData, includeBOT: true) + +// === Extract-then-decode (read side) === +let pixelDataBytes = readPixelDataFromDICOM(...) // your DICOM library +let frames = try J2KDICOMPixelDataDecapsulator.extractFrames(pixelDataBytes) +for frameBytes in frames { + let decoded = try await J2KDecoder().decode(frameBytes) + // … +} +``` + +## Known limitations + +- **No DICOM file parsing**: Phase 2 still doesn't parse `.dcm` files + — that stays in `Sources/J2KCLI/DICOMSupport.swift` (or your + consumer-side DICOM library). Phase 2's encapsulator/decapsulator + works on the Pixel Data element's BYTES, not the surrounding + metadata. +- **BOT endianness**: per the current DICOM Standard, BOT entries are + little-endian u32. Some older interpretations specify big-endian; + v10.19.0 follows the current (and dcm4che / pydicom-consistent) + little-endian convention. If you're consuming legacy archives that + used big-endian BOTs, the BOT contents will be wrong but the + per-frame extraction (which scans Item-by-Item, not BOT-driven) + still works correctly — `extractFrames(_:)` doesn't depend on BOT + contents for correctness. +- **Pad-byte detection**: `extractFrames(_:)` strips one trailing pad + byte from each frame ONLY when the payload's last three bytes are + `0xFF 0xD9 0x00` (EOC + pad). This is the canonical layout for J2K + / HTJ2K codestreams. Codestreams that don't end in EOC (truncated + or non-standard) retain the trailing byte; this is conservative + behaviour — strip the byte yourself if you know the source is + intentionally pad-extended. + +## Reproducing the test numbers + +```bash +swift test -c release --filter "V10_31_PixelDataEncapsulationTests" +``` + +12 tests covering Item / Frames / BOT layout + round-trip + pad +handling + error paths — all PASS in ~0.003 s release mode. + +## Backward upgrade + +`swift package update` won't auto-pick this release if your `Package.swift` +pins an exact version; bump the requirement to `from: "10.19.0"`. No +source changes required for consumers — the new types are strictly +additive. Existing code using `J2KDICOMTransferSyntax`, +`J2KDICOMCodestreamDetector`, or `J2KDICOMPhotometricInterpretation` +continues to work without modification. + +## Companion — Next release candidates + +After v10.19.0 ships, the remaining v10.x candidates are: + +1. **`J2KDICOMHelpers` Phase 3** — full DICOM file parser extraction + (the larger scope from the original Phase 2 design — pulls in + group 0002 parsing, dataset tag walking, photometric handling). +2. **JPIP Phase 1 response parser** — 2-3 weeks, closes the + notImplemented-across-all-public-methods state of the JPIP module. +3. **IncrementalJ2KDecoder completion** — 2-3 weeks, closes a + notImplemented stub at + `Sources/J2KCodec/J2KAdvancedDecoding.swift:430`. +4. **JP3DProgressiveDecoder + JP3DMultiSpectralDecoder / + `…Encoder` surface symmetry** — these JP3D types lack the + `preWarm`/`progressStream` extensions shipped in v10.15/v10.18 for + their sibling types. Modest scope. diff --git a/Sources/J2KCore/J2KCore.swift b/Sources/J2KCore/J2KCore.swift index 1f6ff57..7203871 100644 --- a/Sources/J2KCore/J2KCore.swift +++ b/Sources/J2KCore/J2KCore.swift @@ -888,5 +888,5 @@ public struct J2KConfiguration: Sendable { /// /// - Returns: A string representing the current version in semver format. public func getVersion() -> String { - "10.18.0" + "10.19.0" } diff --git a/Sources/J2KDICOMHelpers/J2KDICOMPixelDataEncapsulation.swift b/Sources/J2KDICOMHelpers/J2KDICOMPixelDataEncapsulation.swift new file mode 100644 index 0000000..5c2452b --- /dev/null +++ b/Sources/J2KDICOMHelpers/J2KDICOMPixelDataEncapsulation.swift @@ -0,0 +1,262 @@ +// J2KDICOMPixelDataEncapsulation.swift +// +// v10.19.0 — J2KDICOMHelpers Phase 2: DICOM Pixel Data encapsulation +// helpers. +// +// JPEG 2000-tagged DICOM Pixel Data is laid out per PS3.5 §A.4 as a +// sequence of Items inside an undefined-length `(7FE0,0010) OB` +// Pixel Data element: +// +// [Item-tag FFFE,E000] [Length LE u32] ← (optional) Basic Offset Table +// [Item-tag FFFE,E000] [Length LE u32] [J2K frame 0 bytes] [optional pad] +// [Item-tag FFFE,E000] [Length LE u32] [J2K frame 1 bytes] [optional pad] +// … +// [Sequence Delim FFFE,E0DD] [Length 00 00 00 00] +// +// The Basic Offset Table (BOT), when non-empty, contains one big-endian +// u32 offset per frame, measured from the start of the FIRST FRAME ITEM +// (NOT from the start of Pixel Data — important detail). When empty +// (length = 0), the BOT item is still present but carries no offsets; +// the decoder must scan the Item sequence linearly to find frame N. +// +// Each frame Item's data is a complete, decoder-ready JPEG 2000 / HTJ2K +// codestream starting with SOC (0xFF 0x4F). Item lengths must be even +// per DICOM PS3.5 §6.4 — frames with odd codestream byte counts get a +// trailing 0x00 pad byte (the pad is NOT part of the codestream, just +// part of the Item). +// +// ADR-004 compliant: no DICOM library dependency. The byte-layout +// rules are codified directly here from PS3.5 §A.4 / §6.4. + +import Foundation + +// MARK: - Encapsulator + +/// Wraps J2K codestreams into DICOM Pixel Data encapsulated bytes. +/// +/// Use this when you have one or more J2K / HTJ2K codestreams (typically +/// from `J2KEncoder.encode(_:)` or `J2KSwift`'s JP3D encoders) and need +/// to write them into a DICOM file's `(7FE0,0010)` Pixel Data element +/// per PS3.5 §A.4. +public enum J2KDICOMPixelDataEncapsulator { + + /// Wraps a single J2K codestream into one DICOM Pixel Data Item. + /// + /// Returns the 8-byte Item header (`FFFE E000` + length LE u32) + + /// codestream bytes + optional 0x00 pad byte if the codestream + /// length is odd. Item length includes the pad byte (per PS3.5 §6.4 + /// "Data Element Length shall always be an even number"). + /// + /// - Parameter codestream: A complete J2K / HTJ2K codestream (the + /// bytes that begin with SOC `0xFF 0x4F`). + /// - Returns: The Item bytes ready to insert into a Pixel Data sequence. + public static func encapsulateItem(_ codestream: Data) -> Data { + // Per PS3.5 §6.4 — Data Element Length must be even. + let paddedLength = (codestream.count + 1) & ~1 + let needsPad = paddedLength != codestream.count + + var item = Data() + item.reserveCapacity(8 + paddedLength) + + // Item tag FFFE,E000 — written little-endian as (FE FF) (00 E0). + item.append(contentsOf: [0xFE, 0xFF, 0x00, 0xE0]) + + // Length LE u32. + let len = UInt32(paddedLength) + item.append(UInt8(len & 0xFF)) + item.append(UInt8((len >> 8) & 0xFF)) + item.append(UInt8((len >> 16) & 0xFF)) + item.append(UInt8((len >> 24) & 0xFF)) + + // Payload + (optional) pad. + item.append(codestream) + if needsPad { item.append(0x00) } + + return item + } + + /// Wraps one or more J2K codestreams into a DICOM Pixel Data Item + /// sequence, optionally prefixed with a Basic Offset Table. + /// + /// Per PS3.5 §A.4 the sequence is: + /// - BOT item (always present; length 0 if not populated) + /// - One Item per frame, each carrying the frame's codestream + /// - Sequence Delimitation Item (`FFFE,E0DD`) with length 0 + /// + /// When `includeBOT == true`, the BOT contains one big-endian u32 + /// per frame: the offset of that frame's Item header measured from + /// the start of the FIRST FRAME ITEM (NOT from the start of the + /// returned bytes). When `false`, the BOT is emitted with length 0 + /// (per the standard, this is the common case for single-frame + /// images — a populated BOT is optional even for multi-frame). + /// + /// - Parameters: + /// - frames: One J2K / HTJ2K codestream per frame. + /// - includeBOT: When true, populate the BOT with per-frame offsets. + /// - Returns: The complete Item sequence (BOT + frames + delimiter). + public static func encapsulateFrames( + _ frames: [Data], + includeBOT: Bool = false + ) -> Data { + // Build the frame items first so we can compute BOT offsets + // measured from the start of the first frame item. + var frameItems: [Data] = [] + frameItems.reserveCapacity(frames.count) + var offsets: [UInt32] = [] + offsets.reserveCapacity(frames.count) + var runningOffset: UInt32 = 0 + for frame in frames { + offsets.append(runningOffset) + let item = encapsulateItem(frame) + frameItems.append(item) + runningOffset = runningOffset &+ UInt32(item.count) + } + + // BOT item: 8-byte header + (optional) u32 offsets payload. + let botPayloadCount = includeBOT ? frames.count * 4 : 0 + var output = Data() + output.reserveCapacity(8 + botPayloadCount + frameItems.reduce(0) { $0 + $1.count } + 8) + + // BOT item tag + length. + output.append(contentsOf: [0xFE, 0xFF, 0x00, 0xE0]) + let botLen = UInt32(botPayloadCount) + output.append(UInt8(botLen & 0xFF)) + output.append(UInt8((botLen >> 8) & 0xFF)) + output.append(UInt8((botLen >> 16) & 0xFF)) + output.append(UInt8((botLen >> 24) & 0xFF)) + + if includeBOT { + // BOT entries — BIG-endian u32 per DICOM PS3.5 §A.4. + // (Note: confusingly, the BOT entries are little-endian per + // some interpretations — but the canonical reading per the + // most recent DICOM Standard and the dcm4che / pydicom + // reference codebases is little-endian. We follow the + // current standard: little-endian u32.) + for offset in offsets { + output.append(UInt8(offset & 0xFF)) + output.append(UInt8((offset >> 8) & 0xFF)) + output.append(UInt8((offset >> 16) & 0xFF)) + output.append(UInt8((offset >> 24) & 0xFF)) + } + } + + // Frame items. + for item in frameItems { + output.append(item) + } + + // Sequence Delimitation Item — FFFE,E0DD, length 0. + output.append(contentsOf: [0xFE, 0xFF, 0xDD, 0xE0]) + output.append(contentsOf: [0x00, 0x00, 0x00, 0x00]) + + return output + } +} + +// MARK: - Decapsulator + +/// Errors emitted by `J2KDICOMPixelDataDecapsulator`. +public enum J2KDICOMPixelDataError: Error, Sendable, Equatable { + /// The input was too short to contain even the BOT Item header. + case truncated(needed: Int, got: Int) + /// Expected an Item tag (`FFFE,E000`) at the given offset. + case itemTagExpected(offset: Int) + /// Item length runs past the end of the input buffer. + case itemLengthOverrun(itemOffset: Int, declaredLength: Int) + /// Sequence Delimitation Item (`FFFE,E0DD`) malformed (non-zero length). + case malformedSequenceDelimitation(actualLength: UInt32) +} + +/// Extracts J2K codestream frames from DICOM Pixel Data encapsulated bytes. +public enum J2KDICOMPixelDataDecapsulator { + + /// Parses a DICOM Pixel Data encapsulated sequence and returns the + /// constituent J2K codestream frame(s). + /// + /// The input is expected to be the bytes between the `(7FE0,0010)` + /// Pixel Data element's 8-byte header (tag + VR + reserved + length) + /// and the (optional) Sequence Delimitation Item — i.e., starting + /// at the BOT Item tag and ending at or before the + /// `FFFE,E0DD` delimiter. + /// + /// Strips trailing 0x00 pad bytes from each frame's payload only when + /// the codestream payload is one byte longer than the actual J2K + /// codestream — detection uses the EOC marker (`0xFF 0xD9`) being + /// the last two bytes before a single pad byte. Conservatively + /// retains all bytes when EOC isn't at the expected position. + /// + /// - Parameter encapsulated: DICOM Pixel Data sequence bytes (BOT + frame items + delimiter). + /// - Returns: One `Data` per frame, each a decoder-ready J2K codestream. + /// - Throws: `J2KDICOMPixelDataError` on malformed input. + public static func extractFrames(_ encapsulated: Data) throws -> [Data] { + var frames: [Data] = [] + var offset = 0 + + // BOT Item — REQUIRED to be present (even if length 0). + guard encapsulated.count >= 8 else { + throw J2KDICOMPixelDataError.truncated(needed: 8, got: encapsulated.count) + } + let botTag = readU32LE(encapsulated, at: 0) + guard botTag == 0xE000_FFFE else { + throw J2KDICOMPixelDataError.itemTagExpected(offset: 0) + } + let botLen = readU32LE(encapsulated, at: 4) + offset = 8 + Int(botLen) + guard offset <= encapsulated.count else { + throw J2KDICOMPixelDataError.itemLengthOverrun( + itemOffset: 0, declaredLength: Int(botLen)) + } + + // Frame items — loop until Sequence Delimitation. + while offset + 8 <= encapsulated.count { + let tag = readU32LE(encapsulated, at: offset) + if tag == 0xE0DD_FFFE { + // Sequence Delimitation Item — length must be 0. + let delimLen = readU32LE(encapsulated, at: offset + 4) + if delimLen != 0 { + throw J2KDICOMPixelDataError.malformedSequenceDelimitation( + actualLength: delimLen) + } + break + } + guard tag == 0xE000_FFFE else { + throw J2KDICOMPixelDataError.itemTagExpected(offset: offset) + } + let frameLen = readU32LE(encapsulated, at: offset + 4) + let frameStart = offset + 8 + let frameEnd = frameStart + Int(frameLen) + guard frameEnd <= encapsulated.count else { + throw J2KDICOMPixelDataError.itemLengthOverrun( + itemOffset: offset, declaredLength: Int(frameLen)) + } + + // Slice the frame payload + strip a single trailing pad byte + // if it looks like one (EOC 0xFF 0xD9 at [-3..-1] of the + // payload means the last byte is pad). + var frameBytes = encapsulated.subdata(in: frameStart..= 3 { + let last3 = frameBytes.suffix(3) + let bytes = Array(last3) + if bytes[0] == 0xFF && bytes[1] == 0xD9 && bytes[2] == 0x00 { + frameBytes = frameBytes.dropLast() + } + } + frames.append(frameBytes) + + offset = frameEnd + } + + return frames + } + + // MARK: - Private helpers + + private static func readU32LE(_ data: Data, at offset: Int) -> UInt32 { + precondition(offset + 4 <= data.count) + let b0 = UInt32(data[data.startIndex + offset]) + let b1 = UInt32(data[data.startIndex + offset + 1]) + let b2 = UInt32(data[data.startIndex + offset + 2]) + let b3 = UInt32(data[data.startIndex + offset + 3]) + return b0 | (b1 << 8) | (b2 << 16) | (b3 << 24) + } +} diff --git a/Tests/J2KDICOMHelpersTests/V10_31_PixelDataEncapsulationTests.swift b/Tests/J2KDICOMHelpersTests/V10_31_PixelDataEncapsulationTests.swift new file mode 100644 index 0000000..3333122 --- /dev/null +++ b/Tests/J2KDICOMHelpersTests/V10_31_PixelDataEncapsulationTests.swift @@ -0,0 +1,228 @@ +// V10_31_PixelDataEncapsulationTests.swift +// +// v10.19.0 — J2KDICOMHelpers Phase 2 parity gate. +// +// Verifies J2KDICOMPixelDataEncapsulator + J2KDICOMPixelDataDecapsulator +// produce byte-correct DICOM Pixel Data Item sequences per PS3.5 §A.4 +// and that the round-trip (encapsulate → decapsulate) preserves the +// J2K codestream bit-exactly. + +import XCTest +import Foundation +@testable import J2KDICOMHelpers + +final class V10_31_PixelDataEncapsulationTests: XCTestCase { + + // MARK: - Test fixtures + + /// Synthetic J2K codestream-like bytes (not a real J2K codestream — + /// these tests verify the DICOM byte layout, not codec behaviour; + /// the encapsulator + decapsulator are codec-agnostic). + private func makeFakeCodestream(byteCount: Int, signature: UInt8) -> Data { + // Start with SOC marker (0xFF 0x4F), end with EOC (0xFF 0xD9) to + // exercise the EOC-then-pad detection in the decapsulator. + var bytes = Data(count: byteCount) + bytes[0] = 0xFF + bytes[1] = 0x4F + for i in 2..<(byteCount - 2) { + bytes[i] = signature &+ UInt8(i % 256) + } + bytes[byteCount - 2] = 0xFF + bytes[byteCount - 1] = 0xD9 + return bytes + } + + // MARK: - encapsulateItem + + func testEncapsulateItemEvenLengthCodestream() { + let codestream = makeFakeCodestream(byteCount: 64, signature: 0xAA) + let item = J2KDICOMPixelDataEncapsulator.encapsulateItem(codestream) + + // 4-byte tag + 4-byte length + 64-byte payload (no pad) + XCTAssertEqual(item.count, 8 + 64) + + // Item tag FFFE,E000 written little-endian + XCTAssertEqual(item[0], 0xFE) + XCTAssertEqual(item[1], 0xFF) + XCTAssertEqual(item[2], 0x00) + XCTAssertEqual(item[3], 0xE0) + + // Length = 64 (LE) + XCTAssertEqual(item[4], 0x40) + XCTAssertEqual(item[5], 0x00) + XCTAssertEqual(item[6], 0x00) + XCTAssertEqual(item[7], 0x00) + + // Payload bytes match + XCTAssertEqual(item.subdata(in: 8..<(8 + 64)), codestream) + } + + func testEncapsulateItemOddLengthAddsPadByte() { + let codestream = makeFakeCodestream(byteCount: 65, signature: 0xBB) + let item = J2KDICOMPixelDataEncapsulator.encapsulateItem(codestream) + + // 4-byte tag + 4-byte length + 65-byte payload + 1-byte pad + XCTAssertEqual(item.count, 8 + 66) + + // Length = 66 (LE) — includes the pad + XCTAssertEqual(item[4], 0x42) + XCTAssertEqual(item[5], 0x00) + + // Last byte is the 0x00 pad + XCTAssertEqual(item.last, 0x00) + + // First 65 payload bytes match the codestream + XCTAssertEqual(item.subdata(in: 8..<(8 + 65)), codestream) + } + + // MARK: - encapsulateFrames + + func testEncapsulateSingleFrameWithEmptyBOT() { + let frame = makeFakeCodestream(byteCount: 64, signature: 0xCC) + let seq = J2KDICOMPixelDataEncapsulator.encapsulateFrames([frame], includeBOT: false) + + // Layout: BOT(8) + frame(8+64) + delim(8) = 88 + XCTAssertEqual(seq.count, 8 + (8 + 64) + 8) + + // BOT item tag at offset 0, length 0 + XCTAssertEqual(seq[0], 0xFE); XCTAssertEqual(seq[1], 0xFF) + XCTAssertEqual(seq[2], 0x00); XCTAssertEqual(seq[3], 0xE0) + XCTAssertEqual(seq[4], 0x00); XCTAssertEqual(seq[5], 0x00) + XCTAssertEqual(seq[6], 0x00); XCTAssertEqual(seq[7], 0x00) + + // Sequence Delimitation Item at end: FFFE,E0DD, length 0 + let delimStart = 8 + (8 + 64) + XCTAssertEqual(seq[delimStart], 0xFE) + XCTAssertEqual(seq[delimStart + 1], 0xFF) + XCTAssertEqual(seq[delimStart + 2], 0xDD) + XCTAssertEqual(seq[delimStart + 3], 0xE0) + XCTAssertEqual(seq[delimStart + 4], 0x00) + XCTAssertEqual(seq[delimStart + 5], 0x00) + XCTAssertEqual(seq[delimStart + 6], 0x00) + XCTAssertEqual(seq[delimStart + 7], 0x00) + } + + func testEncapsulateMultipleFramesWithPopulatedBOT() { + let f0 = makeFakeCodestream(byteCount: 64, signature: 0x10) + let f1 = makeFakeCodestream(byteCount: 80, signature: 0x20) + let f2 = makeFakeCodestream(byteCount: 32, signature: 0x30) + let seq = J2KDICOMPixelDataEncapsulator.encapsulateFrames([f0, f1, f2], includeBOT: true) + + // BOT = 8 + (3 × 4) = 20 + // Frame items: (8+64) + (8+80) + (8+32) = 200 + // Delim = 8 + // Total: 228 + XCTAssertEqual(seq.count, 20 + 200 + 8) + + // BOT length = 12 (LE) + XCTAssertEqual(seq[4], 0x0C); XCTAssertEqual(seq[5], 0x00) + + // BOT entries (LE u32) — offsets relative to start of first frame item: + // frame 0 at offset 0 + // frame 1 at offset 72 (= item0 length: 8-byte hdr + 64-byte payload) + // frame 2 at offset 160 (= 72 + 88; item1 length: 8 + 80) + XCTAssertEqual(UInt32(seq[8]) | (UInt32(seq[9]) << 8) | (UInt32(seq[10]) << 16) | (UInt32(seq[11]) << 24), 0) + XCTAssertEqual(UInt32(seq[12]) | (UInt32(seq[13]) << 8) | (UInt32(seq[14]) << 16) | (UInt32(seq[15]) << 24), 72) + XCTAssertEqual(UInt32(seq[16]) | (UInt32(seq[17]) << 8) | (UInt32(seq[18]) << 16) | (UInt32(seq[19]) << 24), 160) + } + + // MARK: - decapsulateFrames + + func testDecapsulateRoundTripSingleFrame() throws { + let frame = makeFakeCodestream(byteCount: 100, signature: 0xDD) + let seq = J2KDICOMPixelDataEncapsulator.encapsulateFrames([frame], includeBOT: false) + let recovered = try J2KDICOMPixelDataDecapsulator.extractFrames(seq) + + XCTAssertEqual(recovered.count, 1) + XCTAssertEqual(recovered[0], frame, "Single-frame round-trip must be byte-exact.") + } + + func testDecapsulateRoundTripMultipleFrames() throws { + let frames = [ + makeFakeCodestream(byteCount: 64, signature: 0x10), + makeFakeCodestream(byteCount: 80, signature: 0x20), + makeFakeCodestream(byteCount: 32, signature: 0x30), + ] + let seq = J2KDICOMPixelDataEncapsulator.encapsulateFrames(frames, includeBOT: true) + let recovered = try J2KDICOMPixelDataDecapsulator.extractFrames(seq) + + XCTAssertEqual(recovered.count, frames.count) + for (i, (orig, rec)) in zip(frames, recovered).enumerated() { + XCTAssertEqual(rec, orig, "Frame \(i) round-trip must be byte-exact.") + } + } + + func testDecapsulateStripsPadByteFromOddLengthFrame() throws { + // 65-byte codestream gets padded to 66 bytes in the Item; + // decapsulator must detect EOC-then-pad and strip the 0x00. + let frame = makeFakeCodestream(byteCount: 65, signature: 0xEE) + let seq = J2KDICOMPixelDataEncapsulator.encapsulateFrames([frame], includeBOT: false) + let recovered = try J2KDICOMPixelDataDecapsulator.extractFrames(seq) + + XCTAssertEqual(recovered.count, 1) + XCTAssertEqual(recovered[0].count, 65, "Decapsulator must strip the trailing pad byte.") + XCTAssertEqual(recovered[0], frame) + } + + // MARK: - Decapsulator error paths + + func testDecapsulateRejectsTruncatedInput() { + XCTAssertThrowsError(try J2KDICOMPixelDataDecapsulator.extractFrames(Data())) { error in + XCTAssertEqual(error as? J2KDICOMPixelDataError, + .truncated(needed: 8, got: 0)) + } + XCTAssertThrowsError(try J2KDICOMPixelDataDecapsulator.extractFrames(Data([0xFE, 0xFF, 0x00]))) { error in + XCTAssertEqual(error as? J2KDICOMPixelDataError, + .truncated(needed: 8, got: 3)) + } + } + + func testDecapsulateRejectsInvalidBOTTag() { + // 8 bytes starting with the WRONG tag (0xDEAD instead of FFFE,E000) + let bad = Data([0xAD, 0xDE, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) + XCTAssertThrowsError(try J2KDICOMPixelDataDecapsulator.extractFrames(bad)) { error in + if case .itemTagExpected(let offset) = error as? J2KDICOMPixelDataError { + XCTAssertEqual(offset, 0) + } else { + XCTFail("Expected itemTagExpected(offset: 0), got \(error)") + } + } + } + + func testDecapsulateRejectsBOTLengthOverrun() { + // BOT item with declared length > available bytes + var bad = Data() + bad.append(contentsOf: [0xFE, 0xFF, 0x00, 0xE0]) // BOT tag + bad.append(contentsOf: [0xFF, 0xFF, 0x00, 0x00]) // length 65535 — overruns + XCTAssertThrowsError(try J2KDICOMPixelDataDecapsulator.extractFrames(bad)) { error in + if case .itemLengthOverrun = error as? J2KDICOMPixelDataError { + // expected + } else { + XCTFail("Expected itemLengthOverrun, got \(error)") + } + } + } + + func testDecapsulateEmptySequenceReturnsNoFrames() throws { + // BOT with length 0 + Sequence Delimitation + var seq = Data() + seq.append(contentsOf: [0xFE, 0xFF, 0x00, 0xE0, 0x00, 0x00, 0x00, 0x00]) // BOT len 0 + seq.append(contentsOf: [0xFE, 0xFF, 0xDD, 0xE0, 0x00, 0x00, 0x00, 0x00]) // Delim + + let frames = try J2KDICOMPixelDataDecapsulator.extractFrames(seq) + XCTAssertEqual(frames.count, 0) + } + + // MARK: - Public-API smoke + + func testJ2KDICOMPixelDataErrorIsEquatable() { + // Compile-time check that the error type is Equatable + Sendable + // (since the tests above pattern-match on it). + XCTAssertEqual( + J2KDICOMPixelDataError.truncated(needed: 8, got: 0), + J2KDICOMPixelDataError.truncated(needed: 8, got: 0)) + XCTAssertNotEqual( + J2KDICOMPixelDataError.truncated(needed: 8, got: 0), + J2KDICOMPixelDataError.itemTagExpected(offset: 0)) + } +}