dicom-synth 1.10.0 → 1.11.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.
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync, writeFileSync } from 'node:fs'
3
3
  import { resolve } from 'node:path'
4
+ import { gzipSync } from 'node:zlib'
4
5
  import { describeDirectory } from '../dist/esm/index.js'
5
6
 
6
7
  function usage() {
7
8
  console.error(
8
- 'Usage: dicom-synth-describe <dir> [--out <spec.json>] [--keep-tags <a,b,...>] [--keep-tags-file <path>]\n' +
9
+ 'Usage: dicom-synth-describe <dir> [--out <spec.json[.gz]>] [--keep-tags <a,b,...>] [--keep-tags-file <path>] [--size-tolerance <fraction>] [--gzip]\n' +
9
10
  '\n' +
10
11
  'Scans a DICOM tree and emits a DatasetSpec describing its shape\n' +
11
12
  '(studies/series, per-file size, modality).\n' +
@@ -15,7 +16,10 @@ function usage() {
15
16
  "verbatim; if a kept tag holds PHI, handling it is the caller's\n" +
16
17
  "responsibility, not this tool's.\n" +
17
18
  '--keep-tags-file reads the same allow-list from a file (one tag per\n' +
18
- 'line, # comments); merged with any --keep-tags.',
19
+ 'line, # comments); merged with any --keep-tags.\n' +
20
+ '--size-tolerance folds near-identical file sizes (fraction, e.g. 0.05\n' +
21
+ '= ±5%) into one entry; default exact.\n' +
22
+ '--gzip (or a .gz --out suffix) writes the spec gzip-compressed.',
19
23
  )
20
24
  process.exit(1)
21
25
  }
@@ -33,6 +37,8 @@ if (args.length === 0) usage()
33
37
 
34
38
  let dir
35
39
  let outPath
40
+ let sizeTolerance
41
+ let gzip = false
36
42
  const keepTags = []
37
43
 
38
44
  for (let i = 0; i < args.length; i++) {
@@ -52,6 +58,14 @@ for (let i = 0; i < args.length; i++) {
52
58
  console.error(`Error reading keep-tags file: ${err.message}`)
53
59
  process.exit(1)
54
60
  }
61
+ } else if (args[i] === '--size-tolerance' && args[i + 1]) {
62
+ sizeTolerance = Number(args[++i])
63
+ if (!Number.isFinite(sizeTolerance) || sizeTolerance < 0) {
64
+ console.error('--size-tolerance must be a non-negative number')
65
+ process.exit(1)
66
+ }
67
+ } else if (args[i] === '--gzip') {
68
+ gzip = true
55
69
  } else if (args[i].startsWith('--')) {
56
70
  console.error(`Unknown argument: ${args[i]}`)
57
71
  usage()
@@ -67,18 +81,22 @@ if (dir === undefined) usage()
67
81
 
68
82
  let result
69
83
  try {
70
- result = describeDirectory(dir, keepTags.length ? { keepTags } : {})
84
+ result = describeDirectory(dir, {
85
+ ...(keepTags.length ? { keepTags } : {}),
86
+ ...(sizeTolerance !== undefined ? { sizeTolerance } : {}),
87
+ })
71
88
  } catch (err) {
72
89
  console.error(`Error: ${err.message}`)
73
90
  process.exit(1)
74
91
  }
75
92
 
76
93
  const json = `${JSON.stringify(result.spec, null, 2)}\n`
94
+ const compress = gzip || outPath?.endsWith('.gz')
77
95
  if (outPath) {
78
- writeFileSync(outPath, json)
96
+ writeFileSync(outPath, compress ? gzipSync(json) : json)
79
97
  console.error(`Wrote DatasetSpec to ${outPath}`)
80
98
  } else {
81
- process.stdout.write(json)
99
+ process.stdout.write(compress ? gzipSync(json) : json)
82
100
  }
83
101
 
84
102
  const { dicomFiles, skipped, unknownModality } = result.stats
@@ -587,12 +587,41 @@ function filename(type, index, padWidth) {
587
587
  function entryCount(entries) {
588
588
  return entries.reduce((sum, e) => sum + (e.count ?? 1), 0);
589
589
  }
590
+ function seriesFileCount(series) {
591
+ return series.instances ? series.instances.count : entryCount(series.entries ?? []);
592
+ }
590
593
  function studyFileCount(studies) {
591
594
  return studies.reduce(
592
- (sum, study) => sum + (study.count ?? 1) * study.series.reduce((s, series) => s + entryCount(series.entries), 0),
595
+ (sum, study) => sum + (study.count ?? 1) * study.series.reduce((s, series) => s + seriesFileCount(series), 0),
593
596
  0
594
597
  );
595
598
  }
599
+ function* seriesFiles(series) {
600
+ if (series.instances) {
601
+ const inst = series.instances;
602
+ for (let i = 0; i < inst.count; i++) {
603
+ const modality = Array.isArray(inst.modality) ? inst.modality[i] : inst.modality;
604
+ const baseSpec = {
605
+ ...inst.targetBytes ? { type: "large-image", targetBytes: inst.targetBytes[i] } : { type: "valid-image", targetSizeKb: inst.targetSizeKb?.[i] },
606
+ ...modality !== void 0 ? { modality } : {}
607
+ };
608
+ const instanceTags = {};
609
+ if (inst.tags) {
610
+ for (const [key, values] of Object.entries(inst.tags)) {
611
+ instanceTags[key] = values[i];
612
+ }
613
+ }
614
+ yield { baseSpec, instanceTags };
615
+ }
616
+ return;
617
+ }
618
+ for (const entry of series.entries ?? []) {
619
+ const { count = 1, tags, ...baseSpec } = entry;
620
+ for (let i = 0; i < count; i++) {
621
+ yield { baseSpec, instanceTags: tags ?? {} };
622
+ }
623
+ }
624
+ }
596
625
  function resolveUid(seed, index, override) {
597
626
  return { ...makeUidGenerator(seed)(index), ...override };
598
627
  }
@@ -720,36 +749,33 @@ function* planCollection(spec) {
720
749
  for (const [seriesIndex, series] of study.series.entries()) {
721
750
  const seriesUid = groupUids.series(studyOrdinal, seriesIndex);
722
751
  let instanceNumber = 0;
723
- for (const entry of series.entries) {
724
- const { count = 1, ...fileSpec } = entry;
725
- for (let i = 0; i < count; i++) {
726
- instanceNumber++;
727
- const tags = {
728
- InstanceNumber: instanceNumber,
729
- ...study.tags,
730
- ...series.tags,
731
- ...fileSpec.tags
732
- };
733
- yield {
734
- fileSpec: { ...fileSpec, tags },
752
+ for (const { baseSpec, instanceTags } of seriesFiles(series)) {
753
+ instanceNumber++;
754
+ const tags = {
755
+ InstanceNumber: instanceNumber,
756
+ ...study.tags,
757
+ ...series.tags,
758
+ ...instanceTags
759
+ };
760
+ yield {
761
+ fileSpec: { ...baseSpec, tags },
762
+ index: globalIndex,
763
+ uidOverride: { study: studyUid, series: seriesUid },
764
+ context: {
735
765
  index: globalIndex,
736
- uidOverride: { study: studyUid, series: seriesUid },
737
- context: {
738
- index: globalIndex,
739
- studyIndex: studyOrdinal,
740
- seriesIndex,
741
- instanceNumber
742
- },
743
- ...computeNames(
744
- fileSpec.type,
745
- globalIndex,
746
- padWidth,
747
- hierarchical ? { studyOrdinal, seriesIndex, instanceNumber } : void 0,
748
- quirks
749
- )
750
- };
751
- globalIndex++;
752
- }
766
+ studyIndex: studyOrdinal,
767
+ seriesIndex,
768
+ instanceNumber
769
+ },
770
+ ...computeNames(
771
+ baseSpec.type,
772
+ globalIndex,
773
+ padWidth,
774
+ hierarchical ? { studyOrdinal, seriesIndex, instanceNumber } : void 0,
775
+ quirks
776
+ )
777
+ };
778
+ globalIndex++;
753
779
  }
754
780
  }
755
781
  studyOrdinal++;
@@ -239,26 +239,100 @@ function validateEntry(entry, path) {
239
239
  return e;
240
240
  }
241
241
  var MODALITY_TAG = "00080060";
242
+ function validateTagValue(key, value, path) {
243
+ const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
244
+ if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
245
+ throw new Error(
246
+ `${path}.${key}: set a preset modality (${Object.keys(VALID_MODALITIES).join("/")}) via the "modality" field, not tags \u2014 it is coupled to SOPClassUID and type-1 attributes`
247
+ );
248
+ }
249
+ if (typeof value === "string") {
250
+ for (const name of findTemplateTokens(value)) {
251
+ if (!TEMPLATE_VOCAB.includes(name)) {
252
+ throw new Error(
253
+ `${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
254
+ );
255
+ }
256
+ }
257
+ }
258
+ }
242
259
  function validateTags(tags, path) {
243
260
  if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
244
261
  throw new Error(`${path}: must be an object`);
245
262
  }
246
263
  for (const [key, value] of Object.entries(tags)) {
247
264
  validateTagKey(key, path);
248
- const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
249
- if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
250
- throw new Error(
251
- `${path}.${key}: set a preset modality (${Object.keys(VALID_MODALITIES).join("/")}) via the "modality" field, not tags \u2014 it is coupled to SOPClassUID and type-1 attributes`
265
+ validateTagValue(key, value, path);
266
+ }
267
+ }
268
+ function validateArrayOfLength(value, count, path) {
269
+ if (!Array.isArray(value)) {
270
+ throw new Error(`${path}: must be an array`);
271
+ }
272
+ if (value.length !== count) {
273
+ throw new Error(
274
+ `${path}: length (${value.length}) must equal count (${count})`
275
+ );
276
+ }
277
+ return value;
278
+ }
279
+ function validateInstances(instances, path) {
280
+ if (typeof instances !== "object" || instances === null || Array.isArray(instances)) {
281
+ throw new Error(`${path}: must be an object`);
282
+ }
283
+ const inst = instances;
284
+ validatePositiveInt(inst.count, `${path}.count`);
285
+ const count = inst.count;
286
+ const hasKb = "targetSizeKb" in inst;
287
+ const hasBytes = "targetBytes" in inst;
288
+ if (hasKb === hasBytes) {
289
+ throw new Error(
290
+ `${path}: exactly one of "targetSizeKb" or "targetBytes" is required`
291
+ );
292
+ }
293
+ if (hasKb) {
294
+ const sizes = validateArrayOfLength(
295
+ inst.targetSizeKb,
296
+ count,
297
+ `${path}.targetSizeKb`
298
+ );
299
+ sizes.forEach((kb, i) => {
300
+ validatePositiveInt(kb, `${path}.targetSizeKb[${i}]`);
301
+ validateSizeKbLimit(kb, `${path}.targetSizeKb[${i}]`);
302
+ });
303
+ } else {
304
+ const sizes = validateArrayOfLength(
305
+ inst.targetBytes,
306
+ count,
307
+ `${path}.targetBytes`
308
+ );
309
+ sizes.forEach((b, i) => {
310
+ validateLargeTargetBytes(b, `${path}.targetBytes[${i}]`);
311
+ });
312
+ }
313
+ if ("modality" in inst) {
314
+ if (Array.isArray(inst.modality)) {
315
+ validateArrayOfLength(inst.modality, count, `${path}.modality`).forEach(
316
+ (m, i) => {
317
+ validateEnum(m, VALID_MODALITIES, `${path}.modality[${i}]`);
318
+ }
252
319
  );
320
+ } else {
321
+ validateEnum(inst.modality, VALID_MODALITIES, `${path}.modality`);
322
+ }
323
+ }
324
+ if ("tags" in inst) {
325
+ const tags = inst.tags;
326
+ if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
327
+ throw new Error(`${path}.tags: must be an object`);
253
328
  }
254
- if (typeof value === "string") {
255
- for (const name of findTemplateTokens(value)) {
256
- if (!TEMPLATE_VOCAB.includes(name)) {
257
- throw new Error(
258
- `${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
259
- );
329
+ for (const [key, arr] of Object.entries(tags)) {
330
+ validateTagKey(key, `${path}.tags`);
331
+ validateArrayOfLength(arr, count, `${path}.tags.${key}`).forEach(
332
+ (value, i) => {
333
+ validateTagValue(key, value, `${path}.tags[${i}]`);
260
334
  }
261
- }
335
+ );
262
336
  }
263
337
  }
264
338
  }
@@ -267,16 +341,27 @@ function validateSeries(series, path) {
267
341
  throw new Error(`${path}: must be an object`);
268
342
  }
269
343
  const s = series;
270
- if (!Array.isArray(s.entries) || s.entries.length === 0) {
271
- throw new Error(`${path}.entries: must be a non-empty array`);
344
+ const hasEntries = "entries" in s;
345
+ const hasInstances = "instances" in s;
346
+ if (hasEntries === hasInstances) {
347
+ throw new Error(
348
+ `${path}: must have exactly one of "entries" or "instances"`
349
+ );
272
350
  }
273
- for (let i = 0; i < s.entries.length; i++) {
274
- const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
275
- if (!TYPE_CAPABILITIES[e.type].image) {
276
- throw new Error(
277
- `${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
278
- );
351
+ if (hasEntries) {
352
+ if (!Array.isArray(s.entries) || s.entries.length === 0) {
353
+ throw new Error(`${path}.entries: must be a non-empty array`);
354
+ }
355
+ for (let i = 0; i < s.entries.length; i++) {
356
+ const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
357
+ if (!TYPE_CAPABILITIES[e.type].image) {
358
+ throw new Error(
359
+ `${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
360
+ );
361
+ }
279
362
  }
363
+ } else {
364
+ validateInstances(s.instances, `${path}.instances`);
280
365
  }
281
366
  if ("tags" in s) validateTags(s.tags, `${path}.tags`);
282
367
  return s;
@@ -498,8 +583,117 @@ function extractTags(record, keepKeywords) {
498
583
  }
499
584
  return found ? tags : void 0;
500
585
  }
586
+ function sizeKbOf(bytes) {
587
+ return Math.max(1, Math.round(bytes / 1024));
588
+ }
589
+ function bucketBytes(bytes, tolerance) {
590
+ if (tolerance <= 0 || bytes < 1) return bytes;
591
+ const factor = 1 + tolerance;
592
+ const bucket = Math.floor(Math.log(bytes) / Math.log(factor));
593
+ return Math.max(1, Math.round(factor ** (bucket + 0.5)));
594
+ }
595
+ function collapseKeyOf(r) {
596
+ return JSON.stringify([
597
+ r.large,
598
+ r.modality ?? null,
599
+ r.large ? r.bytes : sizeKbOf(r.bytes),
600
+ r.tags ?? null
601
+ ]);
602
+ }
603
+ function accumulate(byKey, r) {
604
+ const key = collapseKeyOf(r);
605
+ const existing = byKey.get(key);
606
+ if (existing) {
607
+ existing.count++;
608
+ } else {
609
+ byKey.set(key, {
610
+ modality: r.modality,
611
+ large: r.large,
612
+ bytes: r.bytes,
613
+ tags: r.tags,
614
+ count: 1
615
+ });
616
+ }
617
+ }
618
+ function entryOf(acc) {
619
+ return {
620
+ ...acc.modality !== void 0 ? { modality: acc.modality } : {},
621
+ ...acc.large ? { type: "large-image", targetBytes: acc.bytes } : { type: "valid-image", targetSizeKb: sizeKbOf(acc.bytes) },
622
+ ...acc.tags !== void 0 ? { tags: acc.tags } : {},
623
+ ...acc.count > 1 ? { count: acc.count } : {}
624
+ };
625
+ }
626
+ function collapseEntries(records) {
627
+ const byKey = /* @__PURE__ */ new Map();
628
+ for (const r of records) accumulate(byKey, r);
629
+ return [...byKey.values()].map(entryOf);
630
+ }
631
+ function tagValuesEqual(a, b) {
632
+ if (a === b) return true;
633
+ if (typeof a === "object" && a !== null && typeof b === "object" && b !== null)
634
+ return JSON.stringify(a) === JSON.stringify(b);
635
+ return false;
636
+ }
637
+ function tryInstancesForm(records) {
638
+ const count = records.length;
639
+ if (count === 0) return null;
640
+ const large = records[0]?.large ?? false;
641
+ const modality = records[0]?.modality;
642
+ const columns = /* @__PURE__ */ new Map();
643
+ for (let i = 0; i < count; i++) {
644
+ const r = records[i];
645
+ if (r.large !== large || r.modality !== modality) return null;
646
+ if (!r.tags) continue;
647
+ for (const [k, v] of Object.entries(r.tags)) {
648
+ let col = columns.get(k);
649
+ if (!col) {
650
+ col = { values: new Array(count), present: 0 };
651
+ columns.set(k, col);
652
+ }
653
+ col.values[i] = v;
654
+ col.present++;
655
+ }
656
+ }
657
+ if (columns.size === 0) return null;
658
+ const sharedTags = {};
659
+ const perInstanceTags = {};
660
+ for (const [k, col] of columns) {
661
+ if (col.present !== count) return null;
662
+ const first = col.values[0];
663
+ if (col.values.every((v) => tagValuesEqual(v, first))) {
664
+ sharedTags[k] = first;
665
+ } else {
666
+ perInstanceTags[k] = col.values;
667
+ }
668
+ }
669
+ if (Object.keys(perInstanceTags).length === 0) return null;
670
+ const instances = {
671
+ count,
672
+ ...large ? { targetBytes: records.map((r) => r.bytes) } : { targetSizeKb: records.map((r) => sizeKbOf(r.bytes)) },
673
+ ...modality !== void 0 ? { modality } : {},
674
+ // perInstanceTags is non-empty here (we returned null above otherwise).
675
+ tags: perInstanceTags
676
+ };
677
+ return {
678
+ instances,
679
+ ...Object.keys(sharedTags).length ? { tags: sharedTags } : {}
680
+ };
681
+ }
682
+ function buildSeriesSpec(records) {
683
+ return tryInstancesForm(records) ?? { entries: collapseEntries(records) };
684
+ }
685
+ function seriesSpecFromAccum(accum) {
686
+ return Array.isArray(accum) ? buildSeriesSpec(accum) : { entries: [...accum.values()].map(entryOf) };
687
+ }
501
688
  function describeDirectory(dir, options = {}) {
502
689
  const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
690
+ const keepTags = keepKeywords.length > 0;
691
+ const tolerance = options.sizeTolerance ?? 0;
692
+ if (!Number.isFinite(tolerance) || tolerance < 0) {
693
+ throw new Error(
694
+ `describe: sizeTolerance must be a non-negative fraction; got ${JSON.stringify(options.sizeTolerance)}`
695
+ );
696
+ }
503
697
  const studies = /* @__PURE__ */ new Map();
504
698
  const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
505
699
  for (const path of [...walkFiles(dir)].sort()) {
@@ -531,8 +725,9 @@ function describeDirectory(dir, options = {}) {
531
725
  stats.unknownModality++;
532
726
  }
533
727
  }
534
- const sizeKb = Math.max(1, Math.round(size / 1024));
535
- const tags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
728
+ const tags = keepTags ? extractTags(record, keepKeywords) : void 0;
729
+ const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
730
+ const fileRecord = { large, bytes, modality, tags };
536
731
  let study = studies.get(studyUid);
537
732
  if (!study) {
538
733
  study = /* @__PURE__ */ new Map();
@@ -540,37 +735,18 @@ function describeDirectory(dir, options = {}) {
540
735
  }
541
736
  let series = study.get(seriesUid);
542
737
  if (!series) {
543
- series = /* @__PURE__ */ new Map();
738
+ series = keepTags ? [] : /* @__PURE__ */ new Map();
544
739
  study.set(seriesUid, series);
545
740
  }
546
- const collapseKey = JSON.stringify([
547
- large,
548
- modality ?? null,
549
- large ? size : sizeKb,
550
- tags ?? null
551
- ]);
552
- const existing = series.get(collapseKey);
553
- if (existing) {
554
- existing.count++;
741
+ if (Array.isArray(series)) {
742
+ series.push(fileRecord);
555
743
  } else {
556
- series.set(collapseKey, { modality, large, bytes: size, tags, count: 1 });
744
+ accumulate(series, fileRecord);
557
745
  }
558
746
  }
559
- const studySpecs = [...studies.values()].map((study) => {
560
- const seriesSpecs = [...study.values()].map((series) => {
561
- const entries = [...series.values()].map((acc) => ({
562
- ...acc.modality !== void 0 ? { modality: acc.modality } : {},
563
- ...acc.large ? { type: "large-image", targetBytes: acc.bytes } : {
564
- type: "valid-image",
565
- targetSizeKb: Math.max(1, Math.round(acc.bytes / 1024))
566
- },
567
- ...acc.tags !== void 0 ? { tags: acc.tags } : {},
568
- ...acc.count > 1 ? { count: acc.count } : {}
569
- }));
570
- return { entries };
571
- });
572
- return { series: seriesSpecs };
573
- });
747
+ const studySpecs = [...studies.values()].map((study) => ({
748
+ series: [...study.values()].map(seriesSpecFromAccum)
749
+ }));
574
750
  if (studySpecs.length === 0) {
575
751
  throw new Error(`describe: no DICOM series found in ${dir}`);
576
752
  }
@@ -580,5 +756,6 @@ function describeDirectory(dir, options = {}) {
580
756
  }
581
757
  export {
582
758
  describeDirectory,
583
- readHeaderOnly
759
+ readHeaderOnly,
760
+ tagValuesEqual
584
761
  };
package/dist/esm/index.js CHANGED
@@ -288,26 +288,100 @@ function validateEntry(entry, path) {
288
288
  return e;
289
289
  }
290
290
  var MODALITY_TAG = "00080060";
291
+ function validateTagValue(key, value, path) {
292
+ const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
293
+ if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
294
+ throw new Error(
295
+ `${path}.${key}: set a preset modality (${Object.keys(VALID_MODALITIES).join("/")}) via the "modality" field, not tags \u2014 it is coupled to SOPClassUID and type-1 attributes`
296
+ );
297
+ }
298
+ if (typeof value === "string") {
299
+ for (const name of findTemplateTokens(value)) {
300
+ if (!TEMPLATE_VOCAB.includes(name)) {
301
+ throw new Error(
302
+ `${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
303
+ );
304
+ }
305
+ }
306
+ }
307
+ }
291
308
  function validateTags(tags, path) {
292
309
  if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
293
310
  throw new Error(`${path}: must be an object`);
294
311
  }
295
312
  for (const [key, value] of Object.entries(tags)) {
296
313
  validateTagKey(key, path);
297
- const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
298
- if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
299
- throw new Error(
300
- `${path}.${key}: set a preset modality (${Object.keys(VALID_MODALITIES).join("/")}) via the "modality" field, not tags \u2014 it is coupled to SOPClassUID and type-1 attributes`
314
+ validateTagValue(key, value, path);
315
+ }
316
+ }
317
+ function validateArrayOfLength(value, count, path) {
318
+ if (!Array.isArray(value)) {
319
+ throw new Error(`${path}: must be an array`);
320
+ }
321
+ if (value.length !== count) {
322
+ throw new Error(
323
+ `${path}: length (${value.length}) must equal count (${count})`
324
+ );
325
+ }
326
+ return value;
327
+ }
328
+ function validateInstances(instances, path) {
329
+ if (typeof instances !== "object" || instances === null || Array.isArray(instances)) {
330
+ throw new Error(`${path}: must be an object`);
331
+ }
332
+ const inst = instances;
333
+ validatePositiveInt(inst.count, `${path}.count`);
334
+ const count = inst.count;
335
+ const hasKb = "targetSizeKb" in inst;
336
+ const hasBytes = "targetBytes" in inst;
337
+ if (hasKb === hasBytes) {
338
+ throw new Error(
339
+ `${path}: exactly one of "targetSizeKb" or "targetBytes" is required`
340
+ );
341
+ }
342
+ if (hasKb) {
343
+ const sizes = validateArrayOfLength(
344
+ inst.targetSizeKb,
345
+ count,
346
+ `${path}.targetSizeKb`
347
+ );
348
+ sizes.forEach((kb, i) => {
349
+ validatePositiveInt(kb, `${path}.targetSizeKb[${i}]`);
350
+ validateSizeKbLimit(kb, `${path}.targetSizeKb[${i}]`);
351
+ });
352
+ } else {
353
+ const sizes = validateArrayOfLength(
354
+ inst.targetBytes,
355
+ count,
356
+ `${path}.targetBytes`
357
+ );
358
+ sizes.forEach((b, i) => {
359
+ validateLargeTargetBytes(b, `${path}.targetBytes[${i}]`);
360
+ });
361
+ }
362
+ if ("modality" in inst) {
363
+ if (Array.isArray(inst.modality)) {
364
+ validateArrayOfLength(inst.modality, count, `${path}.modality`).forEach(
365
+ (m, i) => {
366
+ validateEnum(m, VALID_MODALITIES, `${path}.modality[${i}]`);
367
+ }
301
368
  );
369
+ } else {
370
+ validateEnum(inst.modality, VALID_MODALITIES, `${path}.modality`);
302
371
  }
303
- if (typeof value === "string") {
304
- for (const name of findTemplateTokens(value)) {
305
- if (!TEMPLATE_VOCAB.includes(name)) {
306
- throw new Error(
307
- `${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
308
- );
372
+ }
373
+ if ("tags" in inst) {
374
+ const tags = inst.tags;
375
+ if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
376
+ throw new Error(`${path}.tags: must be an object`);
377
+ }
378
+ for (const [key, arr] of Object.entries(tags)) {
379
+ validateTagKey(key, `${path}.tags`);
380
+ validateArrayOfLength(arr, count, `${path}.tags.${key}`).forEach(
381
+ (value, i) => {
382
+ validateTagValue(key, value, `${path}.tags[${i}]`);
309
383
  }
310
- }
384
+ );
311
385
  }
312
386
  }
313
387
  }
@@ -316,16 +390,27 @@ function validateSeries(series, path) {
316
390
  throw new Error(`${path}: must be an object`);
317
391
  }
318
392
  const s = series;
319
- if (!Array.isArray(s.entries) || s.entries.length === 0) {
320
- throw new Error(`${path}.entries: must be a non-empty array`);
393
+ const hasEntries = "entries" in s;
394
+ const hasInstances = "instances" in s;
395
+ if (hasEntries === hasInstances) {
396
+ throw new Error(
397
+ `${path}: must have exactly one of "entries" or "instances"`
398
+ );
321
399
  }
322
- for (let i = 0; i < s.entries.length; i++) {
323
- const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
324
- if (!TYPE_CAPABILITIES[e.type].image) {
325
- throw new Error(
326
- `${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
327
- );
400
+ if (hasEntries) {
401
+ if (!Array.isArray(s.entries) || s.entries.length === 0) {
402
+ throw new Error(`${path}.entries: must be a non-empty array`);
403
+ }
404
+ for (let i = 0; i < s.entries.length; i++) {
405
+ const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
406
+ if (!TYPE_CAPABILITIES[e.type].image) {
407
+ throw new Error(
408
+ `${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
409
+ );
410
+ }
328
411
  }
412
+ } else {
413
+ validateInstances(s.instances, `${path}.instances`);
329
414
  }
330
415
  if ("tags" in s) validateTags(s.tags, `${path}.tags`);
331
416
  return s;
@@ -1033,12 +1118,41 @@ function filename(type, index, padWidth) {
1033
1118
  function entryCount(entries) {
1034
1119
  return entries.reduce((sum, e) => sum + (e.count ?? 1), 0);
1035
1120
  }
1121
+ function seriesFileCount(series) {
1122
+ return series.instances ? series.instances.count : entryCount(series.entries ?? []);
1123
+ }
1036
1124
  function studyFileCount(studies) {
1037
1125
  return studies.reduce(
1038
- (sum, study) => sum + (study.count ?? 1) * study.series.reduce((s, series) => s + entryCount(series.entries), 0),
1126
+ (sum, study) => sum + (study.count ?? 1) * study.series.reduce((s, series) => s + seriesFileCount(series), 0),
1039
1127
  0
1040
1128
  );
1041
1129
  }
1130
+ function* seriesFiles(series) {
1131
+ if (series.instances) {
1132
+ const inst = series.instances;
1133
+ for (let i = 0; i < inst.count; i++) {
1134
+ const modality = Array.isArray(inst.modality) ? inst.modality[i] : inst.modality;
1135
+ const baseSpec = {
1136
+ ...inst.targetBytes ? { type: "large-image", targetBytes: inst.targetBytes[i] } : { type: "valid-image", targetSizeKb: inst.targetSizeKb?.[i] },
1137
+ ...modality !== void 0 ? { modality } : {}
1138
+ };
1139
+ const instanceTags = {};
1140
+ if (inst.tags) {
1141
+ for (const [key, values] of Object.entries(inst.tags)) {
1142
+ instanceTags[key] = values[i];
1143
+ }
1144
+ }
1145
+ yield { baseSpec, instanceTags };
1146
+ }
1147
+ return;
1148
+ }
1149
+ for (const entry of series.entries ?? []) {
1150
+ const { count = 1, tags, ...baseSpec } = entry;
1151
+ for (let i = 0; i < count; i++) {
1152
+ yield { baseSpec, instanceTags: tags ?? {} };
1153
+ }
1154
+ }
1155
+ }
1042
1156
  function resolveUid(seed, index, override) {
1043
1157
  return { ...makeUidGenerator(seed)(index), ...override };
1044
1158
  }
@@ -1166,36 +1280,33 @@ function* planCollection(spec) {
1166
1280
  for (const [seriesIndex, series] of study.series.entries()) {
1167
1281
  const seriesUid = groupUids.series(studyOrdinal, seriesIndex);
1168
1282
  let instanceNumber = 0;
1169
- for (const entry of series.entries) {
1170
- const { count = 1, ...fileSpec } = entry;
1171
- for (let i = 0; i < count; i++) {
1172
- instanceNumber++;
1173
- const tags = {
1174
- InstanceNumber: instanceNumber,
1175
- ...study.tags,
1176
- ...series.tags,
1177
- ...fileSpec.tags
1178
- };
1179
- yield {
1180
- fileSpec: { ...fileSpec, tags },
1283
+ for (const { baseSpec, instanceTags } of seriesFiles(series)) {
1284
+ instanceNumber++;
1285
+ const tags = {
1286
+ InstanceNumber: instanceNumber,
1287
+ ...study.tags,
1288
+ ...series.tags,
1289
+ ...instanceTags
1290
+ };
1291
+ yield {
1292
+ fileSpec: { ...baseSpec, tags },
1293
+ index: globalIndex,
1294
+ uidOverride: { study: studyUid, series: seriesUid },
1295
+ context: {
1181
1296
  index: globalIndex,
1182
- uidOverride: { study: studyUid, series: seriesUid },
1183
- context: {
1184
- index: globalIndex,
1185
- studyIndex: studyOrdinal,
1186
- seriesIndex,
1187
- instanceNumber
1188
- },
1189
- ...computeNames(
1190
- fileSpec.type,
1191
- globalIndex,
1192
- padWidth,
1193
- hierarchical ? { studyOrdinal, seriesIndex, instanceNumber } : void 0,
1194
- quirks
1195
- )
1196
- };
1197
- globalIndex++;
1198
- }
1297
+ studyIndex: studyOrdinal,
1298
+ seriesIndex,
1299
+ instanceNumber
1300
+ },
1301
+ ...computeNames(
1302
+ baseSpec.type,
1303
+ globalIndex,
1304
+ padWidth,
1305
+ hierarchical ? { studyOrdinal, seriesIndex, instanceNumber } : void 0,
1306
+ quirks
1307
+ )
1308
+ };
1309
+ globalIndex++;
1199
1310
  }
1200
1311
  }
1201
1312
  studyOrdinal++;
@@ -1444,8 +1555,117 @@ function extractTags(record, keepKeywords) {
1444
1555
  }
1445
1556
  return found ? tags : void 0;
1446
1557
  }
1558
+ function sizeKbOf(bytes) {
1559
+ return Math.max(1, Math.round(bytes / 1024));
1560
+ }
1561
+ function bucketBytes(bytes, tolerance) {
1562
+ if (tolerance <= 0 || bytes < 1) return bytes;
1563
+ const factor = 1 + tolerance;
1564
+ const bucket = Math.floor(Math.log(bytes) / Math.log(factor));
1565
+ return Math.max(1, Math.round(factor ** (bucket + 0.5)));
1566
+ }
1567
+ function collapseKeyOf(r) {
1568
+ return JSON.stringify([
1569
+ r.large,
1570
+ r.modality ?? null,
1571
+ r.large ? r.bytes : sizeKbOf(r.bytes),
1572
+ r.tags ?? null
1573
+ ]);
1574
+ }
1575
+ function accumulate(byKey, r) {
1576
+ const key = collapseKeyOf(r);
1577
+ const existing = byKey.get(key);
1578
+ if (existing) {
1579
+ existing.count++;
1580
+ } else {
1581
+ byKey.set(key, {
1582
+ modality: r.modality,
1583
+ large: r.large,
1584
+ bytes: r.bytes,
1585
+ tags: r.tags,
1586
+ count: 1
1587
+ });
1588
+ }
1589
+ }
1590
+ function entryOf(acc) {
1591
+ return {
1592
+ ...acc.modality !== void 0 ? { modality: acc.modality } : {},
1593
+ ...acc.large ? { type: "large-image", targetBytes: acc.bytes } : { type: "valid-image", targetSizeKb: sizeKbOf(acc.bytes) },
1594
+ ...acc.tags !== void 0 ? { tags: acc.tags } : {},
1595
+ ...acc.count > 1 ? { count: acc.count } : {}
1596
+ };
1597
+ }
1598
+ function collapseEntries(records) {
1599
+ const byKey = /* @__PURE__ */ new Map();
1600
+ for (const r of records) accumulate(byKey, r);
1601
+ return [...byKey.values()].map(entryOf);
1602
+ }
1603
+ function tagValuesEqual(a, b) {
1604
+ if (a === b) return true;
1605
+ if (typeof a === "object" && a !== null && typeof b === "object" && b !== null)
1606
+ return JSON.stringify(a) === JSON.stringify(b);
1607
+ return false;
1608
+ }
1609
+ function tryInstancesForm(records) {
1610
+ const count = records.length;
1611
+ if (count === 0) return null;
1612
+ const large = records[0]?.large ?? false;
1613
+ const modality = records[0]?.modality;
1614
+ const columns = /* @__PURE__ */ new Map();
1615
+ for (let i = 0; i < count; i++) {
1616
+ const r = records[i];
1617
+ if (r.large !== large || r.modality !== modality) return null;
1618
+ if (!r.tags) continue;
1619
+ for (const [k, v] of Object.entries(r.tags)) {
1620
+ let col = columns.get(k);
1621
+ if (!col) {
1622
+ col = { values: new Array(count), present: 0 };
1623
+ columns.set(k, col);
1624
+ }
1625
+ col.values[i] = v;
1626
+ col.present++;
1627
+ }
1628
+ }
1629
+ if (columns.size === 0) return null;
1630
+ const sharedTags = {};
1631
+ const perInstanceTags = {};
1632
+ for (const [k, col] of columns) {
1633
+ if (col.present !== count) return null;
1634
+ const first = col.values[0];
1635
+ if (col.values.every((v) => tagValuesEqual(v, first))) {
1636
+ sharedTags[k] = first;
1637
+ } else {
1638
+ perInstanceTags[k] = col.values;
1639
+ }
1640
+ }
1641
+ if (Object.keys(perInstanceTags).length === 0) return null;
1642
+ const instances = {
1643
+ count,
1644
+ ...large ? { targetBytes: records.map((r) => r.bytes) } : { targetSizeKb: records.map((r) => sizeKbOf(r.bytes)) },
1645
+ ...modality !== void 0 ? { modality } : {},
1646
+ // perInstanceTags is non-empty here (we returned null above otherwise).
1647
+ tags: perInstanceTags
1648
+ };
1649
+ return {
1650
+ instances,
1651
+ ...Object.keys(sharedTags).length ? { tags: sharedTags } : {}
1652
+ };
1653
+ }
1654
+ function buildSeriesSpec(records) {
1655
+ return tryInstancesForm(records) ?? { entries: collapseEntries(records) };
1656
+ }
1657
+ function seriesSpecFromAccum(accum) {
1658
+ return Array.isArray(accum) ? buildSeriesSpec(accum) : { entries: [...accum.values()].map(entryOf) };
1659
+ }
1447
1660
  function describeDirectory(dir, options = {}) {
1448
1661
  const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
1662
+ const keepTags = keepKeywords.length > 0;
1663
+ const tolerance = options.sizeTolerance ?? 0;
1664
+ if (!Number.isFinite(tolerance) || tolerance < 0) {
1665
+ throw new Error(
1666
+ `describe: sizeTolerance must be a non-negative fraction; got ${JSON.stringify(options.sizeTolerance)}`
1667
+ );
1668
+ }
1449
1669
  const studies = /* @__PURE__ */ new Map();
1450
1670
  const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
1451
1671
  for (const path of [...walkFiles(dir)].sort()) {
@@ -1477,8 +1697,9 @@ function describeDirectory(dir, options = {}) {
1477
1697
  stats.unknownModality++;
1478
1698
  }
1479
1699
  }
1480
- const sizeKb = Math.max(1, Math.round(size / 1024));
1481
- const tags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
1700
+ const tags = keepTags ? extractTags(record, keepKeywords) : void 0;
1701
+ const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
1702
+ const fileRecord = { large, bytes, modality, tags };
1482
1703
  let study = studies.get(studyUid);
1483
1704
  if (!study) {
1484
1705
  study = /* @__PURE__ */ new Map();
@@ -1486,37 +1707,18 @@ function describeDirectory(dir, options = {}) {
1486
1707
  }
1487
1708
  let series = study.get(seriesUid);
1488
1709
  if (!series) {
1489
- series = /* @__PURE__ */ new Map();
1710
+ series = keepTags ? [] : /* @__PURE__ */ new Map();
1490
1711
  study.set(seriesUid, series);
1491
1712
  }
1492
- const collapseKey = JSON.stringify([
1493
- large,
1494
- modality ?? null,
1495
- large ? size : sizeKb,
1496
- tags ?? null
1497
- ]);
1498
- const existing = series.get(collapseKey);
1499
- if (existing) {
1500
- existing.count++;
1713
+ if (Array.isArray(series)) {
1714
+ series.push(fileRecord);
1501
1715
  } else {
1502
- series.set(collapseKey, { modality, large, bytes: size, tags, count: 1 });
1716
+ accumulate(series, fileRecord);
1503
1717
  }
1504
1718
  }
1505
- const studySpecs = [...studies.values()].map((study) => {
1506
- const seriesSpecs = [...study.values()].map((series) => {
1507
- const entries = [...series.values()].map((acc) => ({
1508
- ...acc.modality !== void 0 ? { modality: acc.modality } : {},
1509
- ...acc.large ? { type: "large-image", targetBytes: acc.bytes } : {
1510
- type: "valid-image",
1511
- targetSizeKb: Math.max(1, Math.round(acc.bytes / 1024))
1512
- },
1513
- ...acc.tags !== void 0 ? { tags: acc.tags } : {},
1514
- ...acc.count > 1 ? { count: acc.count } : {}
1515
- }));
1516
- return { entries };
1517
- });
1518
- return { series: seriesSpecs };
1519
- });
1719
+ const studySpecs = [...studies.values()].map((study) => ({
1720
+ series: [...study.values()].map(seriesSpecFromAccum)
1721
+ }));
1520
1722
  if (studySpecs.length === 0) {
1521
1723
  throw new Error(`describe: no DICOM series found in ${dir}`);
1522
1724
  }
@@ -224,27 +224,30 @@ function validateEntry(entry, path) {
224
224
  return e;
225
225
  }
226
226
  var MODALITY_TAG = "00080060";
227
+ function validateTagValue(key, value, path) {
228
+ const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
229
+ if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
230
+ throw new Error(
231
+ `${path}.${key}: set a preset modality (${Object.keys(VALID_MODALITIES).join("/")}) via the "modality" field, not tags \u2014 it is coupled to SOPClassUID and type-1 attributes`
232
+ );
233
+ }
234
+ if (typeof value === "string") {
235
+ for (const name of findTemplateTokens(value)) {
236
+ if (!TEMPLATE_VOCAB.includes(name)) {
237
+ throw new Error(
238
+ `${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
239
+ );
240
+ }
241
+ }
242
+ }
243
+ }
227
244
  function validateTags(tags, path) {
228
245
  if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
229
246
  throw new Error(`${path}: must be an object`);
230
247
  }
231
248
  for (const [key, value] of Object.entries(tags)) {
232
249
  validateTagKey(key, path);
233
- const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
234
- if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
235
- throw new Error(
236
- `${path}.${key}: set a preset modality (${Object.keys(VALID_MODALITIES).join("/")}) via the "modality" field, not tags \u2014 it is coupled to SOPClassUID and type-1 attributes`
237
- );
238
- }
239
- if (typeof value === "string") {
240
- for (const name of findTemplateTokens(value)) {
241
- if (!TEMPLATE_VOCAB.includes(name)) {
242
- throw new Error(
243
- `${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
244
- );
245
- }
246
- }
247
- }
250
+ validateTagValue(key, value, path);
248
251
  }
249
252
  }
250
253
  function validateRange(value, path, options = {}) {
@@ -213,26 +213,100 @@ function validateEntry(entry, path) {
213
213
  return e;
214
214
  }
215
215
  var MODALITY_TAG = "00080060";
216
+ function validateTagValue(key, value, path) {
217
+ const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
218
+ if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
219
+ throw new Error(
220
+ `${path}.${key}: set a preset modality (${Object.keys(VALID_MODALITIES).join("/")}) via the "modality" field, not tags \u2014 it is coupled to SOPClassUID and type-1 attributes`
221
+ );
222
+ }
223
+ if (typeof value === "string") {
224
+ for (const name of findTemplateTokens(value)) {
225
+ if (!TEMPLATE_VOCAB.includes(name)) {
226
+ throw new Error(
227
+ `${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
228
+ );
229
+ }
230
+ }
231
+ }
232
+ }
216
233
  function validateTags(tags, path) {
217
234
  if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
218
235
  throw new Error(`${path}: must be an object`);
219
236
  }
220
237
  for (const [key, value] of Object.entries(tags)) {
221
238
  validateTagKey(key, path);
222
- const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
223
- if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
224
- throw new Error(
225
- `${path}.${key}: set a preset modality (${Object.keys(VALID_MODALITIES).join("/")}) via the "modality" field, not tags \u2014 it is coupled to SOPClassUID and type-1 attributes`
239
+ validateTagValue(key, value, path);
240
+ }
241
+ }
242
+ function validateArrayOfLength(value, count, path) {
243
+ if (!Array.isArray(value)) {
244
+ throw new Error(`${path}: must be an array`);
245
+ }
246
+ if (value.length !== count) {
247
+ throw new Error(
248
+ `${path}: length (${value.length}) must equal count (${count})`
249
+ );
250
+ }
251
+ return value;
252
+ }
253
+ function validateInstances(instances, path) {
254
+ if (typeof instances !== "object" || instances === null || Array.isArray(instances)) {
255
+ throw new Error(`${path}: must be an object`);
256
+ }
257
+ const inst = instances;
258
+ validatePositiveInt(inst.count, `${path}.count`);
259
+ const count = inst.count;
260
+ const hasKb = "targetSizeKb" in inst;
261
+ const hasBytes = "targetBytes" in inst;
262
+ if (hasKb === hasBytes) {
263
+ throw new Error(
264
+ `${path}: exactly one of "targetSizeKb" or "targetBytes" is required`
265
+ );
266
+ }
267
+ if (hasKb) {
268
+ const sizes = validateArrayOfLength(
269
+ inst.targetSizeKb,
270
+ count,
271
+ `${path}.targetSizeKb`
272
+ );
273
+ sizes.forEach((kb, i) => {
274
+ validatePositiveInt(kb, `${path}.targetSizeKb[${i}]`);
275
+ validateSizeKbLimit(kb, `${path}.targetSizeKb[${i}]`);
276
+ });
277
+ } else {
278
+ const sizes = validateArrayOfLength(
279
+ inst.targetBytes,
280
+ count,
281
+ `${path}.targetBytes`
282
+ );
283
+ sizes.forEach((b, i) => {
284
+ validateLargeTargetBytes(b, `${path}.targetBytes[${i}]`);
285
+ });
286
+ }
287
+ if ("modality" in inst) {
288
+ if (Array.isArray(inst.modality)) {
289
+ validateArrayOfLength(inst.modality, count, `${path}.modality`).forEach(
290
+ (m, i) => {
291
+ validateEnum(m, VALID_MODALITIES, `${path}.modality[${i}]`);
292
+ }
226
293
  );
294
+ } else {
295
+ validateEnum(inst.modality, VALID_MODALITIES, `${path}.modality`);
296
+ }
297
+ }
298
+ if ("tags" in inst) {
299
+ const tags = inst.tags;
300
+ if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
301
+ throw new Error(`${path}.tags: must be an object`);
227
302
  }
228
- if (typeof value === "string") {
229
- for (const name of findTemplateTokens(value)) {
230
- if (!TEMPLATE_VOCAB.includes(name)) {
231
- throw new Error(
232
- `${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
233
- );
303
+ for (const [key, arr] of Object.entries(tags)) {
304
+ validateTagKey(key, `${path}.tags`);
305
+ validateArrayOfLength(arr, count, `${path}.tags.${key}`).forEach(
306
+ (value, i) => {
307
+ validateTagValue(key, value, `${path}.tags[${i}]`);
234
308
  }
235
- }
309
+ );
236
310
  }
237
311
  }
238
312
  }
@@ -241,16 +315,27 @@ function validateSeries(series, path) {
241
315
  throw new Error(`${path}: must be an object`);
242
316
  }
243
317
  const s = series;
244
- if (!Array.isArray(s.entries) || s.entries.length === 0) {
245
- throw new Error(`${path}.entries: must be a non-empty array`);
318
+ const hasEntries = "entries" in s;
319
+ const hasInstances = "instances" in s;
320
+ if (hasEntries === hasInstances) {
321
+ throw new Error(
322
+ `${path}: must have exactly one of "entries" or "instances"`
323
+ );
246
324
  }
247
- for (let i = 0; i < s.entries.length; i++) {
248
- const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
249
- if (!TYPE_CAPABILITIES[e.type].image) {
250
- throw new Error(
251
- `${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
252
- );
325
+ if (hasEntries) {
326
+ if (!Array.isArray(s.entries) || s.entries.length === 0) {
327
+ throw new Error(`${path}.entries: must be a non-empty array`);
328
+ }
329
+ for (let i = 0; i < s.entries.length; i++) {
330
+ const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
331
+ if (!TYPE_CAPABILITIES[e.type].image) {
332
+ throw new Error(
333
+ `${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
334
+ );
335
+ }
253
336
  }
337
+ } else {
338
+ validateInstances(s.instances, `${path}.instances`);
254
339
  }
255
340
  if ("tags" in s) validateTags(s.tags, `${path}.tags`);
256
341
  return s;
@@ -1,6 +1,7 @@
1
1
  import type { DatasetSpec } from '../schema/types.js';
2
2
  export type DescribeOptions = {
3
3
  keepTags?: string[];
4
+ sizeTolerance?: number;
4
5
  };
5
6
  export type DescribeResult = {
6
7
  spec: DatasetSpec;
@@ -11,4 +12,5 @@ export type DescribeResult = {
11
12
  };
12
13
  };
13
14
  export declare function readHeaderOnly(path: string): Record<string, unknown> | null;
15
+ export declare function tagValuesEqual(a: unknown, b: unknown): boolean;
14
16
  export declare function describeDirectory(dir: string, options?: DescribeOptions): DescribeResult;
@@ -4,7 +4,7 @@ export { defaultPublicCasesPath, loadCaseById, loadCasesFromJson, loadDefaultCas
4
4
  export { caseCachePath, fetchPublicCaseToCache, verifySha256, } from './public-fixtures/fetch.js';
5
5
  export { MAX_PIXEL_BYTES, MAX_STREAM_BYTES } from './schema/limits.js';
6
6
  export { resolveParametricSpec } from './schema/parametric.js';
7
- export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidImageSpec, LargeFileSpec, Modality, NonDicomSpec, ParametricEdgeCase, ParametricSpec, ParametricStudies, PathQuirk, Range, SeriesEntrySpec, SeriesSpec, StudySpec, TransferSyntax, ValidImageSpec, VendorWarningsImageSpec, ViolationClass, } from './schema/types.js';
7
+ export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidImageSpec, LargeFileSpec, Modality, NonDicomSpec, ParametricEdgeCase, ParametricSpec, ParametricStudies, PathQuirk, Range, SeriesEntrySpec, SeriesInstances, SeriesSpec, StudySpec, TransferSyntax, ValidImageSpec, VendorWarningsImageSpec, ViolationClass, } from './schema/types.js';
8
8
  export { validateDatasetSpec, validateParametricSpec, } from './schema/validate.js';
9
9
  export { type LargeFileEncoding, type LargeFileResult, type LargeImageSpec, type StreamLargeImageOptions, streamLargeImage, writeLargeImageFile, } from './syntheticFixtures/streamWrite.js';
10
10
  export { resolveTagTemplates, type TagContext, TEMPLATE_VOCAB, } from './syntheticFixtures/tagTemplate.js';
@@ -51,8 +51,16 @@ export type EntrySpec = FileSpec & {
51
51
  export type SeriesEntrySpec = (ImageSpec | LargeFileSpec) & {
52
52
  count?: number;
53
53
  };
54
+ export type SeriesInstances = {
55
+ count: number;
56
+ targetSizeKb?: number[];
57
+ targetBytes?: number[];
58
+ modality?: Modality | Modality[];
59
+ tags?: Record<string, unknown[]>;
60
+ };
54
61
  export type SeriesSpec = {
55
- entries: SeriesEntrySpec[];
62
+ entries?: SeriesEntrySpec[];
63
+ instances?: SeriesInstances;
56
64
  tags?: DicomTagOverrides;
57
65
  };
58
66
  export type StudySpec = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dicom-synth",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "Toolkit for synthetic DICOM fixtures and public fixture fetch/cache.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",