@cosyte/dicom 0.0.4 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,8 @@
1
+ <picture>
2
+ <source media="(prefers-color-scheme: dark)" srcset="https://cosyte.com/tile/cosyte-lockup-tile-on-dark-1200x300.png">
3
+ <img alt="Cosyte: a plus mark set in two overlapping rounded squares, one solid and one outlined, beside the Cosyte wordmark" src="https://cosyte.com/tile/cosyte-lockup-tile-on-light-1200x300.png">
4
+ </picture>
5
+
1
6
  # @cosyte/dicom
2
7
 
3
8
  > Read a real-world, vendor-quirky DICOM Part 10 file and pull the metadata you need in one line, without having read the DICOM standard.
@@ -26,7 +31,7 @@ import { parseDicom } from "@cosyte/dicom";
26
31
 
27
32
  const ds = parseDicom(await readFile("study.dcm"));
28
33
 
29
- ds.get("PatientName")?.value; // PN value: structured, never flattened
34
+ ds.get("00100010")?.value; // (0010,0010) Patient's Name: structured PN, never flattened
30
35
  ds.study.instanceUid; // "1.2.840.…": the global study anchor
31
36
  ds.image.rescaleSlope; // number | undefined: undefined means "absent", never 1
32
37
  ```
@@ -38,11 +43,11 @@ That's the pitch: no config, no schema upload, no spec lookup. The parser accept
38
43
  ## Features
39
44
 
40
45
  - **One-line metadata extraction**: `ds.patient`, `ds.study`, `ds.series`, `ds.image`: typed, fail-safe views over the safety-critical attributes. No `(group,element)` tags to memorise.
41
- - **Two access patterns**: named views, or structural `ds.get("PatientName")` / `ds.get("(0010,0010)")` by keyword or tag, plus `ds.elements()` to walk everything.
46
+ - **Two access patterns**: named views, or structural `ds.get("00100010")` by 8-character `(group,element)` tag (resolve a keyword to its tag with `Dictionary.byKeyword`), plus `ds.elements()` to walk everything.
42
47
  - **Lazy typed value decode**: `element.value` decodes raw bytes into a discriminated `DicomValue` across all 34 VRs (numbers, `bigint`s, person names, dates/times, sequences, raw `binary`), honoring `(0008,0005)` Specific Character Set through nested items.
43
- - **Real-world tolerance, Postel's Law**: a lenient reader emits 24 stable warning codes for what it tolerated; only 4 truly-structural conditions are fatal. The serializer always writes spec-clean Part 10.
48
+ - **Real-world tolerance, Postel's Law**: a lenient reader emits 25 stable warning codes for what it tolerated; only 4 truly-structural conditions are fatal. The serializer always writes spec-clean Part 10.
44
49
  - **Source/vendor profile system**: `defineProfile()` + 5 built-ins (`ge`, `siemens`, `philips`, `strict`, `lenient`) that only ever _tighten or annotate_ a parse, resolving vendor private tags by the file's live Private Creator string, never a wrong decode.
45
- - **Metadata-level de-identification**: `deidentify()` applies the PS3.15 Annex E Basic Profile + the nine metadata Options, returning a fresh dataset and a value-free audit report.
50
+ - **Metadata-level de-identification**: `deidentify()` applies the PS3.15 Annex E Basic Profile + the nine metadata Options, returning a fresh dataset and an audit report that is value-free apart from the source UIDs in `report.uidMap`.
46
51
  - **Spec-clean serializer**: `serializeDicom(ds)` round-trips a dataset back to Part 10 bytes in its source transfer syntax (no transcode), with correct File Meta group length, even-length padding, byte-exact sequence passthrough, and lossless File Meta: non-modeled `(0002,xxxx)` elements are preserved and re-emitted in tag order.
47
52
  - **Strict TypeScript, dual ESM + CJS, Node ≥ 22**: `noUncheckedIndexedAccess`, no `any`, JSDoc + `@example` on every public export feeding your editor's IntelliSense. Zero runtime dependencies today.
48
53
 
@@ -87,15 +92,20 @@ s.instanceUid; // "1.2.840.…" Study Instance UID (0020,000D)
87
92
  s.accessionNumber; // ties the study to the HIS order (0008,0050)
88
93
  ```
89
94
 
90
- ### By keyword or tag
95
+ ### By tag
91
96
 
92
- `get` reaches any element by keyword **or** by `(group,element)` tag through the same path; `has`, `getAll` (for repeating tags), and `elements()` round out structural access.
97
+ `get`, `has` and `getAll` take the **8-character `(group,element)` tag** (case-insensitive) and only that: `"00080060"`, not `"Modality"` and not `"(0008,0060)"`. A keyword resolves to its tag through the dictionary first. `getAll` is the always-array complement of `get` (a dataset holds at most one element per tag, so it returns 0 or 1), and `elements()` walks everything.
93
98
 
94
99
  ```ts
95
- ds.get("Modality"); // by keyword
96
- ds.get("(0008,0060)"); // same element, by tag
97
- ds.has("PixelData"); // boolean
100
+ import { Dictionary } from "@cosyte/dicom";
101
+
102
+ ds.get("00080060"); // Modality (0008,0060)
103
+ ds.has("7FE00010"); // boolean: is Pixel Data present
98
104
  ds.elements(); // readonly Element[]: walk everything
105
+
106
+ // Prefer keywords? Resolve one to its tag, then get by tag.
107
+ const tag = Dictionary.byKeyword("Modality")?.tag; // "00080060"
108
+ ds.get(tag ?? "");
99
109
  ```
100
110
 
101
111
  ### Typed values
@@ -103,10 +113,10 @@ ds.elements(); // readonly Element[]: walk everything
103
113
  `get` returns an `Element`; its `.value` lazily decodes the raw bytes into a discriminated `DicomValue` and caches the result.
104
114
 
105
115
  ```ts
106
- const rows = ds.get("Rows")?.value; // US
116
+ const rows = ds.get("00280010")?.value; // Rows, a US
107
117
  if (rows?.kind === "numbers") rows.values[0]; // 512
108
118
 
109
- const name = ds.get("PatientName")?.value; // PN
119
+ const name = ds.get("00100010")?.value; // Patient's Name, a PN
110
120
  if (name?.kind === "personName") name.values[0]?.alphabetic.givenName; // "Jane"
111
121
  ```
112
122
 
@@ -141,7 +151,7 @@ async function indexFile(path: string) {
141
151
  }
142
152
  ```
143
153
 
144
- Nothing here throws on a quirky file; absent fields come back `undefined`. Check `ds.warnings` if you want to log what was tolerated.
154
+ A quirky object is tolerated rather than rejected, and absent fields come back `undefined`. Check `ds.warnings` to log what was tolerated. A folder walk **does** still need a `try`/`catch`, because all four Tier-3 conditions throw and a real archive meets all four: `UNSUPPORTED_TRANSFER_SYNTAX` for a pixel-compressed object, which this parser does not read; `INVALID_FILE_META` for a truncated or partly-copied file; `NOT_DICOM_PART_10` for whatever non-DICOM file wandered into the folder; and `EMPTY_INPUT` for a zero-byte one. They all throw the one class, so catch `DicomParseError` per file and skip.
145
155
 
146
156
  ### Build routing keys
147
157
 
@@ -173,7 +183,7 @@ img.photometricInterpretation; // (0028,0004) never defaulted to MONOCHROME2
173
183
  img.pixelSpacing; // (0028,0030) patient-plane mm, distinct from imagerPixelSpacing
174
184
  ```
175
185
 
176
- > **Vendor note.** Philips writes private rescale tags `(2005,1409/140A/140B)` that shadow the standard `(0028,1052/1053)`; using the standard tags alone can yield non-quantitative values. This parser **preserves** the private tags so you can prefer them. Reach them with `ds.get("(2005,1409)")` (optionally under `profiles.philips`).
186
+ > **Vendor note.** Philips writes private rescale tags `(2005,1409/140A/140B)` that shadow the standard `(0028,1052/1053)`; using the standard tags alone can yield non-quantitative values. This parser **preserves** the private tags so you can prefer them. Reach them with `ds.get("20051409")` (optionally under `profiles.philips`).
177
187
 
178
188
  For Enhanced multi-frame objects, `image.frame(i)` resolves each frame's functional-group macros Per-Frame-else-Shared (PS3.3 C.7.6.16). It throws a `DicomValueError` (carrying only structural facts, never PHI) for an out-of-range frame or a required geometry macro missing from both groups.
179
189
 
@@ -187,7 +197,7 @@ if (img.isEnhancedMultiFrame) {
187
197
 
188
198
  ### De-identify before sharing
189
199
 
190
- `deidentify()` applies the PS3.15 Annex E Basic Application Level Confidentiality Profile (replacing, emptying, or removing every attribute the standard lists as identifying) and returns a fresh dataset plus a value-free report.
200
+ `deidentify()` applies the PS3.15 Annex E Basic Application Level Confidentiality Profile (replacing, emptying, or removing every attribute the standard lists as identifying) and returns a fresh dataset plus a report that is value-free apart from the source UIDs in `report.uidMap`.
191
201
 
192
202
  ```ts
193
203
  import { parseDicom, deidentify, serializeDicom } from "@cosyte/dicom";
@@ -262,7 +272,7 @@ const ds = parseDicom(buf, { profile: profiles.siemens });
262
272
 
263
273
  A profile bundles three things that only ever **tighten or annotate** a parse, never loosen it past the lenient default:
264
274
 
265
- - **Private-dictionary overlay**: resolves the Implicit VR of vendor private data elements by the file's _live_ Private Creator string (e.g. `"SIEMENS CSA HEADER"`), keyed canonically as `"GGGGxxLL"` (PS3.5 §7.8.1), never a hard-coded block number. (This is why Agfa IMPAX re-assigning blocks still resolves.) An unknown creator degrades to `UN` plus a `DICOM_PRIVATE_CREATOR_UNKNOWN` warning.
275
+ - **Private-dictionary overlay**: resolves the Implicit VR of vendor private data elements by the file's _live_ Private Creator string (e.g. `"SIEMENS CSA HEADER"`), keyed canonically as `"GGGGxxLL"` (PS3.5 §7.8.1), never a hard-coded block number. (This is why Agfa IMPAX re-assigning blocks still resolves.) An unknown creator degrades to `UN` plus a `DICOM_PRIVATE_CREATOR_UNKNOWN` warning. The lookup is scoped to one Data Set, and every Sequence Item is its own (PS3.5 §7.5.1, §7.8.1): a block claimed at the root does not resolve an element inside an item, and an element whose block was never claimed in its own Data Set reads `UN` plus `DICOM_PRIVATE_TAG_NO_CREATOR` rather than borrowing a neighbour's VR. Declare the creator in each item that writes private data.
266
276
  - **Escalations**: Tier-2 warning codes promoted to a thrown `DicomParseError` (a stricter posture for known-unsafe deviations).
267
277
  - **Suppressions**: benign, high-volume warning codes silenced for a known-quirky source.
268
278
 
@@ -293,7 +303,7 @@ At an RSNA-era interoperability test, ~80% of real-world patient CDs failed stri
293
303
  | 2 | Warning | Recoverable deviation | `DICOM_MISSING_PREAMBLE` |
294
304
  | 3 | Fatal (always) | Unrecoverable structural error | `NOT_DICOM_PART_10` |
295
305
 
296
- Tier-2 warnings are plain data on `ds.warnings`. Each carries a stable string `code`, a PHI-free `message`, and a `position` with the byte offset where it occurred, so you can react programmatically:
306
+ Tier-2 warnings are plain data on `ds.warnings`. Each carries a stable string `code`, a `message` looked up from a frozen registry (never composed from the document), and a `position` with the byte offset where it occurred, so you can react programmatically:
297
307
 
298
308
  ```ts
299
309
  import { parseDicom, WARNING_CODES } from "@cosyte/dicom";
@@ -306,7 +316,7 @@ for (const w of ds.warnings) {
306
316
  }
307
317
  ```
308
318
 
309
- The 24 Tier-2 codes (`DICOM_MISSING_PREAMBLE`, `DICOM_FILE_META_GROUP_LENGTH_MISMATCH`, `DICOM_UN_PARSED_AS_SQ`, `DICOM_ODD_LENGTH_VALUE_PADDED`, `DICOM_PRIVATE_CREATOR_UNKNOWN`, `DICOM_VR_MISMATCH`, `DICOM_DA_LEGACY_FORMAT`, … ) live in [`src/parser/warnings.ts`](./src/parser/warnings.ts). Narrow on `w.code === WARNING_CODES.…` for typo-free comparisons, or pass `{ onWarning }` to `parseDicom` to stream them.
319
+ The 25 Tier-2 codes (`DICOM_MISSING_PREAMBLE`, `DICOM_FILE_META_GROUP_LENGTH_MISMATCH`, `DICOM_UN_PARSED_AS_SQ`, `DICOM_ODD_LENGTH_VALUE_PADDED`, `DICOM_PRIVATE_CREATOR_UNKNOWN`, `DICOM_VR_MISMATCH`, `DICOM_DA_LEGACY_FORMAT`, … ) live in [`src/parser/warnings.ts`](./src/parser/warnings.ts). Narrow on `w.code === WARNING_CODES.…` for typo-free comparisons, or pass `{ onWarning }` to `parseDicom` to stream them.
310
320
 
311
321
  The 4 Tier-3 fatal codes (`NOT_DICOM_PART_10`, `INVALID_FILE_META`, `UNSUPPORTED_TRANSFER_SYNTAX`, `EMPTY_INPUT`) always throw a `DicomParseError`; they represent input the parser cannot meaningfully recover.
312
322
 
@@ -314,11 +324,11 @@ The 4 Tier-3 fatal codes (`NOT_DICOM_PART_10`, `INVALID_FILE_META`, `UNSUPPORTED
314
324
 
315
325
  ## Error Handling
316
326
 
317
- The library throws four typed errors, all exported from the package barrel; warnings are data, never thrown.
327
+ The library throws five typed errors, all exported from the package barrel. Warnings are data rather than throws unless you ask otherwise: a profile's `escalate` list promotes only the codes it names.
318
328
 
319
329
  ### `DicomParseError`
320
330
 
321
- Thrown by `parseDicom` on one of the 4 Tier-3 fatal codes. Carries the byte position and a PHI-free message.
331
+ Thrown by `parseDicom` on one of the 4 Tier-3 fatal codes. Carries the byte position, a registry-composed message, and a 16-byte hex `snippet` of the source. The snippet is raw input: treat it as PHI and redact it at your own boundary.
322
332
 
323
333
  ```ts
324
334
  import { parseDicom, DicomParseError, FATAL_CODES } from "@cosyte/dicom";
@@ -350,13 +360,14 @@ Thrown by `serializeDicom` for `MISSING_TRANSFER_SYNTAX` (the dataset names no t
350
360
 
351
361
  `@cosyte/dicom` is metadata-first by design. Even at v1-complete, do **not** rely on it for:
352
362
 
353
- - **Pixel data.** No decode/decompression of _any_ transfer syntax (JPEG / JPEG-LS / JPEG2000 / RLE / HTJ2K); no rendering; no measurements. Encapsulated pixel data is exposed as raw fragments. → `@cosyte/dicom-pixel`.
363
+ - **Pixel data.** No decode/decompression, no rendering, no measurements: Pixel Data is exposed as raw bytes. And v1 does not read a **compressed object at all**, not even structurally: a transfer syntax outside the four listed below is the fatal `UNSUPPORTED_TRANSFER_SYNTAX`, so JPEG / JPEG-LS / JPEG2000 / RLE / HTJ2K objects do not parse. → `@cosyte/dicom-pixel`.
354
364
  - **Burned-in PHI.** v1 **warns** it cannot remove burned-in annotation; a "de-identified" output is **metadata-de-identified only**.
365
+ - **A sequence the parser could not open.** `deidentify()` recurses only into a sequence whose items the parser materialized, so anything nested inside one it could not read is neither cleaned nor listed in the report. Two shapes reach that state and **only one of them warns**: a defined-length Implicit VR LE value whose dictionary-resolved `SQ` is not an item stream raises `DICOM_SQ_NOT_DESCENDED`, while an undefined-length `UN` value the CP-246 descent could not read as a sequence is **silent** (it keeps `vr === "UN"` and raises nothing). So `ds.warnings` is worth reading but is not sufficient: the reliable test is `el.items === undefined` on an `SQ` or `UN` element you are trusting the report about.
355
366
  - **Networking & web.** No DIMSE (C-STORE/FIND/MOVE, MWL, MPPS); no DICOMweb (QIDO/WADO/STOW). → `@cosyte/dicom-net`, `@cosyte/dicomweb`.
356
367
  - **Transcoding.** No transfer-syntax conversion. The serializer re-emits in the dataset's source syntax only.
357
368
  - **Terminology resolution.** Coded values are surfaced (designator + canonical source) but not validated against SNOMED/LOINC/etc.
358
369
 
359
- Supported transfer syntaxes (structure for all; **pixels never decoded**): Implicit VR LE `1.2.840.10008.1.2`, Explicit VR LE `…1.2.1`, Deflated Explicit VR LE `…1.2.1.99`, Explicit VR BE `…1.2.2` (retired, legacy-only), and any compressed syntax at the structural level (fragments preserved).
370
+ Supported transfer syntaxes, and **exactly** these four (**pixels never decoded** in any of them): Implicit VR LE `1.2.840.10008.1.2`, Explicit VR LE `…1.2.1`, Deflated Explicit VR LE `…1.2.1.99`, Explicit VR BE `…1.2.2` (retired, legacy-only). Any other UID, which includes every pixel-compressed syntax (JPEG, JPEG-LS, JPEG2000, RLE, HTJ2K), is rejected by `parseDicom` with the fatal `UNSUPPORTED_TRANSFER_SYNTAX` rather than read structurally. Deflated is the one compressed syntax in the supported set: it deflates the whole dataset stream rather than the pixels, and it is inflated on parse.
360
371
 
361
372
  ---
362
373