dicom-synth 1.1.0 → 1.3.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/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // src/collection/writer.ts
2
2
  import { mkdirSync as mkdirSync2 } from "node:fs";
3
3
  import { writeFile } from "node:fs/promises";
4
- import { resolve as resolve2 } from "node:path";
4
+ import { dirname, resolve as resolve2 } from "node:path";
5
5
 
6
6
  // src/loadDcmjs.ts
7
7
  import dcmjsDefaultImport, * as dcmjsNamespace from "dcmjs";
@@ -46,13 +46,23 @@ function buildDicomdirBuffer() {
46
46
 
47
47
  // src/schema/validate.ts
48
48
  var TYPE_CAPABILITIES = {
49
- "valid-ct": { tags: true, violations: true, transferSyntax: true },
50
- "invalid-uid-ct": { tags: true, violations: true, transferSyntax: true },
51
- "vendor-warnings-ct": { tags: true, violations: true, transferSyntax: true },
52
- "large-ct": { tags: true, violations: true, transferSyntax: true },
53
- "fake-signature": { tags: false, violations: false, transferSyntax: false },
54
- "non-dicom": { tags: false, violations: false, transferSyntax: false },
55
- dicomdir: { tags: false, violations: false, transferSyntax: false }
49
+ "valid-image": { image: true },
50
+ "invalid-uid-image": { image: true },
51
+ "vendor-warnings-image": { image: true },
52
+ "large-image": { image: true },
53
+ "fake-signature": { image: false },
54
+ "non-dicom": { image: false },
55
+ dicomdir: { image: false }
56
+ };
57
+ var VALID_MODALITIES = {
58
+ CT: true,
59
+ PT: true,
60
+ MR: true,
61
+ CR: true
62
+ };
63
+ var VALID_LAYOUTS = {
64
+ flat: true,
65
+ hierarchical: true
56
66
  };
57
67
  var VALID_TRANSFER_SYNTAXES = {
58
68
  "explicit-vr-little-endian": true,
@@ -69,6 +79,13 @@ var VALID_VIOLATIONS = {
69
79
  };
70
80
  var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
71
81
  var KEYWORD_RE = /^[A-Z][A-Za-z0-9]*$/;
82
+ function validatePositiveInt(value, path) {
83
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
84
+ throw new Error(
85
+ `${path}: must be a positive integer; got ${JSON.stringify(value)}`
86
+ );
87
+ }
88
+ }
72
89
  function validateTagKey(key, path) {
73
90
  if (!HEX_TAG_RE.test(key) && !KEYWORD_RE.test(key)) {
74
91
  throw new Error(
@@ -88,37 +105,31 @@ function validateEntry(entry, path) {
88
105
  }
89
106
  const type = e.type;
90
107
  const caps = TYPE_CAPABILITIES[type];
91
- if ("count" in e) {
92
- if (typeof e.count !== "number" || !Number.isInteger(e.count) || e.count < 1) {
93
- throw new Error(
94
- `${path}.count: must be a positive integer; got ${JSON.stringify(e.count)}`
95
- );
96
- }
108
+ if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
109
+ for (const field of [
110
+ "modality",
111
+ "tags",
112
+ "violations",
113
+ "transferSyntax"
114
+ ]) {
115
+ if (!caps.image && field in e)
116
+ throw new Error(`${path}.${field}: not supported for type "${type}"`);
97
117
  }
98
118
  if ("transferSyntax" in e) {
99
- if (!caps.transferSyntax) {
100
- throw new Error(
101
- `${path}.transferSyntax: not supported for type "${type}"`
102
- );
103
- }
104
119
  if (!Object.hasOwn(VALID_TRANSFER_SYNTAXES, e.transferSyntax)) {
105
120
  throw new Error(
106
121
  `${path}.transferSyntax: must be one of ${Object.keys(VALID_TRANSFER_SYNTAXES).join(", ")}`
107
122
  );
108
123
  }
109
124
  }
110
- if (!caps.tags && "tags" in e)
111
- throw new Error(`${path}.tags: not supported for type "${type}"`);
112
- if (!caps.violations && "violations" in e)
113
- throw new Error(`${path}.violations: not supported for type "${type}"`);
114
- if ("tags" in e) {
115
- if (typeof e.tags !== "object" || e.tags === null || Array.isArray(e.tags)) {
116
- throw new Error(`${path}.tags: must be an object`);
117
- }
118
- for (const key of Object.keys(e.tags)) {
119
- validateTagKey(key, `${path}.tags`);
125
+ if ("modality" in e) {
126
+ if (typeof e.modality !== "string" || !Object.hasOwn(VALID_MODALITIES, e.modality)) {
127
+ throw new Error(
128
+ `${path}.modality: must be one of ${Object.keys(VALID_MODALITIES).join(", ")}; got ${JSON.stringify(e.modality)}`
129
+ );
120
130
  }
121
131
  }
132
+ if ("tags" in e) validateTags(e.tags, `${path}.tags`);
122
133
  if ("violations" in e) {
123
134
  if (!Array.isArray(e.violations)) {
124
135
  throw new Error(`${path}.violations: must be an array`);
@@ -131,18 +142,10 @@ function validateEntry(entry, path) {
131
142
  }
132
143
  }
133
144
  }
134
- if (type === "large-ct") {
135
- if (typeof e.rows !== "number" || !Number.isInteger(e.rows) || e.rows < 1) {
136
- throw new Error(`${path}.rows: must be a positive integer`);
137
- }
138
- if (typeof e.columns !== "number" || !Number.isInteger(e.columns) || e.columns < 1) {
139
- throw new Error(`${path}.columns: must be a positive integer`);
140
- }
141
- if ("frames" in e) {
142
- if (typeof e.frames !== "number" || !Number.isInteger(e.frames) || e.frames < 1) {
143
- throw new Error(`${path}.frames: must be a positive integer`);
144
- }
145
- }
145
+ if (type === "large-image") {
146
+ validatePositiveInt(e.rows, `${path}.rows`);
147
+ validatePositiveInt(e.columns, `${path}.columns`);
148
+ if ("frames" in e) validatePositiveInt(e.frames, `${path}.frames`);
146
149
  const rows = e.rows;
147
150
  const columns = e.columns;
148
151
  const frames = typeof e.frames === "number" ? e.frames : 1;
@@ -154,20 +157,72 @@ function validateEntry(entry, path) {
154
157
  }
155
158
  return e;
156
159
  }
160
+ function validateTags(tags, path) {
161
+ if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
162
+ throw new Error(`${path}: must be an object`);
163
+ }
164
+ for (const key of Object.keys(tags)) {
165
+ validateTagKey(key, path);
166
+ }
167
+ }
168
+ function validateSeries(series, path) {
169
+ if (typeof series !== "object" || series === null || Array.isArray(series)) {
170
+ throw new Error(`${path}: must be an object`);
171
+ }
172
+ const s = series;
173
+ if (!Array.isArray(s.entries) || s.entries.length === 0) {
174
+ throw new Error(`${path}.entries: must be a non-empty array`);
175
+ }
176
+ for (let i = 0; i < s.entries.length; i++) {
177
+ const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
178
+ if (!TYPE_CAPABILITIES[e.type].image) {
179
+ throw new Error(
180
+ `${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
181
+ );
182
+ }
183
+ }
184
+ if ("tags" in s) validateTags(s.tags, `${path}.tags`);
185
+ return s;
186
+ }
187
+ function validateStudy(study, path) {
188
+ if (typeof study !== "object" || study === null || Array.isArray(study)) {
189
+ throw new Error(`${path}: must be an object`);
190
+ }
191
+ const st = study;
192
+ if (!Array.isArray(st.series) || st.series.length === 0) {
193
+ throw new Error(`${path}.series: must be a non-empty array`);
194
+ }
195
+ for (let i = 0; i < st.series.length; i++) {
196
+ validateSeries(st.series[i], `${path}.series[${i}]`);
197
+ }
198
+ if ("count" in st) validatePositiveInt(st.count, `${path}.count`);
199
+ if ("tags" in st) validateTags(st.tags, `${path}.tags`);
200
+ return st;
201
+ }
157
202
  function validateDatasetSpec(raw) {
158
203
  if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
159
204
  throw new Error("DatasetSpec: must be a JSON object");
160
205
  }
161
206
  const r = raw;
162
- if (!Array.isArray(r.entries)) {
207
+ if ("entries" in r && !Array.isArray(r.entries)) {
163
208
  throw new Error("DatasetSpec.entries: must be an array");
164
209
  }
165
- if (r.entries.length === 0) {
166
- throw new Error("DatasetSpec.entries: must not be empty");
210
+ if ("studies" in r && !Array.isArray(r.studies)) {
211
+ throw new Error("DatasetSpec.studies: must be an array");
167
212
  }
168
- const entries = r.entries.map(
213
+ const rawEntries = r.entries ?? [];
214
+ const rawStudies = r.studies ?? [];
215
+ if (rawEntries.length === 0 && rawStudies.length === 0) {
216
+ throw new Error(
217
+ 'DatasetSpec: must contain at least one of "entries" or "studies" (non-empty)'
218
+ );
219
+ }
220
+ const entries = rawEntries.map(
169
221
  (entry, i) => validateEntry(entry, `entries[${i}]`)
170
222
  );
223
+ const studies = rawStudies.map(
224
+ (study, i) => validateStudy(study, `studies[${i}]`)
225
+ );
171
226
  if ("seed" in r) {
172
227
  if (typeof r.seed !== "number" || !Number.isFinite(r.seed)) {
173
228
  throw new Error(
@@ -175,14 +230,51 @@ function validateDatasetSpec(raw) {
175
230
  );
176
231
  }
177
232
  }
233
+ if ("layout" in r) {
234
+ if (typeof r.layout !== "string" || !Object.hasOwn(VALID_LAYOUTS, r.layout)) {
235
+ throw new Error(
236
+ `DatasetSpec.layout: must be one of ${Object.keys(VALID_LAYOUTS).join(", ")}; got ${JSON.stringify(r.layout)}`
237
+ );
238
+ }
239
+ }
178
240
  return {
179
- entries,
180
- ...r.seed !== void 0 ? { seed: r.seed } : {}
241
+ ...entries.length > 0 ? { entries } : {},
242
+ ...studies.length > 0 ? { studies } : {},
243
+ ...r.seed !== void 0 ? { seed: r.seed } : {},
244
+ ...r.layout !== void 0 ? { layout: r.layout } : {}
181
245
  };
182
246
  }
183
247
 
184
248
  // src/syntheticFixtures/generator.ts
185
- var CT_SOP_CLASS = "1.2.840.10008.5.1.4.1.1.2";
249
+ var MODALITY_PRESETS = {
250
+ CT: {
251
+ sopClassUid: "1.2.840.10008.5.1.4.1.1.2",
252
+ // CT Image Storage
253
+ attributes: {}
254
+ },
255
+ PT: {
256
+ sopClassUid: "1.2.840.10008.5.1.4.1.1.128",
257
+ // PET Image Storage
258
+ attributes: {
259
+ Units: "BQML",
260
+ DecayCorrection: "NONE",
261
+ CorrectedImage: ["ATTN", "DECY"]
262
+ }
263
+ },
264
+ MR: {
265
+ sopClassUid: "1.2.840.10008.5.1.4.1.1.4",
266
+ // MR Image Storage
267
+ attributes: {
268
+ ScanningSequence: "SE",
269
+ SequenceVariant: "NONE"
270
+ }
271
+ },
272
+ CR: {
273
+ sopClassUid: "1.2.840.10008.5.1.4.1.1.1",
274
+ // CR Image Storage
275
+ attributes: {}
276
+ }
277
+ };
186
278
  var TRANSFER_SYNTAX_UID = {
187
279
  "explicit-vr-little-endian": "1.2.840.10008.1.2.1",
188
280
  "implicit-vr-little-endian": "1.2.840.10008.1.2"
@@ -215,22 +307,23 @@ function applyTagOverrides(dataset, tags) {
215
307
  }
216
308
  }
217
309
  }
218
- function buildMeta(uid, transferSyntax = "explicit-vr-little-endian") {
310
+ function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
219
311
  return {
220
312
  FileMetaInformationVersion: new Uint8Array([0, 1]).buffer,
221
- MediaStorageSOPClassUID: CT_SOP_CLASS,
313
+ MediaStorageSOPClassUID: MODALITY_PRESETS[modality].sopClassUid,
222
314
  MediaStorageSOPInstanceUID: uid.sop,
223
315
  TransferSyntaxUID: TRANSFER_SYNTAX_UID[transferSyntax],
224
316
  ImplementationClassUID: "1.2.3.4",
225
317
  ImplementationVersionName: "SYNTH"
226
318
  };
227
319
  }
228
- function buildBaseCtDataset(uid) {
320
+ function buildBaseImageDataset(uid, modality) {
321
+ const preset = MODALITY_PRESETS[modality];
229
322
  return {
230
323
  PatientName: "SYNTH^SUBJECT",
231
324
  PatientID: "SYNTH_PID",
232
- Modality: "CT",
233
- SOPClassUID: CT_SOP_CLASS,
325
+ Modality: modality,
326
+ SOPClassUID: preset.sopClassUid,
234
327
  SOPInstanceUID: uid.sop,
235
328
  SeriesInstanceUID: uid.series,
236
329
  StudyInstanceUID: uid.study,
@@ -247,7 +340,8 @@ function buildBaseCtDataset(uid) {
247
340
  PixelData: new Uint8Array([0, 0]).buffer,
248
341
  // Declares PixelData's VR explicitly — without it dcmjs logs
249
342
  // "No value representation given for PixelData" and falls back to OW anyway.
250
- _vrMap: { PixelData: "OW" }
343
+ _vrMap: { PixelData: "OW" },
344
+ ...preset.attributes
251
345
  };
252
346
  }
253
347
  function serializeDict(meta, dataset) {
@@ -261,36 +355,36 @@ function serializeDict(meta, dataset) {
261
355
  );
262
356
  return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
263
357
  }
264
- function buildValidCtBuffer(uid, tags, transferSyntax) {
265
- const dataset = buildBaseCtDataset(uid);
358
+ function buildValidImageBuffer(uid, modality, tags, transferSyntax) {
359
+ const dataset = buildBaseImageDataset(uid, modality);
266
360
  applyTagOverrides(dataset, tags);
267
- return serializeDict(buildMeta(uid, transferSyntax), dataset);
361
+ return serializeDict(buildMeta(uid, modality, transferSyntax), dataset);
268
362
  }
269
- function buildInvalidUidCtBuffer(uid, tags, transferSyntax) {
363
+ function buildInvalidUidImageBuffer(uid, modality, tags, transferSyntax) {
270
364
  const invalidUid = {
271
365
  study: `2.25.invalid.study.${uid.study.slice(-6)}`,
272
366
  series: `2.25.invalid.series.${uid.series.slice(-6)}`,
273
367
  sop: `2.25.invalid.sop.${uid.sop.slice(-6)}`
274
368
  };
275
- const dataset = buildBaseCtDataset(invalidUid);
369
+ const dataset = buildBaseImageDataset(invalidUid, modality);
276
370
  applyTagOverrides(dataset, tags);
277
- return serializeDict(buildMeta(invalidUid, transferSyntax), dataset);
371
+ return serializeDict(buildMeta(invalidUid, modality, transferSyntax), dataset);
278
372
  }
279
- function buildVendorCtBuffer(uid, tags, transferSyntax) {
280
- const dataset = buildBaseCtDataset(uid);
373
+ function buildVendorWarningsImageBuffer(uid, modality, tags, transferSyntax) {
374
+ const dataset = buildBaseImageDataset(uid, modality);
281
375
  dataset.Laterality = "";
282
376
  dataset.PatientWeight = "0";
283
377
  applyTagOverrides(dataset, tags);
284
- return serializeDict(buildMeta(uid, transferSyntax), dataset);
378
+ return serializeDict(buildMeta(uid, modality, transferSyntax), dataset);
285
379
  }
286
- function buildLargeCtBuffer(uid, rows, columns, frames, tags, transferSyntax) {
287
- const dataset = buildBaseCtDataset(uid);
380
+ function buildLargeImageBuffer(uid, modality, rows, columns, frames, tags, transferSyntax) {
381
+ const dataset = buildBaseImageDataset(uid, modality);
288
382
  dataset.Rows = rows;
289
383
  dataset.Columns = columns;
290
384
  dataset.NumberOfFrames = frames;
291
385
  dataset.PixelData = new Uint8Array(rows * columns * frames * 2).buffer;
292
386
  applyTagOverrides(dataset, tags);
293
- return serializeDict(buildMeta(uid, transferSyntax), dataset);
387
+ return serializeDict(buildMeta(uid, modality, transferSyntax), dataset);
294
388
  }
295
389
  function buildFakeSignatureBuffer() {
296
390
  const buf = Buffer.alloc(200, 0);
@@ -302,15 +396,31 @@ function buildNonDicomBuffer(content = "not dicom") {
302
396
  }
303
397
  function buildBufferForSpec(spec, uid) {
304
398
  switch (spec.type) {
305
- case "valid-ct":
306
- return buildValidCtBuffer(uid, spec.tags, spec.transferSyntax);
307
- case "invalid-uid-ct":
308
- return buildInvalidUidCtBuffer(uid, spec.tags, spec.transferSyntax);
309
- case "vendor-warnings-ct":
310
- return buildVendorCtBuffer(uid, spec.tags, spec.transferSyntax);
311
- case "large-ct":
312
- return buildLargeCtBuffer(
399
+ case "valid-image":
400
+ return buildValidImageBuffer(
401
+ uid,
402
+ spec.modality ?? "CT",
403
+ spec.tags,
404
+ spec.transferSyntax
405
+ );
406
+ case "invalid-uid-image":
407
+ return buildInvalidUidImageBuffer(
408
+ uid,
409
+ spec.modality ?? "CT",
410
+ spec.tags,
411
+ spec.transferSyntax
412
+ );
413
+ case "vendor-warnings-image":
414
+ return buildVendorWarningsImageBuffer(
415
+ uid,
416
+ spec.modality ?? "CT",
417
+ spec.tags,
418
+ spec.transferSyntax
419
+ );
420
+ case "large-image":
421
+ return buildLargeImageBuffer(
313
422
  uid,
423
+ spec.modality ?? "CT",
314
424
  spec.rows,
315
425
  spec.columns,
316
426
  spec.frames ?? 1,
@@ -346,6 +456,33 @@ function randomUid(role) {
346
456
  function seededUid(next, role) {
347
457
  return formatUid(next(), next(), role);
348
458
  }
459
+ var GROUP_STREAM_OFFSET = 2654435769;
460
+ function seededStream(seed, offset, a, b) {
461
+ return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
462
+ }
463
+ function makeGroupUidGenerator(seed) {
464
+ if (seed === void 0) {
465
+ return {
466
+ study: () => randomUid("study"),
467
+ series: () => randomUid("series")
468
+ };
469
+ }
470
+ return {
471
+ study: (studyIndex) => seededUid(
472
+ seededStream(seed, GROUP_STREAM_OFFSET, studyIndex + 1, 0),
473
+ "study"
474
+ ),
475
+ series: (studyIndex, seriesIndex) => seededUid(
476
+ seededStream(
477
+ seed,
478
+ GROUP_STREAM_OFFSET,
479
+ studyIndex + 1,
480
+ seriesIndex + 1
481
+ ),
482
+ "series"
483
+ )
484
+ };
485
+ }
349
486
  function makeUidGenerator(seed) {
350
487
  if (seed === void 0) {
351
488
  return () => ({
@@ -355,7 +492,7 @@ function makeUidGenerator(seed) {
355
492
  });
356
493
  }
357
494
  return (fileIndex) => {
358
- const next = lcg(seed * 999983 + fileIndex * 1000003 >>> 0);
495
+ const next = seededStream(seed, 0, fileIndex, 0);
359
496
  return {
360
497
  study: seededUid(next, "study"),
361
498
  series: seededUid(next, "series"),
@@ -468,28 +605,50 @@ function filename(type, index, padWidth) {
468
605
  const base = `${type}-${padded}`;
469
606
  return NO_EXTENSION_TYPES.has(type) ? base : `${base}.dcm`;
470
607
  }
608
+ function entryCount(entries) {
609
+ return entries.reduce((sum, e) => sum + (e.count ?? 1), 0);
610
+ }
611
+ function studyFileCount(studies) {
612
+ return studies.reduce(
613
+ (sum, study) => sum + (study.count ?? 1) * study.series.reduce((s, series) => s + entryCount(series.entries), 0),
614
+ 0
615
+ );
616
+ }
471
617
  async function generateFile(spec, options) {
472
618
  const index = options?.index ?? 0;
473
619
  const padWidth = options?.padWidth ?? 3;
474
620
  const uidGen = makeUidGenerator(options?.seed);
475
- const uid = uidGen(index);
621
+ const uid = { ...uidGen(index), ...options?.uid };
476
622
  let buffer = buildBufferForSpec(spec, uid);
477
623
  const violations = "violations" in spec ? spec.violations : void 0;
478
624
  if (violations?.length) {
479
625
  buffer = applyViolations(buffer, violations);
480
626
  }
627
+ const name = filename(spec.type, index, padWidth);
481
628
  return {
482
- filename: filename(spec.type, index, padWidth),
629
+ filename: name,
630
+ relativePath: name,
483
631
  buffer,
484
632
  type: spec.type,
485
633
  index
486
634
  };
487
635
  }
636
+ function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
637
+ const pad3 = (n) => String(n).padStart(3, "0");
638
+ const name = `${String(instanceNumber).padStart(5, "0")}.dcm`;
639
+ return {
640
+ filename: name,
641
+ relativePath: `study-${pad3(studyOrdinal + 1)}/series-${pad3(seriesIndex + 1)}/${name}`
642
+ };
643
+ }
488
644
  async function* generateCollectionFromSpec(spec) {
489
- const totalFiles = spec.entries.reduce((sum, e) => sum + (e.count ?? 1), 0);
645
+ const flatEntries = spec.entries ?? [];
646
+ const studies = spec.studies ?? [];
647
+ const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
490
648
  const padWidth = String(totalFiles).length;
649
+ const hierarchical = spec.layout === "hierarchical";
491
650
  let globalIndex = 0;
492
- for (const entry of spec.entries) {
651
+ for (const entry of flatEntries) {
493
652
  const { count = 1, ...fileSpec } = entry;
494
653
  for (let i = 0; i < count; i++) {
495
654
  yield generateFile(fileSpec, {
@@ -500,13 +659,62 @@ async function* generateCollectionFromSpec(spec) {
500
659
  globalIndex++;
501
660
  }
502
661
  }
662
+ const groupUids = makeGroupUidGenerator(spec.seed);
663
+ let studyOrdinal = 0;
664
+ for (const study of studies) {
665
+ const copies = study.count ?? 1;
666
+ for (let copy = 0; copy < copies; copy++) {
667
+ const studyUid = groupUids.study(studyOrdinal);
668
+ for (const [seriesIndex, series] of study.series.entries()) {
669
+ const seriesUid = groupUids.series(studyOrdinal, seriesIndex);
670
+ let instanceNumber = 0;
671
+ for (const entry of series.entries) {
672
+ const { count = 1, ...fileSpec } = entry;
673
+ for (let i = 0; i < count; i++) {
674
+ instanceNumber++;
675
+ const tags = {
676
+ InstanceNumber: instanceNumber,
677
+ ...study.tags,
678
+ ...series.tags,
679
+ ...fileSpec.tags
680
+ };
681
+ const file = await generateFile(
682
+ { ...fileSpec, tags },
683
+ {
684
+ index: globalIndex,
685
+ seed: spec.seed,
686
+ padWidth,
687
+ uid: { study: studyUid, series: seriesUid }
688
+ }
689
+ );
690
+ yield hierarchical ? {
691
+ ...file,
692
+ ...hierarchicalName(
693
+ studyOrdinal,
694
+ seriesIndex,
695
+ instanceNumber
696
+ )
697
+ } : file;
698
+ globalIndex++;
699
+ }
700
+ }
701
+ }
702
+ studyOrdinal++;
703
+ }
704
+ }
503
705
  }
504
706
  async function writeCollectionFromSpec(spec, outDir) {
505
707
  const root = resolve2(outDir);
506
708
  mkdirSync2(root, { recursive: true });
507
709
  const manifest = [];
710
+ const createdDirs = /* @__PURE__ */ new Set([root]);
508
711
  for await (const file of generateCollectionFromSpec(spec)) {
509
- const filePath = resolve2(root, file.filename);
712
+ const filePath = resolve2(root, file.relativePath);
713
+ const dir = dirname(filePath);
714
+ if (!createdDirs.has(dir)) {
715
+ mkdirSync2(dir, { recursive: true });
716
+ createdDirs.add(dir);
717
+ }
510
718
  await writeFile(filePath, file.buffer);
511
719
  manifest.push({ path: filePath, type: file.type, index: file.index });
512
720
  }
@@ -515,10 +723,10 @@ async function writeCollectionFromSpec(spec, outDir) {
515
723
 
516
724
  // src/public-fixtures/catalog.ts
517
725
  import { readFileSync } from "node:fs";
518
- import { dirname, join } from "node:path";
726
+ import { dirname as dirname2, join } from "node:path";
519
727
  import { fileURLToPath } from "node:url";
520
728
  var bundledCatalogPath = join(
521
- dirname(fileURLToPath(import.meta.url)),
729
+ dirname2(fileURLToPath(import.meta.url)),
522
730
  "../../data/public-cases.json"
523
731
  );
524
732
  function defaultPublicCasesPath() {