Skip to content

Let a caller bring their own data dictionary, and keep it alive past the read. - #82

Merged
xbmlz merged 1 commit into
mainfrom
feat/dictionary-composition
Aug 25, 2026
Merged

xbmlz merged 1 commit into
mainfrom
feat/dictionary-composition

Conversation

@xbmlz

@xbmlz xbmlz commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Closes #71.

#81 put the data dictionary behind a Dictionary 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.

What this adds

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.

NewPrivateDictionary builds a set of vendor entries that touches no global
state. NewDictionary composes: each member is tried in turn and the first entry
found wins, so the argument order is the precedence.

vendor := godicom.NewPrivateDictionary()
if err := vendor.Add("ACME 3.2", godicom.NewTag(0x0041, 0x1001), godicom.VRUS, "Some Number"); err != nil {
	return err
}
ds, err := godicom.ReadFile("ct.dcm", &godicom.ReadOptions{
	Dictionary: godicom.NewDictionary(vendor, godicom.Standard()),
})

Compose rather than replace — Standard() behind the vendor's dictionary keeps
the 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, not codecContext. 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 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

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 — a command-line tool registering its vendor's blocks
at startup is what they are for. They are now implemented on top of
PrivateDictionary rather than a parallel table, and document the global mutation
they perform.

Gaps left deliberately

Each recorded in the code where someone would look for it, not only here:

  • 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 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.go cover composition order, that a
composite 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
ReadBytes and ReadFile, the diagnostic, and the Deflated path over
testdata/image_dfl.dcm. ExampleNewPrivateDictionary compile-checks the new
README 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 ./..., and go vet for windows/386,
linux/386, linux/arm (plus GOARM=5), linux/riscv64, linux/ppc64le, js/wasm and
wasip1/wasm — all clean.

Breaking at 0.x: nothing exported changes behaviour. creatorFunc and the two
removed resolvers were unexported.

🤖 Generated with Claude Code

…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>
@xbmlz
xbmlz merged commit c69f831 into main Aug 25, 2026
6 checks passed
@xbmlz
xbmlz deleted the feat/dictionary-composition branch August 25, 2026 16:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dictionary should be represented by an interface (#51 §17)

1 participant