dicom-synth 1.14.0 → 1.16.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.
@@ -97,11 +97,14 @@ function validateUidSalt(value, path) {
97
97
  );
98
98
  }
99
99
  }
100
- function validateSizeKbLimit(kb, path) {
101
- if (kb * 1024 > MAX_PIXEL_BYTES) {
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 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,101 @@ function validateStudy(study, path) {
362
407
  if ("tags" in st) validateTags(st.tags, `${path}.tags`);
363
408
  return st;
364
409
  }
410
+ function positionalName(prefix, ordinal) {
411
+ return `${prefix}-${String(ordinal).padStart(3, "0")}`;
412
+ }
413
+ var VALID_TREE_ROLES = {
414
+ study: true,
415
+ series: true
416
+ };
417
+ function validateNodeName(value, path) {
418
+ if (typeof value !== "string" || value.length === 0 || value === "." || value === ".." || /[/\\]/.test(value)) {
419
+ throw new Error(
420
+ `${path}: must be a non-empty name without path separators; got ${JSON.stringify(value)}`
421
+ );
422
+ }
423
+ }
424
+ function validateTreeNode(node, path, inStudy, inSeries) {
425
+ if (typeof node !== "object" || node === null || Array.isArray(node)) {
426
+ throw new Error(`${path}: must be an object`);
427
+ }
428
+ const n = node;
429
+ if ("children" in n) {
430
+ if ("type" in n) {
431
+ throw new Error(
432
+ `${path}: a node cannot have both "children" (directory) and "type" (file)`
433
+ );
434
+ }
435
+ if ("name" in n) validateNodeName(n.name, `${path}.name`);
436
+ if ("tags" in n) validateTags(n.tags, `${path}.tags`);
437
+ let childStudy = inStudy;
438
+ let childSeries = inSeries;
439
+ if ("role" in n) {
440
+ validateEnum(n.role, VALID_TREE_ROLES, `${path}.role`);
441
+ if (n.role === "study") {
442
+ if (inStudy) {
443
+ throw new Error(
444
+ `${path}.role: a study directory cannot nest inside another study`
445
+ );
446
+ }
447
+ childStudy = true;
448
+ } else {
449
+ if (!inStudy) {
450
+ throw new Error(
451
+ `${path}.role: a series directory requires an enclosing study-role directory`
452
+ );
453
+ }
454
+ if (inSeries) {
455
+ throw new Error(
456
+ `${path}.role: a series directory cannot nest inside another series`
457
+ );
458
+ }
459
+ childSeries = true;
460
+ }
461
+ }
462
+ if (!Array.isArray(n.children) || n.children.length === 0) {
463
+ throw new Error(`${path}.children: must be a non-empty array`);
464
+ }
465
+ validateTreeChildren(
466
+ n.children,
467
+ `${path}.children`,
468
+ childStudy,
469
+ childSeries
470
+ );
471
+ return;
472
+ }
473
+ const e = validateEntry(node, path);
474
+ if ("name" in n) {
475
+ validateNodeName(n.name, `${path}.name`);
476
+ if ((e.count ?? 1) > 1) {
477
+ throw new Error(
478
+ `${path}: "name" cannot be combined with count > 1 \u2014 the copies would collide on one path`
479
+ );
480
+ }
481
+ }
482
+ }
483
+ function validateTreeChildren(children, basePath, inStudy, inSeries) {
484
+ const claimed = /* @__PURE__ */ new Map();
485
+ let dirOrdinal = 0;
486
+ children.forEach((child, i) => {
487
+ const path = `${basePath}[${i}]`;
488
+ validateTreeNode(child, path, inStudy, inSeries);
489
+ const n = child;
490
+ let name = "name" in n ? n.name : void 0;
491
+ if ("children" in n) {
492
+ dirOrdinal++;
493
+ name ?? (name = positionalName("dir", dirOrdinal));
494
+ }
495
+ if (name === void 0) return;
496
+ const prior = claimed.get(name);
497
+ if (prior !== void 0) {
498
+ throw new Error(
499
+ `${path}: name "${name}" collides with ${prior} \u2014 siblings must resolve to distinct paths`
500
+ );
501
+ }
502
+ claimed.set(name, path);
503
+ });
504
+ }
365
505
  function validateDatasetSpec(raw) {
366
506
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
367
507
  throw new Error("DatasetSpec: must be a JSON object");
@@ -373,11 +513,15 @@ function validateDatasetSpec(raw) {
373
513
  if ("studies" in r && !Array.isArray(r.studies)) {
374
514
  throw new Error("DatasetSpec.studies: must be an array");
375
515
  }
516
+ if ("tree" in r && !Array.isArray(r.tree)) {
517
+ throw new Error("DatasetSpec.tree: must be an array");
518
+ }
376
519
  const rawEntries = r.entries ?? [];
377
520
  const rawStudies = r.studies ?? [];
378
- if (rawEntries.length === 0 && rawStudies.length === 0) {
521
+ const rawTree = r.tree ?? [];
522
+ if (rawEntries.length === 0 && rawStudies.length === 0 && rawTree.length === 0) {
379
523
  throw new Error(
380
- 'DatasetSpec: must contain at least one of "entries" or "studies" (non-empty)'
524
+ 'DatasetSpec: must contain at least one of "entries", "studies" or "tree" (non-empty)'
381
525
  );
382
526
  }
383
527
  const entries = rawEntries.map(
@@ -386,6 +530,9 @@ function validateDatasetSpec(raw) {
386
530
  const studies = rawStudies.map(
387
531
  (study, i) => validateStudy(study, `studies[${i}]`)
388
532
  );
533
+ if (rawTree.length > 0) {
534
+ validateTreeChildren(rawTree, "tree", false, false);
535
+ }
389
536
  if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
390
537
  if ("uidSalt" in r) validateUidSalt(r.uidSalt, "DatasetSpec.uidSalt");
391
538
  if ("layout" in r) {
@@ -402,6 +549,7 @@ function validateDatasetSpec(raw) {
402
549
  return {
403
550
  ...entries.length > 0 ? { entries } : {},
404
551
  ...studies.length > 0 ? { studies } : {},
552
+ ...rawTree.length > 0 ? { tree: r.tree } : {},
405
553
  ...r.seed !== void 0 ? { seed: r.seed } : {},
406
554
  ...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
407
555
  ...r.layout !== void 0 ? { layout: r.layout } : {},
@@ -533,6 +681,8 @@ function validateParametricSpec(raw) {
533
681
  }
534
682
  export {
535
683
  HEX_TAG_RE,
684
+ isImageType,
685
+ positionalName,
536
686
  validateDatasetSpec,
537
687
  validateParametricSpec
538
688
  };
@@ -108,6 +108,14 @@ function applyTagOverrides(dataset, tags) {
108
108
  }
109
109
  }
110
110
  }
111
+ function syncMetaWithDataset(meta, dataset) {
112
+ if (typeof dataset.SOPInstanceUID === "string") {
113
+ meta.MediaStorageSOPInstanceUID = dataset.SOPInstanceUID;
114
+ }
115
+ if (typeof dataset.SOPClassUID === "string") {
116
+ meta.MediaStorageSOPClassUID = dataset.SOPClassUID;
117
+ }
118
+ }
111
119
  function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
112
120
  return {
113
121
  FileMetaInformationVersion: new Uint8Array([0, 1]).buffer,
@@ -194,6 +202,7 @@ function buildImageBuffer(spec, uid) {
194
202
  }
195
203
  applyTagOverrides(dataset, spec.tags);
196
204
  const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
205
+ syncMetaWithDataset(meta, dataset);
197
206
  applySizing(dataset, meta, spec);
198
207
  return serializeDict(meta, dataset);
199
208
  }
@@ -202,8 +211,13 @@ function buildFakeSignatureBuffer() {
202
211
  buf.write("XXXX", 128, 4, "ascii");
203
212
  return buf;
204
213
  }
205
- function buildNonDicomBuffer(content = "not dicom") {
206
- return Buffer.from(content, "utf8");
214
+ function sizedNonDicomBytes(spec) {
215
+ return spec.targetBytes ?? (spec.targetSizeKb !== void 0 ? spec.targetSizeKb * 1024 : void 0);
216
+ }
217
+ function buildNonDicomBuffer(spec) {
218
+ const bytes = sizedNonDicomBytes(spec);
219
+ if (bytes !== void 0) return Buffer.alloc(bytes, "not dicom ");
220
+ return Buffer.from(spec.content ?? "not dicom", "utf8");
207
221
  }
208
222
  function buildBufferForSpec(spec, uid) {
209
223
  switch (spec.type) {
@@ -214,7 +228,7 @@ function buildBufferForSpec(spec, uid) {
214
228
  case "fake-signature":
215
229
  return buildFakeSignatureBuffer();
216
230
  case "non-dicom":
217
- return buildNonDicomBuffer(spec.content);
231
+ return buildNonDicomBuffer(spec);
218
232
  case "dicomdir":
219
233
  return buildDicomdirBuffer();
220
234
  case "large-image":
@@ -228,5 +242,7 @@ export {
228
242
  buildBaseImageDataset,
229
243
  buildBufferForSpec,
230
244
  buildMeta,
231
- serializeDict
245
+ serializeDict,
246
+ sizedNonDicomBytes,
247
+ syncMetaWithDataset
232
248
  };
@@ -94,6 +94,14 @@ function applyTagOverrides(dataset, tags) {
94
94
  }
95
95
  }
96
96
  }
97
+ function syncMetaWithDataset(meta, dataset) {
98
+ if (typeof dataset.SOPInstanceUID === "string") {
99
+ meta.MediaStorageSOPInstanceUID = dataset.SOPInstanceUID;
100
+ }
101
+ if (typeof dataset.SOPClassUID === "string") {
102
+ meta.MediaStorageSOPClassUID = dataset.SOPClassUID;
103
+ }
104
+ }
97
105
  function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
98
106
  return {
99
107
  FileMetaInformationVersion: new Uint8Array([0, 1]).buffer,
@@ -204,6 +212,7 @@ function writeNative(outPath, params, dims, zeroChunk) {
204
212
  const meta = buildMeta(uid, modality);
205
213
  const dataset = buildBaseImageDataset(uid, modality);
206
214
  applyTagOverrides(dataset, tags);
215
+ syncMetaWithDataset(meta, dataset);
207
216
  dataset.Rows = dims.rows;
208
217
  dataset.Columns = dims.columns;
209
218
  const minimalPixelBytes = dataset.PixelData.byteLength;
@@ -237,6 +246,7 @@ function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk
237
246
  };
238
247
  const dataset = buildBaseImageDataset(uid, modality);
239
248
  applyTagOverrides(dataset, tags);
249
+ syncMetaWithDataset(meta, dataset);
240
250
  dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
241
251
  dataset._vrMap = { PixelData: "OB" };
242
252
  const probe = serializeDict(meta, dataset);
@@ -4,6 +4,7 @@ export type DescribeOptions = {
4
4
  sizeTolerance?: number;
5
5
  preserveUids?: boolean;
6
6
  uidSalt?: string;
7
+ captureTree?: boolean;
7
8
  };
8
9
  export type DescribeResult = {
9
10
  spec: DatasetSpec;
@@ -11,6 +12,7 @@ export type DescribeResult = {
11
12
  dicomFiles: number;
12
13
  skipped: number;
13
14
  unknownModality: number;
15
+ nonDicomFiles: number;
14
16
  };
15
17
  };
16
18
  export declare function readHeaderOnly(path: string): Record<string, unknown> | null;
@@ -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,6 @@
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;
4
+ export declare function positionalName(prefix: string, ordinal: number): string;
3
5
  export declare function validateDatasetSpec(raw: unknown): DatasetSpec;
4
6
  export declare function validateParametricSpec(raw: unknown): ParametricSpec;
@@ -1,7 +1,9 @@
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
+ export declare function syncMetaWithDataset(meta: Record<string, unknown>, dataset: Record<string, unknown>): void;
4
5
  export declare function buildMeta(uid: UidSet, modality: Modality, transferSyntax?: TransferSyntax): Record<string, unknown>;
5
6
  export declare function buildBaseImageDataset(uid: UidSet, modality: Modality): Record<string, unknown>;
6
7
  export declare function serializeDict(meta: Record<string, unknown>, dataset: Record<string, unknown>): Buffer;
8
+ export declare function sizedNonDicomBytes(spec: NonDicomSpec): number | undefined;
7
9
  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.14.0",
3
+ "version": "1.16.0",
4
4
  "description": "Toolkit for synthetic DICOM fixtures and public fixture fetch/cache.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",