dicom-synth 1.8.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,8 +1,12 @@
1
1
  // src/collection/writer.ts
2
2
  import { randomBytes as randomBytes2 } from "node:crypto";
3
- import { mkdirSync as mkdirSync2 } from "node:fs";
3
+ import { mkdirSync as mkdirSync3 } from "node:fs";
4
4
  import { writeFile } from "node:fs/promises";
5
- 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;
6
10
 
7
11
  // src/loadDcmjs.ts
8
12
  import dcmjsDefaultImport, * as dcmjsNamespace from "dcmjs";
@@ -50,6 +54,7 @@ var TYPE_CAPABILITIES = {
50
54
  "valid-image": { image: true },
51
55
  "invalid-uid-image": { image: true },
52
56
  "vendor-warnings-image": { image: true },
57
+ "large-image": { image: true },
53
58
  "fake-signature": { image: false },
54
59
  "non-dicom": { image: false },
55
60
  dicomdir: { image: false }
@@ -74,7 +79,6 @@ var VALID_TRANSFER_SYNTAXES = {
74
79
  "explicit-vr-little-endian": true,
75
80
  "implicit-vr-little-endian": true
76
81
  };
77
- var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
78
82
  var VALID_VIOLATIONS = {
79
83
  "uid-too-long": true,
80
84
  "non-conformant-uid": true,
@@ -92,6 +96,13 @@ function validatePositiveInt(value, path) {
92
96
  );
93
97
  }
94
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
+ }
95
106
  function validateSeed(value, path) {
96
107
  if (typeof value !== "number" || !Number.isFinite(value)) {
97
108
  throw new Error(
@@ -104,6 +115,43 @@ function validateSizeKbLimit(kb, path) {
104
115
  throw new Error(`${path}: exceeds the 512 MB limit`);
105
116
  }
106
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
+ }
107
155
  function validateEnum(value, valid, path) {
108
156
  if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
109
157
  throw new Error(
@@ -127,6 +175,15 @@ function validateEntry(entry, path) {
127
175
  const type = e.type;
128
176
  const caps = TYPE_CAPABILITIES[type];
129
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
+ }
130
187
  for (const field of [
131
188
  "modality",
132
189
  "rows",
@@ -272,19 +329,20 @@ function validateDatasetSpec(raw) {
272
329
  ...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
273
330
  };
274
331
  }
275
- function validateRange(value, path) {
332
+ function validateRange(value, path, options = {}) {
333
+ const checkInt = options.allowZero ? validateNonNegativeInt : validatePositiveInt;
276
334
  if (typeof value === "number") {
277
- validatePositiveInt(value, path);
335
+ checkInt(value, path);
278
336
  return value;
279
337
  }
280
338
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
281
339
  throw new Error(
282
- `${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)}`
283
341
  );
284
342
  }
285
343
  const r = value;
286
- validatePositiveInt(r.min, `${path}.min`);
287
- validatePositiveInt(r.max, `${path}.max`);
344
+ checkInt(r.min, `${path}.min`);
345
+ checkInt(r.max, `${path}.max`);
288
346
  if (r.min > r.max) {
289
347
  throw new Error(`${path}: min (${r.min}) must be \u2264 max (${r.max})`);
290
348
  }
@@ -293,6 +351,9 @@ function validateRange(value, path) {
293
351
  function rangeMax(range) {
294
352
  return typeof range === "number" ? range : range.max;
295
353
  }
354
+ function rangeMin(range) {
355
+ return typeof range === "number" ? range : range.min;
356
+ }
296
357
  function validateParametricStudies(studies, path) {
297
358
  if (typeof studies !== "object" || studies === null || Array.isArray(studies)) {
298
359
  throw new Error(`${path}: must be an object`);
@@ -306,6 +367,32 @@ function validateParametricStudies(studies, path) {
306
367
  validateRange(s.fileSizeKb, `${path}.fileSizeKb`);
307
368
  validateSizeKbLimit(rangeMax(s.fileSizeKb), `${path}.fileSizeKb`);
308
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
+ }
309
396
  if ("modalities" in s) {
310
397
  if (!Array.isArray(s.modalities) || s.modalities.length === 0) {
311
398
  throw new Error(`${path}.modalities: must be a non-empty array`);
@@ -535,9 +622,17 @@ function buildBufferForSpec(spec, uid) {
535
622
  return buildNonDicomBuffer(spec.content);
536
623
  case "dicomdir":
537
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
+ );
538
629
  }
539
630
  }
540
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
+
541
636
  // src/syntheticFixtures/uid.ts
542
637
  import { randomBytes } from "node:crypto";
543
638
  var ROLE_CODE = { study: 1, series: 2, sop: 3 };
@@ -604,6 +699,173 @@ function makeUidGenerator(seed) {
604
699
  };
605
700
  }
606
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
+
607
869
  // src/syntheticFixtures/violations.ts
608
870
  var dcmjsAny = dcmjs;
609
871
  function parseBuffer(buffer) {
@@ -717,11 +979,13 @@ function studyFileCount(studies) {
717
979
  0
718
980
  );
719
981
  }
982
+ function resolveUid(seed, index, override) {
983
+ return { ...makeUidGenerator(seed)(index), ...override };
984
+ }
720
985
  async function generateFile(spec, options) {
721
986
  const index = options?.index ?? 0;
722
987
  const padWidth = options?.padWidth ?? 3;
723
- const uidGen = makeUidGenerator(options?.seed);
724
- const uid = { ...uidGen(index), ...options?.uid };
988
+ const uid = resolveUid(options?.seed, index, options?.uid);
725
989
  let buffer = buildBufferForSpec(spec, uid);
726
990
  const violations = "violations" in spec ? spec.violations : void 0;
727
991
  if (violations?.length) {
@@ -789,25 +1053,42 @@ function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
789
1053
  function withResolvedSeed(spec) {
790
1054
  return spec.seed === void 0 ? { ...spec, seed: randomBytes2(4).readUInt32BE(0) } : spec;
791
1055
  }
792
- async function* generateCollectionFromSpec(spec) {
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) {
793
1071
  const flatEntries = spec.entries ?? [];
794
1072
  const studies = spec.studies ?? [];
795
1073
  const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
796
1074
  const padWidth = String(totalFiles).length;
797
1075
  const hierarchical = spec.layout === "hierarchical";
798
1076
  const quirks = spec.pathQuirks ?? [];
799
- const decorate = (file) => quirks.length === 0 ? file : { ...file, ...applyPathQuirks(file.relativePath, quirks) };
800
1077
  let globalIndex = 0;
801
1078
  for (const entry of flatEntries) {
802
1079
  const { count = 1, ...fileSpec } = entry;
803
1080
  for (let i = 0; i < count; i++) {
804
- yield decorate(
805
- await generateFile(fileSpec, {
806
- index: globalIndex,
807
- seed: spec.seed,
808
- padWidth
809
- })
810
- );
1081
+ yield {
1082
+ fileSpec,
1083
+ index: globalIndex,
1084
+ ...computeNames(
1085
+ fileSpec.type,
1086
+ globalIndex,
1087
+ padWidth,
1088
+ void 0,
1089
+ quirks
1090
+ )
1091
+ };
811
1092
  globalIndex++;
812
1093
  }
813
1094
  }
@@ -830,25 +1111,18 @@ async function* generateCollectionFromSpec(spec) {
830
1111
  ...series.tags,
831
1112
  ...fileSpec.tags
832
1113
  };
833
- const file = await generateFile(
834
- { ...fileSpec, tags },
835
- {
836
- index: globalIndex,
837
- 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,
838
1121
  padWidth,
839
- uid: { study: studyUid, series: seriesUid }
840
- }
841
- );
842
- yield decorate(
843
- hierarchical ? {
844
- ...file,
845
- ...hierarchicalName(
846
- studyOrdinal,
847
- seriesIndex,
848
- instanceNumber
849
- )
850
- } : file
851
- );
1122
+ hierarchical ? { studyOrdinal, seriesIndex, instanceNumber } : void 0,
1123
+ quirks
1124
+ )
1125
+ };
852
1126
  globalIndex++;
853
1127
  }
854
1128
  }
@@ -857,26 +1131,90 @@ async function* generateCollectionFromSpec(spec) {
857
1131
  }
858
1132
  }
859
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
+ }
860
1167
  async function writeCollectionFromSpec(spec, outDir) {
861
- const root = resolve2(outDir);
862
- mkdirSync2(root, { recursive: true });
1168
+ const root = resolve3(outDir);
1169
+ mkdirSync3(root, { recursive: true });
863
1170
  const manifest = [];
864
1171
  const createdDirs = /* @__PURE__ */ new Set([root]);
865
- for await (const file of generateCollectionFromSpec(spec)) {
866
- const filePath = resolve2(root, file.relativePath);
1172
+ const ensureDir = (filePath) => {
867
1173
  const dir = dirname(filePath);
868
1174
  if (!createdDirs.has(dir)) {
869
- mkdirSync2(dir, { recursive: true });
1175
+ mkdirSync3(dir, { recursive: true });
870
1176
  createdDirs.add(dir);
871
1177
  }
872
- await writeFile(filePath, file.buffer);
873
- 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
+ });
874
1205
  }
875
1206
  return manifest;
876
1207
  }
877
1208
 
878
1209
  // src/describe/describe.ts
879
- 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";
880
1218
  import { join } from "node:path";
881
1219
  var dcmjsAny2 = dcmjs;
882
1220
  var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
@@ -921,18 +1259,77 @@ function* walkFiles(dir) {
921
1259
  }
922
1260
  }
923
1261
  }
924
- 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) {
925
1273
  try {
926
- const buf = readFileSync(path);
927
- const ab = buf.buffer.slice(
928
- buf.byteOffset,
929
- buf.byteOffset + buf.byteLength
930
- );
931
- const parsed = dcmjsAny2.data.DicomMessage.readFile(ab);
932
- const record = dcmjsAny2.data.DicomMetaDictionary.naturalizeDataset(
933
- parsed.dict
934
- );
935
- return { record, size: buf.byteLength };
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;
1313
+ try {
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));
936
1333
  } catch {
937
1334
  return null;
938
1335
  }
@@ -954,14 +1351,25 @@ function describeDirectory(dir, options = {}) {
954
1351
  const studies = /* @__PURE__ */ new Map();
955
1352
  const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
956
1353
  for (const path of [...walkFiles(dir)].sort()) {
957
- const parsed = parseFile(path);
958
- const studyUid = parsed?.record.StudyInstanceUID;
959
- const seriesUid = parsed?.record.SeriesInstanceUID;
960
- 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") {
961
1370
  stats.skipped++;
962
1371
  continue;
963
1372
  }
964
- const { record } = parsed;
965
1373
  stats.dicomFiles++;
966
1374
  let modality;
967
1375
  if (typeof record.Modality === "string") {
@@ -971,7 +1379,7 @@ function describeDirectory(dir, options = {}) {
971
1379
  stats.unknownModality++;
972
1380
  }
973
1381
  }
974
- const sizeKb = Math.max(1, Math.round(parsed.size / 1024));
1382
+ const sizeKb = Math.max(1, Math.round(size / 1024));
975
1383
  const tags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
976
1384
  let study = studies.get(studyUid);
977
1385
  if (!study) {
@@ -983,25 +1391,27 @@ function describeDirectory(dir, options = {}) {
983
1391
  series = /* @__PURE__ */ new Map();
984
1392
  study.set(seriesUid, series);
985
1393
  }
986
- 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
+ ]);
987
1400
  const existing = series.get(collapseKey);
988
1401
  if (existing) {
989
1402
  existing.count++;
990
1403
  } else {
991
- series.set(collapseKey, {
992
- modality,
993
- targetSizeKb: sizeKb,
994
- tags,
995
- count: 1
996
- });
1404
+ series.set(collapseKey, { modality, large, bytes: size, tags, count: 1 });
997
1405
  }
998
1406
  }
999
1407
  const studySpecs = [...studies.values()].map((study) => {
1000
1408
  const seriesSpecs = [...study.values()].map((series) => {
1001
1409
  const entries = [...series.values()].map((acc) => ({
1002
- type: "valid-image",
1003
1410
  ...acc.modality !== void 0 ? { modality: acc.modality } : {},
1004
- 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
+ },
1005
1415
  ...acc.tags !== void 0 ? { tags: acc.tags } : {},
1006
1416
  ...acc.count > 1 ? { count: acc.count } : {}
1007
1417
  }));
@@ -1050,7 +1460,7 @@ function loadCaseById(casesJsonPath, id) {
1050
1460
 
1051
1461
  // src/public-fixtures/fetch.ts
1052
1462
  import { createHash } from "node:crypto";
1053
- 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";
1054
1464
  import { homedir } from "node:os";
1055
1465
  import { join as join3 } from "node:path";
1056
1466
  var DEFAULT_CACHE_ROOT = join3(homedir(), ".cache", "dicom-synth-testcases");
@@ -1077,7 +1487,7 @@ async function fetchPublicCaseToCache(record, cacheRoot = DEFAULT_CACHE_ROOT) {
1077
1487
  verifySha256(buf2, record.sha256);
1078
1488
  return dest;
1079
1489
  }
1080
- mkdirSync3(join3(cacheRoot, record.sha256), { recursive: true });
1490
+ mkdirSync4(join3(cacheRoot, record.sha256), { recursive: true });
1081
1491
  const res = await fetch(record.source.url);
1082
1492
  if (!res.ok) {
1083
1493
  throw new Error(
@@ -1096,6 +1506,12 @@ function sample(next, range) {
1096
1506
  if (typeof range === "number") return range;
1097
1507
  return range.min + next() % (range.max - range.min + 1);
1098
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
+ }
1099
1515
  function pick(next, items) {
1100
1516
  return items[next() % items.length];
1101
1517
  }
@@ -1136,6 +1552,30 @@ function resolveParametricSpec(spec) {
1136
1552
  ...fileCount > 1 ? { count: fileCount } : {}
1137
1553
  });
1138
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
+ }
1139
1579
  series.push({ entries: entries2 });
1140
1580
  }
1141
1581
  studies.push({
@@ -1164,6 +1604,8 @@ function resolveParametricSpec(spec) {
1164
1604
  };
1165
1605
  }
1166
1606
  export {
1607
+ MAX_PIXEL_BYTES,
1608
+ MAX_STREAM_BYTES,
1167
1609
  caseCachePath,
1168
1610
  defaultPublicCasesPath,
1169
1611
  describeDirectory,
@@ -1173,10 +1615,13 @@ export {
1173
1615
  loadCaseById,
1174
1616
  loadCasesFromJson,
1175
1617
  loadDefaultCases,
1618
+ previewCollection,
1176
1619
  resolveParametricSpec,
1620
+ streamLargeImage,
1177
1621
  validateDatasetSpec,
1178
1622
  validateParametricSpec,
1179
1623
  verifySha256,
1180
1624
  withResolvedSeed,
1181
- writeCollectionFromSpec
1625
+ writeCollectionFromSpec,
1626
+ writeLargeImageFile
1182
1627
  };