dicom-synth 1.16.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.
@@ -92,6 +92,92 @@ var VALID_VIOLATIONS = {
92
92
  };
93
93
  var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
94
94
  var KEYWORD_RE = /^[A-Z][A-Za-z0-9]*$/;
95
+ var VALID_VRS = /* @__PURE__ */ new Set([
96
+ "AE",
97
+ "AS",
98
+ "AT",
99
+ "CS",
100
+ "DA",
101
+ "DS",
102
+ "DT",
103
+ "FL",
104
+ "FD",
105
+ "IS",
106
+ "LO",
107
+ "LT",
108
+ "OB",
109
+ "OD",
110
+ "OF",
111
+ "OL",
112
+ "OV",
113
+ "OW",
114
+ "PN",
115
+ "SH",
116
+ "SL",
117
+ "SQ",
118
+ "SS",
119
+ "ST",
120
+ "SV",
121
+ "TM",
122
+ "UC",
123
+ "UI",
124
+ "UL",
125
+ "UN",
126
+ "UR",
127
+ "US",
128
+ "UT",
129
+ "UV"
130
+ ]);
131
+ function isPrivateHexTag(key) {
132
+ return HEX_TAG_RE.test(key) && Number.parseInt(key.slice(0, 4), 16) % 2 === 1;
133
+ }
134
+ function isRawTagValue(value) {
135
+ return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.vr === "string";
136
+ }
137
+ function validateRawTagValue(raw, path) {
138
+ for (const key of Object.keys(raw)) {
139
+ if (key !== "vr" && key !== "value") {
140
+ throw new Error(
141
+ `${path}: raw tag form accepts only "vr" and "value"; got "${key}"`
142
+ );
143
+ }
144
+ }
145
+ const vr = raw.vr;
146
+ if (!VALID_VRS.has(vr)) {
147
+ throw new Error(`${path}.vr: "${vr}" is not a standard DICOM VR`);
148
+ }
149
+ if (!("value" in raw)) {
150
+ throw new Error(`${path}.value: is required in the raw tag form`);
151
+ }
152
+ if (vr === "SQ") {
153
+ if (!Array.isArray(raw.value)) {
154
+ throw new Error(`${path}.value: an SQ raw tag requires an array of items`);
155
+ }
156
+ raw.value.forEach((item, i) => {
157
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
158
+ throw new Error(
159
+ `${path}.value[${i}]: must be an object of hex-tag entries`
160
+ );
161
+ }
162
+ for (const [tag, nested] of Object.entries(item)) {
163
+ if (!HEX_TAG_RE.test(tag)) {
164
+ throw new Error(
165
+ `${path}.value[${i}]: invalid item tag "${tag}" \u2014 SQ items use 8-hex-char tags`
166
+ );
167
+ }
168
+ if (!isRawTagValue(nested)) {
169
+ throw new Error(
170
+ `${path}.value[${i}].${tag}: SQ item entries must use the raw { vr, value } form`
171
+ );
172
+ }
173
+ validateRawTagValue(
174
+ nested,
175
+ `${path}.value[${i}].${tag}`
176
+ );
177
+ }
178
+ });
179
+ }
180
+ }
95
181
  function validatePositiveInt(value, path) {
96
182
  if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
97
183
  throw new Error(
@@ -289,13 +375,33 @@ function validateEntry(entry, path) {
289
375
  return e;
290
376
  }
291
377
  var MODALITY_TAG = "00080060";
292
- function validateTagValue(key, value, path) {
378
+ function validateModalityGuard(key, candidate, path) {
293
379
  const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
294
- if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
380
+ if (isModalityKey && typeof candidate === "string" && Object.hasOwn(VALID_MODALITIES, candidate)) {
295
381
  throw new Error(
296
382
  `${path}.${key}: set a preset modality (${Object.keys(VALID_MODALITIES).join("/")}) via the "modality" field, not tags \u2014 it is coupled to SOPClassUID and type-1 attributes`
297
383
  );
298
384
  }
385
+ }
386
+ function validateTagValue(key, value, path) {
387
+ if (isRawTagValue(value)) {
388
+ if (!HEX_TAG_RE.test(key)) {
389
+ throw new Error(
390
+ `${path}.${key}: the raw { vr, value } form requires an 8-hex-char tag key \u2014 keyword tags take plain values`
391
+ );
392
+ }
393
+ validateRawTagValue(value, `${path}.${key}`);
394
+ const raw = value.value;
395
+ const scalar = Array.isArray(raw) && raw.length === 1 ? raw[0] : raw;
396
+ validateModalityGuard(key, scalar, path);
397
+ return;
398
+ }
399
+ if (isPrivateHexTag(key)) {
400
+ throw new Error(
401
+ `${path}.${key}: private tags have no dictionary VR \u2014 use the raw form, e.g. { "vr": "LO", "value": \u2026 }`
402
+ );
403
+ }
404
+ validateModalityGuard(key, value, path);
299
405
  if (typeof value === "string") {
300
406
  for (const name of findTemplateTokens(value)) {
301
407
  if (!TEMPLATE_VOCAB.includes(name)) {
@@ -612,13 +718,28 @@ function buildHexToKeyword() {
612
718
  }
613
719
  return map;
614
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
+ }
615
731
  function resolveKeepTags(keepTags) {
616
732
  const nameMap = dcmjsAny.data.DicomMetaDictionary.nameMap;
617
733
  let hexToKeyword;
618
734
  const kept = [];
735
+ const privateHex = /* @__PURE__ */ new Set();
619
736
  for (const tag of keepTags) {
620
737
  let keyword;
621
738
  if (HEX_TAG_RE2.test(tag)) {
739
+ if (isPrivateHexTag(tag)) {
740
+ privateHex.add(tag.toUpperCase());
741
+ continue;
742
+ }
622
743
  hexToKeyword ?? (hexToKeyword = buildHexToKeyword());
623
744
  const mapped = hexToKeyword.get(tag.toUpperCase());
624
745
  if (!mapped) {
@@ -645,7 +766,71 @@ function resolveKeepTags(keepTags) {
645
766
  }
646
767
  kept.push(keyword);
647
768
  }
648
- 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;
649
834
  }
650
835
  function* walkFiles(dir) {
651
836
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -663,13 +848,16 @@ function toArrayBuffer(buf) {
663
848
  buf.byteOffset + buf.byteLength
664
849
  );
665
850
  }
666
- function naturalize(ab) {
851
+ function parseParts(ab) {
667
852
  const parsed = dcmjsAny.data.DicomMessage.readFile(ab);
668
- return dcmjsAny.data.DicomMetaDictionary.naturalizeDataset(parsed.dict);
853
+ return {
854
+ record: dcmjsAny.data.DicomMetaDictionary.naturalizeDataset(parsed.dict),
855
+ rawDict: parsed.dict
856
+ };
669
857
  }
670
858
  function parseFull(path) {
671
859
  try {
672
- return naturalize(toArrayBuffer(readFileSync(path)));
860
+ return parseParts(toArrayBuffer(readFileSync(path)));
673
861
  } catch {
674
862
  return null;
675
863
  }
@@ -727,7 +915,7 @@ function readHeaderOnly(path) {
727
915
  EMPTY_OW_PIXEL_DATA
728
916
  ]);
729
917
  try {
730
- return naturalize(toArrayBuffer(headerOnly));
918
+ return parseParts(toArrayBuffer(headerOnly));
731
919
  } catch {
732
920
  return null;
733
921
  }
@@ -853,6 +1041,20 @@ function mergeTags(a, b) {
853
1041
  if (!b) return a;
854
1042
  return { ...a, ...b };
855
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
+ }
856
1058
  function scanFile(path) {
857
1059
  let size;
858
1060
  try {
@@ -862,7 +1064,7 @@ function scanFile(path) {
862
1064
  }
863
1065
  if (size > MAX_STREAM_BYTES) return null;
864
1066
  const large = size > MAX_PIXEL_BYTES;
865
- return { size, large, record: large ? readHeaderOnly(path) : parseFull(path) };
1067
+ return { size, large, parsed: large ? readHeaderOnly(path) : parseFull(path) };
866
1068
  }
867
1069
  function presetModalityOf(record) {
868
1070
  if (typeof record.Modality !== "string") return void 0;
@@ -877,6 +1079,13 @@ function hashRecordUids(rec, salt, uidMap, sweptKeepTags) {
877
1079
  }
878
1080
  rec.tags = mergeTags(rec.tags, hashUidTags(rec.uidOriginals, salt, uidMap));
879
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
+ }
880
1089
  function warnSweptKeepTags(sweptKeepTags) {
881
1090
  for (const keyword of sweptKeepTags) {
882
1091
  console.warn(
@@ -1022,14 +1231,16 @@ function emitTree(acc) {
1022
1231
  }
1023
1232
  return nodes;
1024
1233
  }
1025
- function describeTree(dir, options, keepKeywords) {
1234
+ function describeTree(dir, options, keep) {
1026
1235
  const root = { dirs: /* @__PURE__ */ new Map(), files: [] };
1027
1236
  const stats = {
1028
1237
  dicomFiles: 0,
1029
1238
  skipped: 0,
1030
1239
  unknownModality: 0,
1031
- nonDicomFiles: 0
1240
+ nonDicomFiles: 0,
1241
+ privateBinarySkipped: 0
1032
1242
  };
1243
+ const privateSkipped = { count: 0 };
1033
1244
  const studyUids = /* @__PURE__ */ new Set();
1034
1245
  const dicomLeaves = [];
1035
1246
  for (const path of [...walkFiles(dir)].sort()) {
@@ -1038,7 +1249,8 @@ function describeTree(dir, options, keepKeywords) {
1038
1249
  stats.skipped++;
1039
1250
  continue;
1040
1251
  }
1041
- const { size, large, record } = scanned;
1252
+ const { size, large, parsed } = scanned;
1253
+ const record = parsed?.record;
1042
1254
  const studyUid = record?.StudyInstanceUID;
1043
1255
  const seriesUid = record?.SeriesInstanceUID;
1044
1256
  const relDirs = relative(dir, path).split(sep).slice(0, -1);
@@ -1069,9 +1281,10 @@ function describeTree(dir, options, keepKeywords) {
1069
1281
  modality,
1070
1282
  tags: mergeTags(
1071
1283
  instanceNumber,
1072
- keepKeywords.length ? extractTags(record, keepKeywords) : void 0
1284
+ keep.keywords.length ? extractTags(record, keep.keywords) : void 0
1073
1285
  ),
1074
- uidOriginals: collectUidOriginals(record)
1286
+ uidOriginals: collectUidOriginals(record),
1287
+ privateTags: keep.capturingPrivate && parsed ? capturePrivateTags(parsed.rawDict, keep, privateSkipped) : void 0
1075
1288
  },
1076
1289
  studyUid,
1077
1290
  seriesUid
@@ -1087,18 +1300,21 @@ function describeTree(dir, options, keepKeywords) {
1087
1300
  const sweptKeepTags = /* @__PURE__ */ new Set();
1088
1301
  for (const { rec, studyUid, seriesUid } of dicomLeaves) {
1089
1302
  hashRecordUids(rec, salt, uidMap, sweptKeepTags);
1303
+ mergePrivateTags(rec, salt, uidMap);
1090
1304
  rec.tags = mergeTags(rec.tags, {
1091
1305
  StudyInstanceUID: hashUidVia(uidMap, salt, studyUid),
1092
1306
  SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
1093
1307
  });
1094
1308
  }
1095
1309
  warnSweptKeepTags(sweptKeepTags);
1310
+ stats.privateBinarySkipped = privateSkipped.count;
1311
+ warnPrivateBinarySkipped(privateSkipped.count);
1096
1312
  const spec = { tree: emitTree(root), uidSalt: salt };
1097
1313
  validateDatasetSpec(spec);
1098
1314
  return { spec, stats };
1099
1315
  }
1100
1316
  function describeDirectory(dir, options = {}) {
1101
- const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
1317
+ const keep = buildKeepPlan(options);
1102
1318
  const preserveUids = options.preserveUids ?? true;
1103
1319
  if (options.captureTree) {
1104
1320
  if (!preserveUids) {
@@ -1106,10 +1322,10 @@ function describeDirectory(dir, options = {}) {
1106
1322
  "describe: captureTree requires preserveUids \u2014 the captured tree carries its grouping as hashed UID tags"
1107
1323
  );
1108
1324
  }
1109
- return describeTree(dir, options, keepKeywords);
1325
+ return describeTree(dir, options, keep);
1110
1326
  }
1111
1327
  const uidMap = /* @__PURE__ */ new Map();
1112
- const useRecords = keepKeywords.length > 0 || preserveUids;
1328
+ const useRecords = keep.keywords.length > 0 || preserveUids || keep.capturingPrivate;
1113
1329
  const tolerance = options.sizeTolerance ?? 0;
1114
1330
  if (!Number.isFinite(tolerance) || tolerance < 0) {
1115
1331
  throw new Error(
@@ -1121,15 +1337,18 @@ function describeDirectory(dir, options = {}) {
1121
1337
  dicomFiles: 0,
1122
1338
  skipped: 0,
1123
1339
  unknownModality: 0,
1124
- nonDicomFiles: 0
1340
+ nonDicomFiles: 0,
1341
+ privateBinarySkipped: 0
1125
1342
  };
1343
+ const privateSkipped = { count: 0 };
1126
1344
  for (const path of [...walkFiles(dir)].sort()) {
1127
1345
  const scanned = scanFile(path);
1128
1346
  if (!scanned) {
1129
1347
  stats.skipped++;
1130
1348
  continue;
1131
1349
  }
1132
- const { size, large, record } = scanned;
1350
+ const { size, large, parsed } = scanned;
1351
+ const record = parsed?.record;
1133
1352
  const studyUid = record?.StudyInstanceUID;
1134
1353
  const seriesUid = record?.SeriesInstanceUID;
1135
1354
  if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
@@ -1140,7 +1359,7 @@ function describeDirectory(dir, options = {}) {
1140
1359
  const preset = presetModalityOf(record);
1141
1360
  if (preset === "unsupported") stats.unknownModality++;
1142
1361
  const modality = preset === "unsupported" ? void 0 : preset;
1143
- const keptTags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
1362
+ const keptTags = keep.keywords.length ? extractTags(record, keep.keywords) : void 0;
1144
1363
  const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
1145
1364
  const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
1146
1365
  const fileRecord = {
@@ -1148,7 +1367,8 @@ function describeDirectory(dir, options = {}) {
1148
1367
  bytes,
1149
1368
  modality,
1150
1369
  tags: keptTags,
1151
- uidOriginals
1370
+ uidOriginals,
1371
+ privateTags: keep.capturingPrivate && parsed ? capturePrivateTags(parsed.rawDict, keep, privateSkipped) : void 0
1152
1372
  };
1153
1373
  let study = studies.get(studyUid);
1154
1374
  if (!study) {
@@ -1167,18 +1387,23 @@ function describeDirectory(dir, options = {}) {
1167
1387
  }
1168
1388
  }
1169
1389
  const salt = preserveUids ? options.uidSalt ?? deriveSalt([...studies.keys()]) : void 0;
1170
- if (salt !== void 0) {
1390
+ if (salt !== void 0 || keep.capturingPrivate) {
1171
1391
  const sweptKeepTags = /* @__PURE__ */ new Set();
1172
1392
  for (const study of studies.values()) {
1173
1393
  for (const series of study.values()) {
1174
1394
  if (!Array.isArray(series)) continue;
1175
1395
  for (const rec of series) {
1176
- hashRecordUids(rec, salt, uidMap, sweptKeepTags);
1396
+ if (salt !== void 0) {
1397
+ hashRecordUids(rec, salt, uidMap, sweptKeepTags);
1398
+ }
1399
+ mergePrivateTags(rec, salt, uidMap);
1177
1400
  }
1178
1401
  }
1179
1402
  }
1180
1403
  warnSweptKeepTags(sweptKeepTags);
1181
1404
  }
1405
+ stats.privateBinarySkipped = privateSkipped.count;
1406
+ warnPrivateBinarySkipped(privateSkipped.count);
1182
1407
  const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
1183
1408
  ...spec2,
1184
1409
  tags: {