Repository navigation
Let a caller bring their own data dictionary, and keep it alive past the read. - #82
Merged
Merged
Conversation
…the read. #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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #71.
#81 put the data dictionary behind a
Dictionaryinterface. Nothing could supplyone. 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 aprocess-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.
What this adds
ReadOptions.Dictionaryis the per-read version, nil meaning PS3.6. It changeswhat 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.
NewPrivateDictionarybuilds a set of vendor entries that touches no globalstate.
NewDictionarycomposes: each member is tried in turn and the first entryfound wins, so the argument order is the precedence.
Compose rather than replace —
Standard()behind the vendor's dictionary keepsthe other 5,189 entries, and handing over the vendor's 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, and each is
mutation-tested rather than asserted.
The dictionary lives on
readContext, notcodecContext. It has to outlivethe parse: a deferred value is decoded on
Get, long after the read returned, anddeferred.gocompares the VR it re-resolves against the one the element was firstread 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.readContextis the only piece of parse state aDatasetretains, which is whatmakes it the right home. Replacing
rc.dictionary()withStandard()indatasetResolverreproduces that error in both subtests ofTestReadOptionsDictionarySurvivesADeferredLoad.finishDeflatedcopiesReadOptionsfield by field, so a Deflated file wouldhave read its inflated dataset against PS3.6 while the same file undeflated
resolved properly. Only
readReaderAttakes that path —readBytesinflates inplace and keeps its
readCtx— so the test drivesReadFileas well asReadBytes, and dropping the copy fails it.The VR-mismatch diagnostic is judged against the dictionary the read was
given, not 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.
Refactoring
dictionaryVRForReadandlookupVRWithCreatorare replaced byvrResolver,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
Standardwith no creator,which is what a header decoded outside a parse should get.
LookupVRis now onecall to it.
AddPrivateDictEntryandResetExtraPrivateDictionariesbehave exactly as beforeand are not deprecated — a command-line tool registering its vendor's blocks
at startup is what they are for. They are now implemented on top of
PrivateDictionaryrather than a parallel table, and document the global mutationthey perform.
Gaps left deliberately
Each recorded in the code where someone would look for it, not only here:
WriteOptions.Dictionary. The write path resolves an ambiguous VR fromthe dataset's own values — Pixel Representation decides between US and SS — and
never consults the dictionary, so the field would have nothing to do.
DecodeDatasetand friends take no options at all and so still resolveagainst
Standard(). Giving four functions an options parameter is a widerchange than the dictionary alone justifies.
dictionaryHasTagstays a direct probe of the exact standard table. It runsbefore the transfer syntax is known, to guess a byte order from the first tag in
the dataset; a private block cannot help — a private tag is private in both byte
orders — and letting a supplied dictionary answer would make the guess depend on
how many entries the caller happened to add.
Testing
19 new tests in
dictionary_composition_test.gocover composition order, that acomposite does not look past the first answer, nil and empty members, live
members, a relocated private block, standard-tag rejection, separation from the
process-global dictionary, private VR resolution, deferred loads through both
ReadBytesandReadFile, the diagnostic, and the Deflated path overtestdata/image_dfl.dcm.ExampleNewPrivateDictionarycompile-checks the newREADME snippet.
Local gate, mirroring
ci.yml:go build,go vet ./...,go test -count=1 -p 4 ./...(966 pass in 8 packages), gofmt over 131 files,staticcheck -checks=all,GOARCH=386 CGO_ENABLED=0 go test -count=1 ./..., andgo vetfor windows/386,linux/386, linux/arm (plus
GOARM=5), linux/riscv64, linux/ppc64le, js/wasm andwasip1/wasm — all clean.
Breaking at 0.x: nothing exported changes behaviour.
creatorFuncand the tworemoved resolvers were unexported.
🤖 Generated with Claude Code