dicom-synth 1.14.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 +146 -4
- package/dist/esm/index.js +294 -53
- 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 } : {},
|
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
|
|
165
|
-
if (
|
|
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
|
-
|
|
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 "
|
|
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
|
|
767
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
1575
|
+
type: plan.fileSpec.type,
|
|
1335
1576
|
index: plan.index,
|
|
1336
|
-
approxBytes:
|
|
1577
|
+
approxBytes: knownBytes
|
|
1337
1578
|
};
|
|
1338
1579
|
} else {
|
|
1339
1580
|
const file = await materialisePlan(plan, salt);
|
|
@@ -97,11 +97,14 @@ function validateUidSalt(value, path) {
|
|
|
97
97
|
);
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
|
-
function
|
|
101
|
-
if (
|
|
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"`
|
|
@@ -28,6 +28,9 @@ var TYPE_CAPABILITIES = {
|
|
|
28
28
|
"non-dicom": { image: false },
|
|
29
29
|
dicomdir: { image: false }
|
|
30
30
|
};
|
|
31
|
+
function isImageType(type) {
|
|
32
|
+
return TYPE_CAPABILITIES[type].image;
|
|
33
|
+
}
|
|
31
34
|
var VALID_MODALITIES = {
|
|
32
35
|
CT: true,
|
|
33
36
|
PT: true,
|
|
@@ -86,11 +89,14 @@ function validateUidSalt(value, path) {
|
|
|
86
89
|
);
|
|
87
90
|
}
|
|
88
91
|
}
|
|
89
|
-
function
|
|
90
|
-
if (
|
|
92
|
+
function validateInMemoryByteLimit(bytes, path) {
|
|
93
|
+
if (bytes > MAX_PIXEL_BYTES) {
|
|
91
94
|
throw new Error(`${path}: exceeds the 512 MB limit`);
|
|
92
95
|
}
|
|
93
96
|
}
|
|
97
|
+
function validateSizeKbLimit(kb, path) {
|
|
98
|
+
validateInMemoryByteLimit(kb * 1024, path);
|
|
99
|
+
}
|
|
94
100
|
function validateLargeTargetBytes(value, path) {
|
|
95
101
|
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
96
102
|
throw new Error(`${path}: must be an integer; got ${JSON.stringify(value)}`);
|
|
@@ -128,6 +134,29 @@ function validateLargeImageEntry(e, path) {
|
|
|
128
134
|
}
|
|
129
135
|
if ("tags" in e) validateTags(e.tags, `${path}.tags`);
|
|
130
136
|
}
|
|
137
|
+
function validateNonDicomEntry(e, path) {
|
|
138
|
+
const sizing = ["content", "targetSizeKb", "targetBytes"].filter(
|
|
139
|
+
(f) => f in e
|
|
140
|
+
);
|
|
141
|
+
if (sizing.length > 1) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`${path}: "content", "targetSizeKb" and "targetBytes" are mutually exclusive`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if ("content" in e && typeof e.content !== "string") {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`${path}.content: must be a string; got ${JSON.stringify(e.content)}`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if ("targetSizeKb" in e) {
|
|
152
|
+
validatePositiveInt(e.targetSizeKb, `${path}.targetSizeKb`);
|
|
153
|
+
validateSizeKbLimit(e.targetSizeKb, `${path}.targetSizeKb`);
|
|
154
|
+
}
|
|
155
|
+
if ("targetBytes" in e) {
|
|
156
|
+
validatePositiveInt(e.targetBytes, `${path}.targetBytes`);
|
|
157
|
+
validateInMemoryByteLimit(e.targetBytes, `${path}.targetBytes`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
131
160
|
function validateEnum(value, valid, path) {
|
|
132
161
|
if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
|
|
133
162
|
throw new Error(
|
|
@@ -155,6 +184,22 @@ function validateEntry(entry, path) {
|
|
|
155
184
|
validateLargeImageEntry(e, path);
|
|
156
185
|
return e;
|
|
157
186
|
}
|
|
187
|
+
if (type === "non-dicom") {
|
|
188
|
+
for (const field of [
|
|
189
|
+
"modality",
|
|
190
|
+
"rows",
|
|
191
|
+
"columns",
|
|
192
|
+
"frames",
|
|
193
|
+
"tags",
|
|
194
|
+
"violations",
|
|
195
|
+
"transferSyntax"
|
|
196
|
+
]) {
|
|
197
|
+
if (field in e)
|
|
198
|
+
throw new Error(`${path}.${field}: not supported for type "${type}"`);
|
|
199
|
+
}
|
|
200
|
+
validateNonDicomEntry(e, path);
|
|
201
|
+
return e;
|
|
202
|
+
}
|
|
158
203
|
if ("targetBytes" in e) {
|
|
159
204
|
throw new Error(
|
|
160
205
|
`${path}.targetBytes: only supported for type "large-image"`
|
|
@@ -362,6 +407,98 @@ function validateStudy(study, path) {
|
|
|
362
407
|
if ("tags" in st) validateTags(st.tags, `${path}.tags`);
|
|
363
408
|
return st;
|
|
364
409
|
}
|
|
410
|
+
var VALID_TREE_ROLES = {
|
|
411
|
+
study: true,
|
|
412
|
+
series: true
|
|
413
|
+
};
|
|
414
|
+
function validateNodeName(value, path) {
|
|
415
|
+
if (typeof value !== "string" || value.length === 0 || value === "." || value === ".." || /[/\\]/.test(value)) {
|
|
416
|
+
throw new Error(
|
|
417
|
+
`${path}: must be a non-empty name without path separators; got ${JSON.stringify(value)}`
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
function validateTreeNode(node, path, inStudy, inSeries) {
|
|
422
|
+
if (typeof node !== "object" || node === null || Array.isArray(node)) {
|
|
423
|
+
throw new Error(`${path}: must be an object`);
|
|
424
|
+
}
|
|
425
|
+
const n = node;
|
|
426
|
+
if ("children" in n) {
|
|
427
|
+
if ("type" in n) {
|
|
428
|
+
throw new Error(
|
|
429
|
+
`${path}: a node cannot have both "children" (directory) and "type" (file)`
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
if ("name" in n) validateNodeName(n.name, `${path}.name`);
|
|
433
|
+
if ("tags" in n) validateTags(n.tags, `${path}.tags`);
|
|
434
|
+
let childStudy = inStudy;
|
|
435
|
+
let childSeries = inSeries;
|
|
436
|
+
if ("role" in n) {
|
|
437
|
+
validateEnum(n.role, VALID_TREE_ROLES, `${path}.role`);
|
|
438
|
+
if (n.role === "study") {
|
|
439
|
+
if (inStudy) {
|
|
440
|
+
throw new Error(
|
|
441
|
+
`${path}.role: a study directory cannot nest inside another study`
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
childStudy = true;
|
|
445
|
+
} else {
|
|
446
|
+
if (!inStudy) {
|
|
447
|
+
throw new Error(
|
|
448
|
+
`${path}.role: a series directory requires an enclosing study-role directory`
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
if (inSeries) {
|
|
452
|
+
throw new Error(
|
|
453
|
+
`${path}.role: a series directory cannot nest inside another series`
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
childSeries = true;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (!Array.isArray(n.children) || n.children.length === 0) {
|
|
460
|
+
throw new Error(`${path}.children: must be a non-empty array`);
|
|
461
|
+
}
|
|
462
|
+
validateTreeChildren(
|
|
463
|
+
n.children,
|
|
464
|
+
`${path}.children`,
|
|
465
|
+
childStudy,
|
|
466
|
+
childSeries
|
|
467
|
+
);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const e = validateEntry(node, path);
|
|
471
|
+
if ("name" in n) {
|
|
472
|
+
validateNodeName(n.name, `${path}.name`);
|
|
473
|
+
if ((e.count ?? 1) > 1) {
|
|
474
|
+
throw new Error(
|
|
475
|
+
`${path}: "name" cannot be combined with count > 1 \u2014 the copies would collide on one path`
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
function validateTreeChildren(children, basePath, inStudy, inSeries) {
|
|
481
|
+
const claimed = /* @__PURE__ */ new Map();
|
|
482
|
+
let dirOrdinal = 0;
|
|
483
|
+
children.forEach((child, i) => {
|
|
484
|
+
const path = `${basePath}[${i}]`;
|
|
485
|
+
validateTreeNode(child, path, inStudy, inSeries);
|
|
486
|
+
const n = child;
|
|
487
|
+
let name = "name" in n ? n.name : void 0;
|
|
488
|
+
if ("children" in n) {
|
|
489
|
+
dirOrdinal++;
|
|
490
|
+
name ?? (name = `dir-${String(dirOrdinal).padStart(3, "0")}`);
|
|
491
|
+
}
|
|
492
|
+
if (name === void 0) return;
|
|
493
|
+
const prior = claimed.get(name);
|
|
494
|
+
if (prior !== void 0) {
|
|
495
|
+
throw new Error(
|
|
496
|
+
`${path}: name "${name}" collides with ${prior} \u2014 siblings must resolve to distinct paths`
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
claimed.set(name, path);
|
|
500
|
+
});
|
|
501
|
+
}
|
|
365
502
|
function validateDatasetSpec(raw) {
|
|
366
503
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
367
504
|
throw new Error("DatasetSpec: must be a JSON object");
|
|
@@ -373,11 +510,15 @@ function validateDatasetSpec(raw) {
|
|
|
373
510
|
if ("studies" in r && !Array.isArray(r.studies)) {
|
|
374
511
|
throw new Error("DatasetSpec.studies: must be an array");
|
|
375
512
|
}
|
|
513
|
+
if ("tree" in r && !Array.isArray(r.tree)) {
|
|
514
|
+
throw new Error("DatasetSpec.tree: must be an array");
|
|
515
|
+
}
|
|
376
516
|
const rawEntries = r.entries ?? [];
|
|
377
517
|
const rawStudies = r.studies ?? [];
|
|
378
|
-
|
|
518
|
+
const rawTree = r.tree ?? [];
|
|
519
|
+
if (rawEntries.length === 0 && rawStudies.length === 0 && rawTree.length === 0) {
|
|
379
520
|
throw new Error(
|
|
380
|
-
'DatasetSpec: must contain at least one of "entries" or "
|
|
521
|
+
'DatasetSpec: must contain at least one of "entries", "studies" or "tree" (non-empty)'
|
|
381
522
|
);
|
|
382
523
|
}
|
|
383
524
|
const entries = rawEntries.map(
|
|
@@ -386,6 +527,9 @@ function validateDatasetSpec(raw) {
|
|
|
386
527
|
const studies = rawStudies.map(
|
|
387
528
|
(study, i) => validateStudy(study, `studies[${i}]`)
|
|
388
529
|
);
|
|
530
|
+
if (rawTree.length > 0) {
|
|
531
|
+
validateTreeChildren(rawTree, "tree", false, false);
|
|
532
|
+
}
|
|
389
533
|
if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
|
|
390
534
|
if ("uidSalt" in r) validateUidSalt(r.uidSalt, "DatasetSpec.uidSalt");
|
|
391
535
|
if ("layout" in r) {
|
|
@@ -402,6 +546,7 @@ function validateDatasetSpec(raw) {
|
|
|
402
546
|
return {
|
|
403
547
|
...entries.length > 0 ? { entries } : {},
|
|
404
548
|
...studies.length > 0 ? { studies } : {},
|
|
549
|
+
...rawTree.length > 0 ? { tree: r.tree } : {},
|
|
405
550
|
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
406
551
|
...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
|
|
407
552
|
...r.layout !== void 0 ? { layout: r.layout } : {},
|
|
@@ -533,6 +678,7 @@ function validateParametricSpec(raw) {
|
|
|
533
678
|
}
|
|
534
679
|
export {
|
|
535
680
|
HEX_TAG_RE,
|
|
681
|
+
isImageType,
|
|
536
682
|
validateDatasetSpec,
|
|
537
683
|
validateParametricSpec
|
|
538
684
|
};
|
|
@@ -202,8 +202,13 @@ function buildFakeSignatureBuffer() {
|
|
|
202
202
|
buf.write("XXXX", 128, 4, "ascii");
|
|
203
203
|
return buf;
|
|
204
204
|
}
|
|
205
|
-
function
|
|
206
|
-
return
|
|
205
|
+
function sizedNonDicomBytes(spec) {
|
|
206
|
+
return spec.targetBytes ?? (spec.targetSizeKb !== void 0 ? spec.targetSizeKb * 1024 : void 0);
|
|
207
|
+
}
|
|
208
|
+
function buildNonDicomBuffer(spec) {
|
|
209
|
+
const bytes = sizedNonDicomBytes(spec);
|
|
210
|
+
if (bytes !== void 0) return Buffer.alloc(bytes, "not dicom ");
|
|
211
|
+
return Buffer.from(spec.content ?? "not dicom", "utf8");
|
|
207
212
|
}
|
|
208
213
|
function buildBufferForSpec(spec, uid) {
|
|
209
214
|
switch (spec.type) {
|
|
@@ -214,7 +219,7 @@ function buildBufferForSpec(spec, uid) {
|
|
|
214
219
|
case "fake-signature":
|
|
215
220
|
return buildFakeSignatureBuffer();
|
|
216
221
|
case "non-dicom":
|
|
217
|
-
return buildNonDicomBuffer(spec
|
|
222
|
+
return buildNonDicomBuffer(spec);
|
|
218
223
|
case "dicomdir":
|
|
219
224
|
return buildDicomdirBuffer();
|
|
220
225
|
case "large-image":
|
|
@@ -228,5 +233,6 @@ export {
|
|
|
228
233
|
buildBaseImageDataset,
|
|
229
234
|
buildBufferForSpec,
|
|
230
235
|
buildMeta,
|
|
231
|
-
serializeDict
|
|
236
|
+
serializeDict,
|
|
237
|
+
sizedNonDicomBytes
|
|
232
238
|
};
|
package/dist/types/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export { defaultPublicCasesPath, loadCaseById, loadCasesFromJson, loadDefaultCas
|
|
|
4
4
|
export { caseCachePath, fetchPublicCaseToCache, verifySha256, } from './public-fixtures/fetch.js';
|
|
5
5
|
export { MAX_PIXEL_BYTES, MAX_STREAM_BYTES } from './schema/limits.js';
|
|
6
6
|
export { resolveParametricSpec } from './schema/parametric.js';
|
|
7
|
-
export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidImageSpec, LargeFileSpec, Modality, NonDicomSpec, ParametricEdgeCase, ParametricSpec, ParametricStudies, PathQuirk, Range, SeriesEntrySpec, SeriesInstances, SeriesSpec, StudySpec, TransferSyntax, ValidImageSpec, VendorWarningsImageSpec, ViolationClass, } from './schema/types.js';
|
|
7
|
+
export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, EntrySpec, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidImageSpec, LargeFileSpec, Modality, NonDicomSpec, ParametricEdgeCase, ParametricSpec, ParametricStudies, PathQuirk, Range, SeriesEntrySpec, SeriesInstances, SeriesSpec, StudySpec, TransferSyntax, TreeDirNode, TreeFileNode, TreeNode, ValidImageSpec, VendorWarningsImageSpec, ViolationClass, } from './schema/types.js';
|
|
8
8
|
export { validateDatasetSpec, validateParametricSpec, } from './schema/validate.js';
|
|
9
9
|
export { type LargeFileEncoding, type LargeFileResult, type LargeImageSpec, type StreamLargeImageOptions, streamLargeImage, writeLargeImageFile, } from './syntheticFixtures/streamWrite.js';
|
|
10
10
|
export { resolveTagTemplates, type TagContext, TEMPLATE_VOCAB, } from './syntheticFixtures/tagTemplate.js';
|
|
@@ -33,6 +33,8 @@ export type FakeSignatureSpec = {
|
|
|
33
33
|
export type NonDicomSpec = {
|
|
34
34
|
type: 'non-dicom';
|
|
35
35
|
content?: string;
|
|
36
|
+
targetSizeKb?: number;
|
|
37
|
+
targetBytes?: number;
|
|
36
38
|
};
|
|
37
39
|
export type DicomdirSpec = {
|
|
38
40
|
type: 'dicomdir';
|
|
@@ -68,11 +70,22 @@ export type StudySpec = {
|
|
|
68
70
|
tags?: DicomTagOverrides;
|
|
69
71
|
count?: number;
|
|
70
72
|
};
|
|
73
|
+
export type TreeFileNode = EntrySpec & {
|
|
74
|
+
name?: string;
|
|
75
|
+
};
|
|
76
|
+
export type TreeDirNode = {
|
|
77
|
+
name?: string;
|
|
78
|
+
role?: 'study' | 'series';
|
|
79
|
+
tags?: DicomTagOverrides;
|
|
80
|
+
children: TreeNode[];
|
|
81
|
+
};
|
|
82
|
+
export type TreeNode = TreeDirNode | TreeFileNode;
|
|
71
83
|
export type DatasetLayout = 'flat' | 'hierarchical';
|
|
72
84
|
export type PathQuirk = 'trailing-dot' | 'unicode' | 'deep-nesting' | 'long-name';
|
|
73
85
|
export type DatasetSpec = {
|
|
74
86
|
entries?: EntrySpec[];
|
|
75
87
|
studies?: StudySpec[];
|
|
88
|
+
tree?: TreeNode[];
|
|
76
89
|
seed?: number;
|
|
77
90
|
uidSalt?: string;
|
|
78
91
|
layout?: DatasetLayout;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { DatasetSpec, ParametricSpec } from './types.js';
|
|
1
|
+
import type { DatasetSpec, FileSpec, ParametricSpec } from './types.js';
|
|
2
|
+
export declare function isImageType(type: FileSpec['type']): boolean;
|
|
2
3
|
export declare const HEX_TAG_RE: RegExp;
|
|
3
4
|
export declare function validateDatasetSpec(raw: unknown): DatasetSpec;
|
|
4
5
|
export declare function validateParametricSpec(raw: unknown): ParametricSpec;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { DicomTagOverrides, FileSpec, Modality, TransferSyntax } from '../schema/types.js';
|
|
1
|
+
import type { DicomTagOverrides, FileSpec, Modality, NonDicomSpec, TransferSyntax } from '../schema/types.js';
|
|
2
2
|
import type { UidSet } from './uid.js';
|
|
3
3
|
export declare function applyTagOverrides(dataset: Record<string, unknown>, tags: DicomTagOverrides | undefined): void;
|
|
4
4
|
export declare function buildMeta(uid: UidSet, modality: Modality, transferSyntax?: TransferSyntax): Record<string, unknown>;
|
|
5
5
|
export declare function buildBaseImageDataset(uid: UidSet, modality: Modality): Record<string, unknown>;
|
|
6
6
|
export declare function serializeDict(meta: Record<string, unknown>, dataset: Record<string, unknown>): Buffer;
|
|
7
|
+
export declare function sizedNonDicomBytes(spec: NonDicomSpec): number | undefined;
|
|
7
8
|
export declare function buildBufferForSpec(spec: FileSpec, uid: UidSet): Buffer;
|