@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 +218 -0
- package/LICENSE +21 -0
- package/README.md +381 -0
- package/dist/index.cjs +14492 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2577 -0
- package/dist/index.d.ts +2577 -0
- package/dist/index.mjs +14455 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +105 -0
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
|
+
[](https://www.npmjs.com/package/@cosyte/dicom)
|
|
6
|
+
[](https://github.com/cosyte/dicom/actions/workflows/ci.yml)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+
[](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)._
|