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)) + } +}