dicom-synth 1.16.0 → 1.18.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)) {
@@ -802,27 +908,50 @@ function buildTagToKeyword() {
802
908
  return map;
803
909
  }
804
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
+ }
805
922
  function applyTagOverrides(dataset, tags) {
806
- if (!tags) return;
923
+ if (!tags) return void 0;
924
+ let raw;
807
925
  for (const [key, value] of Object.entries(tags)) {
808
- 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)) {
809
930
  const keyword = tagToKeyword.get(key.toUpperCase());
810
- if (keyword) {
811
- dataset[keyword] = value;
812
- } else {
813
- 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
+ );
814
935
  }
936
+ dataset[keyword] = value;
815
937
  } else {
816
938
  dataset[key] = value;
817
939
  }
818
940
  }
941
+ return raw;
819
942
  }
820
- function syncMetaWithDataset(meta, dataset) {
821
- if (typeof dataset.SOPInstanceUID === "string") {
822
- meta.MediaStorageSOPInstanceUID = dataset.SOPInstanceUID;
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;
823
951
  }
824
- if (typeof dataset.SOPClassUID === "string") {
825
- meta.MediaStorageSOPClassUID = dataset.SOPClassUID;
952
+ const sopClass = rawScalarString(rawElements, "00080016") ?? (typeof dataset.SOPClassUID === "string" ? dataset.SOPClassUID : void 0);
953
+ if (sopClass !== void 0) {
954
+ meta.MediaStorageSOPClassUID = sopClass;
826
955
  }
827
956
  }
828
957
  function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
@@ -862,7 +991,7 @@ function buildBaseImageDataset(uid, modality) {
862
991
  ...preset.attributes
863
992
  };
864
993
  }
865
- function serializeDict(meta, dataset) {
994
+ function serializeDict(meta, dataset, rawElements) {
866
995
  const dicomDict = new dcmjs.data.DicomDict(
867
996
  dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
868
997
  meta
@@ -871,14 +1000,17 @@ function serializeDict(meta, dataset) {
871
1000
  dicomDict.dict = dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
872
1001
  dataset
873
1002
  );
1003
+ if (rawElements) {
1004
+ Object.assign(dicomDict.dict, rawElements);
1005
+ }
874
1006
  return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
875
1007
  }
876
1008
  var MAX_DIMENSION = 65535;
877
- function applySizing(dataset, meta, spec) {
1009
+ function applySizing(dataset, meta, spec, rawElements) {
878
1010
  if (spec.targetSizeKb !== void 0) {
879
1011
  const clampDim = (n) => Math.min(MAX_DIMENSION, Math.max(1, Math.round(n)));
880
1012
  const minimalPixelBytes = dataset.PixelData.byteLength;
881
- const overhead = serializeDict(meta, dataset).length - minimalPixelBytes;
1013
+ const overhead = serializeDict(meta, dataset, rawElements).length - minimalPixelBytes;
882
1014
  const cells = Math.max(
883
1015
  1,
884
1016
  Math.round((spec.targetSizeKb * 1024 - overhead) / 2)
@@ -909,11 +1041,11 @@ function buildImageBuffer(spec, uid) {
909
1041
  dataset.Laterality = "";
910
1042
  dataset.PatientWeight = "0";
911
1043
  }
912
- applyTagOverrides(dataset, spec.tags);
1044
+ const rawElements = applyTagOverrides(dataset, spec.tags);
913
1045
  const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
914
- syncMetaWithDataset(meta, dataset);
915
- applySizing(dataset, meta, spec);
916
- return serializeDict(meta, dataset);
1046
+ syncMetaWithDataset(meta, dataset, rawElements);
1047
+ applySizing(dataset, meta, spec, rawElements);
1048
+ return { buffer: serializeDict(meta, dataset, rawElements), rawElements };
917
1049
  }
918
1050
  function buildFakeSignatureBuffer() {
919
1051
  const buf = Buffer.alloc(200, 0);
@@ -935,11 +1067,11 @@ function buildBufferForSpec(spec, uid) {
935
1067
  case "vendor-warnings-image":
936
1068
  return buildImageBuffer(spec, uid);
937
1069
  case "fake-signature":
938
- return buildFakeSignatureBuffer();
1070
+ return { buffer: buildFakeSignatureBuffer() };
939
1071
  case "non-dicom":
940
- return buildNonDicomBuffer(spec);
1072
+ return { buffer: buildNonDicomBuffer(spec) };
941
1073
  case "dicomdir":
942
- return buildDicomdirBuffer();
1074
+ return { buffer: buildDicomdirBuffer() };
943
1075
  case "large-image":
944
1076
  throw new Error(
945
1077
  "large-image cannot be generated in memory \u2014 use writeCollectionFromSpec (disk-only streaming)"
@@ -1031,12 +1163,12 @@ function writeNative(outPath, params, dims, zeroChunk) {
1031
1163
  const { modality, uid, tags } = params;
1032
1164
  const meta = buildMeta(uid, modality);
1033
1165
  const dataset = buildBaseImageDataset(uid, modality);
1034
- applyTagOverrides(dataset, tags);
1035
- syncMetaWithDataset(meta, dataset);
1166
+ const rawElements = applyTagOverrides(dataset, tags);
1167
+ syncMetaWithDataset(meta, dataset, rawElements);
1036
1168
  dataset.Rows = dims.rows;
1037
1169
  dataset.Columns = dims.columns;
1038
1170
  const minimalPixelBytes = dataset.PixelData.byteLength;
1039
- const probe = serializeDict(meta, dataset);
1171
+ const probe = serializeDict(meta, dataset, rawElements);
1040
1172
  const pixelElement = Buffer.alloc(12);
1041
1173
  PIXEL_DATA_OW_HEADER.copy(pixelElement);
1042
1174
  pixelElement.writeUInt32LE(minimalPixelBytes, 8);
@@ -1065,11 +1197,11 @@ function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk
1065
1197
  TransferSyntaxUID: RLE_TRANSFER_SYNTAX_UID
1066
1198
  };
1067
1199
  const dataset = buildBaseImageDataset(uid, modality);
1068
- applyTagOverrides(dataset, tags);
1069
- syncMetaWithDataset(meta, dataset);
1200
+ const rawElements = applyTagOverrides(dataset, tags);
1201
+ syncMetaWithDataset(meta, dataset, rawElements);
1070
1202
  dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
1071
1203
  dataset._vrMap = { PixelData: "OB" };
1072
- const probe = serializeDict(meta, dataset);
1204
+ const probe = serializeDict(meta, dataset, rawElements);
1073
1205
  const idx = probe.indexOf(PIXEL_DATA_OB_HEADER);
1074
1206
  if (idx < 0 || probe.readUInt32LE(idx + 8) !== 4294967295) {
1075
1207
  throw new Error("failed to locate encapsulated PixelData element header");
@@ -1187,7 +1319,7 @@ var VIOLATION_LEVEL = {
1187
1319
  "missing-meta-header": "byte",
1188
1320
  "malformed-sq-delimiter": "byte"
1189
1321
  };
1190
- function applyTagLevel(buffer, violations) {
1322
+ function applyTagLevel(buffer, violations, rawElements) {
1191
1323
  if (violations.length === 0) return buffer;
1192
1324
  let parsed;
1193
1325
  try {
@@ -1221,6 +1353,9 @@ function applyTagLevel(buffer, violations) {
1221
1353
  }
1222
1354
  }
1223
1355
  parsed.dict = dcmjsAny.data.DicomMetaDictionary.denaturalizeDataset(natural);
1356
+ if (rawElements) {
1357
+ Object.assign(parsed.dict, rawElements);
1358
+ }
1224
1359
  if (violations.includes("uid-too-long")) {
1225
1360
  ;
1226
1361
  parsed.dict["00080018"] = {
@@ -1253,11 +1388,14 @@ function applyByteLevel(buffer, violations) {
1253
1388
  }
1254
1389
  return result;
1255
1390
  }
1256
- function applyViolations(buffer, violations) {
1391
+ function applyViolations(buffer, violations, rawElements) {
1257
1392
  if (violations.length === 0) return buffer;
1258
1393
  const tagViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "tag");
1259
1394
  const byteViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "byte");
1260
- return applyByteLevel(applyTagLevel(buffer, tagViolations), byteViolations);
1395
+ return applyByteLevel(
1396
+ applyTagLevel(buffer, tagViolations, rawElements),
1397
+ byteViolations
1398
+ );
1261
1399
  }
1262
1400
 
1263
1401
  // src/collection/writer.ts
@@ -1331,10 +1469,11 @@ async function generateFile(spec, options) {
1331
1469
  ...spec,
1332
1470
  tags: resolveTagTemplates(spec.tags, { index, ...options?.context })
1333
1471
  } : spec;
1334
- let buffer = buildBufferForSpec(resolvedSpec, uid);
1472
+ const built = buildBufferForSpec(resolvedSpec, uid);
1473
+ let buffer = built.buffer;
1335
1474
  const violations = "violations" in spec ? spec.violations : void 0;
1336
1475
  if (violations?.length) {
1337
- buffer = applyViolations(buffer, violations);
1476
+ buffer = applyViolations(buffer, violations, built.rawElements);
1338
1477
  }
1339
1478
  const name = filename(spec.type, index, padWidth);
1340
1479
  return {
@@ -1677,13 +1816,28 @@ function buildHexToKeyword() {
1677
1816
  }
1678
1817
  return map;
1679
1818
  }
1819
+ function buildKeepPlan(options) {
1820
+ const { keywords, privateHex } = options.keepTags ? resolveKeepTags(options.keepTags) : { keywords: [], privateHex: /* @__PURE__ */ new Set() };
1821
+ const allPrivate = options.keepPrivateTags === true;
1822
+ return {
1823
+ keywords,
1824
+ privateHex,
1825
+ allPrivate,
1826
+ capturingPrivate: allPrivate || privateHex.size > 0
1827
+ };
1828
+ }
1680
1829
  function resolveKeepTags(keepTags) {
1681
1830
  const nameMap = dcmjsAny2.data.DicomMetaDictionary.nameMap;
1682
1831
  let hexToKeyword;
1683
1832
  const kept = [];
1833
+ const privateHex = /* @__PURE__ */ new Set();
1684
1834
  for (const tag of keepTags) {
1685
1835
  let keyword;
1686
1836
  if (HEX_TAG_RE2.test(tag)) {
1837
+ if (isPrivateHexTag(tag)) {
1838
+ privateHex.add(tag.toUpperCase());
1839
+ continue;
1840
+ }
1687
1841
  hexToKeyword ?? (hexToKeyword = buildHexToKeyword());
1688
1842
  const mapped = hexToKeyword.get(tag.toUpperCase());
1689
1843
  if (!mapped) {
@@ -1710,7 +1864,71 @@ function resolveKeepTags(keepTags) {
1710
1864
  }
1711
1865
  kept.push(keyword);
1712
1866
  }
1713
- return kept;
1867
+ return { keywords: kept, privateHex };
1868
+ }
1869
+ function isUnrepresentableValue(value) {
1870
+ return value === null || value === void 0 || typeof value === "number" && !Number.isFinite(value) || value instanceof ArrayBuffer || ArrayBuffer.isView(value);
1871
+ }
1872
+ function toRawCapture(el, skipped) {
1873
+ const vr = el.vr;
1874
+ if (typeof vr !== "string") {
1875
+ skipped.count++;
1876
+ return null;
1877
+ }
1878
+ if (vr === "SQ") {
1879
+ const items = [];
1880
+ for (const item of el.Value ?? []) {
1881
+ if (typeof item !== "object" || item === null) continue;
1882
+ const converted = {};
1883
+ for (const [tag, nested] of Object.entries(item)) {
1884
+ if (!HEX_TAG_RE2.test(tag)) continue;
1885
+ const capture = toRawCapture(nested, skipped);
1886
+ if (capture) converted[tag.toUpperCase()] = capture;
1887
+ }
1888
+ items.push(converted);
1889
+ }
1890
+ return { vr, value: items };
1891
+ }
1892
+ const values = el.Value ?? [];
1893
+ if (values.some(isUnrepresentableValue)) {
1894
+ skipped.count++;
1895
+ return null;
1896
+ }
1897
+ return { vr, value: values.length === 1 ? values[0] : values };
1898
+ }
1899
+ function capturePrivateTags(rawDict, keep, skipped) {
1900
+ const captured = {};
1901
+ let found = false;
1902
+ for (const [tag, el] of Object.entries(rawDict)) {
1903
+ if (!HEX_TAG_RE2.test(tag) || !isPrivateHexTag(tag)) continue;
1904
+ if (!keep.allPrivate && !keep.privateHex.has(tag.toUpperCase())) continue;
1905
+ const capture = toRawCapture(el, skipped);
1906
+ if (capture) {
1907
+ captured[tag.toUpperCase()] = capture;
1908
+ found = true;
1909
+ }
1910
+ }
1911
+ return found ? captured : void 0;
1912
+ }
1913
+ function hashRawCaptureUids(capture, salt, map) {
1914
+ if (capture.vr === "UI") {
1915
+ return isUidLeaf(capture.value) ? { vr: "UI", value: hashUidValue(capture.value, salt, map) } : capture;
1916
+ }
1917
+ if (capture.vr === "SQ") {
1918
+ const items = capture.value;
1919
+ return {
1920
+ vr: "SQ",
1921
+ value: items.map(
1922
+ (item) => Object.fromEntries(
1923
+ Object.entries(item).map(([tag, nested]) => [
1924
+ tag,
1925
+ hashRawCaptureUids(nested, salt, map)
1926
+ ])
1927
+ )
1928
+ )
1929
+ };
1930
+ }
1931
+ return capture;
1714
1932
  }
1715
1933
  function* walkFiles(dir) {
1716
1934
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -1728,13 +1946,16 @@ function toArrayBuffer(buf) {
1728
1946
  buf.byteOffset + buf.byteLength
1729
1947
  );
1730
1948
  }
1731
- function naturalize(ab) {
1949
+ function parseParts(ab) {
1732
1950
  const parsed = dcmjsAny2.data.DicomMessage.readFile(ab);
1733
- return dcmjsAny2.data.DicomMetaDictionary.naturalizeDataset(parsed.dict);
1951
+ return {
1952
+ record: dcmjsAny2.data.DicomMetaDictionary.naturalizeDataset(parsed.dict),
1953
+ rawDict: parsed.dict
1954
+ };
1734
1955
  }
1735
1956
  function parseFull(path) {
1736
1957
  try {
1737
- return naturalize(toArrayBuffer(readFileSync(path)));
1958
+ return parseParts(toArrayBuffer(readFileSync(path)));
1738
1959
  } catch {
1739
1960
  return null;
1740
1961
  }
@@ -1792,7 +2013,7 @@ function readHeaderOnly(path) {
1792
2013
  EMPTY_OW_PIXEL_DATA
1793
2014
  ]);
1794
2015
  try {
1795
- return naturalize(toArrayBuffer(headerOnly));
2016
+ return parseParts(toArrayBuffer(headerOnly));
1796
2017
  } catch {
1797
2018
  return null;
1798
2019
  }
@@ -1918,6 +2139,20 @@ function mergeTags(a, b) {
1918
2139
  if (!b) return a;
1919
2140
  return { ...a, ...b };
1920
2141
  }
2142
+ function mergePrivateTags(rec, salt, uidMap) {
2143
+ if (!rec.privateTags) return;
2144
+ const hashed = Object.fromEntries(
2145
+ Object.entries(rec.privateTags).map(([tag, capture]) => [
2146
+ tag,
2147
+ salt === void 0 ? capture : hashRawCaptureUids(
2148
+ capture,
2149
+ salt,
2150
+ uidMap
2151
+ )
2152
+ ])
2153
+ );
2154
+ rec.tags = mergeTags(rec.tags, hashed);
2155
+ }
1921
2156
  function scanFile(path) {
1922
2157
  let size;
1923
2158
  try {
@@ -1927,7 +2162,7 @@ function scanFile(path) {
1927
2162
  }
1928
2163
  if (size > MAX_STREAM_BYTES) return null;
1929
2164
  const large = size > MAX_PIXEL_BYTES;
1930
- return { size, large, record: large ? readHeaderOnly(path) : parseFull(path) };
2165
+ return { size, large, parsed: large ? readHeaderOnly(path) : parseFull(path) };
1931
2166
  }
1932
2167
  function presetModalityOf(record) {
1933
2168
  if (typeof record.Modality !== "string") return void 0;
@@ -1942,6 +2177,13 @@ function hashRecordUids(rec, salt, uidMap, sweptKeepTags) {
1942
2177
  }
1943
2178
  rec.tags = mergeTags(rec.tags, hashUidTags(rec.uidOriginals, salt, uidMap));
1944
2179
  }
2180
+ function warnPrivateBinarySkipped(count) {
2181
+ if (count > 0) {
2182
+ console.warn(
2183
+ `describe: ${count} private element(s) with binary payloads were not captured \u2014 binary values cannot ride a JSON spec`
2184
+ );
2185
+ }
2186
+ }
1945
2187
  function warnSweptKeepTags(sweptKeepTags) {
1946
2188
  for (const keyword of sweptKeepTags) {
1947
2189
  console.warn(
@@ -2087,14 +2329,16 @@ function emitTree(acc) {
2087
2329
  }
2088
2330
  return nodes;
2089
2331
  }
2090
- function describeTree(dir, options, keepKeywords) {
2332
+ function describeTree(dir, options, keep) {
2091
2333
  const root = { dirs: /* @__PURE__ */ new Map(), files: [] };
2092
2334
  const stats = {
2093
2335
  dicomFiles: 0,
2094
2336
  skipped: 0,
2095
2337
  unknownModality: 0,
2096
- nonDicomFiles: 0
2338
+ nonDicomFiles: 0,
2339
+ privateBinarySkipped: 0
2097
2340
  };
2341
+ const privateSkipped = { count: 0 };
2098
2342
  const studyUids = /* @__PURE__ */ new Set();
2099
2343
  const dicomLeaves = [];
2100
2344
  for (const path of [...walkFiles(dir)].sort()) {
@@ -2103,7 +2347,8 @@ function describeTree(dir, options, keepKeywords) {
2103
2347
  stats.skipped++;
2104
2348
  continue;
2105
2349
  }
2106
- const { size, large, record } = scanned;
2350
+ const { size, large, parsed } = scanned;
2351
+ const record = parsed?.record;
2107
2352
  const studyUid = record?.StudyInstanceUID;
2108
2353
  const seriesUid = record?.SeriesInstanceUID;
2109
2354
  const relDirs = relative(dir, path).split(sep).slice(0, -1);
@@ -2134,9 +2379,10 @@ function describeTree(dir, options, keepKeywords) {
2134
2379
  modality,
2135
2380
  tags: mergeTags(
2136
2381
  instanceNumber,
2137
- keepKeywords.length ? extractTags(record, keepKeywords) : void 0
2382
+ keep.keywords.length ? extractTags(record, keep.keywords) : void 0
2138
2383
  ),
2139
- uidOriginals: collectUidOriginals(record)
2384
+ uidOriginals: collectUidOriginals(record),
2385
+ privateTags: keep.capturingPrivate && parsed ? capturePrivateTags(parsed.rawDict, keep, privateSkipped) : void 0
2140
2386
  },
2141
2387
  studyUid,
2142
2388
  seriesUid
@@ -2152,18 +2398,21 @@ function describeTree(dir, options, keepKeywords) {
2152
2398
  const sweptKeepTags = /* @__PURE__ */ new Set();
2153
2399
  for (const { rec, studyUid, seriesUid } of dicomLeaves) {
2154
2400
  hashRecordUids(rec, salt, uidMap, sweptKeepTags);
2401
+ mergePrivateTags(rec, salt, uidMap);
2155
2402
  rec.tags = mergeTags(rec.tags, {
2156
2403
  StudyInstanceUID: hashUidVia(uidMap, salt, studyUid),
2157
2404
  SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
2158
2405
  });
2159
2406
  }
2160
2407
  warnSweptKeepTags(sweptKeepTags);
2408
+ stats.privateBinarySkipped = privateSkipped.count;
2409
+ warnPrivateBinarySkipped(privateSkipped.count);
2161
2410
  const spec = { tree: emitTree(root), uidSalt: salt };
2162
2411
  validateDatasetSpec(spec);
2163
2412
  return { spec, stats };
2164
2413
  }
2165
2414
  function describeDirectory(dir, options = {}) {
2166
- const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
2415
+ const keep = buildKeepPlan(options);
2167
2416
  const preserveUids = options.preserveUids ?? true;
2168
2417
  if (options.captureTree) {
2169
2418
  if (!preserveUids) {
@@ -2171,10 +2420,10 @@ function describeDirectory(dir, options = {}) {
2171
2420
  "describe: captureTree requires preserveUids \u2014 the captured tree carries its grouping as hashed UID tags"
2172
2421
  );
2173
2422
  }
2174
- return describeTree(dir, options, keepKeywords);
2423
+ return describeTree(dir, options, keep);
2175
2424
  }
2176
2425
  const uidMap = /* @__PURE__ */ new Map();
2177
- const useRecords = keepKeywords.length > 0 || preserveUids;
2426
+ const useRecords = keep.keywords.length > 0 || preserveUids || keep.capturingPrivate;
2178
2427
  const tolerance = options.sizeTolerance ?? 0;
2179
2428
  if (!Number.isFinite(tolerance) || tolerance < 0) {
2180
2429
  throw new Error(
@@ -2186,15 +2435,18 @@ function describeDirectory(dir, options = {}) {
2186
2435
  dicomFiles: 0,
2187
2436
  skipped: 0,
2188
2437
  unknownModality: 0,
2189
- nonDicomFiles: 0
2438
+ nonDicomFiles: 0,
2439
+ privateBinarySkipped: 0
2190
2440
  };
2441
+ const privateSkipped = { count: 0 };
2191
2442
  for (const path of [...walkFiles(dir)].sort()) {
2192
2443
  const scanned = scanFile(path);
2193
2444
  if (!scanned) {
2194
2445
  stats.skipped++;
2195
2446
  continue;
2196
2447
  }
2197
- const { size, large, record } = scanned;
2448
+ const { size, large, parsed } = scanned;
2449
+ const record = parsed?.record;
2198
2450
  const studyUid = record?.StudyInstanceUID;
2199
2451
  const seriesUid = record?.SeriesInstanceUID;
2200
2452
  if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
@@ -2205,7 +2457,7 @@ function describeDirectory(dir, options = {}) {
2205
2457
  const preset = presetModalityOf(record);
2206
2458
  if (preset === "unsupported") stats.unknownModality++;
2207
2459
  const modality = preset === "unsupported" ? void 0 : preset;
2208
- const keptTags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
2460
+ const keptTags = keep.keywords.length ? extractTags(record, keep.keywords) : void 0;
2209
2461
  const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
2210
2462
  const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
2211
2463
  const fileRecord = {
@@ -2213,7 +2465,8 @@ function describeDirectory(dir, options = {}) {
2213
2465
  bytes,
2214
2466
  modality,
2215
2467
  tags: keptTags,
2216
- uidOriginals
2468
+ uidOriginals,
2469
+ privateTags: keep.capturingPrivate && parsed ? capturePrivateTags(parsed.rawDict, keep, privateSkipped) : void 0
2217
2470
  };
2218
2471
  let study = studies.get(studyUid);
2219
2472
  if (!study) {
@@ -2232,18 +2485,23 @@ function describeDirectory(dir, options = {}) {
2232
2485
  }
2233
2486
  }
2234
2487
  const salt = preserveUids ? options.uidSalt ?? deriveSalt([...studies.keys()]) : void 0;
2235
- if (salt !== void 0) {
2488
+ if (salt !== void 0 || keep.capturingPrivate) {
2236
2489
  const sweptKeepTags = /* @__PURE__ */ new Set();
2237
2490
  for (const study of studies.values()) {
2238
2491
  for (const series of study.values()) {
2239
2492
  if (!Array.isArray(series)) continue;
2240
2493
  for (const rec of series) {
2241
- hashRecordUids(rec, salt, uidMap, sweptKeepTags);
2494
+ if (salt !== void 0) {
2495
+ hashRecordUids(rec, salt, uidMap, sweptKeepTags);
2496
+ }
2497
+ mergePrivateTags(rec, salt, uidMap);
2242
2498
  }
2243
2499
  }
2244
2500
  }
2245
2501
  warnSweptKeepTags(sweptKeepTags);
2246
2502
  }
2503
+ stats.privateBinarySkipped = privateSkipped.count;
2504
+ warnPrivateBinarySkipped(privateSkipped.count);
2247
2505
  const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
2248
2506
  ...spec2,
2249
2507
  tags: {