dicom-synth 1.8.0 → 1.10.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";
@@ -45,11 +49,56 @@ function buildDicomdirBuffer() {
45
49
  return buf.subarray(0, off);
46
50
  }
47
51
 
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 escapeTagTemplate(value) {
61
+ return value.replace(/\{/g, "{{").replace(/\}/g, "}}");
62
+ }
63
+ function findTemplateTokens(value) {
64
+ const names = [];
65
+ for (const m of value.matchAll(TEMPLATE_PART_RE)) {
66
+ if (m[1] !== void 0) names.push(m[1]);
67
+ }
68
+ return names;
69
+ }
70
+ function resolveTagTemplates(tags, context) {
71
+ if (!tags) return tags;
72
+ const resolved = {};
73
+ for (const [key, value] of Object.entries(tags)) {
74
+ resolved[key] = typeof value === "string" ? resolveString(value, context) : value;
75
+ }
76
+ return resolved;
77
+ }
78
+ function resolveString(value, context) {
79
+ return value.replace(TEMPLATE_PART_RE, (match, name) => {
80
+ if (name === void 0) return match === "{{" ? "{" : "}";
81
+ if (!TEMPLATE_VOCAB.includes(name)) {
82
+ throw new Error(
83
+ `tag template: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
84
+ );
85
+ }
86
+ const resolved = context[name];
87
+ if (resolved === void 0) {
88
+ throw new Error(
89
+ `tag template: placeholder "{${name}}" is not available here \u2014 it only applies inside grouped studies`
90
+ );
91
+ }
92
+ return String(resolved);
93
+ });
94
+ }
95
+
48
96
  // src/schema/validate.ts
49
97
  var TYPE_CAPABILITIES = {
50
98
  "valid-image": { image: true },
51
99
  "invalid-uid-image": { image: true },
52
100
  "vendor-warnings-image": { image: true },
101
+ "large-image": { image: true },
53
102
  "fake-signature": { image: false },
54
103
  "non-dicom": { image: false },
55
104
  dicomdir: { image: false }
@@ -74,7 +123,6 @@ var VALID_TRANSFER_SYNTAXES = {
74
123
  "explicit-vr-little-endian": true,
75
124
  "implicit-vr-little-endian": true
76
125
  };
77
- var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
78
126
  var VALID_VIOLATIONS = {
79
127
  "uid-too-long": true,
80
128
  "non-conformant-uid": true,
@@ -92,6 +140,13 @@ function validatePositiveInt(value, path) {
92
140
  );
93
141
  }
94
142
  }
143
+ function validateNonNegativeInt(value, path) {
144
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
145
+ throw new Error(
146
+ `${path}: must be a non-negative integer; got ${JSON.stringify(value)}`
147
+ );
148
+ }
149
+ }
95
150
  function validateSeed(value, path) {
96
151
  if (typeof value !== "number" || !Number.isFinite(value)) {
97
152
  throw new Error(
@@ -104,6 +159,43 @@ function validateSizeKbLimit(kb, path) {
104
159
  throw new Error(`${path}: exceeds the 512 MB limit`);
105
160
  }
106
161
  }
162
+ function validateLargeTargetBytes(value, path) {
163
+ if (typeof value !== "number" || !Number.isInteger(value)) {
164
+ throw new Error(`${path}: must be an integer; got ${JSON.stringify(value)}`);
165
+ }
166
+ if (value <= MAX_PIXEL_BYTES) {
167
+ throw new Error(
168
+ `${path}: must exceed 512 MB \u2014 use targetSizeKb for smaller files`
169
+ );
170
+ }
171
+ if (value > MAX_STREAM_BYTES) {
172
+ throw new Error(`${path}: exceeds the 50 GB limit`);
173
+ }
174
+ }
175
+ function validateLargeImageEntry(e, path) {
176
+ for (const field of [
177
+ "rows",
178
+ "columns",
179
+ "frames",
180
+ "targetSizeKb",
181
+ "violations",
182
+ "transferSyntax"
183
+ ]) {
184
+ if (field in e) {
185
+ throw new Error(
186
+ `${path}.${field}: not supported for type "large-image" \u2014 use targetBytes`
187
+ );
188
+ }
189
+ }
190
+ if (!("targetBytes" in e)) {
191
+ throw new Error(`${path}.targetBytes: is required for type "large-image"`);
192
+ }
193
+ validateLargeTargetBytes(e.targetBytes, `${path}.targetBytes`);
194
+ if ("modality" in e) {
195
+ validateEnum(e.modality, VALID_MODALITIES, `${path}.modality`);
196
+ }
197
+ if ("tags" in e) validateTags(e.tags, `${path}.tags`);
198
+ }
107
199
  function validateEnum(value, valid, path) {
108
200
  if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
109
201
  throw new Error(
@@ -127,6 +219,15 @@ function validateEntry(entry, path) {
127
219
  const type = e.type;
128
220
  const caps = TYPE_CAPABILITIES[type];
129
221
  if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
222
+ if (type === "large-image") {
223
+ validateLargeImageEntry(e, path);
224
+ return e;
225
+ }
226
+ if ("targetBytes" in e) {
227
+ throw new Error(
228
+ `${path}.targetBytes: only supported for type "large-image"`
229
+ );
230
+ }
130
231
  for (const field of [
131
232
  "modality",
132
233
  "rows",
@@ -186,12 +287,28 @@ function validateEntry(entry, path) {
186
287
  }
187
288
  return e;
188
289
  }
290
+ var MODALITY_TAG = "00080060";
189
291
  function validateTags(tags, path) {
190
292
  if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
191
293
  throw new Error(`${path}: must be an object`);
192
294
  }
193
- for (const key of Object.keys(tags)) {
295
+ for (const [key, value] of Object.entries(tags)) {
194
296
  validateTagKey(key, path);
297
+ const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
298
+ if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
299
+ throw new Error(
300
+ `${path}.${key}: set a preset modality (${Object.keys(VALID_MODALITIES).join("/")}) via the "modality" field, not tags \u2014 it is coupled to SOPClassUID and type-1 attributes`
301
+ );
302
+ }
303
+ if (typeof value === "string") {
304
+ for (const name of findTemplateTokens(value)) {
305
+ if (!TEMPLATE_VOCAB.includes(name)) {
306
+ throw new Error(
307
+ `${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
308
+ );
309
+ }
310
+ }
311
+ }
195
312
  }
196
313
  }
197
314
  function validateSeries(series, path) {
@@ -272,19 +389,20 @@ function validateDatasetSpec(raw) {
272
389
  ...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
273
390
  };
274
391
  }
275
- function validateRange(value, path) {
392
+ function validateRange(value, path, options = {}) {
393
+ const checkInt = options.allowZero ? validateNonNegativeInt : validatePositiveInt;
276
394
  if (typeof value === "number") {
277
- validatePositiveInt(value, path);
395
+ checkInt(value, path);
278
396
  return value;
279
397
  }
280
398
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
281
399
  throw new Error(
282
- `${path}: must be a positive integer or { min, max }; got ${JSON.stringify(value)}`
400
+ `${path}: must be an integer or { min, max }; got ${JSON.stringify(value)}`
283
401
  );
284
402
  }
285
403
  const r = value;
286
- validatePositiveInt(r.min, `${path}.min`);
287
- validatePositiveInt(r.max, `${path}.max`);
404
+ checkInt(r.min, `${path}.min`);
405
+ checkInt(r.max, `${path}.max`);
288
406
  if (r.min > r.max) {
289
407
  throw new Error(`${path}: min (${r.min}) must be \u2264 max (${r.max})`);
290
408
  }
@@ -293,6 +411,9 @@ function validateRange(value, path) {
293
411
  function rangeMax(range) {
294
412
  return typeof range === "number" ? range : range.max;
295
413
  }
414
+ function rangeMin(range) {
415
+ return typeof range === "number" ? range : range.min;
416
+ }
296
417
  function validateParametricStudies(studies, path) {
297
418
  if (typeof studies !== "object" || studies === null || Array.isArray(studies)) {
298
419
  throw new Error(`${path}: must be an object`);
@@ -306,6 +427,32 @@ function validateParametricStudies(studies, path) {
306
427
  validateRange(s.fileSizeKb, `${path}.fileSizeKb`);
307
428
  validateSizeKbLimit(rangeMax(s.fileSizeKb), `${path}.fileSizeKb`);
308
429
  }
430
+ if ("largeFilesPerSeries" in s) {
431
+ validateRange(s.largeFilesPerSeries, `${path}.largeFilesPerSeries`, {
432
+ allowZero: true
433
+ });
434
+ if (!("largeFileBytes" in s)) {
435
+ throw new Error(
436
+ `${path}.largeFileBytes: is required when largeFilesPerSeries is set`
437
+ );
438
+ }
439
+ }
440
+ if ("largeFileBytes" in s) {
441
+ if (!("largeFilesPerSeries" in s)) {
442
+ throw new Error(
443
+ `${path}.largeFilesPerSeries: is required when largeFileBytes is set`
444
+ );
445
+ }
446
+ validateRange(s.largeFileBytes, `${path}.largeFileBytes`);
447
+ validateLargeTargetBytes(
448
+ rangeMin(s.largeFileBytes),
449
+ `${path}.largeFileBytes (min)`
450
+ );
451
+ validateLargeTargetBytes(
452
+ rangeMax(s.largeFileBytes),
453
+ `${path}.largeFileBytes (max)`
454
+ );
455
+ }
309
456
  if ("modalities" in s) {
310
457
  if (!Array.isArray(s.modalities) || s.modalities.length === 0) {
311
458
  throw new Error(`${path}.modalities: must be a non-empty array`);
@@ -535,9 +682,17 @@ function buildBufferForSpec(spec, uid) {
535
682
  return buildNonDicomBuffer(spec.content);
536
683
  case "dicomdir":
537
684
  return buildDicomdirBuffer();
685
+ case "large-image":
686
+ throw new Error(
687
+ "large-image cannot be generated in memory \u2014 use writeCollectionFromSpec (disk-only streaming)"
688
+ );
538
689
  }
539
690
  }
540
691
 
692
+ // src/syntheticFixtures/streamWrite.ts
693
+ import { closeSync, mkdirSync as mkdirSync2, openSync, writeSync } from "node:fs";
694
+ import { resolve as resolve2 } from "node:path";
695
+
541
696
  // src/syntheticFixtures/uid.ts
542
697
  import { randomBytes } from "node:crypto";
543
698
  var ROLE_CODE = { study: 1, series: 2, sop: 3 };
@@ -604,6 +759,173 @@ function makeUidGenerator(seed) {
604
759
  };
605
760
  }
606
761
 
762
+ // src/syntheticFixtures/streamWrite.ts
763
+ var OW_MAX_BYTES = 4294967294;
764
+ var FRAGMENT_MAX_BYTES = 4294967294;
765
+ var DEFAULT_CHUNK_BYTES = 8 * 1024 * 1024;
766
+ var RLE_TRANSFER_SYNTAX_UID = "1.2.840.10008.1.2.5";
767
+ var PIXEL_DATA_OW_HEADER = Buffer.from([224, 127, 16, 0, 79, 87]);
768
+ var PIXEL_DATA_OB_HEADER = Buffer.from([224, 127, 16, 0, 79, 66]);
769
+ var ITEM_TAG_GROUP = 65534;
770
+ var ITEM_TAG_ELEMENT = 57344;
771
+ var SEQUENCE_DELIMITER_ELEMENT = 57565;
772
+ function itemTag(element, byteLength = 0) {
773
+ const b = Buffer.alloc(8);
774
+ b.writeUInt16LE(ITEM_TAG_GROUP, 0);
775
+ b.writeUInt16LE(element, 2);
776
+ b.writeUInt32LE(byteLength, 4);
777
+ return b;
778
+ }
779
+ function nativeDimensions(pixelBytes) {
780
+ const cells = Math.max(1, Math.floor(pixelBytes / 2));
781
+ const rows = Math.min(65535, Math.max(1, Math.round(Math.sqrt(cells))));
782
+ const columns = Math.min(65535, Math.max(1, Math.floor(cells / rows)));
783
+ return { rows, columns, bytes: rows * columns * 2 };
784
+ }
785
+ function streamZeros(fd, totalBytes, zeroChunk) {
786
+ let remaining = totalBytes;
787
+ while (remaining > 0) {
788
+ const n = Math.min(zeroChunk.length, remaining);
789
+ writeSync(fd, zeroChunk, 0, n);
790
+ remaining -= n;
791
+ }
792
+ }
793
+ function writeNative(outPath, params, dims, zeroChunk) {
794
+ const { modality, uid, tags } = params;
795
+ const meta = buildMeta(uid, modality);
796
+ const dataset = buildBaseImageDataset(uid, modality);
797
+ applyTagOverrides(dataset, tags);
798
+ dataset.Rows = dims.rows;
799
+ dataset.Columns = dims.columns;
800
+ const minimalPixelBytes = dataset.PixelData.byteLength;
801
+ const probe = serializeDict(meta, dataset);
802
+ const pixelElement = Buffer.alloc(12);
803
+ PIXEL_DATA_OW_HEADER.copy(pixelElement);
804
+ pixelElement.writeUInt32LE(minimalPixelBytes, 8);
805
+ const owIdx = probe.indexOf(pixelElement);
806
+ if (owIdx < 0) {
807
+ throw new Error("failed to locate native PixelData element header");
808
+ }
809
+ const dataStart = owIdx + 12;
810
+ const header = Buffer.from(probe.subarray(0, dataStart));
811
+ header.writeUInt32LE(dims.bytes, owIdx + 8);
812
+ const trailing = probe.subarray(dataStart + minimalPixelBytes);
813
+ const fd = openSync(outPath, "w");
814
+ try {
815
+ writeSync(fd, header);
816
+ streamZeros(fd, dims.bytes, zeroChunk);
817
+ if (trailing.length > 0) writeSync(fd, trailing);
818
+ } finally {
819
+ closeSync(fd);
820
+ }
821
+ return header.length + dims.bytes + trailing.length;
822
+ }
823
+ function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk) {
824
+ const { modality, uid, tags } = params;
825
+ const meta = {
826
+ ...buildMeta(uid, modality),
827
+ TransferSyntaxUID: RLE_TRANSFER_SYNTAX_UID
828
+ };
829
+ const dataset = buildBaseImageDataset(uid, modality);
830
+ applyTagOverrides(dataset, tags);
831
+ dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
832
+ dataset._vrMap = { PixelData: "OB" };
833
+ const probe = serializeDict(meta, dataset);
834
+ const idx = probe.indexOf(PIXEL_DATA_OB_HEADER);
835
+ if (idx < 0 || probe.readUInt32LE(idx + 8) !== 4294967295) {
836
+ throw new Error("failed to locate encapsulated PixelData element header");
837
+ }
838
+ const prefix = Buffer.from(probe.subarray(0, idx + 12));
839
+ const fd = openSync(outPath, "w");
840
+ let bytesWritten = 0;
841
+ try {
842
+ writeSync(fd, prefix);
843
+ bytesWritten += prefix.length;
844
+ const bot = itemTag(ITEM_TAG_ELEMENT, 0);
845
+ writeSync(fd, bot);
846
+ bytesWritten += bot.length;
847
+ let remaining = pixelBytes;
848
+ while (remaining > 0) {
849
+ const fragLen = Math.min(fragmentBytes, remaining);
850
+ const fh = itemTag(ITEM_TAG_ELEMENT, fragLen);
851
+ writeSync(fd, fh);
852
+ streamZeros(fd, fragLen, zeroChunk);
853
+ bytesWritten += fh.length + fragLen;
854
+ remaining -= fragLen;
855
+ }
856
+ const delimiter = itemTag(SEQUENCE_DELIMITER_ELEMENT);
857
+ writeSync(fd, delimiter);
858
+ bytesWritten += delimiter.length;
859
+ } finally {
860
+ closeSync(fd);
861
+ }
862
+ return bytesWritten;
863
+ }
864
+ function streamLargeImage(outPath, options) {
865
+ const {
866
+ targetBytes,
867
+ chunkBytes = DEFAULT_CHUNK_BYTES,
868
+ owMaxBytes = OW_MAX_BYTES,
869
+ fragmentBytes = FRAGMENT_MAX_BYTES
870
+ } = options;
871
+ const modality = options.modality ?? "CT";
872
+ const uid = options.uid ?? makeUidGenerator(options.seed)(0);
873
+ const identity = { modality, uid, tags: options.tags };
874
+ mkdirSync2(resolve2(outPath, ".."), { recursive: true });
875
+ const zeroChunk = Buffer.alloc(Math.max(1, chunkBytes));
876
+ const probe = serializeDict(
877
+ buildMeta(uid, modality),
878
+ buildBaseImageDataset(uid, modality)
879
+ );
880
+ const nativeHeaderLen = probe.length - 2;
881
+ const requestedPixel = Math.max(2, targetBytes - nativeHeaderLen);
882
+ if (requestedPixel <= owMaxBytes) {
883
+ const dims = nativeDimensions(requestedPixel);
884
+ const bytesWritten2 = writeNative(outPath, identity, dims, zeroChunk);
885
+ return {
886
+ path: outPath,
887
+ bytesWritten: bytesWritten2,
888
+ encoding: "native",
889
+ pixelBytes: dims.bytes
890
+ };
891
+ }
892
+ const estimateFrags = Math.max(
893
+ 1,
894
+ Math.ceil((targetBytes - nativeHeaderLen - 16) / fragmentBytes)
895
+ );
896
+ const overhead = nativeHeaderLen + 8 + estimateFrags * 8 + 8;
897
+ let pixelBytes = Math.max(2, targetBytes - overhead);
898
+ pixelBytes -= pixelBytes % 2;
899
+ const bytesWritten = writeEncapsulated(
900
+ outPath,
901
+ identity,
902
+ pixelBytes,
903
+ fragmentBytes - fragmentBytes % 2,
904
+ zeroChunk
905
+ );
906
+ return { path: outPath, bytesWritten, encoding: "encapsulated", pixelBytes };
907
+ }
908
+ function writeLargeImageFile(spec, outPath, options = {}) {
909
+ const { targetBytes } = spec;
910
+ if (!Number.isInteger(targetBytes)) {
911
+ throw new Error("writeLargeImageFile: targetBytes must be an integer");
912
+ }
913
+ if (targetBytes <= MAX_PIXEL_BYTES) {
914
+ throw new Error(
915
+ "writeLargeImageFile is for files above the 512 MB in-memory limit \u2014 use targetSizeKb for smaller files"
916
+ );
917
+ }
918
+ if (targetBytes > MAX_STREAM_BYTES) {
919
+ throw new Error("writeLargeImageFile: targetBytes exceeds the 50 GB limit");
920
+ }
921
+ return streamLargeImage(outPath, {
922
+ targetBytes,
923
+ modality: spec.modality,
924
+ seed: spec.seed,
925
+ chunkBytes: options.chunkBytes
926
+ });
927
+ }
928
+
607
929
  // src/syntheticFixtures/violations.ts
608
930
  var dcmjsAny = dcmjs;
609
931
  function parseBuffer(buffer) {
@@ -717,12 +1039,18 @@ function studyFileCount(studies) {
717
1039
  0
718
1040
  );
719
1041
  }
1042
+ function resolveUid(seed, index, override) {
1043
+ return { ...makeUidGenerator(seed)(index), ...override };
1044
+ }
720
1045
  async function generateFile(spec, options) {
721
1046
  const index = options?.index ?? 0;
722
1047
  const padWidth = options?.padWidth ?? 3;
723
- const uidGen = makeUidGenerator(options?.seed);
724
- const uid = { ...uidGen(index), ...options?.uid };
725
- let buffer = buildBufferForSpec(spec, uid);
1048
+ const uid = resolveUid(options?.seed, index, options?.uid);
1049
+ const resolvedSpec = "tags" in spec && spec.tags ? {
1050
+ ...spec,
1051
+ tags: resolveTagTemplates(spec.tags, { index, ...options?.context })
1052
+ } : spec;
1053
+ let buffer = buildBufferForSpec(resolvedSpec, uid);
726
1054
  const violations = "violations" in spec ? spec.violations : void 0;
727
1055
  if (violations?.length) {
728
1056
  buffer = applyViolations(buffer, violations);
@@ -789,25 +1117,43 @@ function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
789
1117
  function withResolvedSeed(spec) {
790
1118
  return spec.seed === void 0 ? { ...spec, seed: randomBytes2(4).readUInt32BE(0) } : spec;
791
1119
  }
792
- async function* generateCollectionFromSpec(spec) {
1120
+ function computeNames(type, index, padWidth, grouped, quirks) {
1121
+ let names;
1122
+ if (grouped) {
1123
+ names = hierarchicalName(
1124
+ grouped.studyOrdinal,
1125
+ grouped.seriesIndex,
1126
+ grouped.instanceNumber
1127
+ );
1128
+ } else {
1129
+ const name = filename(type, index, padWidth);
1130
+ names = { filename: name, relativePath: name };
1131
+ }
1132
+ return quirks.length === 0 ? names : applyPathQuirks(names.relativePath, quirks);
1133
+ }
1134
+ function* planCollection(spec) {
793
1135
  const flatEntries = spec.entries ?? [];
794
1136
  const studies = spec.studies ?? [];
795
1137
  const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
796
1138
  const padWidth = String(totalFiles).length;
797
1139
  const hierarchical = spec.layout === "hierarchical";
798
1140
  const quirks = spec.pathQuirks ?? [];
799
- const decorate = (file) => quirks.length === 0 ? file : { ...file, ...applyPathQuirks(file.relativePath, quirks) };
800
1141
  let globalIndex = 0;
801
1142
  for (const entry of flatEntries) {
802
1143
  const { count = 1, ...fileSpec } = entry;
803
1144
  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
- );
1145
+ yield {
1146
+ fileSpec,
1147
+ index: globalIndex,
1148
+ context: { index: globalIndex },
1149
+ ...computeNames(
1150
+ fileSpec.type,
1151
+ globalIndex,
1152
+ padWidth,
1153
+ void 0,
1154
+ quirks
1155
+ )
1156
+ };
811
1157
  globalIndex++;
812
1158
  }
813
1159
  }
@@ -830,25 +1176,24 @@ async function* generateCollectionFromSpec(spec) {
830
1176
  ...series.tags,
831
1177
  ...fileSpec.tags
832
1178
  };
833
- const file = await generateFile(
834
- { ...fileSpec, tags },
835
- {
1179
+ yield {
1180
+ fileSpec: { ...fileSpec, tags },
1181
+ index: globalIndex,
1182
+ uidOverride: { study: studyUid, series: seriesUid },
1183
+ context: {
836
1184
  index: globalIndex,
837
- seed: spec.seed,
1185
+ studyIndex: studyOrdinal,
1186
+ seriesIndex,
1187
+ instanceNumber
1188
+ },
1189
+ ...computeNames(
1190
+ fileSpec.type,
1191
+ globalIndex,
838
1192
  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
- );
1193
+ hierarchical ? { studyOrdinal, seriesIndex, instanceNumber } : void 0,
1194
+ quirks
1195
+ )
1196
+ };
852
1197
  globalIndex++;
853
1198
  }
854
1199
  }
@@ -857,30 +1202,102 @@ async function* generateCollectionFromSpec(spec) {
857
1202
  }
858
1203
  }
859
1204
  }
1205
+ async function materialisePlan(plan, seed) {
1206
+ const file = await generateFile(plan.fileSpec, {
1207
+ index: plan.index,
1208
+ seed,
1209
+ uid: plan.uidOverride,
1210
+ context: plan.context
1211
+ });
1212
+ return { ...file, filename: plan.filename, relativePath: plan.relativePath };
1213
+ }
1214
+ async function* generateCollectionFromSpec(spec) {
1215
+ for (const plan of planCollection(spec)) {
1216
+ yield await materialisePlan(plan, spec.seed);
1217
+ }
1218
+ }
1219
+ async function* previewCollection(spec) {
1220
+ for (const plan of planCollection(spec)) {
1221
+ if (plan.fileSpec.type === "large-image") {
1222
+ yield {
1223
+ relativePath: plan.relativePath,
1224
+ type: "large-image",
1225
+ index: plan.index,
1226
+ approxBytes: plan.fileSpec.targetBytes
1227
+ };
1228
+ } else {
1229
+ const file = await materialisePlan(plan, spec.seed);
1230
+ yield {
1231
+ relativePath: plan.relativePath,
1232
+ type: plan.fileSpec.type,
1233
+ index: plan.index,
1234
+ approxBytes: file.buffer.length
1235
+ };
1236
+ }
1237
+ }
1238
+ }
860
1239
  async function writeCollectionFromSpec(spec, outDir) {
861
- const root = resolve2(outDir);
862
- mkdirSync2(root, { recursive: true });
1240
+ const root = resolve3(outDir);
1241
+ mkdirSync3(root, { recursive: true });
863
1242
  const manifest = [];
864
1243
  const createdDirs = /* @__PURE__ */ new Set([root]);
865
- for await (const file of generateCollectionFromSpec(spec)) {
866
- const filePath = resolve2(root, file.relativePath);
1244
+ const ensureDir = (filePath) => {
867
1245
  const dir = dirname(filePath);
868
1246
  if (!createdDirs.has(dir)) {
869
- mkdirSync2(dir, { recursive: true });
1247
+ mkdirSync3(dir, { recursive: true });
870
1248
  createdDirs.add(dir);
871
1249
  }
872
- await writeFile(filePath, file.buffer);
873
- manifest.push({ path: filePath, type: file.type, index: file.index });
1250
+ };
1251
+ for (const plan of planCollection(spec)) {
1252
+ const filePath = resolve3(root, plan.relativePath);
1253
+ ensureDir(filePath);
1254
+ if (plan.fileSpec.type === "large-image") {
1255
+ const large = plan.fileSpec;
1256
+ if (large.targetBytes > MAX_STREAM_BYTES) {
1257
+ throw new Error(
1258
+ `large-image targetBytes (${large.targetBytes}) exceeds the 50 GB limit`
1259
+ );
1260
+ }
1261
+ const uid = resolveUid(spec.seed, plan.index, plan.uidOverride);
1262
+ streamLargeImage(filePath, {
1263
+ targetBytes: large.targetBytes,
1264
+ modality: large.modality,
1265
+ uid,
1266
+ tags: resolveTagTemplates(large.tags, plan.context)
1267
+ });
1268
+ } else {
1269
+ const file = await materialisePlan(plan, spec.seed);
1270
+ await writeFile(filePath, file.buffer);
1271
+ }
1272
+ manifest.push({
1273
+ path: filePath,
1274
+ type: plan.fileSpec.type,
1275
+ index: plan.index
1276
+ });
874
1277
  }
875
1278
  return manifest;
876
1279
  }
877
1280
 
878
1281
  // src/describe/describe.ts
879
- import { readdirSync, readFileSync } from "node:fs";
1282
+ import {
1283
+ closeSync as closeSync2,
1284
+ openSync as openSync2,
1285
+ readdirSync,
1286
+ readFileSync,
1287
+ readSync,
1288
+ statSync
1289
+ } from "node:fs";
880
1290
  import { join } from "node:path";
881
1291
  var dcmjsAny2 = dcmjs;
882
1292
  var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
883
1293
  var SUPPORTED_MODALITIES = /* @__PURE__ */ new Set(["CT", "PT", "MR", "CR"]);
1294
+ var RESERVED_KEEP_KEYWORDS = /* @__PURE__ */ new Set([
1295
+ "Units",
1296
+ "DecayCorrection",
1297
+ "CorrectedImage",
1298
+ "ScanningSequence",
1299
+ "SequenceVariant"
1300
+ ]);
884
1301
  function buildHexToKeyword() {
885
1302
  const map = /* @__PURE__ */ new Map();
886
1303
  const nm = dcmjsAny2.data.DicomMetaDictionary.nameMap;
@@ -896,20 +1313,37 @@ function buildHexToKeyword() {
896
1313
  function resolveKeepTags(keepTags) {
897
1314
  const nameMap = dcmjsAny2.data.DicomMetaDictionary.nameMap;
898
1315
  let hexToKeyword;
899
- return keepTags.map((tag) => {
1316
+ const kept = [];
1317
+ for (const tag of keepTags) {
1318
+ let keyword;
900
1319
  if (HEX_TAG_RE2.test(tag)) {
901
1320
  hexToKeyword ?? (hexToKeyword = buildHexToKeyword());
902
- const keyword = hexToKeyword.get(tag.toUpperCase());
903
- if (!keyword) {
1321
+ const mapped = hexToKeyword.get(tag.toUpperCase());
1322
+ if (!mapped) {
904
1323
  throw new Error(`describe: unknown hex tag "${tag}"`);
905
1324
  }
906
- return keyword;
1325
+ keyword = mapped;
1326
+ } else {
1327
+ if (nameMap && !Object.hasOwn(nameMap, tag)) {
1328
+ throw new Error(`describe: unknown tag keyword "${tag}"`);
1329
+ }
1330
+ keyword = tag;
907
1331
  }
908
- if (nameMap && !Object.hasOwn(nameMap, tag)) {
909
- throw new Error(`describe: unknown tag keyword "${tag}"`);
1332
+ if (RESERVED_KEEP_KEYWORDS.has(keyword)) {
1333
+ console.warn(
1334
+ `describe: ignoring keep-tag "${keyword}" \u2014 modality-coupled tags are reproduced from the modality field`
1335
+ );
1336
+ continue;
910
1337
  }
911
- return tag;
912
- });
1338
+ if (nameMap?.[keyword]?.vr === "UI") {
1339
+ console.warn(
1340
+ `describe: ignoring keep-tag "${keyword}" \u2014 UID-valued tags are not kept verbatim`
1341
+ );
1342
+ continue;
1343
+ }
1344
+ kept.push(keyword);
1345
+ }
1346
+ return kept;
913
1347
  }
914
1348
  function* walkFiles(dir) {
915
1349
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -921,18 +1355,77 @@ function* walkFiles(dir) {
921
1355
  }
922
1356
  }
923
1357
  }
924
- function parseFile(path) {
1358
+ function toArrayBuffer(buf) {
1359
+ return buf.buffer.slice(
1360
+ buf.byteOffset,
1361
+ buf.byteOffset + buf.byteLength
1362
+ );
1363
+ }
1364
+ function naturalize(ab) {
1365
+ const parsed = dcmjsAny2.data.DicomMessage.readFile(ab);
1366
+ return dcmjsAny2.data.DicomMetaDictionary.naturalizeDataset(parsed.dict);
1367
+ }
1368
+ function parseFull(path) {
925
1369
  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 };
1370
+ return naturalize(toArrayBuffer(readFileSync(path)));
1371
+ } catch {
1372
+ return null;
1373
+ }
1374
+ }
1375
+ var PIXEL_DATA_OW = Buffer.from([224, 127, 16, 0, 79, 87]);
1376
+ var PIXEL_DATA_OB = Buffer.from([224, 127, 16, 0, 79, 66]);
1377
+ var EMPTY_OW_PIXEL_DATA = Buffer.from([
1378
+ 224,
1379
+ 127,
1380
+ 16,
1381
+ 0,
1382
+ 79,
1383
+ 87,
1384
+ 0,
1385
+ 0,
1386
+ 0,
1387
+ 0,
1388
+ 0,
1389
+ 0
1390
+ ]);
1391
+ var HEADER_READ_BYTES = 2 * 1024 * 1024;
1392
+ function findPixelDataOffset(prefix) {
1393
+ let best = -1;
1394
+ for (const pattern of [PIXEL_DATA_OW, PIXEL_DATA_OB]) {
1395
+ for (let from = 0; ; ) {
1396
+ const i = prefix.indexOf(pattern, from);
1397
+ if (i < 0) break;
1398
+ if (prefix[i + 6] === 0 && prefix[i + 7] === 0) {
1399
+ if (best < 0 || i < best) best = i;
1400
+ break;
1401
+ }
1402
+ from = i + 1;
1403
+ }
1404
+ }
1405
+ return best;
1406
+ }
1407
+ function readHeaderOnly(path) {
1408
+ let prefix;
1409
+ try {
1410
+ const fd = openSync2(path, "r");
1411
+ try {
1412
+ const buf = Buffer.alloc(HEADER_READ_BYTES);
1413
+ const n = readSync(fd, buf, 0, HEADER_READ_BYTES, 0);
1414
+ prefix = buf.subarray(0, n);
1415
+ } finally {
1416
+ closeSync2(fd);
1417
+ }
1418
+ } catch {
1419
+ return null;
1420
+ }
1421
+ const tagIdx = findPixelDataOffset(prefix);
1422
+ if (tagIdx < 0) return null;
1423
+ const headerOnly = Buffer.concat([
1424
+ prefix.subarray(0, tagIdx),
1425
+ EMPTY_OW_PIXEL_DATA
1426
+ ]);
1427
+ try {
1428
+ return naturalize(toArrayBuffer(headerOnly));
936
1429
  } catch {
937
1430
  return null;
938
1431
  }
@@ -942,10 +1435,12 @@ function extractTags(record, keepKeywords) {
942
1435
  let found = false;
943
1436
  for (const keyword of keepKeywords) {
944
1437
  const value = record[keyword];
945
- if (value !== void 0) {
946
- tags[keyword] = value;
947
- found = true;
1438
+ if (value === void 0) continue;
1439
+ if (keyword === "Modality" && typeof value === "string" && SUPPORTED_MODALITIES.has(value)) {
1440
+ continue;
948
1441
  }
1442
+ tags[keyword] = typeof value === "string" ? escapeTagTemplate(value) : value;
1443
+ found = true;
949
1444
  }
950
1445
  return found ? tags : void 0;
951
1446
  }
@@ -954,14 +1449,25 @@ function describeDirectory(dir, options = {}) {
954
1449
  const studies = /* @__PURE__ */ new Map();
955
1450
  const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
956
1451
  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") {
1452
+ let size;
1453
+ try {
1454
+ size = statSync(path).size;
1455
+ } catch {
1456
+ stats.skipped++;
1457
+ continue;
1458
+ }
1459
+ if (size > MAX_STREAM_BYTES) {
1460
+ stats.skipped++;
1461
+ continue;
1462
+ }
1463
+ const large = size > MAX_PIXEL_BYTES;
1464
+ const record = large ? readHeaderOnly(path) : parseFull(path);
1465
+ const studyUid = record?.StudyInstanceUID;
1466
+ const seriesUid = record?.SeriesInstanceUID;
1467
+ if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
961
1468
  stats.skipped++;
962
1469
  continue;
963
1470
  }
964
- const { record } = parsed;
965
1471
  stats.dicomFiles++;
966
1472
  let modality;
967
1473
  if (typeof record.Modality === "string") {
@@ -971,7 +1477,7 @@ function describeDirectory(dir, options = {}) {
971
1477
  stats.unknownModality++;
972
1478
  }
973
1479
  }
974
- const sizeKb = Math.max(1, Math.round(parsed.size / 1024));
1480
+ const sizeKb = Math.max(1, Math.round(size / 1024));
975
1481
  const tags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
976
1482
  let study = studies.get(studyUid);
977
1483
  if (!study) {
@@ -983,25 +1489,27 @@ function describeDirectory(dir, options = {}) {
983
1489
  series = /* @__PURE__ */ new Map();
984
1490
  study.set(seriesUid, series);
985
1491
  }
986
- const collapseKey = JSON.stringify([modality ?? null, sizeKb, tags ?? null]);
1492
+ const collapseKey = JSON.stringify([
1493
+ large,
1494
+ modality ?? null,
1495
+ large ? size : sizeKb,
1496
+ tags ?? null
1497
+ ]);
987
1498
  const existing = series.get(collapseKey);
988
1499
  if (existing) {
989
1500
  existing.count++;
990
1501
  } else {
991
- series.set(collapseKey, {
992
- modality,
993
- targetSizeKb: sizeKb,
994
- tags,
995
- count: 1
996
- });
1502
+ series.set(collapseKey, { modality, large, bytes: size, tags, count: 1 });
997
1503
  }
998
1504
  }
999
1505
  const studySpecs = [...studies.values()].map((study) => {
1000
1506
  const seriesSpecs = [...study.values()].map((series) => {
1001
1507
  const entries = [...series.values()].map((acc) => ({
1002
- type: "valid-image",
1003
1508
  ...acc.modality !== void 0 ? { modality: acc.modality } : {},
1004
- targetSizeKb: acc.targetSizeKb,
1509
+ ...acc.large ? { type: "large-image", targetBytes: acc.bytes } : {
1510
+ type: "valid-image",
1511
+ targetSizeKb: Math.max(1, Math.round(acc.bytes / 1024))
1512
+ },
1005
1513
  ...acc.tags !== void 0 ? { tags: acc.tags } : {},
1006
1514
  ...acc.count > 1 ? { count: acc.count } : {}
1007
1515
  }));
@@ -1050,7 +1558,7 @@ function loadCaseById(casesJsonPath, id) {
1050
1558
 
1051
1559
  // src/public-fixtures/fetch.ts
1052
1560
  import { createHash } from "node:crypto";
1053
- import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
1561
+ import { existsSync, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
1054
1562
  import { homedir } from "node:os";
1055
1563
  import { join as join3 } from "node:path";
1056
1564
  var DEFAULT_CACHE_ROOT = join3(homedir(), ".cache", "dicom-synth-testcases");
@@ -1077,7 +1585,7 @@ async function fetchPublicCaseToCache(record, cacheRoot = DEFAULT_CACHE_ROOT) {
1077
1585
  verifySha256(buf2, record.sha256);
1078
1586
  return dest;
1079
1587
  }
1080
- mkdirSync3(join3(cacheRoot, record.sha256), { recursive: true });
1588
+ mkdirSync4(join3(cacheRoot, record.sha256), { recursive: true });
1081
1589
  const res = await fetch(record.source.url);
1082
1590
  if (!res.ok) {
1083
1591
  throw new Error(
@@ -1096,6 +1604,12 @@ function sample(next, range) {
1096
1604
  if (typeof range === "number") return range;
1097
1605
  return range.min + next() % (range.max - range.min + 1);
1098
1606
  }
1607
+ var BYTES_PER_MB = 1024 * 1024;
1608
+ function sampleBytes(next, range) {
1609
+ if (typeof range === "number") return range;
1610
+ const spanMb = Math.floor((range.max - range.min) / BYTES_PER_MB) + 1;
1611
+ return range.min + next() % spanMb * BYTES_PER_MB;
1612
+ }
1099
1613
  function pick(next, items) {
1100
1614
  return items[next() % items.length];
1101
1615
  }
@@ -1136,6 +1650,30 @@ function resolveParametricSpec(spec) {
1136
1650
  ...fileCount > 1 ? { count: fileCount } : {}
1137
1651
  });
1138
1652
  }
1653
+ if (params.largeFilesPerSeries !== void 0 && params.largeFileBytes !== void 0) {
1654
+ const largeCount = sample(next, params.largeFilesPerSeries);
1655
+ const largeBase = {
1656
+ type: "large-image",
1657
+ targetBytes: 0,
1658
+ ...modality !== void 0 ? { modality } : {}
1659
+ };
1660
+ if (typeof params.largeFileBytes === "number") {
1661
+ if (largeCount > 0) {
1662
+ entries2.push({
1663
+ ...largeBase,
1664
+ targetBytes: params.largeFileBytes,
1665
+ ...largeCount > 1 ? { count: largeCount } : {}
1666
+ });
1667
+ }
1668
+ } else {
1669
+ for (let lf = 0; lf < largeCount; lf++) {
1670
+ entries2.push({
1671
+ ...largeBase,
1672
+ targetBytes: sampleBytes(next, params.largeFileBytes)
1673
+ });
1674
+ }
1675
+ }
1676
+ }
1139
1677
  series.push({ entries: entries2 });
1140
1678
  }
1141
1679
  studies.push({
@@ -1164,6 +1702,9 @@ function resolveParametricSpec(spec) {
1164
1702
  };
1165
1703
  }
1166
1704
  export {
1705
+ MAX_PIXEL_BYTES,
1706
+ MAX_STREAM_BYTES,
1707
+ TEMPLATE_VOCAB,
1167
1708
  caseCachePath,
1168
1709
  defaultPublicCasesPath,
1169
1710
  describeDirectory,
@@ -1173,10 +1714,14 @@ export {
1173
1714
  loadCaseById,
1174
1715
  loadCasesFromJson,
1175
1716
  loadDefaultCases,
1717
+ previewCollection,
1176
1718
  resolveParametricSpec,
1719
+ resolveTagTemplates,
1720
+ streamLargeImage,
1177
1721
  validateDatasetSpec,
1178
1722
  validateParametricSpec,
1179
1723
  verifySha256,
1180
1724
  withResolvedSeed,
1181
- writeCollectionFromSpec
1725
+ writeCollectionFromSpec,
1726
+ writeLargeImageFile
1182
1727
  };