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.
package/dist/esm/index.js CHANGED
@@ -8,47 +8,6 @@ import { dirname, resolve as resolve3 } from "node:path";
8
8
  var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
9
9
  var MAX_STREAM_BYTES = 50 * 1024 * 1024 * 1024;
10
10
 
11
- // src/loadDcmjs.ts
12
- import dcmjsDefaultImport, * as dcmjsNamespace from "dcmjs";
13
- function loadDcmjs() {
14
- if (dcmjsNamespace.data?.DicomDict) {
15
- return dcmjsNamespace;
16
- }
17
- const d = dcmjsDefaultImport;
18
- if (d?.data?.DicomDict) {
19
- return d;
20
- }
21
- if (d?.default?.data?.DicomDict) {
22
- return d.default;
23
- }
24
- throw new Error(
25
- "dcmjs failed to load (missing data.DicomDict). Install dcmjs >= 0.29."
26
- );
27
- }
28
- var dcmjs = loadDcmjs();
29
-
30
- // src/nonStandardDicom/dicomdir.ts
31
- import { mkdirSync, writeFileSync } from "node:fs";
32
- import { resolve } from "node:path";
33
- var DICOMDIR_SOP_CLASS_UID = "1.2.840.10008.1.3.10";
34
- function buildDicomdirBuffer() {
35
- const uid = DICOMDIR_SOP_CLASS_UID;
36
- const buf = Buffer.alloc(256, 0);
37
- buf.write("DICM", 128, 4, "ascii");
38
- let off = 132;
39
- buf.writeUInt16LE(2, off);
40
- off += 2;
41
- buf.writeUInt16LE(2, off);
42
- off += 2;
43
- buf.write("UI", off, 2, "ascii");
44
- off += 2;
45
- buf.writeUInt16LE(uid.length, off);
46
- off += 2;
47
- buf.write(uid, off, uid.length, "ascii");
48
- off += uid.length;
49
- return buf.subarray(0, off);
50
- }
51
-
52
11
  // src/syntheticFixtures/tagTemplate.ts
53
12
  var TEMPLATE_VOCAB = [
54
13
  "index",
@@ -103,6 +62,9 @@ var TYPE_CAPABILITIES = {
103
62
  "non-dicom": { image: false },
104
63
  dicomdir: { image: false }
105
64
  };
65
+ function isImageType(type) {
66
+ return TYPE_CAPABILITIES[type].image;
67
+ }
106
68
  var VALID_MODALITIES = {
107
69
  CT: true,
108
70
  PT: true,
@@ -161,11 +123,14 @@ function validateUidSalt(value, path) {
161
123
  );
162
124
  }
163
125
  }
164
- function validateSizeKbLimit(kb, path) {
165
- if (kb * 1024 > MAX_PIXEL_BYTES) {
126
+ function validateInMemoryByteLimit(bytes, path) {
127
+ if (bytes > MAX_PIXEL_BYTES) {
166
128
  throw new Error(`${path}: exceeds the 512 MB limit`);
167
129
  }
168
130
  }
131
+ function validateSizeKbLimit(kb, path) {
132
+ validateInMemoryByteLimit(kb * 1024, path);
133
+ }
169
134
  function validateLargeTargetBytes(value, path) {
170
135
  if (typeof value !== "number" || !Number.isInteger(value)) {
171
136
  throw new Error(`${path}: must be an integer; got ${JSON.stringify(value)}`);
@@ -203,6 +168,29 @@ function validateLargeImageEntry(e, path) {
203
168
  }
204
169
  if ("tags" in e) validateTags(e.tags, `${path}.tags`);
205
170
  }
171
+ function validateNonDicomEntry(e, path) {
172
+ const sizing = ["content", "targetSizeKb", "targetBytes"].filter(
173
+ (f) => f in e
174
+ );
175
+ if (sizing.length > 1) {
176
+ throw new Error(
177
+ `${path}: "content", "targetSizeKb" and "targetBytes" are mutually exclusive`
178
+ );
179
+ }
180
+ if ("content" in e && typeof e.content !== "string") {
181
+ throw new Error(
182
+ `${path}.content: must be a string; got ${JSON.stringify(e.content)}`
183
+ );
184
+ }
185
+ if ("targetSizeKb" in e) {
186
+ validatePositiveInt(e.targetSizeKb, `${path}.targetSizeKb`);
187
+ validateSizeKbLimit(e.targetSizeKb, `${path}.targetSizeKb`);
188
+ }
189
+ if ("targetBytes" in e) {
190
+ validatePositiveInt(e.targetBytes, `${path}.targetBytes`);
191
+ validateInMemoryByteLimit(e.targetBytes, `${path}.targetBytes`);
192
+ }
193
+ }
206
194
  function validateEnum(value, valid, path) {
207
195
  if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
208
196
  throw new Error(
@@ -230,6 +218,22 @@ function validateEntry(entry, path) {
230
218
  validateLargeImageEntry(e, path);
231
219
  return e;
232
220
  }
221
+ if (type === "non-dicom") {
222
+ for (const field of [
223
+ "modality",
224
+ "rows",
225
+ "columns",
226
+ "frames",
227
+ "tags",
228
+ "violations",
229
+ "transferSyntax"
230
+ ]) {
231
+ if (field in e)
232
+ throw new Error(`${path}.${field}: not supported for type "${type}"`);
233
+ }
234
+ validateNonDicomEntry(e, path);
235
+ return e;
236
+ }
233
237
  if ("targetBytes" in e) {
234
238
  throw new Error(
235
239
  `${path}.targetBytes: only supported for type "large-image"`
@@ -437,6 +441,101 @@ function validateStudy(study, path) {
437
441
  if ("tags" in st) validateTags(st.tags, `${path}.tags`);
438
442
  return st;
439
443
  }
444
+ function positionalName(prefix, ordinal) {
445
+ return `${prefix}-${String(ordinal).padStart(3, "0")}`;
446
+ }
447
+ var VALID_TREE_ROLES = {
448
+ study: true,
449
+ series: true
450
+ };
451
+ function validateNodeName(value, path) {
452
+ if (typeof value !== "string" || value.length === 0 || value === "." || value === ".." || /[/\\]/.test(value)) {
453
+ throw new Error(
454
+ `${path}: must be a non-empty name without path separators; got ${JSON.stringify(value)}`
455
+ );
456
+ }
457
+ }
458
+ function validateTreeNode(node, path, inStudy, inSeries) {
459
+ if (typeof node !== "object" || node === null || Array.isArray(node)) {
460
+ throw new Error(`${path}: must be an object`);
461
+ }
462
+ const n = node;
463
+ if ("children" in n) {
464
+ if ("type" in n) {
465
+ throw new Error(
466
+ `${path}: a node cannot have both "children" (directory) and "type" (file)`
467
+ );
468
+ }
469
+ if ("name" in n) validateNodeName(n.name, `${path}.name`);
470
+ if ("tags" in n) validateTags(n.tags, `${path}.tags`);
471
+ let childStudy = inStudy;
472
+ let childSeries = inSeries;
473
+ if ("role" in n) {
474
+ validateEnum(n.role, VALID_TREE_ROLES, `${path}.role`);
475
+ if (n.role === "study") {
476
+ if (inStudy) {
477
+ throw new Error(
478
+ `${path}.role: a study directory cannot nest inside another study`
479
+ );
480
+ }
481
+ childStudy = true;
482
+ } else {
483
+ if (!inStudy) {
484
+ throw new Error(
485
+ `${path}.role: a series directory requires an enclosing study-role directory`
486
+ );
487
+ }
488
+ if (inSeries) {
489
+ throw new Error(
490
+ `${path}.role: a series directory cannot nest inside another series`
491
+ );
492
+ }
493
+ childSeries = true;
494
+ }
495
+ }
496
+ if (!Array.isArray(n.children) || n.children.length === 0) {
497
+ throw new Error(`${path}.children: must be a non-empty array`);
498
+ }
499
+ validateTreeChildren(
500
+ n.children,
501
+ `${path}.children`,
502
+ childStudy,
503
+ childSeries
504
+ );
505
+ return;
506
+ }
507
+ const e = validateEntry(node, path);
508
+ if ("name" in n) {
509
+ validateNodeName(n.name, `${path}.name`);
510
+ if ((e.count ?? 1) > 1) {
511
+ throw new Error(
512
+ `${path}: "name" cannot be combined with count > 1 \u2014 the copies would collide on one path`
513
+ );
514
+ }
515
+ }
516
+ }
517
+ function validateTreeChildren(children, basePath, inStudy, inSeries) {
518
+ const claimed = /* @__PURE__ */ new Map();
519
+ let dirOrdinal = 0;
520
+ children.forEach((child, i) => {
521
+ const path = `${basePath}[${i}]`;
522
+ validateTreeNode(child, path, inStudy, inSeries);
523
+ const n = child;
524
+ let name = "name" in n ? n.name : void 0;
525
+ if ("children" in n) {
526
+ dirOrdinal++;
527
+ name ?? (name = positionalName("dir", dirOrdinal));
528
+ }
529
+ if (name === void 0) return;
530
+ const prior = claimed.get(name);
531
+ if (prior !== void 0) {
532
+ throw new Error(
533
+ `${path}: name "${name}" collides with ${prior} \u2014 siblings must resolve to distinct paths`
534
+ );
535
+ }
536
+ claimed.set(name, path);
537
+ });
538
+ }
440
539
  function validateDatasetSpec(raw) {
441
540
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
442
541
  throw new Error("DatasetSpec: must be a JSON object");
@@ -448,11 +547,15 @@ function validateDatasetSpec(raw) {
448
547
  if ("studies" in r && !Array.isArray(r.studies)) {
449
548
  throw new Error("DatasetSpec.studies: must be an array");
450
549
  }
550
+ if ("tree" in r && !Array.isArray(r.tree)) {
551
+ throw new Error("DatasetSpec.tree: must be an array");
552
+ }
451
553
  const rawEntries = r.entries ?? [];
452
554
  const rawStudies = r.studies ?? [];
453
- if (rawEntries.length === 0 && rawStudies.length === 0) {
555
+ const rawTree = r.tree ?? [];
556
+ if (rawEntries.length === 0 && rawStudies.length === 0 && rawTree.length === 0) {
454
557
  throw new Error(
455
- 'DatasetSpec: must contain at least one of "entries" or "studies" (non-empty)'
558
+ 'DatasetSpec: must contain at least one of "entries", "studies" or "tree" (non-empty)'
456
559
  );
457
560
  }
458
561
  const entries = rawEntries.map(
@@ -461,6 +564,9 @@ function validateDatasetSpec(raw) {
461
564
  const studies = rawStudies.map(
462
565
  (study, i) => validateStudy(study, `studies[${i}]`)
463
566
  );
567
+ if (rawTree.length > 0) {
568
+ validateTreeChildren(rawTree, "tree", false, false);
569
+ }
464
570
  if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
465
571
  if ("uidSalt" in r) validateUidSalt(r.uidSalt, "DatasetSpec.uidSalt");
466
572
  if ("layout" in r) {
@@ -477,6 +583,7 @@ function validateDatasetSpec(raw) {
477
583
  return {
478
584
  ...entries.length > 0 ? { entries } : {},
479
585
  ...studies.length > 0 ? { studies } : {},
586
+ ...rawTree.length > 0 ? { tree: r.tree } : {},
480
587
  ...r.seed !== void 0 ? { seed: r.seed } : {},
481
588
  ...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
482
589
  ...r.layout !== void 0 ? { layout: r.layout } : {},
@@ -607,6 +714,47 @@ function validateParametricSpec(raw) {
607
714
  };
608
715
  }
609
716
 
717
+ // src/loadDcmjs.ts
718
+ import dcmjsDefaultImport, * as dcmjsNamespace from "dcmjs";
719
+ function loadDcmjs() {
720
+ if (dcmjsNamespace.data?.DicomDict) {
721
+ return dcmjsNamespace;
722
+ }
723
+ const d = dcmjsDefaultImport;
724
+ if (d?.data?.DicomDict) {
725
+ return d;
726
+ }
727
+ if (d?.default?.data?.DicomDict) {
728
+ return d.default;
729
+ }
730
+ throw new Error(
731
+ "dcmjs failed to load (missing data.DicomDict). Install dcmjs >= 0.29."
732
+ );
733
+ }
734
+ var dcmjs = loadDcmjs();
735
+
736
+ // src/nonStandardDicom/dicomdir.ts
737
+ import { mkdirSync, writeFileSync } from "node:fs";
738
+ import { resolve } from "node:path";
739
+ var DICOMDIR_SOP_CLASS_UID = "1.2.840.10008.1.3.10";
740
+ function buildDicomdirBuffer() {
741
+ const uid = DICOMDIR_SOP_CLASS_UID;
742
+ const buf = Buffer.alloc(256, 0);
743
+ buf.write("DICM", 128, 4, "ascii");
744
+ let off = 132;
745
+ buf.writeUInt16LE(2, off);
746
+ off += 2;
747
+ buf.writeUInt16LE(2, off);
748
+ off += 2;
749
+ buf.write("UI", off, 2, "ascii");
750
+ off += 2;
751
+ buf.writeUInt16LE(uid.length, off);
752
+ off += 2;
753
+ buf.write(uid, off, uid.length, "ascii");
754
+ off += uid.length;
755
+ return buf.subarray(0, off);
756
+ }
757
+
610
758
  // src/syntheticFixtures/generator.ts
611
759
  var MODALITY_PRESETS = {
612
760
  CT: {
@@ -669,6 +817,14 @@ function applyTagOverrides(dataset, tags) {
669
817
  }
670
818
  }
671
819
  }
820
+ function syncMetaWithDataset(meta, dataset) {
821
+ if (typeof dataset.SOPInstanceUID === "string") {
822
+ meta.MediaStorageSOPInstanceUID = dataset.SOPInstanceUID;
823
+ }
824
+ if (typeof dataset.SOPClassUID === "string") {
825
+ meta.MediaStorageSOPClassUID = dataset.SOPClassUID;
826
+ }
827
+ }
672
828
  function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
673
829
  return {
674
830
  FileMetaInformationVersion: new Uint8Array([0, 1]).buffer,
@@ -755,6 +911,7 @@ function buildImageBuffer(spec, uid) {
755
911
  }
756
912
  applyTagOverrides(dataset, spec.tags);
757
913
  const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
914
+ syncMetaWithDataset(meta, dataset);
758
915
  applySizing(dataset, meta, spec);
759
916
  return serializeDict(meta, dataset);
760
917
  }
@@ -763,8 +920,13 @@ function buildFakeSignatureBuffer() {
763
920
  buf.write("XXXX", 128, 4, "ascii");
764
921
  return buf;
765
922
  }
766
- function buildNonDicomBuffer(content = "not dicom") {
767
- return Buffer.from(content, "utf8");
923
+ function sizedNonDicomBytes(spec) {
924
+ return spec.targetBytes ?? (spec.targetSizeKb !== void 0 ? spec.targetSizeKb * 1024 : void 0);
925
+ }
926
+ function buildNonDicomBuffer(spec) {
927
+ const bytes = sizedNonDicomBytes(spec);
928
+ if (bytes !== void 0) return Buffer.alloc(bytes, "not dicom ");
929
+ return Buffer.from(spec.content ?? "not dicom", "utf8");
768
930
  }
769
931
  function buildBufferForSpec(spec, uid) {
770
932
  switch (spec.type) {
@@ -775,7 +937,7 @@ function buildBufferForSpec(spec, uid) {
775
937
  case "fake-signature":
776
938
  return buildFakeSignatureBuffer();
777
939
  case "non-dicom":
778
- return buildNonDicomBuffer(spec.content);
940
+ return buildNonDicomBuffer(spec);
779
941
  case "dicomdir":
780
942
  return buildDicomdirBuffer();
781
943
  case "large-image":
@@ -870,6 +1032,7 @@ function writeNative(outPath, params, dims, zeroChunk) {
870
1032
  const meta = buildMeta(uid, modality);
871
1033
  const dataset = buildBaseImageDataset(uid, modality);
872
1034
  applyTagOverrides(dataset, tags);
1035
+ syncMetaWithDataset(meta, dataset);
873
1036
  dataset.Rows = dims.rows;
874
1037
  dataset.Columns = dims.columns;
875
1038
  const minimalPixelBytes = dataset.PixelData.byteLength;
@@ -903,6 +1066,7 @@ function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk
903
1066
  };
904
1067
  const dataset = buildBaseImageDataset(uid, modality);
905
1068
  applyTagOverrides(dataset, tags);
1069
+ syncMetaWithDataset(meta, dataset);
906
1070
  dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
907
1071
  dataset._vrMap = { PixelData: "OB" };
908
1072
  const probe = serializeDict(meta, dataset);
@@ -1118,6 +1282,15 @@ function studyFileCount(studies) {
1118
1282
  0
1119
1283
  );
1120
1284
  }
1285
+ function isDirNode(node) {
1286
+ return "children" in node;
1287
+ }
1288
+ function treeFileCount(nodes) {
1289
+ return nodes.reduce(
1290
+ (sum, node) => sum + (isDirNode(node) ? treeFileCount(node.children) : node.count ?? 1),
1291
+ 0
1292
+ );
1293
+ }
1121
1294
  function* seriesFiles(series) {
1122
1295
  if (series.instances) {
1123
1296
  const inst = series.instances;
@@ -1214,6 +1387,9 @@ function applyPathQuirks(relativePath, quirks) {
1214
1387
  }
1215
1388
  return { relativePath: [...dirs, newLeaf].join("/"), filename: newLeaf };
1216
1389
  }
1390
+ function decorateNames(names, quirks) {
1391
+ return quirks.length === 0 ? names : applyPathQuirks(names.relativePath, quirks);
1392
+ }
1217
1393
  function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
1218
1394
  const pad3 = (n) => String(n).padStart(3, "0");
1219
1395
  const name = `${String(instanceNumber).padStart(5, "0")}.dcm`;
@@ -1240,12 +1416,78 @@ function computeNames(type, index, padWidth, grouped, quirks) {
1240
1416
  const name = filename(type, index, padWidth);
1241
1417
  names = { filename: name, relativePath: name };
1242
1418
  }
1243
- return quirks.length === 0 ? names : applyPathQuirks(names.relativePath, quirks);
1419
+ return decorateNames(names, quirks);
1420
+ }
1421
+ function* planTree(nodes, scope, env) {
1422
+ let dirOrdinal = 0;
1423
+ for (const node of nodes) {
1424
+ if (isDirNode(node)) {
1425
+ dirOrdinal++;
1426
+ const dirName = node.name ?? positionalName("dir", dirOrdinal);
1427
+ const child = {
1428
+ ...scope,
1429
+ dirs: [...scope.dirs, dirName],
1430
+ tags: { ...scope.tags, ...node.tags }
1431
+ };
1432
+ if (node.role === "study") {
1433
+ const ordinal = env.counters.study++;
1434
+ child.study = {
1435
+ ordinal,
1436
+ uid: env.groupUids.study(ordinal),
1437
+ seriesCount: 0
1438
+ };
1439
+ } else if (node.role === "series" && child.study) {
1440
+ const index = child.study.seriesCount++;
1441
+ child.series = {
1442
+ index,
1443
+ uid: env.groupUids.series(child.study.ordinal, index),
1444
+ instanceCount: 0
1445
+ };
1446
+ }
1447
+ yield* planTree(node.children, child, env);
1448
+ continue;
1449
+ }
1450
+ const { count = 1, name, ...fileSpec } = node;
1451
+ const image = isImageType(fileSpec.type);
1452
+ for (let i = 0; i < count; i++) {
1453
+ const index = env.counters.file++;
1454
+ const instanceNumber = image && scope.series ? ++scope.series.instanceCount : void 0;
1455
+ const leaf = name ?? filename(fileSpec.type, index, env.padWidth);
1456
+ const names = decorateNames(
1457
+ { filename: leaf, relativePath: [...scope.dirs, leaf].join("/") },
1458
+ env.quirks
1459
+ );
1460
+ const tags = image ? {
1461
+ ...instanceNumber !== void 0 ? { InstanceNumber: instanceNumber } : {},
1462
+ ...scope.tags,
1463
+ ..."tags" in fileSpec ? fileSpec.tags : void 0
1464
+ } : void 0;
1465
+ yield {
1466
+ fileSpec: tags ? { ...fileSpec, tags } : fileSpec,
1467
+ index,
1468
+ ...image && scope.study ? {
1469
+ uidOverride: {
1470
+ study: scope.study.uid,
1471
+ ...scope.series ? { series: scope.series.uid } : {}
1472
+ }
1473
+ } : {},
1474
+ context: {
1475
+ index,
1476
+ ...scope.study ? { studyIndex: scope.study.ordinal } : {},
1477
+ ...scope.series ? { seriesIndex: scope.series.index } : {},
1478
+ ...instanceNumber !== void 0 ? { instanceNumber } : {}
1479
+ },
1480
+ filename: names.filename,
1481
+ relativePath: names.relativePath
1482
+ };
1483
+ }
1484
+ }
1244
1485
  }
1245
1486
  function* planCollection(spec) {
1246
1487
  const flatEntries = spec.entries ?? [];
1247
1488
  const studies = spec.studies ?? [];
1248
- const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
1489
+ const tree = spec.tree ?? [];
1490
+ const totalFiles = entryCount(flatEntries) + studyFileCount(studies) + treeFileCount(tree);
1249
1491
  const padWidth = String(totalFiles).length;
1250
1492
  const hierarchical = spec.layout === "hierarchical";
1251
1493
  const quirks = spec.pathQuirks ?? [];
@@ -1309,6 +1551,18 @@ function* planCollection(spec) {
1309
1551
  studyOrdinal++;
1310
1552
  }
1311
1553
  }
1554
+ if (tree.length > 0) {
1555
+ yield* planTree(
1556
+ tree,
1557
+ { dirs: [], tags: {} },
1558
+ {
1559
+ groupUids,
1560
+ padWidth,
1561
+ quirks,
1562
+ counters: { file: globalIndex, study: studyOrdinal }
1563
+ }
1564
+ );
1565
+ }
1312
1566
  }
1313
1567
  async function materialisePlan(plan, salt) {
1314
1568
  const file = await generateFile(plan.fileSpec, {
@@ -1328,12 +1582,13 @@ async function* generateCollectionFromSpec(spec) {
1328
1582
  async function* previewCollection(spec) {
1329
1583
  const salt = effectiveSalt(spec);
1330
1584
  for (const plan of planCollection(spec)) {
1331
- if (plan.fileSpec.type === "large-image") {
1585
+ const knownBytes = plan.fileSpec.type === "large-image" ? plan.fileSpec.targetBytes : plan.fileSpec.type === "non-dicom" ? sizedNonDicomBytes(plan.fileSpec) : void 0;
1586
+ if (knownBytes !== void 0) {
1332
1587
  yield {
1333
1588
  relativePath: plan.relativePath,
1334
- type: "large-image",
1589
+ type: plan.fileSpec.type,
1335
1590
  index: plan.index,
1336
- approxBytes: plan.fileSpec.targetBytes
1591
+ approxBytes: knownBytes
1337
1592
  };
1338
1593
  } else {
1339
1594
  const file = await materialisePlan(plan, salt);
@@ -1399,7 +1654,7 @@ import {
1399
1654
  readSync,
1400
1655
  statSync
1401
1656
  } from "node:fs";
1402
- import { join } from "node:path";
1657
+ import { basename, extname, join, relative, sep } from "node:path";
1403
1658
  var dcmjsAny2 = dcmjs;
1404
1659
  var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
1405
1660
  var SUPPORTED_MODALITIES = /* @__PURE__ */ new Set(["CT", "PT", "MR", "CR"]);
@@ -1663,6 +1918,37 @@ function mergeTags(a, b) {
1663
1918
  if (!b) return a;
1664
1919
  return { ...a, ...b };
1665
1920
  }
1921
+ function scanFile(path) {
1922
+ let size;
1923
+ try {
1924
+ size = statSync(path).size;
1925
+ } catch {
1926
+ return null;
1927
+ }
1928
+ if (size > MAX_STREAM_BYTES) return null;
1929
+ const large = size > MAX_PIXEL_BYTES;
1930
+ return { size, large, record: large ? readHeaderOnly(path) : parseFull(path) };
1931
+ }
1932
+ function presetModalityOf(record) {
1933
+ if (typeof record.Modality !== "string") return void 0;
1934
+ return SUPPORTED_MODALITIES.has(record.Modality) ? record.Modality : "unsupported";
1935
+ }
1936
+ function hashRecordUids(rec, salt, uidMap, sweptKeepTags) {
1937
+ if (!rec.uidOriginals) return;
1938
+ if (rec.tags) {
1939
+ for (const keyword of Object.keys(rec.uidOriginals)) {
1940
+ if (keyword in rec.tags) sweptKeepTags.add(keyword);
1941
+ }
1942
+ }
1943
+ rec.tags = mergeTags(rec.tags, hashUidTags(rec.uidOriginals, salt, uidMap));
1944
+ }
1945
+ function warnSweptKeepTags(sweptKeepTags) {
1946
+ for (const keyword of sweptKeepTags) {
1947
+ console.warn(
1948
+ `describe: keep-tag "${keyword}" contains UID references \u2014 kept as its hashed UID skeleton, not verbatim (disable with preserveUids: false)`
1949
+ );
1950
+ }
1951
+ }
1666
1952
  function sizeKbOf(bytes) {
1667
1953
  return Math.max(1, Math.round(bytes / 1024));
1668
1954
  }
@@ -1765,9 +2051,128 @@ function buildSeriesSpec(records) {
1765
2051
  function seriesSpecFromAccum(accum) {
1766
2052
  return Array.isArray(accum) ? buildSeriesSpec(accum) : { entries: [...accum.values()].map(entryOf) };
1767
2053
  }
2054
+ function dirAccumFor(root, dirs) {
2055
+ let node = root;
2056
+ for (const name of dirs) {
2057
+ let child = node.dirs.get(name);
2058
+ if (!child) {
2059
+ child = { dirs: /* @__PURE__ */ new Map(), files: [] };
2060
+ node.dirs.set(name, child);
2061
+ }
2062
+ node = child;
2063
+ }
2064
+ return node;
2065
+ }
2066
+ var SAFE_EXTENSION_RE = /^\.[A-Za-z0-9]{1,8}$/;
2067
+ function safeExtension(path) {
2068
+ const ext = extname(basename(path));
2069
+ return SAFE_EXTENSION_RE.test(ext) ? ext : "";
2070
+ }
2071
+ function emitTree(acc) {
2072
+ const nodes = [];
2073
+ let otherOrdinal = 0;
2074
+ for (const leaf of acc.files) {
2075
+ if (leaf.kind === "other") {
2076
+ otherOrdinal++;
2077
+ const name = `${positionalName("file", otherOrdinal)}${leaf.ext}`;
2078
+ nodes.push(
2079
+ leaf.bytes > 0 ? { type: "non-dicom", name, targetBytes: leaf.bytes } : { type: "non-dicom", name, content: "" }
2080
+ );
2081
+ } else {
2082
+ nodes.push(entryOf({ ...leaf.rec, count: 1 }));
2083
+ }
2084
+ }
2085
+ for (const sub of acc.dirs.values()) {
2086
+ nodes.push({ children: emitTree(sub) });
2087
+ }
2088
+ return nodes;
2089
+ }
2090
+ function describeTree(dir, options, keepKeywords) {
2091
+ const root = { dirs: /* @__PURE__ */ new Map(), files: [] };
2092
+ const stats = {
2093
+ dicomFiles: 0,
2094
+ skipped: 0,
2095
+ unknownModality: 0,
2096
+ nonDicomFiles: 0
2097
+ };
2098
+ const studyUids = /* @__PURE__ */ new Set();
2099
+ const dicomLeaves = [];
2100
+ for (const path of [...walkFiles(dir)].sort()) {
2101
+ const scanned = scanFile(path);
2102
+ if (!scanned) {
2103
+ stats.skipped++;
2104
+ continue;
2105
+ }
2106
+ const { size, large, record } = scanned;
2107
+ const studyUid = record?.StudyInstanceUID;
2108
+ const seriesUid = record?.SeriesInstanceUID;
2109
+ const relDirs = relative(dir, path).split(sep).slice(0, -1);
2110
+ if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
2111
+ if (large) {
2112
+ stats.skipped++;
2113
+ continue;
2114
+ }
2115
+ stats.nonDicomFiles++;
2116
+ dirAccumFor(root, relDirs).files.push({
2117
+ kind: "other",
2118
+ bytes: size,
2119
+ ext: safeExtension(path)
2120
+ });
2121
+ continue;
2122
+ }
2123
+ stats.dicomFiles++;
2124
+ studyUids.add(studyUid);
2125
+ const preset = presetModalityOf(record);
2126
+ if (preset === "unsupported") stats.unknownModality++;
2127
+ const modality = preset === "unsupported" ? void 0 : preset;
2128
+ const instanceNumber = typeof record.InstanceNumber === "number" ? { InstanceNumber: record.InstanceNumber } : typeof record.InstanceNumber === "string" ? { InstanceNumber: escapeTagTemplate(record.InstanceNumber) } : void 0;
2129
+ const leaf = {
2130
+ kind: "dicom",
2131
+ rec: {
2132
+ large,
2133
+ bytes: size,
2134
+ modality,
2135
+ tags: mergeTags(
2136
+ instanceNumber,
2137
+ keepKeywords.length ? extractTags(record, keepKeywords) : void 0
2138
+ ),
2139
+ uidOriginals: collectUidOriginals(record)
2140
+ },
2141
+ studyUid,
2142
+ seriesUid
2143
+ };
2144
+ dirAccumFor(root, relDirs).files.push(leaf);
2145
+ dicomLeaves.push(leaf);
2146
+ }
2147
+ if (root.files.length === 0 && root.dirs.size === 0) {
2148
+ throw new Error(`describe: no files found in ${dir}`);
2149
+ }
2150
+ const salt = options.uidSalt ?? deriveSalt([...studyUids]);
2151
+ const uidMap = /* @__PURE__ */ new Map();
2152
+ const sweptKeepTags = /* @__PURE__ */ new Set();
2153
+ for (const { rec, studyUid, seriesUid } of dicomLeaves) {
2154
+ hashRecordUids(rec, salt, uidMap, sweptKeepTags);
2155
+ rec.tags = mergeTags(rec.tags, {
2156
+ StudyInstanceUID: hashUidVia(uidMap, salt, studyUid),
2157
+ SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
2158
+ });
2159
+ }
2160
+ warnSweptKeepTags(sweptKeepTags);
2161
+ const spec = { tree: emitTree(root), uidSalt: salt };
2162
+ validateDatasetSpec(spec);
2163
+ return { spec, stats };
2164
+ }
1768
2165
  function describeDirectory(dir, options = {}) {
1769
2166
  const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
1770
2167
  const preserveUids = options.preserveUids ?? true;
2168
+ if (options.captureTree) {
2169
+ if (!preserveUids) {
2170
+ throw new Error(
2171
+ "describe: captureTree requires preserveUids \u2014 the captured tree carries its grouping as hashed UID tags"
2172
+ );
2173
+ }
2174
+ return describeTree(dir, options, keepKeywords);
2175
+ }
1771
2176
  const uidMap = /* @__PURE__ */ new Map();
1772
2177
  const useRecords = keepKeywords.length > 0 || preserveUids;
1773
2178
  const tolerance = options.sizeTolerance ?? 0;
@@ -1777,21 +2182,19 @@ function describeDirectory(dir, options = {}) {
1777
2182
  );
1778
2183
  }
1779
2184
  const studies = /* @__PURE__ */ new Map();
1780
- const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
2185
+ const stats = {
2186
+ dicomFiles: 0,
2187
+ skipped: 0,
2188
+ unknownModality: 0,
2189
+ nonDicomFiles: 0
2190
+ };
1781
2191
  for (const path of [...walkFiles(dir)].sort()) {
1782
- let size;
1783
- try {
1784
- size = statSync(path).size;
1785
- } catch {
2192
+ const scanned = scanFile(path);
2193
+ if (!scanned) {
1786
2194
  stats.skipped++;
1787
2195
  continue;
1788
2196
  }
1789
- if (size > MAX_STREAM_BYTES) {
1790
- stats.skipped++;
1791
- continue;
1792
- }
1793
- const large = size > MAX_PIXEL_BYTES;
1794
- const record = large ? readHeaderOnly(path) : parseFull(path);
2197
+ const { size, large, record } = scanned;
1795
2198
  const studyUid = record?.StudyInstanceUID;
1796
2199
  const seriesUid = record?.SeriesInstanceUID;
1797
2200
  if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
@@ -1799,14 +2202,9 @@ function describeDirectory(dir, options = {}) {
1799
2202
  continue;
1800
2203
  }
1801
2204
  stats.dicomFiles++;
1802
- let modality;
1803
- if (typeof record.Modality === "string") {
1804
- if (SUPPORTED_MODALITIES.has(record.Modality)) {
1805
- modality = record.Modality;
1806
- } else {
1807
- stats.unknownModality++;
1808
- }
1809
- }
2205
+ const preset = presetModalityOf(record);
2206
+ if (preset === "unsupported") stats.unknownModality++;
2207
+ const modality = preset === "unsupported" ? void 0 : preset;
1810
2208
  const keptTags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
1811
2209
  const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
1812
2210
  const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
@@ -1840,25 +2238,11 @@ function describeDirectory(dir, options = {}) {
1840
2238
  for (const series of study.values()) {
1841
2239
  if (!Array.isArray(series)) continue;
1842
2240
  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
- }
2241
+ hashRecordUids(rec, salt, uidMap, sweptKeepTags);
1854
2242
  }
1855
2243
  }
1856
2244
  }
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
- }
2245
+ warnSweptKeepTags(sweptKeepTags);
1862
2246
  }
1863
2247
  const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
1864
2248
  ...spec2,