Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .changeset/brave-pandas-remove.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
---
"@cosyte/dicom": minor
---

Remove a retained private value this run did not enumerate, instead of shipping it under
`(0012,0062) = YES`.

`RetainSafePrivate` plus a `Profile` used to write private values into de-identified output that the
run never looked inside. `0.0.19` disclosed that (`DICOM_DEIDENT_PRIVATE_CARRIER_NOT_AUDITABLE` plus
a `report.unauditableSequences` entry stamped as kept) and said outright that the disclosure was not
a fix. It is fixed now: PS3.15 2026c §E.3.10 retains Private Attributes "known by the de-identifier
to be safe from identity leakage" and sends "all other Private Attributes" to removal or to the
`(0008,0307)` action this library does not implement, so a value nothing enumerated is removed.

**The over-redaction is the point rather than a corner case, and this is the size of it.** Three
classes of private value reach the output on something this run enumerated: one the run **walked as
Data Elements** and put through the Annex E action table (a private `SQ` whose items the parser
materialized), a **Private Creator `(gggg,00EE)` whose whole decoded value is a member of your
profile's private dictionary**, and a **zero-length** value. Decoding a value under the VR your
profile declares for it is not enumeration, and neither is the embedded-attribute scanner's silence,
so **an ordinary vendor scalar under an ordinary string VR that the file's own declaration does not
name safe is removed**. If you carried opaque vendor values through `RetainSafePrivate`, they are
gone unless your senders declare them: getting them back otherwise needs the content test that
separates a nested Data Set from a legitimate binary blob, which is an open product question. The
declaration route that keeps the fourth class is the other entry in this release, and what it
retains rests on the sender's assertion rather than on anything this run examined.

**Three things to act on if you consume the audit surfaces.**

1. **New report surface.** `report.unenumerablePrivateRemovals` records each removal per instance:
the tag, the Data Set it lived in, `applied: "removed"` and `reason: "unenumerable"`. It is
complete and **never capped** at any input size, which is what lets you separate an unenumerable
removal from an Annex E one and from an emptied value; the matching warnings stay bounded.
`report.removedPrivateTags` names these removals too, unchanged in meaning.
2. **`report.unauditableSequences` no longer produces its retired `kept` outcome** for a retained
private value. Its `applied` field was `"emptied" | "kept"` and is `"emptied"` alone now, so an
entry there means content is not in your output again. A comparison against the retired outcome
stops compiling, which is the intended way to find out.
3. **`DICOM_DEIDENT_PRIVATE_CARRIER_NOT_AUDITABLE` changes meaning**, from "this value was shipped
unexamined" to "this attribute was removed unexamined". **No published warning code is renamed or
retired**, so a consumer narrowing on that code name keeps compiling and must re-read what it now
means rather than re-type it. This work adds no code; the code this release does add,
`DICOM_DEIDENT_PRIVATE_DECLARATION_NOT_RESOLVED`, belongs to the declaration entry beside it.

The measured matrix in `test/integration/deident-private-reservation.test.ts` flips its twenty
leaking cells from kept-verbatim to removed, on every combination of profile-declared VR and on-wire
VR it enumerates and on the VR-less Implicit VR LE encoding, and gains a mutation control that reds
if the retention decision is reverted.
40 changes: 40 additions & 0 deletions .changeset/coded-method-sequence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
"@cosyte/dicom": patch
---

`deidentify()` now writes `(0012,0064)` De-identification Method Code Sequence on every run, beside
the `(0012,0063)` text, so a consumer can read which Profile and Options produced an object from
codes instead of parsing English, and can tell the two PS3.15 §E.3.6 temporal branches apart by
`113106` and `113107`.

PS3.15 2026c §E.1.1 asks for "one or more codes from CID 7050 "De-identification Method"
corresponding to the Profile and Options used ... and/or a text string". The text already satisfied
the "and/or", so this is the machine-readable half rather than a conformance fix.

- **What is written.** A top-level `SQ` with an Item for each code from PS3.16 2026d CID 7050
(context group version `20170914`, UID `1.2.840.10008.6.1.925`): `113100` Basic Application
Confidentiality Profile first, then an Item for each active Option in `DEIDENTIFY_OPTIONS` order,
whatever order `retain` names them in. Each Item carries exactly Code Value, Coding Scheme
Designator `DCM` and Code Meaning.
- **Added to, never replaced.** A prior `(0012,0064)` the source carried keeps every Item, in order
and byte for byte, and this run's Items follow. A code a prior Item already records under the same
designator and value is not added again, so de-identifying an output again with the same options
is a fixed point.
- **New public export** `DEIDENTIFICATION_METHOD_CODES` (with the `DeidentificationMethodCode` type)
carries the CID 7050 rows, the context group UID and version, and the code each Option writes.
- **Two new stable warning codes on `report.warnings`**, never on `Dataset.warnings`:
`DICOM_DEIDENT_METHOD_CODES_PRIOR_RETAINED` when prior Items were kept (neither `(0012,0064)` nor
the code attributes in its Items has a Table E.1-1 row, so what a sender wrote there reaches the
output un-inspected, and no prior code is checked against CID 7050), and
`DICOM_DEIDENT_METHOD_CODES_PRIOR_REPLACED` when a prior `(0012,0064)` was not a Sequence of Items
this run could read and was replaced by this run's codes. Neither message carries the prior's bytes
or VR. `DICOM_DEIDENT_METHOD_PRIOR_RETAINED` keeps its published meaning, `(0012,0063)` only.

**Limits, stated with the capability.** `113101` Clean Pixel Data Option and `113102` Clean
Recognizable Visual Features Option are never written, because this layer does not touch pixels.
`113107` says the modified-dates column was resolved, not that any date was shifted: the same limit
`(0028,0303) = MODIFIED` carries, and `DICOM_DEIDENT_DATES_NOT_TRANSFORMED` still rides with it. An
Option's code means the Option was active, whether or not the object carried an attribute it acts
on, and a caller's `deidentificationMethod` text never changes the codes. Only the top-level
attribute is written or read. `(0012,0062)`, `(0012,0063)` and `(0028,0303)` are written exactly as
before.
45 changes: 45 additions & 0 deletions .changeset/dicomdir-record-tree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
"@cosyte/dicom": patch
---

A DICOMDIR's Directory Record tree is now read, and `serializeDicom()` writes every record offset as
the byte offset of the record it named, so a DICOMDIR this library rewrites or de-identifies still
points each record at the right bytes. Until now a DICOMDIR parsed as an ordinary Data Set and every
write carried the source's offsets into a file whose layout had moved (a rebuilt File Meta group,
Data Sets put in ascending order, Items `deidentify()` re-encoded), so a viewer could step from one
patient's record into another's images.

- **New `Dataset.directory`** (types `DicomDirectory`, `DirectoryRecord`): for an object whose
`(0002,0002)` is `1.2.840.10008.1.3.10`, every Item of the Directory Record Sequence `(0004,1220)`
as a record with its `(0004,1430)` type, its `(0004,1500)` Referenced File ID components (verbatim)
and its lower-level records, plus the root entity `(0004,1200)` names. `undefined` for anything
else. Read under Implicit VR LE, Explicit VR LE and Explicit VR BE.
- **New `Item.fileOffset`**: where the parser found an Item's `(FFFE,E000)` tag, counted from the
first byte of the File Preamble, as PS3.3's Basic Directory IOD counts DICOMDIR offsets (a file read
without a preamble still counts the 132 bytes it lacks). `deidentify()` carries it to the Item it
rebuilds; it is the identity the writer ties each offset to. An offset names a record only when it
equals a Directory Record Sequence Item's `fileOffset`, never an Item tag found by scanning.
- **New Tier-2 codes, each raised only by a non-conformant DICOMDIR, with registry-only messages:**
`DICOM_DIRECTORY_OFFSET_UNRESOLVED` (an offset that is not a record's Item tag: inside a record, on
an Item of a Sequence nested in a record, on the Sequence header, past the end),
`DICOM_DIRECTORY_OFFSET_MALFORMED` (a Value Length other than 4),
`DICOM_DIRECTORY_RECORD_REVISITED` (an offset that would reach a record already reached; it is in
the tree once and the parse finishes) and `DICOM_DIRECTORY_OFFSET_DEFLATED` (a Deflated DICOMDIR
with a non-zero offset, which PS3.10 does not allow, since it requires a DICOMDIR File to use
Explicit VR Little Endian; no offset resolves). No
offset value, record index or record key reaches a message. `{ strict: true }` escalates them.
- **New `SERIALIZE_ERROR_CODES` members `DIRECTORY_OFFSET_UNRESOLVED` and
`DIRECTORY_OFFSET_DEFLATED`.** The writer refuses, and returns no bytes for, a DICOMDIR carrying an
offset it cannot tie to a record the Dataset holds (one that did not resolve on read, one left after
the Directory Record Sequence was removed or emptied, one on a hand-built Dataset with no records,
a value that is not one 32-bit unsigned integer) and a Deflated DICOMDIR with a non-zero offset. It
never writes the stale value or zero. Both messages are fixed strings with no digit in them.
- **`DICOM_DEIDENT_DICOMDIR_FILE_SET_NOT_DISCHARGED` keeps its code and its trigger** (every run on a
DICOMDIR); its message now names only the two File-set clauses of PS3.15 §E.1.1 this run does not
discharge, a DICOMDIR created from the de-identified files and the removal of the non-de-identified
DICOMDIR from the File-set, and no longer says the directory records were not de-identified or that
DICOMDIR is not modelled.
- **Limits, stated beside the capability in `docs-content/limitations.md`:** Referenced File IDs are
kept verbatim; no File-set view; record keys, `(0004,1202)` against the end of the root chain, the
File-set Consistency Flag and Private Record UIDs are not checked; the retired MRDR offset
`(0004,1504)` is written as read.
18 changes: 18 additions & 0 deletions .changeset/dose-calculation-model-name.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@cosyte/dicom": patch
---

The data dictionary and the de-identification action table are now generated from the **PS3.6 and
PS3.15 2026d** DocBook, re-pinned together under `vendor/nema/` by SHA-256. PS3.5 stays at 2026c.

- **`deidentify()` now removes Dose Calculation Model Name `(3004,007F)` under the Basic Profile
(`X`), and cleans it under `CleanDescriptors` (`C`)**, at the top level and inside Sequence Items
alike, and the report audits it. 2026d adds that attribute to Table E.1-1; against the 2026c table
it had no row, so it was kept verbatim under every Option with a report that said nothing about
it. No other Option changes its treatment.
- **`Dictionary.lookup("3004007F")`** now returns `DoseCalculationModelName`, VR `LO`, VM `1`, so an
Implicit VR Little Endian object reads that element as `LO` rather than `UN`.
- **The report name for `(0018,11BB)` changes case**, from "Acquisition Field Of View Label" to
"Acquisition Field of View Label", as 2026d prints it. Its action codes (`D`, and `C` under
`CleanDescriptors`) do not change. A caller matching `DeidentifiedAttribute.keyword` for that tag
on the old spelling needs the new one.
35 changes: 35 additions & 0 deletions .changeset/encapsulated-serialize.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
"@cosyte/dicom": patch
---

`serializeDicom()` now writes objects under every Transfer Syntax PS3.5 2026c section A.4 names
for encapsulated Pixel Data (JPEG, JPEG-LS, JPEG 2000, HTJ2K, RLE, MPEG, HEVC, JPEG XL, Deflated
Image Frame and Encapsulated Uncompressed), the same generated list `parseDicom()` reads. It used to
refuse each one with `UNSUPPORTED_TRANSFER_SYNTAX`, so a JPEG or JPEG 2000 object run through
`deidentify()` could not be saved. The File Meta Transfer Syntax UID is kept, nothing is
transcoded, and no pixel is decoded.

- **The Data Set is written as Explicit VR Little Endian**, as section A.4 requires: the bytes after
the File Meta group are the bytes the same Data Set writes under Explicit VR LE.
- **Top-level Pixel Data `(7FE0,0010)` is written as `OB` of undefined length**: the input's Basic
Offset Table Item and every fragment Item byte for byte and in order (Item tag, Item Length and
value), then a Sequence Delimitation Item of length zero. The header is written this way whatever
VR and reserved bytes the input span carried. No offset table is interpreted or rebuilt and no
frame is assembled.
- **New `SERIALIZE_ERROR_CODES` member `INVALID_ENCAPSULATED_PIXEL_DATA`.** Under a section A.4
syntax the writer refuses, rather than repairs, a top-level Data Set the section does not allow:
no top-level Pixel Data; Float or Double Float Pixel Data `(7FE0,0008)` / `(7FE0,0009)`; Pixel Data
with a defined Value Length; a fragment stream not ended by its Sequence Delimitation Item
(including one `parseDicom()` read with `DICOM_PIXEL_DATA_FRAGMENTS_NOT_DELIMITED`, since a
delimiter appended to a stream that may be short would be a well-formed file missing frames);
anything other than an Item of defined length before the delimiter; an Item of odd length; a
fragment Item of length zero after the Basic Offset Table; no Basic Offset Table Item or no
fragment Item; and bytes after the delimiter. Nothing is returned on a refusal, and the message is a
fixed string that carries no length, byte or UID from the Dataset.
- **Unchanged:** the four native syntaxes serialize exactly as before; every other UID (JPIP
Referenced, SMPTE ST 2110, every retired UID) still throws `UNSUPPORTED_TRANSFER_SYNTAX`, and a
missing one `MISSING_TRANSFER_SYNTAX`. Pixel Data nested in a Sequence Item, such as an Icon Image
Sequence, is written as read and not checked. `parseDicom()` and `deidentify()` are untouched, and
burned-in annotation is still reported by `DICOM_BURNED_IN_ANNOTATION_NOT_REMOVED`, never removed.
- The `UNSUPPORTED_TRANSFER_SYNTAX` message now says the writer also supports every section A.4
syntax. The code is unchanged.
38 changes: 38 additions & 0 deletions .changeset/encapsulated-transfer-syntaxes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
"@cosyte/dicom": patch
---

`parseDicom()` now reads objects under every encapsulated Pixel Data Transfer Syntax that PS3.5
2026c section A.4 names.

Those are JPEG Baseline, Extended, Lossless and Lossless First-Order Prediction; RLE Lossless;
JPEG-LS Lossless and Near-Lossless; JPEG 2000 and HTJ2K; MPEG2; MPEG-4 AVC/H.264; HEVC/H.265 Main
and Main 10; JPEG XL; Deflated Image Frame Compression; and Encapsulated Uncompressed Explicit VR
LE. Such an object used to throw `UNSUPPORTED_TRANSFER_SYNTAX` and lose its
metadata with it. Section A.4 makes the whole Data Set Explicit VR Little Endian with only Pixel Data
`(7FE0,0010)` encapsulated, so it is read by the Explicit VR LE reader and its patient, study, series
and image views read exactly as the same Data Set does under Explicit VR LE. The list is read out of
the vendored, SHA-pinned PS3.5 by a new generator that joins `gen:all` and the byte-identical regen
gate, and each UID is checked against the PS3.6 registry as a non-retired Transfer Syntax.

- **New: `readPixelDataFragments(ds)`** returns a `PixelDataFragments`: the Basic Offset Table's
Item Value and every fragment's Item Value as raw bytes, in file order, with no Item header in any
of them. An empty Basic Offset Table reads as zero bytes. It returns `undefined` when Pixel Data is
absent or native. The buffers follow `copyValues` like `Element.rawBytes`.
- **No pixel is ever decoded.** Nothing is decompressed, no frame is assembled from fragments, and
neither the Basic Offset Table nor an Extended Offset Table is interpreted.
- **`serializeDicom()` refuses every one of these syntaxes** with `UNSUPPORTED_TRANSFER_SYNTAX`, a
de-identified object included, so a compressed object is read and not written.
- **An empty Basic Offset Table no longer raises `DICOM_EMPTY_ITEM_IN_SEQUENCE`.** Section A.4 makes
it the conformant form decoders must accept. A later empty fragment still raises it. A caller that
switched on that code for a conformant encapsulated file under Explicit VR LE no longer sees it.
- **New Tier-2 code `DICOM_PIXEL_DATA_FRAGMENTS_NOT_DELIMITED`** for a fragment stream that ends
with the input before its Sequence Delimitation Item. The metadata still parses and the fragment
list may be short; `{ strict: true }` refuses it. Its message carries no fragment byte, length or
count.
- **Still refused:** the JPIP Referenced syntaxes (their Pixel Data is a reference, not fragments),
the SMPTE ST 2110 syntaxes, and every retired UID, the retired JPEG processes included. The fatal
names the syntax by its PS3.6 registry name, never by the UID, and its message now summarises the
supported set instead of listing four UIDs.
- **Not diagnosed:** an encapsulation-syntax object with no top-level Pixel Data, or with native
top-level Pixel Data, parses without a code, as the same bytes do under Explicit VR LE.
30 changes: 30 additions & 0 deletions .changeset/first-minor-release.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@cosyte/dicom": minor
---

**This is 0.1.0, the first release whose public API we treat as settled.**

What is covered, and what you can build against:

- Reading DICOM Part 10 metadata: the four typed views (`patient`, `study`, `series`, `image`),
access to any element by tag with the Part 6 data dictionary compiled in, and lazy typed decode of
all 34 value representations, honoring Specific Character Set.
- The four native transfer syntaxes and every PS3.5 2026c section A.4 encapsulation syntax are read;
encapsulated pixel data comes back as raw fragments, and a DICOMDIR reads as a record tree.
- Lenient parsing: only four structural conditions are fatal, and every tolerated deviation is a
stable warning code with a byte offset. Vendor profiles (`ge`, `siemens`, `philips`, `strict`,
`lenient`, or your own through `defineProfile`) only ever tighten or annotate a parse.
- A spec-clean serializer that writes the source transfer syntax back, and metadata
de-identification with the PS3.15 Annex E Basic Profile and the metadata-affecting options,
returning a fresh dataset and an audit report.

What the version promises. The exported names, options, return shapes and warning codes are the
surface we keep stable. While the package is below 1.0, a breaking change bumps the minor version
(0.1 to 0.2) and is called out in this changelog with its migration; a fix that changes no public
value ships as a patch.

What is not covered yet. Pixel data is never decoded, in any transfer syntax. A de-identified output
is metadata-de-identified only: burned-in annotation is warned about, not removed, and the
residuals the README lists under known limitations are disclosed rather than closed. There is no
transcoding between transfer syntaxes, no DIMSE networking and no DICOMweb, and coded values are
surfaced but not validated against a terminology.
7 changes: 7 additions & 0 deletions .changeset/first-use-examples-executed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@cosyte/dicom": patch
---

Docs: the README's first usage example and the quickstart's first example are now executed by the test suite, read straight out of the page they are printed on.

The README example claims its values as checked results now, against a synthetic CT object saved without its preamble, and one claim moved in the process: the study instance UID it shows is the one that object actually carries, not a placeholder. The quickstart's object is byte-identical to a fixture the test corpus builds, so the PHI scan reads it with the rest of the fixtures. Both examples are also compiled with the settings `tsc --init` writes for a new TypeScript project, so an example that does not compile fails the suite too. A changed value in either example fails the suite, and every install command the README and the installation page print is checked against the package's own name.
Loading
Loading