dicom-synth 1.5.0 → 1.7.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
@@ -287,6 +287,33 @@ import type { StudySpec, SeriesSpec, SeriesEntrySpec } from 'dicom-synth'
287
287
 
288
288
  `GeneratedFile.relativePath` carries the layout-aware path for in-process consumers; `filename` is always the basename.
289
289
 
290
+ ### Path quirks (`pathQuirks`)
291
+
292
+ Decorate written paths with edge-case names to exercise consumer file handling (e.g. the Chrome File System Access API). Quirks apply to the relative paths the layout already produces — DICOM byte content is unchanged.
293
+
294
+ ```json
295
+ {
296
+ "studies": [{ "series": [{ "entries": [{ "type": "valid-image", "count": 3 }] }] }],
297
+ "layout": "hierarchical",
298
+ "pathQuirks": ["trailing-dot", "unicode"]
299
+ }
300
+ ```
301
+
302
+ | Quirk | Effect |
303
+ |---|---|
304
+ | `trailing-dot` | Appends `.` to each directory component (`study-001./series-001./…`); on flat output, to the filename |
305
+ | `unicode` | Inserts a non-ASCII character into directory and file names |
306
+ | `deep-nesting` | Injects extra nested directory levels before the file |
307
+ | `long-name` | Pads the filename stem and the deepest directory component toward the 255-byte limit |
308
+
309
+ Decorations are stable per path component, so a series' files still share one directory. Quirks compose; the result is deterministic and independent of the order they're listed in.
310
+
311
+ > **Cross-platform:** these names are writable on POSIX (Linux/macOS, where CI runs) but Windows rewrites or rejects several (trailing dots, reserved names, long paths). The intent is to *feed* a downstream consumer (e.g. a browser), not to round-trip on Windows.
312
+
313
+ ```ts
314
+ import type { PathQuirk } from 'dicom-synth'
315
+ ```
316
+
290
317
  ### Tag overrides (`tags`)
291
318
 
292
319
  Available on all image types.
@@ -417,6 +444,35 @@ See `examples/parametric.json` for a JSON example and `examples/convert-data-sha
417
444
 
418
445
  ---
419
446
 
447
+ ## Describe tool
448
+
449
+ `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.
450
+
451
+ ```ts
452
+ import { describeDirectory, writeCollectionFromSpec } from 'dicom-synth'
453
+
454
+ const { spec, stats } = describeDirectory('./real-dataset')
455
+ // stats: { dicomFiles, skipped, unknownModality }
456
+ await writeCollectionFromSpec(spec, './reproduced')
457
+ ```
458
+
459
+ - **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.
460
+ - **Opt-in tag preservation** — `keepTags` copies the named tags verbatim (some datasets are valuable for testing *precisely because* of their problematic tags):
461
+
462
+ ```ts
463
+ describeDirectory('./real-dataset', { keepTags: ['Manufacturer', '00080060'] })
464
+ ```
465
+
466
+ ⚠️ 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.
467
+ - 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.
468
+ - 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.
469
+
470
+ ```ts
471
+ import type { DescribeOptions, DescribeResult } from 'dicom-synth'
472
+ ```
473
+
474
+ ---
475
+
420
476
  ## CLI
421
477
 
422
478
  ```bash
@@ -455,6 +511,24 @@ dicom-synth-generate --schema examples/default.json --out /tmp/out
455
511
 
456
512
  Schema validation errors exit with code 1 and a descriptive message.
457
513
 
514
+ ### Describe an existing tree
515
+
516
+ ```bash
517
+ # Shape only (PHI-safe) — prints the DatasetSpec to stdout
518
+ dicom-synth-describe ./real-dataset
519
+
520
+ # Save to a file, and keep selected tags (caller owns any PHI in them)
521
+ dicom-synth-describe ./real-dataset --out spec.json --keep-tags Manufacturer,00080060
522
+ ```
523
+
524
+ | Flag | Effect |
525
+ |---|---|
526
+ | `<dir>` | DICOM tree to scan (required) |
527
+ | `--out <path>` | Write the `DatasetSpec` JSON (default: stdout) |
528
+ | `--keep-tags <a,b,...>` | Copy the named tags (keywords or 8-hex) verbatim; omit for shape only |
529
+
530
+ Scan stats (DICOM/skipped/unsupported-modality counts) are written to stderr.
531
+
458
532
  ---
459
533
 
460
534
  ## Public fixtures
@@ -522,6 +596,7 @@ pnpm fetch:public-case -- pydicom-CT-small
522
596
  | `src/schema/types.ts` | All public TypeScript types (`FileSpec`, `DatasetSpec`, `ViolationClass`, etc.) |
523
597
  | `src/schema/validate.ts` | `validateDatasetSpec` / `validateParametricSpec` — validate raw JSON values against the schema |
524
598
  | `src/schema/parametric.ts` | `resolveParametricSpec` — seeded `ParametricSpec` → `DatasetSpec` resolution |
599
+ | `src/describe/describe.ts` | `describeDirectory` — scan a DICOM tree → `DatasetSpec` |
525
600
  | `src/syntheticFixtures/generator.ts` | Internal buffer builders keyed on `FileSpec` type |
526
601
  | `src/syntheticFixtures/uid.ts` | Seeded and random UID generation |
527
602
  | `src/syntheticFixtures/violations.ts` | Post-processing violation injection |
@@ -530,6 +605,7 @@ pnpm fetch:public-case -- pydicom-CT-small
530
605
  | `src/public-fixtures/fetch.ts` | Fetch, SHA-256 verify, content-addressed cache |
531
606
  | `bin/dicom-synth-generate.mjs` | Published generate CLI (requires `pnpm build`) |
532
607
  | `bin/dicom-synth-fetch.mjs` | Published fetch CLI (requires `pnpm build`) |
608
+ | `bin/dicom-synth-describe.mjs` | Published describe CLI (requires `pnpm build`) |
533
609
  | `examples/default.json` | Default `DatasetSpec` — one each of `valid-image`, `invalid-uid-image`, `vendor-warnings-image` |
534
610
  | `examples/parametric.json` | Example `ParametricSpec` for the `--parametric` CLI path |
535
611
  | `examples/convert-data-shape.mjs` | Example converter: tree-shape JSON → `DatasetSpec` |
@@ -558,8 +634,8 @@ Git hooks: see [CONTRIBUTING.md](CONTRIBUTING.md).
558
634
 
559
635
  ## Future development
560
636
 
561
- - **Describe tool** — scan an existing DICOM tree and emit a matching `DatasetSpec` (counts, grouping, sizes, modalities; no PHI)
562
637
  - **Dimension edge-case recipes** — documented presets for geometry pathologies (1×65535 strips, high frame counts)
638
+ - **Per-file tag variance templating** — patterned overrides like `"PatientID": "P{index}"` for shape replication
563
639
  - **Compressed transfer syntaxes** — JPEG-LS, JPEG 2000, RLE
564
640
  - **Private fixture catalogues** — same SHA-256 fetch/cache pattern for credentials-backed sources (e.g. S3)
565
641
 
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ import { writeFileSync } from 'node:fs'
3
+ import { resolve } from 'node:path'
4
+ import { describeDirectory } from '../dist/esm/index.js'
5
+
6
+ function usage() {
7
+ console.error(
8
+ 'Usage: dicom-synth-describe <dir> [--out <spec.json>] [--keep-tags <a,b,...>]\n' +
9
+ '\n' +
10
+ 'Scans a DICOM tree and emits a DatasetSpec describing its shape\n' +
11
+ '(studies/series, per-file size, modality).\n' +
12
+ '\n' +
13
+ 'By default only the shape is reproduced — no tags are copied.\n' +
14
+ '--keep-tags copies the named tags (DICOM keywords or 8-hex tags)\n' +
15
+ "verbatim; if a kept tag holds PHI, handling it is the caller's\n" +
16
+ "responsibility, not this tool's.",
17
+ )
18
+ process.exit(1)
19
+ }
20
+
21
+ const args = process.argv.slice(2)
22
+ if (args.length === 0) usage()
23
+
24
+ let dir
25
+ let outPath
26
+ let keepTags
27
+
28
+ for (let i = 0; i < args.length; i++) {
29
+ if (args[i] === '--out' && args[i + 1]) {
30
+ outPath = resolve(args[++i])
31
+ } else if (args[i] === '--keep-tags' && args[i + 1]) {
32
+ keepTags = args[++i]
33
+ .split(',')
34
+ .map((t) => t.trim())
35
+ .filter(Boolean)
36
+ } else if (args[i].startsWith('--')) {
37
+ console.error(`Unknown argument: ${args[i]}`)
38
+ usage()
39
+ } else if (dir === undefined) {
40
+ dir = resolve(args[i])
41
+ } else {
42
+ console.error(`Unexpected argument: ${args[i]}`)
43
+ usage()
44
+ }
45
+ }
46
+
47
+ if (dir === undefined) usage()
48
+
49
+ let result
50
+ try {
51
+ result = describeDirectory(dir, keepTags ? { keepTags } : {})
52
+ } catch (err) {
53
+ console.error(`Error: ${err.message}`)
54
+ process.exit(1)
55
+ }
56
+
57
+ const json = `${JSON.stringify(result.spec, null, 2)}\n`
58
+ if (outPath) {
59
+ writeFileSync(outPath, json)
60
+ console.error(`Wrote DatasetSpec to ${outPath}`)
61
+ } else {
62
+ process.stdout.write(json)
63
+ }
64
+
65
+ const { dicomFiles, skipped, unknownModality } = result.stats
66
+ console.error(
67
+ `Described ${dicomFiles} DICOM file(s); skipped ${skipped} non-DICOM/ungroupable file(s)`,
68
+ )
69
+ if (unknownModality > 0) {
70
+ console.error(
71
+ ` ${unknownModality} file(s) had an unsupported Modality — emitted as default (CT)`,
72
+ )
73
+ }
@@ -419,6 +419,48 @@ async function generateFile(spec, options) {
419
419
  index
420
420
  };
421
421
  }
422
+ function hashString(s) {
423
+ let h = 2166136261;
424
+ for (let i = 0; i < s.length; i++) {
425
+ h ^= s.charCodeAt(i);
426
+ h = Math.imul(h, 16777619);
427
+ }
428
+ return h >>> 0;
429
+ }
430
+ var UNICODE_TOKENS = ["\xE9", "\xFC", "\xF1", "\u65E5", "\u2122", "\u03A9"];
431
+ var NEST_DEPTH = 8;
432
+ var LONG_NAME_TARGET = 200;
433
+ function applyPathQuirks(relativePath, quirks) {
434
+ const segments = relativePath.split("/");
435
+ let dirs = segments.slice(0, -1);
436
+ const leaf = segments[segments.length - 1];
437
+ const dot = leaf.lastIndexOf(".");
438
+ let stem = dot > 0 ? leaf.slice(0, dot) : leaf;
439
+ const ext = dot > 0 ? leaf.slice(dot) : "";
440
+ const unicode = (s) => `${s}${UNICODE_TOKENS[hashString(s) % UNICODE_TOKENS.length]}`;
441
+ const has = (q) => quirks.includes(q);
442
+ if (has("deep-nesting")) {
443
+ dirs = [...dirs, ...Array.from({ length: NEST_DEPTH }, (_, i) => `d${i}`)];
444
+ }
445
+ if (has("unicode")) {
446
+ dirs = dirs.map(unicode);
447
+ stem = unicode(stem);
448
+ }
449
+ if (has("long-name")) {
450
+ const pad = (s) => s.length < LONG_NAME_TARGET ? s + "x".repeat(LONG_NAME_TARGET - s.length) : s;
451
+ stem = pad(stem);
452
+ if (dirs.length > 0) {
453
+ const last = dirs.length - 1;
454
+ dirs = dirs.map((d, i) => i === last ? pad(d) : d);
455
+ }
456
+ }
457
+ let newLeaf = stem + ext;
458
+ if (has("trailing-dot")) {
459
+ if (dirs.length > 0) dirs = dirs.map((d) => `${d}.`);
460
+ else newLeaf += ".";
461
+ }
462
+ return { relativePath: [...dirs, newLeaf].join("/"), filename: newLeaf };
463
+ }
422
464
  function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
423
465
  const pad3 = (n) => String(n).padStart(3, "0");
424
466
  const name = `${String(instanceNumber).padStart(5, "0")}.dcm`;
@@ -433,15 +475,19 @@ async function* generateCollectionFromSpec(spec) {
433
475
  const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
434
476
  const padWidth = String(totalFiles).length;
435
477
  const hierarchical = spec.layout === "hierarchical";
478
+ const quirks = spec.pathQuirks ?? [];
479
+ const decorate = (file) => quirks.length === 0 ? file : { ...file, ...applyPathQuirks(file.relativePath, quirks) };
436
480
  let globalIndex = 0;
437
481
  for (const entry of flatEntries) {
438
482
  const { count = 1, ...fileSpec } = entry;
439
483
  for (let i = 0; i < count; i++) {
440
- yield generateFile(fileSpec, {
441
- index: globalIndex,
442
- seed: spec.seed,
443
- padWidth
444
- });
484
+ yield decorate(
485
+ await generateFile(fileSpec, {
486
+ index: globalIndex,
487
+ seed: spec.seed,
488
+ padWidth
489
+ })
490
+ );
445
491
  globalIndex++;
446
492
  }
447
493
  }
@@ -473,14 +519,16 @@ async function* generateCollectionFromSpec(spec) {
473
519
  uid: { study: studyUid, series: seriesUid }
474
520
  }
475
521
  );
476
- yield hierarchical ? {
477
- ...file,
478
- ...hierarchicalName(
479
- studyOrdinal,
480
- seriesIndex,
481
- instanceNumber
482
- )
483
- } : file;
522
+ yield decorate(
523
+ hierarchical ? {
524
+ ...file,
525
+ ...hierarchicalName(
526
+ studyOrdinal,
527
+ seriesIndex,
528
+ instanceNumber
529
+ )
530
+ } : file
531
+ );
484
532
  globalIndex++;
485
533
  }
486
534
  }
@@ -0,0 +1,393 @@
1
+ // src/describe/describe.ts
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ // src/loadDcmjs.ts
6
+ import dcmjsDefaultImport, * as dcmjsNamespace from "dcmjs";
7
+ function loadDcmjs() {
8
+ if (dcmjsNamespace.data?.DicomDict) {
9
+ return dcmjsNamespace;
10
+ }
11
+ const d = dcmjsDefaultImport;
12
+ if (d?.data?.DicomDict) {
13
+ return d;
14
+ }
15
+ if (d?.default?.data?.DicomDict) {
16
+ return d.default;
17
+ }
18
+ throw new Error(
19
+ "dcmjs failed to load (missing data.DicomDict). Install dcmjs >= 0.29."
20
+ );
21
+ }
22
+ var dcmjs = loadDcmjs();
23
+
24
+ // src/schema/validate.ts
25
+ var TYPE_CAPABILITIES = {
26
+ "valid-image": { image: true },
27
+ "invalid-uid-image": { image: true },
28
+ "vendor-warnings-image": { image: true },
29
+ "fake-signature": { image: false },
30
+ "non-dicom": { image: false },
31
+ dicomdir: { image: false }
32
+ };
33
+ var VALID_MODALITIES = {
34
+ CT: true,
35
+ PT: true,
36
+ MR: true,
37
+ CR: true
38
+ };
39
+ var VALID_LAYOUTS = {
40
+ flat: true,
41
+ hierarchical: true
42
+ };
43
+ var VALID_PATH_QUIRKS = {
44
+ "trailing-dot": true,
45
+ unicode: true,
46
+ "deep-nesting": true,
47
+ "long-name": true
48
+ };
49
+ var VALID_TRANSFER_SYNTAXES = {
50
+ "explicit-vr-little-endian": true,
51
+ "implicit-vr-little-endian": true
52
+ };
53
+ var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
54
+ var VALID_VIOLATIONS = {
55
+ "uid-too-long": true,
56
+ "non-conformant-uid": true,
57
+ "missing-meta-header": true,
58
+ "malformed-sq-delimiter": true,
59
+ "vr-max-length-exceeded": true,
60
+ "missing-type1-tag": true
61
+ };
62
+ var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
63
+ var KEYWORD_RE = /^[A-Z][A-Za-z0-9]*$/;
64
+ function validatePositiveInt(value, path) {
65
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
66
+ throw new Error(
67
+ `${path}: must be a positive integer; got ${JSON.stringify(value)}`
68
+ );
69
+ }
70
+ }
71
+ function validateSeed(value, path) {
72
+ if (typeof value !== "number" || !Number.isFinite(value)) {
73
+ throw new Error(
74
+ `${path}: must be a finite number; got ${JSON.stringify(value)}`
75
+ );
76
+ }
77
+ }
78
+ function validateSizeKbLimit(kb, path) {
79
+ if (kb * 1024 > MAX_PIXEL_BYTES) {
80
+ throw new Error(`${path}: exceeds the 512 MB limit`);
81
+ }
82
+ }
83
+ function validateEnum(value, valid, path) {
84
+ if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
85
+ throw new Error(
86
+ `${path}: must be one of ${Object.keys(valid).join(", ")}; got ${JSON.stringify(value)}`
87
+ );
88
+ }
89
+ }
90
+ function validateTagKey(key, path) {
91
+ if (!HEX_TAG_RE.test(key) && !KEYWORD_RE.test(key)) {
92
+ throw new Error(
93
+ `${path}: invalid tag key "${key}" \u2014 must be a DICOM keyword name (e.g. "Modality") or an 8-hex-char tag (e.g. "00080060")`
94
+ );
95
+ }
96
+ }
97
+ function validateEntry(entry, path) {
98
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
99
+ throw new Error(`${path}: must be an object`);
100
+ }
101
+ const e = entry;
102
+ validateEnum(e.type, TYPE_CAPABILITIES, `${path}.type`);
103
+ const type = e.type;
104
+ const caps = TYPE_CAPABILITIES[type];
105
+ if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
106
+ for (const field of [
107
+ "modality",
108
+ "rows",
109
+ "columns",
110
+ "frames",
111
+ "targetSizeKb",
112
+ "tags",
113
+ "violations",
114
+ "transferSyntax"
115
+ ]) {
116
+ if (!caps.image && field in e)
117
+ throw new Error(`${path}.${field}: not supported for type "${type}"`);
118
+ }
119
+ if ("transferSyntax" in e) {
120
+ validateEnum(
121
+ e.transferSyntax,
122
+ VALID_TRANSFER_SYNTAXES,
123
+ `${path}.transferSyntax`
124
+ );
125
+ }
126
+ if ("modality" in e) {
127
+ validateEnum(e.modality, VALID_MODALITIES, `${path}.modality`);
128
+ }
129
+ if ("tags" in e) validateTags(e.tags, `${path}.tags`);
130
+ if ("violations" in e) {
131
+ if (!Array.isArray(e.violations)) {
132
+ throw new Error(`${path}.violations: must be an array`);
133
+ }
134
+ for (const [i, v] of e.violations.entries()) {
135
+ validateEnum(v, VALID_VIOLATIONS, `${path}.violations[${i}]`);
136
+ }
137
+ }
138
+ const hasDims = "rows" in e || "columns" in e || "frames" in e;
139
+ if ("targetSizeKb" in e) {
140
+ if (hasDims) {
141
+ throw new Error(
142
+ `${path}: targetSizeKb cannot be combined with rows/columns/frames \u2014 use one sizing mechanism`
143
+ );
144
+ }
145
+ validatePositiveInt(e.targetSizeKb, `${path}.targetSizeKb`);
146
+ validateSizeKbLimit(e.targetSizeKb, `${path}.targetSizeKb`);
147
+ } else if (hasDims) {
148
+ if (!("rows" in e) || !("columns" in e)) {
149
+ throw new Error(`${path}: rows and columns must be provided together`);
150
+ }
151
+ validatePositiveInt(e.rows, `${path}.rows`);
152
+ validatePositiveInt(e.columns, `${path}.columns`);
153
+ if ("frames" in e) validatePositiveInt(e.frames, `${path}.frames`);
154
+ const rows = e.rows;
155
+ const columns = e.columns;
156
+ const frames = typeof e.frames === "number" ? e.frames : 1;
157
+ if (rows * columns * frames * 2 > MAX_PIXEL_BYTES) {
158
+ throw new Error(
159
+ `${path}: pixel data (${rows}\xD7${columns}\xD7${frames}\xD72 bytes) exceeds the 512 MB limit`
160
+ );
161
+ }
162
+ }
163
+ return e;
164
+ }
165
+ function validateTags(tags, path) {
166
+ if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
167
+ throw new Error(`${path}: must be an object`);
168
+ }
169
+ for (const key of Object.keys(tags)) {
170
+ validateTagKey(key, path);
171
+ }
172
+ }
173
+ function validateSeries(series, path) {
174
+ if (typeof series !== "object" || series === null || Array.isArray(series)) {
175
+ throw new Error(`${path}: must be an object`);
176
+ }
177
+ const s = series;
178
+ if (!Array.isArray(s.entries) || s.entries.length === 0) {
179
+ throw new Error(`${path}.entries: must be a non-empty array`);
180
+ }
181
+ for (let i = 0; i < s.entries.length; i++) {
182
+ const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
183
+ if (!TYPE_CAPABILITIES[e.type].image) {
184
+ throw new Error(
185
+ `${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
186
+ );
187
+ }
188
+ }
189
+ if ("tags" in s) validateTags(s.tags, `${path}.tags`);
190
+ return s;
191
+ }
192
+ function validateStudy(study, path) {
193
+ if (typeof study !== "object" || study === null || Array.isArray(study)) {
194
+ throw new Error(`${path}: must be an object`);
195
+ }
196
+ const st = study;
197
+ if (!Array.isArray(st.series) || st.series.length === 0) {
198
+ throw new Error(`${path}.series: must be a non-empty array`);
199
+ }
200
+ for (let i = 0; i < st.series.length; i++) {
201
+ validateSeries(st.series[i], `${path}.series[${i}]`);
202
+ }
203
+ if ("count" in st) validatePositiveInt(st.count, `${path}.count`);
204
+ if ("tags" in st) validateTags(st.tags, `${path}.tags`);
205
+ return st;
206
+ }
207
+ function validateDatasetSpec(raw) {
208
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
209
+ throw new Error("DatasetSpec: must be a JSON object");
210
+ }
211
+ const r = raw;
212
+ if ("entries" in r && !Array.isArray(r.entries)) {
213
+ throw new Error("DatasetSpec.entries: must be an array");
214
+ }
215
+ if ("studies" in r && !Array.isArray(r.studies)) {
216
+ throw new Error("DatasetSpec.studies: must be an array");
217
+ }
218
+ const rawEntries = r.entries ?? [];
219
+ const rawStudies = r.studies ?? [];
220
+ if (rawEntries.length === 0 && rawStudies.length === 0) {
221
+ throw new Error(
222
+ 'DatasetSpec: must contain at least one of "entries" or "studies" (non-empty)'
223
+ );
224
+ }
225
+ const entries = rawEntries.map(
226
+ (entry, i) => validateEntry(entry, `entries[${i}]`)
227
+ );
228
+ const studies = rawStudies.map(
229
+ (study, i) => validateStudy(study, `studies[${i}]`)
230
+ );
231
+ if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
232
+ if ("layout" in r) {
233
+ validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
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
+ }
243
+ return {
244
+ ...entries.length > 0 ? { entries } : {},
245
+ ...studies.length > 0 ? { studies } : {},
246
+ ...r.seed !== void 0 ? { seed: r.seed } : {},
247
+ ...r.layout !== void 0 ? { layout: r.layout } : {},
248
+ ...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
249
+ };
250
+ }
251
+
252
+ // src/describe/describe.ts
253
+ var dcmjsAny = dcmjs;
254
+ var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
255
+ var SUPPORTED_MODALITIES = /* @__PURE__ */ new Set(["CT", "PT", "MR", "CR"]);
256
+ function buildHexToKeyword() {
257
+ const map = /* @__PURE__ */ new Map();
258
+ const nm = dcmjsAny.data.DicomMetaDictionary.nameMap;
259
+ if (!nm) return map;
260
+ for (const [keyword, info] of Object.entries(nm)) {
261
+ if (info?.tag) {
262
+ const hex = info.tag.replace(/[(,)]/g, "").toUpperCase();
263
+ if (hex.length === 8) map.set(hex, keyword);
264
+ }
265
+ }
266
+ return map;
267
+ }
268
+ function resolveKeepTags(keepTags) {
269
+ const nameMap = dcmjsAny.data.DicomMetaDictionary.nameMap;
270
+ let hexToKeyword;
271
+ return keepTags.map((tag) => {
272
+ if (HEX_TAG_RE2.test(tag)) {
273
+ hexToKeyword ?? (hexToKeyword = buildHexToKeyword());
274
+ const keyword = hexToKeyword.get(tag.toUpperCase());
275
+ if (!keyword) {
276
+ throw new Error(`describe: unknown hex tag "${tag}"`);
277
+ }
278
+ return keyword;
279
+ }
280
+ if (nameMap && !Object.hasOwn(nameMap, tag)) {
281
+ throw new Error(`describe: unknown tag keyword "${tag}"`);
282
+ }
283
+ return tag;
284
+ });
285
+ }
286
+ function* walkFiles(dir) {
287
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
288
+ const path = join(dir, entry.name);
289
+ if (entry.isDirectory()) {
290
+ yield* walkFiles(path);
291
+ } else if (entry.isFile()) {
292
+ yield path;
293
+ }
294
+ }
295
+ }
296
+ function parseFile(path) {
297
+ try {
298
+ const buf = readFileSync(path);
299
+ const ab = buf.buffer.slice(
300
+ buf.byteOffset,
301
+ buf.byteOffset + buf.byteLength
302
+ );
303
+ const parsed = dcmjsAny.data.DicomMessage.readFile(ab);
304
+ const record = dcmjsAny.data.DicomMetaDictionary.naturalizeDataset(
305
+ parsed.dict
306
+ );
307
+ return { record, size: buf.byteLength };
308
+ } catch {
309
+ return null;
310
+ }
311
+ }
312
+ function extractTags(record, keepKeywords) {
313
+ const tags = {};
314
+ let found = false;
315
+ for (const keyword of keepKeywords) {
316
+ const value = record[keyword];
317
+ if (value !== void 0) {
318
+ tags[keyword] = value;
319
+ found = true;
320
+ }
321
+ }
322
+ return found ? tags : void 0;
323
+ }
324
+ function describeDirectory(dir, options = {}) {
325
+ const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
326
+ const studies = /* @__PURE__ */ new Map();
327
+ const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
328
+ for (const path of [...walkFiles(dir)].sort()) {
329
+ const parsed = parseFile(path);
330
+ const studyUid = parsed?.record.StudyInstanceUID;
331
+ const seriesUid = parsed?.record.SeriesInstanceUID;
332
+ if (!parsed || typeof studyUid !== "string" || typeof seriesUid !== "string") {
333
+ stats.skipped++;
334
+ continue;
335
+ }
336
+ const { record } = parsed;
337
+ stats.dicomFiles++;
338
+ let modality;
339
+ if (typeof record.Modality === "string") {
340
+ if (SUPPORTED_MODALITIES.has(record.Modality)) {
341
+ modality = record.Modality;
342
+ } else {
343
+ stats.unknownModality++;
344
+ }
345
+ }
346
+ const sizeKb = Math.max(1, Math.round(parsed.size / 1024));
347
+ const tags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
348
+ let study = studies.get(studyUid);
349
+ if (!study) {
350
+ study = /* @__PURE__ */ new Map();
351
+ studies.set(studyUid, study);
352
+ }
353
+ let series = study.get(seriesUid);
354
+ if (!series) {
355
+ series = /* @__PURE__ */ new Map();
356
+ study.set(seriesUid, series);
357
+ }
358
+ const collapseKey = JSON.stringify([modality ?? null, sizeKb, tags ?? null]);
359
+ const existing = series.get(collapseKey);
360
+ if (existing) {
361
+ existing.count++;
362
+ } else {
363
+ series.set(collapseKey, {
364
+ modality,
365
+ targetSizeKb: sizeKb,
366
+ tags,
367
+ count: 1
368
+ });
369
+ }
370
+ }
371
+ const studySpecs = [...studies.values()].map((study) => {
372
+ const seriesSpecs = [...study.values()].map((series) => {
373
+ const entries = [...series.values()].map((acc) => ({
374
+ type: "valid-image",
375
+ ...acc.modality !== void 0 ? { modality: acc.modality } : {},
376
+ targetSizeKb: acc.targetSizeKb,
377
+ ...acc.tags !== void 0 ? { tags: acc.tags } : {},
378
+ ...acc.count > 1 ? { count: acc.count } : {}
379
+ }));
380
+ return { entries };
381
+ });
382
+ return { series: seriesSpecs };
383
+ });
384
+ if (studySpecs.length === 0) {
385
+ throw new Error(`describe: no DICOM series found in ${dir}`);
386
+ }
387
+ const spec = { studies: studySpecs, layout: "hierarchical" };
388
+ validateDatasetSpec(spec);
389
+ return { spec, stats };
390
+ }
391
+ export {
392
+ describeDirectory
393
+ };
package/dist/esm/index.js CHANGED
@@ -63,6 +63,12 @@ var VALID_LAYOUTS = {
63
63
  flat: true,
64
64
  hierarchical: true
65
65
  };
66
+ var VALID_PATH_QUIRKS = {
67
+ "trailing-dot": true,
68
+ unicode: true,
69
+ "deep-nesting": true,
70
+ "long-name": true
71
+ };
66
72
  var VALID_TRANSFER_SYNTAXES = {
67
73
  "explicit-vr-little-endian": true,
68
74
  "implicit-vr-little-endian": true
@@ -249,11 +255,20 @@ function validateDatasetSpec(raw) {
249
255
  if ("layout" in r) {
250
256
  validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
251
257
  }
258
+ if ("pathQuirks" in r) {
259
+ if (!Array.isArray(r.pathQuirks)) {
260
+ throw new Error("DatasetSpec.pathQuirks: must be an array");
261
+ }
262
+ for (const [i, q] of r.pathQuirks.entries()) {
263
+ validateEnum(q, VALID_PATH_QUIRKS, `DatasetSpec.pathQuirks[${i}]`);
264
+ }
265
+ }
252
266
  return {
253
267
  ...entries.length > 0 ? { entries } : {},
254
268
  ...studies.length > 0 ? { studies } : {},
255
269
  ...r.seed !== void 0 ? { seed: r.seed } : {},
256
- ...r.layout !== void 0 ? { layout: r.layout } : {}
270
+ ...r.layout !== void 0 ? { layout: r.layout } : {},
271
+ ...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
257
272
  };
258
273
  }
259
274
  function validateRange(value, path) {
@@ -720,6 +735,48 @@ async function generateFile(spec, options) {
720
735
  index
721
736
  };
722
737
  }
738
+ function hashString(s) {
739
+ let h = 2166136261;
740
+ for (let i = 0; i < s.length; i++) {
741
+ h ^= s.charCodeAt(i);
742
+ h = Math.imul(h, 16777619);
743
+ }
744
+ return h >>> 0;
745
+ }
746
+ var UNICODE_TOKENS = ["\xE9", "\xFC", "\xF1", "\u65E5", "\u2122", "\u03A9"];
747
+ var NEST_DEPTH = 8;
748
+ var LONG_NAME_TARGET = 200;
749
+ function applyPathQuirks(relativePath, quirks) {
750
+ const segments = relativePath.split("/");
751
+ let dirs = segments.slice(0, -1);
752
+ const leaf = segments[segments.length - 1];
753
+ const dot = leaf.lastIndexOf(".");
754
+ let stem = dot > 0 ? leaf.slice(0, dot) : leaf;
755
+ const ext = dot > 0 ? leaf.slice(dot) : "";
756
+ const unicode = (s) => `${s}${UNICODE_TOKENS[hashString(s) % UNICODE_TOKENS.length]}`;
757
+ const has = (q) => quirks.includes(q);
758
+ if (has("deep-nesting")) {
759
+ dirs = [...dirs, ...Array.from({ length: NEST_DEPTH }, (_, i) => `d${i}`)];
760
+ }
761
+ if (has("unicode")) {
762
+ dirs = dirs.map(unicode);
763
+ stem = unicode(stem);
764
+ }
765
+ if (has("long-name")) {
766
+ const pad = (s) => s.length < LONG_NAME_TARGET ? s + "x".repeat(LONG_NAME_TARGET - s.length) : s;
767
+ stem = pad(stem);
768
+ if (dirs.length > 0) {
769
+ const last = dirs.length - 1;
770
+ dirs = dirs.map((d, i) => i === last ? pad(d) : d);
771
+ }
772
+ }
773
+ let newLeaf = stem + ext;
774
+ if (has("trailing-dot")) {
775
+ if (dirs.length > 0) dirs = dirs.map((d) => `${d}.`);
776
+ else newLeaf += ".";
777
+ }
778
+ return { relativePath: [...dirs, newLeaf].join("/"), filename: newLeaf };
779
+ }
723
780
  function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
724
781
  const pad3 = (n) => String(n).padStart(3, "0");
725
782
  const name = `${String(instanceNumber).padStart(5, "0")}.dcm`;
@@ -734,15 +791,19 @@ async function* generateCollectionFromSpec(spec) {
734
791
  const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
735
792
  const padWidth = String(totalFiles).length;
736
793
  const hierarchical = spec.layout === "hierarchical";
794
+ const quirks = spec.pathQuirks ?? [];
795
+ const decorate = (file) => quirks.length === 0 ? file : { ...file, ...applyPathQuirks(file.relativePath, quirks) };
737
796
  let globalIndex = 0;
738
797
  for (const entry of flatEntries) {
739
798
  const { count = 1, ...fileSpec } = entry;
740
799
  for (let i = 0; i < count; i++) {
741
- yield generateFile(fileSpec, {
742
- index: globalIndex,
743
- seed: spec.seed,
744
- padWidth
745
- });
800
+ yield decorate(
801
+ await generateFile(fileSpec, {
802
+ index: globalIndex,
803
+ seed: spec.seed,
804
+ padWidth
805
+ })
806
+ );
746
807
  globalIndex++;
747
808
  }
748
809
  }
@@ -774,14 +835,16 @@ async function* generateCollectionFromSpec(spec) {
774
835
  uid: { study: studyUid, series: seriesUid }
775
836
  }
776
837
  );
777
- yield hierarchical ? {
778
- ...file,
779
- ...hierarchicalName(
780
- studyOrdinal,
781
- seriesIndex,
782
- instanceNumber
783
- )
784
- } : file;
838
+ yield decorate(
839
+ hierarchical ? {
840
+ ...file,
841
+ ...hierarchicalName(
842
+ studyOrdinal,
843
+ seriesIndex,
844
+ instanceNumber
845
+ )
846
+ } : file
847
+ );
785
848
  globalIndex++;
786
849
  }
787
850
  }
@@ -808,11 +871,153 @@ async function writeCollectionFromSpec(spec, outDir) {
808
871
  return manifest;
809
872
  }
810
873
 
874
+ // src/describe/describe.ts
875
+ import { readdirSync, readFileSync } from "node:fs";
876
+ import { join } from "node:path";
877
+ var dcmjsAny2 = dcmjs;
878
+ var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
879
+ var SUPPORTED_MODALITIES = /* @__PURE__ */ new Set(["CT", "PT", "MR", "CR"]);
880
+ function buildHexToKeyword() {
881
+ const map = /* @__PURE__ */ new Map();
882
+ const nm = dcmjsAny2.data.DicomMetaDictionary.nameMap;
883
+ if (!nm) return map;
884
+ for (const [keyword, info] of Object.entries(nm)) {
885
+ if (info?.tag) {
886
+ const hex = info.tag.replace(/[(,)]/g, "").toUpperCase();
887
+ if (hex.length === 8) map.set(hex, keyword);
888
+ }
889
+ }
890
+ return map;
891
+ }
892
+ function resolveKeepTags(keepTags) {
893
+ const nameMap = dcmjsAny2.data.DicomMetaDictionary.nameMap;
894
+ let hexToKeyword;
895
+ return keepTags.map((tag) => {
896
+ if (HEX_TAG_RE2.test(tag)) {
897
+ hexToKeyword ?? (hexToKeyword = buildHexToKeyword());
898
+ const keyword = hexToKeyword.get(tag.toUpperCase());
899
+ if (!keyword) {
900
+ throw new Error(`describe: unknown hex tag "${tag}"`);
901
+ }
902
+ return keyword;
903
+ }
904
+ if (nameMap && !Object.hasOwn(nameMap, tag)) {
905
+ throw new Error(`describe: unknown tag keyword "${tag}"`);
906
+ }
907
+ return tag;
908
+ });
909
+ }
910
+ function* walkFiles(dir) {
911
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
912
+ const path = join(dir, entry.name);
913
+ if (entry.isDirectory()) {
914
+ yield* walkFiles(path);
915
+ } else if (entry.isFile()) {
916
+ yield path;
917
+ }
918
+ }
919
+ }
920
+ function parseFile(path) {
921
+ try {
922
+ const buf = readFileSync(path);
923
+ const ab = buf.buffer.slice(
924
+ buf.byteOffset,
925
+ buf.byteOffset + buf.byteLength
926
+ );
927
+ const parsed = dcmjsAny2.data.DicomMessage.readFile(ab);
928
+ const record = dcmjsAny2.data.DicomMetaDictionary.naturalizeDataset(
929
+ parsed.dict
930
+ );
931
+ return { record, size: buf.byteLength };
932
+ } catch {
933
+ return null;
934
+ }
935
+ }
936
+ function extractTags(record, keepKeywords) {
937
+ const tags = {};
938
+ let found = false;
939
+ for (const keyword of keepKeywords) {
940
+ const value = record[keyword];
941
+ if (value !== void 0) {
942
+ tags[keyword] = value;
943
+ found = true;
944
+ }
945
+ }
946
+ return found ? tags : void 0;
947
+ }
948
+ function describeDirectory(dir, options = {}) {
949
+ const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
950
+ const studies = /* @__PURE__ */ new Map();
951
+ const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
952
+ for (const path of [...walkFiles(dir)].sort()) {
953
+ const parsed = parseFile(path);
954
+ const studyUid = parsed?.record.StudyInstanceUID;
955
+ const seriesUid = parsed?.record.SeriesInstanceUID;
956
+ if (!parsed || typeof studyUid !== "string" || typeof seriesUid !== "string") {
957
+ stats.skipped++;
958
+ continue;
959
+ }
960
+ const { record } = parsed;
961
+ stats.dicomFiles++;
962
+ let modality;
963
+ if (typeof record.Modality === "string") {
964
+ if (SUPPORTED_MODALITIES.has(record.Modality)) {
965
+ modality = record.Modality;
966
+ } else {
967
+ stats.unknownModality++;
968
+ }
969
+ }
970
+ const sizeKb = Math.max(1, Math.round(parsed.size / 1024));
971
+ const tags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
972
+ let study = studies.get(studyUid);
973
+ if (!study) {
974
+ study = /* @__PURE__ */ new Map();
975
+ studies.set(studyUid, study);
976
+ }
977
+ let series = study.get(seriesUid);
978
+ if (!series) {
979
+ series = /* @__PURE__ */ new Map();
980
+ study.set(seriesUid, series);
981
+ }
982
+ const collapseKey = JSON.stringify([modality ?? null, sizeKb, tags ?? null]);
983
+ const existing = series.get(collapseKey);
984
+ if (existing) {
985
+ existing.count++;
986
+ } else {
987
+ series.set(collapseKey, {
988
+ modality,
989
+ targetSizeKb: sizeKb,
990
+ tags,
991
+ count: 1
992
+ });
993
+ }
994
+ }
995
+ const studySpecs = [...studies.values()].map((study) => {
996
+ const seriesSpecs = [...study.values()].map((series) => {
997
+ const entries = [...series.values()].map((acc) => ({
998
+ type: "valid-image",
999
+ ...acc.modality !== void 0 ? { modality: acc.modality } : {},
1000
+ targetSizeKb: acc.targetSizeKb,
1001
+ ...acc.tags !== void 0 ? { tags: acc.tags } : {},
1002
+ ...acc.count > 1 ? { count: acc.count } : {}
1003
+ }));
1004
+ return { entries };
1005
+ });
1006
+ return { series: seriesSpecs };
1007
+ });
1008
+ if (studySpecs.length === 0) {
1009
+ throw new Error(`describe: no DICOM series found in ${dir}`);
1010
+ }
1011
+ const spec = { studies: studySpecs, layout: "hierarchical" };
1012
+ validateDatasetSpec(spec);
1013
+ return { spec, stats };
1014
+ }
1015
+
811
1016
  // src/public-fixtures/catalog.ts
812
- import { readFileSync } from "node:fs";
813
- import { dirname as dirname2, join } from "node:path";
1017
+ import { readFileSync as readFileSync2 } from "node:fs";
1018
+ import { dirname as dirname2, join as join2 } from "node:path";
814
1019
  import { fileURLToPath } from "node:url";
815
- var bundledCatalogPath = join(
1020
+ var bundledCatalogPath = join2(
816
1021
  dirname2(fileURLToPath(import.meta.url)),
817
1022
  "../../data/public-cases.json"
818
1023
  );
@@ -823,7 +1028,7 @@ function loadDefaultCases() {
823
1028
  return loadCasesFromJson(defaultPublicCasesPath());
824
1029
  }
825
1030
  function loadCasesFromJson(casesJsonPath) {
826
- const j = JSON.parse(readFileSync(casesJsonPath, "utf8"));
1031
+ const j = JSON.parse(readFileSync2(casesJsonPath, "utf8"));
827
1032
  if (!Array.isArray(j.cases)) {
828
1033
  throw new Error(
829
1034
  `Invalid catalog in ${casesJsonPath}: expected "cases" array`
@@ -841,12 +1046,12 @@ function loadCaseById(casesJsonPath, id) {
841
1046
 
842
1047
  // src/public-fixtures/fetch.ts
843
1048
  import { createHash } from "node:crypto";
844
- import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
1049
+ import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
845
1050
  import { homedir } from "node:os";
846
- import { join as join2 } from "node:path";
847
- var DEFAULT_CACHE_ROOT = join2(homedir(), ".cache", "dicom-synth-testcases");
1051
+ import { join as join3 } from "node:path";
1052
+ var DEFAULT_CACHE_ROOT = join3(homedir(), ".cache", "dicom-synth-testcases");
848
1053
  function caseCachePath(sha256, root = DEFAULT_CACHE_ROOT) {
849
- return join2(root, sha256, "file.dcm");
1054
+ return join3(root, sha256, "file.dcm");
850
1055
  }
851
1056
  function verifySha256(buffer, expected) {
852
1057
  const h = createHash("sha256").update(buffer).digest("hex");
@@ -864,11 +1069,11 @@ async function fetchPublicCaseToCache(record, cacheRoot = DEFAULT_CACHE_ROOT) {
864
1069
  }
865
1070
  const dest = caseCachePath(record.sha256, cacheRoot);
866
1071
  if (existsSync(dest)) {
867
- const buf2 = readFileSync2(dest);
1072
+ const buf2 = readFileSync3(dest);
868
1073
  verifySha256(buf2, record.sha256);
869
1074
  return dest;
870
1075
  }
871
- mkdirSync3(join2(cacheRoot, record.sha256), { recursive: true });
1076
+ mkdirSync3(join3(cacheRoot, record.sha256), { recursive: true });
872
1077
  const res = await fetch(record.source.url);
873
1078
  if (!res.ok) {
874
1079
  throw new Error(
@@ -957,6 +1162,7 @@ function resolveParametricSpec(spec) {
957
1162
  export {
958
1163
  caseCachePath,
959
1164
  defaultPublicCasesPath,
1165
+ describeDirectory,
960
1166
  fetchPublicCaseToCache,
961
1167
  generateCollectionFromSpec,
962
1168
  generateFile,
@@ -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) {
@@ -0,0 +1,13 @@
1
+ import type { DatasetSpec } from '../schema/types.js';
2
+ export type DescribeOptions = {
3
+ keepTags?: string[];
4
+ };
5
+ export type DescribeResult = {
6
+ spec: DatasetSpec;
7
+ stats: {
8
+ dicomFiles: number;
9
+ skipped: number;
10
+ unknownModality: number;
11
+ };
12
+ };
13
+ export declare function describeDirectory(dir: string, options?: DescribeOptions): DescribeResult;
@@ -1,6 +1,7 @@
1
1
  export { type CollectionManifest, type GeneratedFile, generateCollectionFromSpec, generateFile, writeCollectionFromSpec, } from './collection/writer.js';
2
+ export { type DescribeOptions, type DescribeResult, describeDirectory, } from './describe/describe.js';
2
3
  export { defaultPublicCasesPath, loadCaseById, loadCasesFromJson, loadDefaultCases, type PublicCaseRecord, type PublicCaseSource, } from './public-fixtures/catalog.js';
3
4
  export { caseCachePath, fetchPublicCaseToCache, verifySha256, } from './public-fixtures/fetch.js';
4
5
  export { resolveParametricSpec } from './schema/parametric.js';
5
- 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';
6
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.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "Toolkit for synthetic DICOM fixtures and public fixture fetch/cache.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -23,7 +23,8 @@
23
23
  "types": "dist/types/index.d.ts",
24
24
  "bin": {
25
25
  "dicom-synth-generate": "./bin/dicom-synth-generate.mjs",
26
- "dicom-synth-fetch": "./bin/dicom-synth-fetch.mjs"
26
+ "dicom-synth-fetch": "./bin/dicom-synth-fetch.mjs",
27
+ "dicom-synth-describe": "./bin/dicom-synth-describe.mjs"
27
28
  },
28
29
  "exports": {
29
30
  ".": {
@@ -82,7 +83,7 @@
82
83
  "@typescript-eslint/eslint-plugin": "^8.46.1",
83
84
  "@typescript-eslint/parser": "^8.46.1",
84
85
  "dcmjs": "^0.51.1",
85
- "esbuild": "^0.25.9",
86
+ "esbuild": "^0.28.1",
86
87
  "eslint": "^9.38.0",
87
88
  "globals": "^16.4.0",
88
89
  "husky": "^9.1.7",