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.
@@ -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 buildNonDicomBuffer(content = "not dicom") {
246
- return Buffer.from(content, "utf8");
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.content);
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 quirks.length === 0 ? names : applyPathQuirks(names.relativePath, quirks);
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 totalFiles = entryCount(flatEntries) + studyFileCount(studies);
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
- if (plan.fileSpec.type === "large-image") {
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: "large-image",
889
+ type: plan.fileSpec.type,
782
890
  index: plan.index,
783
- approxBytes: plan.fileSpec.targetBytes
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 validateSizeKbLimit(kb, path) {
117
- if (kb * 1024 > MAX_PIXEL_BYTES) {
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
- if (rawEntries.length === 0 && rawStudies.length === 0) {
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 "studies" (non-empty)'
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 validateSizeKbLimit(kb, path) {
165
- if (kb * 1024 > MAX_PIXEL_BYTES) {
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
- if (rawEntries.length === 0 && rawStudies.length === 0) {
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 "studies" (non-empty)'
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 buildNonDicomBuffer(content = "not dicom") {
767
- return Buffer.from(content, "utf8");
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.content);
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 quirks.length === 0 ? names : applyPathQuirks(names.relativePath, quirks);
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 totalFiles = entryCount(flatEntries) + studyFileCount(studies);
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
- if (plan.fileSpec.type === "large-image") {
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: "large-image",
1575
+ type: plan.fileSpec.type,
1335
1576
  index: plan.index,
1336
- approxBytes: plan.fileSpec.targetBytes
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 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,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.14.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",