dicom-synth 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { writeFileSync } from 'node:fs'
2
+ import { readFileSync, writeFileSync } from 'node:fs'
3
3
  import { resolve } from 'node:path'
4
4
  import { describeDirectory } from '../dist/esm/index.js'
5
5
 
6
6
  function usage() {
7
7
  console.error(
8
- 'Usage: dicom-synth-describe <dir> [--out <spec.json>] [--keep-tags <a,b,...>]\n' +
8
+ 'Usage: dicom-synth-describe <dir> [--out <spec.json>] [--keep-tags <a,b,...>] [--keep-tags-file <path>]\n' +
9
9
  '\n' +
10
10
  'Scans a DICOM tree and emits a DatasetSpec describing its shape\n' +
11
11
  '(studies/series, per-file size, modality).\n' +
@@ -13,26 +13,45 @@ function usage() {
13
13
  'By default only the shape is reproduced — no tags are copied.\n' +
14
14
  '--keep-tags copies the named tags (DICOM keywords or 8-hex tags)\n' +
15
15
  "verbatim; if a kept tag holds PHI, handling it is the caller's\n" +
16
- "responsibility, not this tool's.",
16
+ "responsibility, not this tool's.\n" +
17
+ '--keep-tags-file reads the same allow-list from a file (one tag per\n' +
18
+ 'line, # comments); merged with any --keep-tags.',
17
19
  )
18
20
  process.exit(1)
19
21
  }
20
22
 
23
+ // Parse a keep-tags file: one tag per line, blank lines and # comments ignored.
24
+ function parseKeepTagsFile(path) {
25
+ return readFileSync(path, 'utf8')
26
+ .split(/\r?\n/)
27
+ .map((line) => line.replace(/#.*$/, '').trim())
28
+ .filter(Boolean)
29
+ }
30
+
21
31
  const args = process.argv.slice(2)
22
32
  if (args.length === 0) usage()
23
33
 
24
34
  let dir
25
35
  let outPath
26
- let keepTags
36
+ const keepTags = []
27
37
 
28
38
  for (let i = 0; i < args.length; i++) {
29
39
  if (args[i] === '--out' && args[i + 1]) {
30
40
  outPath = resolve(args[++i])
31
41
  } else if (args[i] === '--keep-tags' && args[i + 1]) {
32
- keepTags = args[++i]
33
- .split(',')
34
- .map((t) => t.trim())
35
- .filter(Boolean)
42
+ keepTags.push(
43
+ ...args[++i]
44
+ .split(',')
45
+ .map((t) => t.trim())
46
+ .filter(Boolean),
47
+ )
48
+ } else if (args[i] === '--keep-tags-file' && args[i + 1]) {
49
+ try {
50
+ keepTags.push(...parseKeepTagsFile(resolve(args[++i])))
51
+ } catch (err) {
52
+ console.error(`Error reading keep-tags file: ${err.message}`)
53
+ process.exit(1)
54
+ }
36
55
  } else if (args[i].startsWith('--')) {
37
56
  console.error(`Unknown argument: ${args[i]}`)
38
57
  usage()
@@ -48,7 +67,7 @@ if (dir === undefined) usage()
48
67
 
49
68
  let result
50
69
  try {
51
- result = describeDirectory(dir, keepTags ? { keepTags } : {})
70
+ result = describeDirectory(dir, keepTags.length ? { keepTags } : {})
52
71
  } catch (err) {
53
72
  console.error(`Error: ${err.message}`)
54
73
  process.exit(1)
@@ -49,6 +49,40 @@ function buildDicomdirBuffer() {
49
49
  return buf.subarray(0, off);
50
50
  }
51
51
 
52
+ // src/syntheticFixtures/tagTemplate.ts
53
+ var TEMPLATE_VOCAB = [
54
+ "index",
55
+ "studyIndex",
56
+ "seriesIndex",
57
+ "instanceNumber"
58
+ ];
59
+ var TEMPLATE_PART_RE = /\{\{|\}\}|\{([a-zA-Z]+)\}/g;
60
+ function resolveTagTemplates(tags, context) {
61
+ if (!tags) return tags;
62
+ const resolved = {};
63
+ for (const [key, value] of Object.entries(tags)) {
64
+ resolved[key] = typeof value === "string" ? resolveString(value, context) : value;
65
+ }
66
+ return resolved;
67
+ }
68
+ function resolveString(value, context) {
69
+ return value.replace(TEMPLATE_PART_RE, (match, name) => {
70
+ if (name === void 0) return match === "{{" ? "{" : "}";
71
+ if (!TEMPLATE_VOCAB.includes(name)) {
72
+ throw new Error(
73
+ `tag template: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
74
+ );
75
+ }
76
+ const resolved = context[name];
77
+ if (resolved === void 0) {
78
+ throw new Error(
79
+ `tag template: placeholder "{${name}}" is not available here \u2014 it only applies inside grouped studies`
80
+ );
81
+ }
82
+ return String(resolved);
83
+ });
84
+ }
85
+
52
86
  // src/schema/validate.ts
53
87
  var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
54
88
 
@@ -566,7 +600,11 @@ async function generateFile(spec, options) {
566
600
  const index = options?.index ?? 0;
567
601
  const padWidth = options?.padWidth ?? 3;
568
602
  const uid = resolveUid(options?.seed, index, options?.uid);
569
- let buffer = buildBufferForSpec(spec, uid);
603
+ const resolvedSpec = "tags" in spec && spec.tags ? {
604
+ ...spec,
605
+ tags: resolveTagTemplates(spec.tags, { index, ...options?.context })
606
+ } : spec;
607
+ let buffer = buildBufferForSpec(resolvedSpec, uid);
570
608
  const violations = "violations" in spec ? spec.violations : void 0;
571
609
  if (violations?.length) {
572
610
  buffer = applyViolations(buffer, violations);
@@ -661,6 +699,7 @@ function* planCollection(spec) {
661
699
  yield {
662
700
  fileSpec,
663
701
  index: globalIndex,
702
+ context: { index: globalIndex },
664
703
  ...computeNames(
665
704
  fileSpec.type,
666
705
  globalIndex,
@@ -695,6 +734,12 @@ function* planCollection(spec) {
695
734
  fileSpec: { ...fileSpec, tags },
696
735
  index: globalIndex,
697
736
  uidOverride: { study: studyUid, series: seriesUid },
737
+ context: {
738
+ index: globalIndex,
739
+ studyIndex: studyOrdinal,
740
+ seriesIndex,
741
+ instanceNumber
742
+ },
698
743
  ...computeNames(
699
744
  fileSpec.type,
700
745
  globalIndex,
@@ -715,7 +760,8 @@ async function materialisePlan(plan, seed) {
715
760
  const file = await generateFile(plan.fileSpec, {
716
761
  index: plan.index,
717
762
  seed,
718
- uid: plan.uidOverride
763
+ uid: plan.uidOverride,
764
+ context: plan.context
719
765
  });
720
766
  return { ...file, filename: plan.filename, relativePath: plan.relativePath };
721
767
  }
@@ -771,7 +817,7 @@ async function writeCollectionFromSpec(spec, outDir) {
771
817
  targetBytes: large.targetBytes,
772
818
  modality: large.modality,
773
819
  uid,
774
- tags: large.tags
820
+ tags: resolveTagTemplates(large.tags, plan.context)
775
821
  });
776
822
  } else {
777
823
  const file = await materialisePlan(plan, spec.seed);
@@ -32,6 +32,25 @@ var dcmjs = loadDcmjs();
32
32
  var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
33
33
  var MAX_STREAM_BYTES = 50 * 1024 * 1024 * 1024;
34
34
 
35
+ // src/syntheticFixtures/tagTemplate.ts
36
+ var TEMPLATE_VOCAB = [
37
+ "index",
38
+ "studyIndex",
39
+ "seriesIndex",
40
+ "instanceNumber"
41
+ ];
42
+ var TEMPLATE_PART_RE = /\{\{|\}\}|\{([a-zA-Z]+)\}/g;
43
+ function escapeTagTemplate(value) {
44
+ return value.replace(/\{/g, "{{").replace(/\}/g, "}}");
45
+ }
46
+ function findTemplateTokens(value) {
47
+ const names = [];
48
+ for (const m of value.matchAll(TEMPLATE_PART_RE)) {
49
+ if (m[1] !== void 0) names.push(m[1]);
50
+ }
51
+ return names;
52
+ }
53
+
35
54
  // src/schema/validate.ts
36
55
  var TYPE_CAPABILITIES = {
37
56
  "valid-image": { image: true },
@@ -219,12 +238,28 @@ function validateEntry(entry, path) {
219
238
  }
220
239
  return e;
221
240
  }
241
+ var MODALITY_TAG = "00080060";
222
242
  function validateTags(tags, path) {
223
243
  if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
224
244
  throw new Error(`${path}: must be an object`);
225
245
  }
226
- for (const key of Object.keys(tags)) {
246
+ for (const [key, value] of Object.entries(tags)) {
227
247
  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`
252
+ );
253
+ }
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
+ );
260
+ }
261
+ }
262
+ }
228
263
  }
229
264
  }
230
265
  function validateSeries(series, path) {
@@ -310,6 +345,13 @@ function validateDatasetSpec(raw) {
310
345
  var dcmjsAny = dcmjs;
311
346
  var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
312
347
  var SUPPORTED_MODALITIES = /* @__PURE__ */ new Set(["CT", "PT", "MR", "CR"]);
348
+ var RESERVED_KEEP_KEYWORDS = /* @__PURE__ */ new Set([
349
+ "Units",
350
+ "DecayCorrection",
351
+ "CorrectedImage",
352
+ "ScanningSequence",
353
+ "SequenceVariant"
354
+ ]);
313
355
  function buildHexToKeyword() {
314
356
  const map = /* @__PURE__ */ new Map();
315
357
  const nm = dcmjsAny.data.DicomMetaDictionary.nameMap;
@@ -325,20 +367,37 @@ function buildHexToKeyword() {
325
367
  function resolveKeepTags(keepTags) {
326
368
  const nameMap = dcmjsAny.data.DicomMetaDictionary.nameMap;
327
369
  let hexToKeyword;
328
- return keepTags.map((tag) => {
370
+ const kept = [];
371
+ for (const tag of keepTags) {
372
+ let keyword;
329
373
  if (HEX_TAG_RE2.test(tag)) {
330
374
  hexToKeyword ?? (hexToKeyword = buildHexToKeyword());
331
- const keyword = hexToKeyword.get(tag.toUpperCase());
332
- if (!keyword) {
375
+ const mapped = hexToKeyword.get(tag.toUpperCase());
376
+ if (!mapped) {
333
377
  throw new Error(`describe: unknown hex tag "${tag}"`);
334
378
  }
335
- return keyword;
379
+ keyword = mapped;
380
+ } else {
381
+ if (nameMap && !Object.hasOwn(nameMap, tag)) {
382
+ throw new Error(`describe: unknown tag keyword "${tag}"`);
383
+ }
384
+ keyword = tag;
336
385
  }
337
- if (nameMap && !Object.hasOwn(nameMap, tag)) {
338
- throw new Error(`describe: unknown tag keyword "${tag}"`);
386
+ if (RESERVED_KEEP_KEYWORDS.has(keyword)) {
387
+ console.warn(
388
+ `describe: ignoring keep-tag "${keyword}" \u2014 modality-coupled tags are reproduced from the modality field`
389
+ );
390
+ continue;
339
391
  }
340
- return tag;
341
- });
392
+ if (nameMap?.[keyword]?.vr === "UI") {
393
+ console.warn(
394
+ `describe: ignoring keep-tag "${keyword}" \u2014 UID-valued tags are not kept verbatim`
395
+ );
396
+ continue;
397
+ }
398
+ kept.push(keyword);
399
+ }
400
+ return kept;
342
401
  }
343
402
  function* walkFiles(dir) {
344
403
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -430,10 +489,12 @@ function extractTags(record, keepKeywords) {
430
489
  let found = false;
431
490
  for (const keyword of keepKeywords) {
432
491
  const value = record[keyword];
433
- if (value !== void 0) {
434
- tags[keyword] = value;
435
- found = true;
492
+ if (value === void 0) continue;
493
+ if (keyword === "Modality" && typeof value === "string" && SUPPORTED_MODALITIES.has(value)) {
494
+ continue;
436
495
  }
496
+ tags[keyword] = typeof value === "string" ? escapeTagTemplate(value) : value;
497
+ found = true;
437
498
  }
438
499
  return found ? tags : void 0;
439
500
  }
package/dist/esm/index.js CHANGED
@@ -49,6 +49,50 @@ function buildDicomdirBuffer() {
49
49
  return buf.subarray(0, off);
50
50
  }
51
51
 
52
+ // src/syntheticFixtures/tagTemplate.ts
53
+ var TEMPLATE_VOCAB = [
54
+ "index",
55
+ "studyIndex",
56
+ "seriesIndex",
57
+ "instanceNumber"
58
+ ];
59
+ var TEMPLATE_PART_RE = /\{\{|\}\}|\{([a-zA-Z]+)\}/g;
60
+ function escapeTagTemplate(value) {
61
+ return value.replace(/\{/g, "{{").replace(/\}/g, "}}");
62
+ }
63
+ function findTemplateTokens(value) {
64
+ const names = [];
65
+ for (const m of value.matchAll(TEMPLATE_PART_RE)) {
66
+ if (m[1] !== void 0) names.push(m[1]);
67
+ }
68
+ return names;
69
+ }
70
+ function resolveTagTemplates(tags, context) {
71
+ if (!tags) return tags;
72
+ const resolved = {};
73
+ for (const [key, value] of Object.entries(tags)) {
74
+ resolved[key] = typeof value === "string" ? resolveString(value, context) : value;
75
+ }
76
+ return resolved;
77
+ }
78
+ function resolveString(value, context) {
79
+ return value.replace(TEMPLATE_PART_RE, (match, name) => {
80
+ if (name === void 0) return match === "{{" ? "{" : "}";
81
+ if (!TEMPLATE_VOCAB.includes(name)) {
82
+ throw new Error(
83
+ `tag template: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
84
+ );
85
+ }
86
+ const resolved = context[name];
87
+ if (resolved === void 0) {
88
+ throw new Error(
89
+ `tag template: placeholder "{${name}}" is not available here \u2014 it only applies inside grouped studies`
90
+ );
91
+ }
92
+ return String(resolved);
93
+ });
94
+ }
95
+
52
96
  // src/schema/validate.ts
53
97
  var TYPE_CAPABILITIES = {
54
98
  "valid-image": { image: true },
@@ -243,12 +287,28 @@ function validateEntry(entry, path) {
243
287
  }
244
288
  return e;
245
289
  }
290
+ var MODALITY_TAG = "00080060";
246
291
  function validateTags(tags, path) {
247
292
  if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
248
293
  throw new Error(`${path}: must be an object`);
249
294
  }
250
- for (const key of Object.keys(tags)) {
295
+ for (const [key, value] of Object.entries(tags)) {
251
296
  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`
301
+ );
302
+ }
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
+ );
309
+ }
310
+ }
311
+ }
252
312
  }
253
313
  }
254
314
  function validateSeries(series, path) {
@@ -986,7 +1046,11 @@ async function generateFile(spec, options) {
986
1046
  const index = options?.index ?? 0;
987
1047
  const padWidth = options?.padWidth ?? 3;
988
1048
  const uid = resolveUid(options?.seed, index, options?.uid);
989
- let buffer = buildBufferForSpec(spec, uid);
1049
+ const resolvedSpec = "tags" in spec && spec.tags ? {
1050
+ ...spec,
1051
+ tags: resolveTagTemplates(spec.tags, { index, ...options?.context })
1052
+ } : spec;
1053
+ let buffer = buildBufferForSpec(resolvedSpec, uid);
990
1054
  const violations = "violations" in spec ? spec.violations : void 0;
991
1055
  if (violations?.length) {
992
1056
  buffer = applyViolations(buffer, violations);
@@ -1081,6 +1145,7 @@ function* planCollection(spec) {
1081
1145
  yield {
1082
1146
  fileSpec,
1083
1147
  index: globalIndex,
1148
+ context: { index: globalIndex },
1084
1149
  ...computeNames(
1085
1150
  fileSpec.type,
1086
1151
  globalIndex,
@@ -1115,6 +1180,12 @@ function* planCollection(spec) {
1115
1180
  fileSpec: { ...fileSpec, tags },
1116
1181
  index: globalIndex,
1117
1182
  uidOverride: { study: studyUid, series: seriesUid },
1183
+ context: {
1184
+ index: globalIndex,
1185
+ studyIndex: studyOrdinal,
1186
+ seriesIndex,
1187
+ instanceNumber
1188
+ },
1118
1189
  ...computeNames(
1119
1190
  fileSpec.type,
1120
1191
  globalIndex,
@@ -1135,7 +1206,8 @@ async function materialisePlan(plan, seed) {
1135
1206
  const file = await generateFile(plan.fileSpec, {
1136
1207
  index: plan.index,
1137
1208
  seed,
1138
- uid: plan.uidOverride
1209
+ uid: plan.uidOverride,
1210
+ context: plan.context
1139
1211
  });
1140
1212
  return { ...file, filename: plan.filename, relativePath: plan.relativePath };
1141
1213
  }
@@ -1191,7 +1263,7 @@ async function writeCollectionFromSpec(spec, outDir) {
1191
1263
  targetBytes: large.targetBytes,
1192
1264
  modality: large.modality,
1193
1265
  uid,
1194
- tags: large.tags
1266
+ tags: resolveTagTemplates(large.tags, plan.context)
1195
1267
  });
1196
1268
  } else {
1197
1269
  const file = await materialisePlan(plan, spec.seed);
@@ -1219,6 +1291,13 @@ import { join } from "node:path";
1219
1291
  var dcmjsAny2 = dcmjs;
1220
1292
  var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
1221
1293
  var SUPPORTED_MODALITIES = /* @__PURE__ */ new Set(["CT", "PT", "MR", "CR"]);
1294
+ var RESERVED_KEEP_KEYWORDS = /* @__PURE__ */ new Set([
1295
+ "Units",
1296
+ "DecayCorrection",
1297
+ "CorrectedImage",
1298
+ "ScanningSequence",
1299
+ "SequenceVariant"
1300
+ ]);
1222
1301
  function buildHexToKeyword() {
1223
1302
  const map = /* @__PURE__ */ new Map();
1224
1303
  const nm = dcmjsAny2.data.DicomMetaDictionary.nameMap;
@@ -1234,20 +1313,37 @@ function buildHexToKeyword() {
1234
1313
  function resolveKeepTags(keepTags) {
1235
1314
  const nameMap = dcmjsAny2.data.DicomMetaDictionary.nameMap;
1236
1315
  let hexToKeyword;
1237
- return keepTags.map((tag) => {
1316
+ const kept = [];
1317
+ for (const tag of keepTags) {
1318
+ let keyword;
1238
1319
  if (HEX_TAG_RE2.test(tag)) {
1239
1320
  hexToKeyword ?? (hexToKeyword = buildHexToKeyword());
1240
- const keyword = hexToKeyword.get(tag.toUpperCase());
1241
- if (!keyword) {
1321
+ const mapped = hexToKeyword.get(tag.toUpperCase());
1322
+ if (!mapped) {
1242
1323
  throw new Error(`describe: unknown hex tag "${tag}"`);
1243
1324
  }
1244
- return keyword;
1325
+ keyword = mapped;
1326
+ } else {
1327
+ if (nameMap && !Object.hasOwn(nameMap, tag)) {
1328
+ throw new Error(`describe: unknown tag keyword "${tag}"`);
1329
+ }
1330
+ keyword = tag;
1245
1331
  }
1246
- if (nameMap && !Object.hasOwn(nameMap, tag)) {
1247
- throw new Error(`describe: unknown tag keyword "${tag}"`);
1332
+ if (RESERVED_KEEP_KEYWORDS.has(keyword)) {
1333
+ console.warn(
1334
+ `describe: ignoring keep-tag "${keyword}" \u2014 modality-coupled tags are reproduced from the modality field`
1335
+ );
1336
+ continue;
1248
1337
  }
1249
- return tag;
1250
- });
1338
+ if (nameMap?.[keyword]?.vr === "UI") {
1339
+ console.warn(
1340
+ `describe: ignoring keep-tag "${keyword}" \u2014 UID-valued tags are not kept verbatim`
1341
+ );
1342
+ continue;
1343
+ }
1344
+ kept.push(keyword);
1345
+ }
1346
+ return kept;
1251
1347
  }
1252
1348
  function* walkFiles(dir) {
1253
1349
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -1339,10 +1435,12 @@ function extractTags(record, keepKeywords) {
1339
1435
  let found = false;
1340
1436
  for (const keyword of keepKeywords) {
1341
1437
  const value = record[keyword];
1342
- if (value !== void 0) {
1343
- tags[keyword] = value;
1344
- found = true;
1438
+ if (value === void 0) continue;
1439
+ if (keyword === "Modality" && typeof value === "string" && SUPPORTED_MODALITIES.has(value)) {
1440
+ continue;
1345
1441
  }
1442
+ tags[keyword] = typeof value === "string" ? escapeTagTemplate(value) : value;
1443
+ found = true;
1346
1444
  }
1347
1445
  return found ? tags : void 0;
1348
1446
  }
@@ -1606,6 +1704,7 @@ function resolveParametricSpec(spec) {
1606
1704
  export {
1607
1705
  MAX_PIXEL_BYTES,
1608
1706
  MAX_STREAM_BYTES,
1707
+ TEMPLATE_VOCAB,
1609
1708
  caseCachePath,
1610
1709
  defaultPublicCasesPath,
1611
1710
  describeDirectory,
@@ -1617,6 +1716,7 @@ export {
1617
1716
  loadDefaultCases,
1618
1717
  previewCollection,
1619
1718
  resolveParametricSpec,
1719
+ resolveTagTemplates,
1620
1720
  streamLargeImage,
1621
1721
  validateDatasetSpec,
1622
1722
  validateParametricSpec,
@@ -15,6 +15,22 @@ function seededStream(seed, offset, a, b) {
15
15
  return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
16
16
  }
17
17
 
18
+ // src/syntheticFixtures/tagTemplate.ts
19
+ var TEMPLATE_VOCAB = [
20
+ "index",
21
+ "studyIndex",
22
+ "seriesIndex",
23
+ "instanceNumber"
24
+ ];
25
+ var TEMPLATE_PART_RE = /\{\{|\}\}|\{([a-zA-Z]+)\}/g;
26
+ function findTemplateTokens(value) {
27
+ const names = [];
28
+ for (const m of value.matchAll(TEMPLATE_PART_RE)) {
29
+ if (m[1] !== void 0) names.push(m[1]);
30
+ }
31
+ return names;
32
+ }
33
+
18
34
  // src/schema/limits.ts
19
35
  var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
20
36
  var MAX_STREAM_BYTES = 50 * 1024 * 1024 * 1024;
@@ -207,12 +223,28 @@ function validateEntry(entry, path) {
207
223
  }
208
224
  return e;
209
225
  }
226
+ var MODALITY_TAG = "00080060";
210
227
  function validateTags(tags, path) {
211
228
  if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
212
229
  throw new Error(`${path}: must be an object`);
213
230
  }
214
- for (const key of Object.keys(tags)) {
231
+ for (const [key, value] of Object.entries(tags)) {
215
232
  validateTagKey(key, path);
233
+ const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
234
+ if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
235
+ throw new Error(
236
+ `${path}.${key}: set a preset modality (${Object.keys(VALID_MODALITIES).join("/")}) via the "modality" field, not tags \u2014 it is coupled to SOPClassUID and type-1 attributes`
237
+ );
238
+ }
239
+ if (typeof value === "string") {
240
+ for (const name of findTemplateTokens(value)) {
241
+ if (!TEMPLATE_VOCAB.includes(name)) {
242
+ throw new Error(
243
+ `${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
244
+ );
245
+ }
246
+ }
247
+ }
216
248
  }
217
249
  }
218
250
  function validateRange(value, path, options = {}) {
@@ -1,3 +1,19 @@
1
+ // src/syntheticFixtures/tagTemplate.ts
2
+ var TEMPLATE_VOCAB = [
3
+ "index",
4
+ "studyIndex",
5
+ "seriesIndex",
6
+ "instanceNumber"
7
+ ];
8
+ var TEMPLATE_PART_RE = /\{\{|\}\}|\{([a-zA-Z]+)\}/g;
9
+ function findTemplateTokens(value) {
10
+ const names = [];
11
+ for (const m of value.matchAll(TEMPLATE_PART_RE)) {
12
+ if (m[1] !== void 0) names.push(m[1]);
13
+ }
14
+ return names;
15
+ }
16
+
1
17
  // src/schema/limits.ts
2
18
  var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
3
19
  var MAX_STREAM_BYTES = 50 * 1024 * 1024 * 1024;
@@ -196,12 +212,28 @@ function validateEntry(entry, path) {
196
212
  }
197
213
  return e;
198
214
  }
215
+ var MODALITY_TAG = "00080060";
199
216
  function validateTags(tags, path) {
200
217
  if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
201
218
  throw new Error(`${path}: must be an object`);
202
219
  }
203
- for (const key of Object.keys(tags)) {
220
+ for (const [key, value] of Object.entries(tags)) {
204
221
  validateTagKey(key, path);
222
+ const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
223
+ if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
224
+ throw new Error(
225
+ `${path}.${key}: set a preset modality (${Object.keys(VALID_MODALITIES).join("/")}) via the "modality" field, not tags \u2014 it is coupled to SOPClassUID and type-1 attributes`
226
+ );
227
+ }
228
+ if (typeof value === "string") {
229
+ for (const name of findTemplateTokens(value)) {
230
+ if (!TEMPLATE_VOCAB.includes(name)) {
231
+ throw new Error(
232
+ `${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
233
+ );
234
+ }
235
+ }
236
+ }
205
237
  }
206
238
  }
207
239
  function validateSeries(series, path) {
@@ -0,0 +1,49 @@
1
+ // src/syntheticFixtures/tagTemplate.ts
2
+ var TEMPLATE_VOCAB = [
3
+ "index",
4
+ "studyIndex",
5
+ "seriesIndex",
6
+ "instanceNumber"
7
+ ];
8
+ var TEMPLATE_PART_RE = /\{\{|\}\}|\{([a-zA-Z]+)\}/g;
9
+ function escapeTagTemplate(value) {
10
+ return value.replace(/\{/g, "{{").replace(/\}/g, "}}");
11
+ }
12
+ function findTemplateTokens(value) {
13
+ const names = [];
14
+ for (const m of value.matchAll(TEMPLATE_PART_RE)) {
15
+ if (m[1] !== void 0) names.push(m[1]);
16
+ }
17
+ return names;
18
+ }
19
+ function resolveTagTemplates(tags, context) {
20
+ if (!tags) return tags;
21
+ const resolved = {};
22
+ for (const [key, value] of Object.entries(tags)) {
23
+ resolved[key] = typeof value === "string" ? resolveString(value, context) : value;
24
+ }
25
+ return resolved;
26
+ }
27
+ function resolveString(value, context) {
28
+ return value.replace(TEMPLATE_PART_RE, (match, name) => {
29
+ if (name === void 0) return match === "{{" ? "{" : "}";
30
+ if (!TEMPLATE_VOCAB.includes(name)) {
31
+ throw new Error(
32
+ `tag template: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
33
+ );
34
+ }
35
+ const resolved = context[name];
36
+ if (resolved === void 0) {
37
+ throw new Error(
38
+ `tag template: placeholder "{${name}}" is not available here \u2014 it only applies inside grouped studies`
39
+ );
40
+ }
41
+ return String(resolved);
42
+ });
43
+ }
44
+ export {
45
+ TEMPLATE_VOCAB,
46
+ escapeTagTemplate,
47
+ findTemplateTokens,
48
+ resolveTagTemplates
49
+ };
@@ -1,4 +1,5 @@
1
1
  import type { DatasetSpec, FileSpec } from '../schema/types.js';
2
+ import { type TagContext } from '../syntheticFixtures/tagTemplate.js';
2
3
  import type { UidSet } from '../syntheticFixtures/uid.js';
3
4
  export type GeneratedFile = {
4
5
  filename: string;
@@ -17,6 +18,7 @@ export declare function generateFile(spec: FileSpec, options?: {
17
18
  seed?: number;
18
19
  padWidth?: number;
19
20
  uid?: Partial<UidSet>;
21
+ context?: Partial<TagContext>;
20
22
  }): Promise<GeneratedFile>;
21
23
  export declare function withResolvedSeed(spec: DatasetSpec): DatasetSpec;
22
24
  export declare function generateCollectionFromSpec(spec: DatasetSpec): AsyncGenerator<GeneratedFile>;
@@ -7,3 +7,4 @@ export { resolveParametricSpec } from './schema/parametric.js';
7
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';
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
+ export { resolveTagTemplates, type TagContext, TEMPLATE_VOCAB, } from './syntheticFixtures/tagTemplate.js';
@@ -0,0 +1,11 @@
1
+ import type { DicomTagOverrides } from '../schema/types.js';
2
+ export type TagContext = {
3
+ index: number;
4
+ studyIndex?: number;
5
+ seriesIndex?: number;
6
+ instanceNumber?: number;
7
+ };
8
+ export declare const TEMPLATE_VOCAB: readonly ["index", "studyIndex", "seriesIndex", "instanceNumber"];
9
+ export declare function escapeTagTemplate(value: string): string;
10
+ export declare function findTemplateTokens(value: string): string[];
11
+ export declare function resolveTagTemplates(tags: DicomTagOverrides | undefined, context: TagContext): DicomTagOverrides | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dicom-synth",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Toolkit for synthetic DICOM fixtures and public fixture fetch/cache.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",