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
|
@@ -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;
|