dicom-synth 1.13.0 → 1.15.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,98 @@ function validateStudy(study, path) {
437
441
  if ("tags" in st) validateTags(st.tags, `${path}.tags`);
438
442
  return st;
439
443
  }
444
+ var VALID_TREE_ROLES = {
445
+ study: true,
446
+ series: true
447
+ };
448
+ function validateNodeName(value, path) {
449
+ if (typeof value !== "string" || value.length === 0 || value === "." || value === ".." || /[/\\]/.test(value)) {
450
+ throw new Error(
451
+ `${path}: must be a non-empty name without path separators; got ${JSON.stringify(value)}`
452
+ );
453
+ }
454
+ }
455
+ function validateTreeNode(node, path, inStudy, inSeries) {
456
+ if (typeof node !== "object" || node === null || Array.isArray(node)) {
457
+ throw new Error(`${path}: must be an object`);
458
+ }
459
+ const n = node;
460
+ if ("children" in n) {
461
+ if ("type" in n) {
462
+ throw new Error(
463
+ `${path}: a node cannot have both "children" (directory) and "type" (file)`
464
+ );
465
+ }
466
+ if ("name" in n) validateNodeName(n.name, `${path}.name`);
467
+ if ("tags" in n) validateTags(n.tags, `${path}.tags`);
468
+ let childStudy = inStudy;
469
+ let childSeries = inSeries;
470
+ if ("role" in n) {
471
+ validateEnum(n.role, VALID_TREE_ROLES, `${path}.role`);
472
+ if (n.role === "study") {
473
+ if (inStudy) {
474
+ throw new Error(
475
+ `${path}.role: a study directory cannot nest inside another study`
476
+ );
477
+ }
478
+ childStudy = true;
479
+ } else {
480
+ if (!inStudy) {
481
+ throw new Error(
482
+ `${path}.role: a series directory requires an enclosing study-role directory`
483
+ );
484
+ }
485
+ if (inSeries) {
486
+ throw new Error(
487
+ `${path}.role: a series directory cannot nest inside another series`
488
+ );
489
+ }
490
+ childSeries = true;
491
+ }
492
+ }
493
+ if (!Array.isArray(n.children) || n.children.length === 0) {
494
+ throw new Error(`${path}.children: must be a non-empty array`);
495
+ }
496
+ validateTreeChildren(
497
+ n.children,
498
+ `${path}.children`,
499
+ childStudy,
500
+ childSeries
501
+ );
502
+ return;
503
+ }
504
+ const e = validateEntry(node, path);
505
+ if ("name" in n) {
506
+ validateNodeName(n.name, `${path}.name`);
507
+ if ((e.count ?? 1) > 1) {
508
+ throw new Error(
509
+ `${path}: "name" cannot be combined with count > 1 \u2014 the copies would collide on one path`
510
+ );
511
+ }
512
+ }
513
+ }
514
+ function validateTreeChildren(children, basePath, inStudy, inSeries) {
515
+ const claimed = /* @__PURE__ */ new Map();
516
+ let dirOrdinal = 0;
517
+ children.forEach((child, i) => {
518
+ const path = `${basePath}[${i}]`;
519
+ validateTreeNode(child, path, inStudy, inSeries);
520
+ const n = child;
521
+ let name = "name" in n ? n.name : void 0;
522
+ if ("children" in n) {
523
+ dirOrdinal++;
524
+ name ?? (name = `dir-${String(dirOrdinal).padStart(3, "0")}`);
525
+ }
526
+ if (name === void 0) return;
527
+ const prior = claimed.get(name);
528
+ if (prior !== void 0) {
529
+ throw new Error(
530
+ `${path}: name "${name}" collides with ${prior} \u2014 siblings must resolve to distinct paths`
531
+ );
532
+ }
533
+ claimed.set(name, path);
534
+ });
535
+ }
440
536
  function validateDatasetSpec(raw) {
441
537
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
442
538
  throw new Error("DatasetSpec: must be a JSON object");
@@ -448,11 +544,15 @@ function validateDatasetSpec(raw) {
448
544
  if ("studies" in r && !Array.isArray(r.studies)) {
449
545
  throw new Error("DatasetSpec.studies: must be an array");
450
546
  }
547
+ if ("tree" in r && !Array.isArray(r.tree)) {
548
+ throw new Error("DatasetSpec.tree: must be an array");
549
+ }
451
550
  const rawEntries = r.entries ?? [];
452
551
  const rawStudies = r.studies ?? [];
453
- if (rawEntries.length === 0 && rawStudies.length === 0) {
552
+ const rawTree = r.tree ?? [];
553
+ if (rawEntries.length === 0 && rawStudies.length === 0 && rawTree.length === 0) {
454
554
  throw new Error(
455
- 'DatasetSpec: must contain at least one of "entries" or "studies" (non-empty)'
555
+ 'DatasetSpec: must contain at least one of "entries", "studies" or "tree" (non-empty)'
456
556
  );
457
557
  }
458
558
  const entries = rawEntries.map(
@@ -461,6 +561,9 @@ function validateDatasetSpec(raw) {
461
561
  const studies = rawStudies.map(
462
562
  (study, i) => validateStudy(study, `studies[${i}]`)
463
563
  );
564
+ if (rawTree.length > 0) {
565
+ validateTreeChildren(rawTree, "tree", false, false);
566
+ }
464
567
  if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
465
568
  if ("uidSalt" in r) validateUidSalt(r.uidSalt, "DatasetSpec.uidSalt");
466
569
  if ("layout" in r) {
@@ -477,6 +580,7 @@ function validateDatasetSpec(raw) {
477
580
  return {
478
581
  ...entries.length > 0 ? { entries } : {},
479
582
  ...studies.length > 0 ? { studies } : {},
583
+ ...rawTree.length > 0 ? { tree: r.tree } : {},
480
584
  ...r.seed !== void 0 ? { seed: r.seed } : {},
481
585
  ...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
482
586
  ...r.layout !== void 0 ? { layout: r.layout } : {},
@@ -607,6 +711,47 @@ function validateParametricSpec(raw) {
607
711
  };
608
712
  }
609
713
 
714
+ // src/loadDcmjs.ts
715
+ import dcmjsDefaultImport, * as dcmjsNamespace from "dcmjs";
716
+ function loadDcmjs() {
717
+ if (dcmjsNamespace.data?.DicomDict) {
718
+ return dcmjsNamespace;
719
+ }
720
+ const d = dcmjsDefaultImport;
721
+ if (d?.data?.DicomDict) {
722
+ return d;
723
+ }
724
+ if (d?.default?.data?.DicomDict) {
725
+ return d.default;
726
+ }
727
+ throw new Error(
728
+ "dcmjs failed to load (missing data.DicomDict). Install dcmjs >= 0.29."
729
+ );
730
+ }
731
+ var dcmjs = loadDcmjs();
732
+
733
+ // src/nonStandardDicom/dicomdir.ts
734
+ import { mkdirSync, writeFileSync } from "node:fs";
735
+ import { resolve } from "node:path";
736
+ var DICOMDIR_SOP_CLASS_UID = "1.2.840.10008.1.3.10";
737
+ function buildDicomdirBuffer() {
738
+ const uid = DICOMDIR_SOP_CLASS_UID;
739
+ const buf = Buffer.alloc(256, 0);
740
+ buf.write("DICM", 128, 4, "ascii");
741
+ let off = 132;
742
+ buf.writeUInt16LE(2, off);
743
+ off += 2;
744
+ buf.writeUInt16LE(2, off);
745
+ off += 2;
746
+ buf.write("UI", off, 2, "ascii");
747
+ off += 2;
748
+ buf.writeUInt16LE(uid.length, off);
749
+ off += 2;
750
+ buf.write(uid, off, uid.length, "ascii");
751
+ off += uid.length;
752
+ return buf.subarray(0, off);
753
+ }
754
+
610
755
  // src/syntheticFixtures/generator.ts
611
756
  var MODALITY_PRESETS = {
612
757
  CT: {
@@ -763,8 +908,13 @@ function buildFakeSignatureBuffer() {
763
908
  buf.write("XXXX", 128, 4, "ascii");
764
909
  return buf;
765
910
  }
766
- function buildNonDicomBuffer(content = "not dicom") {
767
- return Buffer.from(content, "utf8");
911
+ function sizedNonDicomBytes(spec) {
912
+ return spec.targetBytes ?? (spec.targetSizeKb !== void 0 ? spec.targetSizeKb * 1024 : void 0);
913
+ }
914
+ function buildNonDicomBuffer(spec) {
915
+ const bytes = sizedNonDicomBytes(spec);
916
+ if (bytes !== void 0) return Buffer.alloc(bytes, "not dicom ");
917
+ return Buffer.from(spec.content ?? "not dicom", "utf8");
768
918
  }
769
919
  function buildBufferForSpec(spec, uid) {
770
920
  switch (spec.type) {
@@ -775,7 +925,7 @@ function buildBufferForSpec(spec, uid) {
775
925
  case "fake-signature":
776
926
  return buildFakeSignatureBuffer();
777
927
  case "non-dicom":
778
- return buildNonDicomBuffer(spec.content);
928
+ return buildNonDicomBuffer(spec);
779
929
  case "dicomdir":
780
930
  return buildDicomdirBuffer();
781
931
  case "large-image":
@@ -1118,6 +1268,15 @@ function studyFileCount(studies) {
1118
1268
  0
1119
1269
  );
1120
1270
  }
1271
+ function isDirNode(node) {
1272
+ return "children" in node;
1273
+ }
1274
+ function treeFileCount(nodes) {
1275
+ return nodes.reduce(
1276
+ (sum, node) => sum + (isDirNode(node) ? treeFileCount(node.children) : node.count ?? 1),
1277
+ 0
1278
+ );
1279
+ }
1121
1280
  function* seriesFiles(series) {
1122
1281
  if (series.instances) {
1123
1282
  const inst = series.instances;
@@ -1214,6 +1373,9 @@ function applyPathQuirks(relativePath, quirks) {
1214
1373
  }
1215
1374
  return { relativePath: [...dirs, newLeaf].join("/"), filename: newLeaf };
1216
1375
  }
1376
+ function decorateNames(names, quirks) {
1377
+ return quirks.length === 0 ? names : applyPathQuirks(names.relativePath, quirks);
1378
+ }
1217
1379
  function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
1218
1380
  const pad3 = (n) => String(n).padStart(3, "0");
1219
1381
  const name = `${String(instanceNumber).padStart(5, "0")}.dcm`;
@@ -1240,12 +1402,78 @@ function computeNames(type, index, padWidth, grouped, quirks) {
1240
1402
  const name = filename(type, index, padWidth);
1241
1403
  names = { filename: name, relativePath: name };
1242
1404
  }
1243
- return quirks.length === 0 ? names : applyPathQuirks(names.relativePath, quirks);
1405
+ return decorateNames(names, quirks);
1406
+ }
1407
+ function* planTree(nodes, scope, env) {
1408
+ let dirOrdinal = 0;
1409
+ for (const node of nodes) {
1410
+ if (isDirNode(node)) {
1411
+ dirOrdinal++;
1412
+ const dirName = node.name ?? `dir-${String(dirOrdinal).padStart(3, "0")}`;
1413
+ const child = {
1414
+ ...scope,
1415
+ dirs: [...scope.dirs, dirName],
1416
+ tags: { ...scope.tags, ...node.tags }
1417
+ };
1418
+ if (node.role === "study") {
1419
+ const ordinal = env.counters.study++;
1420
+ child.study = {
1421
+ ordinal,
1422
+ uid: env.groupUids.study(ordinal),
1423
+ seriesCount: 0
1424
+ };
1425
+ } else if (node.role === "series" && child.study) {
1426
+ const index = child.study.seriesCount++;
1427
+ child.series = {
1428
+ index,
1429
+ uid: env.groupUids.series(child.study.ordinal, index),
1430
+ instanceCount: 0
1431
+ };
1432
+ }
1433
+ yield* planTree(node.children, child, env);
1434
+ continue;
1435
+ }
1436
+ const { count = 1, name, ...fileSpec } = node;
1437
+ const image = isImageType(fileSpec.type);
1438
+ for (let i = 0; i < count; i++) {
1439
+ const index = env.counters.file++;
1440
+ const instanceNumber = image && scope.series ? ++scope.series.instanceCount : void 0;
1441
+ const leaf = name ?? filename(fileSpec.type, index, env.padWidth);
1442
+ const names = decorateNames(
1443
+ { filename: leaf, relativePath: [...scope.dirs, leaf].join("/") },
1444
+ env.quirks
1445
+ );
1446
+ const tags = image ? {
1447
+ ...instanceNumber !== void 0 ? { InstanceNumber: instanceNumber } : {},
1448
+ ...scope.tags,
1449
+ ..."tags" in fileSpec ? fileSpec.tags : void 0
1450
+ } : void 0;
1451
+ yield {
1452
+ fileSpec: tags ? { ...fileSpec, tags } : fileSpec,
1453
+ index,
1454
+ ...image && scope.study ? {
1455
+ uidOverride: {
1456
+ study: scope.study.uid,
1457
+ ...scope.series ? { series: scope.series.uid } : {}
1458
+ }
1459
+ } : {},
1460
+ context: {
1461
+ index,
1462
+ ...scope.study ? { studyIndex: scope.study.ordinal } : {},
1463
+ ...scope.series ? { seriesIndex: scope.series.index } : {},
1464
+ ...instanceNumber !== void 0 ? { instanceNumber } : {}
1465
+ },
1466
+ filename: names.filename,
1467
+ relativePath: names.relativePath
1468
+ };
1469
+ }
1470
+ }
1244
1471
  }
1245
1472
  function* planCollection(spec) {
1246
1473
  const flatEntries = spec.entries ?? [];
1247
1474
  const studies = spec.studies ?? [];
1248
- const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
1475
+ const tree = spec.tree ?? [];
1476
+ const totalFiles = entryCount(flatEntries) + studyFileCount(studies) + treeFileCount(tree);
1249
1477
  const padWidth = String(totalFiles).length;
1250
1478
  const hierarchical = spec.layout === "hierarchical";
1251
1479
  const quirks = spec.pathQuirks ?? [];
@@ -1309,6 +1537,18 @@ function* planCollection(spec) {
1309
1537
  studyOrdinal++;
1310
1538
  }
1311
1539
  }
1540
+ if (tree.length > 0) {
1541
+ yield* planTree(
1542
+ tree,
1543
+ { dirs: [], tags: {} },
1544
+ {
1545
+ groupUids,
1546
+ padWidth,
1547
+ quirks,
1548
+ counters: { file: globalIndex, study: studyOrdinal }
1549
+ }
1550
+ );
1551
+ }
1312
1552
  }
1313
1553
  async function materialisePlan(plan, salt) {
1314
1554
  const file = await generateFile(plan.fileSpec, {
@@ -1328,12 +1568,13 @@ async function* generateCollectionFromSpec(spec) {
1328
1568
  async function* previewCollection(spec) {
1329
1569
  const salt = effectiveSalt(spec);
1330
1570
  for (const plan of planCollection(spec)) {
1331
- if (plan.fileSpec.type === "large-image") {
1571
+ const knownBytes = plan.fileSpec.type === "large-image" ? plan.fileSpec.targetBytes : plan.fileSpec.type === "non-dicom" ? sizedNonDicomBytes(plan.fileSpec) : void 0;
1572
+ if (knownBytes !== void 0) {
1332
1573
  yield {
1333
1574
  relativePath: plan.relativePath,
1334
- type: "large-image",
1575
+ type: plan.fileSpec.type,
1335
1576
  index: plan.index,
1336
- approxBytes: plan.fileSpec.targetBytes
1577
+ approxBytes: knownBytes
1337
1578
  };
1338
1579
  } else {
1339
1580
  const file = await materialisePlan(plan, salt);
@@ -1562,9 +1803,13 @@ var UID_SWEEP_EXCLUDE = /* @__PURE__ */ new Set([
1562
1803
  "SeriesInstanceUID",
1563
1804
  "SOPClassUID"
1564
1805
  ]);
1806
+ var vrCache = /* @__PURE__ */ new Map();
1565
1807
  function vrOf(keyword) {
1808
+ if (vrCache.has(keyword)) return vrCache.get(keyword);
1566
1809
  const nm = dcmjsAny2.data.DicomMetaDictionary.nameMap;
1567
- return (nm?.[keyword] ?? nm?.[`RETIRED_${keyword}`])?.vr;
1810
+ const vr = (nm?.[keyword] ?? nm?.[`RETIRED_${keyword}`])?.vr;
1811
+ vrCache.set(keyword, vr);
1812
+ return vr;
1568
1813
  }
1569
1814
  function hashUidVia(map, salt, original) {
1570
1815
  let synthetic = map.get(original);
@@ -1574,22 +1819,78 @@ function hashUidVia(map, salt, original) {
1574
1819
  }
1575
1820
  return synthetic;
1576
1821
  }
1822
+ function isUidLeaf(value) {
1823
+ return typeof value === "string" || Array.isArray(value) && value.every((v) => typeof v === "string");
1824
+ }
1825
+ function hasHashableUid(value) {
1826
+ const uids = typeof value === "string" ? [value] : value;
1827
+ return uids.some((v) => !v.startsWith(DICOM_ORG_ROOT));
1828
+ }
1829
+ function pruneUidSequence(value) {
1830
+ const items = Array.isArray(value) ? value : [value];
1831
+ const pruned = [];
1832
+ for (const item of items) {
1833
+ if (typeof item !== "object" || item === null || Array.isArray(item))
1834
+ continue;
1835
+ const skeleton = {};
1836
+ let hashable = false;
1837
+ for (const [keyword, v] of Object.entries(item)) {
1838
+ if (keyword === "_vrMap") continue;
1839
+ const vr = vrOf(keyword);
1840
+ if (vr === "UI") {
1841
+ if (isUidLeaf(v)) {
1842
+ skeleton[keyword] = v;
1843
+ if (hasHashableUid(v)) hashable = true;
1844
+ }
1845
+ } else if (vr === "SQ") {
1846
+ const nested = pruneUidSequence(v);
1847
+ if (nested) {
1848
+ skeleton[keyword] = nested;
1849
+ hashable = true;
1850
+ }
1851
+ }
1852
+ }
1853
+ if (hashable) pruned.push(skeleton);
1854
+ }
1855
+ return pruned.length > 0 ? pruned : void 0;
1856
+ }
1577
1857
  function collectUidOriginals(record) {
1578
1858
  const uids = {};
1579
1859
  let found = false;
1580
1860
  for (const [keyword, value] of Object.entries(record)) {
1581
1861
  if (UID_SWEEP_EXCLUDE.has(keyword)) continue;
1582
- if (typeof value !== "string" || value.startsWith(DICOM_ORG_ROOT)) continue;
1583
- if (vrOf(keyword) !== "UI") continue;
1584
- uids[keyword] = value;
1585
- found = true;
1862
+ const vr = vrOf(keyword);
1863
+ if (vr === "UI") {
1864
+ if (isUidLeaf(value) && hasHashableUid(value)) {
1865
+ uids[keyword] = value;
1866
+ found = true;
1867
+ }
1868
+ } else if (vr === "SQ") {
1869
+ const pruned = pruneUidSequence(value);
1870
+ if (pruned) {
1871
+ uids[keyword] = pruned;
1872
+ found = true;
1873
+ }
1874
+ }
1586
1875
  }
1587
1876
  return found ? uids : void 0;
1588
1877
  }
1878
+ function hashUidValue(value, salt, map) {
1879
+ if (typeof value === "string") {
1880
+ return value.startsWith(DICOM_ORG_ROOT) ? value : hashUidVia(map, salt, value);
1881
+ }
1882
+ if (Array.isArray(value)) return value.map((v) => hashUidValue(v, salt, map));
1883
+ return Object.fromEntries(
1884
+ Object.entries(value).map(([k, v]) => [
1885
+ k,
1886
+ hashUidValue(v, salt, map)
1887
+ ])
1888
+ );
1889
+ }
1589
1890
  function hashUidTags(uids, salt, map) {
1590
1891
  const tags = {};
1591
1892
  for (const [keyword, original] of Object.entries(uids)) {
1592
- tags[keyword] = hashUidVia(map, salt, original);
1893
+ tags[keyword] = hashUidValue(original, salt, map);
1593
1894
  }
1594
1895
  return tags;
1595
1896
  }
@@ -1775,11 +2076,17 @@ function describeDirectory(dir, options = {}) {
1775
2076
  }
1776
2077
  const salt = preserveUids ? options.uidSalt ?? deriveSalt([...studies.keys()]) : void 0;
1777
2078
  if (salt !== void 0) {
2079
+ const sweptKeepTags = /* @__PURE__ */ new Set();
1778
2080
  for (const study of studies.values()) {
1779
2081
  for (const series of study.values()) {
1780
2082
  if (!Array.isArray(series)) continue;
1781
2083
  for (const rec of series) {
1782
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
+ }
1783
2090
  rec.tags = mergeTags(
1784
2091
  rec.tags,
1785
2092
  hashUidTags(rec.uidOriginals, salt, uidMap)
@@ -1788,6 +2095,11 @@ function describeDirectory(dir, options = {}) {
1788
2095
  }
1789
2096
  }
1790
2097
  }
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
+ }
1791
2103
  }
1792
2104
  const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
1793
2105
  ...spec2,
@@ -97,11 +97,14 @@ function validateUidSalt(value, path) {
97
97
  );
98
98
  }
99
99
  }
100
- function validateSizeKbLimit(kb, path) {
101
- if (kb * 1024 > MAX_PIXEL_BYTES) {
100
+ function validateInMemoryByteLimit(bytes, path) {
101
+ if (bytes > MAX_PIXEL_BYTES) {
102
102
  throw new Error(`${path}: exceeds the 512 MB limit`);
103
103
  }
104
104
  }
105
+ function validateSizeKbLimit(kb, path) {
106
+ validateInMemoryByteLimit(kb * 1024, path);
107
+ }
105
108
  function validateLargeTargetBytes(value, path) {
106
109
  if (typeof value !== "number" || !Number.isInteger(value)) {
107
110
  throw new Error(`${path}: must be an integer; got ${JSON.stringify(value)}`);
@@ -139,6 +142,29 @@ function validateLargeImageEntry(e, path) {
139
142
  }
140
143
  if ("tags" in e) validateTags(e.tags, `${path}.tags`);
141
144
  }
145
+ function validateNonDicomEntry(e, path) {
146
+ const sizing = ["content", "targetSizeKb", "targetBytes"].filter(
147
+ (f) => f in e
148
+ );
149
+ if (sizing.length > 1) {
150
+ throw new Error(
151
+ `${path}: "content", "targetSizeKb" and "targetBytes" are mutually exclusive`
152
+ );
153
+ }
154
+ if ("content" in e && typeof e.content !== "string") {
155
+ throw new Error(
156
+ `${path}.content: must be a string; got ${JSON.stringify(e.content)}`
157
+ );
158
+ }
159
+ if ("targetSizeKb" in e) {
160
+ validatePositiveInt(e.targetSizeKb, `${path}.targetSizeKb`);
161
+ validateSizeKbLimit(e.targetSizeKb, `${path}.targetSizeKb`);
162
+ }
163
+ if ("targetBytes" in e) {
164
+ validatePositiveInt(e.targetBytes, `${path}.targetBytes`);
165
+ validateInMemoryByteLimit(e.targetBytes, `${path}.targetBytes`);
166
+ }
167
+ }
142
168
  function validateEnum(value, valid, path) {
143
169
  if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
144
170
  throw new Error(
@@ -166,6 +192,22 @@ function validateEntry(entry, path) {
166
192
  validateLargeImageEntry(e, path);
167
193
  return e;
168
194
  }
195
+ if (type === "non-dicom") {
196
+ for (const field of [
197
+ "modality",
198
+ "rows",
199
+ "columns",
200
+ "frames",
201
+ "tags",
202
+ "violations",
203
+ "transferSyntax"
204
+ ]) {
205
+ if (field in e)
206
+ throw new Error(`${path}.${field}: not supported for type "${type}"`);
207
+ }
208
+ validateNonDicomEntry(e, path);
209
+ return e;
210
+ }
169
211
  if ("targetBytes" in e) {
170
212
  throw new Error(
171
213
  `${path}.targetBytes: only supported for type "large-image"`