dicom-synth 1.15.0 → 1.17.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]>] [--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>] [--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' +
@@ -14,6 +14,9 @@ function usage() {
14
14
  'UIDs are preserved by default as hash-derived synthetic UIDs, keeping\n' +
15
15
  'cross-file links (FrameOfReferenceUID etc.) without emitting any original\n' +
16
16
  'UID. Other tags are not copied unless named.\n' +
17
+ '--tree captures the real directory structure as a generic tree spec\n' +
18
+ '(anonymised names, sized non-DICOM placeholders included) instead of\n' +
19
+ 'the compact studies/series form. Requires UID preservation.\n' +
17
20
  '--keep-tags copies the named tags (DICOM keywords or 8-hex tags)\n' +
18
21
  "verbatim; if a kept tag holds PHI, handling it is the caller's\n" +
19
22
  "responsibility, not this tool's.\n" +
@@ -48,6 +51,7 @@ let sizeTolerance
48
51
  let preserveUids = true
49
52
  let uidSalt
50
53
  let gzip = false
54
+ let captureTree = false
51
55
  const keepTags = []
52
56
 
53
57
  for (let i = 0; i < args.length; i++) {
@@ -75,6 +79,8 @@ for (let i = 0; i < args.length; i++) {
75
79
  }
76
80
  } else if (args[i] === '--no-preserve-uids') {
77
81
  preserveUids = false
82
+ } else if (args[i] === '--tree') {
83
+ captureTree = true
78
84
  } else if (args[i] === '--uid-salt' && args[i + 1]) {
79
85
  uidSalt = args[++i]
80
86
  } else if (args[i] === '--gzip') {
@@ -104,6 +110,7 @@ try {
104
110
  ...(sizeTolerance !== undefined ? { sizeTolerance } : {}),
105
111
  preserveUids,
106
112
  ...(uidSalt !== undefined ? { uidSalt } : {}),
113
+ ...(captureTree ? { captureTree: true } : {}),
107
114
  })
108
115
  } catch (err) {
109
116
  console.error(`Error: ${err.message}`)
@@ -119,10 +126,15 @@ if (outPath) {
119
126
  process.stdout.write(compress ? gzipSync(json) : json)
120
127
  }
121
128
 
122
- const { dicomFiles, skipped, unknownModality } = result.stats
129
+ const { dicomFiles, skipped, unknownModality, nonDicomFiles } = result.stats
123
130
  console.error(
124
131
  `Described ${dicomFiles} DICOM file(s); skipped ${skipped} non-DICOM/ungroupable file(s)`,
125
132
  )
133
+ if (nonDicomFiles > 0) {
134
+ console.error(
135
+ ` ${nonDicomFiles} non-DICOM file(s) captured as sized placeholders`,
136
+ )
137
+ }
126
138
  if (result.spec.uidSalt) {
127
139
  console.error(
128
140
  ` UIDs hash-derived with salt ${result.spec.uidSalt} (reuse via --uid-salt to reproduce)`,
@@ -56,6 +56,12 @@ function isImageType(type) {
56
56
  return TYPE_CAPABILITIES[type].image;
57
57
  }
58
58
  var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
59
+ function isRawTagValue(value) {
60
+ return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.vr === "string";
61
+ }
62
+ function positionalName(prefix, ordinal) {
63
+ return `${prefix}-${String(ordinal).padStart(3, "0")}`;
64
+ }
59
65
 
60
66
  // src/loadDcmjs.ts
61
67
  import dcmjsDefaultImport, * as dcmjsNamespace from "dcmjs";
@@ -145,20 +151,51 @@ function buildTagToKeyword() {
145
151
  return map;
146
152
  }
147
153
  var tagToKeyword = buildTagToKeyword();
154
+ function toRawElement(raw) {
155
+ const value = raw.vr === "SQ" ? raw.value.map(
156
+ (item) => Object.fromEntries(
157
+ Object.entries(item).map(([tag, nested]) => [
158
+ tag.toUpperCase(),
159
+ toRawElement(nested)
160
+ ])
161
+ )
162
+ ) : raw.value;
163
+ return { vr: raw.vr, Value: Array.isArray(value) ? value : [value] };
164
+ }
148
165
  function applyTagOverrides(dataset, tags) {
149
- if (!tags) return;
166
+ if (!tags) return void 0;
167
+ let raw;
150
168
  for (const [key, value] of Object.entries(tags)) {
151
- if (HEX_TAG_RE.test(key)) {
169
+ if (isRawTagValue(value)) {
170
+ raw ?? (raw = {});
171
+ raw[key.toUpperCase()] = toRawElement(value);
172
+ } else if (HEX_TAG_RE.test(key)) {
152
173
  const keyword = tagToKeyword.get(key.toUpperCase());
153
- if (keyword) {
154
- dataset[keyword] = value;
155
- } else {
156
- console.warn(`dicom-synth: unknown hex tag "${key}" \u2014 skipping`);
174
+ if (!keyword) {
175
+ throw new Error(
176
+ `dicom-synth: unknown hex tag "${key}" \u2014 dictionary tags take plain values; private/unknown tags require the raw { vr, value } form`
177
+ );
157
178
  }
179
+ dataset[keyword] = value;
158
180
  } else {
159
181
  dataset[key] = value;
160
182
  }
161
183
  }
184
+ return raw;
185
+ }
186
+ function rawScalarString(rawElements, tag) {
187
+ const value = rawElements?.[tag]?.Value;
188
+ return value?.length === 1 && typeof value[0] === "string" ? value[0] : void 0;
189
+ }
190
+ function syncMetaWithDataset(meta, dataset, rawElements) {
191
+ const sopInstance = rawScalarString(rawElements, "00080018") ?? (typeof dataset.SOPInstanceUID === "string" ? dataset.SOPInstanceUID : void 0);
192
+ if (sopInstance !== void 0) {
193
+ meta.MediaStorageSOPInstanceUID = sopInstance;
194
+ }
195
+ const sopClass = rawScalarString(rawElements, "00080016") ?? (typeof dataset.SOPClassUID === "string" ? dataset.SOPClassUID : void 0);
196
+ if (sopClass !== void 0) {
197
+ meta.MediaStorageSOPClassUID = sopClass;
198
+ }
162
199
  }
163
200
  function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
164
201
  return {
@@ -197,7 +234,7 @@ function buildBaseImageDataset(uid, modality) {
197
234
  ...preset.attributes
198
235
  };
199
236
  }
200
- function serializeDict(meta, dataset) {
237
+ function serializeDict(meta, dataset, rawElements) {
201
238
  const dicomDict = new dcmjs.data.DicomDict(
202
239
  dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
203
240
  meta
@@ -206,14 +243,17 @@ function serializeDict(meta, dataset) {
206
243
  dicomDict.dict = dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
207
244
  dataset
208
245
  );
246
+ if (rawElements) {
247
+ Object.assign(dicomDict.dict, rawElements);
248
+ }
209
249
  return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
210
250
  }
211
251
  var MAX_DIMENSION = 65535;
212
- function applySizing(dataset, meta, spec) {
252
+ function applySizing(dataset, meta, spec, rawElements) {
213
253
  if (spec.targetSizeKb !== void 0) {
214
254
  const clampDim = (n) => Math.min(MAX_DIMENSION, Math.max(1, Math.round(n)));
215
255
  const minimalPixelBytes = dataset.PixelData.byteLength;
216
- const overhead = serializeDict(meta, dataset).length - minimalPixelBytes;
256
+ const overhead = serializeDict(meta, dataset, rawElements).length - minimalPixelBytes;
217
257
  const cells = Math.max(
218
258
  1,
219
259
  Math.round((spec.targetSizeKb * 1024 - overhead) / 2)
@@ -244,10 +284,11 @@ function buildImageBuffer(spec, uid) {
244
284
  dataset.Laterality = "";
245
285
  dataset.PatientWeight = "0";
246
286
  }
247
- applyTagOverrides(dataset, spec.tags);
287
+ const rawElements = applyTagOverrides(dataset, spec.tags);
248
288
  const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
249
- applySizing(dataset, meta, spec);
250
- return serializeDict(meta, dataset);
289
+ syncMetaWithDataset(meta, dataset, rawElements);
290
+ applySizing(dataset, meta, spec, rawElements);
291
+ return { buffer: serializeDict(meta, dataset, rawElements), rawElements };
251
292
  }
252
293
  function buildFakeSignatureBuffer() {
253
294
  const buf = Buffer.alloc(200, 0);
@@ -269,11 +310,11 @@ function buildBufferForSpec(spec, uid) {
269
310
  case "vendor-warnings-image":
270
311
  return buildImageBuffer(spec, uid);
271
312
  case "fake-signature":
272
- return buildFakeSignatureBuffer();
313
+ return { buffer: buildFakeSignatureBuffer() };
273
314
  case "non-dicom":
274
- return buildNonDicomBuffer(spec);
315
+ return { buffer: buildNonDicomBuffer(spec) };
275
316
  case "dicomdir":
276
- return buildDicomdirBuffer();
317
+ return { buffer: buildDicomdirBuffer() };
277
318
  case "large-image":
278
319
  throw new Error(
279
320
  "large-image cannot be generated in memory \u2014 use writeCollectionFromSpec (disk-only streaming)"
@@ -354,11 +395,12 @@ function writeNative(outPath, params, dims, zeroChunk) {
354
395
  const { modality, uid, tags } = params;
355
396
  const meta = buildMeta(uid, modality);
356
397
  const dataset = buildBaseImageDataset(uid, modality);
357
- applyTagOverrides(dataset, tags);
398
+ const rawElements = applyTagOverrides(dataset, tags);
399
+ syncMetaWithDataset(meta, dataset, rawElements);
358
400
  dataset.Rows = dims.rows;
359
401
  dataset.Columns = dims.columns;
360
402
  const minimalPixelBytes = dataset.PixelData.byteLength;
361
- const probe = serializeDict(meta, dataset);
403
+ const probe = serializeDict(meta, dataset, rawElements);
362
404
  const pixelElement = Buffer.alloc(12);
363
405
  PIXEL_DATA_OW_HEADER.copy(pixelElement);
364
406
  pixelElement.writeUInt32LE(minimalPixelBytes, 8);
@@ -387,10 +429,11 @@ function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk
387
429
  TransferSyntaxUID: RLE_TRANSFER_SYNTAX_UID
388
430
  };
389
431
  const dataset = buildBaseImageDataset(uid, modality);
390
- applyTagOverrides(dataset, tags);
432
+ const rawElements = applyTagOverrides(dataset, tags);
433
+ syncMetaWithDataset(meta, dataset, rawElements);
391
434
  dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
392
435
  dataset._vrMap = { PixelData: "OB" };
393
- const probe = serializeDict(meta, dataset);
436
+ const probe = serializeDict(meta, dataset, rawElements);
394
437
  const idx = probe.indexOf(PIXEL_DATA_OB_HEADER);
395
438
  if (idx < 0 || probe.readUInt32LE(idx + 8) !== 4294967295) {
396
439
  throw new Error("failed to locate encapsulated PixelData element header");
@@ -487,7 +530,7 @@ var VIOLATION_LEVEL = {
487
530
  "missing-meta-header": "byte",
488
531
  "malformed-sq-delimiter": "byte"
489
532
  };
490
- function applyTagLevel(buffer, violations) {
533
+ function applyTagLevel(buffer, violations, rawElements) {
491
534
  if (violations.length === 0) return buffer;
492
535
  let parsed;
493
536
  try {
@@ -521,6 +564,9 @@ function applyTagLevel(buffer, violations) {
521
564
  }
522
565
  }
523
566
  parsed.dict = dcmjsAny.data.DicomMetaDictionary.denaturalizeDataset(natural);
567
+ if (rawElements) {
568
+ Object.assign(parsed.dict, rawElements);
569
+ }
524
570
  if (violations.includes("uid-too-long")) {
525
571
  ;
526
572
  parsed.dict["00080018"] = {
@@ -553,11 +599,14 @@ function applyByteLevel(buffer, violations) {
553
599
  }
554
600
  return result;
555
601
  }
556
- function applyViolations(buffer, violations) {
602
+ function applyViolations(buffer, violations, rawElements) {
557
603
  if (violations.length === 0) return buffer;
558
604
  const tagViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "tag");
559
605
  const byteViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "byte");
560
- return applyByteLevel(applyTagLevel(buffer, tagViolations), byteViolations);
606
+ return applyByteLevel(
607
+ applyTagLevel(buffer, tagViolations, rawElements),
608
+ byteViolations
609
+ );
561
610
  }
562
611
 
563
612
  // src/collection/writer.ts
@@ -631,10 +680,11 @@ async function generateFile(spec, options) {
631
680
  ...spec,
632
681
  tags: resolveTagTemplates(spec.tags, { index, ...options?.context })
633
682
  } : spec;
634
- let buffer = buildBufferForSpec(resolvedSpec, uid);
683
+ const built = buildBufferForSpec(resolvedSpec, uid);
684
+ let buffer = built.buffer;
635
685
  const violations = "violations" in spec ? spec.violations : void 0;
636
686
  if (violations?.length) {
637
- buffer = applyViolations(buffer, violations);
687
+ buffer = applyViolations(buffer, violations, built.rawElements);
638
688
  }
639
689
  const name = filename(spec.type, index, padWidth);
640
690
  return {
@@ -723,7 +773,7 @@ function* planTree(nodes, scope, env) {
723
773
  for (const node of nodes) {
724
774
  if (isDirNode(node)) {
725
775
  dirOrdinal++;
726
- const dirName = node.name ?? `dir-${String(dirOrdinal).padStart(3, "0")}`;
776
+ const dirName = node.name ?? positionalName("dir", dirOrdinal);
727
777
  const child = {
728
778
  ...scope,
729
779
  dirs: [...scope.dirs, dirName],
@@ -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";
@@ -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)) {
@@ -431,6 +537,9 @@ function validateStudy(study, path) {
431
537
  if ("tags" in st) validateTags(st.tags, `${path}.tags`);
432
538
  return st;
433
539
  }
540
+ function positionalName(prefix, ordinal) {
541
+ return `${prefix}-${String(ordinal).padStart(3, "0")}`;
542
+ }
434
543
  var VALID_TREE_ROLES = {
435
544
  study: true,
436
545
  series: true
@@ -511,7 +620,7 @@ function validateTreeChildren(children, basePath, inStudy, inSeries) {
511
620
  let name = "name" in n ? n.name : void 0;
512
621
  if ("children" in n) {
513
622
  dirOrdinal++;
514
- name ?? (name = `dir-${String(dirOrdinal).padStart(3, "0")}`);
623
+ name ?? (name = positionalName("dir", dirOrdinal));
515
624
  }
516
625
  if (name === void 0) return;
517
626
  const prior = claimed.get(name);
@@ -850,6 +959,37 @@ function mergeTags(a, b) {
850
959
  if (!b) return a;
851
960
  return { ...a, ...b };
852
961
  }
962
+ function scanFile(path) {
963
+ let size;
964
+ try {
965
+ size = statSync(path).size;
966
+ } catch {
967
+ return null;
968
+ }
969
+ if (size > MAX_STREAM_BYTES) return null;
970
+ const large = size > MAX_PIXEL_BYTES;
971
+ return { size, large, record: large ? readHeaderOnly(path) : parseFull(path) };
972
+ }
973
+ function presetModalityOf(record) {
974
+ if (typeof record.Modality !== "string") return void 0;
975
+ return SUPPORTED_MODALITIES.has(record.Modality) ? record.Modality : "unsupported";
976
+ }
977
+ function hashRecordUids(rec, salt, uidMap, sweptKeepTags) {
978
+ if (!rec.uidOriginals) return;
979
+ if (rec.tags) {
980
+ for (const keyword of Object.keys(rec.uidOriginals)) {
981
+ if (keyword in rec.tags) sweptKeepTags.add(keyword);
982
+ }
983
+ }
984
+ rec.tags = mergeTags(rec.tags, hashUidTags(rec.uidOriginals, salt, uidMap));
985
+ }
986
+ function warnSweptKeepTags(sweptKeepTags) {
987
+ for (const keyword of sweptKeepTags) {
988
+ console.warn(
989
+ `describe: keep-tag "${keyword}" contains UID references \u2014 kept as its hashed UID skeleton, not verbatim (disable with preserveUids: false)`
990
+ );
991
+ }
992
+ }
853
993
  function sizeKbOf(bytes) {
854
994
  return Math.max(1, Math.round(bytes / 1024));
855
995
  }
@@ -952,9 +1092,128 @@ function buildSeriesSpec(records) {
952
1092
  function seriesSpecFromAccum(accum) {
953
1093
  return Array.isArray(accum) ? buildSeriesSpec(accum) : { entries: [...accum.values()].map(entryOf) };
954
1094
  }
1095
+ function dirAccumFor(root, dirs) {
1096
+ let node = root;
1097
+ for (const name of dirs) {
1098
+ let child = node.dirs.get(name);
1099
+ if (!child) {
1100
+ child = { dirs: /* @__PURE__ */ new Map(), files: [] };
1101
+ node.dirs.set(name, child);
1102
+ }
1103
+ node = child;
1104
+ }
1105
+ return node;
1106
+ }
1107
+ var SAFE_EXTENSION_RE = /^\.[A-Za-z0-9]{1,8}$/;
1108
+ function safeExtension(path) {
1109
+ const ext = extname(basename(path));
1110
+ return SAFE_EXTENSION_RE.test(ext) ? ext : "";
1111
+ }
1112
+ function emitTree(acc) {
1113
+ const nodes = [];
1114
+ let otherOrdinal = 0;
1115
+ for (const leaf of acc.files) {
1116
+ if (leaf.kind === "other") {
1117
+ otherOrdinal++;
1118
+ const name = `${positionalName("file", otherOrdinal)}${leaf.ext}`;
1119
+ nodes.push(
1120
+ leaf.bytes > 0 ? { type: "non-dicom", name, targetBytes: leaf.bytes } : { type: "non-dicom", name, content: "" }
1121
+ );
1122
+ } else {
1123
+ nodes.push(entryOf({ ...leaf.rec, count: 1 }));
1124
+ }
1125
+ }
1126
+ for (const sub of acc.dirs.values()) {
1127
+ nodes.push({ children: emitTree(sub) });
1128
+ }
1129
+ return nodes;
1130
+ }
1131
+ function describeTree(dir, options, keepKeywords) {
1132
+ const root = { dirs: /* @__PURE__ */ new Map(), files: [] };
1133
+ const stats = {
1134
+ dicomFiles: 0,
1135
+ skipped: 0,
1136
+ unknownModality: 0,
1137
+ nonDicomFiles: 0
1138
+ };
1139
+ const studyUids = /* @__PURE__ */ new Set();
1140
+ const dicomLeaves = [];
1141
+ for (const path of [...walkFiles(dir)].sort()) {
1142
+ const scanned = scanFile(path);
1143
+ if (!scanned) {
1144
+ stats.skipped++;
1145
+ continue;
1146
+ }
1147
+ const { size, large, record } = scanned;
1148
+ const studyUid = record?.StudyInstanceUID;
1149
+ const seriesUid = record?.SeriesInstanceUID;
1150
+ const relDirs = relative(dir, path).split(sep).slice(0, -1);
1151
+ if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
1152
+ if (large) {
1153
+ stats.skipped++;
1154
+ continue;
1155
+ }
1156
+ stats.nonDicomFiles++;
1157
+ dirAccumFor(root, relDirs).files.push({
1158
+ kind: "other",
1159
+ bytes: size,
1160
+ ext: safeExtension(path)
1161
+ });
1162
+ continue;
1163
+ }
1164
+ stats.dicomFiles++;
1165
+ studyUids.add(studyUid);
1166
+ const preset = presetModalityOf(record);
1167
+ if (preset === "unsupported") stats.unknownModality++;
1168
+ const modality = preset === "unsupported" ? void 0 : preset;
1169
+ const instanceNumber = typeof record.InstanceNumber === "number" ? { InstanceNumber: record.InstanceNumber } : typeof record.InstanceNumber === "string" ? { InstanceNumber: escapeTagTemplate(record.InstanceNumber) } : void 0;
1170
+ const leaf = {
1171
+ kind: "dicom",
1172
+ rec: {
1173
+ large,
1174
+ bytes: size,
1175
+ modality,
1176
+ tags: mergeTags(
1177
+ instanceNumber,
1178
+ keepKeywords.length ? extractTags(record, keepKeywords) : void 0
1179
+ ),
1180
+ uidOriginals: collectUidOriginals(record)
1181
+ },
1182
+ studyUid,
1183
+ seriesUid
1184
+ };
1185
+ dirAccumFor(root, relDirs).files.push(leaf);
1186
+ dicomLeaves.push(leaf);
1187
+ }
1188
+ if (root.files.length === 0 && root.dirs.size === 0) {
1189
+ throw new Error(`describe: no files found in ${dir}`);
1190
+ }
1191
+ const salt = options.uidSalt ?? deriveSalt([...studyUids]);
1192
+ const uidMap = /* @__PURE__ */ new Map();
1193
+ const sweptKeepTags = /* @__PURE__ */ new Set();
1194
+ for (const { rec, studyUid, seriesUid } of dicomLeaves) {
1195
+ hashRecordUids(rec, salt, uidMap, sweptKeepTags);
1196
+ rec.tags = mergeTags(rec.tags, {
1197
+ StudyInstanceUID: hashUidVia(uidMap, salt, studyUid),
1198
+ SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
1199
+ });
1200
+ }
1201
+ warnSweptKeepTags(sweptKeepTags);
1202
+ const spec = { tree: emitTree(root), uidSalt: salt };
1203
+ validateDatasetSpec(spec);
1204
+ return { spec, stats };
1205
+ }
955
1206
  function describeDirectory(dir, options = {}) {
956
1207
  const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
957
1208
  const preserveUids = options.preserveUids ?? true;
1209
+ if (options.captureTree) {
1210
+ if (!preserveUids) {
1211
+ throw new Error(
1212
+ "describe: captureTree requires preserveUids \u2014 the captured tree carries its grouping as hashed UID tags"
1213
+ );
1214
+ }
1215
+ return describeTree(dir, options, keepKeywords);
1216
+ }
958
1217
  const uidMap = /* @__PURE__ */ new Map();
959
1218
  const useRecords = keepKeywords.length > 0 || preserveUids;
960
1219
  const tolerance = options.sizeTolerance ?? 0;
@@ -964,21 +1223,19 @@ function describeDirectory(dir, options = {}) {
964
1223
  );
965
1224
  }
966
1225
  const studies = /* @__PURE__ */ new Map();
967
- const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
1226
+ const stats = {
1227
+ dicomFiles: 0,
1228
+ skipped: 0,
1229
+ unknownModality: 0,
1230
+ nonDicomFiles: 0
1231
+ };
968
1232
  for (const path of [...walkFiles(dir)].sort()) {
969
- let size;
970
- try {
971
- size = statSync(path).size;
972
- } catch {
973
- stats.skipped++;
974
- continue;
975
- }
976
- if (size > MAX_STREAM_BYTES) {
1233
+ const scanned = scanFile(path);
1234
+ if (!scanned) {
977
1235
  stats.skipped++;
978
1236
  continue;
979
1237
  }
980
- const large = size > MAX_PIXEL_BYTES;
981
- const record = large ? readHeaderOnly(path) : parseFull(path);
1238
+ const { size, large, record } = scanned;
982
1239
  const studyUid = record?.StudyInstanceUID;
983
1240
  const seriesUid = record?.SeriesInstanceUID;
984
1241
  if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
@@ -986,14 +1243,9 @@ function describeDirectory(dir, options = {}) {
986
1243
  continue;
987
1244
  }
988
1245
  stats.dicomFiles++;
989
- let modality;
990
- if (typeof record.Modality === "string") {
991
- if (SUPPORTED_MODALITIES.has(record.Modality)) {
992
- modality = record.Modality;
993
- } else {
994
- stats.unknownModality++;
995
- }
996
- }
1246
+ const preset = presetModalityOf(record);
1247
+ if (preset === "unsupported") stats.unknownModality++;
1248
+ const modality = preset === "unsupported" ? void 0 : preset;
997
1249
  const keptTags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
998
1250
  const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
999
1251
  const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
@@ -1027,25 +1279,11 @@ function describeDirectory(dir, options = {}) {
1027
1279
  for (const series of study.values()) {
1028
1280
  if (!Array.isArray(series)) continue;
1029
1281
  for (const rec of series) {
1030
- if (rec.uidOriginals) {
1031
- if (rec.tags) {
1032
- for (const keyword of Object.keys(rec.uidOriginals)) {
1033
- if (keyword in rec.tags) sweptKeepTags.add(keyword);
1034
- }
1035
- }
1036
- rec.tags = mergeTags(
1037
- rec.tags,
1038
- hashUidTags(rec.uidOriginals, salt, uidMap)
1039
- );
1040
- }
1282
+ hashRecordUids(rec, salt, uidMap, sweptKeepTags);
1041
1283
  }
1042
1284
  }
1043
1285
  }
1044
- for (const keyword of sweptKeepTags) {
1045
- console.warn(
1046
- `describe: keep-tag "${keyword}" contains UID references \u2014 kept as its hashed UID skeleton, not verbatim (disable with preserveUids: false)`
1047
- );
1048
- }
1286
+ warnSweptKeepTags(sweptKeepTags);
1049
1287
  }
1050
1288
  const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
1051
1289
  ...spec2,