Skip to content

Ask for a private block by vendor name, and let a caller create one. - #83

Merged
xbmlz merged 1 commit into
mainfrom
feat/private-block
Aug 26, 2026
Merged

xbmlz merged 1 commit into
mainfrom
feat/private-block

Conversation

@xbmlz

@xbmlz xbmlz commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

A private element's tag is not fixed by the vendor's documentation. PS3.5 §7.8.1 makes the high byte of the element number a block the vendor reserves at write time, by writing its name into (gggg,00xx) — the Private Creator. GE's documented "element 1 of GEMS_ACQU_01" is therefore (0019,1001) in one file and (0019,2001) in the next, depending on which block was free when each was written. Code that hardcodes (0019,1001) reads a different vendor's data the first time it meets a file where the blocks landed differently — and reads it successfully, because there is nothing about the bytes to object to.

Dataset.PrivateBlock existed to remove that hardcoding and was too shallow to build on. It is now the read half of a pair, with the create half beside it, both in private_block.go with the allocation rules they implement.

API

ds.PrivateBlock(group uint16, creator string) (*PrivateBlock, bool) the block a creator holds, if it holds one
ds.NewPrivateBlock(group uint16, creator string) (*PrivateBlock, error) the same, reserving the lowest free block when it holds none
ds.PrivateCreators(group uint16) []string the names that have reserved a block, ordered by block
block.Delete(offset uint8) remove an element from the block, keeping the reservation
block, ok := ds.PrivateBlock(0x0019, "GEMS_ACQU_01")
if !ok {
	return // this file has nothing from that vendor in that group
}
elem, ok := block.Get(0x01) // (0019,1001) or (0019,2001), whichever this file uses

Breaking, and acceptable at 0.x:

  • PrivateBlock returns (*PrivateBlock, bool) rather than a bare pointer that was nil when no such creator was there. A file from another manufacturer has no such block, which is the ordinary case rather than an exceptional one, and the old signature made it a nil dereference in the caller's next line: ds.PrivateBlock(0x0019, "GEMS_ACQU_01").Get(0x01) panics on every file GE did not write.
  • group is uint16 and offset is uint8 where both were int. A block offset is the low byte of an element number and nothing else, and an int let it be anything: GetTag(0x1234) returned (0009,2234), silently a different vendor's block, and GetTag(-1) returned (0009,0FFF), which is not a private element at all. Both were measured before being changed rather than assumed. pydicom raises ValueError above 0xFF at run time and does not check for negatives; here the type carries the constraint, so the check has nowhere left to fail.

NewPrivateBlock is what writing private data needs and PrivateBlock could not do: with no creator element there is no block, and a private element written without one is an element no reader can attribute to anyone. It writes the creator as LO, returns an existing block untouched, and reports an error rather than reserving for an even group, an empty creator, or a group whose 240 blocks are all taken — the alternative to the last being block 0x00, which PS3.5 reserves.

Two defects pydicom shares

Each with the mutation that shows the test for it bites, rather than a test that merely passes:

  • The block was picked by map iteration order. With one creator name reserving two blocks in a group — malformed, and nothing in the read path rejects it — the tag a private element was read from could differ between runs of one program over one file. findPrivateCreator now scans elements 0x10 through 0xFF in order and the lowest block wins. Restoring the range d.elements scan fails TestPrivateBlockLowestBlockWins at (0009,2001) where it wants (0009,1001).
  • A cached block outlived the element that reserved it. Deleting a Private Creator, or overwriting it with another vendor's name, left the block resolving and still answering with its old base element: reads came back from whatever now occupies those tags, and writes produced private elements attributed to a vendor the dataset no longer names. Set and Delete now drop the cache when the tag is a Private Creator, which covers Pop, Clear and RemovePrivateTags too. Disabling either guard fails three tests, one of them the element-count assertion pydicom's issue #1097 was filed on. pydicom fixed the deletion half and still caches across the overwrite.

Clone needed nothing: cloneDataset starts from NewDataset and rebuilds, so a clone's blocks are bound to the clone rather than writing the clone's private elements into the original — which is what pydicom's __deepcopy__ rebuilds every block to avoid. TestPrivateBlockCloneIsIndependent pins it.

The nil-map guard in cachePrivateBlock is gone rather than tested. Only NewDataset constructs a Dataset, and a zero-value one panics at Set on its nil elements map long before reaching that guard, so it protected nothing that was not already broken.

Verification

19 tests in private_block_test.go, mirroring pydicom's test_private_block, test_add_new_private_tag, test_delete_private_tag, test_private_creators, test_non_contiguous_private_creators, test_create_private_tag_after_removing_all, test_create_private_tag_after_removing_private_creator and test_private_creator_from_raw_ds. Every function in private_block.go is at 100% statement coverage.

Locally, mirroring ci.yml: go build, go vet, go test -count=1 -p 4 ./..., staticcheck -checks=all (via go run ...@latest, as CI does), gofmt -l over 133 files with LF normalisation, GOARCH=386 CGO_ENABLED=0 go test as a real 32-bit run under WoW64, and go vet for all eight cross-build targets plus GOARM=5.

The README has a section on private elements with ExampleDataset_PrivateBlock and ExampleDataset_NewPrivateBlock behind it, so the snippets are compile-checked.

API sign-off

Confirmed with the human before it was written, as AGENTS.md requires: the ok idiom for the read path to match Dataset.Get, a separate error-returning create path rather than the single (block, err) of #51's sketch, uint8 offsets, and both PrivateCreators and Delete.

Part of #51 §18. Leaving #51 open — it has other sections.

🤖 Generated with Claude Code

A private element's tag is not fixed by the vendor's documentation. PS3.5 7.8.1
makes the high byte of the element number a block the vendor reserves at write
time, by writing its name into (gggg,00xx) -- the Private Creator. GE's documented
"element 1 of GEMS_ACQU_01" is therefore (0019,1001) in one file and (0019,2001)
in the next, depending on which block was free when each was written. Code that
hardcodes (0019,1001) reads a different vendor's data the first time it meets a
file where the blocks landed differently, and reads it successfully, because there
is nothing about the bytes to object to.

Dataset.PrivateBlock existed to remove that hardcoding and was too shallow to
build on. It is now the read half of a pair, with the create half beside it, both
in private_block.go with the allocation rules they implement:

  - PrivateBlock(group uint16, creator string) (*PrivateBlock, bool) reports "no
    such block" instead of returning nil. A file from another manufacturer has no
    such block, which is the ordinary case rather than an exceptional one, and the
    old signature made it a nil dereference in the caller's next line:
    ds.PrivateBlock(0x0019, "GEMS_ACQU_01").Get(0x01) panics on every file GE did
    not write.

  - NewPrivateBlock(group uint16, creator string) (*PrivateBlock, error) reserves
    the lowest free block when the creator has none. Without it there was no way
    to write private data at all: with no creator element there is no block, and a
    private element written without one is an element no reader can attribute to
    anyone. It writes the creator as LO, returns an existing block untouched, and
    reports an error rather than reserving for an even group, an empty creator, or
    a group whose 240 blocks are all taken -- the alternative to the last being
    block 0x00, which PS3.5 reserves.

  - PrivateCreators(group uint16) []string lists the names that have reserved a
    block, ordered by block. It is where to start with a file from a manufacturer
    whose documentation you do not have: the tags say nothing, the creator names
    say who to ask.

  - PrivateBlock.Delete(offset uint8) completes the set. It does not release the
    block; the creator element stays, so an emptied block is still that vendor's.

offset is uint8 and group is uint16 where both were int. A block offset is the low
byte of an element number and nothing else, and an int let it be anything:
GetTag(0x1234) returned (0009,2234), silently a different vendor's block, and
GetTag(-1) returned (0009,0FFF), which is not a private element at all. Both were
measured before being changed rather than assumed. pydicom raises ValueError above
0xFF at run time and does not check for negatives; here the type carries the
constraint, so the check has nowhere left to fail.

Two defects pydicom shares, each with the mutation that shows the test for it
bites:

The block was picked by map iteration order, so with one creator name reserving
two blocks in a group -- malformed, and nothing in the read path rejects it -- the
tag a private element was read from could differ between runs of one program over
one file. findPrivateCreator now scans elements 0x10 through 0xFF in order and the
lowest block wins. Restoring the range-over-d.elements scan fails
TestPrivateBlockLowestBlockWins at (0009,2001) where it wants (0009,1001).

A cached block outlived the element that reserved it. Deleting a Private Creator,
or overwriting it with another vendor's name, left the block resolving and still
answering with its old base element: reads came back from whatever now occupies
those tags, and writes produced private elements attributed to a vendor the dataset
no longer names. Set and Delete now drop the cache when the tag is a Private
Creator, which covers Pop, Clear and RemovePrivateTags too. Disabling either guard
fails three tests, one of them the element-count assertion pydicom's issue #1097
was filed on. pydicom fixed the deletion half and still caches across the
overwrite.

Clone needed nothing: cloneDataset starts from NewDataset and rebuilds, so a
clone's blocks are bound to the clone rather than writing the clone's private
elements into the original -- which is what pydicom's __deepcopy__ rebuilds every
block to avoid. TestPrivateBlockCloneIsIndependent pins it.

The nil-map guard in cachePrivateBlock is gone rather than tested. Only NewDataset
constructs a Dataset, and a zero-value one panics at Set on its nil elements map
long before reaching that guard, so it protected nothing that was not already
broken.

The API shape was confirmed before it was written, as AGENTS.md requires: the ok
idiom for the read path to match Dataset.Get, a separate error-returning create
path rather than the single (block, err) of #51's sketch, uint8 offsets, and both
PrivateCreators and Delete.

19 tests in private_block_test.go, mirroring pydicom's test_private_block,
test_add_new_private_tag, test_delete_private_tag, test_private_creators,
test_non_contiguous_private_creators, test_create_private_tag_after_removing_all,
test_create_private_tag_after_removing_private_creator and
test_private_creator_from_raw_ds. Every function in private_block.go is at 100%
statement coverage. The README has a section on it with ExampleDataset_PrivateBlock
and ExampleDataset_NewPrivateBlock behind it, so the snippets are compile-checked.

Part of #51 section 18.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@xbmlz
xbmlz merged commit 308d55c into main Aug 26, 2026
6 checks passed
@xbmlz
xbmlz deleted the feat/private-block branch August 26, 2026 02:26
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.

1 participant