dicom-synth 1.17.0 → 1.19.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.
@@ -1,5 +1,5 @@
1
1
  // src/describe/describe.ts
2
- import { createHash as createHash2 } from "node:crypto";
2
+ import { createHash } from "node:crypto";
3
3
  import {
4
4
  closeSync,
5
5
  openSync,
@@ -687,11 +687,148 @@ function validateDatasetSpec(raw) {
687
687
  };
688
688
  }
689
689
 
690
+ // src/platform/bytes.ts
691
+ function bytesToHex(bytes) {
692
+ let hex = "";
693
+ for (const byte of bytes) {
694
+ hex += byte.toString(16).padStart(2, "0");
695
+ }
696
+ return hex;
697
+ }
698
+
699
+ // src/platform/sha256.ts
700
+ var K = new Uint32Array([
701
+ 1116352408,
702
+ 1899447441,
703
+ 3049323471,
704
+ 3921009573,
705
+ 961987163,
706
+ 1508970993,
707
+ 2453635748,
708
+ 2870763221,
709
+ 3624381080,
710
+ 310598401,
711
+ 607225278,
712
+ 1426881987,
713
+ 1925078388,
714
+ 2162078206,
715
+ 2614888103,
716
+ 3248222580,
717
+ 3835390401,
718
+ 4022224774,
719
+ 264347078,
720
+ 604807628,
721
+ 770255983,
722
+ 1249150122,
723
+ 1555081692,
724
+ 1996064986,
725
+ 2554220882,
726
+ 2821834349,
727
+ 2952996808,
728
+ 3210313671,
729
+ 3336571891,
730
+ 3584528711,
731
+ 113926993,
732
+ 338241895,
733
+ 666307205,
734
+ 773529912,
735
+ 1294757372,
736
+ 1396182291,
737
+ 1695183700,
738
+ 1986661051,
739
+ 2177026350,
740
+ 2456956037,
741
+ 2730485921,
742
+ 2820302411,
743
+ 3259730800,
744
+ 3345764771,
745
+ 3516065817,
746
+ 3600352804,
747
+ 4094571909,
748
+ 275423344,
749
+ 430227734,
750
+ 506948616,
751
+ 659060556,
752
+ 883997877,
753
+ 958139571,
754
+ 1322822218,
755
+ 1537002063,
756
+ 1747873779,
757
+ 1955562222,
758
+ 2024104815,
759
+ 2227730452,
760
+ 2361852424,
761
+ 2428436474,
762
+ 2756734187,
763
+ 3204031479,
764
+ 3329325298
765
+ ]);
766
+ var rotr = (x, n) => x >>> n | x << 32 - n;
767
+ function sha256(input) {
768
+ const data2 = typeof input === "string" ? new TextEncoder().encode(input) : input;
769
+ const padded = new Uint8Array((data2.length + 8 >> 6) + 1 << 6);
770
+ padded.set(data2);
771
+ padded[data2.length] = 128;
772
+ new DataView(padded.buffer).setBigUint64(
773
+ padded.length - 8,
774
+ BigInt(data2.length * 8),
775
+ false
776
+ );
777
+ const h = new Uint32Array([
778
+ 1779033703,
779
+ 3144134277,
780
+ 1013904242,
781
+ 2773480762,
782
+ 1359893119,
783
+ 2600822924,
784
+ 528734635,
785
+ 1541459225
786
+ ]);
787
+ const w = new Uint32Array(64);
788
+ const view = new DataView(padded.buffer);
789
+ for (let offset = 0; offset < padded.length; offset += 64) {
790
+ for (let t = 0; t < 16; t++) w[t] = view.getUint32(offset + t * 4, false);
791
+ for (let t = 16; t < 64; t++) {
792
+ const s0 = rotr(w[t - 15], 7) ^ rotr(w[t - 15], 18) ^ w[t - 15] >>> 3;
793
+ const s1 = rotr(w[t - 2], 17) ^ rotr(w[t - 2], 19) ^ w[t - 2] >>> 10;
794
+ w[t] = w[t - 16] + s0 + w[t - 7] + s1 >>> 0;
795
+ }
796
+ let [a, b, c, d, e, f, g, hh] = h;
797
+ for (let t = 0; t < 64; t++) {
798
+ const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
799
+ const ch = e & f ^ ~e & g;
800
+ const temp1 = hh + S1 + ch + K[t] + w[t] >>> 0;
801
+ const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
802
+ const maj = a & b ^ a & c ^ b & c;
803
+ const temp2 = S0 + maj >>> 0;
804
+ hh = g;
805
+ g = f;
806
+ f = e;
807
+ e = d + temp1 >>> 0;
808
+ d = c;
809
+ c = b;
810
+ b = a;
811
+ a = temp1 + temp2 >>> 0;
812
+ }
813
+ h[0] = h[0] + a >>> 0;
814
+ h[1] = h[1] + b >>> 0;
815
+ h[2] = h[2] + c >>> 0;
816
+ h[3] = h[3] + d >>> 0;
817
+ h[4] = h[4] + e >>> 0;
818
+ h[5] = h[5] + f >>> 0;
819
+ h[6] = h[6] + g >>> 0;
820
+ h[7] = h[7] + hh >>> 0;
821
+ }
822
+ const out = new Uint8Array(32);
823
+ const outView = new DataView(out.buffer);
824
+ for (let i = 0; i < 8; i++) outView.setUint32(i * 4, h[i], false);
825
+ return out;
826
+ }
827
+
690
828
  // src/syntheticFixtures/uid.ts
691
- import { createHash, randomBytes } from "node:crypto";
692
829
  function hashUid(salt, key) {
693
- const digest = createHash("sha256").update(salt).update(" ").update(key).digest();
694
- const n = BigInt(`0x${digest.subarray(0, 16).toString("hex")}`);
830
+ const digest = sha256(`${salt} ${key}`);
831
+ const n = BigInt(`0x${bytesToHex(digest.subarray(0, 16))}`);
695
832
  return `2.25.${n.toString()}`;
696
833
  }
697
834
 
@@ -718,13 +855,28 @@ function buildHexToKeyword() {
718
855
  }
719
856
  return map;
720
857
  }
858
+ function buildKeepPlan(options) {
859
+ const { keywords, privateHex } = options.keepTags ? resolveKeepTags(options.keepTags) : { keywords: [], privateHex: /* @__PURE__ */ new Set() };
860
+ const allPrivate = options.keepPrivateTags === true;
861
+ return {
862
+ keywords,
863
+ privateHex,
864
+ allPrivate,
865
+ capturingPrivate: allPrivate || privateHex.size > 0
866
+ };
867
+ }
721
868
  function resolveKeepTags(keepTags) {
722
869
  const nameMap = dcmjsAny.data.DicomMetaDictionary.nameMap;
723
870
  let hexToKeyword;
724
871
  const kept = [];
872
+ const privateHex = /* @__PURE__ */ new Set();
725
873
  for (const tag of keepTags) {
726
874
  let keyword;
727
875
  if (HEX_TAG_RE2.test(tag)) {
876
+ if (isPrivateHexTag(tag)) {
877
+ privateHex.add(tag.toUpperCase());
878
+ continue;
879
+ }
728
880
  hexToKeyword ?? (hexToKeyword = buildHexToKeyword());
729
881
  const mapped = hexToKeyword.get(tag.toUpperCase());
730
882
  if (!mapped) {
@@ -751,7 +903,71 @@ function resolveKeepTags(keepTags) {
751
903
  }
752
904
  kept.push(keyword);
753
905
  }
754
- return kept;
906
+ return { keywords: kept, privateHex };
907
+ }
908
+ function isUnrepresentableValue(value) {
909
+ return value === null || value === void 0 || typeof value === "number" && !Number.isFinite(value) || value instanceof ArrayBuffer || ArrayBuffer.isView(value);
910
+ }
911
+ function toRawCapture(el, skipped) {
912
+ const vr = el.vr;
913
+ if (typeof vr !== "string") {
914
+ skipped.count++;
915
+ return null;
916
+ }
917
+ if (vr === "SQ") {
918
+ const items = [];
919
+ for (const item of el.Value ?? []) {
920
+ if (typeof item !== "object" || item === null) continue;
921
+ const converted = {};
922
+ for (const [tag, nested] of Object.entries(item)) {
923
+ if (!HEX_TAG_RE2.test(tag)) continue;
924
+ const capture = toRawCapture(nested, skipped);
925
+ if (capture) converted[tag.toUpperCase()] = capture;
926
+ }
927
+ items.push(converted);
928
+ }
929
+ return { vr, value: items };
930
+ }
931
+ const values = el.Value ?? [];
932
+ if (values.some(isUnrepresentableValue)) {
933
+ skipped.count++;
934
+ return null;
935
+ }
936
+ return { vr, value: values.length === 1 ? values[0] : values };
937
+ }
938
+ function capturePrivateTags(rawDict, keep, skipped) {
939
+ const captured = {};
940
+ let found = false;
941
+ for (const [tag, el] of Object.entries(rawDict)) {
942
+ if (!HEX_TAG_RE2.test(tag) || !isPrivateHexTag(tag)) continue;
943
+ if (!keep.allPrivate && !keep.privateHex.has(tag.toUpperCase())) continue;
944
+ const capture = toRawCapture(el, skipped);
945
+ if (capture) {
946
+ captured[tag.toUpperCase()] = capture;
947
+ found = true;
948
+ }
949
+ }
950
+ return found ? captured : void 0;
951
+ }
952
+ function hashRawCaptureUids(capture, salt, map) {
953
+ if (capture.vr === "UI") {
954
+ return isUidLeaf(capture.value) ? { vr: "UI", value: hashUidValue(capture.value, salt, map) } : capture;
955
+ }
956
+ if (capture.vr === "SQ") {
957
+ const items = capture.value;
958
+ return {
959
+ vr: "SQ",
960
+ value: items.map(
961
+ (item) => Object.fromEntries(
962
+ Object.entries(item).map(([tag, nested]) => [
963
+ tag,
964
+ hashRawCaptureUids(nested, salt, map)
965
+ ])
966
+ )
967
+ )
968
+ };
969
+ }
970
+ return capture;
755
971
  }
756
972
  function* walkFiles(dir) {
757
973
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -769,13 +985,16 @@ function toArrayBuffer(buf) {
769
985
  buf.byteOffset + buf.byteLength
770
986
  );
771
987
  }
772
- function naturalize(ab) {
988
+ function parseParts(ab) {
773
989
  const parsed = dcmjsAny.data.DicomMessage.readFile(ab);
774
- return dcmjsAny.data.DicomMetaDictionary.naturalizeDataset(parsed.dict);
990
+ return {
991
+ record: dcmjsAny.data.DicomMetaDictionary.naturalizeDataset(parsed.dict),
992
+ rawDict: parsed.dict
993
+ };
775
994
  }
776
995
  function parseFull(path) {
777
996
  try {
778
- return naturalize(toArrayBuffer(readFileSync(path)));
997
+ return parseParts(toArrayBuffer(readFileSync(path)));
779
998
  } catch {
780
999
  return null;
781
1000
  }
@@ -833,7 +1052,7 @@ function readHeaderOnly(path) {
833
1052
  EMPTY_OW_PIXEL_DATA
834
1053
  ]);
835
1054
  try {
836
- return naturalize(toArrayBuffer(headerOnly));
1055
+ return parseParts(toArrayBuffer(headerOnly));
837
1056
  } catch {
838
1057
  return null;
839
1058
  }
@@ -950,7 +1169,7 @@ function hashUidTags(uids, salt, map) {
950
1169
  return tags;
951
1170
  }
952
1171
  function deriveSalt(studyUids) {
953
- const h = createHash2("sha256");
1172
+ const h = createHash("sha256");
954
1173
  for (const uid of [...studyUids].sort()) h.update(uid).update("\n");
955
1174
  return h.digest("hex");
956
1175
  }
@@ -959,6 +1178,20 @@ function mergeTags(a, b) {
959
1178
  if (!b) return a;
960
1179
  return { ...a, ...b };
961
1180
  }
1181
+ function mergePrivateTags(rec, salt, uidMap) {
1182
+ if (!rec.privateTags) return;
1183
+ const hashed = Object.fromEntries(
1184
+ Object.entries(rec.privateTags).map(([tag, capture]) => [
1185
+ tag,
1186
+ salt === void 0 ? capture : hashRawCaptureUids(
1187
+ capture,
1188
+ salt,
1189
+ uidMap
1190
+ )
1191
+ ])
1192
+ );
1193
+ rec.tags = mergeTags(rec.tags, hashed);
1194
+ }
962
1195
  function scanFile(path) {
963
1196
  let size;
964
1197
  try {
@@ -968,7 +1201,7 @@ function scanFile(path) {
968
1201
  }
969
1202
  if (size > MAX_STREAM_BYTES) return null;
970
1203
  const large = size > MAX_PIXEL_BYTES;
971
- return { size, large, record: large ? readHeaderOnly(path) : parseFull(path) };
1204
+ return { size, large, parsed: large ? readHeaderOnly(path) : parseFull(path) };
972
1205
  }
973
1206
  function presetModalityOf(record) {
974
1207
  if (typeof record.Modality !== "string") return void 0;
@@ -983,6 +1216,13 @@ function hashRecordUids(rec, salt, uidMap, sweptKeepTags) {
983
1216
  }
984
1217
  rec.tags = mergeTags(rec.tags, hashUidTags(rec.uidOriginals, salt, uidMap));
985
1218
  }
1219
+ function warnPrivateBinarySkipped(count) {
1220
+ if (count > 0) {
1221
+ console.warn(
1222
+ `describe: ${count} private element(s) with binary payloads were not captured \u2014 binary values cannot ride a JSON spec`
1223
+ );
1224
+ }
1225
+ }
986
1226
  function warnSweptKeepTags(sweptKeepTags) {
987
1227
  for (const keyword of sweptKeepTags) {
988
1228
  console.warn(
@@ -1128,14 +1368,16 @@ function emitTree(acc) {
1128
1368
  }
1129
1369
  return nodes;
1130
1370
  }
1131
- function describeTree(dir, options, keepKeywords) {
1371
+ function describeTree(dir, options, keep) {
1132
1372
  const root = { dirs: /* @__PURE__ */ new Map(), files: [] };
1133
1373
  const stats = {
1134
1374
  dicomFiles: 0,
1135
1375
  skipped: 0,
1136
1376
  unknownModality: 0,
1137
- nonDicomFiles: 0
1377
+ nonDicomFiles: 0,
1378
+ privateBinarySkipped: 0
1138
1379
  };
1380
+ const privateSkipped = { count: 0 };
1139
1381
  const studyUids = /* @__PURE__ */ new Set();
1140
1382
  const dicomLeaves = [];
1141
1383
  for (const path of [...walkFiles(dir)].sort()) {
@@ -1144,7 +1386,8 @@ function describeTree(dir, options, keepKeywords) {
1144
1386
  stats.skipped++;
1145
1387
  continue;
1146
1388
  }
1147
- const { size, large, record } = scanned;
1389
+ const { size, large, parsed } = scanned;
1390
+ const record = parsed?.record;
1148
1391
  const studyUid = record?.StudyInstanceUID;
1149
1392
  const seriesUid = record?.SeriesInstanceUID;
1150
1393
  const relDirs = relative(dir, path).split(sep).slice(0, -1);
@@ -1175,9 +1418,10 @@ function describeTree(dir, options, keepKeywords) {
1175
1418
  modality,
1176
1419
  tags: mergeTags(
1177
1420
  instanceNumber,
1178
- keepKeywords.length ? extractTags(record, keepKeywords) : void 0
1421
+ keep.keywords.length ? extractTags(record, keep.keywords) : void 0
1179
1422
  ),
1180
- uidOriginals: collectUidOriginals(record)
1423
+ uidOriginals: collectUidOriginals(record),
1424
+ privateTags: keep.capturingPrivate && parsed ? capturePrivateTags(parsed.rawDict, keep, privateSkipped) : void 0
1181
1425
  },
1182
1426
  studyUid,
1183
1427
  seriesUid
@@ -1193,18 +1437,21 @@ function describeTree(dir, options, keepKeywords) {
1193
1437
  const sweptKeepTags = /* @__PURE__ */ new Set();
1194
1438
  for (const { rec, studyUid, seriesUid } of dicomLeaves) {
1195
1439
  hashRecordUids(rec, salt, uidMap, sweptKeepTags);
1440
+ mergePrivateTags(rec, salt, uidMap);
1196
1441
  rec.tags = mergeTags(rec.tags, {
1197
1442
  StudyInstanceUID: hashUidVia(uidMap, salt, studyUid),
1198
1443
  SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
1199
1444
  });
1200
1445
  }
1201
1446
  warnSweptKeepTags(sweptKeepTags);
1447
+ stats.privateBinarySkipped = privateSkipped.count;
1448
+ warnPrivateBinarySkipped(privateSkipped.count);
1202
1449
  const spec = { tree: emitTree(root), uidSalt: salt };
1203
1450
  validateDatasetSpec(spec);
1204
1451
  return { spec, stats };
1205
1452
  }
1206
1453
  function describeDirectory(dir, options = {}) {
1207
- const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
1454
+ const keep = buildKeepPlan(options);
1208
1455
  const preserveUids = options.preserveUids ?? true;
1209
1456
  if (options.captureTree) {
1210
1457
  if (!preserveUids) {
@@ -1212,10 +1459,10 @@ function describeDirectory(dir, options = {}) {
1212
1459
  "describe: captureTree requires preserveUids \u2014 the captured tree carries its grouping as hashed UID tags"
1213
1460
  );
1214
1461
  }
1215
- return describeTree(dir, options, keepKeywords);
1462
+ return describeTree(dir, options, keep);
1216
1463
  }
1217
1464
  const uidMap = /* @__PURE__ */ new Map();
1218
- const useRecords = keepKeywords.length > 0 || preserveUids;
1465
+ const useRecords = keep.keywords.length > 0 || preserveUids || keep.capturingPrivate;
1219
1466
  const tolerance = options.sizeTolerance ?? 0;
1220
1467
  if (!Number.isFinite(tolerance) || tolerance < 0) {
1221
1468
  throw new Error(
@@ -1227,15 +1474,18 @@ function describeDirectory(dir, options = {}) {
1227
1474
  dicomFiles: 0,
1228
1475
  skipped: 0,
1229
1476
  unknownModality: 0,
1230
- nonDicomFiles: 0
1477
+ nonDicomFiles: 0,
1478
+ privateBinarySkipped: 0
1231
1479
  };
1480
+ const privateSkipped = { count: 0 };
1232
1481
  for (const path of [...walkFiles(dir)].sort()) {
1233
1482
  const scanned = scanFile(path);
1234
1483
  if (!scanned) {
1235
1484
  stats.skipped++;
1236
1485
  continue;
1237
1486
  }
1238
- const { size, large, record } = scanned;
1487
+ const { size, large, parsed } = scanned;
1488
+ const record = parsed?.record;
1239
1489
  const studyUid = record?.StudyInstanceUID;
1240
1490
  const seriesUid = record?.SeriesInstanceUID;
1241
1491
  if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
@@ -1246,7 +1496,7 @@ function describeDirectory(dir, options = {}) {
1246
1496
  const preset = presetModalityOf(record);
1247
1497
  if (preset === "unsupported") stats.unknownModality++;
1248
1498
  const modality = preset === "unsupported" ? void 0 : preset;
1249
- const keptTags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
1499
+ const keptTags = keep.keywords.length ? extractTags(record, keep.keywords) : void 0;
1250
1500
  const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
1251
1501
  const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
1252
1502
  const fileRecord = {
@@ -1254,7 +1504,8 @@ function describeDirectory(dir, options = {}) {
1254
1504
  bytes,
1255
1505
  modality,
1256
1506
  tags: keptTags,
1257
- uidOriginals
1507
+ uidOriginals,
1508
+ privateTags: keep.capturingPrivate && parsed ? capturePrivateTags(parsed.rawDict, keep, privateSkipped) : void 0
1258
1509
  };
1259
1510
  let study = studies.get(studyUid);
1260
1511
  if (!study) {
@@ -1273,18 +1524,23 @@ function describeDirectory(dir, options = {}) {
1273
1524
  }
1274
1525
  }
1275
1526
  const salt = preserveUids ? options.uidSalt ?? deriveSalt([...studies.keys()]) : void 0;
1276
- if (salt !== void 0) {
1527
+ if (salt !== void 0 || keep.capturingPrivate) {
1277
1528
  const sweptKeepTags = /* @__PURE__ */ new Set();
1278
1529
  for (const study of studies.values()) {
1279
1530
  for (const series of study.values()) {
1280
1531
  if (!Array.isArray(series)) continue;
1281
1532
  for (const rec of series) {
1282
- hashRecordUids(rec, salt, uidMap, sweptKeepTags);
1533
+ if (salt !== void 0) {
1534
+ hashRecordUids(rec, salt, uidMap, sweptKeepTags);
1535
+ }
1536
+ mergePrivateTags(rec, salt, uidMap);
1283
1537
  }
1284
1538
  }
1285
1539
  }
1286
1540
  warnSweptKeepTags(sweptKeepTags);
1287
1541
  }
1542
+ stats.privateBinarySkipped = privateSkipped.count;
1543
+ warnPrivateBinarySkipped(privateSkipped.count);
1288
1544
  const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
1289
1545
  ...spec2,
1290
1546
  tags: {