dicom-synth 1.6.0 → 1.8.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
@@ -13,607 +13,50 @@ pnpm add dicom-synth
13
13
  pnpm add dcmjs # peer dependency (^0.51.1)
14
14
  ```
15
15
 
16
- ---
16
+ ## Quick start
17
17
 
18
- ## Synthetic fixtures
19
-
20
- Generation is a three-layer API. Each layer is a thin wrapper over the one below.
21
-
22
- ### Layer 1 — single file, no I/O
23
-
24
- ```ts
25
- import { generateFile } from 'dicom-synth'
26
-
27
- const { buffer, filename, type, index } = await generateFile({ type: 'valid-image' })
28
- // buffer: Buffer ready to write or pass to a parser
29
- // filename: 'valid-image-000.dcm'
30
- ```
31
-
32
- With options:
33
-
34
- ```ts
35
- const { buffer } = await generateFile(
36
- { type: 'valid-image', tags: { PatientID: 'P001' }, violations: ['uid-too-long'] },
37
- { seed: 42, index: 7 },
38
- )
39
- ```
40
-
41
- Write a single file to disk:
42
-
43
- ```ts
44
- import { writeFileSync } from 'node:fs'
45
- import { generateFile } from 'dicom-synth'
46
-
47
- const { buffer, filename } = await generateFile({ type: 'valid-image' })
48
- writeFileSync(`./out/${filename}`, buffer)
49
- ```
50
-
51
- ### Layer 2 — collection stream, no I/O
18
+ In-process generate a deterministic collection and use it without touching disk:
52
19
 
53
20
  ```ts
54
21
  import { generateCollectionFromSpec } from 'dicom-synth'
55
22
 
56
23
  for await (const file of generateCollectionFromSpec({
57
- entries: [
58
- { type: 'valid-image', count: 10 },
59
- { type: 'invalid-uid-image', count: 3 },
60
- ],
24
+ entries: [{ type: 'valid-image', count: 5 }],
61
25
  seed: 42,
62
26
  })) {
63
- console.log(file.filename, file.buffer.length)
64
- }
65
- ```
66
-
67
- Files are yielded one at a time — safe to pipe without buffering the entire collection.
68
-
69
- ### Layer 3 — disk writer
70
-
71
- ```ts
72
- import { writeCollectionFromSpec } from 'dicom-synth'
73
-
74
- const manifest = await writeCollectionFromSpec(
75
- { entries: [{ type: 'valid-image', count: 5 }], seed: 42 },
76
- './fixtures/generated',
77
- )
78
- // manifest: Array<{ path: string; type: string; index: number }>
79
- ```
80
-
81
- ---
82
-
83
- ## Usage patterns
84
-
85
- ### Ephemeral fixtures in tests (CI)
86
-
87
- Generate into a temp directory per test run — nothing committed, works in any CI environment:
88
-
89
- ```ts
90
- import { mkdtemp, rm } from 'node:fs/promises'
91
- import { tmpdir } from 'node:os'
92
- import { join } from 'node:path'
93
- import { writeCollectionFromSpec } from 'dicom-synth'
94
-
95
- const dir = await mkdtemp(join(tmpdir(), 'synth-'))
96
- try {
97
- await writeCollectionFromSpec(
98
- { entries: [{ type: 'valid-image', count: 3 }], seed: 42 },
99
- dir,
100
- )
101
- // run pipeline against dir
102
- } finally {
103
- await rm(dir, { recursive: true, force: true })
104
- }
105
- ```
106
-
107
- Use a fixed `seed` in CI so UID values are identical across runs and any diff is meaningful.
108
-
109
- ### Static generation (commit schema, not binaries)
110
-
111
- Commit a `dataset.json` schema to your repo. Add a script to regenerate fixtures on demand:
112
-
113
- ```json
114
- // package.json
115
- {
116
- "scripts": {
117
- "fixtures:generate": "dicom-synth-generate --schema dataset.json --out fixtures/dicom"
118
- }
27
+ console.log(file.filename, file.buffer.length) // Buffer ready to parse or pipe
119
28
  }
120
29
  ```
121
30
 
122
- ```bash
123
- pnpm fixtures:generate # regenerate
124
- ```
125
-
126
- Commit `dataset.json` and `.gitignore` the output directory. Fixtures are regenerated locally or in a dedicated CI job — never stored as binaries in version control.
127
-
128
- ### Calling the CLI from a consumer project
129
-
130
- After installing `dicom-synth`, the `dicom-synth-generate` binary is available via your package manager:
31
+ Or from the command line — write fixtures to a directory:
131
32
 
132
33
  ```bash
133
- # pnpm
134
- pnpm exec dicom-synth-generate --schema dataset.json --out ./out
135
-
136
- # npm / npx
137
- npx dicom-synth-generate --schema dataset.json --out ./out
138
-
139
- # one-off without installing
140
- pnpm --package=dicom-synth dlx dicom-synth-generate --schema dataset.json --out ./out
141
- ```
142
-
143
- ### In-process pipeline (no disk I/O)
144
-
145
- Use layer 2 when passing generated files directly to a parser or pipeline without writing to disk:
146
-
147
- ```ts
148
- import { generateCollectionFromSpec, type GeneratedFile } from 'dicom-synth'
149
-
150
- const files: GeneratedFile[] = []
151
- for await (const file of generateCollectionFromSpec({ entries: [{ type: 'valid-image', count: 5 }] })) {
152
- files.push(file)
153
- }
154
- ```
155
-
156
- ---
157
-
158
- ## Schema reference
159
-
160
- ### `DatasetSpec`
161
-
162
- ```ts
163
- import type { DatasetSpec } from 'dicom-synth'
164
-
165
- type DatasetSpec = {
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
170
- }
171
- ```
172
-
173
- At least one of `entries` or `studies` must be present and non-empty; both may be used together.
174
-
175
- `seed` makes UID generation fully deterministic across runs. Omit it for random UIDs.
176
-
177
- ### `EntrySpec`
178
-
179
- An `EntrySpec` is a `FileSpec` plus an optional `count` field (how many times to generate it):
180
-
181
- ```json
182
- { "type": "valid-image", "count": 10 }
183
- ```
184
-
185
- `count` defaults to 1 and must be a positive integer. It is a collection-layer concern — `generateFile` does not accept `count`.
186
-
187
- ### `FileSpec` type catalogue
188
-
189
- | Type | Description | Conformance |
190
- |---|---|---|
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 |
194
- | `fake-signature` | 200-byte buffer with `XXXX` at preamble offset — no DICM magic | edge |
195
- | `non-dicom` | Arbitrary text buffer; no extension in filename | edge |
196
- | `dicomdir` | Minimal DICOMDIR file | strict |
197
-
198
- **`non-dicom` fields:**
199
-
200
- ```json
201
- { "type": "non-dicom", "content": "not a dicom file" }
202
- ```
203
-
204
- `content` defaults to `"not dicom"`.
205
-
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):
211
-
212
- ```json
213
- { "type": "valid-image", "rows": 512, "columns": 512, "frames": 100 }
214
- ```
215
-
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.
289
-
290
- ### Tag overrides (`tags`)
291
-
292
- Available on all image types.
293
-
294
- ```json
295
- {
296
- "type": "valid-image",
297
- "tags": {
298
- "PatientID": "P001",
299
- "PatientName": "DOE^JANE",
300
- "00081030": "Research Protocol A"
301
- }
302
- }
303
- ```
304
-
305
- Keys are either DICOM keyword names (PascalCase, e.g. `"Modality"`) or 8-hex-character tag strings (e.g. `"00080060"`). Unknown hex tags produce a console warning and are skipped.
306
-
307
- ```ts
308
- import type { DicomTagOverrides } from 'dicom-synth'
309
- ```
310
-
311
- ### Transfer syntax (`transferSyntax`)
312
-
313
- Available on all image types.
314
-
315
- | Value | `TransferSyntaxUID` in meta header |
316
- |---|---|
317
- | `explicit-vr-little-endian` (default) | `1.2.840.10008.1.2.1` |
318
- | `implicit-vr-little-endian` | `1.2.840.10008.1.2` |
319
-
320
-
321
- ### Violation injection (`violations`)
322
-
323
- Available on all image types. Violations are applied as post-processing transforms to an otherwise valid buffer.
324
-
325
- | Violation | Effect | Detectable by dciodvfy |
326
- |---|---|---|
327
- | `uid-too-long` | Sets `SOPInstanceUID` to a 65-character value (limit is 64) | yes |
328
- | `non-conformant-uid` | Sets `SOPInstanceUID` to a UID with a leading-zero component | yes |
329
- | `vr-max-length-exceeded` | Sets `StudyDescription` to a 65-character value (LO limit is 64) | yes |
330
- | `missing-type1-tag` | Removes `SOPClassUID` (Type 1 mandatory attribute) | yes |
331
- | `missing-meta-header` | Strips the 128-byte preamble and DICM prefix | yes |
332
- | `malformed-sq-delimiter` | Appends a sequence delimiter tag with a non-zero length field | yes |
333
-
334
- Tag-level violations are applied before byte-level violations. Byte-level violations may produce a buffer that cannot be re-parsed.
335
-
336
- ```json
337
- { "type": "valid-image", "violations": ["uid-too-long", "missing-meta-header"] }
338
- ```
339
-
340
- ```ts
341
- import type { ViolationClass } from 'dicom-synth'
342
- ```
343
-
344
- ### Schema validation
345
-
346
- ```ts
347
- import { validateDatasetSpec } from 'dicom-synth'
348
-
349
- const spec = validateDatasetSpec(JSON.parse(rawJson)) // throws on invalid input
350
- ```
351
-
352
- The validator throws with a human-readable message on any structural error.
353
-
354
- ### Full schema example
355
-
356
- ```json
357
- {
358
- "entries": [
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"] },
365
- { "type": "fake-signature", "count": 5 },
366
- { "type": "non-dicom", "count": 2, "content": "garbage" },
367
- { "type": "dicomdir", "count": 1 }
368
- ],
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"
379
- }
380
- ```
381
-
382
- ---
383
-
384
- ## Parametric designer
385
-
386
- A `ParametricSpec` describes a dataset by ranges instead of explicit entries. `resolveParametricSpec` resolves it — deterministically for a given seed — into a concrete `DatasetSpec` that can be inspected, saved, and re-run.
387
-
388
- ```ts
389
- import { resolveParametricSpec, writeCollectionFromSpec } from 'dicom-synth'
390
-
391
- const resolved = resolveParametricSpec({
392
- seed: 42,
393
- studies: {
394
- count: { min: 2, max: 4 }, // ranges are inclusive
395
- seriesPerStudy: { min: 1, max: 3 },
396
- filesPerSeries: { min: 5, max: 20 },
397
- fileSizeKb: { min: 100, max: 600 }, // sampled per file
398
- modalities: ['CT', 'PT'], // one drawn per series
399
- },
400
- edgeCases: [{ type: 'invalid-uid-image', frequency: 0.05 }],
401
- layout: 'hierarchical',
402
- })
403
- await writeCollectionFromSpec(resolved, './out')
404
- ```
405
-
406
- - Every field of `studies` accepts a fixed number or an inclusive `{ min, max }` range (`Range`).
407
- - `fileSizeKb` maps to `targetSizeKb`; a fixed value collapses each series into one counted entry, a range samples per file.
408
- - `edgeCases` are `EntrySpec`s appended to the flat `entries` of the resolved spec. With `frequency` (0–1), the count is drawn per file across the whole dataset — the resolved spec records the actual draw. Without it, the entry is appended as-is (use `count` for absolute numbers). At small dataset sizes a low frequency may draw zero occurrences.
409
- - The seed (drawn randomly when omitted) is embedded in the resolved spec, so resolution is always reproducible.
410
-
411
- ```ts
412
- import type { ParametricSpec, ParametricStudies, ParametricEdgeCase, Range } from 'dicom-synth'
413
- import { validateParametricSpec } from 'dicom-synth'
414
- ```
415
-
416
- See `examples/parametric.json` for a JSON example and `examples/convert-data-shape.mjs` for converting an exported tree-shape JSON into a `DatasetSpec` (skips non-DICOM files).
417
-
418
- ---
419
-
420
- ## Describe tool
421
-
422
- `describeDirectory` scans an existing DICOM tree and emits a concrete `DatasetSpec` reproducing its shape — studies/series grouped by their UIDs, per-file size as `targetSizeKb`, and modality. This closes the loop: real tree → spec → regenerate.
423
-
424
- ```ts
425
- import { describeDirectory, writeCollectionFromSpec } from 'dicom-synth'
426
-
427
- const { spec, stats } = describeDirectory('./real-dataset')
428
- // stats: { dicomFiles, skipped, unknownModality }
429
- await writeCollectionFromSpec(spec, './reproduced')
430
- ```
431
-
432
- - **Shape only by default** — no tags are copied; the regenerated dataset carries fresh UIDs and synthetic identifiers, so it cannot leak PHI from the source.
433
- - **Opt-in tag preservation** — `keepTags` copies the named tags verbatim (some datasets are valuable for testing *precisely because* of their problematic tags):
434
-
435
- ```ts
436
- describeDirectory('./real-dataset', { keepTags: ['Manufacturer', '00080060'] })
437
- ```
438
-
439
- ⚠️ Tags are copied exactly as found. If a kept tag contains PHI, handling that PHI is the **caller's responsibility** — the tool extracts only what you name.
440
- - Files in a series collapse into one counted entry only when modality, size, and kept tags all match, so tag/size variation is preserved rather than flattened.
441
- - Non-DICOM, unparseable, or ungroupable files (missing study/series UIDs) are skipped and counted. An unsupported `Modality` is emitted as the default (CT) and counted.
442
-
443
- ```ts
444
- import type { DescribeOptions, DescribeResult } from 'dicom-synth'
445
- ```
446
-
447
- ---
448
-
449
- ## CLI
450
-
451
- ```bash
452
- # From a schema file
453
- dicom-synth-generate --schema dataset.json --out ./fixtures/generated
454
-
455
- # Inline schema
456
34
  dicom-synth-generate --schema-inline '{"entries":[{"type":"valid-image","count":5}]}' --out ./out
457
-
458
- # Default output directory is ./fixtures/generated
459
- dicom-synth-generate --schema dataset.json
460
-
461
- # Resolve and generate from a parametric spec; save the resolved DatasetSpec
462
- dicom-synth-generate --parametric examples/parametric.json --out ./out --emit-spec resolved.json
463
-
464
- # Preview the manifest (paths, types, sizes) without writing anything
465
- dicom-synth-generate --parametric examples/parametric.json --dry-run
466
- ```
467
-
468
- | Flag | Effect |
469
- |---|---|
470
- | `--schema <path>` / `--schema-inline <json>` / `--parametric <path>` | Input spec (mutually exclusive) |
471
- | `--out <dir>` | Output directory (default `./fixtures/generated`) |
472
- | `--emit-spec <path>` | Write the resolved `DatasetSpec` JSON (requires `--parametric`) |
473
- | `--dry-run` | Generate in memory and print the manifest; nothing is written |
474
-
475
- The default schema (`examples/default.json`) generates one each of `valid-image`, `invalid-uid-image`, and `vendor-warnings-image`:
476
-
477
- ```bash
478
- dicom-synth-generate --schema examples/default.json --out /tmp/out
479
- # Wrote 3 file(s) to /tmp/out
480
- # valid-image: 1
481
- # invalid-uid-image: 1
482
- # vendor-warnings-image: 1
483
35
  ```
484
36
 
485
- Schema validation errors exit with code 1 and a descriptive message.
486
-
487
- ### Describe an existing tree
488
-
489
- ```bash
490
- # Shape only (PHI-safe) — prints the DatasetSpec to stdout
491
- dicom-synth-describe ./real-dataset
37
+ The full input shape (image types, sizing, modalities, grouping, violations, …) is the [schema reference](./docs/schema-reference.md).
492
38
 
493
- # Save to a file, and keep selected tags (caller owns any PHI in them)
494
- dicom-synth-describe ./real-dataset --out spec.json --keep-tags Manufacturer,00080060
495
- ```
39
+ ## Documentation
496
40
 
497
- | Flag | Effect |
41
+ | Guide | Covers |
498
42
  |---|---|
499
- | `<dir>` | DICOM tree to scan (required) |
500
- | `--out <path>` | Write the `DatasetSpec` JSON (default: stdout) |
501
- | `--keep-tags <a,b,...>` | Copy the named tags (keywords or 8-hex) verbatim; omit for shape only |
502
-
503
- Scan stats (DICOM/skipped/unsupported-modality counts) are written to stderr.
504
-
505
- ---
506
-
507
- ## Public fixtures
508
-
509
- Catalog metadata lives in `data/public-cases.json`. Fetched binaries are cached at `~/.cache/dicom-synth-testcases/<sha256>/file.dcm`.
510
-
511
- ### Load and fetch
512
-
513
- ```ts
514
- import {
515
- loadDefaultCases,
516
- loadCaseById,
517
- loadCasesFromJson,
518
- defaultPublicCasesPath,
519
- fetchPublicCaseToCache,
520
- caseCachePath,
521
- } from 'dicom-synth'
522
-
523
- // Load all bundled cases
524
- const cases = loadDefaultCases()
525
-
526
- // Load one bundled case by id
527
- const record = loadCaseById(defaultPublicCasesPath(), 'pydicom-CT-small')
528
-
529
- // Load a custom catalogue
530
- const custom = loadCasesFromJson('./my-cases.json')
531
-
532
- // Fetch to local cache, returns the file path
533
- const path = await fetchPublicCaseToCache(record)
534
-
535
- // Resolve the cache path for a known SHA-256 without fetching
536
- const cached = caseCachePath(record.sha256)
537
- ```
538
-
539
- ### CI caching
540
-
541
- The fetch cache is keyed by SHA-256 so cached files are safe to restore across CI runs. To avoid re-downloading on every run, cache the default cache root:
542
-
543
- ```yaml
544
- # GitHub Actions example
545
- - uses: actions/cache@v4
546
- with:
547
- path: ~/.cache/dicom-synth-testcases
548
- key: dicom-public-fixtures-${{ hashFiles('node_modules/dicom-synth/dist/data/public-cases.json') }}
549
- ```
550
-
551
- ### Published CLI
552
-
553
- ```bash
554
- pnpm --package=dicom-synth@latest dlx dicom-synth-fetch pydicom-CT-small
555
- ```
556
-
557
- ### Local development
558
-
559
- ```bash
560
- pnpm fetch:public-case -- pydicom-CT-small
561
- ```
562
-
563
- ---
564
-
565
- ## Source layout
566
-
567
- | Path | Responsibility |
568
- |---|---|
569
- | `src/schema/types.ts` | All public TypeScript types (`FileSpec`, `DatasetSpec`, `ViolationClass`, etc.) |
570
- | `src/schema/validate.ts` | `validateDatasetSpec` / `validateParametricSpec` — validate raw JSON values against the schema |
571
- | `src/schema/parametric.ts` | `resolveParametricSpec` — seeded `ParametricSpec` → `DatasetSpec` resolution |
572
- | `src/describe/describe.ts` | `describeDirectory` — scan a DICOM tree → `DatasetSpec` |
573
- | `src/syntheticFixtures/generator.ts` | Internal buffer builders keyed on `FileSpec` type |
574
- | `src/syntheticFixtures/uid.ts` | Seeded and random UID generation |
575
- | `src/syntheticFixtures/violations.ts` | Post-processing violation injection |
576
- | `src/collection/writer.ts` | Three-layer API — `generateFile`, `generateCollectionFromSpec`, `writeCollectionFromSpec` |
577
- | `src/public-fixtures/catalog.ts` | Load bundled public case catalogue |
578
- | `src/public-fixtures/fetch.ts` | Fetch, SHA-256 verify, content-addressed cache |
579
- | `bin/dicom-synth-generate.mjs` | Published generate CLI (requires `pnpm build`) |
580
- | `bin/dicom-synth-fetch.mjs` | Published fetch CLI (requires `pnpm build`) |
581
- | `bin/dicom-synth-describe.mjs` | Published describe CLI (requires `pnpm build`) |
582
- | `examples/default.json` | Default `DatasetSpec` — one each of `valid-image`, `invalid-uid-image`, `vendor-warnings-image` |
583
- | `examples/parametric.json` | Example `ParametricSpec` for the `--parametric` CLI path |
584
- | `examples/convert-data-shape.mjs` | Example converter: tree-shape JSON → `DatasetSpec` |
585
-
586
- ---
587
-
588
- ## Development
589
-
590
- ```bash
591
- pnpm install
592
- pnpm build
593
- pnpm test
594
- pnpm code:quality
595
- ```
596
-
597
- | Script | Purpose |
598
- |---|---|
599
- | `pnpm write:synthetic` | Write `examples/default.json` to `fixtures/generated` |
600
- | `pnpm fetch:public-case` | Fetch one public case by id |
601
- | `pnpm hooks:pre-commit` | Reproduce commit hook locally |
602
- | `pnpm hooks:pre-push` | Reproduce push hook locally |
603
-
604
- Git hooks: see [CONTRIBUTING.md](CONTRIBUTING.md).
605
-
606
- ---
43
+ | [Synthetic fixtures](./docs/synthetic-fixtures.md) | The three-layer generation API (`generateFile`, `generateCollectionFromSpec`, `writeCollectionFromSpec`) and usage patterns |
44
+ | [Schema reference](./docs/schema-reference.md) | `DatasetSpec` and every field — types, sizing, modality, grouping, layout, path quirks, tags, transfer syntax, violations, validation |
45
+ | [Parametric designer](./docs/parametric.md) | Range-based `ParametricSpec` that resolves deterministically into a `DatasetSpec` |
46
+ | [Describe tool](./docs/describe.md) | Scan a real DICOM tree → `DatasetSpec` (shape-only by default; opt-in tag preservation) |
47
+ | [CLI reference](./docs/cli.md) | `dicom-synth-generate`, `dicom-synth-describe`, `dicom-synth-fetch` |
48
+ | [Public fixtures](./docs/public-fixtures.md) | Catalog-backed fetch/cache with SHA-256 verification |
49
+ | [Development](./docs/development.md) | Scripts, hooks, and source layout |
50
+ | [Examples](./examples/README.md) | Runnable example specs and the shape converter |
607
51
 
608
52
  ## Future development
609
53
 
54
+ - **Very large (streamed) files** — synthesise single DICOMs beyond the 512 MB in-memory cap via a disk-only streaming writer
610
55
  - **Dimension edge-case recipes** — documented presets for geometry pathologies (1×65535 strips, high frame counts)
611
56
  - **Per-file tag variance templating** — patterned overrides like `"PatientID": "P{index}"` for shape replication
612
57
  - **Compressed transfer syntaxes** — JPEG-LS, JPEG 2000, RLE
613
58
  - **Private fixture catalogues** — same SHA-256 fetch/cache pattern for credentials-backed sources (e.g. S3)
614
59
 
615
- ---
616
-
617
60
  ## License
618
61
 
619
62
  Apache-2.0
@@ -5,14 +5,18 @@ import {
5
5
  generateCollectionFromSpec,
6
6
  resolveParametricSpec,
7
7
  validateDatasetSpec,
8
+ withResolvedSeed,
8
9
  writeCollectionFromSpec,
9
10
  } from '../dist/esm/index.js'
10
11
 
11
12
  function usage() {
12
13
  console.error(
13
- 'Usage: dicom-synth-generate --schema <path> [--out <dir>] [--dry-run]\n' +
14
- ' dicom-synth-generate --schema-inline <json> [--out <dir>] [--dry-run]\n' +
15
- ' dicom-synth-generate --parametric <path> [--out <dir>] [--emit-spec <path>] [--dry-run]',
14
+ 'Usage: dicom-synth-generate --schema <path> [--out <dir>] [--emit-spec <path>] [--dry-run]\n' +
15
+ ' dicom-synth-generate --schema-inline <json> [--out <dir>] [--emit-spec <path>] [--dry-run]\n' +
16
+ ' dicom-synth-generate --parametric <path> [--out <dir>] [--emit-spec <path>] [--dry-run]\n' +
17
+ '\n' +
18
+ 'A seed is drawn when the spec omits one; pass --emit-spec to capture the\n' +
19
+ 'effective spec (seed included) so the dataset can be re-created.',
16
20
  )
17
21
  process.exit(1)
18
22
  }
@@ -80,22 +84,27 @@ function printTypeCounts(counts) {
80
84
  }
81
85
  }
82
86
 
83
- if (emitSpecPath !== undefined && specSource !== '--parametric') {
84
- console.error('Error: --emit-spec requires --parametric')
85
- process.exit(1)
86
- }
87
+ const seedProvided =
88
+ rawSpec !== null && typeof rawSpec === 'object' && 'seed' in rawSpec
87
89
 
88
90
  let spec
89
91
  try {
90
92
  spec =
91
93
  specSource === '--parametric'
92
94
  ? resolveParametricSpec(rawSpec)
93
- : validateDatasetSpec(rawSpec)
95
+ : withResolvedSeed(validateDatasetSpec(rawSpec))
94
96
  } catch (err) {
95
97
  console.error(`Schema validation error: ${err.message}`)
96
98
  process.exit(1)
97
99
  }
98
100
 
101
+ // Surface the drawn seed so a run is reproducible even without --emit-spec.
102
+ if (!seedProvided) {
103
+ console.error(
104
+ `Generated with seed ${spec.seed} (pass --emit-spec to capture)`,
105
+ )
106
+ }
107
+
99
108
  if (emitSpecPath !== undefined) {
100
109
  try {
101
110
  writeFileSync(emitSpecPath, `${JSON.stringify(spec, null, 2)}\n`)
@@ -1,4 +1,5 @@
1
1
  // src/collection/writer.ts
2
+ import { randomBytes as randomBytes2 } from "node:crypto";
2
3
  import { mkdirSync as mkdirSync2 } from "node:fs";
3
4
  import { writeFile } from "node:fs/promises";
4
5
  import { dirname, resolve as resolve2 } from "node:path";
@@ -419,6 +420,48 @@ async function generateFile(spec, options) {
419
420
  index
420
421
  };
421
422
  }
423
+ function hashString(s) {
424
+ let h = 2166136261;
425
+ for (let i = 0; i < s.length; i++) {
426
+ h ^= s.charCodeAt(i);
427
+ h = Math.imul(h, 16777619);
428
+ }
429
+ return h >>> 0;
430
+ }
431
+ var UNICODE_TOKENS = ["\xE9", "\xFC", "\xF1", "\u65E5", "\u2122", "\u03A9"];
432
+ var NEST_DEPTH = 8;
433
+ var LONG_NAME_TARGET = 200;
434
+ function applyPathQuirks(relativePath, quirks) {
435
+ const segments = relativePath.split("/");
436
+ let dirs = segments.slice(0, -1);
437
+ const leaf = segments[segments.length - 1];
438
+ const dot = leaf.lastIndexOf(".");
439
+ let stem = dot > 0 ? leaf.slice(0, dot) : leaf;
440
+ const ext = dot > 0 ? leaf.slice(dot) : "";
441
+ const unicode = (s) => `${s}${UNICODE_TOKENS[hashString(s) % UNICODE_TOKENS.length]}`;
442
+ const has = (q) => quirks.includes(q);
443
+ if (has("deep-nesting")) {
444
+ dirs = [...dirs, ...Array.from({ length: NEST_DEPTH }, (_, i) => `d${i}`)];
445
+ }
446
+ if (has("unicode")) {
447
+ dirs = dirs.map(unicode);
448
+ stem = unicode(stem);
449
+ }
450
+ if (has("long-name")) {
451
+ const pad = (s) => s.length < LONG_NAME_TARGET ? s + "x".repeat(LONG_NAME_TARGET - s.length) : s;
452
+ stem = pad(stem);
453
+ if (dirs.length > 0) {
454
+ const last = dirs.length - 1;
455
+ dirs = dirs.map((d, i) => i === last ? pad(d) : d);
456
+ }
457
+ }
458
+ let newLeaf = stem + ext;
459
+ if (has("trailing-dot")) {
460
+ if (dirs.length > 0) dirs = dirs.map((d) => `${d}.`);
461
+ else newLeaf += ".";
462
+ }
463
+ return { relativePath: [...dirs, newLeaf].join("/"), filename: newLeaf };
464
+ }
422
465
  function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
423
466
  const pad3 = (n) => String(n).padStart(3, "0");
424
467
  const name = `${String(instanceNumber).padStart(5, "0")}.dcm`;
@@ -427,21 +470,28 @@ function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
427
470
  relativePath: `study-${pad3(studyOrdinal + 1)}/series-${pad3(seriesIndex + 1)}/${name}`
428
471
  };
429
472
  }
473
+ function withResolvedSeed(spec) {
474
+ return spec.seed === void 0 ? { ...spec, seed: randomBytes2(4).readUInt32BE(0) } : spec;
475
+ }
430
476
  async function* generateCollectionFromSpec(spec) {
431
477
  const flatEntries = spec.entries ?? [];
432
478
  const studies = spec.studies ?? [];
433
479
  const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
434
480
  const padWidth = String(totalFiles).length;
435
481
  const hierarchical = spec.layout === "hierarchical";
482
+ const quirks = spec.pathQuirks ?? [];
483
+ const decorate = (file) => quirks.length === 0 ? file : { ...file, ...applyPathQuirks(file.relativePath, quirks) };
436
484
  let globalIndex = 0;
437
485
  for (const entry of flatEntries) {
438
486
  const { count = 1, ...fileSpec } = entry;
439
487
  for (let i = 0; i < count; i++) {
440
- yield generateFile(fileSpec, {
441
- index: globalIndex,
442
- seed: spec.seed,
443
- padWidth
444
- });
488
+ yield decorate(
489
+ await generateFile(fileSpec, {
490
+ index: globalIndex,
491
+ seed: spec.seed,
492
+ padWidth
493
+ })
494
+ );
445
495
  globalIndex++;
446
496
  }
447
497
  }
@@ -473,14 +523,16 @@ async function* generateCollectionFromSpec(spec) {
473
523
  uid: { study: studyUid, series: seriesUid }
474
524
  }
475
525
  );
476
- yield hierarchical ? {
477
- ...file,
478
- ...hierarchicalName(
479
- studyOrdinal,
480
- seriesIndex,
481
- instanceNumber
482
- )
483
- } : file;
526
+ yield decorate(
527
+ hierarchical ? {
528
+ ...file,
529
+ ...hierarchicalName(
530
+ studyOrdinal,
531
+ seriesIndex,
532
+ instanceNumber
533
+ )
534
+ } : file
535
+ );
484
536
  globalIndex++;
485
537
  }
486
538
  }
@@ -509,5 +561,6 @@ async function writeCollectionFromSpec(spec, outDir) {
509
561
  export {
510
562
  generateCollectionFromSpec,
511
563
  generateFile,
564
+ withResolvedSeed,
512
565
  writeCollectionFromSpec
513
566
  };
@@ -40,6 +40,12 @@ var VALID_LAYOUTS = {
40
40
  flat: true,
41
41
  hierarchical: true
42
42
  };
43
+ var VALID_PATH_QUIRKS = {
44
+ "trailing-dot": true,
45
+ unicode: true,
46
+ "deep-nesting": true,
47
+ "long-name": true
48
+ };
43
49
  var VALID_TRANSFER_SYNTAXES = {
44
50
  "explicit-vr-little-endian": true,
45
51
  "implicit-vr-little-endian": true
@@ -226,11 +232,20 @@ function validateDatasetSpec(raw) {
226
232
  if ("layout" in r) {
227
233
  validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
228
234
  }
235
+ if ("pathQuirks" in r) {
236
+ if (!Array.isArray(r.pathQuirks)) {
237
+ throw new Error("DatasetSpec.pathQuirks: must be an array");
238
+ }
239
+ for (const [i, q] of r.pathQuirks.entries()) {
240
+ validateEnum(q, VALID_PATH_QUIRKS, `DatasetSpec.pathQuirks[${i}]`);
241
+ }
242
+ }
229
243
  return {
230
244
  ...entries.length > 0 ? { entries } : {},
231
245
  ...studies.length > 0 ? { studies } : {},
232
246
  ...r.seed !== void 0 ? { seed: r.seed } : {},
233
- ...r.layout !== void 0 ? { layout: r.layout } : {}
247
+ ...r.layout !== void 0 ? { layout: r.layout } : {},
248
+ ...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
234
249
  };
235
250
  }
236
251
 
package/dist/esm/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // src/collection/writer.ts
2
+ import { randomBytes as randomBytes2 } from "node:crypto";
2
3
  import { mkdirSync as mkdirSync2 } from "node:fs";
3
4
  import { writeFile } from "node:fs/promises";
4
5
  import { dirname, resolve as resolve2 } from "node:path";
@@ -63,6 +64,12 @@ var VALID_LAYOUTS = {
63
64
  flat: true,
64
65
  hierarchical: true
65
66
  };
67
+ var VALID_PATH_QUIRKS = {
68
+ "trailing-dot": true,
69
+ unicode: true,
70
+ "deep-nesting": true,
71
+ "long-name": true
72
+ };
66
73
  var VALID_TRANSFER_SYNTAXES = {
67
74
  "explicit-vr-little-endian": true,
68
75
  "implicit-vr-little-endian": true
@@ -249,11 +256,20 @@ function validateDatasetSpec(raw) {
249
256
  if ("layout" in r) {
250
257
  validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
251
258
  }
259
+ if ("pathQuirks" in r) {
260
+ if (!Array.isArray(r.pathQuirks)) {
261
+ throw new Error("DatasetSpec.pathQuirks: must be an array");
262
+ }
263
+ for (const [i, q] of r.pathQuirks.entries()) {
264
+ validateEnum(q, VALID_PATH_QUIRKS, `DatasetSpec.pathQuirks[${i}]`);
265
+ }
266
+ }
252
267
  return {
253
268
  ...entries.length > 0 ? { entries } : {},
254
269
  ...studies.length > 0 ? { studies } : {},
255
270
  ...r.seed !== void 0 ? { seed: r.seed } : {},
256
- ...r.layout !== void 0 ? { layout: r.layout } : {}
271
+ ...r.layout !== void 0 ? { layout: r.layout } : {},
272
+ ...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
257
273
  };
258
274
  }
259
275
  function validateRange(value, path) {
@@ -720,6 +736,48 @@ async function generateFile(spec, options) {
720
736
  index
721
737
  };
722
738
  }
739
+ function hashString(s) {
740
+ let h = 2166136261;
741
+ for (let i = 0; i < s.length; i++) {
742
+ h ^= s.charCodeAt(i);
743
+ h = Math.imul(h, 16777619);
744
+ }
745
+ return h >>> 0;
746
+ }
747
+ var UNICODE_TOKENS = ["\xE9", "\xFC", "\xF1", "\u65E5", "\u2122", "\u03A9"];
748
+ var NEST_DEPTH = 8;
749
+ var LONG_NAME_TARGET = 200;
750
+ function applyPathQuirks(relativePath, quirks) {
751
+ const segments = relativePath.split("/");
752
+ let dirs = segments.slice(0, -1);
753
+ const leaf = segments[segments.length - 1];
754
+ const dot = leaf.lastIndexOf(".");
755
+ let stem = dot > 0 ? leaf.slice(0, dot) : leaf;
756
+ const ext = dot > 0 ? leaf.slice(dot) : "";
757
+ const unicode = (s) => `${s}${UNICODE_TOKENS[hashString(s) % UNICODE_TOKENS.length]}`;
758
+ const has = (q) => quirks.includes(q);
759
+ if (has("deep-nesting")) {
760
+ dirs = [...dirs, ...Array.from({ length: NEST_DEPTH }, (_, i) => `d${i}`)];
761
+ }
762
+ if (has("unicode")) {
763
+ dirs = dirs.map(unicode);
764
+ stem = unicode(stem);
765
+ }
766
+ if (has("long-name")) {
767
+ const pad = (s) => s.length < LONG_NAME_TARGET ? s + "x".repeat(LONG_NAME_TARGET - s.length) : s;
768
+ stem = pad(stem);
769
+ if (dirs.length > 0) {
770
+ const last = dirs.length - 1;
771
+ dirs = dirs.map((d, i) => i === last ? pad(d) : d);
772
+ }
773
+ }
774
+ let newLeaf = stem + ext;
775
+ if (has("trailing-dot")) {
776
+ if (dirs.length > 0) dirs = dirs.map((d) => `${d}.`);
777
+ else newLeaf += ".";
778
+ }
779
+ return { relativePath: [...dirs, newLeaf].join("/"), filename: newLeaf };
780
+ }
723
781
  function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
724
782
  const pad3 = (n) => String(n).padStart(3, "0");
725
783
  const name = `${String(instanceNumber).padStart(5, "0")}.dcm`;
@@ -728,21 +786,28 @@ function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
728
786
  relativePath: `study-${pad3(studyOrdinal + 1)}/series-${pad3(seriesIndex + 1)}/${name}`
729
787
  };
730
788
  }
789
+ function withResolvedSeed(spec) {
790
+ return spec.seed === void 0 ? { ...spec, seed: randomBytes2(4).readUInt32BE(0) } : spec;
791
+ }
731
792
  async function* generateCollectionFromSpec(spec) {
732
793
  const flatEntries = spec.entries ?? [];
733
794
  const studies = spec.studies ?? [];
734
795
  const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
735
796
  const padWidth = String(totalFiles).length;
736
797
  const hierarchical = spec.layout === "hierarchical";
798
+ const quirks = spec.pathQuirks ?? [];
799
+ const decorate = (file) => quirks.length === 0 ? file : { ...file, ...applyPathQuirks(file.relativePath, quirks) };
737
800
  let globalIndex = 0;
738
801
  for (const entry of flatEntries) {
739
802
  const { count = 1, ...fileSpec } = entry;
740
803
  for (let i = 0; i < count; i++) {
741
- yield generateFile(fileSpec, {
742
- index: globalIndex,
743
- seed: spec.seed,
744
- padWidth
745
- });
804
+ yield decorate(
805
+ await generateFile(fileSpec, {
806
+ index: globalIndex,
807
+ seed: spec.seed,
808
+ padWidth
809
+ })
810
+ );
746
811
  globalIndex++;
747
812
  }
748
813
  }
@@ -774,14 +839,16 @@ async function* generateCollectionFromSpec(spec) {
774
839
  uid: { study: studyUid, series: seriesUid }
775
840
  }
776
841
  );
777
- yield hierarchical ? {
778
- ...file,
779
- ...hierarchicalName(
780
- studyOrdinal,
781
- seriesIndex,
782
- instanceNumber
783
- )
784
- } : file;
842
+ yield decorate(
843
+ hierarchical ? {
844
+ ...file,
845
+ ...hierarchicalName(
846
+ studyOrdinal,
847
+ seriesIndex,
848
+ instanceNumber
849
+ )
850
+ } : file
851
+ );
785
852
  globalIndex++;
786
853
  }
787
854
  }
@@ -1024,7 +1091,7 @@ async function fetchPublicCaseToCache(record, cacheRoot = DEFAULT_CACHE_ROOT) {
1024
1091
  }
1025
1092
 
1026
1093
  // src/schema/parametric.ts
1027
- import { randomBytes as randomBytes2 } from "node:crypto";
1094
+ import { randomBytes as randomBytes3 } from "node:crypto";
1028
1095
  function sample(next, range) {
1029
1096
  if (typeof range === "number") return range;
1030
1097
  return range.min + next() % (range.max - range.min + 1);
@@ -1037,7 +1104,7 @@ function fraction(next) {
1037
1104
  }
1038
1105
  function resolveParametricSpec(spec) {
1039
1106
  const validated = validateParametricSpec(spec);
1040
- const seed = validated.seed ?? randomBytes2(4).readUInt32BE(0);
1107
+ const seed = validated.seed ?? randomBytes3(4).readUInt32BE(0);
1041
1108
  const next = seededStream(seed, PARAMETRIC_STREAM_OFFSET, 0, 0);
1042
1109
  const params = validated.studies;
1043
1110
  const studies = [];
@@ -1110,5 +1177,6 @@ export {
1110
1177
  validateDatasetSpec,
1111
1178
  validateParametricSpec,
1112
1179
  verifySha256,
1180
+ withResolvedSeed,
1113
1181
  writeCollectionFromSpec
1114
1182
  };
@@ -17,6 +17,12 @@ var VALID_LAYOUTS = {
17
17
  flat: true,
18
18
  hierarchical: true
19
19
  };
20
+ var VALID_PATH_QUIRKS = {
21
+ "trailing-dot": true,
22
+ unicode: true,
23
+ "deep-nesting": true,
24
+ "long-name": true
25
+ };
20
26
  var VALID_TRANSFER_SYNTAXES = {
21
27
  "explicit-vr-little-endian": true,
22
28
  "implicit-vr-little-endian": true
@@ -203,11 +209,20 @@ function validateDatasetSpec(raw) {
203
209
  if ("layout" in r) {
204
210
  validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
205
211
  }
212
+ if ("pathQuirks" in r) {
213
+ if (!Array.isArray(r.pathQuirks)) {
214
+ throw new Error("DatasetSpec.pathQuirks: must be an array");
215
+ }
216
+ for (const [i, q] of r.pathQuirks.entries()) {
217
+ validateEnum(q, VALID_PATH_QUIRKS, `DatasetSpec.pathQuirks[${i}]`);
218
+ }
219
+ }
206
220
  return {
207
221
  ...entries.length > 0 ? { entries } : {},
208
222
  ...studies.length > 0 ? { studies } : {},
209
223
  ...r.seed !== void 0 ? { seed: r.seed } : {},
210
- ...r.layout !== void 0 ? { layout: r.layout } : {}
224
+ ...r.layout !== void 0 ? { layout: r.layout } : {},
225
+ ...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
211
226
  };
212
227
  }
213
228
  function validateRange(value, path) {
@@ -18,5 +18,6 @@ export declare function generateFile(spec: FileSpec, options?: {
18
18
  padWidth?: number;
19
19
  uid?: Partial<UidSet>;
20
20
  }): Promise<GeneratedFile>;
21
+ export declare function withResolvedSeed(spec: DatasetSpec): DatasetSpec;
21
22
  export declare function generateCollectionFromSpec(spec: DatasetSpec): AsyncGenerator<GeneratedFile>;
22
23
  export declare function writeCollectionFromSpec(spec: DatasetSpec, outDir: string): Promise<CollectionManifest>;
@@ -1,7 +1,7 @@
1
- export { type CollectionManifest, type GeneratedFile, generateCollectionFromSpec, generateFile, writeCollectionFromSpec, } from './collection/writer.js';
1
+ export { type CollectionManifest, type GeneratedFile, generateCollectionFromSpec, generateFile, withResolvedSeed, writeCollectionFromSpec, } from './collection/writer.js';
2
2
  export { type DescribeOptions, type DescribeResult, describeDirectory, } from './describe/describe.js';
3
3
  export { defaultPublicCasesPath, loadCaseById, loadCasesFromJson, loadDefaultCases, type PublicCaseRecord, type PublicCaseSource, } from './public-fixtures/catalog.js';
4
4
  export { caseCachePath, fetchPublicCaseToCache, verifySha256, } from './public-fixtures/fetch.js';
5
5
  export { resolveParametricSpec } from './schema/parametric.js';
6
- export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidImageSpec, Modality, NonDicomSpec, ParametricEdgeCase, ParametricSpec, ParametricStudies, Range, SeriesEntrySpec, SeriesSpec, StudySpec, TransferSyntax, ValidImageSpec, VendorWarningsImageSpec, ViolationClass, } from './schema/types.js';
6
+ export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidImageSpec, Modality, NonDicomSpec, ParametricEdgeCase, ParametricSpec, ParametricStudies, PathQuirk, Range, SeriesEntrySpec, SeriesSpec, StudySpec, TransferSyntax, ValidImageSpec, VendorWarningsImageSpec, ViolationClass, } from './schema/types.js';
7
7
  export { validateDatasetSpec, validateParametricSpec, } from './schema/validate.js';
@@ -55,11 +55,13 @@ export type StudySpec = {
55
55
  count?: number;
56
56
  };
57
57
  export type DatasetLayout = 'flat' | 'hierarchical';
58
+ export type PathQuirk = 'trailing-dot' | 'unicode' | 'deep-nesting' | 'long-name';
58
59
  export type DatasetSpec = {
59
60
  entries?: EntrySpec[];
60
61
  studies?: StudySpec[];
61
62
  seed?: number;
62
63
  layout?: DatasetLayout;
64
+ pathQuirks?: PathQuirk[];
63
65
  };
64
66
  export type Range = number | {
65
67
  min: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dicom-synth",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Toolkit for synthetic DICOM fixtures and public fixture fetch/cache.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",