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.
- package/bin/dicom-synth-describe.mjs +28 -9
- package/dist/esm/collection/writer.js +49 -3
- package/dist/esm/describe/describe.js +73 -12
- package/dist/esm/index.js +115 -15
- package/dist/esm/schema/parametric.js +33 -1
- package/dist/esm/schema/validate.js +33 -1
- package/dist/esm/syntheticFixtures/tagTemplate.js +49 -0
- package/dist/types/collection/writer.d.ts +2 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/syntheticFixtures/tagTemplate.d.ts +11 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
332
|
-
if (!
|
|
375
|
+
const mapped = hexToKeyword.get(tag.toUpperCase());
|
|
376
|
+
if (!mapped) {
|
|
333
377
|
throw new Error(`describe: unknown hex tag "${tag}"`);
|
|
334
378
|
}
|
|
335
|
-
|
|
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 (
|
|
338
|
-
|
|
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
|
-
|
|
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
|
|
434
|
-
|
|
435
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1241
|
-
if (!
|
|
1321
|
+
const mapped = hexToKeyword.get(tag.toUpperCase());
|
|
1322
|
+
if (!mapped) {
|
|
1242
1323
|
throw new Error(`describe: unknown hex tag "${tag}"`);
|
|
1243
1324
|
}
|
|
1244
|
-
|
|
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 (
|
|
1247
|
-
|
|
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
|
-
|
|
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
|
|
1343
|
-
|
|
1344
|
-
|
|
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.
|
|
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.
|
|
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>;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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;
|