@cosyte/dicom 0.0.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,218 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@cosyte/dicom` will be documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4
+
5
+ ## [Unreleased]
6
+
7
+ ### Fixed
8
+
9
+ - **`private: true` removed — `@cosyte/dicom` can publish.** The flag dated to the very first
10
+ scaffold commit and was never explained; `changeset publish` silently skips a private package, so
11
+ this repo was the one parser that could not reach npm even once its pipeline worked. It also
12
+ contradicted the `publishConfig: { access: "public" }` in the same file. Removed as part of the
13
+ coordinated public launch (`PUB-FLIP`).
14
+
15
+ - **The release can actually bump the version.** `package.json` had no `version` script, so the
16
+ shared pipeline's `pnpm run version` failed with `Command "version" not found` and the release
17
+ aborted before opening a "Version Packages" PR. Adds `scripts/sync-version.mjs` (the `hl7`
18
+ reference, retargeted at `src/version.ts`) and the `version` script that runs it after
19
+ `changeset version`, so the bump and the `VERSION` constant land in the same commit.
20
+ - **`VERSION` is no longer typed as a string literal.** It was declared `export const VERSION =
21
+ "0.0.0"`, giving it the literal type `"0.0.0"` — so the exported type would change on every
22
+ release, making each version bump a breaking type change. Now annotated `: string`, matching the
23
+ `hl7` reference. Type-only; the runtime value is unchanged. Done now because the package is
24
+ unpublished — after the first publish this would itself be a breaking change.
25
+
26
+ - **The Release workflow can actually start.** `.github/workflows/release.yml` calls the shared
27
+ `cosyte/.github` pipeline, which requests `contents`/`id-token`/`pull-requests: write`, but declared
28
+ no `permissions:` of its own — so it inherited the repo default of `contents: read`. A called
29
+ workflow may only downgrade the caller's `GITHUB_TOKEN`, never escalate it, so GitHub rejected the
30
+ workflow at startup (~1s, no jobs, no logs). Every Release run from June 2026 until now failed this
31
+ way, unnoticed, because a `startup_failure` produces no logs to read. The caller job now declares
32
+ the three scopes explicitly. CI-only — no runtime or API change.
33
+
34
+ ### Security
35
+
36
+ - **Dev-dependency advisory remediation (no runtime impact — the published
37
+ artifact is unchanged).** Added scoped `pnpm.overrides` pinning two
38
+ transitive **dev/build-time** packages to their patched releases: `esbuild`
39
+ (`>=0.27.3 <0.28.1` → `0.28.1`; GHSA dev-server path-traversal — not
40
+ reachable here: the library builds via `tsup`/`vitest` and never runs
41
+ `esbuild serve`) and the `@changesets/parse` copy of `js-yaml`
42
+ (`>=4.0.0 <4.2.0` → `4.2.0`; GHSA-h67p-54hq-rp68 merge-key DoS). The
43
+ `js-yaml@3.14.2` pulled by `read-yaml-file@1.1.0` (via
44
+ `@manypkg/get-packages` → `@changesets/cli`) is **intentionally left**: it
45
+ calls `yaml.safeLoad`, removed/throwing in js-yaml 4, so it cannot be
46
+ force-upgraded without breaking the release tooling, and it only parses
47
+ trusted local repo YAML at release time. This is the shared canonical
48
+ override block, enforced suite-wide by the `@cosyte/config` drift check.
49
+
50
+ ### Added
51
+
52
+ - **Lossless File Meta round-trip.** The parser now retains non-modeled
53
+ `(0002,xxxx)` File Meta elements — e.g. `(0002,0017)`/`(0002,0018)`
54
+ Sending/Receiving AE Title, `(0002,0100)` Private Information Creator UID,
55
+ `(0002,0102)` Private Information — as raw on-wire bytes on the new
56
+ `FileMeta.extraElements` view (each a `FileMetaRawElement` carrying the tag,
57
+ its Explicit-VR-LE VR, and a defensively copied even-length value). The
58
+ serializer merges these with the typed fields and emits the whole group in
59
+ ascending tag order (PS3.5 §7.4) with a recomputed `(0002,0000)` group length
60
+ (PS3.10 §7.1), so an exotic File Meta group now round-trips byte-for-byte —
61
+ not just the typed fields. New exported type: `FileMetaRawElement`. This
62
+ resolves the Phase 5 serializer known-limitation ("only the typed `FileMeta`
63
+ fields round-trip").
64
+ - **Documentation completeness (Phase 8).** Rewrote `README.md` into a full developer guide — quickstart,
65
+ feature tour, a "DICOM in 90 seconds" primer, the two access patterns, an 80/20 **cookbook** (index a
66
+ folder, build routing keys, read pixel-interpretation metadata safely, de-identify, bridge to FHIR
67
+ `ImagingStudy` / HL7 v2, round-trip serialize), the four-tier tolerance model, the warning/fatal code
68
+ taxonomy, typed-error handling, and an explicit known-limitations / non-goals section. Every public
69
+ export now carries a JSDoc `@example`. Extended the dual ESM/CJS smoke harnesses to exercise the full
70
+ Phase 1–7 published surface so the documented entrypoints are guaranteed importable from both module
71
+ systems. Also corrected an `intro.md` snippet that referenced a nonexistent `ds.pixelData` getter (the
72
+ real accessor is `ds.get("PixelData")?.value`). Docs-only — no runtime API change.
73
+ - **Metadata-level de-identification (Phase 7).** New `deidentify(ds, options?)` applies the PS3.15
74
+ Annex E **Basic Application Level Confidentiality Profile** plus the nine metadata-affecting Annex E
75
+ Options (`RetainUIDs`, `RetainLongitudinalTemporal`, `RetainPatientCharacteristics`,
76
+ `RetainDeviceIdentity`, `RetainInstitutionIdentity`, `RetainSafePrivate`, `CleanDescriptors`,
77
+ `CleanStructuredContent`, `CleanGraphics`), driven by the generated Table E.1-1 action map. It is a
78
+ **pure** function — the input `Dataset` is never mutated; it returns a fresh de-identified `Dataset`
79
+ and a value-free `DeidentifyReport` (tags, keywords, resolved action codes, the UID map, warnings).
80
+ Each attribute's action (`D` dummy, `Z` zero-length, `X` remove, `K` keep, `C` clean, `U` consistent
81
+ UID) is resolved from the Basic Profile, overridden by any active Option; conditional codes (`Z/D`,
82
+ `X/Z`, `X/D`, `X/Z/D`, `X/Z/U*`, `C/X`) collapse to their most-protective **leftmost** branch (the
83
+ tool does no IOD Type-1 conformance analysis, so it fails safe toward _more_ removal). `U`-coded UIDs
84
+ are remapped to deterministic, content-derived `2.25` replacements that stay referentially consistent
85
+ across files (`makeUidRemapper`, default root `DEFAULT_UID_ROOT`). Kept sequences are recursively
86
+ de-identified and **re-encoded** so nested PHI is removed from the serialized bytes, not just the
87
+ object model. Private attributes are removed by default; `RetainSafePrivate` + a `Profile` keeps only
88
+ the creator-recognized safe private elements. `(0012,0062)` Patient Identity Removed = `YES` and
89
+ `(0012,0063)` De-identification Method are written automatically. Pixel-level cleaning is out of scope
90
+ (deferred to `@cosyte/dicom-pixel`): when Pixel Data is present and not affirmatively marked free of
91
+ burned-in text, a `DICOM_BURNED_IN_ANNOTATION_NOT_REMOVED` warning is raised rather than silently
92
+ passing identifying pixels. New public exports: `deidentify`, `makeUidRemapper`, `DEFAULT_UID_ROOT`,
93
+ `DEIDENTIFY_OPTIONS`, `DEIDENTIFY_ERROR_CODES`, `DeidentifyError`, and the types `UidRemapper`,
94
+ `AppliedAction`, `DeidentifiedAttribute`, `DeidentifyErrorCode`, `DeidentifyOption`,
95
+ `DeidentifyOptions`, `DeidentifyReport`, `DeidentifyResult`. The reserved
96
+ `DICOM_BURNED_IN_ANNOTATION_NOT_REMOVED` warning code is now actively emitted (no change to the
97
+ `WARNING_CODES` registry surface).
98
+ - **Source/vendor profile system (Phase 6).** New `defineProfile()` factory builds an immutable,
99
+ composable `Profile` that a parse opts into via `parseDicom(buf, { profile })`. A profile bundles
100
+ three things that only ever _tighten or annotate_ a parse, never loosen it past the lenient default:
101
+ `escalate` (Tier-2 warning codes promoted to a thrown `DicomParseError`), `suppress` (codes silenced
102
+ as a documented benign quirk of the source), and `privateTags` (a private-creator-keyed overlay that
103
+ resolves the Implicit VR of vendor private data elements). Private resolution is keyed on the file's
104
+ **live** private-creator string and the canonical `"GGGGxxLL"` key (PS3.5 §7.8.1) — never a
105
+ hard-coded block number — so the same vendor schema resolves regardless of which block it landed in.
106
+ Profiles compose via `extends` (de-duplicated lineage, union of escalations/suppressions, child-wins
107
+ dictionary merge) and expose a deterministic `describe()` summary. Five built-ins ship under the
108
+ frozen `profiles` namespace: three vendor overlays (`ge`, `siemens`, `philips`, grounded in the
109
+ public GDCM / dcm4che / dcm2niix private dictionaries) and two posture presets (`strict` escalates
110
+ integrity-relevant warnings; `lenient` suppresses cosmetic, high-volume ones). A creator the active
111
+ profile does not recognize degrades to generic `UN` plus the new `DICOM_PRIVATE_CREATOR_UNKNOWN`
112
+ warning — never a wrong decode. Selecting a profile never changes a correct decode. New public
113
+ exports: `defineProfile`, `profiles`, `ProfileDefinitionError`, and the types `Profile`,
114
+ `PrivateTagDefinition`, `DefineProfileOptions`, `ProfilePrivateTags`; `ParseOptions` gains an
115
+ optional `profile` field. The reserved `DICOM_PRIVATE_CREATOR_UNKNOWN` code is now actively emitted
116
+ (no change to the `WARNING_CODES` registry surface).
117
+ - **Spec-clean Part 10 serializer (Phase 5).** New `serializeDicom(ds)` writes a `Dataset` back to a
118
+ DICOM Part 10 `Buffer` — the conservative half of Postel's Law. Emits the 128-byte zero preamble +
119
+ `DICM`, a File Meta group (always Explicit VR LE) with a computed `(0002,0000)` group length and
120
+ conservative Type-1 defaults (File Meta Version `0x0001`, cosyte Implementation Class UID under the
121
+ `2.25` UUID arc), then the dataset body in the dataset's own transfer syntax — **no transcode** —
122
+ across all four v1 syntaxes (Implicit VR LE, Explicit VR LE/BE, Deflated Explicit VR LE). Scalar
123
+ values are padded to even length per PS3.5 §6.2 (`0x00` for `UI`/byte-stream VRs, `0x20` for text),
124
+ short vs long-form headers are chosen by VR per §7.1.2 (`SV`/`UV` long-form), retired `(gggg,0000)`
125
+ group-length elements are omitted per §7.2, and sequence + encapsulated-pixel-data spans pass
126
+ through byte-for-byte per §7.5 / §A.4. Pure function — the input `Dataset` is never mutated.
127
+ - **Serializer error taxonomy.** New `DicomSerializeError` with codes `MISSING_TRANSFER_SYNTAX`
128
+ (no File Meta Transfer Syntax UID) and `UNSUPPORTED_TRANSFER_SYNTAX` (a UID outside the v1 set) —
129
+ separate from the parser's fatal codes and the value layer's `DicomValueError`. The message is
130
+ built only from the code + the offending Transfer Syntax UID (structural facts), never a decoded
131
+ value, so it is always safe to log. New public exports: `serializeDicom`, `DicomSerializeError`,
132
+ `SERIALIZE_ERROR_CODES`, `SerializeErrorCode`.
133
+
134
+ ### Known limitations
135
+
136
+ - **File Meta round-trip is over the modeled surface, not byte-exact.** Only the typed `FileMeta`
137
+ fields round-trip; any other `(0002,xxxx)` element a source file carried (e.g. `(0002,0100)` Private
138
+ Information Creator UID) is dropped at _parse_ time (the Phase 2 `FileMeta` view does not model it)
139
+ and so cannot be re-emitted. The preamble is normalized to zeros and odd-length values are padded
140
+ even — the output stays spec-clean but is not a byte-identical copy of a non-conformant input.
141
+
142
+ ### Tests
143
+
144
+ - **Enhanced multi-frame coverage (DICOM-COV).** Closed the Per-Frame-else-Shared branch gaps left by
145
+ the Phase 4 functional-group resolver (`functional-groups.ts`: ~53% → 100% branch): both optional
146
+ macros (Pixel Value Transformation `(0028,9145)`, Frame VOI LUT `(0028,9132)`), Pixel Measures
147
+ `spacingBetweenSlices`, shared-only resolution (no Per-Frame Functional Groups Sequence), the
148
+ lenient inner-attribute-absence paths (a macro item present but its attributes omitted ⇒ typed-absent,
149
+ never coerced), and all three `MISSING_REQUIRED_FUNCTIONAL_GROUP` throws (Pixel Measures / Plane
150
+ Position / Plane Orientation). Synthetic fixtures only; no public-surface change. Per-directory
151
+ coverage now sits genuinely ≥ 90 on every gated directory (global branches 93.2%).
152
+
153
+ ### Added
154
+
155
+ - **Safety-critical domain helpers (Phase 4).** New `Dataset` accessors `patient` / `study` /
156
+ `series` / `image` return typed, fail-safe views over the DICOM §4 safety-critical attributes
157
+ (memoized on first access). `patient` surfaces the `{id, issuerOfId, issuerQualifiers}` identity
158
+ tuple plus Other Patient IDs (so a caller never matches on a bare, non-unique `(0010,0020)`) and
159
+ keeps `PN` structured; `study`/`series` surface the cross-system UIDs, accession number, modality
160
+ and Frame of Reference UID. `image` surfaces the pixel-interpretation + geometry metadata a
161
+ renderer needs — with the safety-critical omissions intact: `rescaleSlope` is **absent** (not `1`)
162
+ when the tag is absent, `signed` is absent (never guessed) unless `(0028,0103)` was present,
163
+ `photometricInterpretation` is never defaulted to `MONOCHROME2`, and the three pixel-spacing tags
164
+ (`(0028,0030)` / `(0018,1164)` / `(0018,2010)`) are distinct, never aliased.
165
+ - **Enhanced multi-frame functional groups.** `image.frame(i)` resolves the per-frame macros
166
+ Per-Frame-else-Shared (PS3.3 §C.7.6.16): Pixel Measures, Plane Position, Plane Orientation, Pixel
167
+ Value Transformation, Frame VOI LUT. `image.isEnhancedMultiFrame` flags such objects.
168
+ - **Value-layer error taxonomy.** New `DicomValueError` (codes `FRAME_INDEX_OUT_OF_RANGE`,
169
+ `MISSING_REQUIRED_FUNCTIONAL_GROUP`) — separate from the parser's four fatal codes. The helpers are
170
+ otherwise fail-safe (typed-absent for missing data) and throw only for a structural contract
171
+ violation; the error message carries only structural facts (indices, tag/macro names), never a
172
+ decoded PHI value.
173
+ - **Coded terminology.** `readCode` reads the `Code Value`/`Coding Scheme Designator`/`Code Meaning`
174
+ triplet and resolves the canonical scheme OID via `codingSchemeOid` / `CODING_SCHEME_OIDS` for the
175
+ four standard designators (`DCM`/`SCT`/`UCUM`/`LN`); legacy SNOMED designators
176
+ (`SRT`/`SNM3`/`99SDM`) deliberately do **not** resolve to `SCT` (CP-730). Real World Value Mappings
177
+ bind slope/intercept atomically to their measurement-units code.
178
+ - Public types: `PatientView`, `OtherPatientId`, `StudyView`, `SeriesView`, `ImageView`,
179
+ `CodedConcept`, `RealWorldValueMap`, `FrameFunctionalGroups`, `ValueErrorCode`, plus
180
+ `VALUE_ERROR_CODES` and the `readCode` / `codingSchemeOid` / `CODING_SCHEME_OIDS` helpers.
181
+ - **VR value decode + dataset navigation (Phase 3).** `Element.value` now lazily decodes (and
182
+ memoizes) an element's raw bytes into a typed, discriminated `DicomValue` covering all 34 VRs —
183
+ numbers (`US/UL/SS/SL/FL/FD`), 64-bit `bigint`s (`SV/UV`), attribute tags (`AT`), person names
184
+ (`PN` → 3-group / 5-component), strings, free text, numeric strings (`DS/IS` → `number | null`,
185
+ never `NaN`→0), temporal values (`DA/TM/DT`), sequences (`SQ` → threaded items), and raw `binary`
186
+ for the bulk VRs. Decode is fail-safe (never throws, never coerces a malformed value to a
187
+ plausible-but-wrong one) and surfaces per-value `warnings` with stable codes + byte offsets.
188
+ - String decode honors `(0008,0005)` Specific Character Set, threaded through the parser per
189
+ dataset/SQ-item scope: UTF-8 (`ISO_IR 192`), the ISO-8859 single-byte family, and ISO-2022
190
+ multibyte, with three term-list corrections vs PS3.3 §C.12.1.1.2 (no `ISO_IR 14`; `IR 87/159` are
191
+ code-extension-only; `ISO_IR 203` Latin-9 is included). An unknown term emits
192
+ `DICOM_UNSUPPORTED_CHARSET` and falls back to a best-effort decode.
193
+ - `Dataset`/`Item` navigation API: `get` / `has` / `elements` / `getAll`, tag lookup
194
+ case-insensitive.
195
+ - Public surface: `decodeElementValue`, `parseSpecificCharacterSet`, `isKnownCharsetTerm`,
196
+ `resolveDecoderLabel`, `decodeText`, `parsePersonName`, `parseDate`, `parseTime`, `parseDateTime`,
197
+ and the `DicomValue` / `PersonName` / `DicomDate` / `DicomTime` / `DicomDateTime` types.
198
+ - Initial repo scaffold (Phase 1).
199
+ - Unit coverage for the PS3.15 Annex E lookup helper (`annexE`), enabling the per-directory
200
+ coverage gate on `src/dictionary/`.
201
+ - Adopted the shared `@cosyte/test-utils` conformance kit (first parser to do so) and added a
202
+ `fast-check` property + fuzz test layer under `test/property/`: synthetic-only generators
203
+ (`_arbitraries.ts`) plus invariant suites for round-trip fidelity, lenient-mode robustness,
204
+ parsed-model immutability, warning/fatal-code stability (snapshot), and a byte-parser fuzz sweep
205
+ that feeds arbitrary buffers + random truncations and asserts the parser only ever throws a
206
+ sanctioned Tier-3 `DicomParseError` — never an unexpected error, hang, or OOM. No public API
207
+ change. (devDeps: `@cosyte/test-utils@^0.0.1`, `fast-check@3.23.2`.)
208
+
209
+ ### Changed
210
+
211
+ - Migrated onto the shared cosyte engineering standard (Phase E): tooling now flows from the
212
+ published `@cosyte/*` config packages (`@cosyte/tsup-config`, `@cosyte/vitest-config`,
213
+ ESLint 10 via `@cosyte/eslint-config`) instead of repo-local copies; devDependencies pinned to
214
+ the canonical exact versions; `attw` build/publish gate added; the per-directory coverage gate is
215
+ now enabled (transient sub-90 floors with TODOs while the test layer fills in).
216
+ - CI/release workflows reduced to thin callers of the reusable `cosyte/.github` pipelines
217
+ (`ci.yml` runs the shared PHI scan; `release.yml` targets `@cosyte/dicom`). The repo-specific
218
+ byte-identical dictionary-regen workflow is kept and bumped to Node 22.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cosyte
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,381 @@
1
+ # @cosyte/dicom
2
+
3
+ > 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.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@cosyte/dicom.svg)](https://www.npmjs.com/package/@cosyte/dicom)
6
+ [![CI](https://img.shields.io/github/actions/workflow/status/cosyte/dicom/ci.yml?branch=main&label=CI)](https://github.com/cosyte/dicom/actions/workflows/ci.yml)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
8
+ [![Node](https://img.shields.io/badge/node-%3E%3D22-brightgreen.svg)](https://nodejs.org)
9
+
10
+ A developer-focused DICOM Part 10 parser and utility library for Node.js and TypeScript. **Metadata-first**: it reads the headers — patient, study, series, image, codes, UIDs — leniently and fast, exposes pixel data as raw bytes, and **never decodes pixels**. Sibling to [`@cosyte/hl7`](https://github.com/cosyte/hl7); same engineering bar.
11
+
12
+ ---
13
+
14
+ ## Quickstart
15
+
16
+ Useful output after install + parse. No DICOM spec knowledge required.
17
+
18
+ ```bash
19
+ # pnpm (recommended) — also works with: npm install @cosyte/dicom | yarn add @cosyte/dicom
20
+ pnpm add @cosyte/dicom
21
+ ```
22
+
23
+ ```ts
24
+ import { readFile } from "node:fs/promises";
25
+ import { parseDicom } from "@cosyte/dicom";
26
+
27
+ const ds = parseDicom(await readFile("study.dcm"));
28
+
29
+ ds.get("PatientName")?.value; // PN value — structured, never flattened
30
+ ds.study.instanceUid; // "1.2.840.…" — the global study anchor
31
+ ds.image.rescaleSlope; // number | undefined — undefined means "absent", never 1
32
+ ```
33
+
34
+ That's the pitch: no config, no schema upload, no spec lookup. The parser accepts vendor-quirky input by default — it tolerates a missing preamble, a wrong File Meta group length, odd-length values, and the dozen-or-so deviations real scanners emit — and records each as a stable warning code rather than failing. You reach for typed value decode, safety-critical views, profiles, or the serializer when you want them.
35
+
36
+ ---
37
+
38
+ ## Features
39
+
40
+ - **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.
42
+ - **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.
44
+ - **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.
46
+ - **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
+ - **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
+
49
+ ---
50
+
51
+ ## DICOM in 90 seconds
52
+
53
+ A DICOM Part 10 file is a 128-byte preamble + the `DICM` magic, then a **File Meta** group (always Explicit VR Little Endian) naming the **transfer syntax**, then the **dataset**: a flat, tag-ordered stream of **data elements**.
54
+
55
+ Each element is identified by a `(group,element)` **tag** (e.g. `(0010,0010)` = Patient's Name) and carries a two-letter **VR** (Value Representation — `PN`, `DA`, `US`, `SQ`, …) that says how to decode its bytes. Some elements are **sequences** (`SQ`): ordered lists of **items**, each a nested dataset. The transfer syntax decides endianness, whether VRs are written explicitly, and whether the stream is deflated.
56
+
57
+ ```
58
+ DICOM file
59
+ ├── preamble (128 bytes) + "DICM"
60
+ ├── File Meta group (0002,xxxx) — transfer syntax UID, SOP Class/Instance UID
61
+ └── dataset
62
+ ├── (0008,0060) Modality "CT"
63
+ ├── (0010,0010) PatientName PN "Doe^Jane"
64
+ ├── (0020,000D) StudyInstanceUID UI "1.2.840.…"
65
+ ├── (0028,0100) BitsAllocated US 16
66
+ └── (7FE0,0010) PixelData OW «raw bytes — not decoded»
67
+ ```
68
+
69
+ `@cosyte/dicom` reads all of that leniently and hands you typed accessors over it. The one thing it deliberately does **not** do is decode the pixels.
70
+
71
+ ---
72
+
73
+ ## Access patterns
74
+
75
+ ### Safety-critical views
76
+
77
+ The four views — `patient`, `study`, `series`, `image` — pull the right field out of the right tag for the jobs that matter most, and they are **fail-safe**: a missing value is typed-absent (`undefined`), never a substituted default.
78
+
79
+ ```ts
80
+ const p = ds.patient;
81
+ p.id; // "MRN-42" — NOT globally unique on its own…
82
+ p.issuerOfId; // …pair with the issuer for cross-system matching
83
+ p.name?.alphabetic.familyName; // structured PN, never flattened
84
+
85
+ const s = ds.study;
86
+ s.instanceUid; // "1.2.840.…" Study Instance UID (0020,000D)
87
+ s.accessionNumber; // ties the study to the HIS order (0008,0050)
88
+ ```
89
+
90
+ ### By keyword or tag
91
+
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.
93
+
94
+ ```ts
95
+ ds.get("Modality"); // by keyword
96
+ ds.get("(0008,0060)"); // same element, by tag
97
+ ds.has("PixelData"); // boolean
98
+ ds.elements(); // readonly Element[] — walk everything
99
+ ```
100
+
101
+ ### Typed values
102
+
103
+ `get` returns an `Element`; its `.value` lazily decodes the raw bytes into a discriminated `DicomValue` and caches the result.
104
+
105
+ ```ts
106
+ const rows = ds.get("Rows")?.value; // US
107
+ if (rows?.kind === "numbers") rows.values[0]; // 512
108
+
109
+ const name = ds.get("PatientName")?.value; // PN
110
+ if (name?.kind === "personName") name.values[0]?.alphabetic.givenName; // "Jane"
111
+ ```
112
+
113
+ Decode is fail-safe: it never throws and never coerces a malformed value to a plausible-but-wrong one (a bad `DS`/`IS` token becomes `null`, never `NaN`→0). Per-value deviations surface on the returned value's own `warnings`.
114
+
115
+ ---
116
+
117
+ ## Cookbook
118
+
119
+ Recipes for the jobs a metadata parser is actually asked to do. Every attribute cites the PS3 clause it reads.
120
+
121
+ ### Index a folder of studies
122
+
123
+ Pull a few fields out of each file to build a searchable index — the bread-and-butter PACS/archive job.
124
+
125
+ ```ts
126
+ import { readFile } from "node:fs/promises";
127
+ import { parseDicom } from "@cosyte/dicom";
128
+
129
+ async function indexFile(path: string) {
130
+ const ds = parseDicom(await readFile(path));
131
+ return {
132
+ patientId: ds.patient.id, // (0010,0020)
133
+ studyUid: ds.study.instanceUid, // (0020,000D)
134
+ seriesUid: ds.series.instanceUid, // (0020,000E)
135
+ sopInstanceUid: ds.image.sopInstanceUid, // (0008,0018)
136
+ modality: ds.series.modality, // (0008,0060)
137
+ accession: ds.study.accessionNumber, // (0008,0050)
138
+ rows: ds.image.rows, // (0028,0010)
139
+ columns: ds.image.columns, // (0028,0011)
140
+ };
141
+ }
142
+ ```
143
+
144
+ Nothing here throws on a quirky file; absent fields come back `undefined`. Check `ds.warnings` if you want to log what was tolerated.
145
+
146
+ ### Build routing keys
147
+
148
+ Routing and reconciliation hang off a small set of identifiers. Surface them correctly — a Patient ID without its issuer is ambiguous across systems (PS3.3 C.7.1.1).
149
+
150
+ ```ts
151
+ // Hierarchy keys for filing into Study → Series → Instance:
152
+ const studyKey = ds.study.instanceUid; // (0020,000D) — global anchor
153
+ const seriesKey = ds.series.instanceUid; // (0020,000E)
154
+ const instanceKey = ds.image.sopInstanceUid; // (0008,0018)
155
+
156
+ // Cross-system patient key — id ALONE is not unique; pair it with the issuer:
157
+ const p = ds.patient;
158
+ const patientKey = `${p.issuerOfId ?? "?"}|${p.id ?? "?"}`;
159
+ p.otherIds; // (0010,1002) Other Patient IDs Sequence — additional {id, issuer} pairs
160
+ ```
161
+
162
+ ### Read pixel-interpretation metadata safely
163
+
164
+ If you (or a downstream renderer) ever touch the pixels, the interpretation tags decide what the numbers _mean_. The dangerous DICOM failure is the confident, wrong image, so these views never default a missing value.
165
+
166
+ ```ts
167
+ const img = ds.image;
168
+ img.rescaleSlope; // (0028,1053) undefined ⇒ MUST NOT assume 1
169
+ img.rescaleIntercept; // (0028,1052) apply as: stored*slope + intercept
170
+ img.signed; // true/false only if (0028,0103) Pixel Representation was present; else undefined
171
+ img.bitsStored; // (0028,0101)
172
+ img.photometricInterpretation; // (0028,0004) never defaulted to MONOCHROME2
173
+ img.pixelSpacing; // (0028,0030) patient-plane mm — distinct from imagerPixelSpacing
174
+ ```
175
+
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`).
177
+
178
+ 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
+
180
+ ```ts
181
+ if (img.isEnhancedMultiFrame) {
182
+ const f = img.frame(0);
183
+ f.planePosition?.imagePositionPatient; // this frame's [x, y, z]
184
+ f.pixelMeasures?.pixelSpacing; // this frame's [row, col] mm
185
+ }
186
+ ```
187
+
188
+ ### De-identify before sharing
189
+
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.
191
+
192
+ ```ts
193
+ import { parseDicom, deidentify, serializeDicom } from "@cosyte/dicom";
194
+
195
+ const { dataset, report } = deidentify(parseDicom(buf));
196
+ const safe = serializeDicom(dataset); // safe to share — input dataset never mutated
197
+
198
+ report.attributes.length; // count of attributes acted on (each carries tag/keyword/action — no values)
199
+ report.warnings; // e.g. DICOM_BURNED_IN_ANNOTATION_NOT_REMOVED
200
+ ```
201
+
202
+ UIDs are remapped to deterministic `2.25` replacements that stay consistent across files, so a de-identified study still hangs together. Opt into any of the nine metadata-affecting Annex E Options to keep specific classes of attribute:
203
+
204
+ ```ts
205
+ // Keep original UIDs and acquisition dates; clean (rather than drop) free-text descriptions.
206
+ deidentify(parseDicom(buf), {
207
+ retain: ["RetainUIDs", "RetainLongitudinalTemporal", "CleanDescriptors"],
208
+ });
209
+ ```
210
+
211
+ This is **metadata-level** de-identification. Pixel cleaning is out of scope: when a file carries burned-in annotation this layer cannot remove, you get a `DICOM_BURNED_IN_ANNOTATION_NOT_REMOVED` warning rather than a false sense of safety (pixel cleaning is deferred to `@cosyte/dicom-pixel`).
212
+
213
+ ### Bridge to FHIR / HL7 v2
214
+
215
+ A common consulting ask is joining imaging to the rest of the record. The authoritative crosswalk is the FHIR [`ImagingStudy` "Mappings for DICOM"](https://build.fhir.org/imagingstudy-mappings.html) tab. The join keys a metadata parser must surface correctly:
216
+
217
+ ```ts
218
+ // → FHIR ImagingStudy
219
+ const imagingStudy = {
220
+ identifier: ds.study.instanceUid, // (0020,000D) → ImagingStudy.identifier (urn:dicom:uid)
221
+ subjectId: ds.patient.id, // (0010,0020) → Patient identifier (+ issuer (0010,0021))
222
+ started: ds.study.date, // (0008,0020)
223
+ series: {
224
+ uid: ds.series.instanceUid, // (0020,000E) → ImagingStudy.series.uid
225
+ modality: ds.series.modality, // (0008,0060) → ImagingStudy.series.modality
226
+ },
227
+ };
228
+
229
+ // → HL7 v2: Accession Number (0008,0050) is the HIS↔PACS workhorse, typically OBR-18.
230
+ const obr18 = ds.study.accessionNumber;
231
+ ```
232
+
233
+ > Series and SOP Instance UIDs are **not** represented in HL7 v2 — image-level identity lives only in DICOM.
234
+
235
+ ### Round-trip: read, edit, re-serialize
236
+
237
+ ```ts
238
+ import { parseDicom, serializeDicom } from "@cosyte/dicom";
239
+
240
+ const ds = parseDicom(buf);
241
+ const out = serializeDicom(ds); // spec-clean Part 10, same transfer syntax — no transcode
242
+ ```
243
+
244
+ The serializer is the conservative half of Postel's Law: it rebuilds the File Meta group with a correct `(0002,0000)` length, pads values to even length, and re-emits sequences and encapsulated pixel data byte-for-byte. The File Meta group round-trips losslessly — non-modeled `(0002,xxxx)` elements (Sending/Receiving AE Title, Private Information, etc.) are preserved on parse and re-emitted in ascending tag order.
245
+
246
+ ---
247
+
248
+ ## Profiles
249
+
250
+ Real files come from real vendors, and vendors deviate in documented, predictable ways. A **profile** lets you opt into source-specific tolerance without ever risking a wrong decode. Pass one to `parseDicom`:
251
+
252
+ ```ts
253
+ import { parseDicom, profiles } from "@cosyte/dicom";
254
+
255
+ // Resolve Siemens CSA private headers to their real VRs instead of UN.
256
+ const ds = parseDicom(buf, { profile: profiles.siemens });
257
+ ```
258
+
259
+ A profile bundles three things that only ever **tighten or annotate** a parse — never loosen it past the lenient default:
260
+
261
+ - **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.
262
+ - **Escalations** — Tier-2 warning codes promoted to a thrown `DicomParseError` (a stricter posture for known-unsafe deviations).
263
+ - **Suppressions** — benign, high-volume warning codes silenced for a known-quirky source.
264
+
265
+ Five built-ins ship under the `profiles` namespace: `ge`, `siemens`, `philips` (vendor overlays, grounded in the public GDCM / dcm4che / dcm2niix dictionaries) and `strict` / `lenient` (posture presets). Build your own with `defineProfile()` — it validates input, composes via `extends`, and returns a frozen profile:
266
+
267
+ ```ts
268
+ import { defineProfile, profiles } from "@cosyte/dicom";
269
+
270
+ const acmeStrict = defineProfile({
271
+ name: "acme-strict",
272
+ extends: profiles.strict,
273
+ privateTags: {
274
+ "ACME PRIV 01": { "0019XX10": { vr: "DS", keyword: "AcmeDose", name: "ACME Dose" } },
275
+ },
276
+ });
277
+ ```
278
+
279
+ ---
280
+
281
+ ## Real-World Tolerance
282
+
283
+ At an RSNA-era interoperability test, ~80% of real-world patient CDs failed strict conformance (Clunie / `dciodvfy`). A parser that rejects those files is useless on real integrations, so this one reads liberally and classifies every deviation:
284
+
285
+ | Tier | Behavior | When | Example codes |
286
+ | ---- | -------------- | ------------------------------ | ------------------------ |
287
+ | 0 | Silent | Spec-compliant input | — |
288
+ | 1 | Auto-handled | Trivial deviation, no warning | trailing-space tidy |
289
+ | 2 | Warning | Recoverable deviation | `DICOM_MISSING_PREAMBLE` |
290
+ | 3 | Fatal (always) | Unrecoverable structural error | `NOT_DICOM_PART_10` |
291
+
292
+ 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:
293
+
294
+ ```ts
295
+ import { parseDicom, WARNING_CODES } from "@cosyte/dicom";
296
+
297
+ const ds = parseDicom(buf);
298
+ for (const w of ds.warnings) {
299
+ if (w.code === WARNING_CODES.DICOM_UN_PARSED_AS_SQ) {
300
+ // a UN element was recovered as an implicit-VR sequence (CP-246)
301
+ }
302
+ }
303
+ ```
304
+
305
+ 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.
306
+
307
+ 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.
308
+
309
+ ---
310
+
311
+ ## Error Handling
312
+
313
+ The library throws four typed errors, all exported from the package barrel; warnings are data, never thrown.
314
+
315
+ ### `DicomParseError`
316
+
317
+ Thrown by `parseDicom` on one of the 4 Tier-3 fatal codes. Carries the byte position and a PHI-free message.
318
+
319
+ ```ts
320
+ import { parseDicom, DicomParseError, FATAL_CODES } from "@cosyte/dicom";
321
+
322
+ try {
323
+ parseDicom(Buffer.alloc(0));
324
+ } catch (err) {
325
+ if (err instanceof DicomParseError && err.code === FATAL_CODES.EMPTY_INPUT) {
326
+ // …
327
+ }
328
+ }
329
+ ```
330
+
331
+ ### `DicomValueError`
332
+
333
+ Thrown only by `image.frame(i)` — `FRAME_INDEX_OUT_OF_RANGE` for an index outside `[0, numberOfFrames)`, or `MISSING_REQUIRED_FUNCTIONAL_GROUP` when an enhanced object lacks a required geometry macro in both the Per-Frame and Shared groups. Value decode (`element.value`) never throws — it warns and returns `null`/typed-absent instead.
334
+
335
+ ### `DicomSerializeError`
336
+
337
+ Thrown by `serializeDicom` for `MISSING_TRANSFER_SYNTAX` (the dataset names no transfer syntax to write in) or `UNSUPPORTED_TRANSFER_SYNTAX`.
338
+
339
+ ### `ProfileDefinitionError` · `DeidentifyError`
340
+
341
+ `defineProfile()` throws `ProfileDefinitionError` for a structurally invalid profile; `deidentify()` throws `DeidentifyError` (`INVALID_OPTIONS`) for an unknown Retain option or malformed UID root. Both messages carry only structural facts — option names, the UID root — never a decoded value.
342
+
343
+ ---
344
+
345
+ ## Known limitations & non-goals
346
+
347
+ `@cosyte/dicom` is metadata-first by design. Even at v1-complete, do **not** rely on it for:
348
+
349
+ - **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`.
350
+ - **Burned-in PHI.** v1 **warns** it cannot remove burned-in annotation; a "de-identified" output is **metadata-de-identified only**.
351
+ - **Networking & web.** No DIMSE (C-STORE/FIND/MOVE, MWL, MPPS); no DICOMweb (QIDO/WADO/STOW). → `@cosyte/dicom-net`, `@cosyte/dicomweb`.
352
+ - **Transcoding.** No transfer-syntax conversion. The serializer re-emits in the dataset's source syntax only.
353
+ - **Terminology resolution.** Coded values are surfaced (designator + canonical source) but not validated against SNOMED/LOINC/etc.
354
+
355
+ 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).
356
+
357
+ ---
358
+
359
+ ## Roadmap
360
+
361
+ v1 is metadata-feature-complete. Future companion packages (separate repos, demand-sequenced):
362
+
363
+ - **`@cosyte/dicom-pixel`** — pixel decode/decompression, frame extraction, burned-in-annotation cleaning.
364
+ - **`@cosyte/dicom-net`** — DIMSE network services.
365
+ - **`@cosyte/dicomweb`** — QIDO / WADO / STOW REST clients.
366
+
367
+ ---
368
+
369
+ ## Contributing
370
+
371
+ Vendor-quirk fixtures (synthetic or properly de-identified), profile improvements, and dictionary corrections are all welcome — the more real-world edge cases the test suite covers, the more robust the parser gets. See [CONTRIBUTING.md](./CONTRIBUTING.md) if present, or open an issue.
372
+
373
+ ---
374
+
375
+ ## License
376
+
377
+ [MIT](./LICENSE) © Cosyte
378
+
379
+ ---
380
+
381
+ _Built by [Cosyte](https://cosyte.com)._