@cosyte/dicom 0.0.1 → 0.0.4
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 +414 -25
- package/README.md +57 -48
- package/TRADEMARKS.md +32 -0
- package/dist/index.cjs +651 -200
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +158 -133
- package/dist/index.d.ts +158 -133
- package/dist/index.mjs +651 -200
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -4
package/CHANGELOG.md
CHANGED
|
@@ -4,9 +4,392 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **The element registry is now generated from NEMA's PS3.6 DocBook, the normative publication**
|
|
10
|
+
(`DICOM-UPSTREAM-EDITION-LAG`). `vendor/nema/part06/` pins `part06.xml` (PS3.6 **2026c**) by
|
|
11
|
+
SHA-256, and `scripts/generate-dictionary.ts` applies it as a **per-field overlay** over the
|
|
12
|
+
Innolitics mirror: for a tag both sources carry, PS3.6 wins on name, keyword, VR, VM and
|
|
13
|
+
retirement; a tag PS3.6 carries and the mirror does not is added; a tag the mirror carries and
|
|
14
|
+
PS3.6 does not is **kept**, because PS3.6 retires elements rather than deleting them, so an absence
|
|
15
|
+
is far more likely to be a parse gap here than a withdrawal there and dropping the entry would turn
|
|
16
|
+
a decoded element into an unknown one (that set is empty today, and the generator prints its size).
|
|
17
|
+
The overlay is scoped to the four registry tables (6-1, 7-1, 8-1, 9-1). UIDs stay on the
|
|
18
|
+
`sops.json` + curated path deliberately: `uids.ts` carries the four short forms every DICOM toolkit
|
|
19
|
+
uses instead of PS3.6's "...: Default Transfer Syntax for ..." clauses, and retirement as a
|
|
20
|
+
structured `retired` boolean rather than a trailing " (Retired)" glued into the name, and an
|
|
21
|
+
overlay would undo both. PS3.15 Annex E is a different part and a different generator, unchanged.
|
|
22
|
+
This is the durable fix for the edition lag recorded in `vendor/innolitics/README.md`: the mirror's
|
|
23
|
+
pin is exactly current against its own upstream, but that upstream last refreshed its data on
|
|
24
|
+
2024-04-18, so the tables sat on PS3.6 2024b against a current 2026c and no re-pinning discipline
|
|
25
|
+
could have moved them. The registry no longer comes from a source whose regeneration cadence is not
|
|
26
|
+
ours.
|
|
27
|
+
**The pin is a precondition, not a comment.** The generator recomputes the SHA-256 of the file it
|
|
28
|
+
reads and refuses to generate on a mismatch, reads the edition from the document's own
|
|
29
|
+
`<subtitle>` rather than asserting it, and fails loudly rather than emitting a thinner dictionary
|
|
30
|
+
on a row that is not six cells, a malformed `(gggg,eeee)` tag cell, a non-identifier keyword, an
|
|
31
|
+
unrecognized VR token, or a total under 5,000 registry rows. Two traps in the DocBook are handled
|
|
32
|
+
explicitly and covered by tests: the keyword column carries **13,470 ZERO WIDTH SPACE** line-break
|
|
33
|
+
hints in 2026c, one of which left in produces a keyword that looks right and never matches; and the
|
|
34
|
+
sixth column carries `DICOS` and `DICONDE` dictionary markers alongside `RET (edition)`, so reading
|
|
35
|
+
it as a boolean would retire **391** live tags. Only a leading `RET` retires. Row accounting is
|
|
36
|
+
the part that had to be got right rather than merely asserted: every `<tr>` in a registry table
|
|
37
|
+
must resolve to a matched body row or a header row, which covers unrecognized row markup, a second
|
|
38
|
+
`<tbody>`, and a row outside any `<tbody>` in one check. Counting row opens inside the already
|
|
39
|
+
truncated first-`<tbody>` slice, as an earlier draft did, structurally cannot see the rows it
|
|
40
|
+
dropped: splitting Table 6-1 in two made that version read 207 rows instead of 5,309. Cell text
|
|
41
|
+
is likewise stripped of markup to a fixpoint and then refused if any `<` or `>` survives, rather
|
|
42
|
+
than stripped in a single pass that can leave a residue which reassembles into another tag.
|
|
43
|
+
Every run prints the overlay it applied (shared / added / mirror-only, and the fields overridden
|
|
44
|
+
broken out by field, **with every VR override listed individually**), so a future re-pin of either
|
|
45
|
+
source shows what moved instead of a 5,000-line diff. There is deliberately **no staleness clock**:
|
|
46
|
+
a date-based gate would fire the day it was written, demand an action nobody can take on demand
|
|
47
|
+
(NEMA publishes when NEMA publishes), red unrelated pull requests, and train people to bump a date
|
|
48
|
+
instead of re-deriving anything. "Has NEMA published a new edition" is one command against
|
|
49
|
+
_content_, documented in `vendor/nema/README.md`. What is gated in CI is unchanged and offline:
|
|
50
|
+
the committed dictionary must be byte-for-byte what the pinned inputs produce.
|
|
51
|
+
|
|
52
|
+
- **Em-dash brand gate in CI (`EMDASH-CONFORMANCE`).** The founder directive of 2026-07-24
|
|
53
|
+
(`knowledgebase/06-brand/voice-and-tone.md`, "No em dashes. Ever.") bans `U+2014` outright across
|
|
54
|
+
every cosyte surface and names commit messages explicitly, and the meta-repo's
|
|
55
|
+
`documentation/conventions.md` has stated for weeks that the rule is CI-gated. It now actually is,
|
|
56
|
+
here: `scripts/check-no-emdash.sh` (`pnpm check:no-emdash`) plus a dedicated
|
|
57
|
+
`.github/workflows/no-emdash.yml` job that scans **both** halves the rule covers, the tracked files
|
|
58
|
+
**and** the PR title, body, and commit messages. The workflow carries the non-default `edited`
|
|
59
|
+
pull-request trigger, which is load-bearing: this repo squash-merges under
|
|
60
|
+
`squash_merge_commit_title: COMMIT_OR_PR_TITLE` and `squash_merge_commit_message: COMMIT_MESSAGES`
|
|
61
|
+
(read off the repo, not assumed), so the subject that lands on `main` is the PR title, or the lone
|
|
62
|
+
commit's subject when the branch has exactly one, and the body is the branch commit messages.
|
|
63
|
+
Without `edited` a title changed after the last push would never be re-checked. The PR body does
|
|
64
|
+
**not** land; the gate scans it anyway, as deliberate over-strictness on a surface that costs
|
|
65
|
+
nothing to cover. It is a separate workflow rather than a job in `ci.yml` because that trigger
|
|
66
|
+
would otherwise re-run the whole Node 22 + 24 matrix on every typo fix, and because the shared
|
|
67
|
+
`cosyte/.github` pipeline runs no arbitrary repo script.
|
|
68
|
+
**This one did change content, unlike the earlier ports.** An ecosystem survey had measured
|
|
69
|
+
markdown only (0 of 25 `.md` files) and read dicom as already clean. Measuring all 178 tracked
|
|
70
|
+
files found six live em dashes in four non-markdown files, all removed here (see Changed, below).
|
|
71
|
+
Measure every tracked file, because that is what the scan covers.
|
|
72
|
+
The script is the **text-only** variant, taken from `ncpdp` (PR #34, `39212bb`) rather than from
|
|
73
|
+
the older `knowledgebase` copy, so it carries `ncpdp`'s two fixes to the shared shape instead of
|
|
74
|
+
inheriting the holes: a tracked file named `-` was read by `grep` as standard input (which `xargs`
|
|
75
|
+
points at `/dev/null`) and so was never opened, and `-d skip` silently passed a tracked symlink to
|
|
76
|
+
a directory. Both are re-proved here rather than taken on faith: with the `./` path prefix removed
|
|
77
|
+
the gate prints OK and exits 0 over a live em dash in a file named `-`, and with `-d skip` restored
|
|
78
|
+
it goes green over an unread symlink.
|
|
79
|
+
It deliberately omits `grep -I`, and in this repo that is the most load-bearing flag choice in the
|
|
80
|
+
file. `src/dataset/vr/charset.ts` holds a functional NUL byte inside `/[\x00 ]+$/u`, the regex that
|
|
81
|
+
strips DICOM's own padding, so grep classifies it binary. It carries no em dash today and scans
|
|
82
|
+
green, which is the whole answer to the belief that this port was blocked: the red would come from
|
|
83
|
+
a match, never from the NUL. **If it ever gains one, the gate reds.** Measured with GNU grep 3.8:
|
|
84
|
+
grep exits 0, writes nothing to stdout, and writes `binary file matches` to stderr, so the hit
|
|
85
|
+
never reaches the hit list and the stderr capture fails the run instead. With `-I` added, the same
|
|
86
|
+
edit goes green, which is why `-I` stays out. The failure message names that case explicitly rather
|
|
87
|
+
than blaming an I/O error that did not happen.
|
|
88
|
+
A gate that prints OK when it did not read its input is worse than no gate, so nine routes by which
|
|
89
|
+
a dead or blind scan could still report green are each checked red, not assumed: a corrupt git
|
|
90
|
+
index, an unreadable tracked file, a tracked file named `-q`, a C-quoted non-ASCII path, a
|
|
91
|
+
mis-encoded text file, an empty tracked-file list, a tracked file named `-`, a tracked symlink to a
|
|
92
|
+
directory, and the NUL-bearing source file gaining an em dash.
|
|
93
|
+
Known limits are documented in the script header and inherited knowingly from the shared shape
|
|
94
|
+
rather than patched in this copy alone: encoded-form matching is literal (so `—` without the
|
|
95
|
+
semicolon, and lowercase `%e2%80%94`, pass), stderr capture binds to the scanning `grep` rather
|
|
96
|
+
than to the filters ahead of it, an em dash encoded in a non-UTF-8 charset is not matched (a live
|
|
97
|
+
possibility here, given `(0008,0005)` Specific Character Set fixtures), and the scan reads file
|
|
98
|
+
**contents** only, so a tracked path that itself carries an em dash passes. Tooling only: no
|
|
99
|
+
runtime, public-API, or parse-behavior change.
|
|
100
|
+
- **`docs-content/` now covers the full canonical Diátaxis spine** (`DOCS-CONTENT-P6`). Beyond the
|
|
101
|
+
existing Overview (`intro`), the sidebar gains **Installation** and **Quickstart** (tutorials),
|
|
102
|
+
five **Core Concepts** notes (the object model, the tolerance/warning model, the typed value
|
|
103
|
+
layer, the safety-critical views, and the source-profile system), a **Guides** cookbook (four
|
|
104
|
+
recipes: re-serialize, de-identify, read raw pixel data, triage warnings), and a
|
|
105
|
+
**Troubleshooting & known limitations** reference. Every documented capability is grounded in the
|
|
106
|
+
package's actually-shipped surface; the metadata-first boundary is stated explicitly, with the
|
|
107
|
+
permanent non-goals named in Troubleshooting (**no pixel decode, no DIMSE networking, no
|
|
108
|
+
DICOMweb**, no pixel-level de-identification).
|
|
109
|
+
- **Doc/code-agreement gate (`test/docs-content.test.ts`).** Every ` ```ts runnable ` snippet in
|
|
110
|
+
`docs-content/` is extracted, compiled, and executed against the **built** package via
|
|
111
|
+
`docSnippetSuite()` from `@cosyte/vitest-config/snippets`, with its inline `// =>` assertions
|
|
112
|
+
checked, so a documented example can never silently drift from the code. All examples use
|
|
113
|
+
synthetic, base64-encoded Part 10 objects (invented patient, fake UIDs); no real PHI, no `.dcm`
|
|
114
|
+
file on disk.
|
|
115
|
+
|
|
7
116
|
### Fixed
|
|
8
117
|
|
|
9
|
-
- **`
|
|
118
|
+
- **`deidentify()` never removed and never reported the repeating-group attributes PS3.15 marks `X`**
|
|
119
|
+
(`DICOM-REPEATING-GROUP-DEID-GAP`). Table E.1-1 states three of its rows as a group mask rather
|
|
120
|
+
than a single tag: `(50xx,xxxx)` Curve Data, `(60xx,3000)` Overlay Data and `(60xx,4000)` Overlay
|
|
121
|
+
Comments, all three marked **X** (remove). The matcher keyed attributes by exact tag, which cannot
|
|
122
|
+
express a mask, so all three rows were unreachable; `deidentify()` reads "not in the table" as
|
|
123
|
+
"not listed, keep", so a file carrying `(6000,4000)` free text came back with a **clean report and
|
|
124
|
+
the text still in it**. Overlay comments are a classic burned-in-identifier carrier, and
|
|
125
|
+
`DICOM_BURNED_IN_ANNOTATION_NOT_REMOVED` gives no coverage here: it keys on `(7FE0,0010)` +
|
|
126
|
+
`(0028,0301)`, which describe the image, not the overlay planes. This shipped at `0.0.3`, and
|
|
127
|
+
`deid`'s `/dicom` adapter delegates here, so it had the same hole.
|
|
128
|
+
The three rows are now generated from the pinned PS3.15 DocBook as pattern rules and consulted on
|
|
129
|
+
an exact-tag miss. A matched element is **removed and reported**: the report entry carries the
|
|
130
|
+
concrete tag, the family's attribute name, and a new `DeidentifiedAttribute.repeatingGroup` field
|
|
131
|
+
naming the mask that matched, so an audit distinguishes a mask hit from a single-tag hit. The
|
|
132
|
+
`CleanGraphics` column is honoured on these rows exactly as the table states it. **The exact-tag
|
|
133
|
+
table wins** over a mask that would also cover the tag, as the more specific statement the standard
|
|
134
|
+
makes about it; 2026c publishes no such overlap and the generator counts and prints it every run.
|
|
135
|
+
**The mask covers the groups PS3.5 bounds it to, not any four hex digits.** PS3.5 §7.6: repeating
|
|
136
|
+
groups "shall only be allowed in the even numbered Groups 6000-601E", and PS3.5-2004 §7.6, which
|
|
137
|
+
the current edition's note delegates to for curves, states "even Groups (5000-501E,eeee)". Sixteen
|
|
138
|
+
groups per mask, not 256, and the same section says of the odd ones that there is "no implication
|
|
139
|
+
of repeating semantics". Both bounds are load-bearing in opposite directions: a hex wildcard would
|
|
140
|
+
remove attributes the standard never marked (data loss on a call asked to be conservative), and an
|
|
141
|
+
exact tag matched nothing. Odd groups in the overlay range are private and still go through the
|
|
142
|
+
private-attribute path.
|
|
143
|
+
**The generator now refuses a masked row on a prefix PS3.5 does not define as a repeating group**,
|
|
144
|
+
rather than printing it and carrying on, which is what let these three rows go missing. Proven by
|
|
145
|
+
mutation rather than asserted: against a Table E.1-1 carrying an injected `(7Fxx,0010)` family row
|
|
146
|
+
the pre-remedy generator exits 0 and drops it, the post-remedy one exits 1 and names the prefix. A
|
|
147
|
+
second mutation moves the Overlay Comments Basic Profile code `X` -> `K` and asserts the emitted
|
|
148
|
+
rule moves with it, so the action codes are read from the document rather than assumed. Every run
|
|
149
|
+
prints the rules emitted, the concrete groups each covers, the private family row it does not
|
|
150
|
+
emit, and the shadowing count.
|
|
151
|
+
- **`RetainLongitudinalTemporal` now documents which E.3.6 sub-option a caller actually gets.**
|
|
152
|
+
Behaviour is unchanged: PS3.15 §E.3.6 is two options and this package exposes one name carrying the
|
|
153
|
+
**full-dates** column, the less protective branch. It is `K` on all **169** rows where
|
|
154
|
+
modified-dates says `C`. The option's JSDoc and `docs-content/troubleshooting.md` now say so, and
|
|
155
|
+
say that date shifting is not performed at this layer. Splitting the option is a public-surface
|
|
156
|
+
change and is deliberately not made here.
|
|
157
|
+
- **`deidentify()` retained 35 patient-identifying attributes and reported nothing**
|
|
158
|
+
(`DICOM-ANNEX-E-DEID-LAG`). The PS3.15 Annex E action table came from a third-party mirror alone,
|
|
159
|
+
pinned at a snapshot whose Table E.1-1 data is 2024b-era. Current PS3.15 publishes 652 concrete
|
|
160
|
+
attributes; that snapshot carried 617. Because `annexE()` returns `undefined` for a tag it does not
|
|
161
|
+
carry and `deidentify()` reads `undefined` as "not listed, keep", every missing attribute came
|
|
162
|
+
through the call verbatim, and the `DeidentifyReport`, whose whole job is to say what was done,
|
|
163
|
+
said nothing about them. The caller's only signal was a clean return. This shipped at `0.0.3`.
|
|
164
|
+
**32 of the 35 are marked X** (remove) by the standard; the other three are `(0040,B020)` Waveform
|
|
165
|
+
Annotation Sequence (`X/D`), `(0070,0006)` Unformatted Text Value (`D`), and `(300A,0054)` Table Top
|
|
166
|
+
Position Alignment UID (`U`). The removals include `(0010,0011)` through `(0010,0016)`,
|
|
167
|
+
the person-names-to-use and pronoun block, of which `(0010,0012)` **Name to Use is a patient's
|
|
168
|
+
preferred name and survived verbatim**; `(0010,0041)` through `(0010,0047)`, the gender-identity
|
|
169
|
+
and sex-parameters-for-clinical-use attributes; `(0010,2161)` `EthnicGroupCodeSequence` and
|
|
170
|
+
`(0010,2162)` `EthnicGroups`, the two attributes that replaced the retired `(0010,2160)`
|
|
171
|
+
`EthnicGroup`; the four `(0008,130x)` diagnosis code sequences; and the waveform, montage,
|
|
172
|
+
acquisition-context and display-URI attributes at `(003A,xxxx)`, `(0040,Axxx)`, `(0040,Bxxx)` and
|
|
173
|
+
`(0040,E012)`. `(0032,1033)` `RequestingService` also gains the `CleanDescriptors: C` column the
|
|
174
|
+
mirror had dropped.
|
|
175
|
+
The fix is the same authority rule the data dictionary already uses, pointed at PS3.15.
|
|
176
|
+
`vendor/nema/part15/` pins `part15.xml` (PS3.15 **2026c**) by SHA-256, and
|
|
177
|
+
`scripts/generate-annex-e.ts` applies Table E.1-1 as a **per-field overlay** over the mirror: for a
|
|
178
|
+
tag both sources carry, PS3.15 wins on attribute name, Basic Profile action code, and all nine
|
|
179
|
+
metadata-affecting option columns; a tag PS3.15 carries and the mirror does not is added; a tag the
|
|
180
|
+
mirror carries and PS3.15 does not is **kept**, because the standard retires rather than deletes,
|
|
181
|
+
so an absence is far more likely to be a parse gap here than a withdrawal there and dropping the
|
|
182
|
+
entry would turn an attribute the de-identifier acts on into one it silently keeps. That set is
|
|
183
|
+
empty today, and its size prints on every run so the assumption stays observable. Nothing is
|
|
184
|
+
hand-corrected: every value is derived from the fetched normative bytes and reproduced by every
|
|
185
|
+
regen. No Basic Profile code changed for a tag both sources carried, which is the reassuring half
|
|
186
|
+
of the finding: the mirror was not wrong about what it had, only silent about what it lacked.
|
|
187
|
+
**The pin is a precondition, not a comment.** The generator recomputes the SHA-256 of the file it
|
|
188
|
+
reads and refuses to generate on a mismatch, reads the edition from the document's own
|
|
189
|
+
`<subtitle>` rather than asserting it, and fails loudly rather than emitting a thinner table on a
|
|
190
|
+
header row whose 15 column labels are not where the generator's indices expect them, a body row that
|
|
191
|
+
is not 15 cells, a tag cell it does not recognize, an action code outside Table E.1-1a in any
|
|
192
|
+
non-empty action column, an empty Basic Profile cell, a `<tr>` that resolves to neither a matched
|
|
193
|
+
body row nor a header row, or a total under 600 concrete rows. The header check is the one that
|
|
194
|
+
catches a column **reorder**, which a cell count cannot see and which would read one option's action
|
|
195
|
+
code as another's. Every run prints the overlay
|
|
196
|
+
it applied, **with every action-code override listed individually**, because a changed action code
|
|
197
|
+
is the one difference that decides whether an identifier survives.
|
|
198
|
+
Two exclusions are deliberate and now **printed on every run** instead of being assumed. Four rows
|
|
199
|
+
of Table E.1-1 state a family rather than a single tag (`(50xx,xxxx)` Curve Data, `(60xx,3000)`
|
|
200
|
+
Overlay Data, `(60xx,4000)` Overlay Comments, and `(gggg,eeee) where gggg is odd` Private
|
|
201
|
+
Attributes); an exact-tag map cannot key them, private attributes are removed through their own
|
|
202
|
+
path, and the three repeating-group rows remain a stated gap. And PS3.15's E.3.6 is two options,
|
|
203
|
+
full dates and modified dates, against this package's single `RetainLongitudinalTemporal`, which
|
|
204
|
+
carries the full-dates column; the two columns diverge on **169** of the 652 rows in 2026c, and
|
|
205
|
+
that count now prints rather than living in a comment that guessed "usually not".
|
|
206
|
+
There is deliberately **no staleness clock**, for the same reasons as PS3.6: a date gate fires the
|
|
207
|
+
day it is written, demands an action nobody can take on demand, and reds unrelated pull requests.
|
|
208
|
+
What CI gates is unchanged and offline: the committed table must be byte-for-byte what the pinned
|
|
209
|
+
inputs produce.
|
|
210
|
+
- **Three defects in the shipped data dictionary, all resolved from normative bytes**
|
|
211
|
+
(`DICOM-UPSTREAM-EDITION-LAG`). None is hand-corrected; each is what PS3.6 2026c prints, and each
|
|
212
|
+
is reproduced by every regen.
|
|
213
|
+
- **`(0010,2160)` `EthnicGroup` was marked current although PS3.6 retired it in 2025a**, and its
|
|
214
|
+
replacements were absent entirely. It is now `retired: true`, and `(0010,2161)`
|
|
215
|
+
`EthnicGroupCodeSequence` (`SQ`, VM 1) and `(0010,2162)` `EthnicGroups` (`UC`, VM 1-n) are
|
|
216
|
+
present. This is a real demographic-attribute change: an application reading only `EthnicGroup`
|
|
217
|
+
silently misses what current instances actually carry. Retired does not mean removed, so the
|
|
218
|
+
keyword still resolves and an older study still reads; the caller is told both what the tag is
|
|
219
|
+
and that it is no longer current.
|
|
220
|
+
- **`(3004,0012)` `DoseValue` was marked retired although PS3.6 defines it.** The `RET (2022d)`
|
|
221
|
+
marker belongs to the preceding row, `(3004,0010)` `RTDoseROISequence`, which remains retired.
|
|
222
|
+
`DoseValue` is now `retired: false`. A dose attribute wrongly flagged retired is the dangerous
|
|
223
|
+
direction of that error: it is the one that talks a caller out of reading a real RT dose.
|
|
224
|
+
- **`(003A,0320)` and `(003A,0325)` carried truncated keywords**, `SummarizedFilterLookupTable` and
|
|
225
|
+
`AnalogFilterType`, so `byKeyword` missed on the spelling PS3.6 publishes. They are now
|
|
226
|
+
`SummarizedFilterLookupTableSequence` and `AnalogFilterTypeCodeSequence`. The truncated forms are
|
|
227
|
+
**gone rather than kept as aliases**: they were never PS3.6 keywords, and carrying them would
|
|
228
|
+
preserve the defect under a nicer name.
|
|
229
|
+
- Alongside these, **180 tags PS3.6 has gained are now known**, so `Dictionary.lookup` names them
|
|
230
|
+
and Implicit VR parsing resolves their VR from the dictionary instead of falling back to `UN`.
|
|
231
|
+
The registry goes from **5,129 to 5,309** tags and `byKeyword` from **5,035 to 5,214** keywords.
|
|
232
|
+
Zero VR, name and VM differences existed on the 5,129 shared tags and zero tags were dropped, so
|
|
233
|
+
**no previously-decoded element decodes differently**; the change is corrections plus additions.
|
|
234
|
+
|
|
235
|
+
- **174 SOP Class UID names in `UIDS` were wrong (`DICOM-DICT-CURRENCY`).** The dictionary generator
|
|
236
|
+
appended `" Storage"` to every name it took from the vendor `sops.json`, but that field is already
|
|
237
|
+
the full PS3.6 Table A-1 UID Name. The result was a doubled suffix on the 164 names that already
|
|
238
|
+
ended in "Storage", so `UIDS["1.2.840.10008.5.1.4.1.1.2"].name` read **"CT Image Storage Storage"**,
|
|
239
|
+
and an equally wrong tail on 10 of the other 11 ("Digital X-Ray Image Storage - For Presentation
|
|
240
|
+
Storage"). Every one shipped in `0.0.1` through `0.0.3`. The suffix is gone and the names now come
|
|
241
|
+
through verbatim.
|
|
242
|
+
The rule was wrong for all 175 entries it touched but landed on the right string for exactly one,
|
|
243
|
+
so removing it needed a compensating correction rather than a second blanket rule:
|
|
244
|
+
`1.2.840.10008.5.1.4.1.1.79.1` is the single UID whose vendor name genuinely lacks the suffix, and
|
|
245
|
+
it is now pinned in the generator's curated table to the "Macular Grid Thickness and Volume Report
|
|
246
|
+
Storage" that PS3.6 2026c Table A-1 gives it.
|
|
247
|
+
One further name is corrected: `1.2.840.10008.1.2.6.1`, the retired RFC 2557 transfer syntax, read
|
|
248
|
+
"RFC 2557 MIME Encapsulation" where PS3.6 prints "RFC 2557 MIME encapsulation".
|
|
249
|
+
Measured against PS3.6 2026c on the branch head: of the **261** UIDs shared with Table A-1,
|
|
250
|
+
**240 match the `UID Name` column byte for byte**; a further **17** differ only in that Table A-1
|
|
251
|
+
writes retirement into the name as a trailing " (Retired)" where this dictionary carries a
|
|
252
|
+
structured `retired` boolean, which gives **257** when those are read as matches and **zero**
|
|
253
|
+
retirement-flag disagreements; the remaining **4** are the deliberate transfer-syntax short forms
|
|
254
|
+
tabulated in `vendor/innolitics/README.md`; **0** are unexplained. All **7** well-known frames of
|
|
255
|
+
reference match Table A-2 byte for byte. `uids.ts` changes on 175 lines in total.
|
|
256
|
+
Name only: no UID value, `type`, or `retired` flag changed. Transfer-syntax dispatch keys off the
|
|
257
|
+
UID **value**, never the name, so no parse or de-identification behavior changes. The one place a
|
|
258
|
+
name reaches a caller at runtime is the human-readable `snippet` on the fatal
|
|
259
|
+
`UNSUPPORTED_TRANSFER_SYNTAX` error, which is improved by the correction rather than altered in
|
|
260
|
+
meaning.
|
|
261
|
+
- **The byte-identical regen gate could go green while generating nothing (`DICOM-DICT-CURRENCY`).**
|
|
262
|
+
Two ways, both fixed by making the gate delete `src/dictionary/generated/` (except the hand-written
|
|
263
|
+
`README.md`) before running `pnpm gen:all`, so it measures what the generators produce instead of
|
|
264
|
+
what happens to be on disk. A stale orphan, an artifact a generator used to emit and no longer
|
|
265
|
+
does, was never rewritten and never diffed, so it passed indefinitely. And gutting `gen:all` to a
|
|
266
|
+
no-op wrote nothing, left every committed artifact untouched, and produced an empty `git diff`:
|
|
267
|
+
permanently green. `package.json` also joins the workflow's `paths` filters, without which that
|
|
268
|
+
second case never triggered the gate at all. Both now red, proved by seeding each defect and
|
|
269
|
+
watching the run fail. The delete is `-mindepth 1` rather than `-type f`, so a stale symlink or
|
|
270
|
+
subdirectory cannot become an orphan the gate is blind to, and the step asserts that only
|
|
271
|
+
`README.md` survives instead of printing an expectation nothing checks. `pnpm gen:clean` exposes
|
|
272
|
+
the same delete for local use but is deliberately **not** chained into `gen:all`: the generators
|
|
273
|
+
throw on malformed or missing vendor input, which is the normal state part-way through a re-pin,
|
|
274
|
+
and a chained clean would leave the working tree empty and the build broken. The gate does its own
|
|
275
|
+
delete rather than trusting the script under test.
|
|
276
|
+
|
|
277
|
+
### Changed
|
|
278
|
+
|
|
279
|
+
- **The PS3.5 repeating-group bound is now derived from pinned normative documents rather than
|
|
280
|
+
transcribed (`DICOM-PS35-NOT-VENDORED`). No runtime impact: the emitted bound is identical and no
|
|
281
|
+
public API moves.** `deidentify()` expands PS3.15 Table E.1-1's `(50xx,xxxx)`, `(60xx,3000)` and
|
|
282
|
+
`(60xx,4000)` rows over the concrete groups PS3.5 section 7.6 admits, sixteen even groups per mask
|
|
283
|
+
(`5000`-`501E`, `6000`-`601E`), not 256. That bound is a safety limit in **both** directions, and it
|
|
284
|
+
was the last input here that lived as a **quotation in a source file** while PS3.6 and PS3.15 were
|
|
285
|
+
SHA-pinned and re-hashed before use, so the generator's guard could catch a new mask _prefix_ but
|
|
286
|
+
never a changed _bound_.
|
|
287
|
+
`vendor/nema/part05/` pins `part05.xml` (PS3.5 **2026c**) and `vendor/nema/part05-2004/` pins
|
|
288
|
+
`04_05pu.pdf`, both re-hashed as a precondition, and `scripts/generate-repeating-groups.ts` emits
|
|
289
|
+
`src/dictionary/generated/repeating-groups.ts`, which `src/dictionary/repeating-groups.ts`
|
|
290
|
+
re-exports. **Two documents, because the bound is split across two editions:** the current one
|
|
291
|
+
states the overlay bound normatively and excludes the odd `6001`-`601F`, but says nothing about the
|
|
292
|
+
curve bound, having retired curve encoding and delegated it to PS3.5-2004 by URL in section 7.6's
|
|
293
|
+
own Note. So the 2004 PDF is the authority the edition in force names, not a convenience copy. The
|
|
294
|
+
generator **proves that delegation** (section 7.6 must link exactly the vendored URL) instead of
|
|
295
|
+
assuming it, and because both editions state the overlay bound it **requires them to agree**.
|
|
296
|
+
`gen:repeating-groups` runs first in `gen:all`, since the Annex E generator expands family rows
|
|
297
|
+
through this bound.
|
|
298
|
+
**The values did not change; their falsifiability did.** Moving the overlay bound in either
|
|
299
|
+
vendored edition reds the cross-check, moving the curve bound in the 2004 edition moves the emitted
|
|
300
|
+
artifact (caught by the byte-identical regen gate), and removing the delegation link stops the 2004
|
|
301
|
+
document being used at all. Each was confirmed non-vacuous by disabling the guard and watching the
|
|
302
|
+
test fail. Reading a 2004 PDF needs a PDF reader; the one in that generator is deliberately minimal
|
|
303
|
+
(Node `zlib` only, one sentence recovered, matched against a precise expected shape) and is not to
|
|
304
|
+
be grown into a general parser. There is no staleness clock, for the same reasons as PS3.6 and
|
|
305
|
+
PS3.15.
|
|
306
|
+
- **Recorded what the vendor pin is actually current against (`DICOM-DICT-CURRENCY`).**
|
|
307
|
+
`vendor/innolitics/README.md` gains a measured Currency section and drops the "re-pin monthly"
|
|
308
|
+
policy, which nothing ran and which measured the wrong thing. The pin is exactly current against
|
|
309
|
+
upstream: `git ls-remote` resolves `master` to the pinned SHA and all four vendored files are
|
|
310
|
+
byte-identical to upstream at it. Upstream itself is the stale link, having last changed
|
|
311
|
+
`attributes.json` on 2024-04-18 ("Update standard to rev2024b"), so the tag tables are grounded in
|
|
312
|
+
PS3.6 2024b while the current edition is PS3.6 2026c. The drift is now measured rather than
|
|
313
|
+
assumed, against the NEMA DocBook source for 2026c, and every non-additive difference is named in
|
|
314
|
+
that file. Figures on `86ab6c1` (`origin/main` at measurement time; this slice does not touch
|
|
315
|
+
`tags.ts` or `keywords.ts`): all **5,129** committed tags are shared with PS3.6 2026c, and across
|
|
316
|
+
them there are **zero VR differences**, zero name differences and zero VM differences, with 2
|
|
317
|
+
keyword and 2 retirement-status differences. **Every tag this dictionary carries is still in
|
|
318
|
+
PS3.6 2026c**; the drift is otherwise purely additive, 180 tags the standard has gained. VR is
|
|
319
|
+
what turns bytes into a value, so zero VR drift is the result that matters for reading a real
|
|
320
|
+
study. Nothing in the dictionary is hand-edited to close the gap: it is generated, and the
|
|
321
|
+
remaining differences are recorded for the next re-pin. One method note lives with the
|
|
322
|
+
measurement, because getting it wrong is easy and was got wrong on the first run: DICOM tag values
|
|
323
|
+
are hexadecimal and their case is not semantic, so tag keys must be compared case-insensitively.
|
|
324
|
+
PS3.6 prints them uniformly uppercase and `tags.ts` agrees on 1,540 of its 5,129 keys, but
|
|
325
|
+
`tags.ts` lowercases the 8 repeating-group keys whose trailing digits are hex letters and no
|
|
326
|
+
others, so a verbatim comparison mis-classifies exactly those 8.
|
|
327
|
+
|
|
328
|
+
- **Removed the six em dashes the new gate found (`EMDASH-CONFORMANCE`).** Four tracked files, none
|
|
329
|
+
of them markdown: `.github/CODEOWNERS` (2), `.github/workflows/release.yml` (2),
|
|
330
|
+
`vendor/nema/SHA.txt` (1), and the npm `description` in `package.json` (1). The last is the only
|
|
331
|
+
one visible outside the repo: the package description on npm and on the GitHub sidebar now reads
|
|
332
|
+
"Developer-focused DICOM Part 10 parser + utility library for Node.js and TypeScript:
|
|
333
|
+
metadata-first, vendor-quirky-tolerant, dual ESM/CJS." Each rewrite replaces the character with a
|
|
334
|
+
period, colon, or comma, per the rule's own instruction; none re-encodes it, and no wording beyond
|
|
335
|
+
the punctuation changed.
|
|
336
|
+
- **Bumped the `@cosyte/vitest-config` devDependency to `^0.0.2`** to pick up the `./snippets`
|
|
337
|
+
export that ships the doc/code-agreement runner.
|
|
338
|
+
- **Corrected the element-access examples in `intro.md`.** `Dataset.get` / `has` take the
|
|
339
|
+
8-character `(group,element)` **tag** form only. The prior snippets showed `get("PatientName")`,
|
|
340
|
+
`get("(0010,0010)")`, and `get("StudyDate")`, all of which return `undefined`. They now use the
|
|
341
|
+
tag form (and show resolving a keyword to its tag via `Dictionary.byKeyword`), matching the code.
|
|
342
|
+
|
|
343
|
+
- **All tests now live in a top-level `test/` mirroring `src/`.** `@cosyte/dicom` was the lone parser
|
|
344
|
+
that co-located `*.test.ts` files beside their source; the 32 co-located suites were moved to
|
|
345
|
+
`test/<path>/` preserving the `src/` sub-structure, bringing the repo in line with the
|
|
346
|
+
`hl7`/`mllp`/`x12`/`ccda`/`ncpdp` archetype (`DICOM-TEST-RELOCATE`). Relative imports in the moved
|
|
347
|
+
files were retargeted (`./foo.js` → `../../src/<path>/foo.js`). `vitest.config.ts` drops the
|
|
348
|
+
`src/**/*.test.ts` include glob, so the config now matches the archetype and a stray co-located
|
|
349
|
+
test is no longer silently collected alongside the `test/` suite. Test-only, no public-surface
|
|
350
|
+
change: same 585 passing tests (+1 todo), coverage gates (still pointed at the `src/` source
|
|
351
|
+
directories) unchanged and green.
|
|
352
|
+
|
|
353
|
+
### Fixed
|
|
354
|
+
|
|
355
|
+
- **`Dictionary Regen` is green again (`DICOM-DICT-REGEN`).** The byte-identical regen gate had been
|
|
356
|
+
red since 2026-06-24: seven consecutive failing runs, four of them pushes to `main`, every one of
|
|
357
|
+
them failing in its first real step. The last green run was 2026-05-04.
|
|
358
|
+
`.github/workflows/dictionary-regen.yml` passed `version: 10` to `pnpm/action-setup@v6` while
|
|
359
|
+
`package.json` already declares `packageManager: pnpm@10.0.0`, and v6 treats two sources of truth as
|
|
360
|
+
a hard error: "Multiple versions of pnpm specified". The job died in about 13 seconds having never
|
|
361
|
+
installed anything, never run `pnpm gen:all`, and never compared a single generated byte. Dropping
|
|
362
|
+
the redundant `version:` input lets `packageManager` win, matching the fix `astm` already applied to
|
|
363
|
+
its fuzz workflow (`44c3d88`).
|
|
364
|
+
Two smaller repairs to the same file, both aimed at the gate actually asserting something rather
|
|
365
|
+
than merely starting. The workflow now lists **itself** in its `paths` filters, so a change to the
|
|
366
|
+
gate re-runs the gate; without that, the PR fixing a broken gate could not demonstrate the gate
|
|
367
|
+
fixed. And the verify step now also fails on **untracked** files under `src/dictionary/generated/`:
|
|
368
|
+
`git diff` reports modified tracked files only, so a generator that began emitting a brand-new
|
|
369
|
+
artifact would have left it untracked and invisible to the check.
|
|
370
|
+
The committed dictionary itself is **not** drifted. Regenerating from the pinned inputs
|
|
371
|
+
(`vendor/innolitics/90571bc`) reproduces all four artifacts byte for byte: 5,129 tags, 5,035
|
|
372
|
+
keywords, 268 UIDs, 617 Annex E entries, zero diff. Verified as a positive control rather than
|
|
373
|
+
assumed: perturbing a VR in the vendor input made the gate red, and restoring it made the gate
|
|
374
|
+
green, so the check reads its input. Nothing in the tag tables needed correcting, and nothing
|
|
375
|
+
regenerated was committed.
|
|
376
|
+
|
|
377
|
+
- **Corrected the `private: true` claim in `.github/workflows/release.yml`.** A comment at the top of
|
|
378
|
+
the release caller asserted that `@cosyte/dicom` is `private: true` and that `changeset publish` is
|
|
379
|
+
therefore "a no-op until that flag is removed". `package.json` carries no `private` field, the repo
|
|
380
|
+
is public, and the package ships on npm at `0.0.1`, so the comment described the release pipeline as
|
|
381
|
+
inert when it is live. Documentation only, no behavior change, but a misleading note on a workflow
|
|
382
|
+
that really does publish is worth more than a stale one elsewhere.
|
|
383
|
+
|
|
384
|
+
- **Corrected stale publish-status language in `docs-content/` (`README-ORG-SWEEP`).**
|
|
385
|
+
`installation.md` claimed the package was "not yet published to npm" and that the install command
|
|
386
|
+
was only "the shape it will take at first publish"; `troubleshooting.md` listed a "Not yet
|
|
387
|
+
published … not on npm; the first provenance publish is gated on the coordinated public launch"
|
|
388
|
+
non-goal. `@cosyte/dicom` is published on npm at `0.0.1` and public. Both now state the truth
|
|
389
|
+
(published, public, still pre-alpha on the `0.0.x`-until-first-alpha ladder), and the troubleshooting
|
|
390
|
+
bullet becomes an honest pin-your-version pre-alpha caveat rather than a stale non-goal.
|
|
391
|
+
|
|
392
|
+
- **`private: true` removed: `@cosyte/dicom` can publish.** The flag dated to the very first
|
|
10
393
|
scaffold commit and was never explained; `changeset publish` silently skips a private package, so
|
|
11
394
|
this repo was the one parser that could not reach npm even once its pipeline worked. It also
|
|
12
395
|
contradicted the `publishConfig: { access: "public" }` in the same file. Removed as part of the
|
|
@@ -18,25 +401,25 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
|
|
|
18
401
|
reference, retargeted at `src/version.ts`) and the `version` script that runs it after
|
|
19
402
|
`changeset version`, so the bump and the `VERSION` constant land in the same commit.
|
|
20
403
|
- **`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"
|
|
404
|
+
"0.0.0"`, giving it the literal type `"0.0.0"`, so the exported type would change on every
|
|
22
405
|
release, making each version bump a breaking type change. Now annotated `: string`, matching the
|
|
23
406
|
`hl7` reference. Type-only; the runtime value is unchanged. Done now because the package is
|
|
24
|
-
unpublished
|
|
407
|
+
unpublished. After the first publish this would itself be a breaking change.
|
|
25
408
|
|
|
26
409
|
- **The Release workflow can actually start.** `.github/workflows/release.yml` calls the shared
|
|
27
410
|
`cosyte/.github` pipeline, which requests `contents`/`id-token`/`pull-requests: write`, but declared
|
|
28
|
-
no `permissions:` of its own
|
|
411
|
+
no `permissions:` of its own, so it inherited the repo default of `contents: read`. A called
|
|
29
412
|
workflow may only downgrade the caller's `GITHUB_TOKEN`, never escalate it, so GitHub rejected the
|
|
30
413
|
workflow at startup (~1s, no jobs, no logs). Every Release run from June 2026 until now failed this
|
|
31
414
|
way, unnoticed, because a `startup_failure` produces no logs to read. The caller job now declares
|
|
32
|
-
the three scopes explicitly. CI-only
|
|
415
|
+
the three scopes explicitly. CI-only: no runtime or API change.
|
|
33
416
|
|
|
34
417
|
### Security
|
|
35
418
|
|
|
36
|
-
- **Dev-dependency advisory remediation (no runtime impact
|
|
419
|
+
- **Dev-dependency advisory remediation (no runtime impact: the published
|
|
37
420
|
artifact is unchanged).** Added scoped `pnpm.overrides` pinning two
|
|
38
421
|
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
|
|
422
|
+
(`>=0.27.3 <0.28.1` → `0.28.1`; GHSA dev-server path-traversal, not
|
|
40
423
|
reachable here: the library builds via `tsup`/`vitest` and never runs
|
|
41
424
|
`esbuild serve`) and the `@changesets/parse` copy of `js-yaml`
|
|
42
425
|
(`>=4.0.0 <4.2.0` → `4.2.0`; GHSA-h67p-54hq-rp68 merge-key DoS). The
|
|
@@ -49,19 +432,25 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
|
|
|
49
432
|
|
|
50
433
|
### Added
|
|
51
434
|
|
|
435
|
+
- **Trademark notice (`TRADEMARKS.md`).** This package names third-party systems to describe what it
|
|
436
|
+
interoperates with; the notice records that cosyte is not affiliated with, endorsed by, or
|
|
437
|
+
sponsored by any of them, that every reference is descriptive, and that the built-in profiles are
|
|
438
|
+
authored from public sources only. Added to `files` so it ships inside the published tarball, not
|
|
439
|
+
just on GitHub. Documentation only: no runtime or API change.
|
|
440
|
+
|
|
52
441
|
- **Lossless File Meta round-trip.** The parser now retains non-modeled
|
|
53
|
-
`(0002,xxxx)` File Meta elements
|
|
442
|
+
`(0002,xxxx)` File Meta elements (e.g. `(0002,0017)`/`(0002,0018)`
|
|
54
443
|
Sending/Receiving AE Title, `(0002,0100)` Private Information Creator UID,
|
|
55
|
-
`(0002,0102)` Private Information
|
|
444
|
+
`(0002,0102)` Private Information) as raw on-wire bytes on the new
|
|
56
445
|
`FileMeta.extraElements` view (each a `FileMetaRawElement` carrying the tag,
|
|
57
446
|
its Explicit-VR-LE VR, and a defensively copied even-length value). The
|
|
58
447
|
serializer merges these with the typed fields and emits the whole group in
|
|
59
448
|
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
|
|
449
|
+
(PS3.10 §7.1), so an exotic File Meta group now round-trips byte-for-byte,
|
|
61
450
|
not just the typed fields. New exported type: `FileMetaRawElement`. This
|
|
62
451
|
resolves the Phase 5 serializer known-limitation ("only the typed `FileMeta`
|
|
63
452
|
fields round-trip").
|
|
64
|
-
- **Documentation completeness (Phase 8).** Rewrote `README.md` into a full developer guide
|
|
453
|
+
- **Documentation completeness (Phase 8).** Rewrote `README.md` into a full developer guide: quickstart,
|
|
65
454
|
feature tour, a "DICOM in 90 seconds" primer, the two access patterns, an 80/20 **cookbook** (index a
|
|
66
455
|
folder, build routing keys, read pixel-interpretation metadata safely, de-identify, bridge to FHIR
|
|
67
456
|
`ImagingStudy` / HL7 v2, round-trip serialize), the four-tier tolerance model, the warning/fatal code
|
|
@@ -69,13 +458,13 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
|
|
|
69
458
|
export now carries a JSDoc `@example`. Extended the dual ESM/CJS smoke harnesses to exercise the full
|
|
70
459
|
Phase 1–7 published surface so the documented entrypoints are guaranteed importable from both module
|
|
71
460
|
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
|
|
461
|
+
real accessor is `ds.get("PixelData")?.value`). Docs-only: no runtime API change.
|
|
73
462
|
- **Metadata-level de-identification (Phase 7).** New `deidentify(ds, options?)` applies the PS3.15
|
|
74
463
|
Annex E **Basic Application Level Confidentiality Profile** plus the nine metadata-affecting Annex E
|
|
75
464
|
Options (`RetainUIDs`, `RetainLongitudinalTemporal`, `RetainPatientCharacteristics`,
|
|
76
465
|
`RetainDeviceIdentity`, `RetainInstitutionIdentity`, `RetainSafePrivate`, `CleanDescriptors`,
|
|
77
466
|
`CleanStructuredContent`, `CleanGraphics`), driven by the generated Table E.1-1 action map. It is a
|
|
78
|
-
**pure** function
|
|
467
|
+
**pure** function: the input `Dataset` is never mutated; it returns a fresh de-identified `Dataset`
|
|
79
468
|
and a value-free `DeidentifyReport` (tags, keywords, resolved action codes, the UID map, warnings).
|
|
80
469
|
Each attribute's action (`D` dummy, `Z` zero-length, `X` remove, `K` keep, `C` clean, `U` consistent
|
|
81
470
|
UID) is resolved from the Basic Profile, overridden by any active Option; conditional codes (`Z/D`,
|
|
@@ -101,31 +490,31 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
|
|
|
101
490
|
`escalate` (Tier-2 warning codes promoted to a thrown `DicomParseError`), `suppress` (codes silenced
|
|
102
491
|
as a documented benign quirk of the source), and `privateTags` (a private-creator-keyed overlay that
|
|
103
492
|
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)
|
|
105
|
-
hard-coded block number
|
|
493
|
+
**live** private-creator string and the canonical `"GGGGxxLL"` key (PS3.5 §7.8.1), never a
|
|
494
|
+
hard-coded block number, so the same vendor schema resolves regardless of which block it landed in.
|
|
106
495
|
Profiles compose via `extends` (de-duplicated lineage, union of escalations/suppressions, child-wins
|
|
107
496
|
dictionary merge) and expose a deterministic `describe()` summary. Five built-ins ship under the
|
|
108
497
|
frozen `profiles` namespace: three vendor overlays (`ge`, `siemens`, `philips`, grounded in the
|
|
109
498
|
public GDCM / dcm4che / dcm2niix private dictionaries) and two posture presets (`strict` escalates
|
|
110
499
|
integrity-relevant warnings; `lenient` suppresses cosmetic, high-volume ones). A creator the active
|
|
111
500
|
profile does not recognize degrades to generic `UN` plus the new `DICOM_PRIVATE_CREATOR_UNKNOWN`
|
|
112
|
-
warning
|
|
501
|
+
warning, never a wrong decode. Selecting a profile never changes a correct decode. New public
|
|
113
502
|
exports: `defineProfile`, `profiles`, `ProfileDefinitionError`, and the types `Profile`,
|
|
114
503
|
`PrivateTagDefinition`, `DefineProfileOptions`, `ProfilePrivateTags`; `ParseOptions` gains an
|
|
115
504
|
optional `profile` field. The reserved `DICOM_PRIVATE_CREATOR_UNKNOWN` code is now actively emitted
|
|
116
505
|
(no change to the `WARNING_CODES` registry surface).
|
|
117
506
|
- **Spec-clean Part 10 serializer (Phase 5).** New `serializeDicom(ds)` writes a `Dataset` back to a
|
|
118
|
-
DICOM Part 10 `Buffer
|
|
507
|
+
DICOM Part 10 `Buffer`, the conservative half of Postel's Law. Emits the 128-byte zero preamble +
|
|
119
508
|
`DICM`, a File Meta group (always Explicit VR LE) with a computed `(0002,0000)` group length and
|
|
120
509
|
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
|
|
510
|
+
`2.25` UUID arc), then the dataset body in the dataset's own transfer syntax (**no transcode**)
|
|
122
511
|
across all four v1 syntaxes (Implicit VR LE, Explicit VR LE/BE, Deflated Explicit VR LE). Scalar
|
|
123
512
|
values are padded to even length per PS3.5 §6.2 (`0x00` for `UI`/byte-stream VRs, `0x20` for text),
|
|
124
513
|
short vs long-form headers are chosen by VR per §7.1.2 (`SV`/`UV` long-form), retired `(gggg,0000)`
|
|
125
514
|
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
|
|
515
|
+
through byte-for-byte per §7.5 / §A.4. Pure function: the input `Dataset` is never mutated.
|
|
127
516
|
- **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)
|
|
517
|
+
(no File Meta Transfer Syntax UID) and `UNSUPPORTED_TRANSFER_SYNTAX` (a UID outside the v1 set),
|
|
129
518
|
separate from the parser's fatal codes and the value layer's `DicomValueError`. The message is
|
|
130
519
|
built only from the code + the offending Transfer Syntax UID (structural facts), never a decoded
|
|
131
520
|
value, so it is always safe to log. New public exports: `serializeDicom`, `DicomSerializeError`,
|
|
@@ -137,7 +526,7 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
|
|
|
137
526
|
fields round-trip; any other `(0002,xxxx)` element a source file carried (e.g. `(0002,0100)` Private
|
|
138
527
|
Information Creator UID) is dropped at _parse_ time (the Phase 2 `FileMeta` view does not model it)
|
|
139
528
|
and so cannot be re-emitted. The preamble is normalized to zeros and odd-length values are padded
|
|
140
|
-
even
|
|
529
|
+
even. The output stays spec-clean but is not a byte-identical copy of a non-conformant input.
|
|
141
530
|
|
|
142
531
|
### Tests
|
|
143
532
|
|
|
@@ -158,7 +547,7 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
|
|
|
158
547
|
tuple plus Other Patient IDs (so a caller never matches on a bare, non-unique `(0010,0020)`) and
|
|
159
548
|
keeps `PN` structured; `study`/`series` surface the cross-system UIDs, accession number, modality
|
|
160
549
|
and Frame of Reference UID. `image` surfaces the pixel-interpretation + geometry metadata a
|
|
161
|
-
renderer needs
|
|
550
|
+
renderer needs, with the safety-critical omissions intact: `rescaleSlope` is **absent** (not `1`)
|
|
162
551
|
when the tag is absent, `signed` is absent (never guessed) unless `(0028,0103)` was present,
|
|
163
552
|
`photometricInterpretation` is never defaulted to `MONOCHROME2`, and the three pixel-spacing tags
|
|
164
553
|
(`(0028,0030)` / `(0018,1164)` / `(0018,2010)`) are distinct, never aliased.
|
|
@@ -166,7 +555,7 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
|
|
|
166
555
|
Per-Frame-else-Shared (PS3.3 §C.7.6.16): Pixel Measures, Plane Position, Plane Orientation, Pixel
|
|
167
556
|
Value Transformation, Frame VOI LUT. `image.isEnhancedMultiFrame` flags such objects.
|
|
168
557
|
- **Value-layer error taxonomy.** New `DicomValueError` (codes `FRAME_INDEX_OUT_OF_RANGE`,
|
|
169
|
-
`MISSING_REQUIRED_FUNCTIONAL_GROUP`)
|
|
558
|
+
`MISSING_REQUIRED_FUNCTIONAL_GROUP`), separate from the parser's four fatal codes. The helpers are
|
|
170
559
|
otherwise fail-safe (typed-absent for missing data) and throw only for a structural contract
|
|
171
560
|
violation; the error message carries only structural facts (indices, tag/macro names), never a
|
|
172
561
|
decoded PHI value.
|
|
@@ -179,7 +568,7 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
|
|
|
179
568
|
`CodedConcept`, `RealWorldValueMap`, `FrameFunctionalGroups`, `ValueErrorCode`, plus
|
|
180
569
|
`VALUE_ERROR_CODES` and the `readCode` / `codingSchemeOid` / `CODING_SCHEME_OIDS` helpers.
|
|
181
570
|
- **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
|
|
571
|
+
memoizes) an element's raw bytes into a typed, discriminated `DicomValue` covering all 34 VRs:
|
|
183
572
|
numbers (`US/UL/SS/SL/FL/FD`), 64-bit `bigint`s (`SV/UV`), attribute tags (`AT`), person names
|
|
184
573
|
(`PN` → 3-group / 5-component), strings, free text, numeric strings (`DS/IS` → `number | null`,
|
|
185
574
|
never `NaN`→0), temporal values (`DA/TM/DT`), sequences (`SQ` → threaded items), and raw `binary`
|
|
@@ -203,7 +592,7 @@ All notable changes to `@cosyte/dicom` will be documented in this file. The form
|
|
|
203
592
|
(`_arbitraries.ts`) plus invariant suites for round-trip fidelity, lenient-mode robustness,
|
|
204
593
|
parsed-model immutability, warning/fatal-code stability (snapshot), and a byte-parser fuzz sweep
|
|
205
594
|
that feeds arbitrary buffers + random truncations and asserts the parser only ever throws a
|
|
206
|
-
sanctioned Tier-3 `DicomParseError
|
|
595
|
+
sanctioned Tier-3 `DicomParseError`, never an unexpected error, hang, or OOM. No public API
|
|
207
596
|
change. (devDeps: `@cosyte/test-utils@^0.0.1`, `fast-check@3.23.2`.)
|
|
208
597
|
|
|
209
598
|
### Changed
|