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.
@@ -0,0 +1,7 @@
1
+ // src/schema/limits.ts
2
+ var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
3
+ var MAX_STREAM_BYTES = 50 * 1024 * 1024 * 1024;
4
+ export {
5
+ MAX_PIXEL_BYTES,
6
+ MAX_STREAM_BYTES
7
+ };
@@ -15,11 +15,32 @@ function seededStream(seed, offset, a, b) {
15
15
  return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
16
16
  }
17
17
 
18
+ // src/syntheticFixtures/tagTemplate.ts
19
+ var TEMPLATE_VOCAB = [
20
+ "index",
21
+ "studyIndex",
22
+ "seriesIndex",
23
+ "instanceNumber"
24
+ ];
25
+ var TEMPLATE_PART_RE = /\{\{|\}\}|\{([a-zA-Z]+)\}/g;
26
+ function findTemplateTokens(value) {
27
+ const names = [];
28
+ for (const m of value.matchAll(TEMPLATE_PART_RE)) {
29
+ if (m[1] !== void 0) names.push(m[1]);
30
+ }
31
+ return names;
32
+ }
33
+
34
+ // src/schema/limits.ts
35
+ var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
36
+ var MAX_STREAM_BYTES = 50 * 1024 * 1024 * 1024;
37
+
18
38
  // src/schema/validate.ts
19
39
  var TYPE_CAPABILITIES = {
20
40
  "valid-image": { image: true },
21
41
  "invalid-uid-image": { image: true },
22
42
  "vendor-warnings-image": { image: true },
43
+ "large-image": { image: true },
23
44
  "fake-signature": { image: false },
24
45
  "non-dicom": { image: false },
25
46
  dicomdir: { image: false }
@@ -38,7 +59,6 @@ var VALID_TRANSFER_SYNTAXES = {
38
59
  "explicit-vr-little-endian": true,
39
60
  "implicit-vr-little-endian": true
40
61
  };
41
- var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
42
62
  var VALID_VIOLATIONS = {
43
63
  "uid-too-long": true,
44
64
  "non-conformant-uid": true,
@@ -56,6 +76,13 @@ function validatePositiveInt(value, path) {
56
76
  );
57
77
  }
58
78
  }
79
+ function validateNonNegativeInt(value, path) {
80
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
81
+ throw new Error(
82
+ `${path}: must be a non-negative integer; got ${JSON.stringify(value)}`
83
+ );
84
+ }
85
+ }
59
86
  function validateSeed(value, path) {
60
87
  if (typeof value !== "number" || !Number.isFinite(value)) {
61
88
  throw new Error(
@@ -68,6 +95,43 @@ function validateSizeKbLimit(kb, path) {
68
95
  throw new Error(`${path}: exceeds the 512 MB limit`);
69
96
  }
70
97
  }
98
+ function validateLargeTargetBytes(value, path) {
99
+ if (typeof value !== "number" || !Number.isInteger(value)) {
100
+ throw new Error(`${path}: must be an integer; got ${JSON.stringify(value)}`);
101
+ }
102
+ if (value <= MAX_PIXEL_BYTES) {
103
+ throw new Error(
104
+ `${path}: must exceed 512 MB \u2014 use targetSizeKb for smaller files`
105
+ );
106
+ }
107
+ if (value > MAX_STREAM_BYTES) {
108
+ throw new Error(`${path}: exceeds the 50 GB limit`);
109
+ }
110
+ }
111
+ function validateLargeImageEntry(e, path) {
112
+ for (const field of [
113
+ "rows",
114
+ "columns",
115
+ "frames",
116
+ "targetSizeKb",
117
+ "violations",
118
+ "transferSyntax"
119
+ ]) {
120
+ if (field in e) {
121
+ throw new Error(
122
+ `${path}.${field}: not supported for type "large-image" \u2014 use targetBytes`
123
+ );
124
+ }
125
+ }
126
+ if (!("targetBytes" in e)) {
127
+ throw new Error(`${path}.targetBytes: is required for type "large-image"`);
128
+ }
129
+ validateLargeTargetBytes(e.targetBytes, `${path}.targetBytes`);
130
+ if ("modality" in e) {
131
+ validateEnum(e.modality, VALID_MODALITIES, `${path}.modality`);
132
+ }
133
+ if ("tags" in e) validateTags(e.tags, `${path}.tags`);
134
+ }
71
135
  function validateEnum(value, valid, path) {
72
136
  if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
73
137
  throw new Error(
@@ -91,6 +155,15 @@ function validateEntry(entry, path) {
91
155
  const type = e.type;
92
156
  const caps = TYPE_CAPABILITIES[type];
93
157
  if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
158
+ if (type === "large-image") {
159
+ validateLargeImageEntry(e, path);
160
+ return e;
161
+ }
162
+ if ("targetBytes" in e) {
163
+ throw new Error(
164
+ `${path}.targetBytes: only supported for type "large-image"`
165
+ );
166
+ }
94
167
  for (const field of [
95
168
  "modality",
96
169
  "rows",
@@ -150,27 +223,44 @@ function validateEntry(entry, path) {
150
223
  }
151
224
  return e;
152
225
  }
226
+ var MODALITY_TAG = "00080060";
153
227
  function validateTags(tags, path) {
154
228
  if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
155
229
  throw new Error(`${path}: must be an object`);
156
230
  }
157
- for (const key of Object.keys(tags)) {
231
+ for (const [key, value] of Object.entries(tags)) {
158
232
  validateTagKey(key, path);
233
+ const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
234
+ if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
235
+ throw new Error(
236
+ `${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`
237
+ );
238
+ }
239
+ if (typeof value === "string") {
240
+ for (const name of findTemplateTokens(value)) {
241
+ if (!TEMPLATE_VOCAB.includes(name)) {
242
+ throw new Error(
243
+ `${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
244
+ );
245
+ }
246
+ }
247
+ }
159
248
  }
160
249
  }
161
- function validateRange(value, path) {
250
+ function validateRange(value, path, options = {}) {
251
+ const checkInt = options.allowZero ? validateNonNegativeInt : validatePositiveInt;
162
252
  if (typeof value === "number") {
163
- validatePositiveInt(value, path);
253
+ checkInt(value, path);
164
254
  return value;
165
255
  }
166
256
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
167
257
  throw new Error(
168
- `${path}: must be a positive integer or { min, max }; got ${JSON.stringify(value)}`
258
+ `${path}: must be an integer or { min, max }; got ${JSON.stringify(value)}`
169
259
  );
170
260
  }
171
261
  const r = value;
172
- validatePositiveInt(r.min, `${path}.min`);
173
- validatePositiveInt(r.max, `${path}.max`);
262
+ checkInt(r.min, `${path}.min`);
263
+ checkInt(r.max, `${path}.max`);
174
264
  if (r.min > r.max) {
175
265
  throw new Error(`${path}: min (${r.min}) must be \u2264 max (${r.max})`);
176
266
  }
@@ -179,6 +269,9 @@ function validateRange(value, path) {
179
269
  function rangeMax(range) {
180
270
  return typeof range === "number" ? range : range.max;
181
271
  }
272
+ function rangeMin(range) {
273
+ return typeof range === "number" ? range : range.min;
274
+ }
182
275
  function validateParametricStudies(studies, path) {
183
276
  if (typeof studies !== "object" || studies === null || Array.isArray(studies)) {
184
277
  throw new Error(`${path}: must be an object`);
@@ -192,6 +285,32 @@ function validateParametricStudies(studies, path) {
192
285
  validateRange(s.fileSizeKb, `${path}.fileSizeKb`);
193
286
  validateSizeKbLimit(rangeMax(s.fileSizeKb), `${path}.fileSizeKb`);
194
287
  }
288
+ if ("largeFilesPerSeries" in s) {
289
+ validateRange(s.largeFilesPerSeries, `${path}.largeFilesPerSeries`, {
290
+ allowZero: true
291
+ });
292
+ if (!("largeFileBytes" in s)) {
293
+ throw new Error(
294
+ `${path}.largeFileBytes: is required when largeFilesPerSeries is set`
295
+ );
296
+ }
297
+ }
298
+ if ("largeFileBytes" in s) {
299
+ if (!("largeFilesPerSeries" in s)) {
300
+ throw new Error(
301
+ `${path}.largeFilesPerSeries: is required when largeFileBytes is set`
302
+ );
303
+ }
304
+ validateRange(s.largeFileBytes, `${path}.largeFileBytes`);
305
+ validateLargeTargetBytes(
306
+ rangeMin(s.largeFileBytes),
307
+ `${path}.largeFileBytes (min)`
308
+ );
309
+ validateLargeTargetBytes(
310
+ rangeMax(s.largeFileBytes),
311
+ `${path}.largeFileBytes (max)`
312
+ );
313
+ }
195
314
  if ("modalities" in s) {
196
315
  if (!Array.isArray(s.modalities) || s.modalities.length === 0) {
197
316
  throw new Error(`${path}.modalities: must be a non-empty array`);
@@ -255,6 +374,12 @@ function sample(next, range) {
255
374
  if (typeof range === "number") return range;
256
375
  return range.min + next() % (range.max - range.min + 1);
257
376
  }
377
+ var BYTES_PER_MB = 1024 * 1024;
378
+ function sampleBytes(next, range) {
379
+ if (typeof range === "number") return range;
380
+ const spanMb = Math.floor((range.max - range.min) / BYTES_PER_MB) + 1;
381
+ return range.min + next() % spanMb * BYTES_PER_MB;
382
+ }
258
383
  function pick(next, items) {
259
384
  return items[next() % items.length];
260
385
  }
@@ -295,6 +420,30 @@ function resolveParametricSpec(spec) {
295
420
  ...fileCount > 1 ? { count: fileCount } : {}
296
421
  });
297
422
  }
423
+ if (params.largeFilesPerSeries !== void 0 && params.largeFileBytes !== void 0) {
424
+ const largeCount = sample(next, params.largeFilesPerSeries);
425
+ const largeBase = {
426
+ type: "large-image",
427
+ targetBytes: 0,
428
+ ...modality !== void 0 ? { modality } : {}
429
+ };
430
+ if (typeof params.largeFileBytes === "number") {
431
+ if (largeCount > 0) {
432
+ entries2.push({
433
+ ...largeBase,
434
+ targetBytes: params.largeFileBytes,
435
+ ...largeCount > 1 ? { count: largeCount } : {}
436
+ });
437
+ }
438
+ } else {
439
+ for (let lf = 0; lf < largeCount; lf++) {
440
+ entries2.push({
441
+ ...largeBase,
442
+ targetBytes: sampleBytes(next, params.largeFileBytes)
443
+ });
444
+ }
445
+ }
446
+ }
298
447
  series.push({ entries: entries2 });
299
448
  }
300
449
  studies.push({
@@ -1,8 +1,29 @@
1
+ // src/syntheticFixtures/tagTemplate.ts
2
+ var TEMPLATE_VOCAB = [
3
+ "index",
4
+ "studyIndex",
5
+ "seriesIndex",
6
+ "instanceNumber"
7
+ ];
8
+ var TEMPLATE_PART_RE = /\{\{|\}\}|\{([a-zA-Z]+)\}/g;
9
+ function findTemplateTokens(value) {
10
+ const names = [];
11
+ for (const m of value.matchAll(TEMPLATE_PART_RE)) {
12
+ if (m[1] !== void 0) names.push(m[1]);
13
+ }
14
+ return names;
15
+ }
16
+
17
+ // src/schema/limits.ts
18
+ var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
19
+ var MAX_STREAM_BYTES = 50 * 1024 * 1024 * 1024;
20
+
1
21
  // src/schema/validate.ts
2
22
  var TYPE_CAPABILITIES = {
3
23
  "valid-image": { image: true },
4
24
  "invalid-uid-image": { image: true },
5
25
  "vendor-warnings-image": { image: true },
26
+ "large-image": { image: true },
6
27
  "fake-signature": { image: false },
7
28
  "non-dicom": { image: false },
8
29
  dicomdir: { image: false }
@@ -27,7 +48,6 @@ var VALID_TRANSFER_SYNTAXES = {
27
48
  "explicit-vr-little-endian": true,
28
49
  "implicit-vr-little-endian": true
29
50
  };
30
- var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
31
51
  var VALID_VIOLATIONS = {
32
52
  "uid-too-long": true,
33
53
  "non-conformant-uid": true,
@@ -45,6 +65,13 @@ function validatePositiveInt(value, path) {
45
65
  );
46
66
  }
47
67
  }
68
+ function validateNonNegativeInt(value, path) {
69
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
70
+ throw new Error(
71
+ `${path}: must be a non-negative integer; got ${JSON.stringify(value)}`
72
+ );
73
+ }
74
+ }
48
75
  function validateSeed(value, path) {
49
76
  if (typeof value !== "number" || !Number.isFinite(value)) {
50
77
  throw new Error(
@@ -57,6 +84,43 @@ function validateSizeKbLimit(kb, path) {
57
84
  throw new Error(`${path}: exceeds the 512 MB limit`);
58
85
  }
59
86
  }
87
+ function validateLargeTargetBytes(value, path) {
88
+ if (typeof value !== "number" || !Number.isInteger(value)) {
89
+ throw new Error(`${path}: must be an integer; got ${JSON.stringify(value)}`);
90
+ }
91
+ if (value <= MAX_PIXEL_BYTES) {
92
+ throw new Error(
93
+ `${path}: must exceed 512 MB \u2014 use targetSizeKb for smaller files`
94
+ );
95
+ }
96
+ if (value > MAX_STREAM_BYTES) {
97
+ throw new Error(`${path}: exceeds the 50 GB limit`);
98
+ }
99
+ }
100
+ function validateLargeImageEntry(e, path) {
101
+ for (const field of [
102
+ "rows",
103
+ "columns",
104
+ "frames",
105
+ "targetSizeKb",
106
+ "violations",
107
+ "transferSyntax"
108
+ ]) {
109
+ if (field in e) {
110
+ throw new Error(
111
+ `${path}.${field}: not supported for type "large-image" \u2014 use targetBytes`
112
+ );
113
+ }
114
+ }
115
+ if (!("targetBytes" in e)) {
116
+ throw new Error(`${path}.targetBytes: is required for type "large-image"`);
117
+ }
118
+ validateLargeTargetBytes(e.targetBytes, `${path}.targetBytes`);
119
+ if ("modality" in e) {
120
+ validateEnum(e.modality, VALID_MODALITIES, `${path}.modality`);
121
+ }
122
+ if ("tags" in e) validateTags(e.tags, `${path}.tags`);
123
+ }
60
124
  function validateEnum(value, valid, path) {
61
125
  if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
62
126
  throw new Error(
@@ -80,6 +144,15 @@ function validateEntry(entry, path) {
80
144
  const type = e.type;
81
145
  const caps = TYPE_CAPABILITIES[type];
82
146
  if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
147
+ if (type === "large-image") {
148
+ validateLargeImageEntry(e, path);
149
+ return e;
150
+ }
151
+ if ("targetBytes" in e) {
152
+ throw new Error(
153
+ `${path}.targetBytes: only supported for type "large-image"`
154
+ );
155
+ }
83
156
  for (const field of [
84
157
  "modality",
85
158
  "rows",
@@ -139,12 +212,28 @@ function validateEntry(entry, path) {
139
212
  }
140
213
  return e;
141
214
  }
215
+ var MODALITY_TAG = "00080060";
142
216
  function validateTags(tags, path) {
143
217
  if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
144
218
  throw new Error(`${path}: must be an object`);
145
219
  }
146
- for (const key of Object.keys(tags)) {
220
+ for (const [key, value] of Object.entries(tags)) {
147
221
  validateTagKey(key, path);
222
+ const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
223
+ if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
224
+ throw new Error(
225
+ `${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`
226
+ );
227
+ }
228
+ if (typeof value === "string") {
229
+ for (const name of findTemplateTokens(value)) {
230
+ if (!TEMPLATE_VOCAB.includes(name)) {
231
+ throw new Error(
232
+ `${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
233
+ );
234
+ }
235
+ }
236
+ }
148
237
  }
149
238
  }
150
239
  function validateSeries(series, path) {
@@ -225,19 +314,20 @@ function validateDatasetSpec(raw) {
225
314
  ...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
226
315
  };
227
316
  }
228
- function validateRange(value, path) {
317
+ function validateRange(value, path, options = {}) {
318
+ const checkInt = options.allowZero ? validateNonNegativeInt : validatePositiveInt;
229
319
  if (typeof value === "number") {
230
- validatePositiveInt(value, path);
320
+ checkInt(value, path);
231
321
  return value;
232
322
  }
233
323
  if (typeof value !== "object" || value === null || Array.isArray(value)) {
234
324
  throw new Error(
235
- `${path}: must be a positive integer or { min, max }; got ${JSON.stringify(value)}`
325
+ `${path}: must be an integer or { min, max }; got ${JSON.stringify(value)}`
236
326
  );
237
327
  }
238
328
  const r = value;
239
- validatePositiveInt(r.min, `${path}.min`);
240
- validatePositiveInt(r.max, `${path}.max`);
329
+ checkInt(r.min, `${path}.min`);
330
+ checkInt(r.max, `${path}.max`);
241
331
  if (r.min > r.max) {
242
332
  throw new Error(`${path}: min (${r.min}) must be \u2264 max (${r.max})`);
243
333
  }
@@ -246,6 +336,9 @@ function validateRange(value, path) {
246
336
  function rangeMax(range) {
247
337
  return typeof range === "number" ? range : range.max;
248
338
  }
339
+ function rangeMin(range) {
340
+ return typeof range === "number" ? range : range.min;
341
+ }
249
342
  function validateParametricStudies(studies, path) {
250
343
  if (typeof studies !== "object" || studies === null || Array.isArray(studies)) {
251
344
  throw new Error(`${path}: must be an object`);
@@ -259,6 +352,32 @@ function validateParametricStudies(studies, path) {
259
352
  validateRange(s.fileSizeKb, `${path}.fileSizeKb`);
260
353
  validateSizeKbLimit(rangeMax(s.fileSizeKb), `${path}.fileSizeKb`);
261
354
  }
355
+ if ("largeFilesPerSeries" in s) {
356
+ validateRange(s.largeFilesPerSeries, `${path}.largeFilesPerSeries`, {
357
+ allowZero: true
358
+ });
359
+ if (!("largeFileBytes" in s)) {
360
+ throw new Error(
361
+ `${path}.largeFileBytes: is required when largeFilesPerSeries is set`
362
+ );
363
+ }
364
+ }
365
+ if ("largeFileBytes" in s) {
366
+ if (!("largeFilesPerSeries" in s)) {
367
+ throw new Error(
368
+ `${path}.largeFilesPerSeries: is required when largeFileBytes is set`
369
+ );
370
+ }
371
+ validateRange(s.largeFileBytes, `${path}.largeFileBytes`);
372
+ validateLargeTargetBytes(
373
+ rangeMin(s.largeFileBytes),
374
+ `${path}.largeFileBytes (min)`
375
+ );
376
+ validateLargeTargetBytes(
377
+ rangeMax(s.largeFileBytes),
378
+ `${path}.largeFileBytes (max)`
379
+ );
380
+ }
262
381
  if ("modalities" in s) {
263
382
  if (!Array.isArray(s.modalities) || s.modalities.length === 0) {
264
383
  throw new Error(`${path}.modalities: must be a non-empty array`);
@@ -39,8 +39,11 @@ function buildDicomdirBuffer() {
39
39
  return buf.subarray(0, off);
40
40
  }
41
41
 
42
- // src/schema/validate.ts
42
+ // src/schema/limits.ts
43
43
  var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
44
+ var MAX_STREAM_BYTES = 50 * 1024 * 1024 * 1024;
45
+
46
+ // src/schema/validate.ts
44
47
  var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
45
48
 
46
49
  // src/syntheticFixtures/generator.ts
@@ -214,8 +217,16 @@ function buildBufferForSpec(spec, uid) {
214
217
  return buildNonDicomBuffer(spec.content);
215
218
  case "dicomdir":
216
219
  return buildDicomdirBuffer();
220
+ case "large-image":
221
+ throw new Error(
222
+ "large-image cannot be generated in memory \u2014 use writeCollectionFromSpec (disk-only streaming)"
223
+ );
217
224
  }
218
225
  }
219
226
  export {
220
- buildBufferForSpec
227
+ applyTagOverrides,
228
+ buildBaseImageDataset,
229
+ buildBufferForSpec,
230
+ buildMeta,
231
+ serializeDict
221
232
  };