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/collection/writer.js +153 -45
- package/dist/esm/describe/describe.js +223 -10
- package/dist/esm/index.js +371 -59
- package/dist/esm/schema/parametric.js +44 -2
- package/dist/esm/schema/validate.js +150 -4
- package/dist/esm/syntheticFixtures/generator.js +10 -4
- package/dist/types/index.d.ts +1 -1
- package/dist/types/schema/types.d.ts +13 -0
- package/dist/types/schema/validate.d.ts +2 -1
- package/dist/types/syntheticFixtures/generator.d.ts +2 -1
- package/package.json +1 -1
|
@@ -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
|
|
246
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
889
|
+
type: plan.fileSpec.type,
|
|
782
890
|
index: plan.index,
|
|
783
|
-
approxBytes:
|
|
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
|
|
117
|
-
if (
|
|
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
|
-
|
|
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 "
|
|
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
|
-
|
|
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
|
-
|
|
628
|
-
if (
|
|
629
|
-
|
|
630
|
-
|
|
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] =
|
|
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,
|