dicom-synth 1.14.0 → 1.16.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.
@@ -8,7 +8,7 @@ import {
8
8
  readSync,
9
9
  statSync
10
10
  } from "node:fs";
11
- import { join } from "node:path";
11
+ import { basename, extname, join, relative, sep } from "node:path";
12
12
 
13
13
  // src/loadDcmjs.ts
14
14
  import dcmjsDefaultImport, * as dcmjsNamespace from "dcmjs";
@@ -113,11 +113,14 @@ function validateUidSalt(value, path) {
113
113
  );
114
114
  }
115
115
  }
116
- function validateSizeKbLimit(kb, path) {
117
- if (kb * 1024 > MAX_PIXEL_BYTES) {
116
+ function validateInMemoryByteLimit(bytes, path) {
117
+ if (bytes > MAX_PIXEL_BYTES) {
118
118
  throw new Error(`${path}: exceeds the 512 MB limit`);
119
119
  }
120
120
  }
121
+ function validateSizeKbLimit(kb, path) {
122
+ validateInMemoryByteLimit(kb * 1024, path);
123
+ }
121
124
  function validateLargeTargetBytes(value, path) {
122
125
  if (typeof value !== "number" || !Number.isInteger(value)) {
123
126
  throw new Error(`${path}: must be an integer; got ${JSON.stringify(value)}`);
@@ -155,6 +158,29 @@ function validateLargeImageEntry(e, path) {
155
158
  }
156
159
  if ("tags" in e) validateTags(e.tags, `${path}.tags`);
157
160
  }
161
+ function validateNonDicomEntry(e, path) {
162
+ const sizing = ["content", "targetSizeKb", "targetBytes"].filter(
163
+ (f) => f in e
164
+ );
165
+ if (sizing.length > 1) {
166
+ throw new Error(
167
+ `${path}: "content", "targetSizeKb" and "targetBytes" are mutually exclusive`
168
+ );
169
+ }
170
+ if ("content" in e && typeof e.content !== "string") {
171
+ throw new Error(
172
+ `${path}.content: must be a string; got ${JSON.stringify(e.content)}`
173
+ );
174
+ }
175
+ if ("targetSizeKb" in e) {
176
+ validatePositiveInt(e.targetSizeKb, `${path}.targetSizeKb`);
177
+ validateSizeKbLimit(e.targetSizeKb, `${path}.targetSizeKb`);
178
+ }
179
+ if ("targetBytes" in e) {
180
+ validatePositiveInt(e.targetBytes, `${path}.targetBytes`);
181
+ validateInMemoryByteLimit(e.targetBytes, `${path}.targetBytes`);
182
+ }
183
+ }
158
184
  function validateEnum(value, valid, path) {
159
185
  if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
160
186
  throw new Error(
@@ -182,6 +208,22 @@ function validateEntry(entry, path) {
182
208
  validateLargeImageEntry(e, path);
183
209
  return e;
184
210
  }
211
+ if (type === "non-dicom") {
212
+ for (const field of [
213
+ "modality",
214
+ "rows",
215
+ "columns",
216
+ "frames",
217
+ "tags",
218
+ "violations",
219
+ "transferSyntax"
220
+ ]) {
221
+ if (field in e)
222
+ throw new Error(`${path}.${field}: not supported for type "${type}"`);
223
+ }
224
+ validateNonDicomEntry(e, path);
225
+ return e;
226
+ }
185
227
  if ("targetBytes" in e) {
186
228
  throw new Error(
187
229
  `${path}.targetBytes: only supported for type "large-image"`
@@ -389,6 +431,101 @@ function validateStudy(study, path) {
389
431
  if ("tags" in st) validateTags(st.tags, `${path}.tags`);
390
432
  return st;
391
433
  }
434
+ function positionalName(prefix, ordinal) {
435
+ return `${prefix}-${String(ordinal).padStart(3, "0")}`;
436
+ }
437
+ var VALID_TREE_ROLES = {
438
+ study: true,
439
+ series: true
440
+ };
441
+ function validateNodeName(value, path) {
442
+ if (typeof value !== "string" || value.length === 0 || value === "." || value === ".." || /[/\\]/.test(value)) {
443
+ throw new Error(
444
+ `${path}: must be a non-empty name without path separators; got ${JSON.stringify(value)}`
445
+ );
446
+ }
447
+ }
448
+ function validateTreeNode(node, path, inStudy, inSeries) {
449
+ if (typeof node !== "object" || node === null || Array.isArray(node)) {
450
+ throw new Error(`${path}: must be an object`);
451
+ }
452
+ const n = node;
453
+ if ("children" in n) {
454
+ if ("type" in n) {
455
+ throw new Error(
456
+ `${path}: a node cannot have both "children" (directory) and "type" (file)`
457
+ );
458
+ }
459
+ if ("name" in n) validateNodeName(n.name, `${path}.name`);
460
+ if ("tags" in n) validateTags(n.tags, `${path}.tags`);
461
+ let childStudy = inStudy;
462
+ let childSeries = inSeries;
463
+ if ("role" in n) {
464
+ validateEnum(n.role, VALID_TREE_ROLES, `${path}.role`);
465
+ if (n.role === "study") {
466
+ if (inStudy) {
467
+ throw new Error(
468
+ `${path}.role: a study directory cannot nest inside another study`
469
+ );
470
+ }
471
+ childStudy = true;
472
+ } else {
473
+ if (!inStudy) {
474
+ throw new Error(
475
+ `${path}.role: a series directory requires an enclosing study-role directory`
476
+ );
477
+ }
478
+ if (inSeries) {
479
+ throw new Error(
480
+ `${path}.role: a series directory cannot nest inside another series`
481
+ );
482
+ }
483
+ childSeries = true;
484
+ }
485
+ }
486
+ if (!Array.isArray(n.children) || n.children.length === 0) {
487
+ throw new Error(`${path}.children: must be a non-empty array`);
488
+ }
489
+ validateTreeChildren(
490
+ n.children,
491
+ `${path}.children`,
492
+ childStudy,
493
+ childSeries
494
+ );
495
+ return;
496
+ }
497
+ const e = validateEntry(node, path);
498
+ if ("name" in n) {
499
+ validateNodeName(n.name, `${path}.name`);
500
+ if ((e.count ?? 1) > 1) {
501
+ throw new Error(
502
+ `${path}: "name" cannot be combined with count > 1 \u2014 the copies would collide on one path`
503
+ );
504
+ }
505
+ }
506
+ }
507
+ function validateTreeChildren(children, basePath, inStudy, inSeries) {
508
+ const claimed = /* @__PURE__ */ new Map();
509
+ let dirOrdinal = 0;
510
+ children.forEach((child, i) => {
511
+ const path = `${basePath}[${i}]`;
512
+ validateTreeNode(child, path, inStudy, inSeries);
513
+ const n = child;
514
+ let name = "name" in n ? n.name : void 0;
515
+ if ("children" in n) {
516
+ dirOrdinal++;
517
+ name ?? (name = positionalName("dir", dirOrdinal));
518
+ }
519
+ if (name === void 0) return;
520
+ const prior = claimed.get(name);
521
+ if (prior !== void 0) {
522
+ throw new Error(
523
+ `${path}: name "${name}" collides with ${prior} \u2014 siblings must resolve to distinct paths`
524
+ );
525
+ }
526
+ claimed.set(name, path);
527
+ });
528
+ }
392
529
  function validateDatasetSpec(raw) {
393
530
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
394
531
  throw new Error("DatasetSpec: must be a JSON object");
@@ -400,11 +537,15 @@ function validateDatasetSpec(raw) {
400
537
  if ("studies" in r && !Array.isArray(r.studies)) {
401
538
  throw new Error("DatasetSpec.studies: must be an array");
402
539
  }
540
+ if ("tree" in r && !Array.isArray(r.tree)) {
541
+ throw new Error("DatasetSpec.tree: must be an array");
542
+ }
403
543
  const rawEntries = r.entries ?? [];
404
544
  const rawStudies = r.studies ?? [];
405
- if (rawEntries.length === 0 && rawStudies.length === 0) {
545
+ const rawTree = r.tree ?? [];
546
+ if (rawEntries.length === 0 && rawStudies.length === 0 && rawTree.length === 0) {
406
547
  throw new Error(
407
- 'DatasetSpec: must contain at least one of "entries" or "studies" (non-empty)'
548
+ 'DatasetSpec: must contain at least one of "entries", "studies" or "tree" (non-empty)'
408
549
  );
409
550
  }
410
551
  const entries = rawEntries.map(
@@ -413,6 +554,9 @@ function validateDatasetSpec(raw) {
413
554
  const studies = rawStudies.map(
414
555
  (study, i) => validateStudy(study, `studies[${i}]`)
415
556
  );
557
+ if (rawTree.length > 0) {
558
+ validateTreeChildren(rawTree, "tree", false, false);
559
+ }
416
560
  if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
417
561
  if ("uidSalt" in r) validateUidSalt(r.uidSalt, "DatasetSpec.uidSalt");
418
562
  if ("layout" in r) {
@@ -429,6 +573,7 @@ function validateDatasetSpec(raw) {
429
573
  return {
430
574
  ...entries.length > 0 ? { entries } : {},
431
575
  ...studies.length > 0 ? { studies } : {},
576
+ ...rawTree.length > 0 ? { tree: r.tree } : {},
432
577
  ...r.seed !== void 0 ? { seed: r.seed } : {},
433
578
  ...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
434
579
  ...r.layout !== void 0 ? { layout: r.layout } : {},
@@ -708,6 +853,37 @@ function mergeTags(a, b) {
708
853
  if (!b) return a;
709
854
  return { ...a, ...b };
710
855
  }
856
+ function scanFile(path) {
857
+ let size;
858
+ try {
859
+ size = statSync(path).size;
860
+ } catch {
861
+ return null;
862
+ }
863
+ if (size > MAX_STREAM_BYTES) return null;
864
+ const large = size > MAX_PIXEL_BYTES;
865
+ return { size, large, record: large ? readHeaderOnly(path) : parseFull(path) };
866
+ }
867
+ function presetModalityOf(record) {
868
+ if (typeof record.Modality !== "string") return void 0;
869
+ return SUPPORTED_MODALITIES.has(record.Modality) ? record.Modality : "unsupported";
870
+ }
871
+ function hashRecordUids(rec, salt, uidMap, sweptKeepTags) {
872
+ if (!rec.uidOriginals) return;
873
+ if (rec.tags) {
874
+ for (const keyword of Object.keys(rec.uidOriginals)) {
875
+ if (keyword in rec.tags) sweptKeepTags.add(keyword);
876
+ }
877
+ }
878
+ rec.tags = mergeTags(rec.tags, hashUidTags(rec.uidOriginals, salt, uidMap));
879
+ }
880
+ function warnSweptKeepTags(sweptKeepTags) {
881
+ for (const keyword of sweptKeepTags) {
882
+ console.warn(
883
+ `describe: keep-tag "${keyword}" contains UID references \u2014 kept as its hashed UID skeleton, not verbatim (disable with preserveUids: false)`
884
+ );
885
+ }
886
+ }
711
887
  function sizeKbOf(bytes) {
712
888
  return Math.max(1, Math.round(bytes / 1024));
713
889
  }
@@ -810,9 +986,128 @@ function buildSeriesSpec(records) {
810
986
  function seriesSpecFromAccum(accum) {
811
987
  return Array.isArray(accum) ? buildSeriesSpec(accum) : { entries: [...accum.values()].map(entryOf) };
812
988
  }
989
+ function dirAccumFor(root, dirs) {
990
+ let node = root;
991
+ for (const name of dirs) {
992
+ let child = node.dirs.get(name);
993
+ if (!child) {
994
+ child = { dirs: /* @__PURE__ */ new Map(), files: [] };
995
+ node.dirs.set(name, child);
996
+ }
997
+ node = child;
998
+ }
999
+ return node;
1000
+ }
1001
+ var SAFE_EXTENSION_RE = /^\.[A-Za-z0-9]{1,8}$/;
1002
+ function safeExtension(path) {
1003
+ const ext = extname(basename(path));
1004
+ return SAFE_EXTENSION_RE.test(ext) ? ext : "";
1005
+ }
1006
+ function emitTree(acc) {
1007
+ const nodes = [];
1008
+ let otherOrdinal = 0;
1009
+ for (const leaf of acc.files) {
1010
+ if (leaf.kind === "other") {
1011
+ otherOrdinal++;
1012
+ const name = `${positionalName("file", otherOrdinal)}${leaf.ext}`;
1013
+ nodes.push(
1014
+ leaf.bytes > 0 ? { type: "non-dicom", name, targetBytes: leaf.bytes } : { type: "non-dicom", name, content: "" }
1015
+ );
1016
+ } else {
1017
+ nodes.push(entryOf({ ...leaf.rec, count: 1 }));
1018
+ }
1019
+ }
1020
+ for (const sub of acc.dirs.values()) {
1021
+ nodes.push({ children: emitTree(sub) });
1022
+ }
1023
+ return nodes;
1024
+ }
1025
+ function describeTree(dir, options, keepKeywords) {
1026
+ const root = { dirs: /* @__PURE__ */ new Map(), files: [] };
1027
+ const stats = {
1028
+ dicomFiles: 0,
1029
+ skipped: 0,
1030
+ unknownModality: 0,
1031
+ nonDicomFiles: 0
1032
+ };
1033
+ const studyUids = /* @__PURE__ */ new Set();
1034
+ const dicomLeaves = [];
1035
+ for (const path of [...walkFiles(dir)].sort()) {
1036
+ const scanned = scanFile(path);
1037
+ if (!scanned) {
1038
+ stats.skipped++;
1039
+ continue;
1040
+ }
1041
+ const { size, large, record } = scanned;
1042
+ const studyUid = record?.StudyInstanceUID;
1043
+ const seriesUid = record?.SeriesInstanceUID;
1044
+ const relDirs = relative(dir, path).split(sep).slice(0, -1);
1045
+ if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
1046
+ if (large) {
1047
+ stats.skipped++;
1048
+ continue;
1049
+ }
1050
+ stats.nonDicomFiles++;
1051
+ dirAccumFor(root, relDirs).files.push({
1052
+ kind: "other",
1053
+ bytes: size,
1054
+ ext: safeExtension(path)
1055
+ });
1056
+ continue;
1057
+ }
1058
+ stats.dicomFiles++;
1059
+ studyUids.add(studyUid);
1060
+ const preset = presetModalityOf(record);
1061
+ if (preset === "unsupported") stats.unknownModality++;
1062
+ const modality = preset === "unsupported" ? void 0 : preset;
1063
+ const instanceNumber = typeof record.InstanceNumber === "number" ? { InstanceNumber: record.InstanceNumber } : typeof record.InstanceNumber === "string" ? { InstanceNumber: escapeTagTemplate(record.InstanceNumber) } : void 0;
1064
+ const leaf = {
1065
+ kind: "dicom",
1066
+ rec: {
1067
+ large,
1068
+ bytes: size,
1069
+ modality,
1070
+ tags: mergeTags(
1071
+ instanceNumber,
1072
+ keepKeywords.length ? extractTags(record, keepKeywords) : void 0
1073
+ ),
1074
+ uidOriginals: collectUidOriginals(record)
1075
+ },
1076
+ studyUid,
1077
+ seriesUid
1078
+ };
1079
+ dirAccumFor(root, relDirs).files.push(leaf);
1080
+ dicomLeaves.push(leaf);
1081
+ }
1082
+ if (root.files.length === 0 && root.dirs.size === 0) {
1083
+ throw new Error(`describe: no files found in ${dir}`);
1084
+ }
1085
+ const salt = options.uidSalt ?? deriveSalt([...studyUids]);
1086
+ const uidMap = /* @__PURE__ */ new Map();
1087
+ const sweptKeepTags = /* @__PURE__ */ new Set();
1088
+ for (const { rec, studyUid, seriesUid } of dicomLeaves) {
1089
+ hashRecordUids(rec, salt, uidMap, sweptKeepTags);
1090
+ rec.tags = mergeTags(rec.tags, {
1091
+ StudyInstanceUID: hashUidVia(uidMap, salt, studyUid),
1092
+ SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
1093
+ });
1094
+ }
1095
+ warnSweptKeepTags(sweptKeepTags);
1096
+ const spec = { tree: emitTree(root), uidSalt: salt };
1097
+ validateDatasetSpec(spec);
1098
+ return { spec, stats };
1099
+ }
813
1100
  function describeDirectory(dir, options = {}) {
814
1101
  const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
815
1102
  const preserveUids = options.preserveUids ?? true;
1103
+ if (options.captureTree) {
1104
+ if (!preserveUids) {
1105
+ throw new Error(
1106
+ "describe: captureTree requires preserveUids \u2014 the captured tree carries its grouping as hashed UID tags"
1107
+ );
1108
+ }
1109
+ return describeTree(dir, options, keepKeywords);
1110
+ }
816
1111
  const uidMap = /* @__PURE__ */ new Map();
817
1112
  const useRecords = keepKeywords.length > 0 || preserveUids;
818
1113
  const tolerance = options.sizeTolerance ?? 0;
@@ -822,21 +1117,19 @@ function describeDirectory(dir, options = {}) {
822
1117
  );
823
1118
  }
824
1119
  const studies = /* @__PURE__ */ new Map();
825
- const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
1120
+ const stats = {
1121
+ dicomFiles: 0,
1122
+ skipped: 0,
1123
+ unknownModality: 0,
1124
+ nonDicomFiles: 0
1125
+ };
826
1126
  for (const path of [...walkFiles(dir)].sort()) {
827
- let size;
828
- try {
829
- size = statSync(path).size;
830
- } catch {
831
- stats.skipped++;
832
- continue;
833
- }
834
- if (size > MAX_STREAM_BYTES) {
1127
+ const scanned = scanFile(path);
1128
+ if (!scanned) {
835
1129
  stats.skipped++;
836
1130
  continue;
837
1131
  }
838
- const large = size > MAX_PIXEL_BYTES;
839
- const record = large ? readHeaderOnly(path) : parseFull(path);
1132
+ const { size, large, record } = scanned;
840
1133
  const studyUid = record?.StudyInstanceUID;
841
1134
  const seriesUid = record?.SeriesInstanceUID;
842
1135
  if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
@@ -844,14 +1137,9 @@ function describeDirectory(dir, options = {}) {
844
1137
  continue;
845
1138
  }
846
1139
  stats.dicomFiles++;
847
- let modality;
848
- if (typeof record.Modality === "string") {
849
- if (SUPPORTED_MODALITIES.has(record.Modality)) {
850
- modality = record.Modality;
851
- } else {
852
- stats.unknownModality++;
853
- }
854
- }
1140
+ const preset = presetModalityOf(record);
1141
+ if (preset === "unsupported") stats.unknownModality++;
1142
+ const modality = preset === "unsupported" ? void 0 : preset;
855
1143
  const keptTags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
856
1144
  const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
857
1145
  const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
@@ -885,25 +1173,11 @@ function describeDirectory(dir, options = {}) {
885
1173
  for (const series of study.values()) {
886
1174
  if (!Array.isArray(series)) continue;
887
1175
  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
- }
1176
+ hashRecordUids(rec, salt, uidMap, sweptKeepTags);
899
1177
  }
900
1178
  }
901
1179
  }
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
- }
1180
+ warnSweptKeepTags(sweptKeepTags);
907
1181
  }
908
1182
  const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
909
1183
  ...spec2,