dicom-synth 1.2.0 → 1.4.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/README.md CHANGED
@@ -4,7 +4,7 @@ Schema-driven **synthetic DICOM fixture generation** and **public fixture** fetc
4
4
 
5
5
  ## Disclaimer
6
6
 
7
- Still in early release. APIs may change without notice.
7
+ ⚠️ Still in early release. APIs may change without notice. Version 2+ will be the first stable release ⚠️
8
8
 
9
9
  ## Install
10
10
 
@@ -24,16 +24,16 @@ Generation is a three-layer API. Each layer is a thin wrapper over the one below
24
24
  ```ts
25
25
  import { generateFile } from 'dicom-synth'
26
26
 
27
- const { buffer, filename, type, index } = await generateFile({ type: 'valid-ct' })
27
+ const { buffer, filename, type, index } = await generateFile({ type: 'valid-image' })
28
28
  // buffer: Buffer ready to write or pass to a parser
29
- // filename: 'valid-ct-000.dcm'
29
+ // filename: 'valid-image-000.dcm'
30
30
  ```
31
31
 
32
32
  With options:
33
33
 
34
34
  ```ts
35
35
  const { buffer } = await generateFile(
36
- { type: 'valid-ct', tags: { PatientID: 'P001' }, violations: ['uid-too-long'] },
36
+ { type: 'valid-image', tags: { PatientID: 'P001' }, violations: ['uid-too-long'] },
37
37
  { seed: 42, index: 7 },
38
38
  )
39
39
  ```
@@ -44,7 +44,7 @@ Write a single file to disk:
44
44
  import { writeFileSync } from 'node:fs'
45
45
  import { generateFile } from 'dicom-synth'
46
46
 
47
- const { buffer, filename } = await generateFile({ type: 'valid-ct' })
47
+ const { buffer, filename } = await generateFile({ type: 'valid-image' })
48
48
  writeFileSync(`./out/${filename}`, buffer)
49
49
  ```
50
50
 
@@ -55,8 +55,8 @@ import { generateCollectionFromSpec } from 'dicom-synth'
55
55
 
56
56
  for await (const file of generateCollectionFromSpec({
57
57
  entries: [
58
- { type: 'valid-ct', count: 10 },
59
- { type: 'invalid-uid-ct', count: 3 },
58
+ { type: 'valid-image', count: 10 },
59
+ { type: 'invalid-uid-image', count: 3 },
60
60
  ],
61
61
  seed: 42,
62
62
  })) {
@@ -72,7 +72,7 @@ Files are yielded one at a time — safe to pipe without buffering the entire co
72
72
  import { writeCollectionFromSpec } from 'dicom-synth'
73
73
 
74
74
  const manifest = await writeCollectionFromSpec(
75
- { entries: [{ type: 'valid-ct', count: 5 }], seed: 42 },
75
+ { entries: [{ type: 'valid-image', count: 5 }], seed: 42 },
76
76
  './fixtures/generated',
77
77
  )
78
78
  // manifest: Array<{ path: string; type: string; index: number }>
@@ -95,7 +95,7 @@ import { writeCollectionFromSpec } from 'dicom-synth'
95
95
  const dir = await mkdtemp(join(tmpdir(), 'synth-'))
96
96
  try {
97
97
  await writeCollectionFromSpec(
98
- { entries: [{ type: 'valid-ct', count: 3 }], seed: 42 },
98
+ { entries: [{ type: 'valid-image', count: 3 }], seed: 42 },
99
99
  dir,
100
100
  )
101
101
  // run pipeline against dir
@@ -148,7 +148,7 @@ Use layer 2 when passing generated files directly to a parser or pipeline withou
148
148
  import { generateCollectionFromSpec, type GeneratedFile } from 'dicom-synth'
149
149
 
150
150
  const files: GeneratedFile[] = []
151
- for await (const file of generateCollectionFromSpec({ entries: [{ type: 'valid-ct', count: 5 }] })) {
151
+ for await (const file of generateCollectionFromSpec({ entries: [{ type: 'valid-image', count: 5 }] })) {
152
152
  files.push(file)
153
153
  }
154
154
  ```
@@ -163,11 +163,15 @@ for await (const file of generateCollectionFromSpec({ entries: [{ type: 'valid-c
163
163
  import type { DatasetSpec } from 'dicom-synth'
164
164
 
165
165
  type DatasetSpec = {
166
- entries: EntrySpec[] // one or more entry specs; must not be empty
167
- seed?: number // optional: fixed seed for deterministic UID generation
166
+ entries?: EntrySpec[] // flat entries each file gets its own UIDs
167
+ studies?: StudySpec[] // grouped entries shared study/series UIDs
168
+ seed?: number // optional: fixed seed for deterministic UID generation
169
+ layout?: 'flat' | 'hierarchical' // optional: directory layout for grouped files
168
170
  }
169
171
  ```
170
172
 
173
+ At least one of `entries` or `studies` must be present and non-empty; both may be used together.
174
+
171
175
  `seed` makes UID generation fully deterministic across runs. Omit it for random UIDs.
172
176
 
173
177
  ### `EntrySpec`
@@ -175,7 +179,7 @@ type DatasetSpec = {
175
179
  An `EntrySpec` is a `FileSpec` plus an optional `count` field (how many times to generate it):
176
180
 
177
181
  ```json
178
- { "type": "valid-ct", "count": 10 }
182
+ { "type": "valid-image", "count": 10 }
179
183
  ```
180
184
 
181
185
  `count` defaults to 1 and must be a positive integer. It is a collection-layer concern — `generateFile` does not accept `count`.
@@ -184,37 +188,112 @@ An `EntrySpec` is a `FileSpec` plus an optional `count` field (how many times to
184
188
 
185
189
  | Type | Description | Conformance |
186
190
  |---|---|---|
187
- | `valid-ct` | Standards-valid CT image; numeric UIDs, complete meta header | strict |
188
- | `invalid-uid-ct` | CT with non-numeric UIDs (letters in UID components) | edge |
189
- | `vendor-warnings-ct` | CT with empty `Laterality` and `PatientWeight = 0` — produces dciodvfy warnings | edge |
190
- | `large-ct` | CT with configurable pixel dimensions; `rows` and `columns` required | strict |
191
+ | `valid-image` | Standards-valid image (CT by default — see `modality`); numeric UIDs, complete meta header | strict |
192
+ | `invalid-uid-image` | Image with non-numeric UIDs (letters in UID components) | edge |
193
+ | `vendor-warnings-image` | Image with empty `Laterality` and `PatientWeight = 0` — produces dciodvfy warnings | edge |
191
194
  | `fake-signature` | 200-byte buffer with `XXXX` at preamble offset — no DICM magic | edge |
192
195
  | `non-dicom` | Arbitrary text buffer; no extension in filename | edge |
193
196
  | `dicomdir` | Minimal DICOMDIR file | strict |
194
197
 
195
- **`large-ct` fields:**
198
+ **`non-dicom` fields:**
196
199
 
197
200
  ```json
198
- { "type": "large-ct", "rows": 512, "columns": 512, "frames": 100 }
201
+ { "type": "non-dicom", "content": "not a dicom file" }
199
202
  ```
200
203
 
201
- `frames` defaults to 1. A 512×512×100 CT produces a buffer of ~52 MB.
204
+ `content` defaults to `"not dicom"`.
202
205
 
203
- **`non-dicom` fields:**
206
+ ### Sizing (`rows`/`columns`/`frames` or `targetSizeKb`)
207
+
208
+ Available on all image types. Two mutually exclusive mechanisms; omitting both produces a minimal 1×1 image.
209
+
210
+ **Explicit pixel dimensions** — for geometry-shaped cases (large frames, strips, multi-frame):
204
211
 
205
212
  ```json
206
- { "type": "non-dicom", "content": "not a dicom file" }
213
+ { "type": "valid-image", "rows": 512, "columns": 512, "frames": 100 }
207
214
  ```
208
215
 
209
- `content` defaults to `"not dicom"`.
216
+ `rows` and `columns` must be provided together; `frames` defaults to 1. A 512×512×100 image produces a buffer of ~52 MB.
217
+
218
+ **Target file size** — for byte-shaped cases (upload limits, memory ceilings):
219
+
220
+ ```json
221
+ { "type": "valid-image", "targetSizeKb": 250000 }
222
+ ```
223
+
224
+ Pads `PixelData` so the file lands within ±2% or ±4 KB of the target (whichever is larger), accounting for tag and transfer-syntax overhead. `Rows`/`Columns` stay consistent with the pixel data, so the file remains conformant.
225
+
226
+ Both mechanisms are capped at 512 MB of pixel data to prevent accidental OOM. A target smaller than the file's fixed overhead (roughly 1 KB) produces the minimal possible file, slightly above target. When sizing fields are set they control `Rows`, `Columns`, and `PixelData` — tag overrides of those keywords do not apply.
227
+
228
+ ### Modality presets (`modality`)
229
+
230
+ Available on all image types. Defaults to `CT`.
231
+
232
+ ```json
233
+ { "type": "valid-image", "modality": "PT" }
234
+ ```
235
+
236
+ Each preset sets the matching SOP Class UID (dataset and meta header), the `Modality` tag, and minimal modality-specific type-1 attributes:
237
+
238
+ | Modality | SOP Class | Extra attributes |
239
+ |---|---|---|
240
+ | `CT` (default) | CT Image Storage `…1.1.2` | — |
241
+ | `PT` | PET Image Storage `…1.1.128` | `Units`, `DecayCorrection`, `CorrectedImage` |
242
+ | `MR` | MR Image Storage `…1.1.4` | `ScanningSequence`, `SequenceVariant` |
243
+ | `CR` | CR Image Storage `…1.1.1` | — |
244
+
245
+ Presets target shape/variance fidelity for pipeline testing, not clinical fidelity. Tag overrides win over preset attributes.
246
+
247
+ ```ts
248
+ import type { Modality } from 'dicom-synth'
249
+ ```
250
+
251
+ ### Study/series grouping (`studies`)
252
+
253
+ Group files so they share `StudyInstanceUID` (per study) and `SeriesInstanceUID` (per series). `InstanceNumber` increments from 1 within each series. SOP Instance UIDs remain unique per file.
254
+
255
+ ```json
256
+ {
257
+ "studies": [
258
+ {
259
+ "tags": { "PatientID": "P001" },
260
+ "series": [
261
+ { "entries": [{ "type": "valid-image", "modality": "CT", "count": 50 }] },
262
+ { "entries": [{ "type": "valid-image", "modality": "PT", "count": 120 }] }
263
+ ]
264
+ },
265
+ { "series": [{ "entries": [{ "type": "valid-image" }] }], "count": 3 }
266
+ ],
267
+ "seed": 42,
268
+ "layout": "hierarchical"
269
+ }
270
+ ```
271
+
272
+ - `StudySpec.count` generates N copies of the whole study, each with fresh UIDs.
273
+ - `tags` may be set at study, series, and entry level — merged into each file, with entry tags winning over series tags, which win over study tags.
274
+ - Only image-generating types can appear in a series (`fake-signature`, `non-dicom`, and `dicomdir` are rejected).
275
+ - With a `seed`, group UIDs are deterministic; without one they are random but still shared within each group.
276
+
277
+ ```ts
278
+ import type { StudySpec, SeriesSpec, SeriesEntrySpec } from 'dicom-synth'
279
+ ```
280
+
281
+ ### Layout (`layout`)
282
+
283
+ | Value | Effect |
284
+ |---|---|
285
+ | `flat` (default) | All files in the output root, named `<type>-<index>[.dcm]` |
286
+ | `hierarchical` | Grouped files written as `study-001/series-001/00001.dcm`; flat `entries` stay in the root |
287
+
288
+ `GeneratedFile.relativePath` carries the layout-aware path for in-process consumers; `filename` is always the basename.
210
289
 
211
290
  ### Tag overrides (`tags`)
212
291
 
213
- Available on `valid-ct`, `invalid-uid-ct`, `vendor-warnings-ct`, and `large-ct`.
292
+ Available on all image types.
214
293
 
215
294
  ```json
216
295
  {
217
- "type": "valid-ct",
296
+ "type": "valid-image",
218
297
  "tags": {
219
298
  "PatientID": "P001",
220
299
  "PatientName": "DOE^JANE",
@@ -231,7 +310,7 @@ import type { DicomTagOverrides } from 'dicom-synth'
231
310
 
232
311
  ### Transfer syntax (`transferSyntax`)
233
312
 
234
- Available on `valid-ct`, `invalid-uid-ct`, `vendor-warnings-ct`, and `large-ct`.
313
+ Available on all image types.
235
314
 
236
315
  | Value | `TransferSyntaxUID` in meta header |
237
316
  |---|---|
@@ -241,7 +320,7 @@ Available on `valid-ct`, `invalid-uid-ct`, `vendor-warnings-ct`, and `large-ct`.
241
320
 
242
321
  ### Violation injection (`violations`)
243
322
 
244
- Available on `valid-ct`, `invalid-uid-ct`, `vendor-warnings-ct`, and `large-ct`. Violations are applied as post-processing transforms to an otherwise valid buffer.
323
+ Available on all image types. Violations are applied as post-processing transforms to an otherwise valid buffer.
245
324
 
246
325
  | Violation | Effect | Detectable by dciodvfy |
247
326
  |---|---|---|
@@ -255,7 +334,7 @@ Available on `valid-ct`, `invalid-uid-ct`, `vendor-warnings-ct`, and `large-ct`.
255
334
  Tag-level violations are applied before byte-level violations. Byte-level violations may produce a buffer that cannot be re-parsed.
256
335
 
257
336
  ```json
258
- { "type": "valid-ct", "violations": ["uid-too-long", "missing-meta-header"] }
337
+ { "type": "valid-image", "violations": ["uid-too-long", "missing-meta-header"] }
259
338
  ```
260
339
 
261
340
  ```ts
@@ -277,16 +356,26 @@ The validator throws with a human-readable message on any structural error.
277
356
  ```json
278
357
  {
279
358
  "entries": [
280
- { "type": "valid-ct", "count": 10, "tags": { "PatientID": "P001" }, "transferSyntax": "explicit-vr-little-endian" },
281
- { "type": "invalid-uid-ct", "count": 3 },
282
- { "type": "vendor-warnings-ct", "count": 2 },
283
- { "type": "large-ct", "count": 1, "rows": 512, "columns": 512, "frames": 100 },
284
- { "type": "valid-ct", "count": 1, "violations": ["uid-too-long", "missing-meta-header"] },
359
+ { "type": "valid-image", "count": 10, "tags": { "PatientID": "P001" }, "transferSyntax": "explicit-vr-little-endian" },
360
+ { "type": "invalid-uid-image", "count": 3 },
361
+ { "type": "vendor-warnings-image", "count": 2 },
362
+ { "type": "valid-image", "count": 1, "rows": 512, "columns": 512, "frames": 100 },
363
+ { "type": "valid-image", "count": 1, "targetSizeKb": 250000 },
364
+ { "type": "valid-image", "count": 1, "violations": ["uid-too-long", "missing-meta-header"] },
285
365
  { "type": "fake-signature", "count": 5 },
286
366
  { "type": "non-dicom", "count": 2, "content": "garbage" },
287
367
  { "type": "dicomdir", "count": 1 }
288
368
  ],
289
- "seed": 42
369
+ "studies": [
370
+ {
371
+ "series": [
372
+ { "entries": [{ "type": "valid-image", "modality": "CT", "count": 50 }] },
373
+ { "entries": [{ "type": "valid-image", "modality": "PT", "count": 120 }] }
374
+ ]
375
+ }
376
+ ],
377
+ "seed": 42,
378
+ "layout": "hierarchical"
290
379
  }
291
380
  ```
292
381
 
@@ -299,20 +388,20 @@ The validator throws with a human-readable message on any structural error.
299
388
  dicom-synth-generate --schema dataset.json --out ./fixtures/generated
300
389
 
301
390
  # Inline schema
302
- dicom-synth-generate --schema-inline '{"entries":[{"type":"valid-ct","count":5}]}' --out ./out
391
+ dicom-synth-generate --schema-inline '{"entries":[{"type":"valid-image","count":5}]}' --out ./out
303
392
 
304
393
  # Default output directory is ./fixtures/generated
305
394
  dicom-synth-generate --schema dataset.json
306
395
  ```
307
396
 
308
- The default schema (`examples/default.json`) generates one each of `valid-ct`, `invalid-uid-ct`, and `vendor-warnings-ct`:
397
+ The default schema (`examples/default.json`) generates one each of `valid-image`, `invalid-uid-image`, and `vendor-warnings-image`:
309
398
 
310
399
  ```bash
311
400
  dicom-synth-generate --schema examples/default.json --out /tmp/out
312
401
  # Wrote 3 file(s) to /tmp/out
313
- # valid-ct: 1
314
- # invalid-uid-ct: 1
315
- # vendor-warnings-ct: 1
402
+ # valid-image: 1
403
+ # invalid-uid-image: 1
404
+ # vendor-warnings-image: 1
316
405
  ```
317
406
 
318
407
  Schema validation errors exit with code 1 and a descriptive message.
@@ -391,7 +480,7 @@ pnpm fetch:public-case -- pydicom-CT-small
391
480
  | `src/public-fixtures/fetch.ts` | Fetch, SHA-256 verify, content-addressed cache |
392
481
  | `bin/dicom-synth-generate.mjs` | Published generate CLI (requires `pnpm build`) |
393
482
  | `bin/dicom-synth-fetch.mjs` | Published fetch CLI (requires `pnpm build`) |
394
- | `examples/default.json` | Default `DatasetSpec` — one each of `valid-ct`, `invalid-uid-ct`, `vendor-warnings-ct` |
483
+ | `examples/default.json` | Default `DatasetSpec` — one each of `valid-image`, `invalid-uid-image`, `vendor-warnings-image` |
395
484
 
396
485
  ---
397
486
 
@@ -417,9 +506,10 @@ Git hooks: see [CONTRIBUTING.md](CONTRIBUTING.md).
417
506
 
418
507
  ## Future development
419
508
 
420
- - **Multi-series study layouts** — shared `StudyInstanceUID` across files via a `studies` top-level key; no breaking schema change needed
509
+ - **Size targeting** — generate a file of approximately N KB via a `targetSizeKb` field
510
+ - **Parametric dataset designer** — range-based spec (`{min, max}` for counts/sizes) that resolves deterministically into a concrete `DatasetSpec`
511
+ - **Dry-run mode** — emit the manifest (paths, types, grouping) without writing files
421
512
  - **Compressed transfer syntaxes** — JPEG-LS, JPEG 2000, RLE
422
- - **Modality presets** — MR, PET, CR profiles beyond CT
423
513
  - **Private fixture catalogues** — same SHA-256 fetch/cache pattern for credentials-backed sources (e.g. S3)
424
514
 
425
515
  ---
@@ -49,7 +49,35 @@ var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
49
49
  var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
50
50
 
51
51
  // src/syntheticFixtures/generator.ts
52
- var CT_SOP_CLASS = "1.2.840.10008.5.1.4.1.1.2";
52
+ var MODALITY_PRESETS = {
53
+ CT: {
54
+ sopClassUid: "1.2.840.10008.5.1.4.1.1.2",
55
+ // CT Image Storage
56
+ attributes: {}
57
+ },
58
+ PT: {
59
+ sopClassUid: "1.2.840.10008.5.1.4.1.1.128",
60
+ // PET Image Storage
61
+ attributes: {
62
+ Units: "BQML",
63
+ DecayCorrection: "NONE",
64
+ CorrectedImage: ["ATTN", "DECY"]
65
+ }
66
+ },
67
+ MR: {
68
+ sopClassUid: "1.2.840.10008.5.1.4.1.1.4",
69
+ // MR Image Storage
70
+ attributes: {
71
+ ScanningSequence: "SE",
72
+ SequenceVariant: "NONE"
73
+ }
74
+ },
75
+ CR: {
76
+ sopClassUid: "1.2.840.10008.5.1.4.1.1.1",
77
+ // CR Image Storage
78
+ attributes: {}
79
+ }
80
+ };
53
81
  var TRANSFER_SYNTAX_UID = {
54
82
  "explicit-vr-little-endian": "1.2.840.10008.1.2.1",
55
83
  "implicit-vr-little-endian": "1.2.840.10008.1.2"
@@ -82,22 +110,23 @@ function applyTagOverrides(dataset, tags) {
82
110
  }
83
111
  }
84
112
  }
85
- function buildMeta(uid, transferSyntax = "explicit-vr-little-endian") {
113
+ function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
86
114
  return {
87
115
  FileMetaInformationVersion: new Uint8Array([0, 1]).buffer,
88
- MediaStorageSOPClassUID: CT_SOP_CLASS,
116
+ MediaStorageSOPClassUID: MODALITY_PRESETS[modality].sopClassUid,
89
117
  MediaStorageSOPInstanceUID: uid.sop,
90
118
  TransferSyntaxUID: TRANSFER_SYNTAX_UID[transferSyntax],
91
119
  ImplementationClassUID: "1.2.3.4",
92
120
  ImplementationVersionName: "SYNTH"
93
121
  };
94
122
  }
95
- function buildBaseCtDataset(uid) {
123
+ function buildBaseImageDataset(uid, modality) {
124
+ const preset = MODALITY_PRESETS[modality];
96
125
  return {
97
126
  PatientName: "SYNTH^SUBJECT",
98
127
  PatientID: "SYNTH_PID",
99
- Modality: "CT",
100
- SOPClassUID: CT_SOP_CLASS,
128
+ Modality: modality,
129
+ SOPClassUID: preset.sopClassUid,
101
130
  SOPInstanceUID: uid.sop,
102
131
  SeriesInstanceUID: uid.series,
103
132
  StudyInstanceUID: uid.study,
@@ -114,7 +143,8 @@ function buildBaseCtDataset(uid) {
114
143
  PixelData: new Uint8Array([0, 0]).buffer,
115
144
  // Declares PixelData's VR explicitly — without it dcmjs logs
116
145
  // "No value representation given for PixelData" and falls back to OW anyway.
117
- _vrMap: { PixelData: "OW" }
146
+ _vrMap: { PixelData: "OW" },
147
+ ...preset.attributes
118
148
  };
119
149
  }
120
150
  function serializeDict(meta, dataset) {
@@ -128,36 +158,46 @@ function serializeDict(meta, dataset) {
128
158
  );
129
159
  return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
130
160
  }
131
- function buildValidCtBuffer(uid, tags, transferSyntax) {
132
- const dataset = buildBaseCtDataset(uid);
133
- applyTagOverrides(dataset, tags);
134
- return serializeDict(buildMeta(uid, transferSyntax), dataset);
161
+ var MAX_DIMENSION = 65535;
162
+ function applySizing(dataset, meta, spec) {
163
+ if (spec.targetSizeKb !== void 0) {
164
+ const clampDim = (n) => Math.min(MAX_DIMENSION, Math.max(1, Math.round(n)));
165
+ const minimalPixelBytes = dataset.PixelData.byteLength;
166
+ const overhead = serializeDict(meta, dataset).length - minimalPixelBytes;
167
+ const cells = Math.max(
168
+ 1,
169
+ Math.round((spec.targetSizeKb * 1024 - overhead) / 2)
170
+ );
171
+ const rows = clampDim(Math.sqrt(cells));
172
+ const columns = clampDim(cells / rows);
173
+ dataset.Rows = rows;
174
+ dataset.Columns = columns;
175
+ dataset.PixelData = new ArrayBuffer(rows * columns * 2);
176
+ } else if (spec.rows !== void 0 && spec.columns !== void 0) {
177
+ dataset.Rows = spec.rows;
178
+ dataset.Columns = spec.columns;
179
+ if (spec.frames !== void 0) dataset.NumberOfFrames = spec.frames;
180
+ dataset.PixelData = new ArrayBuffer(
181
+ spec.rows * spec.columns * (spec.frames ?? 1) * 2
182
+ );
183
+ }
135
184
  }
136
- function buildInvalidUidCtBuffer(uid, tags, transferSyntax) {
137
- const invalidUid = {
185
+ function buildImageBuffer(spec, uid) {
186
+ const modality = spec.modality ?? "CT";
187
+ const effectiveUid = spec.type === "invalid-uid-image" ? {
138
188
  study: `2.25.invalid.study.${uid.study.slice(-6)}`,
139
189
  series: `2.25.invalid.series.${uid.series.slice(-6)}`,
140
190
  sop: `2.25.invalid.sop.${uid.sop.slice(-6)}`
141
- };
142
- const dataset = buildBaseCtDataset(invalidUid);
143
- applyTagOverrides(dataset, tags);
144
- return serializeDict(buildMeta(invalidUid, transferSyntax), dataset);
145
- }
146
- function buildVendorCtBuffer(uid, tags, transferSyntax) {
147
- const dataset = buildBaseCtDataset(uid);
148
- dataset.Laterality = "";
149
- dataset.PatientWeight = "0";
150
- applyTagOverrides(dataset, tags);
151
- return serializeDict(buildMeta(uid, transferSyntax), dataset);
152
- }
153
- function buildLargeCtBuffer(uid, rows, columns, frames, tags, transferSyntax) {
154
- const dataset = buildBaseCtDataset(uid);
155
- dataset.Rows = rows;
156
- dataset.Columns = columns;
157
- dataset.NumberOfFrames = frames;
158
- dataset.PixelData = new Uint8Array(rows * columns * frames * 2).buffer;
159
- applyTagOverrides(dataset, tags);
160
- return serializeDict(buildMeta(uid, transferSyntax), dataset);
191
+ } : uid;
192
+ const dataset = buildBaseImageDataset(effectiveUid, modality);
193
+ if (spec.type === "vendor-warnings-image") {
194
+ dataset.Laterality = "";
195
+ dataset.PatientWeight = "0";
196
+ }
197
+ applyTagOverrides(dataset, spec.tags);
198
+ const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
199
+ applySizing(dataset, meta, spec);
200
+ return serializeDict(meta, dataset);
161
201
  }
162
202
  function buildFakeSignatureBuffer() {
163
203
  const buf = Buffer.alloc(200, 0);
@@ -169,21 +209,10 @@ function buildNonDicomBuffer(content = "not dicom") {
169
209
  }
170
210
  function buildBufferForSpec(spec, uid) {
171
211
  switch (spec.type) {
172
- case "valid-ct":
173
- return buildValidCtBuffer(uid, spec.tags, spec.transferSyntax);
174
- case "invalid-uid-ct":
175
- return buildInvalidUidCtBuffer(uid, spec.tags, spec.transferSyntax);
176
- case "vendor-warnings-ct":
177
- return buildVendorCtBuffer(uid, spec.tags, spec.transferSyntax);
178
- case "large-ct":
179
- return buildLargeCtBuffer(
180
- uid,
181
- spec.rows,
182
- spec.columns,
183
- spec.frames ?? 1,
184
- spec.tags,
185
- spec.transferSyntax
186
- );
212
+ case "valid-image":
213
+ case "invalid-uid-image":
214
+ case "vendor-warnings-image":
215
+ return buildImageBuffer(spec, uid);
187
216
  case "fake-signature":
188
217
  return buildFakeSignatureBuffer();
189
218
  case "non-dicom":
package/dist/esm/index.js CHANGED
@@ -46,13 +46,18 @@ function buildDicomdirBuffer() {
46
46
 
47
47
  // src/schema/validate.ts
48
48
  var TYPE_CAPABILITIES = {
49
- "valid-ct": { tags: true, violations: true, transferSyntax: true },
50
- "invalid-uid-ct": { tags: true, violations: true, transferSyntax: true },
51
- "vendor-warnings-ct": { tags: true, violations: true, transferSyntax: true },
52
- "large-ct": { tags: true, violations: true, transferSyntax: true },
53
- "fake-signature": { tags: false, violations: false, transferSyntax: false },
54
- "non-dicom": { tags: false, violations: false, transferSyntax: false },
55
- dicomdir: { tags: false, violations: false, transferSyntax: false }
49
+ "valid-image": { image: true },
50
+ "invalid-uid-image": { image: true },
51
+ "vendor-warnings-image": { image: true },
52
+ "fake-signature": { image: false },
53
+ "non-dicom": { image: false },
54
+ dicomdir: { image: false }
55
+ };
56
+ var VALID_MODALITIES = {
57
+ CT: true,
58
+ PT: true,
59
+ MR: true,
60
+ CR: true
56
61
  };
57
62
  var VALID_LAYOUTS = {
58
63
  flat: true,
@@ -100,22 +105,33 @@ function validateEntry(entry, path) {
100
105
  const type = e.type;
101
106
  const caps = TYPE_CAPABILITIES[type];
102
107
  if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
108
+ for (const field of [
109
+ "modality",
110
+ "rows",
111
+ "columns",
112
+ "frames",
113
+ "targetSizeKb",
114
+ "tags",
115
+ "violations",
116
+ "transferSyntax"
117
+ ]) {
118
+ if (!caps.image && field in e)
119
+ throw new Error(`${path}.${field}: not supported for type "${type}"`);
120
+ }
103
121
  if ("transferSyntax" in e) {
104
- if (!caps.transferSyntax) {
122
+ if (!Object.hasOwn(VALID_TRANSFER_SYNTAXES, e.transferSyntax)) {
105
123
  throw new Error(
106
- `${path}.transferSyntax: not supported for type "${type}"`
124
+ `${path}.transferSyntax: must be one of ${Object.keys(VALID_TRANSFER_SYNTAXES).join(", ")}`
107
125
  );
108
126
  }
109
- if (!Object.hasOwn(VALID_TRANSFER_SYNTAXES, e.transferSyntax)) {
127
+ }
128
+ if ("modality" in e) {
129
+ if (typeof e.modality !== "string" || !Object.hasOwn(VALID_MODALITIES, e.modality)) {
110
130
  throw new Error(
111
- `${path}.transferSyntax: must be one of ${Object.keys(VALID_TRANSFER_SYNTAXES).join(", ")}`
131
+ `${path}.modality: must be one of ${Object.keys(VALID_MODALITIES).join(", ")}; got ${JSON.stringify(e.modality)}`
112
132
  );
113
133
  }
114
134
  }
115
- if (!caps.tags && "tags" in e)
116
- throw new Error(`${path}.tags: not supported for type "${type}"`);
117
- if (!caps.violations && "violations" in e)
118
- throw new Error(`${path}.violations: not supported for type "${type}"`);
119
135
  if ("tags" in e) validateTags(e.tags, `${path}.tags`);
120
136
  if ("violations" in e) {
121
137
  if (!Array.isArray(e.violations)) {
@@ -129,7 +145,21 @@ function validateEntry(entry, path) {
129
145
  }
130
146
  }
131
147
  }
132
- if (type === "large-ct") {
148
+ const hasDims = "rows" in e || "columns" in e || "frames" in e;
149
+ if ("targetSizeKb" in e) {
150
+ if (hasDims) {
151
+ throw new Error(
152
+ `${path}: targetSizeKb cannot be combined with rows/columns/frames \u2014 use one sizing mechanism`
153
+ );
154
+ }
155
+ validatePositiveInt(e.targetSizeKb, `${path}.targetSizeKb`);
156
+ if (e.targetSizeKb * 1024 > MAX_PIXEL_BYTES) {
157
+ throw new Error(`${path}.targetSizeKb: exceeds the 512 MB limit`);
158
+ }
159
+ } else if (hasDims) {
160
+ if (!("rows" in e) || !("columns" in e)) {
161
+ throw new Error(`${path}: rows and columns must be provided together`);
162
+ }
133
163
  validatePositiveInt(e.rows, `${path}.rows`);
134
164
  validatePositiveInt(e.columns, `${path}.columns`);
135
165
  if ("frames" in e) validatePositiveInt(e.frames, `${path}.frames`);
@@ -162,9 +192,9 @@ function validateSeries(series, path) {
162
192
  }
163
193
  for (let i = 0; i < s.entries.length; i++) {
164
194
  const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
165
- if (!TYPE_CAPABILITIES[e.type].tags) {
195
+ if (!TYPE_CAPABILITIES[e.type].image) {
166
196
  throw new Error(
167
- `${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only types that support tags can be grouped`
197
+ `${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
168
198
  );
169
199
  }
170
200
  }
@@ -233,7 +263,35 @@ function validateDatasetSpec(raw) {
233
263
  }
234
264
 
235
265
  // src/syntheticFixtures/generator.ts
236
- var CT_SOP_CLASS = "1.2.840.10008.5.1.4.1.1.2";
266
+ var MODALITY_PRESETS = {
267
+ CT: {
268
+ sopClassUid: "1.2.840.10008.5.1.4.1.1.2",
269
+ // CT Image Storage
270
+ attributes: {}
271
+ },
272
+ PT: {
273
+ sopClassUid: "1.2.840.10008.5.1.4.1.1.128",
274
+ // PET Image Storage
275
+ attributes: {
276
+ Units: "BQML",
277
+ DecayCorrection: "NONE",
278
+ CorrectedImage: ["ATTN", "DECY"]
279
+ }
280
+ },
281
+ MR: {
282
+ sopClassUid: "1.2.840.10008.5.1.4.1.1.4",
283
+ // MR Image Storage
284
+ attributes: {
285
+ ScanningSequence: "SE",
286
+ SequenceVariant: "NONE"
287
+ }
288
+ },
289
+ CR: {
290
+ sopClassUid: "1.2.840.10008.5.1.4.1.1.1",
291
+ // CR Image Storage
292
+ attributes: {}
293
+ }
294
+ };
237
295
  var TRANSFER_SYNTAX_UID = {
238
296
  "explicit-vr-little-endian": "1.2.840.10008.1.2.1",
239
297
  "implicit-vr-little-endian": "1.2.840.10008.1.2"
@@ -266,22 +324,23 @@ function applyTagOverrides(dataset, tags) {
266
324
  }
267
325
  }
268
326
  }
269
- function buildMeta(uid, transferSyntax = "explicit-vr-little-endian") {
327
+ function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
270
328
  return {
271
329
  FileMetaInformationVersion: new Uint8Array([0, 1]).buffer,
272
- MediaStorageSOPClassUID: CT_SOP_CLASS,
330
+ MediaStorageSOPClassUID: MODALITY_PRESETS[modality].sopClassUid,
273
331
  MediaStorageSOPInstanceUID: uid.sop,
274
332
  TransferSyntaxUID: TRANSFER_SYNTAX_UID[transferSyntax],
275
333
  ImplementationClassUID: "1.2.3.4",
276
334
  ImplementationVersionName: "SYNTH"
277
335
  };
278
336
  }
279
- function buildBaseCtDataset(uid) {
337
+ function buildBaseImageDataset(uid, modality) {
338
+ const preset = MODALITY_PRESETS[modality];
280
339
  return {
281
340
  PatientName: "SYNTH^SUBJECT",
282
341
  PatientID: "SYNTH_PID",
283
- Modality: "CT",
284
- SOPClassUID: CT_SOP_CLASS,
342
+ Modality: modality,
343
+ SOPClassUID: preset.sopClassUid,
285
344
  SOPInstanceUID: uid.sop,
286
345
  SeriesInstanceUID: uid.series,
287
346
  StudyInstanceUID: uid.study,
@@ -298,7 +357,8 @@ function buildBaseCtDataset(uid) {
298
357
  PixelData: new Uint8Array([0, 0]).buffer,
299
358
  // Declares PixelData's VR explicitly — without it dcmjs logs
300
359
  // "No value representation given for PixelData" and falls back to OW anyway.
301
- _vrMap: { PixelData: "OW" }
360
+ _vrMap: { PixelData: "OW" },
361
+ ...preset.attributes
302
362
  };
303
363
  }
304
364
  function serializeDict(meta, dataset) {
@@ -312,36 +372,46 @@ function serializeDict(meta, dataset) {
312
372
  );
313
373
  return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
314
374
  }
315
- function buildValidCtBuffer(uid, tags, transferSyntax) {
316
- const dataset = buildBaseCtDataset(uid);
317
- applyTagOverrides(dataset, tags);
318
- return serializeDict(buildMeta(uid, transferSyntax), dataset);
375
+ var MAX_DIMENSION = 65535;
376
+ function applySizing(dataset, meta, spec) {
377
+ if (spec.targetSizeKb !== void 0) {
378
+ const clampDim = (n) => Math.min(MAX_DIMENSION, Math.max(1, Math.round(n)));
379
+ const minimalPixelBytes = dataset.PixelData.byteLength;
380
+ const overhead = serializeDict(meta, dataset).length - minimalPixelBytes;
381
+ const cells = Math.max(
382
+ 1,
383
+ Math.round((spec.targetSizeKb * 1024 - overhead) / 2)
384
+ );
385
+ const rows = clampDim(Math.sqrt(cells));
386
+ const columns = clampDim(cells / rows);
387
+ dataset.Rows = rows;
388
+ dataset.Columns = columns;
389
+ dataset.PixelData = new ArrayBuffer(rows * columns * 2);
390
+ } else if (spec.rows !== void 0 && spec.columns !== void 0) {
391
+ dataset.Rows = spec.rows;
392
+ dataset.Columns = spec.columns;
393
+ if (spec.frames !== void 0) dataset.NumberOfFrames = spec.frames;
394
+ dataset.PixelData = new ArrayBuffer(
395
+ spec.rows * spec.columns * (spec.frames ?? 1) * 2
396
+ );
397
+ }
319
398
  }
320
- function buildInvalidUidCtBuffer(uid, tags, transferSyntax) {
321
- const invalidUid = {
399
+ function buildImageBuffer(spec, uid) {
400
+ const modality = spec.modality ?? "CT";
401
+ const effectiveUid = spec.type === "invalid-uid-image" ? {
322
402
  study: `2.25.invalid.study.${uid.study.slice(-6)}`,
323
403
  series: `2.25.invalid.series.${uid.series.slice(-6)}`,
324
404
  sop: `2.25.invalid.sop.${uid.sop.slice(-6)}`
325
- };
326
- const dataset = buildBaseCtDataset(invalidUid);
327
- applyTagOverrides(dataset, tags);
328
- return serializeDict(buildMeta(invalidUid, transferSyntax), dataset);
329
- }
330
- function buildVendorCtBuffer(uid, tags, transferSyntax) {
331
- const dataset = buildBaseCtDataset(uid);
332
- dataset.Laterality = "";
333
- dataset.PatientWeight = "0";
334
- applyTagOverrides(dataset, tags);
335
- return serializeDict(buildMeta(uid, transferSyntax), dataset);
336
- }
337
- function buildLargeCtBuffer(uid, rows, columns, frames, tags, transferSyntax) {
338
- const dataset = buildBaseCtDataset(uid);
339
- dataset.Rows = rows;
340
- dataset.Columns = columns;
341
- dataset.NumberOfFrames = frames;
342
- dataset.PixelData = new Uint8Array(rows * columns * frames * 2).buffer;
343
- applyTagOverrides(dataset, tags);
344
- return serializeDict(buildMeta(uid, transferSyntax), dataset);
405
+ } : uid;
406
+ const dataset = buildBaseImageDataset(effectiveUid, modality);
407
+ if (spec.type === "vendor-warnings-image") {
408
+ dataset.Laterality = "";
409
+ dataset.PatientWeight = "0";
410
+ }
411
+ applyTagOverrides(dataset, spec.tags);
412
+ const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
413
+ applySizing(dataset, meta, spec);
414
+ return serializeDict(meta, dataset);
345
415
  }
346
416
  function buildFakeSignatureBuffer() {
347
417
  const buf = Buffer.alloc(200, 0);
@@ -353,21 +423,10 @@ function buildNonDicomBuffer(content = "not dicom") {
353
423
  }
354
424
  function buildBufferForSpec(spec, uid) {
355
425
  switch (spec.type) {
356
- case "valid-ct":
357
- return buildValidCtBuffer(uid, spec.tags, spec.transferSyntax);
358
- case "invalid-uid-ct":
359
- return buildInvalidUidCtBuffer(uid, spec.tags, spec.transferSyntax);
360
- case "vendor-warnings-ct":
361
- return buildVendorCtBuffer(uid, spec.tags, spec.transferSyntax);
362
- case "large-ct":
363
- return buildLargeCtBuffer(
364
- uid,
365
- spec.rows,
366
- spec.columns,
367
- spec.frames ?? 1,
368
- spec.tags,
369
- spec.transferSyntax
370
- );
426
+ case "valid-image":
427
+ case "invalid-uid-image":
428
+ case "vendor-warnings-image":
429
+ return buildImageBuffer(spec, uid);
371
430
  case "fake-signature":
372
431
  return buildFakeSignatureBuffer();
373
432
  case "non-dicom":
@@ -1,12 +1,17 @@
1
1
  // src/schema/validate.ts
2
2
  var TYPE_CAPABILITIES = {
3
- "valid-ct": { tags: true, violations: true, transferSyntax: true },
4
- "invalid-uid-ct": { tags: true, violations: true, transferSyntax: true },
5
- "vendor-warnings-ct": { tags: true, violations: true, transferSyntax: true },
6
- "large-ct": { tags: true, violations: true, transferSyntax: true },
7
- "fake-signature": { tags: false, violations: false, transferSyntax: false },
8
- "non-dicom": { tags: false, violations: false, transferSyntax: false },
9
- dicomdir: { tags: false, violations: false, transferSyntax: false }
3
+ "valid-image": { image: true },
4
+ "invalid-uid-image": { image: true },
5
+ "vendor-warnings-image": { image: true },
6
+ "fake-signature": { image: false },
7
+ "non-dicom": { image: false },
8
+ dicomdir: { image: false }
9
+ };
10
+ var VALID_MODALITIES = {
11
+ CT: true,
12
+ PT: true,
13
+ MR: true,
14
+ CR: true
10
15
  };
11
16
  var VALID_LAYOUTS = {
12
17
  flat: true,
@@ -54,22 +59,33 @@ function validateEntry(entry, path) {
54
59
  const type = e.type;
55
60
  const caps = TYPE_CAPABILITIES[type];
56
61
  if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
62
+ for (const field of [
63
+ "modality",
64
+ "rows",
65
+ "columns",
66
+ "frames",
67
+ "targetSizeKb",
68
+ "tags",
69
+ "violations",
70
+ "transferSyntax"
71
+ ]) {
72
+ if (!caps.image && field in e)
73
+ throw new Error(`${path}.${field}: not supported for type "${type}"`);
74
+ }
57
75
  if ("transferSyntax" in e) {
58
- if (!caps.transferSyntax) {
76
+ if (!Object.hasOwn(VALID_TRANSFER_SYNTAXES, e.transferSyntax)) {
59
77
  throw new Error(
60
- `${path}.transferSyntax: not supported for type "${type}"`
78
+ `${path}.transferSyntax: must be one of ${Object.keys(VALID_TRANSFER_SYNTAXES).join(", ")}`
61
79
  );
62
80
  }
63
- if (!Object.hasOwn(VALID_TRANSFER_SYNTAXES, e.transferSyntax)) {
81
+ }
82
+ if ("modality" in e) {
83
+ if (typeof e.modality !== "string" || !Object.hasOwn(VALID_MODALITIES, e.modality)) {
64
84
  throw new Error(
65
- `${path}.transferSyntax: must be one of ${Object.keys(VALID_TRANSFER_SYNTAXES).join(", ")}`
85
+ `${path}.modality: must be one of ${Object.keys(VALID_MODALITIES).join(", ")}; got ${JSON.stringify(e.modality)}`
66
86
  );
67
87
  }
68
88
  }
69
- if (!caps.tags && "tags" in e)
70
- throw new Error(`${path}.tags: not supported for type "${type}"`);
71
- if (!caps.violations && "violations" in e)
72
- throw new Error(`${path}.violations: not supported for type "${type}"`);
73
89
  if ("tags" in e) validateTags(e.tags, `${path}.tags`);
74
90
  if ("violations" in e) {
75
91
  if (!Array.isArray(e.violations)) {
@@ -83,7 +99,21 @@ function validateEntry(entry, path) {
83
99
  }
84
100
  }
85
101
  }
86
- if (type === "large-ct") {
102
+ const hasDims = "rows" in e || "columns" in e || "frames" in e;
103
+ if ("targetSizeKb" in e) {
104
+ if (hasDims) {
105
+ throw new Error(
106
+ `${path}: targetSizeKb cannot be combined with rows/columns/frames \u2014 use one sizing mechanism`
107
+ );
108
+ }
109
+ validatePositiveInt(e.targetSizeKb, `${path}.targetSizeKb`);
110
+ if (e.targetSizeKb * 1024 > MAX_PIXEL_BYTES) {
111
+ throw new Error(`${path}.targetSizeKb: exceeds the 512 MB limit`);
112
+ }
113
+ } else if (hasDims) {
114
+ if (!("rows" in e) || !("columns" in e)) {
115
+ throw new Error(`${path}: rows and columns must be provided together`);
116
+ }
87
117
  validatePositiveInt(e.rows, `${path}.rows`);
88
118
  validatePositiveInt(e.columns, `${path}.columns`);
89
119
  if ("frames" in e) validatePositiveInt(e.frames, `${path}.frames`);
@@ -116,9 +146,9 @@ function validateSeries(series, path) {
116
146
  }
117
147
  for (let i = 0; i < s.entries.length; i++) {
118
148
  const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
119
- if (!TYPE_CAPABILITIES[e.type].tags) {
149
+ if (!TYPE_CAPABILITIES[e.type].image) {
120
150
  throw new Error(
121
- `${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only types that support tags can be grouped`
151
+ `${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
122
152
  );
123
153
  }
124
154
  }
@@ -44,7 +44,35 @@ var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
44
44
  var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
45
45
 
46
46
  // src/syntheticFixtures/generator.ts
47
- var CT_SOP_CLASS = "1.2.840.10008.5.1.4.1.1.2";
47
+ var MODALITY_PRESETS = {
48
+ CT: {
49
+ sopClassUid: "1.2.840.10008.5.1.4.1.1.2",
50
+ // CT Image Storage
51
+ attributes: {}
52
+ },
53
+ PT: {
54
+ sopClassUid: "1.2.840.10008.5.1.4.1.1.128",
55
+ // PET Image Storage
56
+ attributes: {
57
+ Units: "BQML",
58
+ DecayCorrection: "NONE",
59
+ CorrectedImage: ["ATTN", "DECY"]
60
+ }
61
+ },
62
+ MR: {
63
+ sopClassUid: "1.2.840.10008.5.1.4.1.1.4",
64
+ // MR Image Storage
65
+ attributes: {
66
+ ScanningSequence: "SE",
67
+ SequenceVariant: "NONE"
68
+ }
69
+ },
70
+ CR: {
71
+ sopClassUid: "1.2.840.10008.5.1.4.1.1.1",
72
+ // CR Image Storage
73
+ attributes: {}
74
+ }
75
+ };
48
76
  var TRANSFER_SYNTAX_UID = {
49
77
  "explicit-vr-little-endian": "1.2.840.10008.1.2.1",
50
78
  "implicit-vr-little-endian": "1.2.840.10008.1.2"
@@ -77,22 +105,23 @@ function applyTagOverrides(dataset, tags) {
77
105
  }
78
106
  }
79
107
  }
80
- function buildMeta(uid, transferSyntax = "explicit-vr-little-endian") {
108
+ function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
81
109
  return {
82
110
  FileMetaInformationVersion: new Uint8Array([0, 1]).buffer,
83
- MediaStorageSOPClassUID: CT_SOP_CLASS,
111
+ MediaStorageSOPClassUID: MODALITY_PRESETS[modality].sopClassUid,
84
112
  MediaStorageSOPInstanceUID: uid.sop,
85
113
  TransferSyntaxUID: TRANSFER_SYNTAX_UID[transferSyntax],
86
114
  ImplementationClassUID: "1.2.3.4",
87
115
  ImplementationVersionName: "SYNTH"
88
116
  };
89
117
  }
90
- function buildBaseCtDataset(uid) {
118
+ function buildBaseImageDataset(uid, modality) {
119
+ const preset = MODALITY_PRESETS[modality];
91
120
  return {
92
121
  PatientName: "SYNTH^SUBJECT",
93
122
  PatientID: "SYNTH_PID",
94
- Modality: "CT",
95
- SOPClassUID: CT_SOP_CLASS,
123
+ Modality: modality,
124
+ SOPClassUID: preset.sopClassUid,
96
125
  SOPInstanceUID: uid.sop,
97
126
  SeriesInstanceUID: uid.series,
98
127
  StudyInstanceUID: uid.study,
@@ -109,7 +138,8 @@ function buildBaseCtDataset(uid) {
109
138
  PixelData: new Uint8Array([0, 0]).buffer,
110
139
  // Declares PixelData's VR explicitly — without it dcmjs logs
111
140
  // "No value representation given for PixelData" and falls back to OW anyway.
112
- _vrMap: { PixelData: "OW" }
141
+ _vrMap: { PixelData: "OW" },
142
+ ...preset.attributes
113
143
  };
114
144
  }
115
145
  function serializeDict(meta, dataset) {
@@ -123,36 +153,46 @@ function serializeDict(meta, dataset) {
123
153
  );
124
154
  return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
125
155
  }
126
- function buildValidCtBuffer(uid, tags, transferSyntax) {
127
- const dataset = buildBaseCtDataset(uid);
128
- applyTagOverrides(dataset, tags);
129
- return serializeDict(buildMeta(uid, transferSyntax), dataset);
156
+ var MAX_DIMENSION = 65535;
157
+ function applySizing(dataset, meta, spec) {
158
+ if (spec.targetSizeKb !== void 0) {
159
+ const clampDim = (n) => Math.min(MAX_DIMENSION, Math.max(1, Math.round(n)));
160
+ const minimalPixelBytes = dataset.PixelData.byteLength;
161
+ const overhead = serializeDict(meta, dataset).length - minimalPixelBytes;
162
+ const cells = Math.max(
163
+ 1,
164
+ Math.round((spec.targetSizeKb * 1024 - overhead) / 2)
165
+ );
166
+ const rows = clampDim(Math.sqrt(cells));
167
+ const columns = clampDim(cells / rows);
168
+ dataset.Rows = rows;
169
+ dataset.Columns = columns;
170
+ dataset.PixelData = new ArrayBuffer(rows * columns * 2);
171
+ } else if (spec.rows !== void 0 && spec.columns !== void 0) {
172
+ dataset.Rows = spec.rows;
173
+ dataset.Columns = spec.columns;
174
+ if (spec.frames !== void 0) dataset.NumberOfFrames = spec.frames;
175
+ dataset.PixelData = new ArrayBuffer(
176
+ spec.rows * spec.columns * (spec.frames ?? 1) * 2
177
+ );
178
+ }
130
179
  }
131
- function buildInvalidUidCtBuffer(uid, tags, transferSyntax) {
132
- const invalidUid = {
180
+ function buildImageBuffer(spec, uid) {
181
+ const modality = spec.modality ?? "CT";
182
+ const effectiveUid = spec.type === "invalid-uid-image" ? {
133
183
  study: `2.25.invalid.study.${uid.study.slice(-6)}`,
134
184
  series: `2.25.invalid.series.${uid.series.slice(-6)}`,
135
185
  sop: `2.25.invalid.sop.${uid.sop.slice(-6)}`
136
- };
137
- const dataset = buildBaseCtDataset(invalidUid);
138
- applyTagOverrides(dataset, tags);
139
- return serializeDict(buildMeta(invalidUid, transferSyntax), dataset);
140
- }
141
- function buildVendorCtBuffer(uid, tags, transferSyntax) {
142
- const dataset = buildBaseCtDataset(uid);
143
- dataset.Laterality = "";
144
- dataset.PatientWeight = "0";
145
- applyTagOverrides(dataset, tags);
146
- return serializeDict(buildMeta(uid, transferSyntax), dataset);
147
- }
148
- function buildLargeCtBuffer(uid, rows, columns, frames, tags, transferSyntax) {
149
- const dataset = buildBaseCtDataset(uid);
150
- dataset.Rows = rows;
151
- dataset.Columns = columns;
152
- dataset.NumberOfFrames = frames;
153
- dataset.PixelData = new Uint8Array(rows * columns * frames * 2).buffer;
154
- applyTagOverrides(dataset, tags);
155
- return serializeDict(buildMeta(uid, transferSyntax), dataset);
186
+ } : uid;
187
+ const dataset = buildBaseImageDataset(effectiveUid, modality);
188
+ if (spec.type === "vendor-warnings-image") {
189
+ dataset.Laterality = "";
190
+ dataset.PatientWeight = "0";
191
+ }
192
+ applyTagOverrides(dataset, spec.tags);
193
+ const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
194
+ applySizing(dataset, meta, spec);
195
+ return serializeDict(meta, dataset);
156
196
  }
157
197
  function buildFakeSignatureBuffer() {
158
198
  const buf = Buffer.alloc(200, 0);
@@ -164,21 +204,10 @@ function buildNonDicomBuffer(content = "not dicom") {
164
204
  }
165
205
  function buildBufferForSpec(spec, uid) {
166
206
  switch (spec.type) {
167
- case "valid-ct":
168
- return buildValidCtBuffer(uid, spec.tags, spec.transferSyntax);
169
- case "invalid-uid-ct":
170
- return buildInvalidUidCtBuffer(uid, spec.tags, spec.transferSyntax);
171
- case "vendor-warnings-ct":
172
- return buildVendorCtBuffer(uid, spec.tags, spec.transferSyntax);
173
- case "large-ct":
174
- return buildLargeCtBuffer(
175
- uid,
176
- spec.rows,
177
- spec.columns,
178
- spec.frames ?? 1,
179
- spec.tags,
180
- spec.transferSyntax
181
- );
207
+ case "valid-image":
208
+ case "invalid-uid-image":
209
+ case "vendor-warnings-image":
210
+ return buildImageBuffer(spec, uid);
182
211
  case "fake-signature":
183
212
  return buildFakeSignatureBuffer();
184
213
  case "non-dicom":
@@ -1,5 +1,5 @@
1
1
  export { type CollectionManifest, type GeneratedFile, generateCollectionFromSpec, generateFile, writeCollectionFromSpec, } from './collection/writer.js';
2
2
  export { defaultPublicCasesPath, loadCaseById, loadCasesFromJson, loadDefaultCases, type PublicCaseRecord, type PublicCaseSource, } from './public-fixtures/catalog.js';
3
3
  export { caseCachePath, fetchPublicCaseToCache, verifySha256, } from './public-fixtures/fetch.js';
4
- export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidCtSpec, LargeCtSpec, NonDicomSpec, SeriesEntrySpec, SeriesSpec, StudySpec, TransferSyntax, ValidCtSpec, VendorCtSpec, ViolationClass, } from './schema/types.js';
4
+ export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidImageSpec, Modality, NonDicomSpec, SeriesEntrySpec, SeriesSpec, StudySpec, TransferSyntax, ValidImageSpec, VendorWarningsImageSpec, ViolationClass, } from './schema/types.js';
5
5
  export { validateDatasetSpec } from './schema/validate.js';
@@ -7,25 +7,25 @@ export type DicomTagOverrides = {
7
7
  [tag: string]: unknown;
8
8
  };
9
9
  export type ViolationClass = 'uid-too-long' | 'non-conformant-uid' | 'missing-meta-header' | 'malformed-sq-delimiter' | 'vr-max-length-exceeded' | 'missing-type1-tag';
10
- type BaseCtSpec = {
10
+ export type Modality = 'CT' | 'PT' | 'MR' | 'CR';
11
+ type BaseImageSpec = {
12
+ modality?: Modality;
13
+ rows?: number;
14
+ columns?: number;
15
+ frames?: number;
16
+ targetSizeKb?: number;
11
17
  tags?: DicomTagOverrides;
12
18
  transferSyntax?: TransferSyntax;
13
19
  violations?: ViolationClass[];
14
20
  };
15
- export type ValidCtSpec = BaseCtSpec & {
16
- type: 'valid-ct';
17
- };
18
- export type InvalidUidCtSpec = BaseCtSpec & {
19
- type: 'invalid-uid-ct';
21
+ export type ValidImageSpec = BaseImageSpec & {
22
+ type: 'valid-image';
20
23
  };
21
- export type VendorCtSpec = BaseCtSpec & {
22
- type: 'vendor-warnings-ct';
24
+ export type InvalidUidImageSpec = BaseImageSpec & {
25
+ type: 'invalid-uid-image';
23
26
  };
24
- export type LargeCtSpec = BaseCtSpec & {
25
- type: 'large-ct';
26
- rows: number;
27
- columns: number;
28
- frames?: number;
27
+ export type VendorWarningsImageSpec = BaseImageSpec & {
28
+ type: 'vendor-warnings-image';
29
29
  };
30
30
  export type FakeSignatureSpec = {
31
31
  type: 'fake-signature';
@@ -37,7 +37,7 @@ export type NonDicomSpec = {
37
37
  export type DicomdirSpec = {
38
38
  type: 'dicomdir';
39
39
  };
40
- export type ImageSpec = ValidCtSpec | InvalidUidCtSpec | VendorCtSpec | LargeCtSpec;
40
+ export type ImageSpec = ValidImageSpec | InvalidUidImageSpec | VendorWarningsImageSpec;
41
41
  export type FileSpec = ImageSpec | FakeSignatureSpec | NonDicomSpec | DicomdirSpec;
42
42
  export type EntrySpec = FileSpec & {
43
43
  count?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dicom-synth",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Toolkit for synthetic DICOM fixtures and public fixture fetch/cache.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -90,6 +90,6 @@
90
90
  "semantic-release": "^25.0.3",
91
91
  "tsx": "^4.19.3",
92
92
  "typescript": "^5.8.3",
93
- "vitest": "^3.2.4"
93
+ "vitest": "^3.2.6"
94
94
  }
95
95
  }