dicom-synth 1.15.0 → 1.17.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 +14 -2
- package/dist/esm/collection/writer.js +75 -25
- package/dist/esm/describe/describe.js +277 -39
- package/dist/esm/index.js +346 -64
- package/dist/esm/schema/parametric.js +108 -2
- package/dist/esm/schema/validate.js +114 -3
- package/dist/esm/syntheticFixtures/generator.js +55 -16
- package/dist/esm/syntheticFixtures/streamWrite.js +50 -11
- package/dist/esm/syntheticFixtures/violations.js +9 -3
- package/dist/types/describe/describe.d.ts +2 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/schema/types.d.ts +4 -0
- package/dist/types/schema/validate.d.ts +5 -0
- package/dist/types/syntheticFixtures/generator.d.ts +12 -3
- package/dist/types/syntheticFixtures/violations.d.ts +2 -1
- package/package.json +3 -1
|
@@ -69,6 +69,92 @@ var VALID_VIOLATIONS = {
|
|
|
69
69
|
};
|
|
70
70
|
var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
|
|
71
71
|
var KEYWORD_RE = /^[A-Z][A-Za-z0-9]*$/;
|
|
72
|
+
var VALID_VRS = /* @__PURE__ */ new Set([
|
|
73
|
+
"AE",
|
|
74
|
+
"AS",
|
|
75
|
+
"AT",
|
|
76
|
+
"CS",
|
|
77
|
+
"DA",
|
|
78
|
+
"DS",
|
|
79
|
+
"DT",
|
|
80
|
+
"FL",
|
|
81
|
+
"FD",
|
|
82
|
+
"IS",
|
|
83
|
+
"LO",
|
|
84
|
+
"LT",
|
|
85
|
+
"OB",
|
|
86
|
+
"OD",
|
|
87
|
+
"OF",
|
|
88
|
+
"OL",
|
|
89
|
+
"OV",
|
|
90
|
+
"OW",
|
|
91
|
+
"PN",
|
|
92
|
+
"SH",
|
|
93
|
+
"SL",
|
|
94
|
+
"SQ",
|
|
95
|
+
"SS",
|
|
96
|
+
"ST",
|
|
97
|
+
"SV",
|
|
98
|
+
"TM",
|
|
99
|
+
"UC",
|
|
100
|
+
"UI",
|
|
101
|
+
"UL",
|
|
102
|
+
"UN",
|
|
103
|
+
"UR",
|
|
104
|
+
"US",
|
|
105
|
+
"UT",
|
|
106
|
+
"UV"
|
|
107
|
+
]);
|
|
108
|
+
function isPrivateHexTag(key) {
|
|
109
|
+
return HEX_TAG_RE.test(key) && Number.parseInt(key.slice(0, 4), 16) % 2 === 1;
|
|
110
|
+
}
|
|
111
|
+
function isRawTagValue(value) {
|
|
112
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.vr === "string";
|
|
113
|
+
}
|
|
114
|
+
function validateRawTagValue(raw, path) {
|
|
115
|
+
for (const key of Object.keys(raw)) {
|
|
116
|
+
if (key !== "vr" && key !== "value") {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`${path}: raw tag form accepts only "vr" and "value"; got "${key}"`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const vr = raw.vr;
|
|
123
|
+
if (!VALID_VRS.has(vr)) {
|
|
124
|
+
throw new Error(`${path}.vr: "${vr}" is not a standard DICOM VR`);
|
|
125
|
+
}
|
|
126
|
+
if (!("value" in raw)) {
|
|
127
|
+
throw new Error(`${path}.value: is required in the raw tag form`);
|
|
128
|
+
}
|
|
129
|
+
if (vr === "SQ") {
|
|
130
|
+
if (!Array.isArray(raw.value)) {
|
|
131
|
+
throw new Error(`${path}.value: an SQ raw tag requires an array of items`);
|
|
132
|
+
}
|
|
133
|
+
raw.value.forEach((item, i) => {
|
|
134
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`${path}.value[${i}]: must be an object of hex-tag entries`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
for (const [tag, nested] of Object.entries(item)) {
|
|
140
|
+
if (!HEX_TAG_RE.test(tag)) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`${path}.value[${i}]: invalid item tag "${tag}" \u2014 SQ items use 8-hex-char tags`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
if (!isRawTagValue(nested)) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`${path}.value[${i}].${tag}: SQ item entries must use the raw { vr, value } form`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
validateRawTagValue(
|
|
151
|
+
nested,
|
|
152
|
+
`${path}.value[${i}].${tag}`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
72
158
|
function validatePositiveInt(value, path) {
|
|
73
159
|
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
74
160
|
throw new Error(
|
|
@@ -273,13 +359,33 @@ function validateEntry(entry, path) {
|
|
|
273
359
|
return e;
|
|
274
360
|
}
|
|
275
361
|
var MODALITY_TAG = "00080060";
|
|
276
|
-
function
|
|
362
|
+
function validateModalityGuard(key, candidate, path) {
|
|
277
363
|
const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
|
|
278
|
-
if (isModalityKey && typeof
|
|
364
|
+
if (isModalityKey && typeof candidate === "string" && Object.hasOwn(VALID_MODALITIES, candidate)) {
|
|
279
365
|
throw new Error(
|
|
280
366
|
`${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`
|
|
281
367
|
);
|
|
282
368
|
}
|
|
369
|
+
}
|
|
370
|
+
function validateTagValue(key, value, path) {
|
|
371
|
+
if (isRawTagValue(value)) {
|
|
372
|
+
if (!HEX_TAG_RE.test(key)) {
|
|
373
|
+
throw new Error(
|
|
374
|
+
`${path}.${key}: the raw { vr, value } form requires an 8-hex-char tag key \u2014 keyword tags take plain values`
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
validateRawTagValue(value, `${path}.${key}`);
|
|
378
|
+
const raw = value.value;
|
|
379
|
+
const scalar = Array.isArray(raw) && raw.length === 1 ? raw[0] : raw;
|
|
380
|
+
validateModalityGuard(key, scalar, path);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (isPrivateHexTag(key)) {
|
|
384
|
+
throw new Error(
|
|
385
|
+
`${path}.${key}: private tags have no dictionary VR \u2014 use the raw form, e.g. { "vr": "LO", "value": \u2026 }`
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
validateModalityGuard(key, value, path);
|
|
283
389
|
if (typeof value === "string") {
|
|
284
390
|
for (const name of findTemplateTokens(value)) {
|
|
285
391
|
if (!TEMPLATE_VOCAB.includes(name)) {
|
|
@@ -61,6 +61,92 @@ var VALID_VIOLATIONS = {
|
|
|
61
61
|
};
|
|
62
62
|
var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
|
|
63
63
|
var KEYWORD_RE = /^[A-Z][A-Za-z0-9]*$/;
|
|
64
|
+
var VALID_VRS = /* @__PURE__ */ new Set([
|
|
65
|
+
"AE",
|
|
66
|
+
"AS",
|
|
67
|
+
"AT",
|
|
68
|
+
"CS",
|
|
69
|
+
"DA",
|
|
70
|
+
"DS",
|
|
71
|
+
"DT",
|
|
72
|
+
"FL",
|
|
73
|
+
"FD",
|
|
74
|
+
"IS",
|
|
75
|
+
"LO",
|
|
76
|
+
"LT",
|
|
77
|
+
"OB",
|
|
78
|
+
"OD",
|
|
79
|
+
"OF",
|
|
80
|
+
"OL",
|
|
81
|
+
"OV",
|
|
82
|
+
"OW",
|
|
83
|
+
"PN",
|
|
84
|
+
"SH",
|
|
85
|
+
"SL",
|
|
86
|
+
"SQ",
|
|
87
|
+
"SS",
|
|
88
|
+
"ST",
|
|
89
|
+
"SV",
|
|
90
|
+
"TM",
|
|
91
|
+
"UC",
|
|
92
|
+
"UI",
|
|
93
|
+
"UL",
|
|
94
|
+
"UN",
|
|
95
|
+
"UR",
|
|
96
|
+
"US",
|
|
97
|
+
"UT",
|
|
98
|
+
"UV"
|
|
99
|
+
]);
|
|
100
|
+
function isPrivateHexTag(key) {
|
|
101
|
+
return HEX_TAG_RE.test(key) && Number.parseInt(key.slice(0, 4), 16) % 2 === 1;
|
|
102
|
+
}
|
|
103
|
+
function isRawTagValue(value) {
|
|
104
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.vr === "string";
|
|
105
|
+
}
|
|
106
|
+
function validateRawTagValue(raw, path) {
|
|
107
|
+
for (const key of Object.keys(raw)) {
|
|
108
|
+
if (key !== "vr" && key !== "value") {
|
|
109
|
+
throw new Error(
|
|
110
|
+
`${path}: raw tag form accepts only "vr" and "value"; got "${key}"`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const vr = raw.vr;
|
|
115
|
+
if (!VALID_VRS.has(vr)) {
|
|
116
|
+
throw new Error(`${path}.vr: "${vr}" is not a standard DICOM VR`);
|
|
117
|
+
}
|
|
118
|
+
if (!("value" in raw)) {
|
|
119
|
+
throw new Error(`${path}.value: is required in the raw tag form`);
|
|
120
|
+
}
|
|
121
|
+
if (vr === "SQ") {
|
|
122
|
+
if (!Array.isArray(raw.value)) {
|
|
123
|
+
throw new Error(`${path}.value: an SQ raw tag requires an array of items`);
|
|
124
|
+
}
|
|
125
|
+
raw.value.forEach((item, i) => {
|
|
126
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`${path}.value[${i}]: must be an object of hex-tag entries`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
for (const [tag, nested] of Object.entries(item)) {
|
|
132
|
+
if (!HEX_TAG_RE.test(tag)) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`${path}.value[${i}]: invalid item tag "${tag}" \u2014 SQ items use 8-hex-char tags`
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (!isRawTagValue(nested)) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`${path}.value[${i}].${tag}: SQ item entries must use the raw { vr, value } form`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
validateRawTagValue(
|
|
143
|
+
nested,
|
|
144
|
+
`${path}.value[${i}].${tag}`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
64
150
|
function validatePositiveInt(value, path) {
|
|
65
151
|
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
66
152
|
throw new Error(
|
|
@@ -265,13 +351,33 @@ function validateEntry(entry, path) {
|
|
|
265
351
|
return e;
|
|
266
352
|
}
|
|
267
353
|
var MODALITY_TAG = "00080060";
|
|
268
|
-
function
|
|
354
|
+
function validateModalityGuard(key, candidate, path) {
|
|
269
355
|
const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
|
|
270
|
-
if (isModalityKey && typeof
|
|
356
|
+
if (isModalityKey && typeof candidate === "string" && Object.hasOwn(VALID_MODALITIES, candidate)) {
|
|
271
357
|
throw new Error(
|
|
272
358
|
`${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`
|
|
273
359
|
);
|
|
274
360
|
}
|
|
361
|
+
}
|
|
362
|
+
function validateTagValue(key, value, path) {
|
|
363
|
+
if (isRawTagValue(value)) {
|
|
364
|
+
if (!HEX_TAG_RE.test(key)) {
|
|
365
|
+
throw new Error(
|
|
366
|
+
`${path}.${key}: the raw { vr, value } form requires an 8-hex-char tag key \u2014 keyword tags take plain values`
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
validateRawTagValue(value, `${path}.${key}`);
|
|
370
|
+
const raw = value.value;
|
|
371
|
+
const scalar = Array.isArray(raw) && raw.length === 1 ? raw[0] : raw;
|
|
372
|
+
validateModalityGuard(key, scalar, path);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (isPrivateHexTag(key)) {
|
|
376
|
+
throw new Error(
|
|
377
|
+
`${path}.${key}: private tags have no dictionary VR \u2014 use the raw form, e.g. { "vr": "LO", "value": \u2026 }`
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
validateModalityGuard(key, value, path);
|
|
275
381
|
if (typeof value === "string") {
|
|
276
382
|
for (const name of findTemplateTokens(value)) {
|
|
277
383
|
if (!TEMPLATE_VOCAB.includes(name)) {
|
|
@@ -407,6 +513,9 @@ function validateStudy(study, path) {
|
|
|
407
513
|
if ("tags" in st) validateTags(st.tags, `${path}.tags`);
|
|
408
514
|
return st;
|
|
409
515
|
}
|
|
516
|
+
function positionalName(prefix, ordinal) {
|
|
517
|
+
return `${prefix}-${String(ordinal).padStart(3, "0")}`;
|
|
518
|
+
}
|
|
410
519
|
var VALID_TREE_ROLES = {
|
|
411
520
|
study: true,
|
|
412
521
|
series: true
|
|
@@ -487,7 +596,7 @@ function validateTreeChildren(children, basePath, inStudy, inSeries) {
|
|
|
487
596
|
let name = "name" in n ? n.name : void 0;
|
|
488
597
|
if ("children" in n) {
|
|
489
598
|
dirOrdinal++;
|
|
490
|
-
name ?? (name =
|
|
599
|
+
name ?? (name = positionalName("dir", dirOrdinal));
|
|
491
600
|
}
|
|
492
601
|
if (name === void 0) return;
|
|
493
602
|
const prior = claimed.get(name);
|
|
@@ -679,6 +788,8 @@ function validateParametricSpec(raw) {
|
|
|
679
788
|
export {
|
|
680
789
|
HEX_TAG_RE,
|
|
681
790
|
isImageType,
|
|
791
|
+
isRawTagValue,
|
|
792
|
+
positionalName,
|
|
682
793
|
validateDatasetSpec,
|
|
683
794
|
validateParametricSpec
|
|
684
795
|
};
|
|
@@ -45,6 +45,9 @@ var MAX_STREAM_BYTES = 50 * 1024 * 1024 * 1024;
|
|
|
45
45
|
|
|
46
46
|
// src/schema/validate.ts
|
|
47
47
|
var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
|
|
48
|
+
function isRawTagValue(value) {
|
|
49
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.vr === "string";
|
|
50
|
+
}
|
|
48
51
|
|
|
49
52
|
// src/syntheticFixtures/generator.ts
|
|
50
53
|
var MODALITY_PRESETS = {
|
|
@@ -93,20 +96,51 @@ function buildTagToKeyword() {
|
|
|
93
96
|
return map;
|
|
94
97
|
}
|
|
95
98
|
var tagToKeyword = buildTagToKeyword();
|
|
99
|
+
function toRawElement(raw) {
|
|
100
|
+
const value = raw.vr === "SQ" ? raw.value.map(
|
|
101
|
+
(item) => Object.fromEntries(
|
|
102
|
+
Object.entries(item).map(([tag, nested]) => [
|
|
103
|
+
tag.toUpperCase(),
|
|
104
|
+
toRawElement(nested)
|
|
105
|
+
])
|
|
106
|
+
)
|
|
107
|
+
) : raw.value;
|
|
108
|
+
return { vr: raw.vr, Value: Array.isArray(value) ? value : [value] };
|
|
109
|
+
}
|
|
96
110
|
function applyTagOverrides(dataset, tags) {
|
|
97
|
-
if (!tags) return;
|
|
111
|
+
if (!tags) return void 0;
|
|
112
|
+
let raw;
|
|
98
113
|
for (const [key, value] of Object.entries(tags)) {
|
|
99
|
-
if (
|
|
114
|
+
if (isRawTagValue(value)) {
|
|
115
|
+
raw ?? (raw = {});
|
|
116
|
+
raw[key.toUpperCase()] = toRawElement(value);
|
|
117
|
+
} else if (HEX_TAG_RE.test(key)) {
|
|
100
118
|
const keyword = tagToKeyword.get(key.toUpperCase());
|
|
101
|
-
if (keyword) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
119
|
+
if (!keyword) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`dicom-synth: unknown hex tag "${key}" \u2014 dictionary tags take plain values; private/unknown tags require the raw { vr, value } form`
|
|
122
|
+
);
|
|
105
123
|
}
|
|
124
|
+
dataset[keyword] = value;
|
|
106
125
|
} else {
|
|
107
126
|
dataset[key] = value;
|
|
108
127
|
}
|
|
109
128
|
}
|
|
129
|
+
return raw;
|
|
130
|
+
}
|
|
131
|
+
function rawScalarString(rawElements, tag) {
|
|
132
|
+
const value = rawElements?.[tag]?.Value;
|
|
133
|
+
return value?.length === 1 && typeof value[0] === "string" ? value[0] : void 0;
|
|
134
|
+
}
|
|
135
|
+
function syncMetaWithDataset(meta, dataset, rawElements) {
|
|
136
|
+
const sopInstance = rawScalarString(rawElements, "00080018") ?? (typeof dataset.SOPInstanceUID === "string" ? dataset.SOPInstanceUID : void 0);
|
|
137
|
+
if (sopInstance !== void 0) {
|
|
138
|
+
meta.MediaStorageSOPInstanceUID = sopInstance;
|
|
139
|
+
}
|
|
140
|
+
const sopClass = rawScalarString(rawElements, "00080016") ?? (typeof dataset.SOPClassUID === "string" ? dataset.SOPClassUID : void 0);
|
|
141
|
+
if (sopClass !== void 0) {
|
|
142
|
+
meta.MediaStorageSOPClassUID = sopClass;
|
|
143
|
+
}
|
|
110
144
|
}
|
|
111
145
|
function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
|
|
112
146
|
return {
|
|
@@ -145,7 +179,7 @@ function buildBaseImageDataset(uid, modality) {
|
|
|
145
179
|
...preset.attributes
|
|
146
180
|
};
|
|
147
181
|
}
|
|
148
|
-
function serializeDict(meta, dataset) {
|
|
182
|
+
function serializeDict(meta, dataset, rawElements) {
|
|
149
183
|
const dicomDict = new dcmjs.data.DicomDict(
|
|
150
184
|
dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
|
|
151
185
|
meta
|
|
@@ -154,14 +188,17 @@ function serializeDict(meta, dataset) {
|
|
|
154
188
|
dicomDict.dict = dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
|
|
155
189
|
dataset
|
|
156
190
|
);
|
|
191
|
+
if (rawElements) {
|
|
192
|
+
Object.assign(dicomDict.dict, rawElements);
|
|
193
|
+
}
|
|
157
194
|
return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
|
|
158
195
|
}
|
|
159
196
|
var MAX_DIMENSION = 65535;
|
|
160
|
-
function applySizing(dataset, meta, spec) {
|
|
197
|
+
function applySizing(dataset, meta, spec, rawElements) {
|
|
161
198
|
if (spec.targetSizeKb !== void 0) {
|
|
162
199
|
const clampDim = (n) => Math.min(MAX_DIMENSION, Math.max(1, Math.round(n)));
|
|
163
200
|
const minimalPixelBytes = dataset.PixelData.byteLength;
|
|
164
|
-
const overhead = serializeDict(meta, dataset).length - minimalPixelBytes;
|
|
201
|
+
const overhead = serializeDict(meta, dataset, rawElements).length - minimalPixelBytes;
|
|
165
202
|
const cells = Math.max(
|
|
166
203
|
1,
|
|
167
204
|
Math.round((spec.targetSizeKb * 1024 - overhead) / 2)
|
|
@@ -192,10 +229,11 @@ function buildImageBuffer(spec, uid) {
|
|
|
192
229
|
dataset.Laterality = "";
|
|
193
230
|
dataset.PatientWeight = "0";
|
|
194
231
|
}
|
|
195
|
-
applyTagOverrides(dataset, spec.tags);
|
|
232
|
+
const rawElements = applyTagOverrides(dataset, spec.tags);
|
|
196
233
|
const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
|
|
197
|
-
|
|
198
|
-
|
|
234
|
+
syncMetaWithDataset(meta, dataset, rawElements);
|
|
235
|
+
applySizing(dataset, meta, spec, rawElements);
|
|
236
|
+
return { buffer: serializeDict(meta, dataset, rawElements), rawElements };
|
|
199
237
|
}
|
|
200
238
|
function buildFakeSignatureBuffer() {
|
|
201
239
|
const buf = Buffer.alloc(200, 0);
|
|
@@ -217,11 +255,11 @@ function buildBufferForSpec(spec, uid) {
|
|
|
217
255
|
case "vendor-warnings-image":
|
|
218
256
|
return buildImageBuffer(spec, uid);
|
|
219
257
|
case "fake-signature":
|
|
220
|
-
return buildFakeSignatureBuffer();
|
|
258
|
+
return { buffer: buildFakeSignatureBuffer() };
|
|
221
259
|
case "non-dicom":
|
|
222
|
-
return buildNonDicomBuffer(spec);
|
|
260
|
+
return { buffer: buildNonDicomBuffer(spec) };
|
|
223
261
|
case "dicomdir":
|
|
224
|
-
return buildDicomdirBuffer();
|
|
262
|
+
return { buffer: buildDicomdirBuffer() };
|
|
225
263
|
case "large-image":
|
|
226
264
|
throw new Error(
|
|
227
265
|
"large-image cannot be generated in memory \u2014 use writeCollectionFromSpec (disk-only streaming)"
|
|
@@ -234,5 +272,6 @@ export {
|
|
|
234
272
|
buildBufferForSpec,
|
|
235
273
|
buildMeta,
|
|
236
274
|
serializeDict,
|
|
237
|
-
sizedNonDicomBytes
|
|
275
|
+
sizedNonDicomBytes,
|
|
276
|
+
syncMetaWithDataset
|
|
238
277
|
};
|
|
@@ -31,6 +31,9 @@ import { resolve } from "node:path";
|
|
|
31
31
|
|
|
32
32
|
// src/schema/validate.ts
|
|
33
33
|
var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
|
|
34
|
+
function isRawTagValue(value) {
|
|
35
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.vr === "string";
|
|
36
|
+
}
|
|
34
37
|
|
|
35
38
|
// src/syntheticFixtures/generator.ts
|
|
36
39
|
var MODALITY_PRESETS = {
|
|
@@ -79,20 +82,51 @@ function buildTagToKeyword() {
|
|
|
79
82
|
return map;
|
|
80
83
|
}
|
|
81
84
|
var tagToKeyword = buildTagToKeyword();
|
|
85
|
+
function toRawElement(raw) {
|
|
86
|
+
const value = raw.vr === "SQ" ? raw.value.map(
|
|
87
|
+
(item) => Object.fromEntries(
|
|
88
|
+
Object.entries(item).map(([tag, nested]) => [
|
|
89
|
+
tag.toUpperCase(),
|
|
90
|
+
toRawElement(nested)
|
|
91
|
+
])
|
|
92
|
+
)
|
|
93
|
+
) : raw.value;
|
|
94
|
+
return { vr: raw.vr, Value: Array.isArray(value) ? value : [value] };
|
|
95
|
+
}
|
|
82
96
|
function applyTagOverrides(dataset, tags) {
|
|
83
|
-
if (!tags) return;
|
|
97
|
+
if (!tags) return void 0;
|
|
98
|
+
let raw;
|
|
84
99
|
for (const [key, value] of Object.entries(tags)) {
|
|
85
|
-
if (
|
|
100
|
+
if (isRawTagValue(value)) {
|
|
101
|
+
raw ?? (raw = {});
|
|
102
|
+
raw[key.toUpperCase()] = toRawElement(value);
|
|
103
|
+
} else if (HEX_TAG_RE.test(key)) {
|
|
86
104
|
const keyword = tagToKeyword.get(key.toUpperCase());
|
|
87
|
-
if (keyword) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
105
|
+
if (!keyword) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`dicom-synth: unknown hex tag "${key}" \u2014 dictionary tags take plain values; private/unknown tags require the raw { vr, value } form`
|
|
108
|
+
);
|
|
91
109
|
}
|
|
110
|
+
dataset[keyword] = value;
|
|
92
111
|
} else {
|
|
93
112
|
dataset[key] = value;
|
|
94
113
|
}
|
|
95
114
|
}
|
|
115
|
+
return raw;
|
|
116
|
+
}
|
|
117
|
+
function rawScalarString(rawElements, tag) {
|
|
118
|
+
const value = rawElements?.[tag]?.Value;
|
|
119
|
+
return value?.length === 1 && typeof value[0] === "string" ? value[0] : void 0;
|
|
120
|
+
}
|
|
121
|
+
function syncMetaWithDataset(meta, dataset, rawElements) {
|
|
122
|
+
const sopInstance = rawScalarString(rawElements, "00080018") ?? (typeof dataset.SOPInstanceUID === "string" ? dataset.SOPInstanceUID : void 0);
|
|
123
|
+
if (sopInstance !== void 0) {
|
|
124
|
+
meta.MediaStorageSOPInstanceUID = sopInstance;
|
|
125
|
+
}
|
|
126
|
+
const sopClass = rawScalarString(rawElements, "00080016") ?? (typeof dataset.SOPClassUID === "string" ? dataset.SOPClassUID : void 0);
|
|
127
|
+
if (sopClass !== void 0) {
|
|
128
|
+
meta.MediaStorageSOPClassUID = sopClass;
|
|
129
|
+
}
|
|
96
130
|
}
|
|
97
131
|
function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
|
|
98
132
|
return {
|
|
@@ -131,7 +165,7 @@ function buildBaseImageDataset(uid, modality) {
|
|
|
131
165
|
...preset.attributes
|
|
132
166
|
};
|
|
133
167
|
}
|
|
134
|
-
function serializeDict(meta, dataset) {
|
|
168
|
+
function serializeDict(meta, dataset, rawElements) {
|
|
135
169
|
const dicomDict = new dcmjs.data.DicomDict(
|
|
136
170
|
dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
|
|
137
171
|
meta
|
|
@@ -140,6 +174,9 @@ function serializeDict(meta, dataset) {
|
|
|
140
174
|
dicomDict.dict = dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
|
|
141
175
|
dataset
|
|
142
176
|
);
|
|
177
|
+
if (rawElements) {
|
|
178
|
+
Object.assign(dicomDict.dict, rawElements);
|
|
179
|
+
}
|
|
143
180
|
return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
|
|
144
181
|
}
|
|
145
182
|
|
|
@@ -203,11 +240,12 @@ function writeNative(outPath, params, dims, zeroChunk) {
|
|
|
203
240
|
const { modality, uid, tags } = params;
|
|
204
241
|
const meta = buildMeta(uid, modality);
|
|
205
242
|
const dataset = buildBaseImageDataset(uid, modality);
|
|
206
|
-
applyTagOverrides(dataset, tags);
|
|
243
|
+
const rawElements = applyTagOverrides(dataset, tags);
|
|
244
|
+
syncMetaWithDataset(meta, dataset, rawElements);
|
|
207
245
|
dataset.Rows = dims.rows;
|
|
208
246
|
dataset.Columns = dims.columns;
|
|
209
247
|
const minimalPixelBytes = dataset.PixelData.byteLength;
|
|
210
|
-
const probe = serializeDict(meta, dataset);
|
|
248
|
+
const probe = serializeDict(meta, dataset, rawElements);
|
|
211
249
|
const pixelElement = Buffer.alloc(12);
|
|
212
250
|
PIXEL_DATA_OW_HEADER.copy(pixelElement);
|
|
213
251
|
pixelElement.writeUInt32LE(minimalPixelBytes, 8);
|
|
@@ -236,10 +274,11 @@ function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk
|
|
|
236
274
|
TransferSyntaxUID: RLE_TRANSFER_SYNTAX_UID
|
|
237
275
|
};
|
|
238
276
|
const dataset = buildBaseImageDataset(uid, modality);
|
|
239
|
-
applyTagOverrides(dataset, tags);
|
|
277
|
+
const rawElements = applyTagOverrides(dataset, tags);
|
|
278
|
+
syncMetaWithDataset(meta, dataset, rawElements);
|
|
240
279
|
dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
|
|
241
280
|
dataset._vrMap = { PixelData: "OB" };
|
|
242
|
-
const probe = serializeDict(meta, dataset);
|
|
281
|
+
const probe = serializeDict(meta, dataset, rawElements);
|
|
243
282
|
const idx = probe.indexOf(PIXEL_DATA_OB_HEADER);
|
|
244
283
|
if (idx < 0 || probe.readUInt32LE(idx + 8) !== 4294967295) {
|
|
245
284
|
throw new Error("failed to locate encapsulated PixelData element header");
|
|
@@ -38,7 +38,7 @@ var VIOLATION_LEVEL = {
|
|
|
38
38
|
"missing-meta-header": "byte",
|
|
39
39
|
"malformed-sq-delimiter": "byte"
|
|
40
40
|
};
|
|
41
|
-
function applyTagLevel(buffer, violations) {
|
|
41
|
+
function applyTagLevel(buffer, violations, rawElements) {
|
|
42
42
|
if (violations.length === 0) return buffer;
|
|
43
43
|
let parsed;
|
|
44
44
|
try {
|
|
@@ -72,6 +72,9 @@ function applyTagLevel(buffer, violations) {
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
parsed.dict = dcmjsAny.data.DicomMetaDictionary.denaturalizeDataset(natural);
|
|
75
|
+
if (rawElements) {
|
|
76
|
+
Object.assign(parsed.dict, rawElements);
|
|
77
|
+
}
|
|
75
78
|
if (violations.includes("uid-too-long")) {
|
|
76
79
|
;
|
|
77
80
|
parsed.dict["00080018"] = {
|
|
@@ -104,11 +107,14 @@ function applyByteLevel(buffer, violations) {
|
|
|
104
107
|
}
|
|
105
108
|
return result;
|
|
106
109
|
}
|
|
107
|
-
function applyViolations(buffer, violations) {
|
|
110
|
+
function applyViolations(buffer, violations, rawElements) {
|
|
108
111
|
if (violations.length === 0) return buffer;
|
|
109
112
|
const tagViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "tag");
|
|
110
113
|
const byteViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "byte");
|
|
111
|
-
return applyByteLevel(
|
|
114
|
+
return applyByteLevel(
|
|
115
|
+
applyTagLevel(buffer, tagViolations, rawElements),
|
|
116
|
+
byteViolations
|
|
117
|
+
);
|
|
112
118
|
}
|
|
113
119
|
export {
|
|
114
120
|
applyViolations
|
|
@@ -4,6 +4,7 @@ export type DescribeOptions = {
|
|
|
4
4
|
sizeTolerance?: number;
|
|
5
5
|
preserveUids?: boolean;
|
|
6
6
|
uidSalt?: string;
|
|
7
|
+
captureTree?: boolean;
|
|
7
8
|
};
|
|
8
9
|
export type DescribeResult = {
|
|
9
10
|
spec: DatasetSpec;
|
|
@@ -11,6 +12,7 @@ export type DescribeResult = {
|
|
|
11
12
|
dicomFiles: number;
|
|
12
13
|
skipped: number;
|
|
13
14
|
unknownModality: number;
|
|
15
|
+
nonDicomFiles: number;
|
|
14
16
|
};
|
|
15
17
|
};
|
|
16
18
|
export declare function readHeaderOnly(path: string): Record<string, unknown> | null;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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, EntrySpec, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidImageSpec, LargeFileSpec, Modality, NonDicomSpec, ParametricEdgeCase, ParametricSpec, ParametricStudies, PathQuirk, Range, SeriesEntrySpec, SeriesInstances, SeriesSpec, StudySpec, TransferSyntax, TreeDirNode, TreeFileNode, TreeNode, ValidImageSpec, VendorWarningsImageSpec, ViolationClass, } from './schema/types.js';
|
|
7
|
+
export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, EntrySpec, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidImageSpec, LargeFileSpec, Modality, NonDicomSpec, ParametricEdgeCase, ParametricSpec, ParametricStudies, PathQuirk, Range, RawTagValue, SeriesEntrySpec, SeriesInstances, SeriesSpec, StudySpec, TransferSyntax, TreeDirNode, TreeFileNode, TreeNode, 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';
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { DatasetSpec, FileSpec, ParametricSpec } from './types.js';
|
|
2
2
|
export declare function isImageType(type: FileSpec['type']): boolean;
|
|
3
3
|
export declare const HEX_TAG_RE: RegExp;
|
|
4
|
+
export declare function isRawTagValue(value: unknown): value is {
|
|
5
|
+
vr: string;
|
|
6
|
+
value: unknown;
|
|
7
|
+
};
|
|
8
|
+
export declare function positionalName(prefix: string, ordinal: number): string;
|
|
4
9
|
export declare function validateDatasetSpec(raw: unknown): DatasetSpec;
|
|
5
10
|
export declare function validateParametricSpec(raw: unknown): ParametricSpec;
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import type { DicomTagOverrides, FileSpec, Modality, NonDicomSpec, TransferSyntax } from '../schema/types.js';
|
|
2
2
|
import type { UidSet } from './uid.js';
|
|
3
|
-
export
|
|
3
|
+
export type RawElement = {
|
|
4
|
+
vr: string;
|
|
5
|
+
Value: unknown[];
|
|
6
|
+
};
|
|
7
|
+
export type RawElementMap = Record<string, RawElement>;
|
|
8
|
+
export declare function applyTagOverrides(dataset: Record<string, unknown>, tags: DicomTagOverrides | undefined): RawElementMap | undefined;
|
|
9
|
+
export declare function syncMetaWithDataset(meta: Record<string, unknown>, dataset: Record<string, unknown>, rawElements?: RawElementMap): void;
|
|
4
10
|
export declare function buildMeta(uid: UidSet, modality: Modality, transferSyntax?: TransferSyntax): Record<string, unknown>;
|
|
5
11
|
export declare function buildBaseImageDataset(uid: UidSet, modality: Modality): Record<string, unknown>;
|
|
6
|
-
export declare function serializeDict(meta: Record<string, unknown>, dataset: Record<string, unknown
|
|
12
|
+
export declare function serializeDict(meta: Record<string, unknown>, dataset: Record<string, unknown>, rawElements?: RawElementMap): Buffer;
|
|
7
13
|
export declare function sizedNonDicomBytes(spec: NonDicomSpec): number | undefined;
|
|
8
|
-
export declare function buildBufferForSpec(spec: FileSpec, uid: UidSet):
|
|
14
|
+
export declare function buildBufferForSpec(spec: FileSpec, uid: UidSet): {
|
|
15
|
+
buffer: Buffer;
|
|
16
|
+
rawElements?: RawElementMap;
|
|
17
|
+
};
|
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import type { ViolationClass } from '../schema/types.js';
|
|
2
|
-
|
|
2
|
+
import type { RawElementMap } from './generator.js';
|
|
3
|
+
export declare function applyViolations(buffer: Buffer, violations: ViolationClass[], rawElements?: RawElementMap): Buffer;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dicom-synth",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.17.0",
|
|
4
4
|
"description": "Toolkit for synthetic DICOM fixtures and public fixture fetch/cache.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"build:data": "mkdir -p dist/data && cp data/public-cases.json dist/data/",
|
|
50
50
|
"typecheck": "tsc --noEmit",
|
|
51
51
|
"test": "vitest run",
|
|
52
|
+
"test:coverage": "vitest run --coverage",
|
|
52
53
|
"test:watch": "vitest",
|
|
53
54
|
"lint": "eslint src test scripts --ext .ts",
|
|
54
55
|
"lint:fix": "eslint src test scripts --ext .ts --fix",
|
|
@@ -82,6 +83,7 @@
|
|
|
82
83
|
"@types/node": "^22.15.3",
|
|
83
84
|
"@typescript-eslint/eslint-plugin": "^8.46.1",
|
|
84
85
|
"@typescript-eslint/parser": "^8.46.1",
|
|
86
|
+
"@vitest/coverage-v8": "3.2.6",
|
|
85
87
|
"dcmjs": "^0.51.1",
|
|
86
88
|
"esbuild": "^0.28.1",
|
|
87
89
|
"eslint": "^9.38.0",
|