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.
package/dist/esm/index.js CHANGED
@@ -95,6 +95,92 @@ var VALID_VIOLATIONS = {
95
95
  };
96
96
  var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
97
97
  var KEYWORD_RE = /^[A-Z][A-Za-z0-9]*$/;
98
+ var VALID_VRS = /* @__PURE__ */ new Set([
99
+ "AE",
100
+ "AS",
101
+ "AT",
102
+ "CS",
103
+ "DA",
104
+ "DS",
105
+ "DT",
106
+ "FL",
107
+ "FD",
108
+ "IS",
109
+ "LO",
110
+ "LT",
111
+ "OB",
112
+ "OD",
113
+ "OF",
114
+ "OL",
115
+ "OV",
116
+ "OW",
117
+ "PN",
118
+ "SH",
119
+ "SL",
120
+ "SQ",
121
+ "SS",
122
+ "ST",
123
+ "SV",
124
+ "TM",
125
+ "UC",
126
+ "UI",
127
+ "UL",
128
+ "UN",
129
+ "UR",
130
+ "US",
131
+ "UT",
132
+ "UV"
133
+ ]);
134
+ function isPrivateHexTag(key) {
135
+ return HEX_TAG_RE.test(key) && Number.parseInt(key.slice(0, 4), 16) % 2 === 1;
136
+ }
137
+ function isRawTagValue(value) {
138
+ return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.vr === "string";
139
+ }
140
+ function validateRawTagValue(raw, path) {
141
+ for (const key of Object.keys(raw)) {
142
+ if (key !== "vr" && key !== "value") {
143
+ throw new Error(
144
+ `${path}: raw tag form accepts only "vr" and "value"; got "${key}"`
145
+ );
146
+ }
147
+ }
148
+ const vr = raw.vr;
149
+ if (!VALID_VRS.has(vr)) {
150
+ throw new Error(`${path}.vr: "${vr}" is not a standard DICOM VR`);
151
+ }
152
+ if (!("value" in raw)) {
153
+ throw new Error(`${path}.value: is required in the raw tag form`);
154
+ }
155
+ if (vr === "SQ") {
156
+ if (!Array.isArray(raw.value)) {
157
+ throw new Error(`${path}.value: an SQ raw tag requires an array of items`);
158
+ }
159
+ raw.value.forEach((item, i) => {
160
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
161
+ throw new Error(
162
+ `${path}.value[${i}]: must be an object of hex-tag entries`
163
+ );
164
+ }
165
+ for (const [tag, nested] of Object.entries(item)) {
166
+ if (!HEX_TAG_RE.test(tag)) {
167
+ throw new Error(
168
+ `${path}.value[${i}]: invalid item tag "${tag}" \u2014 SQ items use 8-hex-char tags`
169
+ );
170
+ }
171
+ if (!isRawTagValue(nested)) {
172
+ throw new Error(
173
+ `${path}.value[${i}].${tag}: SQ item entries must use the raw { vr, value } form`
174
+ );
175
+ }
176
+ validateRawTagValue(
177
+ nested,
178
+ `${path}.value[${i}].${tag}`
179
+ );
180
+ }
181
+ });
182
+ }
183
+ }
98
184
  function validatePositiveInt(value, path) {
99
185
  if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
100
186
  throw new Error(
@@ -299,13 +385,33 @@ function validateEntry(entry, path) {
299
385
  return e;
300
386
  }
301
387
  var MODALITY_TAG = "00080060";
302
- function validateTagValue(key, value, path) {
388
+ function validateModalityGuard(key, candidate, path) {
303
389
  const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
304
- if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
390
+ if (isModalityKey && typeof candidate === "string" && Object.hasOwn(VALID_MODALITIES, candidate)) {
305
391
  throw new Error(
306
392
  `${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`
307
393
  );
308
394
  }
395
+ }
396
+ function validateTagValue(key, value, path) {
397
+ if (isRawTagValue(value)) {
398
+ if (!HEX_TAG_RE.test(key)) {
399
+ throw new Error(
400
+ `${path}.${key}: the raw { vr, value } form requires an 8-hex-char tag key \u2014 keyword tags take plain values`
401
+ );
402
+ }
403
+ validateRawTagValue(value, `${path}.${key}`);
404
+ const raw = value.value;
405
+ const scalar = Array.isArray(raw) && raw.length === 1 ? raw[0] : raw;
406
+ validateModalityGuard(key, scalar, path);
407
+ return;
408
+ }
409
+ if (isPrivateHexTag(key)) {
410
+ throw new Error(
411
+ `${path}.${key}: private tags have no dictionary VR \u2014 use the raw form, e.g. { "vr": "LO", "value": \u2026 }`
412
+ );
413
+ }
414
+ validateModalityGuard(key, value, path);
309
415
  if (typeof value === "string") {
310
416
  for (const name of findTemplateTokens(value)) {
311
417
  if (!TEMPLATE_VOCAB.includes(name)) {
@@ -441,6 +547,9 @@ function validateStudy(study, path) {
441
547
  if ("tags" in st) validateTags(st.tags, `${path}.tags`);
442
548
  return st;
443
549
  }
550
+ function positionalName(prefix, ordinal) {
551
+ return `${prefix}-${String(ordinal).padStart(3, "0")}`;
552
+ }
444
553
  var VALID_TREE_ROLES = {
445
554
  study: true,
446
555
  series: true
@@ -521,7 +630,7 @@ function validateTreeChildren(children, basePath, inStudy, inSeries) {
521
630
  let name = "name" in n ? n.name : void 0;
522
631
  if ("children" in n) {
523
632
  dirOrdinal++;
524
- name ?? (name = `dir-${String(dirOrdinal).padStart(3, "0")}`);
633
+ name ?? (name = positionalName("dir", dirOrdinal));
525
634
  }
526
635
  if (name === void 0) return;
527
636
  const prior = claimed.get(name);
@@ -799,20 +908,51 @@ function buildTagToKeyword() {
799
908
  return map;
800
909
  }
801
910
  var tagToKeyword = buildTagToKeyword();
911
+ function toRawElement(raw) {
912
+ const value = raw.vr === "SQ" ? raw.value.map(
913
+ (item) => Object.fromEntries(
914
+ Object.entries(item).map(([tag, nested]) => [
915
+ tag.toUpperCase(),
916
+ toRawElement(nested)
917
+ ])
918
+ )
919
+ ) : raw.value;
920
+ return { vr: raw.vr, Value: Array.isArray(value) ? value : [value] };
921
+ }
802
922
  function applyTagOverrides(dataset, tags) {
803
- if (!tags) return;
923
+ if (!tags) return void 0;
924
+ let raw;
804
925
  for (const [key, value] of Object.entries(tags)) {
805
- if (HEX_TAG_RE.test(key)) {
926
+ if (isRawTagValue(value)) {
927
+ raw ?? (raw = {});
928
+ raw[key.toUpperCase()] = toRawElement(value);
929
+ } else if (HEX_TAG_RE.test(key)) {
806
930
  const keyword = tagToKeyword.get(key.toUpperCase());
807
- if (keyword) {
808
- dataset[keyword] = value;
809
- } else {
810
- console.warn(`dicom-synth: unknown hex tag "${key}" \u2014 skipping`);
931
+ if (!keyword) {
932
+ throw new Error(
933
+ `dicom-synth: unknown hex tag "${key}" \u2014 dictionary tags take plain values; private/unknown tags require the raw { vr, value } form`
934
+ );
811
935
  }
936
+ dataset[keyword] = value;
812
937
  } else {
813
938
  dataset[key] = value;
814
939
  }
815
940
  }
941
+ return raw;
942
+ }
943
+ function rawScalarString(rawElements, tag) {
944
+ const value = rawElements?.[tag]?.Value;
945
+ return value?.length === 1 && typeof value[0] === "string" ? value[0] : void 0;
946
+ }
947
+ function syncMetaWithDataset(meta, dataset, rawElements) {
948
+ const sopInstance = rawScalarString(rawElements, "00080018") ?? (typeof dataset.SOPInstanceUID === "string" ? dataset.SOPInstanceUID : void 0);
949
+ if (sopInstance !== void 0) {
950
+ meta.MediaStorageSOPInstanceUID = sopInstance;
951
+ }
952
+ const sopClass = rawScalarString(rawElements, "00080016") ?? (typeof dataset.SOPClassUID === "string" ? dataset.SOPClassUID : void 0);
953
+ if (sopClass !== void 0) {
954
+ meta.MediaStorageSOPClassUID = sopClass;
955
+ }
816
956
  }
817
957
  function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
818
958
  return {
@@ -851,7 +991,7 @@ function buildBaseImageDataset(uid, modality) {
851
991
  ...preset.attributes
852
992
  };
853
993
  }
854
- function serializeDict(meta, dataset) {
994
+ function serializeDict(meta, dataset, rawElements) {
855
995
  const dicomDict = new dcmjs.data.DicomDict(
856
996
  dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
857
997
  meta
@@ -860,14 +1000,17 @@ function serializeDict(meta, dataset) {
860
1000
  dicomDict.dict = dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
861
1001
  dataset
862
1002
  );
1003
+ if (rawElements) {
1004
+ Object.assign(dicomDict.dict, rawElements);
1005
+ }
863
1006
  return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
864
1007
  }
865
1008
  var MAX_DIMENSION = 65535;
866
- function applySizing(dataset, meta, spec) {
1009
+ function applySizing(dataset, meta, spec, rawElements) {
867
1010
  if (spec.targetSizeKb !== void 0) {
868
1011
  const clampDim = (n) => Math.min(MAX_DIMENSION, Math.max(1, Math.round(n)));
869
1012
  const minimalPixelBytes = dataset.PixelData.byteLength;
870
- const overhead = serializeDict(meta, dataset).length - minimalPixelBytes;
1013
+ const overhead = serializeDict(meta, dataset, rawElements).length - minimalPixelBytes;
871
1014
  const cells = Math.max(
872
1015
  1,
873
1016
  Math.round((spec.targetSizeKb * 1024 - overhead) / 2)
@@ -898,10 +1041,11 @@ function buildImageBuffer(spec, uid) {
898
1041
  dataset.Laterality = "";
899
1042
  dataset.PatientWeight = "0";
900
1043
  }
901
- applyTagOverrides(dataset, spec.tags);
1044
+ const rawElements = applyTagOverrides(dataset, spec.tags);
902
1045
  const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
903
- applySizing(dataset, meta, spec);
904
- return serializeDict(meta, dataset);
1046
+ syncMetaWithDataset(meta, dataset, rawElements);
1047
+ applySizing(dataset, meta, spec, rawElements);
1048
+ return { buffer: serializeDict(meta, dataset, rawElements), rawElements };
905
1049
  }
906
1050
  function buildFakeSignatureBuffer() {
907
1051
  const buf = Buffer.alloc(200, 0);
@@ -923,11 +1067,11 @@ function buildBufferForSpec(spec, uid) {
923
1067
  case "vendor-warnings-image":
924
1068
  return buildImageBuffer(spec, uid);
925
1069
  case "fake-signature":
926
- return buildFakeSignatureBuffer();
1070
+ return { buffer: buildFakeSignatureBuffer() };
927
1071
  case "non-dicom":
928
- return buildNonDicomBuffer(spec);
1072
+ return { buffer: buildNonDicomBuffer(spec) };
929
1073
  case "dicomdir":
930
- return buildDicomdirBuffer();
1074
+ return { buffer: buildDicomdirBuffer() };
931
1075
  case "large-image":
932
1076
  throw new Error(
933
1077
  "large-image cannot be generated in memory \u2014 use writeCollectionFromSpec (disk-only streaming)"
@@ -1019,11 +1163,12 @@ function writeNative(outPath, params, dims, zeroChunk) {
1019
1163
  const { modality, uid, tags } = params;
1020
1164
  const meta = buildMeta(uid, modality);
1021
1165
  const dataset = buildBaseImageDataset(uid, modality);
1022
- applyTagOverrides(dataset, tags);
1166
+ const rawElements = applyTagOverrides(dataset, tags);
1167
+ syncMetaWithDataset(meta, dataset, rawElements);
1023
1168
  dataset.Rows = dims.rows;
1024
1169
  dataset.Columns = dims.columns;
1025
1170
  const minimalPixelBytes = dataset.PixelData.byteLength;
1026
- const probe = serializeDict(meta, dataset);
1171
+ const probe = serializeDict(meta, dataset, rawElements);
1027
1172
  const pixelElement = Buffer.alloc(12);
1028
1173
  PIXEL_DATA_OW_HEADER.copy(pixelElement);
1029
1174
  pixelElement.writeUInt32LE(minimalPixelBytes, 8);
@@ -1052,10 +1197,11 @@ function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk
1052
1197
  TransferSyntaxUID: RLE_TRANSFER_SYNTAX_UID
1053
1198
  };
1054
1199
  const dataset = buildBaseImageDataset(uid, modality);
1055
- applyTagOverrides(dataset, tags);
1200
+ const rawElements = applyTagOverrides(dataset, tags);
1201
+ syncMetaWithDataset(meta, dataset, rawElements);
1056
1202
  dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
1057
1203
  dataset._vrMap = { PixelData: "OB" };
1058
- const probe = serializeDict(meta, dataset);
1204
+ const probe = serializeDict(meta, dataset, rawElements);
1059
1205
  const idx = probe.indexOf(PIXEL_DATA_OB_HEADER);
1060
1206
  if (idx < 0 || probe.readUInt32LE(idx + 8) !== 4294967295) {
1061
1207
  throw new Error("failed to locate encapsulated PixelData element header");
@@ -1173,7 +1319,7 @@ var VIOLATION_LEVEL = {
1173
1319
  "missing-meta-header": "byte",
1174
1320
  "malformed-sq-delimiter": "byte"
1175
1321
  };
1176
- function applyTagLevel(buffer, violations) {
1322
+ function applyTagLevel(buffer, violations, rawElements) {
1177
1323
  if (violations.length === 0) return buffer;
1178
1324
  let parsed;
1179
1325
  try {
@@ -1207,6 +1353,9 @@ function applyTagLevel(buffer, violations) {
1207
1353
  }
1208
1354
  }
1209
1355
  parsed.dict = dcmjsAny.data.DicomMetaDictionary.denaturalizeDataset(natural);
1356
+ if (rawElements) {
1357
+ Object.assign(parsed.dict, rawElements);
1358
+ }
1210
1359
  if (violations.includes("uid-too-long")) {
1211
1360
  ;
1212
1361
  parsed.dict["00080018"] = {
@@ -1239,11 +1388,14 @@ function applyByteLevel(buffer, violations) {
1239
1388
  }
1240
1389
  return result;
1241
1390
  }
1242
- function applyViolations(buffer, violations) {
1391
+ function applyViolations(buffer, violations, rawElements) {
1243
1392
  if (violations.length === 0) return buffer;
1244
1393
  const tagViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "tag");
1245
1394
  const byteViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "byte");
1246
- return applyByteLevel(applyTagLevel(buffer, tagViolations), byteViolations);
1395
+ return applyByteLevel(
1396
+ applyTagLevel(buffer, tagViolations, rawElements),
1397
+ byteViolations
1398
+ );
1247
1399
  }
1248
1400
 
1249
1401
  // src/collection/writer.ts
@@ -1317,10 +1469,11 @@ async function generateFile(spec, options) {
1317
1469
  ...spec,
1318
1470
  tags: resolveTagTemplates(spec.tags, { index, ...options?.context })
1319
1471
  } : spec;
1320
- let buffer = buildBufferForSpec(resolvedSpec, uid);
1472
+ const built = buildBufferForSpec(resolvedSpec, uid);
1473
+ let buffer = built.buffer;
1321
1474
  const violations = "violations" in spec ? spec.violations : void 0;
1322
1475
  if (violations?.length) {
1323
- buffer = applyViolations(buffer, violations);
1476
+ buffer = applyViolations(buffer, violations, built.rawElements);
1324
1477
  }
1325
1478
  const name = filename(spec.type, index, padWidth);
1326
1479
  return {
@@ -1409,7 +1562,7 @@ function* planTree(nodes, scope, env) {
1409
1562
  for (const node of nodes) {
1410
1563
  if (isDirNode(node)) {
1411
1564
  dirOrdinal++;
1412
- const dirName = node.name ?? `dir-${String(dirOrdinal).padStart(3, "0")}`;
1565
+ const dirName = node.name ?? positionalName("dir", dirOrdinal);
1413
1566
  const child = {
1414
1567
  ...scope,
1415
1568
  dirs: [...scope.dirs, dirName],
@@ -1640,7 +1793,7 @@ import {
1640
1793
  readSync,
1641
1794
  statSync
1642
1795
  } from "node:fs";
1643
- import { join } from "node:path";
1796
+ import { basename, extname, join, relative, sep } from "node:path";
1644
1797
  var dcmjsAny2 = dcmjs;
1645
1798
  var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
1646
1799
  var SUPPORTED_MODALITIES = /* @__PURE__ */ new Set(["CT", "PT", "MR", "CR"]);
@@ -1904,6 +2057,37 @@ function mergeTags(a, b) {
1904
2057
  if (!b) return a;
1905
2058
  return { ...a, ...b };
1906
2059
  }
2060
+ function scanFile(path) {
2061
+ let size;
2062
+ try {
2063
+ size = statSync(path).size;
2064
+ } catch {
2065
+ return null;
2066
+ }
2067
+ if (size > MAX_STREAM_BYTES) return null;
2068
+ const large = size > MAX_PIXEL_BYTES;
2069
+ return { size, large, record: large ? readHeaderOnly(path) : parseFull(path) };
2070
+ }
2071
+ function presetModalityOf(record) {
2072
+ if (typeof record.Modality !== "string") return void 0;
2073
+ return SUPPORTED_MODALITIES.has(record.Modality) ? record.Modality : "unsupported";
2074
+ }
2075
+ function hashRecordUids(rec, salt, uidMap, sweptKeepTags) {
2076
+ if (!rec.uidOriginals) return;
2077
+ if (rec.tags) {
2078
+ for (const keyword of Object.keys(rec.uidOriginals)) {
2079
+ if (keyword in rec.tags) sweptKeepTags.add(keyword);
2080
+ }
2081
+ }
2082
+ rec.tags = mergeTags(rec.tags, hashUidTags(rec.uidOriginals, salt, uidMap));
2083
+ }
2084
+ function warnSweptKeepTags(sweptKeepTags) {
2085
+ for (const keyword of sweptKeepTags) {
2086
+ console.warn(
2087
+ `describe: keep-tag "${keyword}" contains UID references \u2014 kept as its hashed UID skeleton, not verbatim (disable with preserveUids: false)`
2088
+ );
2089
+ }
2090
+ }
1907
2091
  function sizeKbOf(bytes) {
1908
2092
  return Math.max(1, Math.round(bytes / 1024));
1909
2093
  }
@@ -2006,9 +2190,128 @@ function buildSeriesSpec(records) {
2006
2190
  function seriesSpecFromAccum(accum) {
2007
2191
  return Array.isArray(accum) ? buildSeriesSpec(accum) : { entries: [...accum.values()].map(entryOf) };
2008
2192
  }
2193
+ function dirAccumFor(root, dirs) {
2194
+ let node = root;
2195
+ for (const name of dirs) {
2196
+ let child = node.dirs.get(name);
2197
+ if (!child) {
2198
+ child = { dirs: /* @__PURE__ */ new Map(), files: [] };
2199
+ node.dirs.set(name, child);
2200
+ }
2201
+ node = child;
2202
+ }
2203
+ return node;
2204
+ }
2205
+ var SAFE_EXTENSION_RE = /^\.[A-Za-z0-9]{1,8}$/;
2206
+ function safeExtension(path) {
2207
+ const ext = extname(basename(path));
2208
+ return SAFE_EXTENSION_RE.test(ext) ? ext : "";
2209
+ }
2210
+ function emitTree(acc) {
2211
+ const nodes = [];
2212
+ let otherOrdinal = 0;
2213
+ for (const leaf of acc.files) {
2214
+ if (leaf.kind === "other") {
2215
+ otherOrdinal++;
2216
+ const name = `${positionalName("file", otherOrdinal)}${leaf.ext}`;
2217
+ nodes.push(
2218
+ leaf.bytes > 0 ? { type: "non-dicom", name, targetBytes: leaf.bytes } : { type: "non-dicom", name, content: "" }
2219
+ );
2220
+ } else {
2221
+ nodes.push(entryOf({ ...leaf.rec, count: 1 }));
2222
+ }
2223
+ }
2224
+ for (const sub of acc.dirs.values()) {
2225
+ nodes.push({ children: emitTree(sub) });
2226
+ }
2227
+ return nodes;
2228
+ }
2229
+ function describeTree(dir, options, keepKeywords) {
2230
+ const root = { dirs: /* @__PURE__ */ new Map(), files: [] };
2231
+ const stats = {
2232
+ dicomFiles: 0,
2233
+ skipped: 0,
2234
+ unknownModality: 0,
2235
+ nonDicomFiles: 0
2236
+ };
2237
+ const studyUids = /* @__PURE__ */ new Set();
2238
+ const dicomLeaves = [];
2239
+ for (const path of [...walkFiles(dir)].sort()) {
2240
+ const scanned = scanFile(path);
2241
+ if (!scanned) {
2242
+ stats.skipped++;
2243
+ continue;
2244
+ }
2245
+ const { size, large, record } = scanned;
2246
+ const studyUid = record?.StudyInstanceUID;
2247
+ const seriesUid = record?.SeriesInstanceUID;
2248
+ const relDirs = relative(dir, path).split(sep).slice(0, -1);
2249
+ if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
2250
+ if (large) {
2251
+ stats.skipped++;
2252
+ continue;
2253
+ }
2254
+ stats.nonDicomFiles++;
2255
+ dirAccumFor(root, relDirs).files.push({
2256
+ kind: "other",
2257
+ bytes: size,
2258
+ ext: safeExtension(path)
2259
+ });
2260
+ continue;
2261
+ }
2262
+ stats.dicomFiles++;
2263
+ studyUids.add(studyUid);
2264
+ const preset = presetModalityOf(record);
2265
+ if (preset === "unsupported") stats.unknownModality++;
2266
+ const modality = preset === "unsupported" ? void 0 : preset;
2267
+ const instanceNumber = typeof record.InstanceNumber === "number" ? { InstanceNumber: record.InstanceNumber } : typeof record.InstanceNumber === "string" ? { InstanceNumber: escapeTagTemplate(record.InstanceNumber) } : void 0;
2268
+ const leaf = {
2269
+ kind: "dicom",
2270
+ rec: {
2271
+ large,
2272
+ bytes: size,
2273
+ modality,
2274
+ tags: mergeTags(
2275
+ instanceNumber,
2276
+ keepKeywords.length ? extractTags(record, keepKeywords) : void 0
2277
+ ),
2278
+ uidOriginals: collectUidOriginals(record)
2279
+ },
2280
+ studyUid,
2281
+ seriesUid
2282
+ };
2283
+ dirAccumFor(root, relDirs).files.push(leaf);
2284
+ dicomLeaves.push(leaf);
2285
+ }
2286
+ if (root.files.length === 0 && root.dirs.size === 0) {
2287
+ throw new Error(`describe: no files found in ${dir}`);
2288
+ }
2289
+ const salt = options.uidSalt ?? deriveSalt([...studyUids]);
2290
+ const uidMap = /* @__PURE__ */ new Map();
2291
+ const sweptKeepTags = /* @__PURE__ */ new Set();
2292
+ for (const { rec, studyUid, seriesUid } of dicomLeaves) {
2293
+ hashRecordUids(rec, salt, uidMap, sweptKeepTags);
2294
+ rec.tags = mergeTags(rec.tags, {
2295
+ StudyInstanceUID: hashUidVia(uidMap, salt, studyUid),
2296
+ SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
2297
+ });
2298
+ }
2299
+ warnSweptKeepTags(sweptKeepTags);
2300
+ const spec = { tree: emitTree(root), uidSalt: salt };
2301
+ validateDatasetSpec(spec);
2302
+ return { spec, stats };
2303
+ }
2009
2304
  function describeDirectory(dir, options = {}) {
2010
2305
  const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
2011
2306
  const preserveUids = options.preserveUids ?? true;
2307
+ if (options.captureTree) {
2308
+ if (!preserveUids) {
2309
+ throw new Error(
2310
+ "describe: captureTree requires preserveUids \u2014 the captured tree carries its grouping as hashed UID tags"
2311
+ );
2312
+ }
2313
+ return describeTree(dir, options, keepKeywords);
2314
+ }
2012
2315
  const uidMap = /* @__PURE__ */ new Map();
2013
2316
  const useRecords = keepKeywords.length > 0 || preserveUids;
2014
2317
  const tolerance = options.sizeTolerance ?? 0;
@@ -2018,21 +2321,19 @@ function describeDirectory(dir, options = {}) {
2018
2321
  );
2019
2322
  }
2020
2323
  const studies = /* @__PURE__ */ new Map();
2021
- const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
2324
+ const stats = {
2325
+ dicomFiles: 0,
2326
+ skipped: 0,
2327
+ unknownModality: 0,
2328
+ nonDicomFiles: 0
2329
+ };
2022
2330
  for (const path of [...walkFiles(dir)].sort()) {
2023
- let size;
2024
- try {
2025
- size = statSync(path).size;
2026
- } catch {
2331
+ const scanned = scanFile(path);
2332
+ if (!scanned) {
2027
2333
  stats.skipped++;
2028
2334
  continue;
2029
2335
  }
2030
- if (size > MAX_STREAM_BYTES) {
2031
- stats.skipped++;
2032
- continue;
2033
- }
2034
- const large = size > MAX_PIXEL_BYTES;
2035
- const record = large ? readHeaderOnly(path) : parseFull(path);
2336
+ const { size, large, record } = scanned;
2036
2337
  const studyUid = record?.StudyInstanceUID;
2037
2338
  const seriesUid = record?.SeriesInstanceUID;
2038
2339
  if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
@@ -2040,14 +2341,9 @@ function describeDirectory(dir, options = {}) {
2040
2341
  continue;
2041
2342
  }
2042
2343
  stats.dicomFiles++;
2043
- let modality;
2044
- if (typeof record.Modality === "string") {
2045
- if (SUPPORTED_MODALITIES.has(record.Modality)) {
2046
- modality = record.Modality;
2047
- } else {
2048
- stats.unknownModality++;
2049
- }
2050
- }
2344
+ const preset = presetModalityOf(record);
2345
+ if (preset === "unsupported") stats.unknownModality++;
2346
+ const modality = preset === "unsupported" ? void 0 : preset;
2051
2347
  const keptTags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
2052
2348
  const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
2053
2349
  const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
@@ -2081,25 +2377,11 @@ function describeDirectory(dir, options = {}) {
2081
2377
  for (const series of study.values()) {
2082
2378
  if (!Array.isArray(series)) continue;
2083
2379
  for (const rec of series) {
2084
- if (rec.uidOriginals) {
2085
- if (rec.tags) {
2086
- for (const keyword of Object.keys(rec.uidOriginals)) {
2087
- if (keyword in rec.tags) sweptKeepTags.add(keyword);
2088
- }
2089
- }
2090
- rec.tags = mergeTags(
2091
- rec.tags,
2092
- hashUidTags(rec.uidOriginals, salt, uidMap)
2093
- );
2094
- }
2380
+ hashRecordUids(rec, salt, uidMap, sweptKeepTags);
2095
2381
  }
2096
2382
  }
2097
2383
  }
2098
- for (const keyword of sweptKeepTags) {
2099
- console.warn(
2100
- `describe: keep-tag "${keyword}" contains UID references \u2014 kept as its hashed UID skeleton, not verbatim (disable with preserveUids: false)`
2101
- );
2102
- }
2384
+ warnSweptKeepTags(sweptKeepTags);
2103
2385
  }
2104
2386
  const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
2105
2387
  ...spec2,