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.
@@ -8,6 +8,55 @@ 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/syntheticFixtures/tagTemplate.ts
12
+ var TEMPLATE_VOCAB = [
13
+ "index",
14
+ "studyIndex",
15
+ "seriesIndex",
16
+ "instanceNumber"
17
+ ];
18
+ var TEMPLATE_PART_RE = /\{\{|\}\}|\{([a-zA-Z]+)\}/g;
19
+ function resolveTagTemplates(tags, context) {
20
+ if (!tags) return tags;
21
+ const resolved = {};
22
+ for (const [key, value] of Object.entries(tags)) {
23
+ resolved[key] = typeof value === "string" ? resolveString(value, context) : value;
24
+ }
25
+ return resolved;
26
+ }
27
+ function resolveString(value, context) {
28
+ return value.replace(TEMPLATE_PART_RE, (match, name) => {
29
+ if (name === void 0) return match === "{{" ? "{" : "}";
30
+ if (!TEMPLATE_VOCAB.includes(name)) {
31
+ throw new Error(
32
+ `tag template: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
33
+ );
34
+ }
35
+ const resolved = context[name];
36
+ if (resolved === void 0) {
37
+ throw new Error(
38
+ `tag template: placeholder "{${name}}" is not available here \u2014 it only applies inside grouped studies`
39
+ );
40
+ }
41
+ return String(resolved);
42
+ });
43
+ }
44
+
45
+ // src/schema/validate.ts
46
+ var TYPE_CAPABILITIES = {
47
+ "valid-image": { image: true },
48
+ "invalid-uid-image": { image: true },
49
+ "vendor-warnings-image": { image: true },
50
+ "large-image": { image: true },
51
+ "fake-signature": { image: false },
52
+ "non-dicom": { image: false },
53
+ dicomdir: { image: false }
54
+ };
55
+ function isImageType(type) {
56
+ return TYPE_CAPABILITIES[type].image;
57
+ }
58
+ var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
59
+
11
60
  // src/loadDcmjs.ts
12
61
  import dcmjsDefaultImport, * as dcmjsNamespace from "dcmjs";
13
62
  function loadDcmjs() {
@@ -49,43 +98,6 @@ function buildDicomdirBuffer() {
49
98
  return buf.subarray(0, off);
50
99
  }
51
100
 
52
- // src/syntheticFixtures/tagTemplate.ts
53
- var TEMPLATE_VOCAB = [
54
- "index",
55
- "studyIndex",
56
- "seriesIndex",
57
- "instanceNumber"
58
- ];
59
- var TEMPLATE_PART_RE = /\{\{|\}\}|\{([a-zA-Z]+)\}/g;
60
- function resolveTagTemplates(tags, context) {
61
- if (!tags) return tags;
62
- const resolved = {};
63
- for (const [key, value] of Object.entries(tags)) {
64
- resolved[key] = typeof value === "string" ? resolveString(value, context) : value;
65
- }
66
- return resolved;
67
- }
68
- function resolveString(value, context) {
69
- return value.replace(TEMPLATE_PART_RE, (match, name) => {
70
- if (name === void 0) return match === "{{" ? "{" : "}";
71
- if (!TEMPLATE_VOCAB.includes(name)) {
72
- throw new Error(
73
- `tag template: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
74
- );
75
- }
76
- const resolved = context[name];
77
- if (resolved === void 0) {
78
- throw new Error(
79
- `tag template: placeholder "{${name}}" is not available here \u2014 it only applies inside grouped studies`
80
- );
81
- }
82
- return String(resolved);
83
- });
84
- }
85
-
86
- // src/schema/validate.ts
87
- var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
88
-
89
101
  // src/syntheticFixtures/generator.ts
90
102
  var MODALITY_PRESETS = {
91
103
  CT: {
@@ -242,8 +254,13 @@ function buildFakeSignatureBuffer() {
242
254
  buf.write("XXXX", 128, 4, "ascii");
243
255
  return buf;
244
256
  }
245
- function buildNonDicomBuffer(content = "not dicom") {
246
- return Buffer.from(content, "utf8");
257
+ function sizedNonDicomBytes(spec) {
258
+ return spec.targetBytes ?? (spec.targetSizeKb !== void 0 ? spec.targetSizeKb * 1024 : void 0);
259
+ }
260
+ function buildNonDicomBuffer(spec) {
261
+ const bytes = sizedNonDicomBytes(spec);
262
+ if (bytes !== void 0) return Buffer.alloc(bytes, "not dicom ");
263
+ return Buffer.from(spec.content ?? "not dicom", "utf8");
247
264
  }
248
265
  function buildBufferForSpec(spec, uid) {
249
266
  switch (spec.type) {
@@ -254,7 +271,7 @@ function buildBufferForSpec(spec, uid) {
254
271
  case "fake-signature":
255
272
  return buildFakeSignatureBuffer();
256
273
  case "non-dicom":
257
- return buildNonDicomBuffer(spec.content);
274
+ return buildNonDicomBuffer(spec);
258
275
  case "dicomdir":
259
276
  return buildDicomdirBuffer();
260
277
  case "large-image":
@@ -565,6 +582,15 @@ function studyFileCount(studies) {
565
582
  0
566
583
  );
567
584
  }
585
+ function isDirNode(node) {
586
+ return "children" in node;
587
+ }
588
+ function treeFileCount(nodes) {
589
+ return nodes.reduce(
590
+ (sum, node) => sum + (isDirNode(node) ? treeFileCount(node.children) : node.count ?? 1),
591
+ 0
592
+ );
593
+ }
568
594
  function* seriesFiles(series) {
569
595
  if (series.instances) {
570
596
  const inst = series.instances;
@@ -661,6 +687,9 @@ function applyPathQuirks(relativePath, quirks) {
661
687
  }
662
688
  return { relativePath: [...dirs, newLeaf].join("/"), filename: newLeaf };
663
689
  }
690
+ function decorateNames(names, quirks) {
691
+ return quirks.length === 0 ? names : applyPathQuirks(names.relativePath, quirks);
692
+ }
664
693
  function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
665
694
  const pad3 = (n) => String(n).padStart(3, "0");
666
695
  const name = `${String(instanceNumber).padStart(5, "0")}.dcm`;
@@ -687,12 +716,78 @@ function computeNames(type, index, padWidth, grouped, quirks) {
687
716
  const name = filename(type, index, padWidth);
688
717
  names = { filename: name, relativePath: name };
689
718
  }
690
- return quirks.length === 0 ? names : applyPathQuirks(names.relativePath, quirks);
719
+ return decorateNames(names, quirks);
720
+ }
721
+ function* planTree(nodes, scope, env) {
722
+ let dirOrdinal = 0;
723
+ for (const node of nodes) {
724
+ if (isDirNode(node)) {
725
+ dirOrdinal++;
726
+ const dirName = node.name ?? `dir-${String(dirOrdinal).padStart(3, "0")}`;
727
+ const child = {
728
+ ...scope,
729
+ dirs: [...scope.dirs, dirName],
730
+ tags: { ...scope.tags, ...node.tags }
731
+ };
732
+ if (node.role === "study") {
733
+ const ordinal = env.counters.study++;
734
+ child.study = {
735
+ ordinal,
736
+ uid: env.groupUids.study(ordinal),
737
+ seriesCount: 0
738
+ };
739
+ } else if (node.role === "series" && child.study) {
740
+ const index = child.study.seriesCount++;
741
+ child.series = {
742
+ index,
743
+ uid: env.groupUids.series(child.study.ordinal, index),
744
+ instanceCount: 0
745
+ };
746
+ }
747
+ yield* planTree(node.children, child, env);
748
+ continue;
749
+ }
750
+ const { count = 1, name, ...fileSpec } = node;
751
+ const image = isImageType(fileSpec.type);
752
+ for (let i = 0; i < count; i++) {
753
+ const index = env.counters.file++;
754
+ const instanceNumber = image && scope.series ? ++scope.series.instanceCount : void 0;
755
+ const leaf = name ?? filename(fileSpec.type, index, env.padWidth);
756
+ const names = decorateNames(
757
+ { filename: leaf, relativePath: [...scope.dirs, leaf].join("/") },
758
+ env.quirks
759
+ );
760
+ const tags = image ? {
761
+ ...instanceNumber !== void 0 ? { InstanceNumber: instanceNumber } : {},
762
+ ...scope.tags,
763
+ ..."tags" in fileSpec ? fileSpec.tags : void 0
764
+ } : void 0;
765
+ yield {
766
+ fileSpec: tags ? { ...fileSpec, tags } : fileSpec,
767
+ index,
768
+ ...image && scope.study ? {
769
+ uidOverride: {
770
+ study: scope.study.uid,
771
+ ...scope.series ? { series: scope.series.uid } : {}
772
+ }
773
+ } : {},
774
+ context: {
775
+ index,
776
+ ...scope.study ? { studyIndex: scope.study.ordinal } : {},
777
+ ...scope.series ? { seriesIndex: scope.series.index } : {},
778
+ ...instanceNumber !== void 0 ? { instanceNumber } : {}
779
+ },
780
+ filename: names.filename,
781
+ relativePath: names.relativePath
782
+ };
783
+ }
784
+ }
691
785
  }
692
786
  function* planCollection(spec) {
693
787
  const flatEntries = spec.entries ?? [];
694
788
  const studies = spec.studies ?? [];
695
- const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
789
+ const tree = spec.tree ?? [];
790
+ const totalFiles = entryCount(flatEntries) + studyFileCount(studies) + treeFileCount(tree);
696
791
  const padWidth = String(totalFiles).length;
697
792
  const hierarchical = spec.layout === "hierarchical";
698
793
  const quirks = spec.pathQuirks ?? [];
@@ -756,6 +851,18 @@ function* planCollection(spec) {
756
851
  studyOrdinal++;
757
852
  }
758
853
  }
854
+ if (tree.length > 0) {
855
+ yield* planTree(
856
+ tree,
857
+ { dirs: [], tags: {} },
858
+ {
859
+ groupUids,
860
+ padWidth,
861
+ quirks,
862
+ counters: { file: globalIndex, study: studyOrdinal }
863
+ }
864
+ );
865
+ }
759
866
  }
760
867
  async function materialisePlan(plan, salt) {
761
868
  const file = await generateFile(plan.fileSpec, {
@@ -775,12 +882,13 @@ async function* generateCollectionFromSpec(spec) {
775
882
  async function* previewCollection(spec) {
776
883
  const salt = effectiveSalt(spec);
777
884
  for (const plan of planCollection(spec)) {
778
- if (plan.fileSpec.type === "large-image") {
885
+ const knownBytes = plan.fileSpec.type === "large-image" ? plan.fileSpec.targetBytes : plan.fileSpec.type === "non-dicom" ? sizedNonDicomBytes(plan.fileSpec) : void 0;
886
+ if (knownBytes !== void 0) {
779
887
  yield {
780
888
  relativePath: plan.relativePath,
781
- type: "large-image",
889
+ type: plan.fileSpec.type,
782
890
  index: plan.index,
783
- approxBytes: plan.fileSpec.targetBytes
891
+ approxBytes: knownBytes
784
892
  };
785
893
  } else {
786
894
  const file = await materialisePlan(plan, salt);
@@ -113,11 +113,14 @@ function validateUidSalt(value, path) {
113
113
  );
114
114
  }
115
115
  }
116
- function validateSizeKbLimit(kb, path) {
117
- if (kb * 1024 > MAX_PIXEL_BYTES) {
116
+ function validateInMemoryByteLimit(bytes, path) {
117
+ if (bytes > MAX_PIXEL_BYTES) {
118
118
  throw new Error(`${path}: exceeds the 512 MB limit`);
119
119
  }
120
120
  }
121
+ function validateSizeKbLimit(kb, path) {
122
+ validateInMemoryByteLimit(kb * 1024, path);
123
+ }
121
124
  function validateLargeTargetBytes(value, path) {
122
125
  if (typeof value !== "number" || !Number.isInteger(value)) {
123
126
  throw new Error(`${path}: must be an integer; got ${JSON.stringify(value)}`);
@@ -155,6 +158,29 @@ function validateLargeImageEntry(e, path) {
155
158
  }
156
159
  if ("tags" in e) validateTags(e.tags, `${path}.tags`);
157
160
  }
161
+ function validateNonDicomEntry(e, path) {
162
+ const sizing = ["content", "targetSizeKb", "targetBytes"].filter(
163
+ (f) => f in e
164
+ );
165
+ if (sizing.length > 1) {
166
+ throw new Error(
167
+ `${path}: "content", "targetSizeKb" and "targetBytes" are mutually exclusive`
168
+ );
169
+ }
170
+ if ("content" in e && typeof e.content !== "string") {
171
+ throw new Error(
172
+ `${path}.content: must be a string; got ${JSON.stringify(e.content)}`
173
+ );
174
+ }
175
+ if ("targetSizeKb" in e) {
176
+ validatePositiveInt(e.targetSizeKb, `${path}.targetSizeKb`);
177
+ validateSizeKbLimit(e.targetSizeKb, `${path}.targetSizeKb`);
178
+ }
179
+ if ("targetBytes" in e) {
180
+ validatePositiveInt(e.targetBytes, `${path}.targetBytes`);
181
+ validateInMemoryByteLimit(e.targetBytes, `${path}.targetBytes`);
182
+ }
183
+ }
158
184
  function validateEnum(value, valid, path) {
159
185
  if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
160
186
  throw new Error(
@@ -182,6 +208,22 @@ function validateEntry(entry, path) {
182
208
  validateLargeImageEntry(e, path);
183
209
  return e;
184
210
  }
211
+ if (type === "non-dicom") {
212
+ for (const field of [
213
+ "modality",
214
+ "rows",
215
+ "columns",
216
+ "frames",
217
+ "tags",
218
+ "violations",
219
+ "transferSyntax"
220
+ ]) {
221
+ if (field in e)
222
+ throw new Error(`${path}.${field}: not supported for type "${type}"`);
223
+ }
224
+ validateNonDicomEntry(e, path);
225
+ return e;
226
+ }
185
227
  if ("targetBytes" in e) {
186
228
  throw new Error(
187
229
  `${path}.targetBytes: only supported for type "large-image"`
@@ -389,6 +431,98 @@ function validateStudy(study, path) {
389
431
  if ("tags" in st) validateTags(st.tags, `${path}.tags`);
390
432
  return st;
391
433
  }
434
+ var VALID_TREE_ROLES = {
435
+ study: true,
436
+ series: true
437
+ };
438
+ function validateNodeName(value, path) {
439
+ if (typeof value !== "string" || value.length === 0 || value === "." || value === ".." || /[/\\]/.test(value)) {
440
+ throw new Error(
441
+ `${path}: must be a non-empty name without path separators; got ${JSON.stringify(value)}`
442
+ );
443
+ }
444
+ }
445
+ function validateTreeNode(node, path, inStudy, inSeries) {
446
+ if (typeof node !== "object" || node === null || Array.isArray(node)) {
447
+ throw new Error(`${path}: must be an object`);
448
+ }
449
+ const n = node;
450
+ if ("children" in n) {
451
+ if ("type" in n) {
452
+ throw new Error(
453
+ `${path}: a node cannot have both "children" (directory) and "type" (file)`
454
+ );
455
+ }
456
+ if ("name" in n) validateNodeName(n.name, `${path}.name`);
457
+ if ("tags" in n) validateTags(n.tags, `${path}.tags`);
458
+ let childStudy = inStudy;
459
+ let childSeries = inSeries;
460
+ if ("role" in n) {
461
+ validateEnum(n.role, VALID_TREE_ROLES, `${path}.role`);
462
+ if (n.role === "study") {
463
+ if (inStudy) {
464
+ throw new Error(
465
+ `${path}.role: a study directory cannot nest inside another study`
466
+ );
467
+ }
468
+ childStudy = true;
469
+ } else {
470
+ if (!inStudy) {
471
+ throw new Error(
472
+ `${path}.role: a series directory requires an enclosing study-role directory`
473
+ );
474
+ }
475
+ if (inSeries) {
476
+ throw new Error(
477
+ `${path}.role: a series directory cannot nest inside another series`
478
+ );
479
+ }
480
+ childSeries = true;
481
+ }
482
+ }
483
+ if (!Array.isArray(n.children) || n.children.length === 0) {
484
+ throw new Error(`${path}.children: must be a non-empty array`);
485
+ }
486
+ validateTreeChildren(
487
+ n.children,
488
+ `${path}.children`,
489
+ childStudy,
490
+ childSeries
491
+ );
492
+ return;
493
+ }
494
+ const e = validateEntry(node, path);
495
+ if ("name" in n) {
496
+ validateNodeName(n.name, `${path}.name`);
497
+ if ((e.count ?? 1) > 1) {
498
+ throw new Error(
499
+ `${path}: "name" cannot be combined with count > 1 \u2014 the copies would collide on one path`
500
+ );
501
+ }
502
+ }
503
+ }
504
+ function validateTreeChildren(children, basePath, inStudy, inSeries) {
505
+ const claimed = /* @__PURE__ */ new Map();
506
+ let dirOrdinal = 0;
507
+ children.forEach((child, i) => {
508
+ const path = `${basePath}[${i}]`;
509
+ validateTreeNode(child, path, inStudy, inSeries);
510
+ const n = child;
511
+ let name = "name" in n ? n.name : void 0;
512
+ if ("children" in n) {
513
+ dirOrdinal++;
514
+ name ?? (name = `dir-${String(dirOrdinal).padStart(3, "0")}`);
515
+ }
516
+ if (name === void 0) return;
517
+ const prior = claimed.get(name);
518
+ if (prior !== void 0) {
519
+ throw new Error(
520
+ `${path}: name "${name}" collides with ${prior} \u2014 siblings must resolve to distinct paths`
521
+ );
522
+ }
523
+ claimed.set(name, path);
524
+ });
525
+ }
392
526
  function validateDatasetSpec(raw) {
393
527
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
394
528
  throw new Error("DatasetSpec: must be a JSON object");
@@ -400,11 +534,15 @@ function validateDatasetSpec(raw) {
400
534
  if ("studies" in r && !Array.isArray(r.studies)) {
401
535
  throw new Error("DatasetSpec.studies: must be an array");
402
536
  }
537
+ if ("tree" in r && !Array.isArray(r.tree)) {
538
+ throw new Error("DatasetSpec.tree: must be an array");
539
+ }
403
540
  const rawEntries = r.entries ?? [];
404
541
  const rawStudies = r.studies ?? [];
405
- if (rawEntries.length === 0 && rawStudies.length === 0) {
542
+ const rawTree = r.tree ?? [];
543
+ if (rawEntries.length === 0 && rawStudies.length === 0 && rawTree.length === 0) {
406
544
  throw new Error(
407
- 'DatasetSpec: must contain at least one of "entries" or "studies" (non-empty)'
545
+ 'DatasetSpec: must contain at least one of "entries", "studies" or "tree" (non-empty)'
408
546
  );
409
547
  }
410
548
  const entries = rawEntries.map(
@@ -413,6 +551,9 @@ function validateDatasetSpec(raw) {
413
551
  const studies = rawStudies.map(
414
552
  (study, i) => validateStudy(study, `studies[${i}]`)
415
553
  );
554
+ if (rawTree.length > 0) {
555
+ validateTreeChildren(rawTree, "tree", false, false);
556
+ }
416
557
  if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
417
558
  if ("uidSalt" in r) validateUidSalt(r.uidSalt, "DatasetSpec.uidSalt");
418
559
  if ("layout" in r) {
@@ -429,6 +570,7 @@ function validateDatasetSpec(raw) {
429
570
  return {
430
571
  ...entries.length > 0 ? { entries } : {},
431
572
  ...studies.length > 0 ? { studies } : {},
573
+ ...rawTree.length > 0 ? { tree: r.tree } : {},
432
574
  ...r.seed !== void 0 ? { seed: r.seed } : {},
433
575
  ...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
434
576
  ...r.layout !== void 0 ? { layout: r.layout } : {},
@@ -607,9 +749,13 @@ var UID_SWEEP_EXCLUDE = /* @__PURE__ */ new Set([
607
749
  "SeriesInstanceUID",
608
750
  "SOPClassUID"
609
751
  ]);
752
+ var vrCache = /* @__PURE__ */ new Map();
610
753
  function vrOf(keyword) {
754
+ if (vrCache.has(keyword)) return vrCache.get(keyword);
611
755
  const nm = dcmjsAny.data.DicomMetaDictionary.nameMap;
612
- return (nm?.[keyword] ?? nm?.[`RETIRED_${keyword}`])?.vr;
756
+ const vr = (nm?.[keyword] ?? nm?.[`RETIRED_${keyword}`])?.vr;
757
+ vrCache.set(keyword, vr);
758
+ return vr;
613
759
  }
614
760
  function hashUidVia(map, salt, original) {
615
761
  let synthetic = map.get(original);
@@ -619,22 +765,78 @@ function hashUidVia(map, salt, original) {
619
765
  }
620
766
  return synthetic;
621
767
  }
768
+ function isUidLeaf(value) {
769
+ return typeof value === "string" || Array.isArray(value) && value.every((v) => typeof v === "string");
770
+ }
771
+ function hasHashableUid(value) {
772
+ const uids = typeof value === "string" ? [value] : value;
773
+ return uids.some((v) => !v.startsWith(DICOM_ORG_ROOT));
774
+ }
775
+ function pruneUidSequence(value) {
776
+ const items = Array.isArray(value) ? value : [value];
777
+ const pruned = [];
778
+ for (const item of items) {
779
+ if (typeof item !== "object" || item === null || Array.isArray(item))
780
+ continue;
781
+ const skeleton = {};
782
+ let hashable = false;
783
+ for (const [keyword, v] of Object.entries(item)) {
784
+ if (keyword === "_vrMap") continue;
785
+ const vr = vrOf(keyword);
786
+ if (vr === "UI") {
787
+ if (isUidLeaf(v)) {
788
+ skeleton[keyword] = v;
789
+ if (hasHashableUid(v)) hashable = true;
790
+ }
791
+ } else if (vr === "SQ") {
792
+ const nested = pruneUidSequence(v);
793
+ if (nested) {
794
+ skeleton[keyword] = nested;
795
+ hashable = true;
796
+ }
797
+ }
798
+ }
799
+ if (hashable) pruned.push(skeleton);
800
+ }
801
+ return pruned.length > 0 ? pruned : void 0;
802
+ }
622
803
  function collectUidOriginals(record) {
623
804
  const uids = {};
624
805
  let found = false;
625
806
  for (const [keyword, value] of Object.entries(record)) {
626
807
  if (UID_SWEEP_EXCLUDE.has(keyword)) continue;
627
- if (typeof value !== "string" || value.startsWith(DICOM_ORG_ROOT)) continue;
628
- if (vrOf(keyword) !== "UI") continue;
629
- uids[keyword] = value;
630
- found = true;
808
+ const vr = vrOf(keyword);
809
+ if (vr === "UI") {
810
+ if (isUidLeaf(value) && hasHashableUid(value)) {
811
+ uids[keyword] = value;
812
+ found = true;
813
+ }
814
+ } else if (vr === "SQ") {
815
+ const pruned = pruneUidSequence(value);
816
+ if (pruned) {
817
+ uids[keyword] = pruned;
818
+ found = true;
819
+ }
820
+ }
631
821
  }
632
822
  return found ? uids : void 0;
633
823
  }
824
+ function hashUidValue(value, salt, map) {
825
+ if (typeof value === "string") {
826
+ return value.startsWith(DICOM_ORG_ROOT) ? value : hashUidVia(map, salt, value);
827
+ }
828
+ if (Array.isArray(value)) return value.map((v) => hashUidValue(v, salt, map));
829
+ return Object.fromEntries(
830
+ Object.entries(value).map(([k, v]) => [
831
+ k,
832
+ hashUidValue(v, salt, map)
833
+ ])
834
+ );
835
+ }
634
836
  function hashUidTags(uids, salt, map) {
635
837
  const tags = {};
636
838
  for (const [keyword, original] of Object.entries(uids)) {
637
- tags[keyword] = hashUidVia(map, salt, original);
839
+ tags[keyword] = hashUidValue(original, salt, map);
638
840
  }
639
841
  return tags;
640
842
  }
@@ -820,11 +1022,17 @@ function describeDirectory(dir, options = {}) {
820
1022
  }
821
1023
  const salt = preserveUids ? options.uidSalt ?? deriveSalt([...studies.keys()]) : void 0;
822
1024
  if (salt !== void 0) {
1025
+ const sweptKeepTags = /* @__PURE__ */ new Set();
823
1026
  for (const study of studies.values()) {
824
1027
  for (const series of study.values()) {
825
1028
  if (!Array.isArray(series)) continue;
826
1029
  for (const rec of series) {
827
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
+ }
828
1036
  rec.tags = mergeTags(
829
1037
  rec.tags,
830
1038
  hashUidTags(rec.uidOriginals, salt, uidMap)
@@ -833,6 +1041,11 @@ function describeDirectory(dir, options = {}) {
833
1041
  }
834
1042
  }
835
1043
  }
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
+ }
836
1049
  }
837
1050
  const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
838
1051
  ...spec2,