dicom-synth 1.12.0 → 1.14.0

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.
@@ -6,12 +6,14 @@ import { describeDirectory } from '../dist/esm/index.js'
6
6
 
7
7
  function usage() {
8
8
  console.error(
9
- 'Usage: dicom-synth-describe <dir> [--out <spec.json[.gz]>] [--keep-tags <a,b,...>] [--keep-tags-file <path>] [--size-tolerance <fraction>] [--gzip]\n' +
9
+ 'Usage: dicom-synth-describe <dir> [--out <spec.json[.gz]>] [--keep-tags <a,b,...>] [--keep-tags-file <path>] [--size-tolerance <fraction>] [--no-preserve-uids] [--uid-salt <hex>] [--gzip]\n' +
10
10
  '\n' +
11
11
  'Scans a DICOM tree and emits a DatasetSpec describing its shape\n' +
12
12
  '(studies/series, per-file size, modality).\n' +
13
13
  '\n' +
14
- 'By default only the shape is reproduced no tags are copied.\n' +
14
+ 'UIDs are preserved by default as hash-derived synthetic UIDs, keeping\n' +
15
+ 'cross-file links (FrameOfReferenceUID etc.) without emitting any original\n' +
16
+ 'UID. Other tags are not copied unless named.\n' +
15
17
  '--keep-tags copies the named tags (DICOM keywords or 8-hex tags)\n' +
16
18
  "verbatim; if a kept tag holds PHI, handling it is the caller's\n" +
17
19
  "responsibility, not this tool's.\n" +
@@ -19,6 +21,11 @@ function usage() {
19
21
  'line, # comments); merged with any --keep-tags.\n' +
20
22
  '--size-tolerance folds near-identical file sizes (fraction, e.g. 0.05\n' +
21
23
  '= ±5%) into one entry; default exact.\n' +
24
+ '--no-preserve-uids emits shape only with freshly minted UIDs (no link\n' +
25
+ 'preservation).\n' +
26
+ '--uid-salt fixes the hashing salt (hex) for a specific namespace; when\n' +
27
+ "omitted the salt derives from the dataset's own study UIDs (reproducible,\n" +
28
+ 'and disjoint across datasets). The salt is reported on stderr.\n' +
22
29
  '--gzip (or a .gz --out suffix) writes the spec gzip-compressed.',
23
30
  )
24
31
  process.exit(1)
@@ -38,6 +45,8 @@ if (args.length === 0) usage()
38
45
  let dir
39
46
  let outPath
40
47
  let sizeTolerance
48
+ let preserveUids = true
49
+ let uidSalt
41
50
  let gzip = false
42
51
  const keepTags = []
43
52
 
@@ -64,6 +73,10 @@ for (let i = 0; i < args.length; i++) {
64
73
  console.error('--size-tolerance must be a non-negative number')
65
74
  process.exit(1)
66
75
  }
76
+ } else if (args[i] === '--no-preserve-uids') {
77
+ preserveUids = false
78
+ } else if (args[i] === '--uid-salt' && args[i + 1]) {
79
+ uidSalt = args[++i]
67
80
  } else if (args[i] === '--gzip') {
68
81
  gzip = true
69
82
  } else if (args[i].startsWith('--')) {
@@ -79,11 +92,18 @@ for (let i = 0; i < args.length; i++) {
79
92
 
80
93
  if (dir === undefined) usage()
81
94
 
95
+ if (uidSalt !== undefined && !preserveUids) {
96
+ console.error('--uid-salt cannot be combined with --no-preserve-uids')
97
+ process.exit(1)
98
+ }
99
+
82
100
  let result
83
101
  try {
84
102
  result = describeDirectory(dir, {
85
103
  ...(keepTags.length ? { keepTags } : {}),
86
104
  ...(sizeTolerance !== undefined ? { sizeTolerance } : {}),
105
+ preserveUids,
106
+ ...(uidSalt !== undefined ? { uidSalt } : {}),
87
107
  })
88
108
  } catch (err) {
89
109
  console.error(`Error: ${err.message}`)
@@ -103,6 +123,11 @@ const { dicomFiles, skipped, unknownModality } = result.stats
103
123
  console.error(
104
124
  `Described ${dicomFiles} DICOM file(s); skipped ${skipped} non-DICOM/ungroupable file(s)`,
105
125
  )
126
+ if (result.spec.uidSalt) {
127
+ console.error(
128
+ ` UIDs hash-derived with salt ${result.spec.uidSalt} (reuse via --uid-salt to reproduce)`,
129
+ )
130
+ }
106
131
  if (unknownModality > 0) {
107
132
  console.error(
108
133
  ` ${unknownModality} file(s) had an unsupported Modality — emitted as default (CT)`,
@@ -1,4 +1,5 @@
1
1
  // src/describe/describe.ts
2
+ import { createHash as createHash2 } from "node:crypto";
2
3
  import {
3
4
  closeSync,
4
5
  openSync,
@@ -435,6 +436,14 @@ function validateDatasetSpec(raw) {
435
436
  };
436
437
  }
437
438
 
439
+ // src/syntheticFixtures/uid.ts
440
+ import { createHash, randomBytes } from "node:crypto";
441
+ function hashUid(salt, key) {
442
+ const digest = createHash("sha256").update(salt).update(" ").update(key).digest();
443
+ const n = BigInt(`0x${digest.subarray(0, 16).toString("hex")}`);
444
+ return `2.25.${n.toString()}`;
445
+ }
446
+
438
447
  // src/describe/describe.ts
439
448
  var dcmjsAny = dcmjs;
440
449
  var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
@@ -592,6 +601,113 @@ function extractTags(record, keepKeywords) {
592
601
  }
593
602
  return found ? tags : void 0;
594
603
  }
604
+ var DICOM_ORG_ROOT = "1.2.840.10008.";
605
+ var UID_SWEEP_EXCLUDE = /* @__PURE__ */ new Set([
606
+ "StudyInstanceUID",
607
+ "SeriesInstanceUID",
608
+ "SOPClassUID"
609
+ ]);
610
+ var vrCache = /* @__PURE__ */ new Map();
611
+ function vrOf(keyword) {
612
+ if (vrCache.has(keyword)) return vrCache.get(keyword);
613
+ const nm = dcmjsAny.data.DicomMetaDictionary.nameMap;
614
+ const vr = (nm?.[keyword] ?? nm?.[`RETIRED_${keyword}`])?.vr;
615
+ vrCache.set(keyword, vr);
616
+ return vr;
617
+ }
618
+ function hashUidVia(map, salt, original) {
619
+ let synthetic = map.get(original);
620
+ if (synthetic === void 0) {
621
+ synthetic = hashUid(salt, original);
622
+ map.set(original, synthetic);
623
+ }
624
+ return synthetic;
625
+ }
626
+ function isUidLeaf(value) {
627
+ return typeof value === "string" || Array.isArray(value) && value.every((v) => typeof v === "string");
628
+ }
629
+ function hasHashableUid(value) {
630
+ const uids = typeof value === "string" ? [value] : value;
631
+ return uids.some((v) => !v.startsWith(DICOM_ORG_ROOT));
632
+ }
633
+ function pruneUidSequence(value) {
634
+ const items = Array.isArray(value) ? value : [value];
635
+ const pruned = [];
636
+ for (const item of items) {
637
+ if (typeof item !== "object" || item === null || Array.isArray(item))
638
+ continue;
639
+ const skeleton = {};
640
+ let hashable = false;
641
+ for (const [keyword, v] of Object.entries(item)) {
642
+ if (keyword === "_vrMap") continue;
643
+ const vr = vrOf(keyword);
644
+ if (vr === "UI") {
645
+ if (isUidLeaf(v)) {
646
+ skeleton[keyword] = v;
647
+ if (hasHashableUid(v)) hashable = true;
648
+ }
649
+ } else if (vr === "SQ") {
650
+ const nested = pruneUidSequence(v);
651
+ if (nested) {
652
+ skeleton[keyword] = nested;
653
+ hashable = true;
654
+ }
655
+ }
656
+ }
657
+ if (hashable) pruned.push(skeleton);
658
+ }
659
+ return pruned.length > 0 ? pruned : void 0;
660
+ }
661
+ function collectUidOriginals(record) {
662
+ const uids = {};
663
+ let found = false;
664
+ for (const [keyword, value] of Object.entries(record)) {
665
+ if (UID_SWEEP_EXCLUDE.has(keyword)) continue;
666
+ const vr = vrOf(keyword);
667
+ if (vr === "UI") {
668
+ if (isUidLeaf(value) && hasHashableUid(value)) {
669
+ uids[keyword] = value;
670
+ found = true;
671
+ }
672
+ } else if (vr === "SQ") {
673
+ const pruned = pruneUidSequence(value);
674
+ if (pruned) {
675
+ uids[keyword] = pruned;
676
+ found = true;
677
+ }
678
+ }
679
+ }
680
+ return found ? uids : void 0;
681
+ }
682
+ function hashUidValue(value, salt, map) {
683
+ if (typeof value === "string") {
684
+ return value.startsWith(DICOM_ORG_ROOT) ? value : hashUidVia(map, salt, value);
685
+ }
686
+ if (Array.isArray(value)) return value.map((v) => hashUidValue(v, salt, map));
687
+ return Object.fromEntries(
688
+ Object.entries(value).map(([k, v]) => [
689
+ k,
690
+ hashUidValue(v, salt, map)
691
+ ])
692
+ );
693
+ }
694
+ function hashUidTags(uids, salt, map) {
695
+ const tags = {};
696
+ for (const [keyword, original] of Object.entries(uids)) {
697
+ tags[keyword] = hashUidValue(original, salt, map);
698
+ }
699
+ return tags;
700
+ }
701
+ function deriveSalt(studyUids) {
702
+ const h = createHash2("sha256");
703
+ for (const uid of [...studyUids].sort()) h.update(uid).update("\n");
704
+ return h.digest("hex");
705
+ }
706
+ function mergeTags(a, b) {
707
+ if (!a) return b;
708
+ if (!b) return a;
709
+ return { ...a, ...b };
710
+ }
595
711
  function sizeKbOf(bytes) {
596
712
  return Math.max(1, Math.round(bytes / 1024));
597
713
  }
@@ -696,7 +812,9 @@ function seriesSpecFromAccum(accum) {
696
812
  }
697
813
  function describeDirectory(dir, options = {}) {
698
814
  const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
699
- const keepTags = keepKeywords.length > 0;
815
+ const preserveUids = options.preserveUids ?? true;
816
+ const uidMap = /* @__PURE__ */ new Map();
817
+ const useRecords = keepKeywords.length > 0 || preserveUids;
700
818
  const tolerance = options.sizeTolerance ?? 0;
701
819
  if (!Number.isFinite(tolerance) || tolerance < 0) {
702
820
  throw new Error(
@@ -734,9 +852,16 @@ function describeDirectory(dir, options = {}) {
734
852
  stats.unknownModality++;
735
853
  }
736
854
  }
737
- const tags = keepTags ? extractTags(record, keepKeywords) : void 0;
855
+ const keptTags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
856
+ const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
738
857
  const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
739
- const fileRecord = { large, bytes, modality, tags };
858
+ const fileRecord = {
859
+ large,
860
+ bytes,
861
+ modality,
862
+ tags: keptTags,
863
+ uidOriginals
864
+ };
740
865
  let study = studies.get(studyUid);
741
866
  if (!study) {
742
867
  study = /* @__PURE__ */ new Map();
@@ -744,7 +869,7 @@ function describeDirectory(dir, options = {}) {
744
869
  }
745
870
  let series = study.get(seriesUid);
746
871
  if (!series) {
747
- series = keepTags ? [] : /* @__PURE__ */ new Map();
872
+ series = useRecords ? [] : /* @__PURE__ */ new Map();
748
873
  study.set(seriesUid, series);
749
874
  }
750
875
  if (Array.isArray(series)) {
@@ -753,13 +878,56 @@ function describeDirectory(dir, options = {}) {
753
878
  accumulate(series, fileRecord);
754
879
  }
755
880
  }
756
- const studySpecs = [...studies.values()].map((study) => ({
757
- series: [...study.values()].map(seriesSpecFromAccum)
758
- }));
881
+ const salt = preserveUids ? options.uidSalt ?? deriveSalt([...studies.keys()]) : void 0;
882
+ if (salt !== void 0) {
883
+ const sweptKeepTags = /* @__PURE__ */ new Set();
884
+ for (const study of studies.values()) {
885
+ for (const series of study.values()) {
886
+ if (!Array.isArray(series)) continue;
887
+ for (const rec of series) {
888
+ if (rec.uidOriginals) {
889
+ if (rec.tags) {
890
+ for (const keyword of Object.keys(rec.uidOriginals)) {
891
+ if (keyword in rec.tags) sweptKeepTags.add(keyword);
892
+ }
893
+ }
894
+ rec.tags = mergeTags(
895
+ rec.tags,
896
+ hashUidTags(rec.uidOriginals, salt, uidMap)
897
+ );
898
+ }
899
+ }
900
+ }
901
+ }
902
+ for (const keyword of sweptKeepTags) {
903
+ console.warn(
904
+ `describe: keep-tag "${keyword}" contains UID references \u2014 kept as its hashed UID skeleton, not verbatim (disable with preserveUids: false)`
905
+ );
906
+ }
907
+ }
908
+ const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
909
+ ...spec2,
910
+ tags: {
911
+ ...spec2.tags,
912
+ SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
913
+ }
914
+ };
915
+ const studySpecs = [...studies.entries()].map(
916
+ ([studyUid, study]) => ({
917
+ ...salt !== void 0 ? { tags: { StudyInstanceUID: hashUidVia(uidMap, salt, studyUid) } } : {},
918
+ series: [...study.entries()].map(
919
+ ([seriesUid, accum]) => withGroupUid(seriesSpecFromAccum(accum), seriesUid)
920
+ )
921
+ })
922
+ );
759
923
  if (studySpecs.length === 0) {
760
924
  throw new Error(`describe: no DICOM series found in ${dir}`);
761
925
  }
762
- const spec = { studies: studySpecs, layout: "hierarchical" };
926
+ const spec = {
927
+ studies: studySpecs,
928
+ layout: "hierarchical",
929
+ ...salt !== void 0 ? { uidSalt: salt } : {}
930
+ };
763
931
  validateDatasetSpec(spec);
764
932
  return { spec, stats };
765
933
  }
package/dist/esm/index.js CHANGED
@@ -1390,6 +1390,7 @@ async function writeCollectionFromSpec(spec, outDir) {
1390
1390
  }
1391
1391
 
1392
1392
  // src/describe/describe.ts
1393
+ import { createHash as createHash2 } from "node:crypto";
1393
1394
  import {
1394
1395
  closeSync as closeSync2,
1395
1396
  openSync as openSync2,
@@ -1555,6 +1556,113 @@ function extractTags(record, keepKeywords) {
1555
1556
  }
1556
1557
  return found ? tags : void 0;
1557
1558
  }
1559
+ var DICOM_ORG_ROOT = "1.2.840.10008.";
1560
+ var UID_SWEEP_EXCLUDE = /* @__PURE__ */ new Set([
1561
+ "StudyInstanceUID",
1562
+ "SeriesInstanceUID",
1563
+ "SOPClassUID"
1564
+ ]);
1565
+ var vrCache = /* @__PURE__ */ new Map();
1566
+ function vrOf(keyword) {
1567
+ if (vrCache.has(keyword)) return vrCache.get(keyword);
1568
+ const nm = dcmjsAny2.data.DicomMetaDictionary.nameMap;
1569
+ const vr = (nm?.[keyword] ?? nm?.[`RETIRED_${keyword}`])?.vr;
1570
+ vrCache.set(keyword, vr);
1571
+ return vr;
1572
+ }
1573
+ function hashUidVia(map, salt, original) {
1574
+ let synthetic = map.get(original);
1575
+ if (synthetic === void 0) {
1576
+ synthetic = hashUid(salt, original);
1577
+ map.set(original, synthetic);
1578
+ }
1579
+ return synthetic;
1580
+ }
1581
+ function isUidLeaf(value) {
1582
+ return typeof value === "string" || Array.isArray(value) && value.every((v) => typeof v === "string");
1583
+ }
1584
+ function hasHashableUid(value) {
1585
+ const uids = typeof value === "string" ? [value] : value;
1586
+ return uids.some((v) => !v.startsWith(DICOM_ORG_ROOT));
1587
+ }
1588
+ function pruneUidSequence(value) {
1589
+ const items = Array.isArray(value) ? value : [value];
1590
+ const pruned = [];
1591
+ for (const item of items) {
1592
+ if (typeof item !== "object" || item === null || Array.isArray(item))
1593
+ continue;
1594
+ const skeleton = {};
1595
+ let hashable = false;
1596
+ for (const [keyword, v] of Object.entries(item)) {
1597
+ if (keyword === "_vrMap") continue;
1598
+ const vr = vrOf(keyword);
1599
+ if (vr === "UI") {
1600
+ if (isUidLeaf(v)) {
1601
+ skeleton[keyword] = v;
1602
+ if (hasHashableUid(v)) hashable = true;
1603
+ }
1604
+ } else if (vr === "SQ") {
1605
+ const nested = pruneUidSequence(v);
1606
+ if (nested) {
1607
+ skeleton[keyword] = nested;
1608
+ hashable = true;
1609
+ }
1610
+ }
1611
+ }
1612
+ if (hashable) pruned.push(skeleton);
1613
+ }
1614
+ return pruned.length > 0 ? pruned : void 0;
1615
+ }
1616
+ function collectUidOriginals(record) {
1617
+ const uids = {};
1618
+ let found = false;
1619
+ for (const [keyword, value] of Object.entries(record)) {
1620
+ if (UID_SWEEP_EXCLUDE.has(keyword)) continue;
1621
+ const vr = vrOf(keyword);
1622
+ if (vr === "UI") {
1623
+ if (isUidLeaf(value) && hasHashableUid(value)) {
1624
+ uids[keyword] = value;
1625
+ found = true;
1626
+ }
1627
+ } else if (vr === "SQ") {
1628
+ const pruned = pruneUidSequence(value);
1629
+ if (pruned) {
1630
+ uids[keyword] = pruned;
1631
+ found = true;
1632
+ }
1633
+ }
1634
+ }
1635
+ return found ? uids : void 0;
1636
+ }
1637
+ function hashUidValue(value, salt, map) {
1638
+ if (typeof value === "string") {
1639
+ return value.startsWith(DICOM_ORG_ROOT) ? value : hashUidVia(map, salt, value);
1640
+ }
1641
+ if (Array.isArray(value)) return value.map((v) => hashUidValue(v, salt, map));
1642
+ return Object.fromEntries(
1643
+ Object.entries(value).map(([k, v]) => [
1644
+ k,
1645
+ hashUidValue(v, salt, map)
1646
+ ])
1647
+ );
1648
+ }
1649
+ function hashUidTags(uids, salt, map) {
1650
+ const tags = {};
1651
+ for (const [keyword, original] of Object.entries(uids)) {
1652
+ tags[keyword] = hashUidValue(original, salt, map);
1653
+ }
1654
+ return tags;
1655
+ }
1656
+ function deriveSalt(studyUids) {
1657
+ const h = createHash2("sha256");
1658
+ for (const uid of [...studyUids].sort()) h.update(uid).update("\n");
1659
+ return h.digest("hex");
1660
+ }
1661
+ function mergeTags(a, b) {
1662
+ if (!a) return b;
1663
+ if (!b) return a;
1664
+ return { ...a, ...b };
1665
+ }
1558
1666
  function sizeKbOf(bytes) {
1559
1667
  return Math.max(1, Math.round(bytes / 1024));
1560
1668
  }
@@ -1659,7 +1767,9 @@ function seriesSpecFromAccum(accum) {
1659
1767
  }
1660
1768
  function describeDirectory(dir, options = {}) {
1661
1769
  const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
1662
- const keepTags = keepKeywords.length > 0;
1770
+ const preserveUids = options.preserveUids ?? true;
1771
+ const uidMap = /* @__PURE__ */ new Map();
1772
+ const useRecords = keepKeywords.length > 0 || preserveUids;
1663
1773
  const tolerance = options.sizeTolerance ?? 0;
1664
1774
  if (!Number.isFinite(tolerance) || tolerance < 0) {
1665
1775
  throw new Error(
@@ -1697,9 +1807,16 @@ function describeDirectory(dir, options = {}) {
1697
1807
  stats.unknownModality++;
1698
1808
  }
1699
1809
  }
1700
- const tags = keepTags ? extractTags(record, keepKeywords) : void 0;
1810
+ const keptTags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
1811
+ const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
1701
1812
  const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
1702
- const fileRecord = { large, bytes, modality, tags };
1813
+ const fileRecord = {
1814
+ large,
1815
+ bytes,
1816
+ modality,
1817
+ tags: keptTags,
1818
+ uidOriginals
1819
+ };
1703
1820
  let study = studies.get(studyUid);
1704
1821
  if (!study) {
1705
1822
  study = /* @__PURE__ */ new Map();
@@ -1707,7 +1824,7 @@ function describeDirectory(dir, options = {}) {
1707
1824
  }
1708
1825
  let series = study.get(seriesUid);
1709
1826
  if (!series) {
1710
- series = keepTags ? [] : /* @__PURE__ */ new Map();
1827
+ series = useRecords ? [] : /* @__PURE__ */ new Map();
1711
1828
  study.set(seriesUid, series);
1712
1829
  }
1713
1830
  if (Array.isArray(series)) {
@@ -1716,13 +1833,56 @@ function describeDirectory(dir, options = {}) {
1716
1833
  accumulate(series, fileRecord);
1717
1834
  }
1718
1835
  }
1719
- const studySpecs = [...studies.values()].map((study) => ({
1720
- series: [...study.values()].map(seriesSpecFromAccum)
1721
- }));
1836
+ const salt = preserveUids ? options.uidSalt ?? deriveSalt([...studies.keys()]) : void 0;
1837
+ if (salt !== void 0) {
1838
+ const sweptKeepTags = /* @__PURE__ */ new Set();
1839
+ for (const study of studies.values()) {
1840
+ for (const series of study.values()) {
1841
+ if (!Array.isArray(series)) continue;
1842
+ for (const rec of series) {
1843
+ if (rec.uidOriginals) {
1844
+ if (rec.tags) {
1845
+ for (const keyword of Object.keys(rec.uidOriginals)) {
1846
+ if (keyword in rec.tags) sweptKeepTags.add(keyword);
1847
+ }
1848
+ }
1849
+ rec.tags = mergeTags(
1850
+ rec.tags,
1851
+ hashUidTags(rec.uidOriginals, salt, uidMap)
1852
+ );
1853
+ }
1854
+ }
1855
+ }
1856
+ }
1857
+ for (const keyword of sweptKeepTags) {
1858
+ console.warn(
1859
+ `describe: keep-tag "${keyword}" contains UID references \u2014 kept as its hashed UID skeleton, not verbatim (disable with preserveUids: false)`
1860
+ );
1861
+ }
1862
+ }
1863
+ const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
1864
+ ...spec2,
1865
+ tags: {
1866
+ ...spec2.tags,
1867
+ SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
1868
+ }
1869
+ };
1870
+ const studySpecs = [...studies.entries()].map(
1871
+ ([studyUid, study]) => ({
1872
+ ...salt !== void 0 ? { tags: { StudyInstanceUID: hashUidVia(uidMap, salt, studyUid) } } : {},
1873
+ series: [...study.entries()].map(
1874
+ ([seriesUid, accum]) => withGroupUid(seriesSpecFromAccum(accum), seriesUid)
1875
+ )
1876
+ })
1877
+ );
1722
1878
  if (studySpecs.length === 0) {
1723
1879
  throw new Error(`describe: no DICOM series found in ${dir}`);
1724
1880
  }
1725
- const spec = { studies: studySpecs, layout: "hierarchical" };
1881
+ const spec = {
1882
+ studies: studySpecs,
1883
+ layout: "hierarchical",
1884
+ ...salt !== void 0 ? { uidSalt: salt } : {}
1885
+ };
1726
1886
  validateDatasetSpec(spec);
1727
1887
  return { spec, stats };
1728
1888
  }
@@ -1759,7 +1919,7 @@ function loadCaseById(casesJsonPath, id) {
1759
1919
  }
1760
1920
 
1761
1921
  // src/public-fixtures/fetch.ts
1762
- import { createHash as createHash2 } from "node:crypto";
1922
+ import { createHash as createHash3 } from "node:crypto";
1763
1923
  import { existsSync, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
1764
1924
  import { homedir } from "node:os";
1765
1925
  import { join as join3 } from "node:path";
@@ -1768,7 +1928,7 @@ function caseCachePath(sha256, root = DEFAULT_CACHE_ROOT) {
1768
1928
  return join3(root, sha256, "file.dcm");
1769
1929
  }
1770
1930
  function verifySha256(buffer, expected) {
1771
- const h = createHash2("sha256").update(buffer).digest("hex");
1931
+ const h = createHash3("sha256").update(buffer).digest("hex");
1772
1932
  if (h !== expected) {
1773
1933
  throw new Error(
1774
1934
  `SHA256 mismatch: expected ${expected}, got ${h}. Upstream may have changed; bump metadata after review.`
@@ -2,6 +2,8 @@ import type { DatasetSpec } from '../schema/types.js';
2
2
  export type DescribeOptions = {
3
3
  keepTags?: string[];
4
4
  sizeTolerance?: number;
5
+ preserveUids?: boolean;
6
+ uidSalt?: string;
5
7
  };
6
8
  export type DescribeResult = {
7
9
  spec: DatasetSpec;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dicom-synth",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
4
4
  "description": "Toolkit for synthetic DICOM fixtures and public fixture fetch/cache.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",