All notable changes to DICOMKit will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Work in progress, not yet committed.
"Apply this window to all cells" was a one-shot copy made after the fact. Judging a film means comparing its cells, and cells can only be compared when they are shown alike — which meant repeating every zoom by hand, cell by cell.
- The tools are back on a rail, and the locks are on it too. Window/level,
zoom, pan, text, arrow and invert (
V) sit down the left of the film with the three locks, the scope and the two resets. A right-click menu is a fine place for a command and a poor place for a mode: which tool is armed and which cells are linked have to be answerable at a glance, and a menu that only exists while it is open cannot answer them. The menu keeps everything it had. - The lock is drawn on the cells it applies to. A closed padlock appears on every cell that will move when the focused cell is dragged — and on none that will not, which is how the scope becomes visible before the drag rather than after it. It is the one thing the preview draws over a picture that the film will not carry, sits in the corner over the letterbox margin, and takes no clicks.
- A mode, not a command.
PrintCellSyncOptions— Zoom & Pan, Window, Invert — are locks on that rail. While one is on, dragging a cell carries that adjustment to the others as the gesture happens. Zoom and pan are one switch deliberately: cells that magnify together but sit over different anatomy are the confusing state, not one anybody asks for. Rotation and flip are not offered: they are how one image is put the right way up, and turning the whole sheet because one cell was upside down is never what was meant. - A way back out. Reset Cell and Reset All sit beside the links (
0and⇧0), because one linked drag can now put a whole sheet wrong and undoing it must not be harder than causing it. The cell menu's reset still acts on the cell that was right-clicked, and names itself so. - A dragged cell keeps up with the drag. A window/level drag re-keys the
cell on every mouse event, so its GPU render nearly always landed after its own
key had been superseded — and
PrintCellTextureCachethrew those renders away, leaving the cell being dragged showing the picture the drag started from. A superseded render now stands in for the mark it belongs to, so the cell tracks the drag a dispatch behind instead of stalling. - Geometry copies absolutely, windowing carries relatively. Every cell on a film is the same size, so the same zoom and pan is the same picture, and each peer's pan is then re-held inside its own image. A window is not portable that way — a film mixes modalities, and marks do not all state their window in the same space — so a drag scales each cell's width by the same factor and shifts its centre by the same fraction of a width. Presets are the exception: "lung" names a tissue, so it is copied as the numbers it is.
- Scope, because a film is not always one series. All Cells / Same Series / This Film, defaulting to Same Series — a film carrying two series is usually carrying them for comparison. Marks now carry their Series Instance UID for this; marks made without opening a file group by the folder they came from.
- It yields to the job. With raw pixels or a job-wide window on, nothing per-cell reaches the film, so the window link reads and behaves as off rather than claiming an effect the job has already taken away. Marks never opened are given their file's own window when the link goes on, so they move with the rest instead of sitting still.
Work in progress, not yet committed.
Identification was burned under every image, which is right for a sheet that mixes studies and repetitive noise on a sheet that does not: sixteen cells of one CT carried the same name sixteen times, each one costing its picture a strip of height. With multi-study selections coming, "print this film" has to answer both cases.
- The rule, in one place.
FilmIdentificationPlannerdecides per film, not per job: a sheet whose captioned images all share one Study Instance UID states the patient once along its bottom edge; a sheet mixing studies captions each image, as before. A job spilling onto a second sheet has each sheet decided on its own, because each sheet has to be identifiable on its own. The UID is the test rather than the caption text — two studies of one patient on the same day read identically and are still two studies. An image whose header could not be read has no study to agree with, so it forces per-image captions. - The strip is kept clear of the pictures.
FilmCellLayouttakes a footer band out of the sheet before it lays out cells, so the footer sits under the bottom row rather than across it — text over anatomy is where a finding hides. Annotation boxes generally now get that band too; they used to be drawn into the bottom margin over whatever was there, and they are centred rather than flush left. - It reaches real film three ways. Composed sheets (Save Film, the printer
emulator) draw the footer themselves. On the wire it goes as a film-level Basic
Annotation Box —
PrintOptions.filmAnnotationscarries a set per film, so two sheets of one job can name two patients, which one job-wide list could not. - Whether the printer can carry it is asked, not assumed. The first cut
looked at whether an Annotation Display Format ID had been typed into the
settings sheet, so every printer that had not been configured by hand — which
is every printer, by default — silently fell back to captioning each image and
the film never matched the preview. Support is now a question about the
association:
DICOMPrintService.supportsAnnotationBoxesopens one, asks whether the Basic Annotation Box SOP Class was accepted, and releases. It is asked before the frames are prepared, because the answer decides whether they are captioned. A printer that takes annotation boxes but has no configured format ID gets a plain default (ANNOTATION), since a film box cannot carry annotation boxes without one; a printer that refuses the film box over that value has it created again without it rather than losing the job, and the progress line says the film will carry no annotation text. A printer that takes no annotation boxes at all — or cannot be reached — still gets the caption burned under each image: a film with no name on it is worse than a film that repeats one. - The footer's type is sized off the film. It was a flat 3 mm, which is oversized on an 8×10 held in a hand and lost on a 14×17 across a viewing room. It is now 1.1% of the sheet's height, floored at 2.5 mm and capped at 6 mm — ~2.8 mm on 8×10 and ~4.75 mm on 14×17 — and the strip reserved for it, in the composer and in the preview alike, follows the type rather than a constant.
- Choosable, in the preview and in the settings column. Automatic (the rule above), "Under each image", or "Once at the foot of the film". The preview draws whichever the film will carry, scaled off the physical sheet, so the strip that is approved is the strip that prints.
Work in progress, not yet committed.
- A launch shows the library and nothing else. Print Preview and Printer
Emulator are singleton
Windowscenes, so macOS restored whichever was open at quit — the preview holding no marks and the emulator with its server stopped, because neither survives the app. Both now carry.restorationBehavior(.disabled)and.defaultLaunchBehavior(.suppressed); they open when they are asked for and not before. - The console log column is as wide as the reader wants it. It was a fixed 300
points, which wrapped every import path and print job UID into four lines. It
now opens at 460 and is dragged from the divider between it and the film, up to
60% of the panel and never below 260; the width is kept in
AppStorage, so it is chosen once rather than every job. The film takes back whatever the log is not using, as it did before. - The film can be saved as a file. "Save Film" in the preview's header writes
PNG, TIFF or PDF. Not a screenshot of the preview: the images go through the
same
PrintService.preparea real print sends them through and the sheet comes out of the sameFilmComposerthe printer emulator composes received film with, so what lands on disk is the sheet the printer would have laid down — identification band, drawn annotations, spillover and all. A PDF holds every film of the job as a page; PNG and TIFF write one file per sheet.
Work in progress, not yet committed.
PS3.3 C.13.3 lets a film's rows hold different numbers of images: ROW\1,3 is a
scout above three slices, COL\1,2 is one image beside two. The Print SCP has
always understood those forms — it has to, modalities send them — but everything
that composes a film here could only say rows × columns, so the one layout a
reader most often wants for a comparison film could be received and not sent.
- Seven band layouts are in the gallery, drawn.
ROW\1,2,ROW\2,3,COL\1,2,COL\1,3,COL\1,4,COL\1,4,4andCOL\2,4,4are picked the way the grids are — by looking at the film rather than reading a string — and live inPrintBandLayoutinPrintOptionCatalog, the one table the print sheet anddicom-print --layoutboth read. - The print sheet takes a format string too. Under the bands is a Custom field: type an Image Display Format and the film beside it is redrawn as it is typed, with the layout named in words underneath ("rows of 1, 3 — 4 images"). Picking a band fills that same field, so the layout in force can always be read as the string that will be sent, and adjusted from there. Text that is not a format says so in red and leaves the film on the automatic grid rather than quietly printing 1×1, which is what a lenient parse would have made of half-typed input. The gallery scrolls now that it holds four sections.
- The preview draws the film, not a grid.
FilmPreviewViewlays its cells out withFilmCellLayout— the same geometry the SCP composes received film with — instead of a SwiftUIGrid, so a band layout is shown as the bands it is. Every per-cell measurement (zoom and pan limits, annotation anchors, the identification strip) now comes from that cell's own rectangle; it used to come from one film-wide size, which was only ever right because the cells were all the same. Arrow-key navigation follows the cells' geometry for the same reason. - The format reaches the printer verbatim.
PrintLayoutSelectiongained a.displayFormatcase,PrintJobRequest/PrintPlancarry the format and count films by its image-box count, and the SCU sends the string as written. Verified end to end againstdicom-printscp:--layout 'ROW\1,3'composes one image over three,'COL\1,2'one beside two,'COL\1,4,4'one beside two columns of four. dicom-print --layoutaccepts both. A grid token ("2x3") as before, or a format ('ROW\2,1,2', quoted so the shell keeps the backslash); the named bands are listed in its help. The dry-run banner and the film plan name a band layout by its format string — reportingROW\1,1as a 2×1 grid would have misstated the film.PrintImageDisplayFormatgainedvalidated(_:)(strict, for UI and command lines), an initializer from aPrintLayout,isUniformGrid, andsummary.
Work in progress, not yet committed.
Opening a study put four columns on screen before the first picture: the app's feature list, the series pane, the images, and an empty selection tray. Two of them were answering questions nobody had asked yet.
- The feature sidebar steps aside on the way into the viewer and comes back on
the way out (
MainViewdrivescolumnVisibility). The toolbar toggle still opens it over the images, and that choice holds until the viewer is left. Launching straight into the viewer starts collapsed too. - The selection tray starts hidden (
isPrintTrayVisiblenow defaults tofalse) and comes up on its own the moment the first image is marked — every marking path, the library's "Print…" included, goes throughrevealPrintTray(). Unmarking never puts it away again, so the "Clear" button cannot vanish under the pointer; opening a different study does, along with the marks it held. - The current series is marked by one cue, on the card: a neutral white ring and a lifted surface. Nothing is drawn on the thumbnail — a ring there framed the letterboxing rather than the picture — and the accent stays out of the pane entirely, because in the viewer it means "this is what prints".
- In the grid, a marked tile no longer carries an accent edge along its bottom. The chip and the tick already say it, and with a film fully composed the edge drew a blue rule under every tile; the only accent edge left in the grid is the focus ring, so exactly one tile is lit — the one being worked on.
Work in progress, not yet committed.
The series pane, the reading area and the selection tray all sat on the same near-black surface, so nothing on screen said which column held the images that were about to print:
- Three planes instead of one. The panes are a lighter surface
(
StudioColors.viewerPanel) with a dark seam on the side facing the picture; the gutter is darker than before (0.14 → 0.10); the reading area keeps its pure black and gains a shadow that lifts it off the mount. - Every column is titled. A shared
ViewerPaneHeadernames the panes ("Series", "On film" with its count); the reading area's own strip names it, shows the tile layout, and reports "N of M on film" — the count of images on screen that are marked, so the middle column visibly owns the print selection. - The reading area's frame is thicker and keeps the accent while it holds the keyboard, in a grid as well as at 1×1.
- Marked tiles are readable across a whole grid: a numbered film-position chip in the top-left corner (the number the tray lists it at) and an accent bar along the bottom edge. The border stays focus-only — the live tile's ring went 2 pt → 3 pt, and hovering brightens a tile's hairline.
- The patient plate in the series pane is neutral rather than accent-tinted: in the viewer the accent now means "this is what prints".
Work in progress, not yet committed.
-
The preview's cells now draw from GPU textures (
PrintCellTextureCache), because the preview is where the tools are used: window/level, zoom, pan, rotate, flip and invert were each a CPU re-render per mouse delta. The arrangement is the display shader's transform, so those drags now re-draw a quad and render nothing; only a window change makes a new texture, and that is one GPU dispatch. A re-windowed cell holds its previous texture while the new one renders, so a drag never falls back to the CPU. Falls back per cell — overlay planes, YBR, an unresolvable window, no Metal — never per film. Only the film on screen holds textures. -
The shader is given the film's geometry, not the viewer's: new
DisplayPresentation.sourceRegionfits and centres the region thatViewerPresentation.visibleRegion— the same call the print path makes — says will be cropped, and flips after the rotation asPrintPresentationTransformdoes. The viewer's own transform agrees with the printer only while a cell is merely zoomed, so reusing it would have misreported every rotated or edge-panned cell. -
Film composition and export are unchanged and still CPU, as the GPU plan fences them: preview and film agree because neither invents anything the other does not.
-
On macOS the print preview opens in its own window (
StudioWindowID.printPreview) instead of a modal sheet over the viewer, so the film can be compared with the images it was made from, moved to a second display, or zoomed to fill one. ⌘P and the library's "Print…" both raise it;PrintScreenPresenterkeeps the sheet on platforms without windows. -
PrintSettingsViewtakes apresentation(.sheet/.window): a sheet is still given a fixed size, a window only a minimum, so the user's own size sticks. -
The Print screen gained a "Print Preview" button that raises the window — the preview no longer has to be re-opened from the viewer once it has been closed.
-
Opening a study closes the print preview window along with clearing the marks: the film on it was composed from the study being left.
prepareForNewStudy()bumpsprintScreenDismissRequests, which the shell turns into adismissWindow— watched there rather than in the viewer, since the study may be opened while the library is still on screen. -
The print log console starts closed and opens by itself when a job starts, giving the film its width while the film is what is being judged. It is put away again on "Print Again", and the header toggle still overrides both.
Full GPU rendering pipeline for the viewer, landing GPU plan milestones M0–M7
(see GPU_RENDERING_PLAN.md):
- New
DICOMRenderKittarget: a Metal compute pipeline that windows/LUTs a decoded frame directly into aCGImage-readable texture with zero-copy UMA input and no upload/render/readback round trip. Monochrome and RGB/palette kernels; YBR stays on the CPU. Output is byte-for-byte identical to the CPU path (MetalCPUEquivalenceTests), so the two are freely interchangeable. - Shared
WindowLUT(integer, not float shader math) backs both the CPU and GPU paths — 10–19× faster monochrome rendering on its own (M0+M1), before any GPU work. - Focused-viewport direct-to-display path keeps the frame on the GPU across tool actions (window/level, invert, zoom, pan): 0.008 ms per action, down from a full re-render (M5). The per-drag decode is cached too, up to 675× faster per step.
minimumGPUPixelCountdropped from 1 megapixel to 0 — no frame is declined for being small; the CPU is now purely the no-GPU fallback, not a size-selected alternative. Safe only because of the CPU/GPU output equivalence guarantee above.- Shipped the compute shader as
.metal.txtso the app builds without requiring the separate Metal Toolchain install in Xcode.
Work in progress, not yet committed.
- On-screen viewer tiles and the focused viewport now carry the traditional
four-corner reading-room annotation layout — size/window/cursor readout
(top left), patient/study identification (top right), zoom/position/
compression/geometry (bottom left), acquisition date/time (bottom right) —
composed by
ViewerAnnotationCorners/ViewerAnnotationTextand drawn by the newViewerAnnotationOverlayView. Detail scales down (.full→.reduced→.minimal) as a tile shrinks, keeping identification and position and dropping the rest rather than shrinking everything to illegibility. ViewerHoverGeometrymaps a cursor point back through the viewport's fit, zoom, pan, rotation and flip to the underlying image pixel — separately for the CPU/print transform order (pan before rotation) and the GPU display shader's order (pan after rotation), since they only agree when a picture is unrotated or unpanned — so the top-left corner can show the pixel value and patient-space position under the cursor. A wrong readout is worse than none, so every step returnsnilrather than guess when the point is off the picture.PatientIdentificationOverlayViewis now film/print-only — the on-screen viewer's old single-band overlay is replaced by the corner layout above, since on screen the reader can move the picture out from under the text but paper cannot.- New
ViewerTileTextureCacheextends the GPU display path (M5) to unfocused grid tiles: a texture keyed only on file + frame + window, so a synchronized zoom or window drag across a grid re-draws a quad per tile instead of re-rendering one. This is the GPU-tile work M6 explicitly deferred ("ViewerTileImageCache... stay on the readback path") — tiles now have both a CPU image and, where Metal is available and the frame supports it, a GPU texture, falling back to the CPU image per tile (overlay planes, an unresolvable window, no Metal device) rather than per grid. - Also removed the unused Inspector-panel toggle from
MainView/MainViewModel(dead code, unrelated cleanup).
Deleted the whole Tier-2 CLI-parity subsystem: the "CLI Parity" Studio screen and its
engine/comparators, the orphaned CLI Automation Testing screen, bundled synthetic
fixtures and goldens, the cli-parity-gen/cli-parity-docs/studio-cli-introspect
dev targets, all CLIParity*Tests suites plus the StudioParityTests target, the
"Tier-2 Output Parity Gate" CI job, and every APP_CLI_*PARITY*/docs/cli-parity/
doc — 103 files, ~14,400 lines of code, ~5,100 lines of docs, 1.1 MB of fixtures.
- Kept deliberately:
CLIToolTerminalCompare+CLIToolBuilder(reachable only from code now that the Workshop's comparator panel is gone) and thesyn-ct.dcmfixture the print tests depend on, nowTests/DICOMStudioTests/Fixtures/syn-ct.dcmvia a newStudioTestFixtureshelper. APP_CLI_SHARED_API.mdupdated to describe verification as it stands today: the oracle-based round-trip suite (DICOMRoundTripTests) plus, per tool, an optional shared*Consoletype.
dicom-split, dicom-merge and dicom-script were the last three tools whose
Workshop executors shared only the engine while duplicating console text and input
parsing. Text and parsing now live once in DICOMKit — SplitConsole, MergeConsole,
ScriptConsole — pinned by Tests/DICOMRoundTripTest/SharedConsoleParityTests.swift.
Fixed real drift found in the process: the app's banners had dropped the CLI version
string, dicom-merge relabelled its input-count line and echoed --format values as
enum case names instead of the CLI's raw text, and dicom-split's frame parser
silently accepted whitespace-only components the CLI rejected.
A dcm4che-based Print SCU failed at N-ACTION with 0x0112 "Unknown Film Session" and
never produced a film. PS3.7 10.1.5 lets the SCU supply the Affected SOP Instance UID on
N-CREATE, and such SCUs then address every follow-up N-SET / N-ACTION by their UID
regardless of what the response carries — while our SCP minted and stored its own.
PrintSCP.assignedUID(requested:)stores the object under the SCU's UID whenever one is supplied, for Film Session, Film Box, Presentation LUT and Basic Annotation Box N-CREATE; it falls back to the generator otherwise. A supplied UID must be non-empty after trimming NUL/space padding, ≤ 64 characters, and digits-and-dots only, so a malformed value can never become a stored key.- Film Box N-CREATE now rejects a UID already in use with
0x0111(Duplicate SOP Instance) — unreachable while the SCP minted its own. - Image Box UIDs remain SCP-allocated; they are created implicitly with the film box.
PrintSCPStatusMatrixTests.testSCUSuppliedSOPInstanceUIDsAreHonoredreplays the field sequence end to end and asserts the composed film carries the SCU's UIDs.PRINT_CONFORMANCE.md§2.1 / §4 andDICOM_PRINT_SCP_PLAN.mdupdated.
Milestones E and F of DICOM_PRINT_SCP_PLAN.md — the last two rows in that plan's
matrix. The app surface came first, so the sharing ran in the direction opposite
every other print entry: settings, assembly and wording were moved out of
DICOMStudio into DICOMPrintKit, and both the Studio screen and the CLI now
consume rather than own them.
PrintSCPSettings/PrintSCPService/PrintSCPConsole/PrintSCPSimulator(DICOMPrintKit/Printing/): every configuration knob and itsPrintSCPConfiguration/FilmComposerConfigurationmapping, the sink stack and server assembly, the wording of every event/film/startup line, and a no-network film simulator, in one place — Studio's view model now owns only what a window owns (retained films, selection, button state).dicom-printscp(Sources/dicom-printscp/):serve(default) /simulate/status/queues.simulatetakes DICOM files rather than afilm.jsondescriptor as originally sketched — the composer needs real pixels, and routing throughPrintImagePreparermeans a simulated sheet is built by the same code a live SCU's job is.- Studio's "Printer Emulator" (
PrintSCPView,PrintSCPViewModel,PrintSCPSettingsStorageService): a persistentWindow(not aWindowGroup— one emulator, one listener) reachable from the sidebar while a print is in flight from the main window. - Two defects a live SCU→SCP run caught, both fixed in the shared core:
--max-filmscounted off the screen stream (delivered the instant a sheet is composed) instead of.filmPrinted, so the listener could stop mid-N-ACTION and abort the SCU's association before its PNG was written; and the association log line rendered the port as:0becauseAssociationInfo.remotePortis always 0 for this SCP and the whole endpoint is inremoteHost— fixed once in the shared console.
Plan: DICOM_PRINT_SCP_PLAN.md ("Milestone F as built" section has the full
shared-type table). Not in the CLI-parity harness (print tools are covered by
dedicated tests instead, per APP_CLI_SHARED_API.md).
- Overlay plane rendering (
DICOMKit/OverlayPlaneRenderer.swift): PS3.3 C.9.2 group-60xx overlay bitmaps, read and drawn both as aCGImagecomposite (viewer) and burned into raw samples (film). Written for Siemens' "Patient Protocol" Secondary Capture, whose Pixel Data is entirely zero and whose whole content lives in a 1-bit overlay — previously rendered as a black square, now shown/printed correctly. Wired into bothImageViewerViewModel(render path) andPrintImagePreparer(so a film matches the screen it was approved on, except under--raw). - Print window resolution now falls back to the file's own VOI
(
PrintImagePreparer.resolvedWindow,PrintWindowSpace): a mark made without opening the file (whole-series or library print) previously auto-stretched a CT's full pixel range and left soft tissue in a handful of indistinguishable greys; it now falls back to the data set's VOI window like the viewer, export and tiles already do.PrintWindowSpace(.outputUnits/.storedValues) travels with an explicit window so a value taken off the viewer (stored units) is converted through Rescale Slope/Intercept before printing — sending it unconverted put the window entirely outside the pixels and printed a flat black cell. - Arrow geometry fully consolidated (
PrintArrowGeometry.swift): the fractional-of-image-height math for shaft width and head size, previously duplicated betweenImageAnnotationBurner(film) andFilmCellAnnotationLayer(preview), now lives once and both call it; the preview's arrow rendering also switched from a shadow-based halo to a stroked-outline halo so the head no longer blurs into the shaft and reads as a plain line. Selection handles on a selected arrow shrank to a small ring with a separately-sized (2x) invisible grab area, so a handle no longer hides the anatomy the arrow points at. - Viewer patient-identification header (
ImageViewerViewModel+PatientOverlay):patientIdentityLine("name, ID · CT / PT"),modalitiesForOverlay(every modality in the open study, series order, no repeats — a study is routinely PET/CT or a CT with an SR), andstudyDescriptionSanitizedForOverlay(strips the caret/pipe/backslash separators a description arrives with, keeping only what reads as words). - Viewer toolbar reworked around click-armed tools: windowing and zoom are
now toggled tool buttons (highlighted while armed) rather than ⌥-drag/⌘-drag
modifiers, freeing ⌘-drag;
resetView()now also resets window/level and inversion, not just pan/zoom/rotation; the inline "Open DICOM File" button, file importer and ⌘O shortcut were removed from the viewer. - Tests:
PrintSCPSharedCoreTests(23),PrintSCPScreenTests(39),PrintWindowSpaceTests(9),OverlayPlaneRendererTests(17),PrintMarkWindowTests(5),ViewerIdentificationHeaderTests(5);PrintOverlayAnnotationTests,PrintCellEditingTests,NavigationServiceTests,ViewerTileLayoutTestsandPolishReleaseViewModelTestsupdated for the arrow-geometry, cell-editing-window and toolbar/reset-view changes.
Plan: DICOM_PRINT_STUDIO_PLAN.md §10.
- Patient identification burned into the film (
DICOMPrintKit/ImageAnnotationBurner.swift,PatientOverlayText,PatientOverlayTextCache,PatientIdentificationOverlayView): "name, ID, study date" over the study description, from one definition shared by viewer tiles and film cells. A DICOM printer draws identification from annotation boxes to its own layout and many ignore them, so the lines are burned into the prepared 8-bit frame instead — one bitmap pass per cell, applied last so a later crop or rotate cannot carry the caption with it, and skipped quietly on an unexpected pixel format rather than failing the print. The viewer draws it as a reserved band below the picture; the film preview overlays it, since there the cell is the film. - Drawn annotations on a film cell (
PrintOverlayAnnotation,PrintViewModel+Annotations,FilmCellAnnotationLayer): text and arrows, in coordinates normalized to the image and held per mark ID — so re-arranging the film, changing layout or printing to another sheet cannot move an arrow off the vessel it pointed at, and the 512-pixel preview and the 3000-pixel frame agree. The preview uses the burner's own arrow geometry. - Per-cell editing in the print preview (
PrintViewModel+CellEditing): window/level, zoom, pan, text and arrow tools writing back into the mark thatPrintService.preparereads, so the preview cannot drift from the film.FrameSourceCachekeeps decoded pixels so a window/level drag re-maps rather than re-decoding (tens of ms per event on a JPEG 2000 CT); cell renders moved 256 → 512 (at 256 half the grey levels being judged are lost to downscaling) and the previous rendering stands in during a gesture instead of a spinner. - Non-image instances open instead of failing (
ViewerContentKind,ViewerNonImageContent,ViewerNonImageContentView): SR read as a narrative, encapsulated PDF as pages, presentation states / KOS / raw data as named summaries. Previously a valid SR was reported as an "unsupported transfer syntax" — a decode failure for pixels it never claimed to have. - Library rows describe the study again (
StudyModel.merging,StudyRowSummary,LibraryModel,DICOMFileService): study fields are unioned across the files of a study rather than overwritten by whichever was read last; rows fall back to the series' modality and description, then patient ID / accession, before saying "Unknown"; series and instances sort by Series/Instance Number with unnumbered last and a total-order tiebreak (a series used to open on a different image on each read); empty studies are pruned; a DICOMDIR and any object with no SOP/Series Instance UID are refused at import instead of manufacturing an unopenable "Unknown Patient, 0 series, 0 images" row. - Reading with one hand on the keyboard: shared
ScrollWheelHandler(viewer pages images, film preview zooms cells, both live while the print sheet is over the viewer) with step accumulation for fine trackpad deltas; first/last jumps and opt-in wrap at series ends;ViewerPrintTrayView— the selection in film order, each row rendered with that mark's own window and arrangement, without opening the print sheet; ⌘/ keyboard-shortcut legend on both screens. - Tests:
PrintOverlayAnnotationTests,ImageAnnotationBurnerTests,PrintCellAnnotationTests,PrintCellEditingTests,PatientOverlayTextTests,PatientIdentificationOverlaySizingTests,ViewerContentKindTests,ViewerProtocolLineTests,LibraryStudyMergeTests,LibraryImportEndToEndTests,StudyRowSummaryTests,ScrollStepAccumulatorTests,ViewerTileLayoutTests— 4,335 tests in 394 suites green.
DICOMKit now implements Print Management (PS3.4 Annex H) in both roles. Any Print SCU —
modality, workstation or third-party tool — can associate, send a film session, film boxes
and image boxes, and issue N-ACTION print; the film is composed and handed to an output
sink. Conformance statement: PRINT_CONFORMANCE.md. Plan: DICOM_PRINT_SCP_PLAN.md.
- Protocol machine (
DICOMNetwork):DICOMPrintServeractor +PrintSCP.swift,PrintSCPTypes.swift,PrintSCPEncoder.swift— association accept/reject, called/calling AE checks, N-CREATE / N-SET / N-GET / N-ACTION / N-DELETE / N-EVENT-REPORT across Film Session, Film Box, Grayscale/Color Image Box, Printer, Print Job, Presentation LUT and Basic Annotation Box, with the PS3.4 H.4 lifecycle and cascade deletes. Configurable viaPrintSCPConfiguration(film sizes, medium types, colour, max boxes per film, annotation boxes, idle timeout, AE lists, printer identity). - SCP-direction dataset parsing (
PrintDatasetReader.swift,PrintSCPParser.swift): a real element walk for both Explicit and Implicit VR LE, including sequences with defined and undefined length and image-boxPixelData, mapped back onto the existingFilmSession/FilmBox/ImageBoxContent/PrintImageDatamodel. PrintImageDisplayFormat: parsesSTANDARD/ROW/COL/SLIDE/SUPERSLIDE/CUSTOM, driving image-box allocation, composer cell layout and the SCU's box maths.- Film composition (
DICOMPrintKit/Printing/):FilmGeometry(sheet sizes at a configurable DPI, cell layout, fitting),FilmComposer(image boxes → one page bitmap: magnification, polarity, LUT shape, border/empty density, trim marks) andComposedFilm. Four inversions compose correctly — MONOCHROME1, Polarity REVERSE, INVERSE / LIN OD Presentation LUT shape, and film emulation.DensityMappingis.paperDirectby default, with.filmEmulationopt-in rather than a silent guess. - Output sinks:
PrintOutputSinkprotocol withScreenSink(full-resolution stream + bounded downsampled scrollback; never blocks the SCP on a viewer that is not draining),PDFSink,ImageSink(PNG/TIFF),PaperPrinterSink(CUPSlp, opt-in) andCompositePrintSink;FilmComposingPrintHandlerwires the SCP to them. - Tests:
PrintSCPTests.swift(74 — parser round-trip in both VRs, encoder, loopback SCU → SCP, status matrix, robustness) andDICOMPrintKitTests(63 — geometry, composer, sinks, screen scrollback, DCMTK interop).
Automated in Tests/DICOMPrintKitTests/DCMTKInteropTests.swift (skips when DCMTK is absent):
dcmpsprt/dcmprscu → our SCP, and our SCU → dcmprscp (IHE Full profile).
- SCU proposes the meta and the individual SOP Classes and routes each N-service to an
accepted context (
PrintPresentationContexts). A printer that rejects the meta class was previously unusable. - Image Display Format order was a conformance bug: PS3.3 C.13.3 defines
STANDARD\C,Ras columns-first;PrintLayout.imageDisplayFormatemitted rows-first, so every non-square layout printed transposed on a conformant printer.PrintLayoutis now the single source of the string. - Configuration Information (2010,0150) threaded end to end — Studio and the CLI collected
it but
PrintOptionshad no field and it was never sent; several vendors require it. - 0x0106 reclassified as a failure (
DIMSEStatus) — it was treated as a warning, so a job sailed past a printer's rejection and failed later with a misleading code. - Trim (2010,0140) omitted when NO and Requested Decimate/Crop Behavior (2020,0040) omitted when DECIMATE — printers that do not implement them reject a box for merely carrying them.
- SCP idle-association timeout (default 300 s) so a vanished peer no longer holds a slot forever, and A-ASSOCIATE-RJ at capacity instead of dropping the socket.
- Error Comment (0000,0902) transliterated to ASCII —
CommandSetencodes as ASCII and silently dropped any value containing a non-ASCII character, discarding the only diagnostic an SCU developer gets.
- New target/product
DICOMPrintKit(DICOMCore + DICOMDictionary + DICOMKit + DICOMNetwork), consumed bydicom-printandDICOMStudio. The shared core cannot live in DICOMKit — it needs bothImagePreprocessorandPrintImageData— and putting networking into DICOMKit would force it on all ~30 CLIs. PrintJobRequest(every job knob as one value type, plusvalidate(),filmCount(for:)andPrintPlan),PrintOptionCatalog(one table of selectable values per option, feeding both UI pickers and the CLI's arg enums),PrintImagePreparer,PrintWorkflow(preflight, retries, events, progress, job/printer status) andPrintConsoleFormatter(text + JSON).dicom-printrefactored onto all of the above — arg declarations, help text and output unchanged;PrintCLIEndToEndTests,PrintServiceTestsandPrintSCPIntegrationTestsstay green.- Multi-film gap fixed:
PrintResultnow carriesfilmBoxUIDsandprintJobUIDs(the singular properties remain as last-element accessors), so per-film job-status polling and job history work for multi-film jobs.
Library → viewer → mark images → print. Plan and full file map:
DICOM_PRINT_STUDIO_PLAN.md.
- Marking in the viewer: ordered, frame-level
PrintSelectionModel; M marks the current image, ⌘P opens the print sheet, a badge shows the count, a capsule shows the film position, and the context menu carries mark-all-frames / whole-series / clear. - Print settings sheet: printer picker with Test (C-ECHO) and Status (N-GET), layout (auto | grid | preset), film size, orientation, copies, a live film preview showing spillover, a marks tray, and an Advanced disclosure covering the rest of the CLI surface. (Reworked 2026-07-29 — see below.)
- Printer management:
PrinterProfile+PrinterProfileStorageService(printer-profiles.json), modelled on the existing PACS server profiles. The CLI's~/.config/dicomkit/printers.jsonregistry stays independent by design — a sandboxed app cannot reach it. - Execution:
PrintViewModel+ StudioPrintServiceoverDICOMPrintKit, determinate per-image-box progress, live N-EVENT-REPORT console, cancel with film-session cleanup, per-film Check Status, and job history persisted toprint-job-history.json. - New
Printnavigation destination (printers + job history) and a Print… action on a study/series in the library, which adds those files to the selection without discarding marks already made in the viewer.
- Multi-valued VOI no longer discarded (
DICOMImageExporter.determineWindowSettings): a CT that carries a lung and a soft-tissue window in one element (-600\50/1200\350) fell throughwindowSettings()— which parses a single DS — and was auto-stretched over the full pixel range. The multi-valued form is read first and its first pair, the default presentation, wins. - One window policy everywhere:
ImageViewerViewModelnow adopts a file's default window throughdetermineWindowSettings(the same call the exporter, tile cache and film use), andFrameRenderer's fallback ladder goes through it too instead ofrenderFrameWithStoredWindow, which hands a raw HU centre to a renderer reading stored values and washes a CT with a large Rescale Intercept out to white. - A window no longer follows the user across a hang:
ViewerCellState.windowCenter/Widthare optional,nilmeaning "this image's own VOI". A freshly hung tile starts at its own VOI, and the viewer's window is inherited only by tiles in the same series — filling a grid from a flat file list used to stamp one kernel's window across an MPR and every other reconstruction in the study. - Series pane in series-number order: ascending, unnumbered series last (the library's ordering treated a missing number as 0, filing them ahead of series 1), ties broken by title then UID so the pane cannot reshuffle between two reads. Cards show the series number badge, and VoiceOver announces "Series 4, …".
- Print sheet reworked: options moved from a 320 pt left column into a band across the top
so the film preview owns the whole centre; the sheet opens at the size of the window it was
raised from; Advanced expands sideways under a height cap instead of pushing the film off
the bottom; the marks tray became a "Show List…" popover, since film order now follows the
viewer's tile order (
syncPrintOrderToViewer) rather than the order the boxes were ticked.
ViewerPresentation+PrintPresentationTransform(DICOMPrintKit): a mark now records the viewer's arrangement as geometry over the source image — zoom, pan, viewport, quarter turns, flips, invert — and the print path crops, permutes and negates the full-resolution frame accordingly. Nothing resamples, so a zoomed print carries the modality's real detail rather than an upscaled copy of what the monitor showed.- Film preview shows the actual frames:
FrameRenderer+FrameImageStore+PrintThumbnailCacherender each marked frame as it will print. Cache keys include the whole arrangement, so two marks of the same frame at different zooms are two pictures. ImageInversion: the viewer inverts the rendered frame rather than negating the VOI window, which is not equivalent once Rescale Slope/Intercept or a signed representation are involved; the print path inverts P-values directly.
- Tile grid 1×1 … 4×4 whose cells map to film cells in the same order. The focused tile is the live view model, so gestures, window/level and cine behave exactly as at 1×1; every tile keeps its own file, frame, window, zoom/pan, rotation, flips and inversion. Unfocused tiles are cached renders keyed on full tile state, so panning one tile does not re-decode the others.
- Series pane: every series of the open study as a card (thumbnail, description, objects and frames, current/visited state). Drag a card onto a tile, or select a tile and double-click, to hang a different series there. Built from indexed library metadata, with orientation read from one file per series afterwards and folded in.
- Arrow-key navigation by image, not by file: frames first, rolling onto the neighbouring file and stopping at the end of the series; wrapping stays cine behaviour.
- Tests:
ViewerPresentationTests,PrintPresentationTransformTests,PrintViewerPresentationEndToEndTests,PrintThumbnailCacheTests,PrintSelectionModelTests,ViewerTileLayoutTests,ViewerSeriesPaneTests—DICOMStudioTests4,185 tests green.
CommitmentNotificationListener.waitForResulthang: the timeout race used a task group whose waiter child suspended in a non-cancellation-aware continuation, so after the timeout threw the group could never drain —testCommitmentNotificationListenerWaitForResultTimeout(and any caller hitting the timeout path) hung forever, stalling fullDICOMNetworkTestsruns after ~880 tests. The wait is now a single continuation registered synchronously on the actor and resumed by exactly one of: result arrival, timeout, orstop().stop()also no longer waits on a listener that is already cancelled.ClientIdentitykeychain lookup:kSecClassIdentityqueries do not reliably filter onkSecAttrLabel, so a lookup for a non-existent label could return an arbitrary keychain identity (wrong client certificate). The lookup now fetches attributes for all candidates and matches the label explicitly, throwingkeychainIdentityNotFoundwhen nothing matches.StoreAndForwardQueueTests.test_queue_enqueueDrainingThrowsrace: an empty queue could finish draining (→.stopped) before the test's enqueue ran; the test now holds the queue in.drainingvianotifyConnectivityLost()first.- Full
DICOMNetworkTests(1086 XCTest + 192 swift-testing tests) now completes green in ~15 s and can gate CI.
- Palette color printing: PALETTE COLOR sources are mapped through the data
set's Red/Green/Blue palette LUTs to RGB (or luminance grayscale); a missing
LUT module produces a clear error instead of
notYetImplemented. - Uncompressed subsampled YBR: packed YBR_FULL_422 (full range) and YBR_PARTIAL_422 (BT.601 studio range) frames are chroma-upsampled and converted to RGB; 4:2:0/ICT/RCT remain rejected (never occur uncompressed).
- Deep grayscale output:
--bit-depth 8|12|16— depths above 8 emit little-endian 16-bit-allocated P-Values with matching Bits Stored/High Bit. - Explicit VOI window:
--window-center/--window-widthoverride the data set's window. - Full Implicit VR LE support: print presentation contexts propose Explicit VR LE with Implicit VR LE fallback, and both serialization and all response parsers honor the negotiated syntax — implicit-only printers now work end-to-end (verified against the mock SCP in both syntaxes).
--magnification noneexposed (library case existed).- Spawn-based CLI end-to-end tests:
PrintCLIEndToEndTestsruns the builtdicom-printbinary — version/validation exit codes, dry-run behavior, the JSON stdout contract, and a full print + failure path against the in-process mock Print SCP.
Association.release()no longer aborts on a late P-DATA PDU: a message pushed by the peer between A-RELEASE-RQ and A-RELEASE-RP (e.g. a Print SCP's N-EVENT-REPORT) previously hit the unexpected-PDU path — abort + error on an otherwise successful operation. Per PS3.8 §7.2 the release requestor now discards P-DATA received in the release window and keeps waiting for A-RELEASE-RP. Integration-tested with the mock Print SCP.
dicom-printre-enabled inPackage.swift(product + executable target, owner approved) — previously excluded under Phase-1 scope. Builds with zero warnings.- Mock Print SCP test harness: new in-process, NWListener-based
MockPrintSCP(DICOMNetworkTests) implementing A-ASSOCIATE accept/reject, N-GET printer status, N-CREATE film session / film box (Referenced Image Box Sequence sized from the requested Image Display Format), N-SET, N-ACTION, N-DELETE, and A-RELEASE — with scriptable failure injection (status + Error Comment/ID), silence-after-accept, presentation-context rejection, omitted job UID, and pushed N-EVENT-REPORTs. - 10 end-to-end workflow tests (
PrintSCPIntegrationTests): happy path with exact DIMSE sequence + single-association assertion (PS3.4 H.4), multi-film on one association, failure injection carrying "OUT OF FILM (Error ID 42)" to the thrown error, defensive cleanup, silent-SCP timeout, zero-context rejection, interleaved event delivery + acknowledgement, omitted-job-UID handling, and printer-status round-trip. - Defensive cleanup on failure (P2-3): when a later workflow step fails, the SCU now attempts a best-effort in-association Film Session N-DELETE before aborting (the inner guards no longer abort pre-throw, so the association is still alive for cleanup).
- Machine-readable output contract (P3-2):
sendgained--format json(result object on stdout); the stdout/stderr/exit-code contract is documented in the CLI README.
- Scoped image-box UID parsing (P2-2, bug fix):
parseImageBoxUIDsscanned the whole response for (0008,1155) and could mis-attribute annotation-box or presentation-LUT references as image boxes; it is now bounded to the Referenced Image Box Sequence (2010,0510). - Empty Print Job UID no longer recorded (P2-4): the workflow silently
appended an empty job UID when the N-ACTION response omitted it; consistent
with the discrete
printFilmBox, empty UIDs are now dropped. - Port bounds guard (P2-5, crash fix):
pacs://host:99999trapped onUInt16conversion; now a clean validation error. - New
sendoptions (P3-1):--magnification replicate|bilinear|cubic,--film-destination magazine|processor|bin-1|bin-2, and the full film-size set (8.5x11,24x24cm,24x30cmadded). - Pre-flight checks:
--check-status(P2-1) queries printer status before printing — aborts on FAILURE, warns on WARNING;--verify(P3-3) performs a C-ECHO connectivity check first.
- Image-box pixel attributes always sent (P1-1, bug fix): the N-SET
Preformatted Image Sequence previously omitted Rows/Columns/BitsAllocated/
PhotometricInterpretation when no descriptor was supplied — rejected by strict
SCPs. The print workflow now requires one
PrintImageDataper image (validated up front, before any network activity) and emits the attributes unconditionally; the discretesetImageBoxthrows a clear error without a descriptor. printWithTemplate/printImagesWithProgresson a single association (P1-2, conformance fix): both previously opened a separate association per DIMSE step (PS3.4 H.4 violation — the Film Session UID does not survive across associations). Both are reimplemented on the single-association workflow; the progress stream keeps its phase/percent updates via a new internal progress hook, and both gainedimageDescriptors:/eventHandler:parameters.- Transfer syntax (P1-3, documented decision): print presentation contexts now propose Explicit VR LE only. Previously Implicit VR LE was also proposed but data was always serialized Explicit — an implicit-only SCP would accept a syntax we then mis-encoded. Now such an SCP cleanly rejects negotiation; full Implicit-VR support remains future work if a real printer needs it.
- DIMSE-response timeout (P1-4, bug fix): an SCP that accepted the
association and then went silent hung the tool forever. All print DIMSE
response reads now race against
PrintConfiguration.timeout; on expiry the association is aborted andoperationTimeoutis thrown.
- Preprocessing pipeline wired into printing (P0-1, bug fix):
dicom-print sendnow runs every frame throughImagePreprocessorby default — Rescale Slope/Intercept → VOI window (from the data set or auto-calculated) → MONOCHROME1 inversion → 8-bit MONOCHROME2 output (8-bit RGB for color mode). Previously raw stored pixel values were sent, so windowed CT/MR and MONOCHROME1 images printed with clinically incorrect grayscale/polarity. A--rawflag bypasses the pipeline. The twoXCTSkip-quarantined MONOCHROME1 preprocessor tests were rewritten to the decided behavior and re-enabled. - Encapsulated pixel data decoded before N-SET (P0-2, bug fix): compressed
sources (JPEG, JPEG 2000, JPEG-LS, RLE) were shipped as raw encapsulated
fragments — malformed image boxes.
sendnow decodes to native frames viaDICOMFile.tryPixelData(), which also applies the JPEG-Baseline YBR→RGB descriptor correction. - Multi-frame handling (P0-6, bug fix): previously the entire multi-frame
Pixel Data value was sent as one image. New
--frame N(1-based, default 1) and--all-frames(one image box per frame) options with bounds validation. - YBR color conversion (P1-5): uncompressed YBR_FULL sources are converted
to RGB for color printing (PS3.3 C.7.6.3.1.2); RGB→grayscale conversion is
applied for grayscale mode. Uncompressed subsampled YBR (YBR_FULL_422 etc.)
is explicitly rejected with a clear error rather than mis-converted (packed
4:2:2 layouts need
bytesPerFramemodeling in DICOMCore first). - Signed pixel safety (P2-6): with preprocessing on by default, Pixel Representation = 1 sources are sign-extended and emitted as unsigned 8-bit P-Values — signed values are no longer sent to unsigned Image Boxes.
- New
ImagePreprocessor.prepareForPrint(pixelData:dataSet:frameIndex:colorMode:)API prepares a single frame of already-decoded pixel data (the existing data-set variant now delegates to it).
- Real printer-status parsing (P0-3, bug fix):
parsePrinterStatuswas a stub that always returned NORMAL regardless of the N-GET response, soPrinterStatus.isNormalwas always true. It now decodes Printer Status (2110,0010), Printer Status Info (2110,0020), Printer Name (2110,0030), and — when returned — Manufacturer (0008,0070) / Manufacturer Model Name (0008,1090). A response without a status attribute now reports "UNKNOWN" instead of a false NORMAL.dicom-print statussurfaces the new fields in text and JSON output. - Non-zero exit code on print failure (P0-4, bug fix):
dicom-print sendnow exits with a failure code when the print result is unsuccessful; previously it printed "✗ Print failed" but exited 0, so automation could not detect failures. - DIMSE error detail surfaced (P0-5):
CommandSetgainederrorComment(0000,0902),errorID(0000,0903), andoffendingElements(0000,0901) accessors.DICOMNetworkError.printOperationFailednow carries an optionaldetailstring populated from the SCP's Error Comment / Error ID on every print failure path, so users see e.g. "OUT OF FILM" instead of only a numeric status. A one-argumentprintOperationFailed(_:)factory preserves source compatibility.
--layoutnow honored (bug fix): the flag was parsed and echoed but never applied —sendalways used the automatic layout.printImagesgained an optional explicitlayout:override (nil = existing auto-layout) and the CLI now threads--layoutthrough. A single image with an explicit--layoutis routed accordingly.--coloradded (gap fix): the CLI always printed grayscale becausePrintConfiguration.colorModewas never set. Added--color grayscale|coloronsend, which negotiates the Color Print Management Meta SOP Class and sends color image boxes.- Conformant image descriptors:
sendnow extracts per-image attributes (rows, columns, bits allocated/stored, high bit, samples per pixel, pixel representation, photometric interpretation) from each dataset and sends them in the N-SET Preformatted Image Sequence (PS3.3 C.13.5.1). Previously required image attributes were omitted. - N-EVENT-REPORT reception: the Print SCU now receives, decodes, and
acknowledges asynchronous printer/print-job notifications pushed by the SCP
(Printer SOP Class status; Print Job SOP Class progress). New
PrintEvent,PrinterEventType,PrintJobEventType, and aPrintEventHandlercallback wired throughprintImage/printImages.dicom-print sendprints faults always and routine progress in--verbose. This also fixes a latent correctness bug where an interleaved event could be mis-parsed as the awaited DIMSE response. - Build: the
dicom-printtarget was excluded fromPackage.swiftand had drifted out of compilability (DICOMParser/data(for:)no longer existed,@Sendablecapture errors). Repaired to useDICOMFile.read(from:force:). - Layout presets + retry:
dicom-print sendgained--template(single/comparison/grid/multi-phase — sets layout + film size + orientation, routed through the conformant single-association path) and--retries N(retry on connection/setup failure with exponential backoff; a submitted job is never retried, so no duplicate prints). - Presentation LUT: added
PresentationLUTShapeand apresentationLUTShapeprint option. When set, the workflow N-CREATEs a Presentation LUT SOP Instance (part of the Grayscale/Color Print Management Meta, so no extra presentation context) and references it from each film box (Referenced Presentation LUT Sequence, 2050,0500). CLI:--presentation-lut identity|inverse|lin-od. - Annotation boxes: added
PrintAnnotationandannotations/annotationDisplayFormatIDprint options. The workflow sets Annotation Display Format ID on the film box and N-SETs each Basic Annotation Box (position + text) using a new sequence-scoped UID parser so annotation-box UIDs are not confused with image-box UIDs. CLI: repeatable--annotate <text>+--annotation-format <id>. Note: the Annotation Display Format ID is printer-specific. - Overlay box scaffolding: added the Basic Print Image Overlay Box SOP Class UID and Referenced Image Overlay Box Sequence tag; full overlay-plane extraction remains a follow-up.
- Re-enabled the
DICOMNetworkTeststarget (was excluded fromPackage.swift), so print logic — including the new Presentation LUT / annotation / N-EVENT-REPORT code — is covered again (177 tests). The rotted, live-PACSPACSIntegrationTestsis quarantined viaexclude:until ported to the current API; two outdated MONOCHROME1ImagePreprocessorexpectations areXCTSkip-quarantined pending a product decision on 8-bit vs 16-bit print output.
- STOW-RS server (critical): the multipart parser decoded the entire body as UTF-8
before splitting, so binary
application/dicomuploads (almost never valid UTF-8) parsed to zero parts and were silently dropped while the server still reported success; rare bodies that did decode were corrupted by whitespace-trimming raw bytes. Now uses the same byte-scanningMultipartMIMEparser as the client. SeeBUG_REVIEW.md(C1). - SIMD window/level:
applyWindowLevelpassed a positive offset instead of-minValueinto the vDSP scalar-add, blowing out every image rendered through the fast path.PerformanceTests/SIMDImageProcessorTests.swifthad also silently dropped out of the build (excluded inPackage.swift), so the regression test for this never ran; both are now fixed and back in the build. SeeBUG_REVIEW.md(H1). - Crash hardening: bounds-check encapsulated pixel-data fragment lengths before
slicing (
TransferSyntaxConverter), handle empty/dot-onlyTMvalues (DICOMTime), tolerate duplicate tags inside a sequence item (SequenceItem), and guard a double-resume()race in the Storage SCP association handshake (StorageSCP). SeeBUG_REVIEW.md(H2, H3, M3, H4). - Correctness:
allowMissingVRin the DICOM JSON decoder had its condition inverted and never inferred a VR from the dictionary;AT-valued elements were byte-swapped as a single 32-bit word instead of two ordered 16-bit words on cross-endian transcode (also added missingOD/OL/SV/UVto the numeric-VR swap set); the data element dictionary loader dropped rows with an emptyNamefield ((0018,0061),(0400,0315),(300A,0782)). SeeBUG_REVIEW.md(M1, M2, M4). - Hardening: segmentation palette index underflow on
segmentNumber == 0, slice-unsafe absolute-index reads inByteOrder/PaletteColorLUT(now relative tostartIndex), non-ASCII digits accepted in UID validation, and a 3-byte G1 escape sequence unrecognized at end-of-buffer. SeeBUG_REVIEW.md(L1-L4, M5). - CLI / DICOMStudio Workshop parity: rejected negative
--frame/--retry/--parallel/--batchvalues that previously reached a trapping range or stride instead of erroring cleanly;dicom-anonand its app equivalent now require--output(or--dry-run) instead of silently doing nothing; directory converts indicom-convertnow retag non-DICOM output files with the correct extension per file (previously only the single-file path did); reversed--selectranges indicom-qrno longer trap; study-level C-GET with--hierarchicalnow recovers the series UID from the received dataset instead of collapsing to a flat layout;dicom-splitnow reports real per-file failure counts and exits non-zero when any file failed. SeeBUG_REVIEW.md("CLI / DICOMStudio Workshop hardening").
- Introduced
J2KRoutePlanner, which resolves the three previously conflated encode choices — backend (CPU/GPU/auto), intent (lossy/lossless/lossless-only), and compression type (JPEG 2000 Part-1/Part-2/HTJ2K) — into a single deterministic J2KSwift API call instead of reverse-engineering them from the transfer-syntax UID.autonow genuinely selects the Metal GPU backend for lossy JPEG 2000/HTJ2K encodes where previously it never did. SeeJ2K_ROUTING_ARCHITECTURE.md. - JPEG 2000 Part-2 (
.92/.93) encoding is explicitly rejected with a clear error — the pinned J2KSwift v11.0.2 cannot decode the Part-2 codestreams it encodes — while decoding existing Part-2 files remains fully supported; the real-Part-2 encode path is gated behind a flag for when the library gains decode support. - Updated
CodecBackend/CompressionManager/CompressionConsolewiring and addedJ2KRoutePlannerTestsandJ2KGPUEncodeRoundTripTestscovering the new routing decisions and GPU round-trip correctness.
- Added
CharLSCLICodec(macOS-only), a decode-only DICOMStudio bench peer that wraps DCMTK'sdcmdjplsto cross-validate JLSwift-produced JPEG-LS codestreams against a CharLS-backed reference decoder, matching the existingbinaryPath/version/decodeFramesurface used by the other CLI peers (djpeg/djxl/Kakadu/Grok). J2KTestBenchModels/J2KTestBenchService/J2KTestBenchViewModel/J2KTestBenchView: wired.charlsthrough the JPEG-LS bench family (includeCharLS), andJ2KBenchSyntax.allis now derived entirely fromTransferSyntax.selectableEncodingsfor every format (previously only JPEG 2000/HTJ2K rows were catalog-driven), excluding JPEG XL JPEG Recompression (.111) since the bench encodes raw frames rather than repacking an existing JPEG.- Added
repro12bit, a standalone executable target for reproducing/isolating 12-bit codec issues against J2KSwift, alongsideJLISWIFT_GAP_ANALYSIS.mddocumenting a verified bit-depth, color/sampling, and gap audit of the JLISwift dependency (no critical/high findings). CompressionQuality.expectedMinPSNRDbgives each encode preset (.low/.medium/.high/.maximum, and.customvia interpolation) a conservative minimum-PSNR pass bar for lossy round-trip tests, so the bench'slossyPSNRThresholdDbdefault now tracksJ2KTestBenchService.lossyEncodeQualityinstead of a fixed 40 dB value a lower-quality preset could never clear.ModalityMapping.StandardModality/allCodescentralizes the canonical DICOM modality list; the CLI Workshop's modality pickers (C-FIND/C-MOVE/MWL/etc.) now draw theirallowedValuesfrom it instead of hand-maintained arrays, so new modalities need updating in one place.
CompressionManager.isRecompression/compressData/compressDataWithMetricsnow treat a same-UID lossy target with an explicitqualityas a genuine recompression (decode-to-native- re-encode) rather than a byte passthrough. Previously, re-compressing an already-JPEG-2000
(or other lossy-encapsulated) file into the same transfer syntax UID with a new
--qualitysilently copied the existing codestream through unchanged (input size == output size, ~0 ms), discarding the requested quality. Lossless / no-quality same-syntax targets keep the passthrough.dicom-compressand the DICOMStudio Workshop both passqualitythrough toisRecompressionso their two-phase-recompression UI note stays accurate.
- re-encode) rather than a byte passthrough. Previously, re-compressing an already-JPEG-2000
(or other lossy-encapsulated) file into the same transfer syntax UID with a new
- Removed the DICOMStudio-only OpenJPEG comparison wrapper from
DICOMCore's default dependency graph. Standalone macOS consumers no longer require a Homebrew installation or the absolute/opt/homebrew/lib/libopenjp2.apath. - Removed the corresponding DICOMStudio static-link and arm64-only Xcode settings; production JPEG 2000 support continues to use J2KSwift.
- Optional real-image codec and benchmark tests now report as skipped when the
gitignored
LocalDatasetsorSampleStudiescorpora are unavailable in CI.
- Updated the JPEG 2000 / HTJ2K / JP3D dependency floor to J2KSwift v11.0.2.
- The decoder-only update stops truncated quality-layer decoding at the exact coding-pass boundary and bounds scratch clearing to the active code-block region, without changing the DICOMKit public API or codestream format.
- Added
JPEGCodecEngine(DICOMCore:.jli/.native) andCodecRegistry.encoder(for:engine:), which honours the engine selection only for JPEG Baseline (1.2.840.10008.1.2.4.50) — the one transfer syntax this library can encode two ways: the pure-SwiftJLICodec(the registry default for all four JPEG syntaxes) or Apple'sNativeJPEGCodec(ImageIO). JPEG Extended/Lossless/Lossless SV1 have no second encoder, soengineis a no-op for them. - Threaded
jpegEnginethroughCompressionManager.compressData/compressDataWithMetrics, defaulting to.jlisodicom-compressoutput is unchanged. - Added an internal "JPEG Engine" picker to the DICOMStudio CLI Workshop's
dicom-compressform, visible only foroperation == compress && codec == jpeg— a benchmarking aid with nodicom-compressCLI counterpart, so it never appears in the copy-pasteable command preview. Required a newCLIParameterDefinition.visibleWhenAll([[CLIParameterVisibilityCondition]], ANDed with the existing single-conditionvisibleWhen) sincecodecpersists across operations and a single condition couldn't pin the picker to compress-only.CommandBuilderHelpers.isVisible(...)is now the one predicate shared by command-preview generation, required-field validation, and the ViewModel's form rendering.
- An encapsulated source (JPEG/JPEG 2000/RLE/…) converted to Deflated Explicit VR Little
Endian is now decoded to native pixels first. DEFLATE (PS3.5 A.5) is a data-set-level
codec over a native stream — it has no encapsulated form — but
DICOMConverterused to serialize the encapsulated (7FE0,0010) straight into the deflate stream: the codestream survived, but the output was labelled 1.2.840.10008.1.2.1.99 while still carrying an undefined-length, Item-fragmented pixel element, so no conformant reader (including DICOMKit's own) could decode pixel data from it — and the tool reported success.
- Both CLIs now resolve
--outputthrough the existingOutputPathResolver(already used elsewhere) before writing, so passing a directory (e.g.~/Desktop/DICOM_Output/, or whatever the DICOMStudio Workshop's Browse button hands back) writes the input's filename into that directory instead of failing. An explicit file path is still used verbatim.
CLIToolBuilder.repoRoot()andCLIToolTerminalCompare.locateBinary()no longer fall back to a hard-coded absolute path or the process's working directory (which is/for a GUI app). They now resolve the SwiftPM package root by walking up from#filePath— the checkout the running app was actually compiled from — soswift build/binary lookup can no longer silently target a sibling repo whose DICOMKit accepts different tokens, which previously made "Compare CLI" report diffs that didn't exist in the current repo.
- Introduced
LosslessCapability(losslessOnly/lossyOnly/both),EncodingIntent, andSelectableEncodingonTransferSyntaxto correctly model the JPEG 2000/HTJ2K/JPEG XL "general" UIDs (.91/.93/.203/.112), which per PS3.5 may carry either a lossy or lossless codestream.TransferSyntax.parse()itself remains conservative (bare…-losslessstill maps to the old reversible-only UID, to avoid changing association negotiation behavior in dicom-send/retrieve/qr) — the lossy/lossless split is exposed only through the newparseEncoding()API. SeeJ2K_HTJ2K_TRANSFER_SYNTAX_SPLIT_PLAN.md. - Added missing
UIDDictionaryentries and corrected display names for.92/.93/.201/.202/.203(e.g. "JPEG 2000 Lossless Only", "HTJ2K Lossless Only (RPCL)"). - Threaded
EncodingIntentfrom CLI/app codec-name resolution through toJXLCodecandDICOMConverter, so…-losslesscodec names now genuinely encode reversibly into the general UID (previously not possible) and…-lossyproduces true irreversible compression. SeeJ2K_HTJ2K_JXL_ENCODE_INTENT_AND_LOSSY_ATTRS_PLAN.md. - Added
CompressionManager.applyLossyImageCompressionAttributes, shared bydicom-compressanddicom-convert, which stamps Lossy Image Compression (0028,2110), Method (0028,2114), and Ratio (0028,2112) plus Image Type → DERIVED per PS3.3 C.7.6.1.1.5, with append/once-lossy-always-lossy semantics. JXLCodec: added signed 16-bit support (JXLSwiftint16level-shift) and genuine lossy VarDCT grayscale encoding (JXLSwift 1.4.0) instead of silently falling back to lossless.
- Declared the
dicom-3danddicom-j2ktarget README files as excluded package inputs, removing the two unhandled-file warnings emitted during consumer builds. - Declared the intentionally inactive complements of the explicitly sourced
DICOMKitTestsandDICOMViewerTeststargets as excluded inputs, removing two package-planning warnings covering 102 test files without changing test membership. - Applied the existing optional-fixture contract to two HTJ2K benchmark tests so clean CI checkouts skip them when the gitignored MR corpus is unavailable.
- Hardened release validation to reject production compiler warnings and test package-planning warnings, synchronized the release artifact matrix with all 36 enabled CLI products, pinned manual release jobs to the requested tag, and staged release creation as a draft before publication for immutable releases.
- No decoder, runtime, product API, ABI, or command behavior changed.
Patch release: shared transfer-syntax negotiation token list for dicom-retrieve/dicom-qr,
plus two reporting-accuracy fixes (--backend, dicom-compress info lossless state). No
association-negotiation or encode-behavior changes — this release only corrects what tools
report and what token lists they offer.
- Added
TransferSyntax.negotiableImageSyntaxTokens/negotiableImageTokens(DICOMCore) as the single source of truth for the transfer-syntax lists offered by the UID-only negotiation tools (dicom-retrieve,dicom-qr) — the negotiation analogue ofCompressionManager.supportedCodecs()(dicom-compress) andDICOMConverter.cliTokens(dicom-convert). Every token round-trips throughTransferSyntax.parse()(enforced by tests). - Wired the DICOMStudio CLI Workshop
dicom-retrieve/dicom-qrtransfer-syntax pickers and thedicom-retrieve/dicom-qrCLI--transfer-syntaxhelp onto that shared list, so both surfaces stay in lockstep. This adds the previously-missing JPEG-LS, JPEG XL, and JPEG 2000 Part 2 (plusexplicit-vr-be,deflate,jpeg-extended,jpeg-lossless-sv1) syntaxes that the old hand-maintained lists stopped short of; regenerated theCLIContracts.jsonparity golden to match. - CLI Workshop: the
dicom-converttransfer-syntax dropdown now shows the short kebab aliases (DICOMConverter.aliasTokens, e.g.jpeg2000-lossless,htj2k-lossy) instead of the CamelCasecliTokens, so it reads the same as thedicom-compress/dicom-retrieve/dicom-qrdropdowns. Thedicom-convertCLI resolves the kebab alias identically, so the generated command and in-process execution are unchanged.
CompressionManager.getCompressionInfonow derivesisLossless(and the transfer-syntax display name) from Lossy Image Compression (0028,2110) for theboth-capable general UIDs (.91/.93/.203/.112), instead of the UID-level flag which always reported "Lossy". A file reversibly encoded into a general UID (e.g.--codec jpeg2000-lossless→.91) now reportsTransfer Syntax: JPEG 2000 Lossless/Lossless: Yeson bothdicom-compress infosurfaces (text +--json) and in the CLI Workshop, so the name and the Lossless line no longer contradict each other. Single-capability UIDs (e.g..90,.50) are unchanged — their UID is authoritative. Reference: PS3.3 C.7.6.1.1.5.
- Added
CodecBackendPreference.effectiveEncodeBackend(isLossless:isJPEG2000Family:)(DICOMCore) andCompressionConsole.compressBackend(codec:preference:)(DICOMKit), which report the backend the encoder will actually dispatch to rather than the best available hardware.J2KSwiftCodeconly takes the Metal GPU path for a genuinely lossy JPEG 2000/HTJ2K encode — the lossless GPU path isn't bit-exact on 12/16-bit medical data — soauto/--backend metalon a lossless or non-J2K encode previously reported "Metal (GPU)" indicom-compress/CLI Workshop verbose output even though the encode ran on the CPU. An explicit--backend metalrequest that can't use the GPU is now downgraded to the CPU backend with an explanatory note instead of silently misreporting. CodecBackend.accelerate.displayNamenow reports "Accelerate (CPU)" instead of the stale "Accelerate (not available)" on platforms where Apple'sAccelerateframework is present but the old J2KAccelerate SIMD-family probe (removed in J2KSwift v11.0.0) no longer exists.
Patch release: strict-concurrency bridge fix plus package-wide warning cleanup.
Both swift build and swift build -Xswiftc -warnings-as-errors pass cleanly;
no runtime behavior changes.
- Fixed the
DispatchSemaphore+Task {}async-to-sync bridge pattern used by CLI entry points so Swift 6 strict-concurrency no longer flags data-race sendability risks forwaitForTask/runAsynchelpers. The bridge now usesTask.resultand an explicitly documented manually-synchronized capture (nonisolated(unsafe)), preserving runtime behavior while satisfying the compiler's model. - Updated affected targets:
Sources/dicom-jpip/main.swiftSources/dicom-3d/main.swiftSources/dicom-j2k/main.swiftSources/dicom-viewer/main.swift
- Also cleaned up adjacent strict/warnings-as-errors diagnostics surfaced during validation in shared runtime files (
ScriptEngine,DICOMDIRReader/DICOMDIRWriter,ImagePreprocessor,ImageResizer,JP3DVolumeBridge,JP3DVolumeDocument,StudyManager, gateway mapping/converter helpers, and compression manager) without changing intended behavior.
dicom-3d: never-mutatedvarlocals converted tolet(VolumeExport,MPRGenerator); redundanttry/try?removed from non-throwingDataSetaccessor calls (VolumeData).DICOMWeb: redundantawaitremoved from synchronous same-isolation calls (ConformanceStatementGenerator,HTTPConnectionPool,HTTPRequestPipeline); unusedguard let urlbinding replaced with aURL(string:) != nilexistence test (DICOMJSONDecoder); unused locals dropped (CompressionMiddleware,HTTPRequestPipeline).DICOMStudio: pure static J2K test helpers markednonisolated— they already executed off the main actor at runtime (J2KTestingViewModel); pipe-drain captures inCLIToolTerminalCompareuse the same documentednonisolated(unsafe)+ happens-before pattern as the CLI bridge; cine timer callback wrapped inMainActor.assumeIsolated(timer is scheduled on the main run loop); sliderBindingsetters pass closures instead of non-@Sendablefunction values (DICOMVolumeViewerView,JP3DComparisonView,JP3DVolumeComparisonView); unused locals removed (CLIWorkshopViewModel,JP3DMPRView).
CLI parity harness, JPEG XL lossy/recompression, and round-trip regression suite.
Follow-up to the remediation batch: every remaining hand-duplicated console block and directory
walk now lives in one shared builder/gatherer that both the CLI and the Workshop call, so the two
surfaces can no longer drift (CLI_TOOLS_SHARED_CORE_VERIFICATION.md → P — duplication).
- New shared console builders (CLI text canonical):
AnonConsole(+ sharedAnonymizer.parseFlexibleTag),PixelEditConsole,ImageConsole,ExportConsole,UIDConsole,CompressionConsole.infoText/infoJSON/backendsText/backendsJSON,NetworkConsoleqr-line additions,UPSConsole(DICOMWeb), andNetworkConsole.mwlCreateDetailBlock(dedups the app's HL7/REST MWL-create branches). - New shared gatherers:
FileGatherer.regularFiles(under:recursive:)(sorted, content-agnostic walk — anon/validate/convert/pdf/export/image on both surfaces) andFrameMerger.gatherInputFiles(from:recursive:)+FrameMerger.isDICOMFile(dicom-merge). - Real drift found and fixed while hoisting (all previously hiding in un-goldened paths): the app's pixedit executor double-printed the Image line, added an "Edited pixel data:" line and a byte-size suffix the CLI never prints; anon's per-file verbose text and single-file failure behavior differed from the CLI; UPS create-workitem/change-state used app-invented wording instead of the CLI's response text; dicom-merge sorted its inputs in-app but not in the CLI, so merged instance order could differ between surfaces (both now sort).
- Deterministic batch order: all shared walks sort by path, so directory-mode output order is stable run-to-run on both surfaces (previously filesystem enumeration order).
- App-only additions that have no CLI counterpart (sandbox redirect notes, UPS pre-flight guidance and error hints, MWL create banners) are unchanged.
Follow-up to the three-axis verification of all 40 tools (CLI_TOOLS_SHARED_CORE_VERIFICATION.md,
full per-claim outcomes in its Remediation outcomes section). Eleven tools were held by user
triage (measure, viewer, 3d, ai, report, gateway, cloud, server, j2k, jpip, print) and the network
port/hostname workflow was left untouched as planned. Highlights:
- New shared surfaces (CLI + Workshop call one implementation):
DataExchangeWorkflow(DICOMWeb) — the entire dicom-json/dicom-xml pipeline (default output path, always-write-file behavior, tag filtering, metadata-only, verbose lines). Fixes the app's divergent print-to-console/refuse-reverse behavior.ConvertConsole(DICOMKit) — dicom-convert's terse console (transcode line, batch ✓/✗ +Conversion complete:summary); the app's extra Read/Wrote/Transfer-Syntax chrome removed.TagEditConsole(DICOMKit) — dicom-tags change block +Output written to:completion line (the app previously printed an unconditional count andSaved:).QRSessionState(DICOMNetwork) — dicom-qr's save-state model hoisted from the executable;--save-statenow also works in the Workshop and app-saved states resume viadicom-qr resume.- App validate now renders via the shared
ValidationReport(the drifted app copy with itsExit code:annotation block was deleted); app compress batch uses the sharedfindDICOMFiles; app uid/pixedit call the sharedvalidateFileUIDs/parseRegion.
- Formerly-inert flags implemented:
dicom-merge --format enhanced-ct/mr/xa(Enhanced SOP Class + Shared/Per-frame Functional Groups),dicom-script run --parallel(concurrent pipeline commands with source-order output replay),dicom-compress --backend(CompressionConfiguration.forcedBackend→ J2K/HTJ2K Metal GPU encode; other codecs unaffected),dicom-image --use-exifunder--split-pages(per-page EXIF),dicom-dcmdir update(real implementation viaDICOMDIRWorkflow.updateDirectory),dicom-query --referring-physician(now a study-level C-FIND matching key on both surfaces). - Removed (declared no-ops):
dicom-json --format standard|dicomweband--stream(goldens proved byte-identical output for every value; the encoder always emits the DICOMweb PS3.18 JSON model); the app-only qido timeout field. - Wire/exit-code correctness: app mpps update no longer sends a
studyInstanceUIDthe CLI never sets; anon in-app exit code is now structured (any failed file → 1) instead of sniffed from output text; dump's No-Color toggle is honored (defaults ON in-app with a truthful--no-colorpreview). - Surface adds: uid regenerate accepts multiple inputs in-app (cross-file UID mapping like the
CLI); ups gains the CLI's
--create <json-file>form; qido gains--verbose. - New round-trip oracles: merge enhanced-format (SOP class + functional groups + standard unchanged), script parallel-pipeline (rendezvous concurrency + source-order replay + failure propagation), dcmdir update (add + prune-missing).
dicom-compress compress --codec jpeg-xlnow produces lossy JPEG XL (1.2.840.10008.1.2.4.112, general JPEG XL) via JXLSwift's VarDCT encoder, alongside the existing lossless JPEG XL (…4.110). Both thedicom-compressCLI and the DICOMStudio CLI Workshop drive the identical sharedCompressionManager/CodecRegistrypath, so the two surfaces cannot drift.--quality maximum|high|medium|low|0.0–1.0maps to the JPEG XL quality/distance curve (mirroring the--qualitymapping already used by the JPEG and JPEG-LS lossy codecs).- Codec naming aligns with the rest of the family (
jpeg2000/htj2k): the barejpeg-xl/jxlnames now resolve to the lossy syntax (…4.112); the explicitjpeg-xl-lossless/jxl-losslessnames select the lossless syntax (…4.110).jpeg-xl-lossy/jxl-lossyare accepted aliases for the lossy target. (Behaviour change:jpeg-xl/jxlpreviously encoded lossless.) JXLCodec(Sources/DICOMCore/JXLCodec.swift): added…4.112tosupportedEncodingTransferSyntaxes, a per-instanceencodingTransferSyntaxUIDthat selects the encode mode (…4.110 → lossless Modular, distance 0; …4.112 → lossy VarDCT at a quality-derived distance), and ajxlQuality(from:)mapping. JXLSwift's VarDCT lossy encoder covers 8-/16-bit RGB/RGBA within its size limits; for inputs it can't take (grayscale, oversized) it transparently falls back to the lossless Modular path, so a…4.112encode always yields a valid — and conformant, since the general syntax permits both — JPEG XL codestream.CodecRegistry(Sources/DICOMCore/ImageCodec.swift): the JPEG XL encoder is now wired per-UID (JXLCodec(encodingTransferSyntaxUID: uid)for…4.110and…4.112), mirroring the per-syntax encoder wiring already used for JLISwift / J2KSwift.CompressionManager(Sources/DICOMKit/Compression/CompressionManager.swift): codec-name map splits JPEG XL into a lossless entry (jpeg-xl-lossless/jxl-lossless→…4.110) and a lossy entry (jpeg-xl/jxl/jpeg-xl-lossy/jxl-lossy→…4.112). Flows automatically to the CLI validator, the--helpcodec list, and the DICOMStudio Workshop codec picker (which derives fromsupportedCodecs()).- DICOMStudio (
Sources/DICOMStudio/Views/DataExchangeView.swift): the Data Exchange compression-algorithm picker gains "JPEG XL Lossless" and "JPEG XL Lossy" entries, withjpeg-xlmarked lossy. - Tests (
Tests/DICOMCoreTests/JXLCodecRegistryTests.swift,CompressionCodecMapTests.swift): registry now exposes an encoder for…4.112; new round-trips prove an RGB lossy encode decodes back to source dimensions and a grayscale lossy request falls back to lossless (bit-exact); the codec-map parity test pinsjpeg-xl→…4.112andjpeg-xl-lossless→…4.110. - Note:
dicom-convert'sjpeg-xl/jxl--transfer-syntaxaliases remain lossless (…4.112is not yet a convert target) — this change is scoped todicom-compress.
- Codec naming aligns with the rest of the family (
dicom-convert --transfer-syntax JPEGXLRecompressionnow losslessly transcodes a JPEG Baseline (…4.50) file to JPEG XL JPEG Recompression (1.2.840.10008.1.2.4.111) and back. Unlike every other codec, recompression is not a pixel operation: it wraps the existing JPEG bitstream in a JPEG XL container (with ajbrdreconstruction box) so the original JPEG is recovered byte-for-byte — no additional loss on top of the JPEG the file already carries. The reverse (…4.111→JPEGBaseline) reconstructs the byte-identical original JPEG.JXLCodec(Sources/DICOMCore/JXLCodec.swift): added the fragment-levelrecompressJPEGFragment(_:)/reconstructJPEGFragment(_:)helpers (backed by JXLSwift'sencodeLosslessJPEG/decodeLosslessJPEG), added…4.111to the decodable transfer syntaxes, and routed the…4.111pixel decode through JPEG reconstruction → the JPEG codec so decoded pixels match the wrapped JPEG with the descriptor's channel layout (rather than the VarDCT bridge's colour-space representation). The forward encode is deliberately NOT registered as a pixelImageEncoder(recompression takes JPEG bytes, not pixels).TransferSyntaxConverter(Sources/DICOMCore/TransferSyntaxConverter.swift): addedisJXLRecompressionForward/isJXLRecompressionReversepredicates (gated to JPEG Baseline ↔ …4.111, matching JXLSwift's baseline-DCT scope), a fragment-leveltranscodeJXLRecompression(…)path (no pixel decode/re-encode — structurally mirrors the J2K↔HTJ2K fast path), thecanTranscodeadmission for these pairs, and a lossless-guard bypass so a lossy-Baseline → …4.111 wrap is correctly treated as lossless (it adds no loss).DICOMConverter(Sources/DICOMKit/DICOMConverter.swift): addedJPEGXLRecompressionto the shared convert target catalog, so the new target flows automatically to the CLI--transfer-syntaxhelp, the DICOMStudio CLI Workshop picker, the representative parameter catalog, andparseTarget(_:)— the CLI and the Workshop share one code path. It is convert-only (not adicom-compress/CompressionManagerpixel codec).- CLI parity:
CLIContracts.jsontransfer-syntax abstract updated;cli-parity-gengains a derivedsyn-ct-baseline.dcmJPEG-Baseline fixture (ctbaseline) and adicom-convert ts-JPEGXLRecompressiondecoded-pixel-hash scenario exercising the shared path in both surfaces. - Tests (
Tests/DICOMRoundTripTest/ConvertRoundTripTests.swift): oracle round-trips proving byte-identical JPEG reconstruction through the …4.111 wrap, …4.111 pixel decode equals the wrapped JPEG's pixels, onward transcode …4.111 → J2K Lossless, and rejection of a non-JPEG source.
Added — Shared CLI ↔ App Orchestration: CompressionConsole, DICOMConverter, DICOMDIRDumpFormatter, DICOMDIRWorkflow, EncapsulatedDocumentWorkflow
CompressionConsole(Sources/DICOMKit/Compression/CompressionConsole.swift): Pure shared formatter and input-parser fordicom-compress. Both the CLI binary and DICOMStudio's CLI Workshop call this single type for--quality/--backendparsing, binary byte formatting, and every console line (compress header, stats, batch totals). Replaces duplicated inline formatting on both sides. Mirrors theNetworkConsoleshared-formatter pattern.DICOMConverter(Sources/DICOMKit/DICOMConverter.swift): Single source of truth for thedicom-converttransfer-syntax conversion API — the ordered target catalog (UID, CamelCase CLI tokens, kebab aliases),parseTarget(_:),cliTokens/aliasTokenspicker lists, and the sharedconvertToDICOM(dicomFile:to:stripPrivate:)pipeline. Both the CLI (dicom-convert) and the DICOMStudio CLI Workshop call this directly so conversion bytes are identical and the--transfer-syntaxhelp text cannot drift from the picker.DICOMDIRDumpFormatter(Sources/DICOMKit/DICOMDIRDumpFormatter.swift): Shared renderer fordicom-dcmdir dumpoutput (tree / json / text formats). The CLI and the CLI Workshop both callDICOMDIRDumpFormatter.render(_:format:verbose:)so their output pipelines cannot drift.DICOMDIRWorkflow(Sources/DICOMKit/DICOMDIRWorkflow.swift): Shared orchestration helpers fordicom-dcmdir— recursive DICOM file discovery (skipping the existing DICOMDIR, sorting by path), the create build-loop (read, compute relative path, add toDICOMDirectory.Builder, emit summary), and the validate report (statistics + file-set + record-type breakdown). Both the CLI and the Workshop call these helpers, eliminating hand-mirrored orchestration that could silently diverge.EncapsulatedDocumentWorkflow(Sources/DICOMKit/EncapsulatedDocument/EncapsulatedDocumentWorkflow.swift): Shared orchestration helpers fordicom-pdf—EncapsulatedDocumentType.fileExtension,defaultModality, the--show-metadatareport block, and the human-readable file-size formatter. Both the CLI and the Workshop call these helpers so thedicom-pdfparity surface cannot drift.- Synthetic DICOMDIR fixture (
Sources/DICOMStudio/Resources/CLIParity/synthetic/syn-dicomdir): Pre-built minimal DICOMDIR file added to the synthetic corpus for the offlinedicom-dcmdirparity scenario, making the dcmdir dump/validate scenarios runnable without a real PACS file hierarchy.
DICOMWriter.serializeElement(_:)now emits encapsulated pixel data (Sources/DICOMCore/DICOMWriter.swift): The generic serializer previously skipped any undefined-length (0xFFFFFFFF) element that was not.SQ, silently emitting an empty PixelData shell. This causedDICOMConverter/dicom-convertto fail on ANY compressed source — the decoder saw zero fragments and threw "Frame 0 starts beyond data bounds" — and would have corrupted anyDataSet.write()/DICOMFile.write()rewrite of a compressed dataset. Fixed by adding a dedicatedserializeEncapsulatedPixelData(_:fragments:)branch that emits the full Basic Offset Table Item + one Item per codestream fragment (odd fragments padded to even length per PS3.5 A.4) + the Sequence Delimitation Item.
- Removed stale YBR→RGB photometric relabel (
Sources/DICOMKit/DICOMFile+PixelData.swift): The previous code relabelled YBR pixel data as RGB for transfer syntaxes that the retired ImageIO-based codecs once handled (JPEG 50/51/57/70, JPEG 2000 90/91). The current registry uses pure-Swift codecs (JLISwift, J2KSwiftCodec, JPEG-LS, RLE) that decode to the source photometric interpretation and leave YBR→RGB conversion toPixelDataRenderer. The relabel suppressed that renderer conversion and washed out/blanked colour compressed previews. Removing it restores correct colour rendering for all compressed sources while preserving the existingisImageIODecodedTransferSyntaxhelper (unused after this change).
- JPEG-LS NEAR clamped to 255 (
Sources/DICOMCore/JPEGLSCodec.swift): JLSwift'sJPEGLSEncoder.Configurationrejects a NEAR value above 255. For a 16-bit source at high quality, the formulamaxVal * (1 - quality) * 0.1could yield NEAR ≈ 655, causing the encode to throw. Added amaxNear = 255clamp so lossy JPEG-LS encoding of 16-bit images at any quality level succeeds.
PixelEditordecodes encapsulated sources before editing (Sources/DICOMKit/PixelEditing/PixelEditor.swift): Editing a compressed (encapsulated) PixelData element in place corrupts the encoded bitstream; the output — still tagged as the compressed transfer syntax — cannot be decoded by a viewer.PixelEditornow detects an encapsulated source, decodes it to native pixels first, and emits the edited result as uncompressed Explicit VR Little Endian. Multi-frame sources are handled correctly.
PixelEditornow inverts the VOI window and uses the correct signed pivot (Sources/DICOMKit/PixelEditing/PixelEditor.swift):dicom-pixedit --invert(and the CLI Workshop "Invert" toggle, which sharesPixelEditor.processData) produced a solid-white image.applyInvertinverted stored pixels around2^bitsStored − 1but never updated the file's VOI Window Center (0028,1050); because the viewer, image exporter, and Horos all honour the stored window by default (DICOMImageExporter.determineWindowSettings, rescale-adjusted), every inverted pixel fell outside the unchanged window and clamped to white. Signed data was additionally clamped because the pivot used the unsigned max instead of−1. Fixes: (1) invert aroundisSigned ? −1 : maxValue; (2) re-point each Window Center toslope·pivot + 2·intercept − center(output-unit equivalent of inverting the stored center around the pivot), leaving Window Width unchanged and no-op when the file carries no stored window; (3)--apply-windownow bakes into — and resets the stored VOI window to — the full representable stored range (signed-aware:[0, 2^b−1]unsigned,[−2^(b−1), 2^(b−1)−1]signed), so a baked signed image is no longer written into only half the range and rendered ~2× too dark.formatDSguards against non-finite values from pathological rescale metadata. Regression tests inPixelEditorTestsrender the inverted frame through the shared viewer/export window policy and assert a true photographic negative (not solid white) for both MONOCHROME2 and MONOCHROME1, plus the signed window-bake range.
Seven new test files added to the DICOMKitTests target (Package.swift sources updated):
EncapsulatedPixelDataWriteTests: Regression for theDICOMWriterencapsulated pixel data fix — asserts the full BOT + fragment structure is emitted, odd fragments are padded, a zero-fragment BOT writes cleanly, and a round-tripDataSet.write()→DICOMParser.parse()recovers the original fragments.CompressionManagerImplicitVRTests: Regression forCompressionManageron Implicit VR Little Endian sources — pins that the sharedDICOMWriterpath re-encodes sequences from parsed items (not raw bytes) and promotes oversized short-VR values to UN, preventing byte-stream desync and the "No pixel data found" failure on decompress.CompressedPreviewRenderParityTests: Parity regression that theDICOMFile.pixelData()return value for a freshly compressed file matches the value after a round-trip decompress, for every codec — so the photometric-relabel removal cannot silently reintroduce colour corruption.CompressionConsoleTests: Contract tests that lock the exactdicom-compressconsole strings produced byCompressionConsole— byte formatting, quality parsing, header lines, compressed-result lines — so the CLI and CLI Workshop cannot drift.DICOMConverterTests: Contract tests for the sharedDICOMConverterAPI — every catalog token (cliToken / aliasToken / UID / extraAliases) round-trips throughparseTarget; picker token lists are exactly the catalog;CLIContracts.jsonentry is regenerable from the catalog; DEFLATE converts without error.ExportWindowParityTests: Regression thatDICOMImageExporter.renderFrameForExportanddetermineWindowSettingsrescale-adjust the VOI window (HU → stored via Rescale Slope/Intercept), so a CT with a non-zero Rescale Intercept exports with the correct contrast — not washed out.PixelEditorTests: Regression thatPixelEditordecodes encapsulated sources to native pixels before editing, emits Explicit VR Little Endian output, and preserves tag edits in the serialized file.
WADORetrieveConsoleFormatter(Sources/DICOMWeb/WADORetrieveConsoleFormatter.swift): Shared output renderer for WADO-RS / WADO-URI retrieve — verbose preamble blocks, per-mode status lines (metadata / rendered / thumbnail / frames / instances / WADO-URI result), and the metadata body (JSON pretty-printed + PS3.19 Native DICOM Model XML). MirrorsQIDOResultFormatter(query) andUPSResultFormatter(ups): a SINGLE formatter both sides call, so thedicom-wado retrieveCLI binary and DICOMStudio's in-app retrieve cannot produce different output.DICOMWado.swift(RetrieveCommand) now delegates all verbose preamble, per-mode status, and metadata body output toWADORetrieveConsoleFormatterinstead of hand-rolling inline strings.CLIWorkshopViewModel.swift(WADO retrieve case) likewise delegates to the formatter; the mode-detection / inline-echo block is removed, and the verbose preamble is gated by--verboseon both sides identically.parseFrameNumbersmoved fromRetrieveCommandintoWADORetrieveConsoleFormatter(as a throwing method returning[Int]) with a companionWADOFrameParseErrortype; the CLI catchesWADOFrameParseErrorand re-throws asValidationError.
STOWResultFormatter(Sources/DICOMWeb/STOWResultFormatter.swift): Shared console renderer fordicom-wado store(STOW-RS) upload output — verbose pre-upload header, per-batch start/result lines, per-failure detail, and the always-printed final summary block. Both thedicom-wado storeCLI path and DICOMStudio's in-app STOW upload call this single formatter, preventing output pipeline drift. The summary block format is a parity contract thatCLIParityWADOComparator.parseStoreanchors on.
- UPS write scenarios run out-of-the-box: The parity harness no longer gates the full UPS operation matrix on a user-supplied Procedure Step Label. A
upsDefaultLabel("CLI Parity Workitem") is substituted when the WADO panel's label is blank, soups-lifecycle,ups-lifecycle-complete,ups-lifecycle-cancel,ups-get,ups-create-attrs,ups-create-json, andups-subscribealways appear in the scenario list — matching how the harness already auto-picks the AE title and station filter. - Global UPS subscribe scenario (
ups-subscribe-global): NewrunWADOUPSSubscribeGlobalScenariorunner exercisesups --subscribe --aet <ae>(no--workitem-uid) →ups --unsubscribe --aet <ae>— the GLOBAL round-trip that subscribes to ALL workitems' events. Reference usesDICOMwebClient.subscribeToAllWorkitems+unsubscribeFromWorkitem(nil). Parity on round-trip outcome; servers that don't enable UPS subscription fail both sides identically (failureAgreement). - ups-get
--format/--verbosevariants: Four--formatflag variants (table,json,csv) plus a--verbosevariant are now generated for theups-getscenario. The flags are threaded throughstudioParams["get-format"]and"get-verbose"and appended at run time (after the Workitem UID is known), mirroring how the CLI appends them to the chainedups --get <uid>command. - Transaction UID flow corrected: The UPS lifecycle runner (
runWADOUPSLifecycleScenario) no longer pre-mints a Transaction UID and supplies it to the--update --state IN_PROGRESSclaim. Instead it lets the server assign one, parses it from the CLI's IN PROGRESS output (Transaction UID: …), and reuses it for the terminalCOMPLETED/CANCELEDtransition — exactly how a real operator works. The reference (CLIParityNetworkReference.wadoUPSLifecycle) likewise captures and reusesclaimResp.transactionUID. When no UID is returned the terminal transition is skipped and recorded as not reached. wadoUPSSubscribeGlobalreference method added toCLIParityNetworkReference: callsclient.subscribeToAllWorkitems+client.unsubscribeFromWorkitem(nil);createOKis vacuously true (no workitem is created).
- C-GET comparator (
CLIParityRetrieveComparator): The sharedNetworkConsole.cGetSummarynow emits exactly one terse line —"✅ C-GET completed — N file(s) received"on success or"⚠️ C-GET completed but received 0 instances. …"when nothing arrived — instead of a structuredC-GET Completed:block with sub-operation counts. The parser now reads the received-file count from that line only;completed/failedare no longer parsed or compared for C-GET (they are unobservable in the CLI text).canonical()updated accordingly: C-GET comparessuccess + files; C-MOVE still comparescompleted + failed + warning. - dicom-send dry-run comparator (
CLIParitySendComparator): The shared formatter's dry-run path (NetworkConsole.sendHeader) prints the gathered file count in the header's"Files: N"field rather than"Found N file(s) to send". The parser now reads the first"Files:"line — the header count — rather than"Found".
unsubscribeoperation added to the UPS parameter definition inCLIWorkshopHelpers: the operation picker now listssearch,get,create-workitem,change-state,subscribe,unsubscribe.--workitem-uidis shown forunsubscribeas well assubscribeandcreate-workitem.--searchand--create-workitemmoved tocliMapping: Both flags are now emitted automatically when the matching operation tab is selected, removing the separate boolean-toggleCLIParameterDefinitionentries that were previously needed. This mirrors the existing--subscribe/--unsubscribemapping pattern.- No auto-pre-selection in Network mode: Switching to Network mode no longer pre-selects the first network tool. The user explicitly picks which tools to include in the parity sweep.
- HL7 ORM IPC segment + OBR field map corrected (
ModalityWorklistService.buildHL7ORM): The previous implementation wrotescheduledStationAETitleintoOBR-20, which dcm4chee-arc's default inbound order stylesheet (hl7-order2dcm.xsl) reads as the Scheduled Procedure Step ID (0040,0009) — so a user's Station AET surfaced on the server as the SPS ID. Fixed in two ways:- OBR path corrected: rebuilt with an explicit index→value map (
hl7Segment(_:fields:)helper) so the values land at their exact positions. OBR-18 = Accession Number, OBR-19 = Requested Procedure ID, OBR-20 = SPS ID, OBR-24 = Modality, OBR-27 4th component = SPS Start Date/Time. - IPC segment added: a dcm4che-private
IPC(Imaging Procedure Control) segment is emitted after OBR so every SPS attribute has an unambiguous, configuration-independent slot — IPC-7 = Station Name, IPC-9 = Scheduled Station AE Title (the only ORM path that carries them). IPC-1/2/3 also supply Accession / Requested Procedure ID / Study Instance UID, matching the OBR fallback exactly. buildHL7ORMpromoted fromprivatetointernalto allow the new field-placement regression tests (Tests/DICOMStudioTests/MWLCreateHL7ORMTests.swift) to assert each value's exact HL7 position without requiring a live MLLP server.
- OBR path corrected: rebuilt with an explicit index→value map (
WADOURIClient.resolveURIEndpoint(_:)(new public static method): dcm4chee-arc 5.x serves WADO-URI (?requestType=WADO) from/wado, while the sibling WADO-RS/QIDO-RS endpoint lives at/rs. Supplying a WADO-RS base URL for a WADO-URI request returned HTTP 404. The resolver rewrites a trailing/rspath segment to/wado; all other base URLs are returned unchanged. Because thedicom-wadoCLI, CLI Workshop, and parity reference all retrieve through this one client, they resolve identically and cannot drift.
dicom-mpps create --statusvalidation: N-CREATE must always start the stepIN PROGRESS; the previous code acceptedCOMPLETEDorDISCONTINUEDat creation, which servers reject (terminal states are reached only via N-SET). Thecreatesubcommand now validates that--statusisIN PROGRESSand emits a clearValidationErrordirecting the user todicom-mpps updatefor state transitions.
UPSResultFormatter(Sources/DICOMWeb/UPSResultFormatter.swift): Shared output renderer for UPS-RS worklist search results — table, JSON (UPSOutputFormat), and CSV — used by both thedicom-wado ups --searchCLI path and DICOMStudio's in-app UPS worklist search. MirrorsQIDOResultFormatter(QIDO-RS) andDICOMQueryResultFormatter(DIMSE): a single formatter both sides call so their output pipelines cannot drift.
- Edit saved PACS server profiles: The CLI Workshop saved-server list now supports in-place editing (
beginEditServer(id:)/saveEditedServer()onCLIWorkshopViewModel). A newshowEditServerSheet/editingServerIDpair drives the edit sheet; saving re-applies the updated values when the edited profile is currently selected. Previously only add and delete were supported.
NetworkConsole(DICOMNetwork) now covers all DIMSE network tools:dicom-echo,dicom-mwl(query), anddicom-mppsjoined the shared formatter, completing the set started withdicom-query / dicom-send / dicom-retrieve / dicom-qr / dicom-wado. All human console output — headers, per-echo progress, summaries, verbose details — routes through oneNetworkConsolemethod on both the CLI binary and the DICOMStudio CLI Workshop in-process path, making terminal-compare diff drift impossible by construction.dicom-send/ProgressReporter.swiftremoved: its logic was absorbed intoNetworkConsole. Any callers that imported it directly must switch to the correspondingNetworkConsole.*methods.
MWLCreateHL7ORMTests(Tests/DICOMStudioTests/): Regression tests asserting each value in the HL7 ORM^O01 message built byModalityWorklistService.buildHL7ORMlands at its exact field position in both the OBR fallback path and the IPC segment, so the field-placement bug (OBR-20Station AET mismap) cannot silently return.UPSTests(Tests/DICOMWebTests/): Coverage for UPS-RS workitem query parsing and the newUPSResultFormatteroutput (table/JSON/CSV).WADOURIClientTests(Tests/DICOMWebTests/): Coverage forWADOURIClient.resolveURIEndpoint(no-op for/wado, rewrite for/rs, passthrough for other paths) and WADO-URI URL building.
- Network Utility panel (
NetworkUtilityView,NetworkUtilityViewModel,NetworkUtilityService): Six-tab general-purpose network diagnostics tool surfaced as a new sidebar destination in DICOMStudio.- Ping — wraps
/sbin/ping; live per-packet output streams into a terminal panel, parsed summary (min/avg/max RTT, packet loss) replaces it on completion. - Port Scanner — concurrent TCP probes via
NWConnection; results append in arrival order for a live scan log, sorted by port number on completion. - Traceroute — wraps
/usr/sbin/traceroute; each hop line streams as it resolves; stderr merged into stdout so thetraceroute to …header appears at the top in real time. - DNS Lookup — wraps
/usr/bin/digper selected record type (A, AAAA, MX, TXT, NS, CNAME, SOA, PTR); each query echoes a$ dig …header then streams its answer block. - Interfaces — lists all network interfaces with IPv4/IPv6 addresses, MAC address, MTU, flags, and status badges.
- Netstat — wraps
/usr/sbin/netstat; streams TCP/UDP connections or routing table live; parsed counts (listening/established/routes) shown on completion.
- Ping — wraps
- Shared host input: A single
sharedHostfield is shared by the Ping, Port Scanner, and Traceroute tabs — typing a host in any one of them pre-fills the others. AsyncStream<String>-based live streaming (runStreamingProcess): All five process-based tools share a single streaming process runner; stdout and stderr are merged into one pipe so output arrives in natural order, then yielded chunk-by-chunk viaAsyncStream.- UTF-8 carry-over buffer: A
var pending = Data()accumulator in theavailableDataread loop ensures multibyte characters (IDN hostnames, TXT/PTR record content) are never split and silently dropped between reads. - Run-identity guard (
streamGeneration/portScanGeneration): Each run captures a generation counter;onChunkclosures and completion assignments checkself.streamGeneration == genand discard stale deliveries from cancelled or superseded runs. - SIGKILL escalation: Both the wall-clock watchdog and
ProcessKillBox.cancel()send SIGTERM then escalate to SIGKILL after a 3-second grace period, preventing hung processes from blocking the UI indefinitely. - Watchdog liveness guard: The watchdog
DispatchWorkItemchecksproc.isRunningbefore acting, preventing a process that exits naturally at the deadline from being mislabelled as timed out.
DICOMStudio: J2K Test Bench, responsive layout, and imaging-first navigation.
- J2KSwift v3.2.0 codec stack (
Sources/DICOMCore/J2KSwiftCodec.swift,HTJ2KCodec.swift,JP3DCodec.swift): Replaces Apple ImageIO as the primary JPEG 2000 path on all platforms, enabling full Linux support via a pure-Swift scalar backend.J2KSwiftCodec: Handles JPEG 2000 Lossless (.90), JPEG 2000 Lossy (.91), Part 2 Lossless (.92), Part 2 Lossy (.93) with 8/12/16-bit grayscale and RGB support.HTJ2KCodec: Full HTJ2K Lossless (.201), HTJ2K RPCL Lossless (.202), HTJ2K Lossy (.203). Fast-path transcoder viaJ2KTranscoder(no pixel decode); 5.4× decode speedup over J2K on macOS arm64.JP3DCodec: ISO/IEC 15444-10 volumetric encoding/decoding for multi-frame CT/MR/PET series with lossless, lossless-HTJ2K, and lossy modes.
- JPIP streaming (
Sources/DICOMKit/DICOMJPIPClient.swift): Progressive 2D and 3D tile streaming for large remote studies; transfer syntaxes JPIP Referenced (.94) and JPIP Referenced Deflate (.95) registered.dicom-jpipCLI tool withfetch,uri,serve, andinfosubcommands.DICOMFile.openVolumeProgressively(serverURL:sliceJPIPURIs:qualityLayers:)API for huge CT/MR datasets.
- JP3D volume bridge (
Sources/DICOMKit/JP3DVolumeBridge.swift): Converts multi-frame DICOM series ↔J2KVolume; preservesSliceLocation,ImagePositionPatient,SeriesInstanceUID.JP3DVolumeDocument: Encapsulated document SOP (private SOP1.2.826.0.1.3680043.10.511.10) with.jp3dpayload + JSON sidecar; MIME typeapplication/x-jp3d.DICOMFile.openVolume(from:)/openVolume(from:jpipServerURL:)for unified volume access.
- Hardware acceleration (
CodecBackendenum): Metal (Apple GPU), Accelerate (SIMD), scalar fallback;CodecBackendProbeselects best available at runtime.--backendflag ondicom-compressanddicom-3d. dicom-j2kCLI tool (8 subcommands):info,validate,transcode,reduce,roi,benchmark,compare,completions. 53 tests.- DICOMStudio enhancements:
- Progressive decoding with
ProgressiveDecodeModel/ProgressiveImageView(AsyncStream-driven.quarter → .half → .completestate machine). - ROI decoding wired to pinch-zoom gestures.
- JP3D MPR views (axial / sagittal / coronal) via
JP3DMPRViewModel/JP3DMPRView. - JPIP loader with quality-layer slider.
- Progressive decoding with
- Transfer syntaxes added to registry,
DICOMValidator, andStorageSCPpresentation contexts:.htj2kLossless,.htj2kRPCLLossless,.htj2kLossy,.jpip,.jpipDeflate,.jpeg2000Part2Lossless,.jpeg2000Part2. - DICOMweb HTJ2K media types:
image/jphandimage/jphcadvertised in capability; WADO-RS accept headers updated. dicom-compress,dicom-convert,dicom-send,dicom-retrieve,dicom-viewer,dicom-info,dicom-validateextended for HTJ2K, JP3D, and JPIP transfer syntaxes.- Codec Inspector panel in DICOMStudio: shows decoder name, backend (Metal/Accelerate/scalar), and decode timing.
-
JPEG 2000 16-bit rendering pipeline: Fixed near-black output after conversion when preserving original bit depth
- Normalized ImageIO-decoded 16-bit JPEG 2000 samples back to the DICOM
Bits Storedrange inNativeJPEG2000Codec - Preserved original metadata for JPEG 2000 and JPEG 2000 Lossless conversions (
BitsAllocated,BitsStored,HighBit) - Verified CT-style datasets with VOI/Rescale tags render correctly after implicit VR → JPEG 2000 lossless transcoding
- Normalized ImageIO-decoded 16-bit JPEG 2000 samples back to the DICOM
-
DICOM Studio metadata consistency: Fixed transfer syntax source ordering in metadata loading
MetadataViewModelnow prefers File Meta Information(0002,0010)before dataset fallback- Aligns metadata display behavior with converted-file transfer syntax as stored on disk
-
Test Infrastructure: Fixed platform-specific test compilation errors
- Added
#if canImport(CoreGraphics)guards to ColorTransformTests for Apple platform-only APIs - Fixed DataElement initializer calls in ICCProfileAdvancedTests with missing length parameters
- Fixed ambiguous type references in SegmentationParserTests
- Tests now compile cleanly on Linux CI runners and Apple platforms
- Added
- Updated DICOM standard reference from 2025e to 2026a
- The 2026a release is now the current edition available at https://www.dicomstandard.org/current/
- Updated
dicomStandardEditionconstant to "2026a" - Updated all source code doc comments referencing DICOM PS3.x editions
- Updated conformance statement, FAQ, contributing guide, and README
- Key differences from 2025e to 2026a:
- New supplements including CT Image Storage for Processing (Sup252)
- Radiation Dose Structured Report (RDSR) informative annex (Sup245)
- Enhanced DICOMweb services (Sup248, Sup228)
- Data dictionary and controlled terminology updates
- Correction proposals addressing encoding clarifications and CID additions
- Improved sex and gender data representation
- Frame Deflate transfer syntax enhancements for segmentation encoding
- dicom-mpps (v1.2.6): Modality Performed Procedure Step (MPPS) operations
- N-CREATE operation for creating MPPS instances (procedure start)
- N-SET operation for updating MPPS instances (procedure completion/discontinuation)
- Support for IN PROGRESS, COMPLETED, and DISCONTINUED states
- Referenced SOP instance tracking
- MPPSService in DICOMNetwork module
- Complete CLI tool with create and update subcommands
- Documentation and README
- dicom-mwl (v1.2.5): Modality Worklist Management
- C-FIND query support for Modality Worklist Information Model
- WorklistQueryKeys with flexible filtering (date, station AET, patient, modality)
- JSON output support for automation
- Verbose mode for detailed attribute display
- ModalityWorklistService in DICOMNetwork module
- Complete CLI tool with query subcommand
- Documentation and README
1.0.0 - TBD
This is the first production-ready release of DICOMKit, a pure Swift DICOM toolkit for Apple platforms (iOS 17+, macOS 14+, visionOS 1+).
- Reading & Parsing: Full support for reading DICOM files with comprehensive parsing
- Transfer Syntaxes:
- Explicit VR Little Endian (1.2.840.10008.1.2.1)
- Implicit VR Little Endian (1.2.840.10008.1.2)
- Explicit VR Big Endian (1.2.840.10008.1.2.2)
- Deflated Explicit VR Little Endian (1.2.840.10008.1.2.1.99)
- Data Types: All standard DICOM Value Representations (VR) supported
- Specialized Types: Date, Time, DateTime, AgeString, PersonName, UniqueIdentifier, etc.
- Writing: Create and modify DICOM files with proper serialization
- UID Generation: Utilities for creating unique DICOM identifiers
- Uncompressed Images: Support for all standard photometric interpretations
- MONOCHROME1, MONOCHROME2
- RGB, PALETTE COLOR
- Compressed Images: Native codec support for:
- JPEG Baseline (Process 1)
- JPEG Extended (Process 2 & 4)
- JPEG Lossless & JPEG Lossless SV1
- JPEG 2000 (Lossless and Lossy)
- RLE Lossless (pure Swift implementation)
- Multi-frame Support: Handle image sequences
- CGImage Rendering: Native Apple platform integration for display
- Windowing: Window Center/Width support for grayscale images
- Core Infrastructure: PDU handling, association management
- C-ECHO: Verification service for connectivity testing
- C-FIND: Query services for searching DICOM archives (Patient, Study, Series, Image levels)
- C-MOVE & C-GET: Retrieve services for fetching studies and images
- C-STORE: Storage services (SCU and SCP)
- Single file and batch storage operations
- Progress tracking with AsyncSequence
- Storage SCP for receiving files
- Storage Commitment: N-ACTION based commitment verification
- Advanced Features:
- TLS/SSL support for secure connections
- Connection pooling and reuse
- Association timeout configuration
- Asynchronous API with Swift Concurrency
- WADO-RS: Retrieve studies, series, and instances via HTTP
- Multi-part response parsing
- Metadata retrieval
- Rendered image support
- QIDO-RS: Query services over HTTP
- Study, series, and instance queries
- Fuzzy matching support
- Pagination with limit/offset
- STOW-RS: Store instances via HTTP multipart upload
- Batch upload support
- Progress tracking
- UPS-RS: Unified Procedure Step worklist services
- Workitem creation, retrieval, updates
- State transitions (SCHEDULED → IN PROGRESS → COMPLETED/CANCELED)
- Subscription support for notifications
- Authentication: Bearer token and OAuth2 support
- TLS: Secure HTTPS connections with custom certificate validation
- Core Infrastructure: SR IOD parsing and document tree navigation
- Document Types: Support for all standard SR templates
- Basic Text SR, Enhanced SR, Comprehensive SR
- Key Object Selection
- Measurement reports
- CAD SR (Chest, Mammography)
- Content Items: All relationship types and value types supported
- Coded Terminology: SNOMED CT, LOINC, RadLex integration
- Measurement Extraction: Automated extraction of measurements and coordinates
- Document Creation: SR document builders with template validation
- Template Support: TID 1500 (Measurement Report), TID 1400 (Chest CAD SR), and more
- Grayscale Presentation State (GSPS): Annotations, LUT transformations, spatial transforms
- Color Presentation State (CSPS): Color management, blending operations
- Pseudo-Color: Color lookup tables, hot/cold mapping
- Protocol Definition: Screen layout and viewport configuration
- Matching Logic: Image set selection based on modality, anatomy, laterality
- Display Sets: Multi-image layout management
- RT Structure Set: ROI contours, structure visualization, volume calculation
- RT Plan: Beam definitions, treatment machine setup
- RT Dose: Dose grids, isodose curves, DVH (Dose-Volume Histogram)
- SEG IOD: Binary and fractional segmentation support
- Rendering: Segment overlay with configurable colors
- Builder API: Create segmentation objects programmatically
- Quantitative Imaging: Float pixel data support
- Real-World Value Mapping: Physical units, SUV calculation
- ICC Color Profiles: Professional color management
- Character Sets: ISO 2022, ISO 8859, GB18030, GBK, EUC-KR, Shift_JIS, UTF-8
- Private Tags: Vendor-specific tag dictionaries (GE, Siemens, Philips)
- Memory Optimization: Efficient large file handling
- SIMD Acceleration: Vectorized operations for image processing
- Lazy Loading: On-demand pixel data decompression
- DocC Catalogs: Comprehensive API documentation
- Platform Guides: iOS, macOS, visionOS integration guides
- DICOM Conformance: Formal conformance statement
- Multi-modality image viewer with gesture controls
- Windowing, pan, zoom, measurements
- Hanging protocol support
- Series browser with thumbnails
- Local file import and PACS integration
- Removed from the repository.
- dicom-info: Display DICOM file metadata
- dicom-dump: Detailed data element dump
- dicom-convert: Transfer syntax conversion
- dicom-anon: Anonymization tool
- dicom-validate: Conformance validation
- dicom-query: PACS query tool
- dicom-send: DICOM network send utility
- 27 Xcode Playgrounds demonstrating library features
- Integration examples for iOS, macOS, visionOS
- Network protocol examples
- Image processing examples
- Pure Swift: No Objective-C runtime dependencies
- Swift 6 Compliant: Full strict concurrency support
- Platform Native: Leverages Apple frameworks (ImageIO, CoreGraphics, RealityKit)
- Modern API: Swift Concurrency (async/await), Sendable types
- Comprehensive Testing: 1,920+ tests across core, networking, and applications
- Medical Imaging Standards: DICOM PS3.x compliant
- iOS: 17.0 and later
- macOS: 14.0 and later
- visionOS: 1.0 and later
- Swift: 6.0 and later
- Swift Argument Parser 1.3+ (for CLI tools only)
Add to your Package.swift:
dependencies: [
.package(url: "https://github.com/Raster-Lab/DICOMKit.git", from: "1.0.0")
]- README.md - Overview and quick start
- CONTRIBUTING.md - Contribution guidelines
- MILESTONES.md - Development roadmap
- Documentation/ - API documentation and guides
- Network integration tests require access to test PACS systems (documented for future)
- Transfer syntax conversion deferred to future versions
- Some advanced character sets deferred (ISO IR 100 extended)
- Store-and-forward networking features deferred
- No known vulnerabilities in dependencies
- HIPAA considerations documented
- PHI (Protected Health Information) handling guidelines provided
- Secure network communication with TLS support
This is the first stable release (v1.0.0). Future breaking changes will only occur in major version updates (2.0, 3.0, etc.).
Built with ❤️ by the DICOMKit team and contributors.
See LICENSE file for details.
For detailed development history of pre-release versions (v0.1 - v0.9, v1.0.1 - v1.0.15), see MILESTONES.md.
- v0.1: Core infrastructure, basic file parsing
- v0.2: Extended transfer syntax support
- v0.3: Pixel data access
- v0.4: Compressed pixel data
- v0.5: DICOM writing
- v0.6: Networking (Query/Retrieve)
- v0.7: Storage services
- v0.8: DICOMweb
- v0.9: Structured Reporting
- v1.0.1-v1.0.13: Advanced features
- v1.0.14: Example applications
- v1.0.15: Production release preparation