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.
@@ -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 validateSizeKbLimit(kb, path) {
90
- if (kb * 1024 > MAX_PIXEL_BYTES) {
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
- if (rawEntries.length === 0 && rawStudies.length === 0) {
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 "studies" (non-empty)'
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 buildNonDicomBuffer(content = "not dicom") {
206
- return Buffer.from(content, "utf8");
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.content);
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
  };
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dicom-synth",
3
- "version": "1.13.0",
3
+ "version": "1.15.0",
4
4
  "description": "Toolkit for synthetic DICOM fixtures and public fixture fetch/cache.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",