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
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);
|
|
@@ -1562,9 +1803,13 @@ var UID_SWEEP_EXCLUDE = /* @__PURE__ */ new Set([
|
|
|
1562
1803
|
"SeriesInstanceUID",
|
|
1563
1804
|
"SOPClassUID"
|
|
1564
1805
|
]);
|
|
1806
|
+
var vrCache = /* @__PURE__ */ new Map();
|
|
1565
1807
|
function vrOf(keyword) {
|
|
1808
|
+
if (vrCache.has(keyword)) return vrCache.get(keyword);
|
|
1566
1809
|
const nm = dcmjsAny2.data.DicomMetaDictionary.nameMap;
|
|
1567
|
-
|
|
1810
|
+
const vr = (nm?.[keyword] ?? nm?.[`RETIRED_${keyword}`])?.vr;
|
|
1811
|
+
vrCache.set(keyword, vr);
|
|
1812
|
+
return vr;
|
|
1568
1813
|
}
|
|
1569
1814
|
function hashUidVia(map, salt, original) {
|
|
1570
1815
|
let synthetic = map.get(original);
|
|
@@ -1574,22 +1819,78 @@ function hashUidVia(map, salt, original) {
|
|
|
1574
1819
|
}
|
|
1575
1820
|
return synthetic;
|
|
1576
1821
|
}
|
|
1822
|
+
function isUidLeaf(value) {
|
|
1823
|
+
return typeof value === "string" || Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
1824
|
+
}
|
|
1825
|
+
function hasHashableUid(value) {
|
|
1826
|
+
const uids = typeof value === "string" ? [value] : value;
|
|
1827
|
+
return uids.some((v) => !v.startsWith(DICOM_ORG_ROOT));
|
|
1828
|
+
}
|
|
1829
|
+
function pruneUidSequence(value) {
|
|
1830
|
+
const items = Array.isArray(value) ? value : [value];
|
|
1831
|
+
const pruned = [];
|
|
1832
|
+
for (const item of items) {
|
|
1833
|
+
if (typeof item !== "object" || item === null || Array.isArray(item))
|
|
1834
|
+
continue;
|
|
1835
|
+
const skeleton = {};
|
|
1836
|
+
let hashable = false;
|
|
1837
|
+
for (const [keyword, v] of Object.entries(item)) {
|
|
1838
|
+
if (keyword === "_vrMap") continue;
|
|
1839
|
+
const vr = vrOf(keyword);
|
|
1840
|
+
if (vr === "UI") {
|
|
1841
|
+
if (isUidLeaf(v)) {
|
|
1842
|
+
skeleton[keyword] = v;
|
|
1843
|
+
if (hasHashableUid(v)) hashable = true;
|
|
1844
|
+
}
|
|
1845
|
+
} else if (vr === "SQ") {
|
|
1846
|
+
const nested = pruneUidSequence(v);
|
|
1847
|
+
if (nested) {
|
|
1848
|
+
skeleton[keyword] = nested;
|
|
1849
|
+
hashable = true;
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
if (hashable) pruned.push(skeleton);
|
|
1854
|
+
}
|
|
1855
|
+
return pruned.length > 0 ? pruned : void 0;
|
|
1856
|
+
}
|
|
1577
1857
|
function collectUidOriginals(record) {
|
|
1578
1858
|
const uids = {};
|
|
1579
1859
|
let found = false;
|
|
1580
1860
|
for (const [keyword, value] of Object.entries(record)) {
|
|
1581
1861
|
if (UID_SWEEP_EXCLUDE.has(keyword)) continue;
|
|
1582
|
-
|
|
1583
|
-
if (
|
|
1584
|
-
|
|
1585
|
-
|
|
1862
|
+
const vr = vrOf(keyword);
|
|
1863
|
+
if (vr === "UI") {
|
|
1864
|
+
if (isUidLeaf(value) && hasHashableUid(value)) {
|
|
1865
|
+
uids[keyword] = value;
|
|
1866
|
+
found = true;
|
|
1867
|
+
}
|
|
1868
|
+
} else if (vr === "SQ") {
|
|
1869
|
+
const pruned = pruneUidSequence(value);
|
|
1870
|
+
if (pruned) {
|
|
1871
|
+
uids[keyword] = pruned;
|
|
1872
|
+
found = true;
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1586
1875
|
}
|
|
1587
1876
|
return found ? uids : void 0;
|
|
1588
1877
|
}
|
|
1878
|
+
function hashUidValue(value, salt, map) {
|
|
1879
|
+
if (typeof value === "string") {
|
|
1880
|
+
return value.startsWith(DICOM_ORG_ROOT) ? value : hashUidVia(map, salt, value);
|
|
1881
|
+
}
|
|
1882
|
+
if (Array.isArray(value)) return value.map((v) => hashUidValue(v, salt, map));
|
|
1883
|
+
return Object.fromEntries(
|
|
1884
|
+
Object.entries(value).map(([k, v]) => [
|
|
1885
|
+
k,
|
|
1886
|
+
hashUidValue(v, salt, map)
|
|
1887
|
+
])
|
|
1888
|
+
);
|
|
1889
|
+
}
|
|
1589
1890
|
function hashUidTags(uids, salt, map) {
|
|
1590
1891
|
const tags = {};
|
|
1591
1892
|
for (const [keyword, original] of Object.entries(uids)) {
|
|
1592
|
-
tags[keyword] =
|
|
1893
|
+
tags[keyword] = hashUidValue(original, salt, map);
|
|
1593
1894
|
}
|
|
1594
1895
|
return tags;
|
|
1595
1896
|
}
|
|
@@ -1775,11 +2076,17 @@ function describeDirectory(dir, options = {}) {
|
|
|
1775
2076
|
}
|
|
1776
2077
|
const salt = preserveUids ? options.uidSalt ?? deriveSalt([...studies.keys()]) : void 0;
|
|
1777
2078
|
if (salt !== void 0) {
|
|
2079
|
+
const sweptKeepTags = /* @__PURE__ */ new Set();
|
|
1778
2080
|
for (const study of studies.values()) {
|
|
1779
2081
|
for (const series of study.values()) {
|
|
1780
2082
|
if (!Array.isArray(series)) continue;
|
|
1781
2083
|
for (const rec of series) {
|
|
1782
2084
|
if (rec.uidOriginals) {
|
|
2085
|
+
if (rec.tags) {
|
|
2086
|
+
for (const keyword of Object.keys(rec.uidOriginals)) {
|
|
2087
|
+
if (keyword in rec.tags) sweptKeepTags.add(keyword);
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
1783
2090
|
rec.tags = mergeTags(
|
|
1784
2091
|
rec.tags,
|
|
1785
2092
|
hashUidTags(rec.uidOriginals, salt, uidMap)
|
|
@@ -1788,6 +2095,11 @@ function describeDirectory(dir, options = {}) {
|
|
|
1788
2095
|
}
|
|
1789
2096
|
}
|
|
1790
2097
|
}
|
|
2098
|
+
for (const keyword of sweptKeepTags) {
|
|
2099
|
+
console.warn(
|
|
2100
|
+
`describe: keep-tag "${keyword}" contains UID references \u2014 kept as its hashed UID skeleton, not verbatim (disable with preserveUids: false)`
|
|
2101
|
+
);
|
|
2102
|
+
}
|
|
1791
2103
|
}
|
|
1792
2104
|
const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
|
|
1793
2105
|
...spec2,
|
|
@@ -97,11 +97,14 @@ function validateUidSalt(value, path) {
|
|
|
97
97
|
);
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
|
-
function
|
|
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"`
|