dicom-synth 1.17.0 → 1.18.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,7 +6,7 @@ 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]>] [--tree] [--keep-tags <a,b,...>] [--keep-tags-file <path>] [--size-tolerance <fraction>] [--no-preserve-uids] [--uid-salt <hex>] [--gzip]\n' +
9
+ 'Usage: dicom-synth-describe <dir> [--out <spec.json[.gz]>] [--tree] [--keep-tags <a,b,...>] [--keep-tags-file <path>] [--keep-private-tags] [--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' +
@@ -22,6 +22,13 @@ function usage() {
22
22
  "responsibility, not this tool's.\n" +
23
23
  '--keep-tags-file reads the same allow-list from a file (one tag per\n' +
24
24
  'line, # comments); merged with any --keep-tags.\n' +
25
+ '--keep-private-tags captures ALL private (odd-group) elements as-is as\n' +
26
+ 'raw { vr, value } tag forms — creators, scalars, nested private\n' +
27
+ 'sequences. Private blocks routinely carry PHI/device detail: handling\n' +
28
+ "that is the caller's responsibility. UID-valued leaves are still\n" +
29
+ 'hashed unless --no-preserve-uids. Individual private tags can instead\n' +
30
+ 'be named (8-hex) in --keep-tags. Binary/unparseable payloads are\n' +
31
+ 'skipped and counted.\n' +
25
32
  '--size-tolerance folds near-identical file sizes (fraction, e.g. 0.05\n' +
26
33
  '= ±5%) into one entry; default exact.\n' +
27
34
  '--no-preserve-uids emits shape only with freshly minted UIDs (no link\n' +
@@ -52,6 +59,7 @@ let preserveUids = true
52
59
  let uidSalt
53
60
  let gzip = false
54
61
  let captureTree = false
62
+ let keepPrivateTags = false
55
63
  const keepTags = []
56
64
 
57
65
  for (let i = 0; i < args.length; i++) {
@@ -77,6 +85,8 @@ for (let i = 0; i < args.length; i++) {
77
85
  console.error('--size-tolerance must be a non-negative number')
78
86
  process.exit(1)
79
87
  }
88
+ } else if (args[i] === '--keep-private-tags') {
89
+ keepPrivateTags = true
80
90
  } else if (args[i] === '--no-preserve-uids') {
81
91
  preserveUids = false
82
92
  } else if (args[i] === '--tree') {
@@ -107,6 +117,7 @@ let result
107
117
  try {
108
118
  result = describeDirectory(dir, {
109
119
  ...(keepTags.length ? { keepTags } : {}),
120
+ ...(keepPrivateTags ? { keepPrivateTags: true } : {}),
110
121
  ...(sizeTolerance !== undefined ? { sizeTolerance } : {}),
111
122
  preserveUids,
112
123
  ...(uidSalt !== undefined ? { uidSalt } : {}),
@@ -718,13 +718,28 @@ function buildHexToKeyword() {
718
718
  }
719
719
  return map;
720
720
  }
721
+ function buildKeepPlan(options) {
722
+ const { keywords, privateHex } = options.keepTags ? resolveKeepTags(options.keepTags) : { keywords: [], privateHex: /* @__PURE__ */ new Set() };
723
+ const allPrivate = options.keepPrivateTags === true;
724
+ return {
725
+ keywords,
726
+ privateHex,
727
+ allPrivate,
728
+ capturingPrivate: allPrivate || privateHex.size > 0
729
+ };
730
+ }
721
731
  function resolveKeepTags(keepTags) {
722
732
  const nameMap = dcmjsAny.data.DicomMetaDictionary.nameMap;
723
733
  let hexToKeyword;
724
734
  const kept = [];
735
+ const privateHex = /* @__PURE__ */ new Set();
725
736
  for (const tag of keepTags) {
726
737
  let keyword;
727
738
  if (HEX_TAG_RE2.test(tag)) {
739
+ if (isPrivateHexTag(tag)) {
740
+ privateHex.add(tag.toUpperCase());
741
+ continue;
742
+ }
728
743
  hexToKeyword ?? (hexToKeyword = buildHexToKeyword());
729
744
  const mapped = hexToKeyword.get(tag.toUpperCase());
730
745
  if (!mapped) {
@@ -751,7 +766,71 @@ function resolveKeepTags(keepTags) {
751
766
  }
752
767
  kept.push(keyword);
753
768
  }
754
- return kept;
769
+ return { keywords: kept, privateHex };
770
+ }
771
+ function isUnrepresentableValue(value) {
772
+ return value === null || value === void 0 || typeof value === "number" && !Number.isFinite(value) || value instanceof ArrayBuffer || ArrayBuffer.isView(value);
773
+ }
774
+ function toRawCapture(el, skipped) {
775
+ const vr = el.vr;
776
+ if (typeof vr !== "string") {
777
+ skipped.count++;
778
+ return null;
779
+ }
780
+ if (vr === "SQ") {
781
+ const items = [];
782
+ for (const item of el.Value ?? []) {
783
+ if (typeof item !== "object" || item === null) continue;
784
+ const converted = {};
785
+ for (const [tag, nested] of Object.entries(item)) {
786
+ if (!HEX_TAG_RE2.test(tag)) continue;
787
+ const capture = toRawCapture(nested, skipped);
788
+ if (capture) converted[tag.toUpperCase()] = capture;
789
+ }
790
+ items.push(converted);
791
+ }
792
+ return { vr, value: items };
793
+ }
794
+ const values = el.Value ?? [];
795
+ if (values.some(isUnrepresentableValue)) {
796
+ skipped.count++;
797
+ return null;
798
+ }
799
+ return { vr, value: values.length === 1 ? values[0] : values };
800
+ }
801
+ function capturePrivateTags(rawDict, keep, skipped) {
802
+ const captured = {};
803
+ let found = false;
804
+ for (const [tag, el] of Object.entries(rawDict)) {
805
+ if (!HEX_TAG_RE2.test(tag) || !isPrivateHexTag(tag)) continue;
806
+ if (!keep.allPrivate && !keep.privateHex.has(tag.toUpperCase())) continue;
807
+ const capture = toRawCapture(el, skipped);
808
+ if (capture) {
809
+ captured[tag.toUpperCase()] = capture;
810
+ found = true;
811
+ }
812
+ }
813
+ return found ? captured : void 0;
814
+ }
815
+ function hashRawCaptureUids(capture, salt, map) {
816
+ if (capture.vr === "UI") {
817
+ return isUidLeaf(capture.value) ? { vr: "UI", value: hashUidValue(capture.value, salt, map) } : capture;
818
+ }
819
+ if (capture.vr === "SQ") {
820
+ const items = capture.value;
821
+ return {
822
+ vr: "SQ",
823
+ value: items.map(
824
+ (item) => Object.fromEntries(
825
+ Object.entries(item).map(([tag, nested]) => [
826
+ tag,
827
+ hashRawCaptureUids(nested, salt, map)
828
+ ])
829
+ )
830
+ )
831
+ };
832
+ }
833
+ return capture;
755
834
  }
756
835
  function* walkFiles(dir) {
757
836
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -769,13 +848,16 @@ function toArrayBuffer(buf) {
769
848
  buf.byteOffset + buf.byteLength
770
849
  );
771
850
  }
772
- function naturalize(ab) {
851
+ function parseParts(ab) {
773
852
  const parsed = dcmjsAny.data.DicomMessage.readFile(ab);
774
- return dcmjsAny.data.DicomMetaDictionary.naturalizeDataset(parsed.dict);
853
+ return {
854
+ record: dcmjsAny.data.DicomMetaDictionary.naturalizeDataset(parsed.dict),
855
+ rawDict: parsed.dict
856
+ };
775
857
  }
776
858
  function parseFull(path) {
777
859
  try {
778
- return naturalize(toArrayBuffer(readFileSync(path)));
860
+ return parseParts(toArrayBuffer(readFileSync(path)));
779
861
  } catch {
780
862
  return null;
781
863
  }
@@ -833,7 +915,7 @@ function readHeaderOnly(path) {
833
915
  EMPTY_OW_PIXEL_DATA
834
916
  ]);
835
917
  try {
836
- return naturalize(toArrayBuffer(headerOnly));
918
+ return parseParts(toArrayBuffer(headerOnly));
837
919
  } catch {
838
920
  return null;
839
921
  }
@@ -959,6 +1041,20 @@ function mergeTags(a, b) {
959
1041
  if (!b) return a;
960
1042
  return { ...a, ...b };
961
1043
  }
1044
+ function mergePrivateTags(rec, salt, uidMap) {
1045
+ if (!rec.privateTags) return;
1046
+ const hashed = Object.fromEntries(
1047
+ Object.entries(rec.privateTags).map(([tag, capture]) => [
1048
+ tag,
1049
+ salt === void 0 ? capture : hashRawCaptureUids(
1050
+ capture,
1051
+ salt,
1052
+ uidMap
1053
+ )
1054
+ ])
1055
+ );
1056
+ rec.tags = mergeTags(rec.tags, hashed);
1057
+ }
962
1058
  function scanFile(path) {
963
1059
  let size;
964
1060
  try {
@@ -968,7 +1064,7 @@ function scanFile(path) {
968
1064
  }
969
1065
  if (size > MAX_STREAM_BYTES) return null;
970
1066
  const large = size > MAX_PIXEL_BYTES;
971
- return { size, large, record: large ? readHeaderOnly(path) : parseFull(path) };
1067
+ return { size, large, parsed: large ? readHeaderOnly(path) : parseFull(path) };
972
1068
  }
973
1069
  function presetModalityOf(record) {
974
1070
  if (typeof record.Modality !== "string") return void 0;
@@ -983,6 +1079,13 @@ function hashRecordUids(rec, salt, uidMap, sweptKeepTags) {
983
1079
  }
984
1080
  rec.tags = mergeTags(rec.tags, hashUidTags(rec.uidOriginals, salt, uidMap));
985
1081
  }
1082
+ function warnPrivateBinarySkipped(count) {
1083
+ if (count > 0) {
1084
+ console.warn(
1085
+ `describe: ${count} private element(s) with binary payloads were not captured \u2014 binary values cannot ride a JSON spec`
1086
+ );
1087
+ }
1088
+ }
986
1089
  function warnSweptKeepTags(sweptKeepTags) {
987
1090
  for (const keyword of sweptKeepTags) {
988
1091
  console.warn(
@@ -1128,14 +1231,16 @@ function emitTree(acc) {
1128
1231
  }
1129
1232
  return nodes;
1130
1233
  }
1131
- function describeTree(dir, options, keepKeywords) {
1234
+ function describeTree(dir, options, keep) {
1132
1235
  const root = { dirs: /* @__PURE__ */ new Map(), files: [] };
1133
1236
  const stats = {
1134
1237
  dicomFiles: 0,
1135
1238
  skipped: 0,
1136
1239
  unknownModality: 0,
1137
- nonDicomFiles: 0
1240
+ nonDicomFiles: 0,
1241
+ privateBinarySkipped: 0
1138
1242
  };
1243
+ const privateSkipped = { count: 0 };
1139
1244
  const studyUids = /* @__PURE__ */ new Set();
1140
1245
  const dicomLeaves = [];
1141
1246
  for (const path of [...walkFiles(dir)].sort()) {
@@ -1144,7 +1249,8 @@ function describeTree(dir, options, keepKeywords) {
1144
1249
  stats.skipped++;
1145
1250
  continue;
1146
1251
  }
1147
- const { size, large, record } = scanned;
1252
+ const { size, large, parsed } = scanned;
1253
+ const record = parsed?.record;
1148
1254
  const studyUid = record?.StudyInstanceUID;
1149
1255
  const seriesUid = record?.SeriesInstanceUID;
1150
1256
  const relDirs = relative(dir, path).split(sep).slice(0, -1);
@@ -1175,9 +1281,10 @@ function describeTree(dir, options, keepKeywords) {
1175
1281
  modality,
1176
1282
  tags: mergeTags(
1177
1283
  instanceNumber,
1178
- keepKeywords.length ? extractTags(record, keepKeywords) : void 0
1284
+ keep.keywords.length ? extractTags(record, keep.keywords) : void 0
1179
1285
  ),
1180
- uidOriginals: collectUidOriginals(record)
1286
+ uidOriginals: collectUidOriginals(record),
1287
+ privateTags: keep.capturingPrivate && parsed ? capturePrivateTags(parsed.rawDict, keep, privateSkipped) : void 0
1181
1288
  },
1182
1289
  studyUid,
1183
1290
  seriesUid
@@ -1193,18 +1300,21 @@ function describeTree(dir, options, keepKeywords) {
1193
1300
  const sweptKeepTags = /* @__PURE__ */ new Set();
1194
1301
  for (const { rec, studyUid, seriesUid } of dicomLeaves) {
1195
1302
  hashRecordUids(rec, salt, uidMap, sweptKeepTags);
1303
+ mergePrivateTags(rec, salt, uidMap);
1196
1304
  rec.tags = mergeTags(rec.tags, {
1197
1305
  StudyInstanceUID: hashUidVia(uidMap, salt, studyUid),
1198
1306
  SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
1199
1307
  });
1200
1308
  }
1201
1309
  warnSweptKeepTags(sweptKeepTags);
1310
+ stats.privateBinarySkipped = privateSkipped.count;
1311
+ warnPrivateBinarySkipped(privateSkipped.count);
1202
1312
  const spec = { tree: emitTree(root), uidSalt: salt };
1203
1313
  validateDatasetSpec(spec);
1204
1314
  return { spec, stats };
1205
1315
  }
1206
1316
  function describeDirectory(dir, options = {}) {
1207
- const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
1317
+ const keep = buildKeepPlan(options);
1208
1318
  const preserveUids = options.preserveUids ?? true;
1209
1319
  if (options.captureTree) {
1210
1320
  if (!preserveUids) {
@@ -1212,10 +1322,10 @@ function describeDirectory(dir, options = {}) {
1212
1322
  "describe: captureTree requires preserveUids \u2014 the captured tree carries its grouping as hashed UID tags"
1213
1323
  );
1214
1324
  }
1215
- return describeTree(dir, options, keepKeywords);
1325
+ return describeTree(dir, options, keep);
1216
1326
  }
1217
1327
  const uidMap = /* @__PURE__ */ new Map();
1218
- const useRecords = keepKeywords.length > 0 || preserveUids;
1328
+ const useRecords = keep.keywords.length > 0 || preserveUids || keep.capturingPrivate;
1219
1329
  const tolerance = options.sizeTolerance ?? 0;
1220
1330
  if (!Number.isFinite(tolerance) || tolerance < 0) {
1221
1331
  throw new Error(
@@ -1227,15 +1337,18 @@ function describeDirectory(dir, options = {}) {
1227
1337
  dicomFiles: 0,
1228
1338
  skipped: 0,
1229
1339
  unknownModality: 0,
1230
- nonDicomFiles: 0
1340
+ nonDicomFiles: 0,
1341
+ privateBinarySkipped: 0
1231
1342
  };
1343
+ const privateSkipped = { count: 0 };
1232
1344
  for (const path of [...walkFiles(dir)].sort()) {
1233
1345
  const scanned = scanFile(path);
1234
1346
  if (!scanned) {
1235
1347
  stats.skipped++;
1236
1348
  continue;
1237
1349
  }
1238
- const { size, large, record } = scanned;
1350
+ const { size, large, parsed } = scanned;
1351
+ const record = parsed?.record;
1239
1352
  const studyUid = record?.StudyInstanceUID;
1240
1353
  const seriesUid = record?.SeriesInstanceUID;
1241
1354
  if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
@@ -1246,7 +1359,7 @@ function describeDirectory(dir, options = {}) {
1246
1359
  const preset = presetModalityOf(record);
1247
1360
  if (preset === "unsupported") stats.unknownModality++;
1248
1361
  const modality = preset === "unsupported" ? void 0 : preset;
1249
- const keptTags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
1362
+ const keptTags = keep.keywords.length ? extractTags(record, keep.keywords) : void 0;
1250
1363
  const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
1251
1364
  const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
1252
1365
  const fileRecord = {
@@ -1254,7 +1367,8 @@ function describeDirectory(dir, options = {}) {
1254
1367
  bytes,
1255
1368
  modality,
1256
1369
  tags: keptTags,
1257
- uidOriginals
1370
+ uidOriginals,
1371
+ privateTags: keep.capturingPrivate && parsed ? capturePrivateTags(parsed.rawDict, keep, privateSkipped) : void 0
1258
1372
  };
1259
1373
  let study = studies.get(studyUid);
1260
1374
  if (!study) {
@@ -1273,18 +1387,23 @@ function describeDirectory(dir, options = {}) {
1273
1387
  }
1274
1388
  }
1275
1389
  const salt = preserveUids ? options.uidSalt ?? deriveSalt([...studies.keys()]) : void 0;
1276
- if (salt !== void 0) {
1390
+ if (salt !== void 0 || keep.capturingPrivate) {
1277
1391
  const sweptKeepTags = /* @__PURE__ */ new Set();
1278
1392
  for (const study of studies.values()) {
1279
1393
  for (const series of study.values()) {
1280
1394
  if (!Array.isArray(series)) continue;
1281
1395
  for (const rec of series) {
1282
- hashRecordUids(rec, salt, uidMap, sweptKeepTags);
1396
+ if (salt !== void 0) {
1397
+ hashRecordUids(rec, salt, uidMap, sweptKeepTags);
1398
+ }
1399
+ mergePrivateTags(rec, salt, uidMap);
1283
1400
  }
1284
1401
  }
1285
1402
  }
1286
1403
  warnSweptKeepTags(sweptKeepTags);
1287
1404
  }
1405
+ stats.privateBinarySkipped = privateSkipped.count;
1406
+ warnPrivateBinarySkipped(privateSkipped.count);
1288
1407
  const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
1289
1408
  ...spec2,
1290
1409
  tags: {
package/dist/esm/index.js CHANGED
@@ -1816,13 +1816,28 @@ function buildHexToKeyword() {
1816
1816
  }
1817
1817
  return map;
1818
1818
  }
1819
+ function buildKeepPlan(options) {
1820
+ const { keywords, privateHex } = options.keepTags ? resolveKeepTags(options.keepTags) : { keywords: [], privateHex: /* @__PURE__ */ new Set() };
1821
+ const allPrivate = options.keepPrivateTags === true;
1822
+ return {
1823
+ keywords,
1824
+ privateHex,
1825
+ allPrivate,
1826
+ capturingPrivate: allPrivate || privateHex.size > 0
1827
+ };
1828
+ }
1819
1829
  function resolveKeepTags(keepTags) {
1820
1830
  const nameMap = dcmjsAny2.data.DicomMetaDictionary.nameMap;
1821
1831
  let hexToKeyword;
1822
1832
  const kept = [];
1833
+ const privateHex = /* @__PURE__ */ new Set();
1823
1834
  for (const tag of keepTags) {
1824
1835
  let keyword;
1825
1836
  if (HEX_TAG_RE2.test(tag)) {
1837
+ if (isPrivateHexTag(tag)) {
1838
+ privateHex.add(tag.toUpperCase());
1839
+ continue;
1840
+ }
1826
1841
  hexToKeyword ?? (hexToKeyword = buildHexToKeyword());
1827
1842
  const mapped = hexToKeyword.get(tag.toUpperCase());
1828
1843
  if (!mapped) {
@@ -1849,7 +1864,71 @@ function resolveKeepTags(keepTags) {
1849
1864
  }
1850
1865
  kept.push(keyword);
1851
1866
  }
1852
- return kept;
1867
+ return { keywords: kept, privateHex };
1868
+ }
1869
+ function isUnrepresentableValue(value) {
1870
+ return value === null || value === void 0 || typeof value === "number" && !Number.isFinite(value) || value instanceof ArrayBuffer || ArrayBuffer.isView(value);
1871
+ }
1872
+ function toRawCapture(el, skipped) {
1873
+ const vr = el.vr;
1874
+ if (typeof vr !== "string") {
1875
+ skipped.count++;
1876
+ return null;
1877
+ }
1878
+ if (vr === "SQ") {
1879
+ const items = [];
1880
+ for (const item of el.Value ?? []) {
1881
+ if (typeof item !== "object" || item === null) continue;
1882
+ const converted = {};
1883
+ for (const [tag, nested] of Object.entries(item)) {
1884
+ if (!HEX_TAG_RE2.test(tag)) continue;
1885
+ const capture = toRawCapture(nested, skipped);
1886
+ if (capture) converted[tag.toUpperCase()] = capture;
1887
+ }
1888
+ items.push(converted);
1889
+ }
1890
+ return { vr, value: items };
1891
+ }
1892
+ const values = el.Value ?? [];
1893
+ if (values.some(isUnrepresentableValue)) {
1894
+ skipped.count++;
1895
+ return null;
1896
+ }
1897
+ return { vr, value: values.length === 1 ? values[0] : values };
1898
+ }
1899
+ function capturePrivateTags(rawDict, keep, skipped) {
1900
+ const captured = {};
1901
+ let found = false;
1902
+ for (const [tag, el] of Object.entries(rawDict)) {
1903
+ if (!HEX_TAG_RE2.test(tag) || !isPrivateHexTag(tag)) continue;
1904
+ if (!keep.allPrivate && !keep.privateHex.has(tag.toUpperCase())) continue;
1905
+ const capture = toRawCapture(el, skipped);
1906
+ if (capture) {
1907
+ captured[tag.toUpperCase()] = capture;
1908
+ found = true;
1909
+ }
1910
+ }
1911
+ return found ? captured : void 0;
1912
+ }
1913
+ function hashRawCaptureUids(capture, salt, map) {
1914
+ if (capture.vr === "UI") {
1915
+ return isUidLeaf(capture.value) ? { vr: "UI", value: hashUidValue(capture.value, salt, map) } : capture;
1916
+ }
1917
+ if (capture.vr === "SQ") {
1918
+ const items = capture.value;
1919
+ return {
1920
+ vr: "SQ",
1921
+ value: items.map(
1922
+ (item) => Object.fromEntries(
1923
+ Object.entries(item).map(([tag, nested]) => [
1924
+ tag,
1925
+ hashRawCaptureUids(nested, salt, map)
1926
+ ])
1927
+ )
1928
+ )
1929
+ };
1930
+ }
1931
+ return capture;
1853
1932
  }
1854
1933
  function* walkFiles(dir) {
1855
1934
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -1867,13 +1946,16 @@ function toArrayBuffer(buf) {
1867
1946
  buf.byteOffset + buf.byteLength
1868
1947
  );
1869
1948
  }
1870
- function naturalize(ab) {
1949
+ function parseParts(ab) {
1871
1950
  const parsed = dcmjsAny2.data.DicomMessage.readFile(ab);
1872
- return dcmjsAny2.data.DicomMetaDictionary.naturalizeDataset(parsed.dict);
1951
+ return {
1952
+ record: dcmjsAny2.data.DicomMetaDictionary.naturalizeDataset(parsed.dict),
1953
+ rawDict: parsed.dict
1954
+ };
1873
1955
  }
1874
1956
  function parseFull(path) {
1875
1957
  try {
1876
- return naturalize(toArrayBuffer(readFileSync(path)));
1958
+ return parseParts(toArrayBuffer(readFileSync(path)));
1877
1959
  } catch {
1878
1960
  return null;
1879
1961
  }
@@ -1931,7 +2013,7 @@ function readHeaderOnly(path) {
1931
2013
  EMPTY_OW_PIXEL_DATA
1932
2014
  ]);
1933
2015
  try {
1934
- return naturalize(toArrayBuffer(headerOnly));
2016
+ return parseParts(toArrayBuffer(headerOnly));
1935
2017
  } catch {
1936
2018
  return null;
1937
2019
  }
@@ -2057,6 +2139,20 @@ function mergeTags(a, b) {
2057
2139
  if (!b) return a;
2058
2140
  return { ...a, ...b };
2059
2141
  }
2142
+ function mergePrivateTags(rec, salt, uidMap) {
2143
+ if (!rec.privateTags) return;
2144
+ const hashed = Object.fromEntries(
2145
+ Object.entries(rec.privateTags).map(([tag, capture]) => [
2146
+ tag,
2147
+ salt === void 0 ? capture : hashRawCaptureUids(
2148
+ capture,
2149
+ salt,
2150
+ uidMap
2151
+ )
2152
+ ])
2153
+ );
2154
+ rec.tags = mergeTags(rec.tags, hashed);
2155
+ }
2060
2156
  function scanFile(path) {
2061
2157
  let size;
2062
2158
  try {
@@ -2066,7 +2162,7 @@ function scanFile(path) {
2066
2162
  }
2067
2163
  if (size > MAX_STREAM_BYTES) return null;
2068
2164
  const large = size > MAX_PIXEL_BYTES;
2069
- return { size, large, record: large ? readHeaderOnly(path) : parseFull(path) };
2165
+ return { size, large, parsed: large ? readHeaderOnly(path) : parseFull(path) };
2070
2166
  }
2071
2167
  function presetModalityOf(record) {
2072
2168
  if (typeof record.Modality !== "string") return void 0;
@@ -2081,6 +2177,13 @@ function hashRecordUids(rec, salt, uidMap, sweptKeepTags) {
2081
2177
  }
2082
2178
  rec.tags = mergeTags(rec.tags, hashUidTags(rec.uidOriginals, salt, uidMap));
2083
2179
  }
2180
+ function warnPrivateBinarySkipped(count) {
2181
+ if (count > 0) {
2182
+ console.warn(
2183
+ `describe: ${count} private element(s) with binary payloads were not captured \u2014 binary values cannot ride a JSON spec`
2184
+ );
2185
+ }
2186
+ }
2084
2187
  function warnSweptKeepTags(sweptKeepTags) {
2085
2188
  for (const keyword of sweptKeepTags) {
2086
2189
  console.warn(
@@ -2226,14 +2329,16 @@ function emitTree(acc) {
2226
2329
  }
2227
2330
  return nodes;
2228
2331
  }
2229
- function describeTree(dir, options, keepKeywords) {
2332
+ function describeTree(dir, options, keep) {
2230
2333
  const root = { dirs: /* @__PURE__ */ new Map(), files: [] };
2231
2334
  const stats = {
2232
2335
  dicomFiles: 0,
2233
2336
  skipped: 0,
2234
2337
  unknownModality: 0,
2235
- nonDicomFiles: 0
2338
+ nonDicomFiles: 0,
2339
+ privateBinarySkipped: 0
2236
2340
  };
2341
+ const privateSkipped = { count: 0 };
2237
2342
  const studyUids = /* @__PURE__ */ new Set();
2238
2343
  const dicomLeaves = [];
2239
2344
  for (const path of [...walkFiles(dir)].sort()) {
@@ -2242,7 +2347,8 @@ function describeTree(dir, options, keepKeywords) {
2242
2347
  stats.skipped++;
2243
2348
  continue;
2244
2349
  }
2245
- const { size, large, record } = scanned;
2350
+ const { size, large, parsed } = scanned;
2351
+ const record = parsed?.record;
2246
2352
  const studyUid = record?.StudyInstanceUID;
2247
2353
  const seriesUid = record?.SeriesInstanceUID;
2248
2354
  const relDirs = relative(dir, path).split(sep).slice(0, -1);
@@ -2273,9 +2379,10 @@ function describeTree(dir, options, keepKeywords) {
2273
2379
  modality,
2274
2380
  tags: mergeTags(
2275
2381
  instanceNumber,
2276
- keepKeywords.length ? extractTags(record, keepKeywords) : void 0
2382
+ keep.keywords.length ? extractTags(record, keep.keywords) : void 0
2277
2383
  ),
2278
- uidOriginals: collectUidOriginals(record)
2384
+ uidOriginals: collectUidOriginals(record),
2385
+ privateTags: keep.capturingPrivate && parsed ? capturePrivateTags(parsed.rawDict, keep, privateSkipped) : void 0
2279
2386
  },
2280
2387
  studyUid,
2281
2388
  seriesUid
@@ -2291,18 +2398,21 @@ function describeTree(dir, options, keepKeywords) {
2291
2398
  const sweptKeepTags = /* @__PURE__ */ new Set();
2292
2399
  for (const { rec, studyUid, seriesUid } of dicomLeaves) {
2293
2400
  hashRecordUids(rec, salt, uidMap, sweptKeepTags);
2401
+ mergePrivateTags(rec, salt, uidMap);
2294
2402
  rec.tags = mergeTags(rec.tags, {
2295
2403
  StudyInstanceUID: hashUidVia(uidMap, salt, studyUid),
2296
2404
  SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
2297
2405
  });
2298
2406
  }
2299
2407
  warnSweptKeepTags(sweptKeepTags);
2408
+ stats.privateBinarySkipped = privateSkipped.count;
2409
+ warnPrivateBinarySkipped(privateSkipped.count);
2300
2410
  const spec = { tree: emitTree(root), uidSalt: salt };
2301
2411
  validateDatasetSpec(spec);
2302
2412
  return { spec, stats };
2303
2413
  }
2304
2414
  function describeDirectory(dir, options = {}) {
2305
- const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
2415
+ const keep = buildKeepPlan(options);
2306
2416
  const preserveUids = options.preserveUids ?? true;
2307
2417
  if (options.captureTree) {
2308
2418
  if (!preserveUids) {
@@ -2310,10 +2420,10 @@ function describeDirectory(dir, options = {}) {
2310
2420
  "describe: captureTree requires preserveUids \u2014 the captured tree carries its grouping as hashed UID tags"
2311
2421
  );
2312
2422
  }
2313
- return describeTree(dir, options, keepKeywords);
2423
+ return describeTree(dir, options, keep);
2314
2424
  }
2315
2425
  const uidMap = /* @__PURE__ */ new Map();
2316
- const useRecords = keepKeywords.length > 0 || preserveUids;
2426
+ const useRecords = keep.keywords.length > 0 || preserveUids || keep.capturingPrivate;
2317
2427
  const tolerance = options.sizeTolerance ?? 0;
2318
2428
  if (!Number.isFinite(tolerance) || tolerance < 0) {
2319
2429
  throw new Error(
@@ -2325,15 +2435,18 @@ function describeDirectory(dir, options = {}) {
2325
2435
  dicomFiles: 0,
2326
2436
  skipped: 0,
2327
2437
  unknownModality: 0,
2328
- nonDicomFiles: 0
2438
+ nonDicomFiles: 0,
2439
+ privateBinarySkipped: 0
2329
2440
  };
2441
+ const privateSkipped = { count: 0 };
2330
2442
  for (const path of [...walkFiles(dir)].sort()) {
2331
2443
  const scanned = scanFile(path);
2332
2444
  if (!scanned) {
2333
2445
  stats.skipped++;
2334
2446
  continue;
2335
2447
  }
2336
- const { size, large, record } = scanned;
2448
+ const { size, large, parsed } = scanned;
2449
+ const record = parsed?.record;
2337
2450
  const studyUid = record?.StudyInstanceUID;
2338
2451
  const seriesUid = record?.SeriesInstanceUID;
2339
2452
  if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
@@ -2344,7 +2457,7 @@ function describeDirectory(dir, options = {}) {
2344
2457
  const preset = presetModalityOf(record);
2345
2458
  if (preset === "unsupported") stats.unknownModality++;
2346
2459
  const modality = preset === "unsupported" ? void 0 : preset;
2347
- const keptTags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
2460
+ const keptTags = keep.keywords.length ? extractTags(record, keep.keywords) : void 0;
2348
2461
  const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
2349
2462
  const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
2350
2463
  const fileRecord = {
@@ -2352,7 +2465,8 @@ function describeDirectory(dir, options = {}) {
2352
2465
  bytes,
2353
2466
  modality,
2354
2467
  tags: keptTags,
2355
- uidOriginals
2468
+ uidOriginals,
2469
+ privateTags: keep.capturingPrivate && parsed ? capturePrivateTags(parsed.rawDict, keep, privateSkipped) : void 0
2356
2470
  };
2357
2471
  let study = studies.get(studyUid);
2358
2472
  if (!study) {
@@ -2371,18 +2485,23 @@ function describeDirectory(dir, options = {}) {
2371
2485
  }
2372
2486
  }
2373
2487
  const salt = preserveUids ? options.uidSalt ?? deriveSalt([...studies.keys()]) : void 0;
2374
- if (salt !== void 0) {
2488
+ if (salt !== void 0 || keep.capturingPrivate) {
2375
2489
  const sweptKeepTags = /* @__PURE__ */ new Set();
2376
2490
  for (const study of studies.values()) {
2377
2491
  for (const series of study.values()) {
2378
2492
  if (!Array.isArray(series)) continue;
2379
2493
  for (const rec of series) {
2380
- hashRecordUids(rec, salt, uidMap, sweptKeepTags);
2494
+ if (salt !== void 0) {
2495
+ hashRecordUids(rec, salt, uidMap, sweptKeepTags);
2496
+ }
2497
+ mergePrivateTags(rec, salt, uidMap);
2381
2498
  }
2382
2499
  }
2383
2500
  }
2384
2501
  warnSweptKeepTags(sweptKeepTags);
2385
2502
  }
2503
+ stats.privateBinarySkipped = privateSkipped.count;
2504
+ warnPrivateBinarySkipped(privateSkipped.count);
2386
2505
  const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
2387
2506
  ...spec2,
2388
2507
  tags: {
@@ -788,6 +788,7 @@ function validateParametricSpec(raw) {
788
788
  export {
789
789
  HEX_TAG_RE,
790
790
  isImageType,
791
+ isPrivateHexTag,
791
792
  isRawTagValue,
792
793
  positionalName,
793
794
  validateDatasetSpec,
@@ -4,6 +4,7 @@ export type DescribeOptions = {
4
4
  sizeTolerance?: number;
5
5
  preserveUids?: boolean;
6
6
  uidSalt?: string;
7
+ keepPrivateTags?: boolean;
7
8
  captureTree?: boolean;
8
9
  };
9
10
  export type DescribeResult = {
@@ -13,8 +14,18 @@ export type DescribeResult = {
13
14
  skipped: number;
14
15
  unknownModality: number;
15
16
  nonDicomFiles: number;
17
+ privateBinarySkipped: number;
16
18
  };
17
19
  };
18
- export declare function readHeaderOnly(path: string): Record<string, unknown> | null;
20
+ type RawDictElement = {
21
+ vr?: string;
22
+ Value?: unknown[];
23
+ };
24
+ export type ParsedFile = {
25
+ record: Record<string, unknown>;
26
+ rawDict: Record<string, RawDictElement>;
27
+ };
28
+ export declare function readHeaderOnly(path: string): ParsedFile | null;
19
29
  export declare function tagValuesEqual(a: unknown, b: unknown): boolean;
20
30
  export declare function describeDirectory(dir: string, options?: DescribeOptions): DescribeResult;
31
+ export {};
@@ -1,6 +1,7 @@
1
1
  import type { DatasetSpec, FileSpec, ParametricSpec } from './types.js';
2
2
  export declare function isImageType(type: FileSpec['type']): boolean;
3
3
  export declare const HEX_TAG_RE: RegExp;
4
+ export declare function isPrivateHexTag(key: string): boolean;
4
5
  export declare function isRawTagValue(value: unknown): value is {
5
6
  vr: string;
6
7
  value: unknown;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dicom-synth",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "description": "Toolkit for synthetic DICOM fixtures and public fixture fetch/cache.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",