dicom-synth 1.7.0 → 1.9.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,12 @@
1
1
  // src/collection/writer.ts
2
- import { mkdirSync as mkdirSync2 } from "node:fs";
2
+ import { randomBytes as randomBytes2 } from "node:crypto";
3
+ import { mkdirSync as mkdirSync3 } from "node:fs";
3
4
  import { writeFile } from "node:fs/promises";
4
- import { dirname, resolve as resolve2 } from "node:path";
5
+ import { dirname, resolve as resolve3 } from "node:path";
6
+
7
+ // src/schema/limits.ts
8
+ var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
9
+ var MAX_STREAM_BYTES = 50 * 1024 * 1024 * 1024;
5
10
 
6
11
  // src/loadDcmjs.ts
7
12
  import dcmjsDefaultImport, * as dcmjsNamespace from "dcmjs";
@@ -49,6 +54,7 @@ var TYPE_CAPABILITIES = {
49
54
  "valid-image": { image: true },
50
55
  "invalid-uid-image": { image: true },
51
56
  "vendor-warnings-image": { image: true },
57
+ "large-image": { image: true },
52
58
  "fake-signature": { image: false },
53
59
  "non-dicom": { image: false },
54
60
  dicomdir: { image: false }
@@ -73,7 +79,6 @@ var VALID_TRANSFER_SYNTAXES = {
73
79
  "explicit-vr-little-endian": true,
74
80
  "implicit-vr-little-endian": true
75
81
  };
76
- var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
77
82
  var VALID_VIOLATIONS = {
78
83
  "uid-too-long": true,
79
84
  "non-conformant-uid": true,
@@ -91,6 +96,13 @@ function validatePositiveInt(value, path) {
91
96
  );
92
97
  }
93
98
  }
99
+ function validateNonNegativeInt(value, path) {
100
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
101
+ throw new Error(
102
+ `${path}: must be a non-negative integer; got ${JSON.stringify(value)}`
103
+ );
104
+ }
105
+ }
94
106
  function validateSeed(value, path) {
95
107
  if (typeof value !== "number" || !Number.isFinite(value)) {
96
108
  throw new Error(
@@ -103,6 +115,43 @@ function validateSizeKbLimit(kb, path) {
103
115
  throw new Error(`${path}: exceeds the 512 MB limit`);
104
116
  }
105
117
  }
118
+ function validateLargeTargetBytes(value, path) {
119
+ if (typeof value !== "number" || !Number.isInteger(value)) {
120
+ throw new Error(`${path}: must be an integer; got ${JSON.stringify(value)}`);
121
+ }
122
+ if (value <= MAX_PIXEL_BYTES) {
123
+ throw new Error(
124
+ `${path}: must exceed 512 MB \u2014 use targetSizeKb for smaller files`
125
+ );
126
+ }
127
+ if (value > MAX_STREAM_BYTES) {
128
+ throw new Error(`${path}: exceeds the 50 GB limit`);
129
+ }
130
+ }
131
+ function validateLargeImageEntry(e, path) {
132
+ for (const field of [
133
+ "rows",
134
+ "columns",
135
+ "frames",
136
+ "targetSizeKb",
137
+ "violations",
138
+ "transferSyntax"
139
+ ]) {
140
+ if (field in e) {
141
+ throw new Error(
142
+ `${path}.${field}: not supported for type "large-image" \u2014 use targetBytes`
143
+ );
144
+ }
145
+ }
146
+ if (!("targetBytes" in e)) {
147
+ throw new Error(`${path}.targetBytes: is required for type "large-image"`);
148
+ }
149
+ validateLargeTargetBytes(e.targetBytes, `${path}.targetBytes`);
150
+ if ("modality" in e) {
151
+ validateEnum(e.modality, VALID_MODALITIES, `${path}.modality`);
152
+ }
153
+ if ("tags" in e) validateTags(e.tags, `${path}.tags`);
154
+ }
106
155
  function validateEnum(value, valid, path) {
107
156
  if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
108
157
  throw new Error(
@@ -126,6 +175,15 @@ function validateEntry(entry, path) {
126
175
  const type = e.type;
127
176
  const caps = TYPE_CAPABILITIES[type];
128
177
  if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
178
+ if (type === "large-image") {
179
+ validateLargeImageEntry(e, path);
180
+ return e;
181
+ }
182
+ if ("targetBytes" in e) {
183
+ throw new Error(
184
+ `${path}.targetBytes: only supported for type "large-image"`
185
+ );
186
+ }
129
187
  for (const field of [
130
188
  "modality",
131
189
  "rows",
@@ -271,19 +329,20 @@ function validateDatasetSpec(raw) {
271
329
  ...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
272
330
  };
273
331
  }
274
- function validateRange(value, path) {
332
+ function validateRange(value, path, options = {}) {
333
+ const checkInt = options.allowZero ? validateNonNegativeInt : validatePositiveInt;
275
334
  if (typeof value === "number") {
276
- validatePositiveInt(value, path);
335
+ checkInt(value, path);
277
336
  return value;
278
337
  }
279
338
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
280
339
  throw new Error(
281
- `${path}: must be a positive integer or { min, max }; got ${JSON.stringify(value)}`
340
+ `${path}: must be an integer or { min, max }; got ${JSON.stringify(value)}`
282
341
  );
283
342
  }
284
343
  const r = value;
285
- validatePositiveInt(r.min, `${path}.min`);
286
- validatePositiveInt(r.max, `${path}.max`);
344
+ checkInt(r.min, `${path}.min`);
345
+ checkInt(r.max, `${path}.max`);
287
346
  if (r.min > r.max) {
288
347
  throw new Error(`${path}: min (${r.min}) must be \u2264 max (${r.max})`);
289
348
  }
@@ -292,6 +351,9 @@ function validateRange(value, path) {
292
351
  function rangeMax(range) {
293
352
  return typeof range === "number" ? range : range.max;
294
353
  }
354
+ function rangeMin(range) {
355
+ return typeof range === "number" ? range : range.min;
356
+ }
295
357
  function validateParametricStudies(studies, path) {
296
358
  if (typeof studies !== "object" || studies === null || Array.isArray(studies)) {
297
359
  throw new Error(`${path}: must be an object`);
@@ -305,6 +367,32 @@ function validateParametricStudies(studies, path) {
305
367
  validateRange(s.fileSizeKb, `${path}.fileSizeKb`);
306
368
  validateSizeKbLimit(rangeMax(s.fileSizeKb), `${path}.fileSizeKb`);
307
369
  }
370
+ if ("largeFilesPerSeries" in s) {
371
+ validateRange(s.largeFilesPerSeries, `${path}.largeFilesPerSeries`, {
372
+ allowZero: true
373
+ });
374
+ if (!("largeFileBytes" in s)) {
375
+ throw new Error(
376
+ `${path}.largeFileBytes: is required when largeFilesPerSeries is set`
377
+ );
378
+ }
379
+ }
380
+ if ("largeFileBytes" in s) {
381
+ if (!("largeFilesPerSeries" in s)) {
382
+ throw new Error(
383
+ `${path}.largeFilesPerSeries: is required when largeFileBytes is set`
384
+ );
385
+ }
386
+ validateRange(s.largeFileBytes, `${path}.largeFileBytes`);
387
+ validateLargeTargetBytes(
388
+ rangeMin(s.largeFileBytes),
389
+ `${path}.largeFileBytes (min)`
390
+ );
391
+ validateLargeTargetBytes(
392
+ rangeMax(s.largeFileBytes),
393
+ `${path}.largeFileBytes (max)`
394
+ );
395
+ }
308
396
  if ("modalities" in s) {
309
397
  if (!Array.isArray(s.modalities) || s.modalities.length === 0) {
310
398
  throw new Error(`${path}.modalities: must be a non-empty array`);
@@ -534,9 +622,17 @@ function buildBufferForSpec(spec, uid) {
534
622
  return buildNonDicomBuffer(spec.content);
535
623
  case "dicomdir":
536
624
  return buildDicomdirBuffer();
625
+ case "large-image":
626
+ throw new Error(
627
+ "large-image cannot be generated in memory \u2014 use writeCollectionFromSpec (disk-only streaming)"
628
+ );
537
629
  }
538
630
  }
539
631
 
632
+ // src/syntheticFixtures/streamWrite.ts
633
+ import { closeSync, mkdirSync as mkdirSync2, openSync, writeSync } from "node:fs";
634
+ import { resolve as resolve2 } from "node:path";
635
+
540
636
  // src/syntheticFixtures/uid.ts
541
637
  import { randomBytes } from "node:crypto";
542
638
  var ROLE_CODE = { study: 1, series: 2, sop: 3 };
@@ -603,6 +699,173 @@ function makeUidGenerator(seed) {
603
699
  };
604
700
  }
605
701
 
702
+ // src/syntheticFixtures/streamWrite.ts
703
+ var OW_MAX_BYTES = 4294967294;
704
+ var FRAGMENT_MAX_BYTES = 4294967294;
705
+ var DEFAULT_CHUNK_BYTES = 8 * 1024 * 1024;
706
+ var RLE_TRANSFER_SYNTAX_UID = "1.2.840.10008.1.2.5";
707
+ var PIXEL_DATA_OW_HEADER = Buffer.from([224, 127, 16, 0, 79, 87]);
708
+ var PIXEL_DATA_OB_HEADER = Buffer.from([224, 127, 16, 0, 79, 66]);
709
+ var ITEM_TAG_GROUP = 65534;
710
+ var ITEM_TAG_ELEMENT = 57344;
711
+ var SEQUENCE_DELIMITER_ELEMENT = 57565;
712
+ function itemTag(element, byteLength = 0) {
713
+ const b = Buffer.alloc(8);
714
+ b.writeUInt16LE(ITEM_TAG_GROUP, 0);
715
+ b.writeUInt16LE(element, 2);
716
+ b.writeUInt32LE(byteLength, 4);
717
+ return b;
718
+ }
719
+ function nativeDimensions(pixelBytes) {
720
+ const cells = Math.max(1, Math.floor(pixelBytes / 2));
721
+ const rows = Math.min(65535, Math.max(1, Math.round(Math.sqrt(cells))));
722
+ const columns = Math.min(65535, Math.max(1, Math.floor(cells / rows)));
723
+ return { rows, columns, bytes: rows * columns * 2 };
724
+ }
725
+ function streamZeros(fd, totalBytes, zeroChunk) {
726
+ let remaining = totalBytes;
727
+ while (remaining > 0) {
728
+ const n = Math.min(zeroChunk.length, remaining);
729
+ writeSync(fd, zeroChunk, 0, n);
730
+ remaining -= n;
731
+ }
732
+ }
733
+ function writeNative(outPath, params, dims, zeroChunk) {
734
+ const { modality, uid, tags } = params;
735
+ const meta = buildMeta(uid, modality);
736
+ const dataset = buildBaseImageDataset(uid, modality);
737
+ applyTagOverrides(dataset, tags);
738
+ dataset.Rows = dims.rows;
739
+ dataset.Columns = dims.columns;
740
+ const minimalPixelBytes = dataset.PixelData.byteLength;
741
+ const probe = serializeDict(meta, dataset);
742
+ const pixelElement = Buffer.alloc(12);
743
+ PIXEL_DATA_OW_HEADER.copy(pixelElement);
744
+ pixelElement.writeUInt32LE(minimalPixelBytes, 8);
745
+ const owIdx = probe.indexOf(pixelElement);
746
+ if (owIdx < 0) {
747
+ throw new Error("failed to locate native PixelData element header");
748
+ }
749
+ const dataStart = owIdx + 12;
750
+ const header = Buffer.from(probe.subarray(0, dataStart));
751
+ header.writeUInt32LE(dims.bytes, owIdx + 8);
752
+ const trailing = probe.subarray(dataStart + minimalPixelBytes);
753
+ const fd = openSync(outPath, "w");
754
+ try {
755
+ writeSync(fd, header);
756
+ streamZeros(fd, dims.bytes, zeroChunk);
757
+ if (trailing.length > 0) writeSync(fd, trailing);
758
+ } finally {
759
+ closeSync(fd);
760
+ }
761
+ return header.length + dims.bytes + trailing.length;
762
+ }
763
+ function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk) {
764
+ const { modality, uid, tags } = params;
765
+ const meta = {
766
+ ...buildMeta(uid, modality),
767
+ TransferSyntaxUID: RLE_TRANSFER_SYNTAX_UID
768
+ };
769
+ const dataset = buildBaseImageDataset(uid, modality);
770
+ applyTagOverrides(dataset, tags);
771
+ dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
772
+ dataset._vrMap = { PixelData: "OB" };
773
+ const probe = serializeDict(meta, dataset);
774
+ const idx = probe.indexOf(PIXEL_DATA_OB_HEADER);
775
+ if (idx < 0 || probe.readUInt32LE(idx + 8) !== 4294967295) {
776
+ throw new Error("failed to locate encapsulated PixelData element header");
777
+ }
778
+ const prefix = Buffer.from(probe.subarray(0, idx + 12));
779
+ const fd = openSync(outPath, "w");
780
+ let bytesWritten = 0;
781
+ try {
782
+ writeSync(fd, prefix);
783
+ bytesWritten += prefix.length;
784
+ const bot = itemTag(ITEM_TAG_ELEMENT, 0);
785
+ writeSync(fd, bot);
786
+ bytesWritten += bot.length;
787
+ let remaining = pixelBytes;
788
+ while (remaining > 0) {
789
+ const fragLen = Math.min(fragmentBytes, remaining);
790
+ const fh = itemTag(ITEM_TAG_ELEMENT, fragLen);
791
+ writeSync(fd, fh);
792
+ streamZeros(fd, fragLen, zeroChunk);
793
+ bytesWritten += fh.length + fragLen;
794
+ remaining -= fragLen;
795
+ }
796
+ const delimiter = itemTag(SEQUENCE_DELIMITER_ELEMENT);
797
+ writeSync(fd, delimiter);
798
+ bytesWritten += delimiter.length;
799
+ } finally {
800
+ closeSync(fd);
801
+ }
802
+ return bytesWritten;
803
+ }
804
+ function streamLargeImage(outPath, options) {
805
+ const {
806
+ targetBytes,
807
+ chunkBytes = DEFAULT_CHUNK_BYTES,
808
+ owMaxBytes = OW_MAX_BYTES,
809
+ fragmentBytes = FRAGMENT_MAX_BYTES
810
+ } = options;
811
+ const modality = options.modality ?? "CT";
812
+ const uid = options.uid ?? makeUidGenerator(options.seed)(0);
813
+ const identity = { modality, uid, tags: options.tags };
814
+ mkdirSync2(resolve2(outPath, ".."), { recursive: true });
815
+ const zeroChunk = Buffer.alloc(Math.max(1, chunkBytes));
816
+ const probe = serializeDict(
817
+ buildMeta(uid, modality),
818
+ buildBaseImageDataset(uid, modality)
819
+ );
820
+ const nativeHeaderLen = probe.length - 2;
821
+ const requestedPixel = Math.max(2, targetBytes - nativeHeaderLen);
822
+ if (requestedPixel <= owMaxBytes) {
823
+ const dims = nativeDimensions(requestedPixel);
824
+ const bytesWritten2 = writeNative(outPath, identity, dims, zeroChunk);
825
+ return {
826
+ path: outPath,
827
+ bytesWritten: bytesWritten2,
828
+ encoding: "native",
829
+ pixelBytes: dims.bytes
830
+ };
831
+ }
832
+ const estimateFrags = Math.max(
833
+ 1,
834
+ Math.ceil((targetBytes - nativeHeaderLen - 16) / fragmentBytes)
835
+ );
836
+ const overhead = nativeHeaderLen + 8 + estimateFrags * 8 + 8;
837
+ let pixelBytes = Math.max(2, targetBytes - overhead);
838
+ pixelBytes -= pixelBytes % 2;
839
+ const bytesWritten = writeEncapsulated(
840
+ outPath,
841
+ identity,
842
+ pixelBytes,
843
+ fragmentBytes - fragmentBytes % 2,
844
+ zeroChunk
845
+ );
846
+ return { path: outPath, bytesWritten, encoding: "encapsulated", pixelBytes };
847
+ }
848
+ function writeLargeImageFile(spec, outPath, options = {}) {
849
+ const { targetBytes } = spec;
850
+ if (!Number.isInteger(targetBytes)) {
851
+ throw new Error("writeLargeImageFile: targetBytes must be an integer");
852
+ }
853
+ if (targetBytes <= MAX_PIXEL_BYTES) {
854
+ throw new Error(
855
+ "writeLargeImageFile is for files above the 512 MB in-memory limit \u2014 use targetSizeKb for smaller files"
856
+ );
857
+ }
858
+ if (targetBytes > MAX_STREAM_BYTES) {
859
+ throw new Error("writeLargeImageFile: targetBytes exceeds the 50 GB limit");
860
+ }
861
+ return streamLargeImage(outPath, {
862
+ targetBytes,
863
+ modality: spec.modality,
864
+ seed: spec.seed,
865
+ chunkBytes: options.chunkBytes
866
+ });
867
+ }
868
+
606
869
  // src/syntheticFixtures/violations.ts
607
870
  var dcmjsAny = dcmjs;
608
871
  function parseBuffer(buffer) {
@@ -716,11 +979,13 @@ function studyFileCount(studies) {
716
979
  0
717
980
  );
718
981
  }
982
+ function resolveUid(seed, index, override) {
983
+ return { ...makeUidGenerator(seed)(index), ...override };
984
+ }
719
985
  async function generateFile(spec, options) {
720
986
  const index = options?.index ?? 0;
721
987
  const padWidth = options?.padWidth ?? 3;
722
- const uidGen = makeUidGenerator(options?.seed);
723
- const uid = { ...uidGen(index), ...options?.uid };
988
+ const uid = resolveUid(options?.seed, index, options?.uid);
724
989
  let buffer = buildBufferForSpec(spec, uid);
725
990
  const violations = "violations" in spec ? spec.violations : void 0;
726
991
  if (violations?.length) {
@@ -785,25 +1050,45 @@ function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
785
1050
  relativePath: `study-${pad3(studyOrdinal + 1)}/series-${pad3(seriesIndex + 1)}/${name}`
786
1051
  };
787
1052
  }
788
- async function* generateCollectionFromSpec(spec) {
1053
+ function withResolvedSeed(spec) {
1054
+ return spec.seed === void 0 ? { ...spec, seed: randomBytes2(4).readUInt32BE(0) } : spec;
1055
+ }
1056
+ function computeNames(type, index, padWidth, grouped, quirks) {
1057
+ let names;
1058
+ if (grouped) {
1059
+ names = hierarchicalName(
1060
+ grouped.studyOrdinal,
1061
+ grouped.seriesIndex,
1062
+ grouped.instanceNumber
1063
+ );
1064
+ } else {
1065
+ const name = filename(type, index, padWidth);
1066
+ names = { filename: name, relativePath: name };
1067
+ }
1068
+ return quirks.length === 0 ? names : applyPathQuirks(names.relativePath, quirks);
1069
+ }
1070
+ function* planCollection(spec) {
789
1071
  const flatEntries = spec.entries ?? [];
790
1072
  const studies = spec.studies ?? [];
791
1073
  const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
792
1074
  const padWidth = String(totalFiles).length;
793
1075
  const hierarchical = spec.layout === "hierarchical";
794
1076
  const quirks = spec.pathQuirks ?? [];
795
- const decorate = (file) => quirks.length === 0 ? file : { ...file, ...applyPathQuirks(file.relativePath, quirks) };
796
1077
  let globalIndex = 0;
797
1078
  for (const entry of flatEntries) {
798
1079
  const { count = 1, ...fileSpec } = entry;
799
1080
  for (let i = 0; i < count; i++) {
800
- yield decorate(
801
- await generateFile(fileSpec, {
802
- index: globalIndex,
803
- seed: spec.seed,
804
- padWidth
805
- })
806
- );
1081
+ yield {
1082
+ fileSpec,
1083
+ index: globalIndex,
1084
+ ...computeNames(
1085
+ fileSpec.type,
1086
+ globalIndex,
1087
+ padWidth,
1088
+ void 0,
1089
+ quirks
1090
+ )
1091
+ };
807
1092
  globalIndex++;
808
1093
  }
809
1094
  }
@@ -826,25 +1111,18 @@ async function* generateCollectionFromSpec(spec) {
826
1111
  ...series.tags,
827
1112
  ...fileSpec.tags
828
1113
  };
829
- const file = await generateFile(
830
- { ...fileSpec, tags },
831
- {
832
- index: globalIndex,
833
- seed: spec.seed,
1114
+ yield {
1115
+ fileSpec: { ...fileSpec, tags },
1116
+ index: globalIndex,
1117
+ uidOverride: { study: studyUid, series: seriesUid },
1118
+ ...computeNames(
1119
+ fileSpec.type,
1120
+ globalIndex,
834
1121
  padWidth,
835
- uid: { study: studyUid, series: seriesUid }
836
- }
837
- );
838
- yield decorate(
839
- hierarchical ? {
840
- ...file,
841
- ...hierarchicalName(
842
- studyOrdinal,
843
- seriesIndex,
844
- instanceNumber
845
- )
846
- } : file
847
- );
1122
+ hierarchical ? { studyOrdinal, seriesIndex, instanceNumber } : void 0,
1123
+ quirks
1124
+ )
1125
+ };
848
1126
  globalIndex++;
849
1127
  }
850
1128
  }
@@ -853,26 +1131,90 @@ async function* generateCollectionFromSpec(spec) {
853
1131
  }
854
1132
  }
855
1133
  }
1134
+ async function materialisePlan(plan, seed) {
1135
+ const file = await generateFile(plan.fileSpec, {
1136
+ index: plan.index,
1137
+ seed,
1138
+ uid: plan.uidOverride
1139
+ });
1140
+ return { ...file, filename: plan.filename, relativePath: plan.relativePath };
1141
+ }
1142
+ async function* generateCollectionFromSpec(spec) {
1143
+ for (const plan of planCollection(spec)) {
1144
+ yield await materialisePlan(plan, spec.seed);
1145
+ }
1146
+ }
1147
+ async function* previewCollection(spec) {
1148
+ for (const plan of planCollection(spec)) {
1149
+ if (plan.fileSpec.type === "large-image") {
1150
+ yield {
1151
+ relativePath: plan.relativePath,
1152
+ type: "large-image",
1153
+ index: plan.index,
1154
+ approxBytes: plan.fileSpec.targetBytes
1155
+ };
1156
+ } else {
1157
+ const file = await materialisePlan(plan, spec.seed);
1158
+ yield {
1159
+ relativePath: plan.relativePath,
1160
+ type: plan.fileSpec.type,
1161
+ index: plan.index,
1162
+ approxBytes: file.buffer.length
1163
+ };
1164
+ }
1165
+ }
1166
+ }
856
1167
  async function writeCollectionFromSpec(spec, outDir) {
857
- const root = resolve2(outDir);
858
- mkdirSync2(root, { recursive: true });
1168
+ const root = resolve3(outDir);
1169
+ mkdirSync3(root, { recursive: true });
859
1170
  const manifest = [];
860
1171
  const createdDirs = /* @__PURE__ */ new Set([root]);
861
- for await (const file of generateCollectionFromSpec(spec)) {
862
- const filePath = resolve2(root, file.relativePath);
1172
+ const ensureDir = (filePath) => {
863
1173
  const dir = dirname(filePath);
864
1174
  if (!createdDirs.has(dir)) {
865
- mkdirSync2(dir, { recursive: true });
1175
+ mkdirSync3(dir, { recursive: true });
866
1176
  createdDirs.add(dir);
867
1177
  }
868
- await writeFile(filePath, file.buffer);
869
- manifest.push({ path: filePath, type: file.type, index: file.index });
1178
+ };
1179
+ for (const plan of planCollection(spec)) {
1180
+ const filePath = resolve3(root, plan.relativePath);
1181
+ ensureDir(filePath);
1182
+ if (plan.fileSpec.type === "large-image") {
1183
+ const large = plan.fileSpec;
1184
+ if (large.targetBytes > MAX_STREAM_BYTES) {
1185
+ throw new Error(
1186
+ `large-image targetBytes (${large.targetBytes}) exceeds the 50 GB limit`
1187
+ );
1188
+ }
1189
+ const uid = resolveUid(spec.seed, plan.index, plan.uidOverride);
1190
+ streamLargeImage(filePath, {
1191
+ targetBytes: large.targetBytes,
1192
+ modality: large.modality,
1193
+ uid,
1194
+ tags: large.tags
1195
+ });
1196
+ } else {
1197
+ const file = await materialisePlan(plan, spec.seed);
1198
+ await writeFile(filePath, file.buffer);
1199
+ }
1200
+ manifest.push({
1201
+ path: filePath,
1202
+ type: plan.fileSpec.type,
1203
+ index: plan.index
1204
+ });
870
1205
  }
871
1206
  return manifest;
872
1207
  }
873
1208
 
874
1209
  // src/describe/describe.ts
875
- import { readdirSync, readFileSync } from "node:fs";
1210
+ import {
1211
+ closeSync as closeSync2,
1212
+ openSync as openSync2,
1213
+ readdirSync,
1214
+ readFileSync,
1215
+ readSync,
1216
+ statSync
1217
+ } from "node:fs";
876
1218
  import { join } from "node:path";
877
1219
  var dcmjsAny2 = dcmjs;
878
1220
  var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
@@ -917,18 +1259,77 @@ function* walkFiles(dir) {
917
1259
  }
918
1260
  }
919
1261
  }
920
- function parseFile(path) {
1262
+ function toArrayBuffer(buf) {
1263
+ return buf.buffer.slice(
1264
+ buf.byteOffset,
1265
+ buf.byteOffset + buf.byteLength
1266
+ );
1267
+ }
1268
+ function naturalize(ab) {
1269
+ const parsed = dcmjsAny2.data.DicomMessage.readFile(ab);
1270
+ return dcmjsAny2.data.DicomMetaDictionary.naturalizeDataset(parsed.dict);
1271
+ }
1272
+ function parseFull(path) {
1273
+ try {
1274
+ return naturalize(toArrayBuffer(readFileSync(path)));
1275
+ } catch {
1276
+ return null;
1277
+ }
1278
+ }
1279
+ var PIXEL_DATA_OW = Buffer.from([224, 127, 16, 0, 79, 87]);
1280
+ var PIXEL_DATA_OB = Buffer.from([224, 127, 16, 0, 79, 66]);
1281
+ var EMPTY_OW_PIXEL_DATA = Buffer.from([
1282
+ 224,
1283
+ 127,
1284
+ 16,
1285
+ 0,
1286
+ 79,
1287
+ 87,
1288
+ 0,
1289
+ 0,
1290
+ 0,
1291
+ 0,
1292
+ 0,
1293
+ 0
1294
+ ]);
1295
+ var HEADER_READ_BYTES = 2 * 1024 * 1024;
1296
+ function findPixelDataOffset(prefix) {
1297
+ let best = -1;
1298
+ for (const pattern of [PIXEL_DATA_OW, PIXEL_DATA_OB]) {
1299
+ for (let from = 0; ; ) {
1300
+ const i = prefix.indexOf(pattern, from);
1301
+ if (i < 0) break;
1302
+ if (prefix[i + 6] === 0 && prefix[i + 7] === 0) {
1303
+ if (best < 0 || i < best) best = i;
1304
+ break;
1305
+ }
1306
+ from = i + 1;
1307
+ }
1308
+ }
1309
+ return best;
1310
+ }
1311
+ function readHeaderOnly(path) {
1312
+ let prefix;
921
1313
  try {
922
- const buf = readFileSync(path);
923
- const ab = buf.buffer.slice(
924
- buf.byteOffset,
925
- buf.byteOffset + buf.byteLength
926
- );
927
- const parsed = dcmjsAny2.data.DicomMessage.readFile(ab);
928
- const record = dcmjsAny2.data.DicomMetaDictionary.naturalizeDataset(
929
- parsed.dict
930
- );
931
- return { record, size: buf.byteLength };
1314
+ const fd = openSync2(path, "r");
1315
+ try {
1316
+ const buf = Buffer.alloc(HEADER_READ_BYTES);
1317
+ const n = readSync(fd, buf, 0, HEADER_READ_BYTES, 0);
1318
+ prefix = buf.subarray(0, n);
1319
+ } finally {
1320
+ closeSync2(fd);
1321
+ }
1322
+ } catch {
1323
+ return null;
1324
+ }
1325
+ const tagIdx = findPixelDataOffset(prefix);
1326
+ if (tagIdx < 0) return null;
1327
+ const headerOnly = Buffer.concat([
1328
+ prefix.subarray(0, tagIdx),
1329
+ EMPTY_OW_PIXEL_DATA
1330
+ ]);
1331
+ try {
1332
+ return naturalize(toArrayBuffer(headerOnly));
932
1333
  } catch {
933
1334
  return null;
934
1335
  }
@@ -950,14 +1351,25 @@ function describeDirectory(dir, options = {}) {
950
1351
  const studies = /* @__PURE__ */ new Map();
951
1352
  const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
952
1353
  for (const path of [...walkFiles(dir)].sort()) {
953
- const parsed = parseFile(path);
954
- const studyUid = parsed?.record.StudyInstanceUID;
955
- const seriesUid = parsed?.record.SeriesInstanceUID;
956
- if (!parsed || typeof studyUid !== "string" || typeof seriesUid !== "string") {
1354
+ let size;
1355
+ try {
1356
+ size = statSync(path).size;
1357
+ } catch {
1358
+ stats.skipped++;
1359
+ continue;
1360
+ }
1361
+ if (size > MAX_STREAM_BYTES) {
1362
+ stats.skipped++;
1363
+ continue;
1364
+ }
1365
+ const large = size > MAX_PIXEL_BYTES;
1366
+ const record = large ? readHeaderOnly(path) : parseFull(path);
1367
+ const studyUid = record?.StudyInstanceUID;
1368
+ const seriesUid = record?.SeriesInstanceUID;
1369
+ if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
957
1370
  stats.skipped++;
958
1371
  continue;
959
1372
  }
960
- const { record } = parsed;
961
1373
  stats.dicomFiles++;
962
1374
  let modality;
963
1375
  if (typeof record.Modality === "string") {
@@ -967,7 +1379,7 @@ function describeDirectory(dir, options = {}) {
967
1379
  stats.unknownModality++;
968
1380
  }
969
1381
  }
970
- const sizeKb = Math.max(1, Math.round(parsed.size / 1024));
1382
+ const sizeKb = Math.max(1, Math.round(size / 1024));
971
1383
  const tags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
972
1384
  let study = studies.get(studyUid);
973
1385
  if (!study) {
@@ -979,25 +1391,27 @@ function describeDirectory(dir, options = {}) {
979
1391
  series = /* @__PURE__ */ new Map();
980
1392
  study.set(seriesUid, series);
981
1393
  }
982
- const collapseKey = JSON.stringify([modality ?? null, sizeKb, tags ?? null]);
1394
+ const collapseKey = JSON.stringify([
1395
+ large,
1396
+ modality ?? null,
1397
+ large ? size : sizeKb,
1398
+ tags ?? null
1399
+ ]);
983
1400
  const existing = series.get(collapseKey);
984
1401
  if (existing) {
985
1402
  existing.count++;
986
1403
  } else {
987
- series.set(collapseKey, {
988
- modality,
989
- targetSizeKb: sizeKb,
990
- tags,
991
- count: 1
992
- });
1404
+ series.set(collapseKey, { modality, large, bytes: size, tags, count: 1 });
993
1405
  }
994
1406
  }
995
1407
  const studySpecs = [...studies.values()].map((study) => {
996
1408
  const seriesSpecs = [...study.values()].map((series) => {
997
1409
  const entries = [...series.values()].map((acc) => ({
998
- type: "valid-image",
999
1410
  ...acc.modality !== void 0 ? { modality: acc.modality } : {},
1000
- targetSizeKb: acc.targetSizeKb,
1411
+ ...acc.large ? { type: "large-image", targetBytes: acc.bytes } : {
1412
+ type: "valid-image",
1413
+ targetSizeKb: Math.max(1, Math.round(acc.bytes / 1024))
1414
+ },
1001
1415
  ...acc.tags !== void 0 ? { tags: acc.tags } : {},
1002
1416
  ...acc.count > 1 ? { count: acc.count } : {}
1003
1417
  }));
@@ -1046,7 +1460,7 @@ function loadCaseById(casesJsonPath, id) {
1046
1460
 
1047
1461
  // src/public-fixtures/fetch.ts
1048
1462
  import { createHash } from "node:crypto";
1049
- import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
1463
+ import { existsSync, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
1050
1464
  import { homedir } from "node:os";
1051
1465
  import { join as join3 } from "node:path";
1052
1466
  var DEFAULT_CACHE_ROOT = join3(homedir(), ".cache", "dicom-synth-testcases");
@@ -1073,7 +1487,7 @@ async function fetchPublicCaseToCache(record, cacheRoot = DEFAULT_CACHE_ROOT) {
1073
1487
  verifySha256(buf2, record.sha256);
1074
1488
  return dest;
1075
1489
  }
1076
- mkdirSync3(join3(cacheRoot, record.sha256), { recursive: true });
1490
+ mkdirSync4(join3(cacheRoot, record.sha256), { recursive: true });
1077
1491
  const res = await fetch(record.source.url);
1078
1492
  if (!res.ok) {
1079
1493
  throw new Error(
@@ -1087,11 +1501,17 @@ async function fetchPublicCaseToCache(record, cacheRoot = DEFAULT_CACHE_ROOT) {
1087
1501
  }
1088
1502
 
1089
1503
  // src/schema/parametric.ts
1090
- import { randomBytes as randomBytes2 } from "node:crypto";
1504
+ import { randomBytes as randomBytes3 } from "node:crypto";
1091
1505
  function sample(next, range) {
1092
1506
  if (typeof range === "number") return range;
1093
1507
  return range.min + next() % (range.max - range.min + 1);
1094
1508
  }
1509
+ var BYTES_PER_MB = 1024 * 1024;
1510
+ function sampleBytes(next, range) {
1511
+ if (typeof range === "number") return range;
1512
+ const spanMb = Math.floor((range.max - range.min) / BYTES_PER_MB) + 1;
1513
+ return range.min + next() % spanMb * BYTES_PER_MB;
1514
+ }
1095
1515
  function pick(next, items) {
1096
1516
  return items[next() % items.length];
1097
1517
  }
@@ -1100,7 +1520,7 @@ function fraction(next) {
1100
1520
  }
1101
1521
  function resolveParametricSpec(spec) {
1102
1522
  const validated = validateParametricSpec(spec);
1103
- const seed = validated.seed ?? randomBytes2(4).readUInt32BE(0);
1523
+ const seed = validated.seed ?? randomBytes3(4).readUInt32BE(0);
1104
1524
  const next = seededStream(seed, PARAMETRIC_STREAM_OFFSET, 0, 0);
1105
1525
  const params = validated.studies;
1106
1526
  const studies = [];
@@ -1132,6 +1552,30 @@ function resolveParametricSpec(spec) {
1132
1552
  ...fileCount > 1 ? { count: fileCount } : {}
1133
1553
  });
1134
1554
  }
1555
+ if (params.largeFilesPerSeries !== void 0 && params.largeFileBytes !== void 0) {
1556
+ const largeCount = sample(next, params.largeFilesPerSeries);
1557
+ const largeBase = {
1558
+ type: "large-image",
1559
+ targetBytes: 0,
1560
+ ...modality !== void 0 ? { modality } : {}
1561
+ };
1562
+ if (typeof params.largeFileBytes === "number") {
1563
+ if (largeCount > 0) {
1564
+ entries2.push({
1565
+ ...largeBase,
1566
+ targetBytes: params.largeFileBytes,
1567
+ ...largeCount > 1 ? { count: largeCount } : {}
1568
+ });
1569
+ }
1570
+ } else {
1571
+ for (let lf = 0; lf < largeCount; lf++) {
1572
+ entries2.push({
1573
+ ...largeBase,
1574
+ targetBytes: sampleBytes(next, params.largeFileBytes)
1575
+ });
1576
+ }
1577
+ }
1578
+ }
1135
1579
  series.push({ entries: entries2 });
1136
1580
  }
1137
1581
  studies.push({
@@ -1160,6 +1604,8 @@ function resolveParametricSpec(spec) {
1160
1604
  };
1161
1605
  }
1162
1606
  export {
1607
+ MAX_PIXEL_BYTES,
1608
+ MAX_STREAM_BYTES,
1163
1609
  caseCachePath,
1164
1610
  defaultPublicCasesPath,
1165
1611
  describeDirectory,
@@ -1169,9 +1615,13 @@ export {
1169
1615
  loadCaseById,
1170
1616
  loadCasesFromJson,
1171
1617
  loadDefaultCases,
1618
+ previewCollection,
1172
1619
  resolveParametricSpec,
1620
+ streamLargeImage,
1173
1621
  validateDatasetSpec,
1174
1622
  validateParametricSpec,
1175
1623
  verifySha256,
1176
- writeCollectionFromSpec
1624
+ withResolvedSeed,
1625
+ writeCollectionFromSpec,
1626
+ writeLargeImageFile
1177
1627
  };