Skip to content

Commit c69f831

Browse files
xbmlzclaude
andauthored
Let a caller bring their own data dictionary, and keep it alive past the read. (#82)
#81 put the dictionary behind an interface. Nothing could supply one. A private element's VR depends on the vendor who wrote the file, and the only way to teach godicom a vendor's block was AddPrivateDictEntry, which mutates a process-global table: every caller in the process sees the entry, and the only way to remove it clears whatever anyone else registered too. Right for a program that owns its process, wrong for a library. ReadOptions.Dictionary is the per-read version, nil meaning PS3.6. It changes what an element means rather than how it is described -- an implicit VR file carries no VRs at all, so every element's VR is whatever the dictionary says, and for a private element that answer depends on who wrote it. NewPrivateDictionary builds a set of vendor entries that touches no global state, and NewDictionary composes: each member is tried in turn and the first entry found wins, so the argument order is the precedence. Compose rather than replace -- NewDictionary(vendor, Standard()) keeps the other 5,189 entries, and handing over the vendor's dictionary on its own would leave every standard element as UN. Three places the plumbing had to reach, none of them obvious from the signature that needed changing: The dictionary lives on readContext, not codecContext where the rest of the per-element decode state lives, because it has to outlive the parse. A deferred value is decoded on Get, long after the read returned, and deferred.go compares the VR it re-resolves against the one the element was first read under. A dictionary scoped to the parse would turn every deferred implicit-VR private element into "deferred read VR UN does not match original US". readContext is the only piece of parse state a Dataset retains, which is what makes it the right home. Replacing rc.dictionary() with Standard() in datasetResolver reproduces that error in both subtests of TestReadOptionsDictionarySurvivesADeferredLoad. finishDeflated copies ReadOptions field by field, so a Deflated file would have read its inflated dataset against PS3.6 while the same file undeflated resolved properly. Only readReaderAt takes that path -- readBytes inflates in place and keeps its readCtx -- so the test drives ReadFile as well as ReadBytes, and dropping the copy fails it. The VR-mismatch diagnostic is now judged against the dictionary the read was given rather than always PS3.6. A caller who overrode an entry said what they expect the file to contain; measuring against a different expectation than the parse used would be reporting on nothing. Private tags stay exempt even when the supplied dictionary has an entry, because the creator is not in hand at that point and resolving one per element would charge the quiet path for a diagnostic nobody asked for. dictionaryVRForRead and lookupVRWithCreator are replaced by vrResolver, which pairs the dictionary with the private-creator lookup because neither answers alone -- a creator is only worth resolving against a dictionary that has that vendor's block in it. Its zero value resolves against Standard with no creator, which is what a header decoded outside a parse should get. LookupVR is now one call to it. AddPrivateDictEntry and ResetExtraPrivateDictionaries behave exactly as before and are not deprecated; they are now implemented on top of PrivateDictionary rather than a parallel table, and document the global mutation they perform. Three gaps left deliberately, each recorded where someone would look for it: - No WriteOptions.Dictionary. The write path resolves an ambiguous VR from the dataset's own values -- Pixel Representation decides between US and SS -- and never consults the dictionary, so the field would have nothing to do. - DecodeDataset and friends take no options at all and so still resolve against Standard(). Giving four functions an options parameter is a wider change than the dictionary alone justifies. - dictionaryHasTag stays a direct probe of the exact standard table. It runs before the transfer syntax is known, to guess a byte order from the first tag; a private block cannot help, and letting a supplied dictionary answer would make the guess depend on how many entries the caller happened to add. 19 new tests in dictionary_composition_test.go, and ExampleNewPrivateDictionary compile-checks the README snippet. Closes #71. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 136ab3d commit c69f831

17 files changed

Lines changed: 1071 additions & 125 deletions

‎CHANGELOG.md‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5252
so nearly every image ever written — and `(0028,1200)` Gray Lookup Table Data
5353
permits three, `US or SS or OW`. `vrDisagreesWithDictionary` now uses it instead
5454
of splitting the string itself
55+
- `ReadOptions.Dictionary` resolves tags for one read, defaulting to `Standard()`
56+
when nil. It changes what an element *means* rather than how it is described: an
57+
implicit VR file carries no VRs, so every element's VR is whatever the dictionary
58+
says, and for a private element that answer depends on the vendor who wrote the
59+
file. The dataset that comes back retains it, because a deferred value is decoded
60+
on `Get` — long after the read returned — and the reload rejects an element whose
61+
VR no longer matches the one it was first read under; a dictionary that went out
62+
of scope with the parse would turn every deferred private element into a mismatch
63+
error. It is also carried into the second read a Deflated transfer syntax starts,
64+
so a file means the same thing deflated and inflated. There is deliberately no
65+
`WriteOptions.Dictionary`: the write path resolves an ambiguous VR from the
66+
dataset's own values — Pixel Representation decides between US and SS — and never
67+
consults the dictionary, so the field would have nothing to do. `DecodeDataset`
68+
and `DecodeDatasetContext` take no options at all and so still resolve against
69+
`Standard()`. Closes
70+
[#71](https://github.com/godicom-dev/godicom/issues/71)
71+
- `NewDictionary(dicts ...Dictionary) Dictionary` composes dictionaries, trying each
72+
in turn and returning the first entry found, so the argument order is the
73+
precedence: `NewDictionary(vendor, godicom.Standard())` reads the vendor's entries
74+
where it has them and PS3.6's everywhere else. First match wins rather than most
75+
specific, because that is the only rule a caller can read off the argument order.
76+
Composing rather than replacing is the point — a caller adding one vendor block
77+
should not have to carry the other 5,189 entries. A nil member is skipped and an
78+
empty composition answers nothing, both of which a caller threading a built-up
79+
list will produce; the members are held rather than copied, so a `PrivateDictionary`
80+
among them stays live, while the slice itself is copied
81+
- `NewPrivateDictionary` returns a `*PrivateDictionary`, a set of private entries
82+
keyed by Private Creator that satisfies `Dictionary`. `Add(creator, tag, vr, name,
83+
vm...)` mirrors `AddPrivateDictEntry` so moving from one to the other is
84+
mechanical, and stores the entry against the tag's block byte, so it resolves
85+
wherever the vendor's block lands — `(0041,1001)` in one file and `(0041,2001)` in
86+
the next. Unlike `AddPrivateDictEntry` it touches no process-global state: a
87+
library can build one, compose it with `Standard()`, and hand it to
88+
`ReadOptions.Dictionary` without changing what any other caller in the process
89+
reads. Safe for concurrent use. Documented in the README with
90+
`ExampleNewPrivateDictionary` behind it, so the snippet is compile-checked
5591

5692
### Changed
93+
- the VR-mismatch diagnostic is judged against the dictionary the read was given
94+
rather than always against PS3.6. A caller who overrode an entry said what they
95+
expect the file to contain, and measuring against a different expectation than the
96+
parse used would be reporting on nothing. Private tags stay exempt even when the
97+
supplied dictionary has an entry for them: the creator is not in hand at that
98+
point, and resolving one per element would charge the quiet path for a diagnostic
99+
nobody asked for
100+
- `AddPrivateDictEntry` and `ResetExtraPrivateDictionaries` now document that they
101+
mutate process-global state — every caller in the process sees an added entry, and
102+
`Reset` clears entries registered by code that has nothing to do with the caller —
103+
and point at `NewPrivateDictionary` plus `ReadOptions.Dictionary` for the version
104+
that does not. Neither is deprecated: a command-line tool registering its vendor's
105+
blocks at startup is what they are for. Behaviour is unchanged, and they are now
106+
implemented on top of `PrivateDictionary` rather than a parallel table
57107
- **Breaking:** the three exported dictionary maps are unexported —
58108
`DicomDictionaryGo`, `RepeatersDictionaryGo` and `PrivateDictionaries` become
59109
`dicomDictionary`, `repeatersDictionary` and `privateDictionaries`, and

‎README.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,39 @@ entry, _ := godicom.Standard().Lookup(tag.PixelData, "")
104104
// entry.VRs() []VR{"OB", "OW"}
105105
```
106106

107+
**Your own dictionary**
108+
109+
`AddPrivateDictEntry` mutates process-global state, which is right for a program
110+
that owns its process and wrong for a library. `NewPrivateDictionary` builds one
111+
that belongs to a single read instead, and `ReadOptions.Dictionary` is how it
112+
gets there:
113+
114+
```go
115+
vendor := godicom.NewPrivateDictionary()
116+
if err := vendor.Add("ACME 3.2", godicom.NewTag(0x0041, 0x1001), godicom.VRUS, "Some Number"); err != nil {
117+
return err
118+
}
119+
ds, err := godicom.ReadFile("ct.dcm", &godicom.ReadOptions{
120+
Dictionary: godicom.NewDictionary(vendor, godicom.Standard()),
121+
})
122+
```
123+
124+
`NewDictionary` composes: each dictionary is tried in turn and the first entry
125+
found wins, so the order is the precedence. Compose rather than replace —
126+
`Standard()` behind the vendor's dictionary is what keeps the other 5,189
127+
entries, and handing over the vendor's on its own would leave every standard
128+
element as UN.
129+
130+
This changes what an element *means*, not merely how it is described. An implicit
131+
VR file carries no VRs, so every element's VR is whatever the dictionary says it
132+
is; the dataset that comes back retains the dictionary, because a deferred value
133+
is decoded on `Get`, long after the read returned, and has to resolve to the VR
134+
it was first read under.
135+
136+
There is no `WriteOptions.Dictionary`. The write path resolves an ambiguous VR
137+
from the dataset's own values — Pixel Representation decides between US and SS —
138+
and never asks the dictionary, so the field would have nothing to do.
139+
107140
**Truncated and malformed files**
108141

109142
By default a read keeps whatever it parsed before the file stopped making sense,

‎codec.go‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,15 @@ package godicom
66
// These travelled the write chain as three separate parameters. That cost two
77
// things worth fixing. The two bools are adjacent and interchangeable, so
88
// transposing them at a call site compiles and silently writes a file in the
9-
// wrong encoding. And every new piece of codec state -- a diagnostic hook, a
10-
// dictionary, a validation policy -- had to be threaded through every signature
11-
// between writeDataset and the leaf that needed it.
9+
// wrong encoding. And every new piece of codec state -- a validation policy, a
10+
// character-set fallback -- had to be threaded through every signature between
11+
// writeDataset and the leaf that needed it.
12+
//
13+
// Not everything the read path carries around belongs here. State that has to
14+
// outlive the parse lives on readContext instead, which is what a Dataset retains:
15+
// the data dictionary in effect is there rather than here because a deferred load
16+
// re-resolves an element's VR long after the codecContext it was first read under
17+
// is gone.
1218
//
1319
// EncodingInfo is embedded rather than restated field by field: it is already
1420
// the type for the implicit-VR/endianness pair, and it is what a Dataset stores

‎dataset.go‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ type readContext struct {
4646
// onDiag is ReadOptions.OnDiagnostic, kept so deferred loads can report
4747
// through the same hook after the read has returned.
4848
onDiag func(Diagnostic) error
49+
// dict is ReadOptions.Dictionary, nil for PS3.6. It lives here rather than on
50+
// codecContext -- where the rest of the per-element decoding state lives --
51+
// because it has to outlive the parse. A deferred load reruns the header
52+
// decode after the read returned, and a private element's VR has to resolve
53+
// the same way it did the first time or the reload is rejected as a mismatch.
54+
// readContext is the only piece of parse state that survives, as
55+
// Dataset.readCtx.
56+
dict Dictionary
4957
// seqPath is the sequences currently being descended into, and the item of
5058
// each, used to stamp Diagnostic.Path.
5159
seqPath []PathStep
@@ -54,6 +62,16 @@ type readContext struct {
5462
baseOffset int64
5563
}
5664

65+
// dictionary returns the data dictionary this parse resolves against: the one
66+
// ReadOptions.Dictionary supplied, or PS3.6. Nil-safe, because a decoder handed
67+
// no readContext at all still has to resolve VRs.
68+
func (rc *readContext) dictionary() Dictionary {
69+
if rc == nil {
70+
return Standard()
71+
}
72+
return dictionaryOrStandard(rc.dict)
73+
}
74+
5775
func (rc *readContext) logCtx() context.Context {
5876
if rc != nil && rc.ctx != nil {
5977
return rc.ctx

‎decode.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ func DecodeDatasetEncodingContext(ctx context.Context, data []byte, isImplicitVR
5454
return ds, nil
5555
}
5656
ds := NewDataset()
57+
// No dict: these entry points take no ReadOptions, so they resolve against
58+
// PS3.6. A caller with a vendor's private dictionary to apply reads through
59+
// ReadBytes with Force set, which takes one; giving these four functions an
60+
// options parameter is a wider change than the dictionary alone justifies.
5761
rc := &readContext{data: data, ctx: ctx}
5862
enc := EncodingInfo{IsImplicitVR: isImplicitVR, IsLittleEndian: isLittleEndian}
5963
_, err := readDatasetElements(data, 0, int64(len(data)), ds, codecContext{EncodingInfo: enc}, nil, rc)

‎deferred.go‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,12 @@ func loadDeferredElement(ctx *readContext, ds *Dataset, elem *Element) error {
106106
}
107107

108108
// The private creator lives in the dataset, not necessarily in data: a
109-
// deferred load may have re-read only the element's own bytes.
110-
raw, err := readRawDataElementAt(data, elementStart, cc.EncodingInfo, datasetCreator(ds))
109+
// deferred load may have re-read only the element's own bytes. The dictionary
110+
// comes from ctx, which is why it lives there: the VR resolved below is
111+
// compared against the one from the original read, so a private element
112+
// resolved through a caller's dictionary the first time has to resolve through
113+
// the same one now.
114+
raw, err := readRawDataElementAt(data, elementStart, cc.EncodingInfo, datasetResolver(ctx, ds))
111115
if err != nil {
112116
return err
113117
}
@@ -128,16 +132,17 @@ func loadDeferredElement(ctx *readContext, ds *Dataset, elem *Element) error {
128132

129133
// readRawDataElementAt reads a single defined-length element at tag position pos.
130134
// Mirrors pydicom.filereader.data_element_generator for one element with
131-
// defer_size=None. creator resolves private creators so an implicit VR private
132-
// element resolves to the same VR it did on the original read.
135+
// defer_size=None. vr carries the dictionary and the private creator lookup, so
136+
// an implicit VR private element resolves to the same VR it did on the original
137+
// read.
133138
func readRawDataElementAt(
134139
data []byte,
135140
pos int64,
136141
enc EncodingInfo,
137-
creator creatorFunc,
142+
vr vrResolver,
138143
) (*RawDataElement, error) {
139144
tag := readTagBytes(data, pos, enc.IsLittleEndian)
140-
h, need, ok := decodeElementHeader(data, pos, tag, enc, creator)
145+
h, need, ok := decodeElementHeader(data, pos, tag, enc, vr)
141146
if !ok {
142147
return nil, fmt.Errorf("godicom: unexpected EOF reading deferred element header for %s: need %d bytes, have %d",
143148
tag, need, int64(len(data))-pos)

‎diagnostic.go‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,13 @@ func truncatedValue(tag Tag, vr VR, valueStart, need, total int64) Diagnostic {
184184
}
185185

186186
// reportVRMismatch offers a disagreement between the VR encoded for tag at pos
187-
// and the VR the data dictionary gives it. Nothing about the parse changes, so
188-
// unlike the truncation diagnostics this one is pure information.
187+
// and the VR the dictionary this read was given gives it. Nothing about the parse
188+
// changes, so unlike the truncation diagnostics this one is pure information.
189+
//
190+
// Judged against the same dictionary the header decoder resolved against, not
191+
// always PS3.6: a caller who overrode an entry through ReadOptions.Dictionary said
192+
// what they expect the file to contain, and a diagnostic measuring against a
193+
// different expectation than the parse used would be reporting on nothing.
189194
//
190195
// Because it is only information, nobody who is not listening should pay for it:
191196
// the dictionary lookup runs once per explicit VR element, which is once per
@@ -206,7 +211,7 @@ func (rc *readContext) reportVRMismatch(tag Tag, encoded VR, pos int64, isImplic
206211
return nil
207212
}
208213
}
209-
want := vrDisagreesWithDictionary(tag, encoded)
214+
want := vrDisagreesWithDictionary(rc.dictionary(), tag, encoded)
210215
if want == "" {
211216
return nil
212217
}

0 commit comments

Comments
 (0)