dicom-synth 1.16.0 → 1.18.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 +12 -1
- package/dist/esm/collection/writer.js +68 -32
- package/dist/esm/describe/describe.js +247 -22
- package/dist/esm/index.js +312 -54
- package/dist/esm/schema/parametric.js +108 -2
- package/dist/esm/schema/validate.js +110 -2
- package/dist/esm/syntheticFixtures/generator.js +50 -21
- package/dist/esm/syntheticFixtures/streamWrite.js +47 -18
- package/dist/esm/syntheticFixtures/violations.js +9 -3
- package/dist/types/describe/describe.d.ts +12 -1
- 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 -4
- 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)) {
|
|
@@ -682,6 +788,8 @@ function validateParametricSpec(raw) {
|
|
|
682
788
|
export {
|
|
683
789
|
HEX_TAG_RE,
|
|
684
790
|
isImageType,
|
|
791
|
+
isPrivateHexTag,
|
|
792
|
+
isRawTagValue,
|
|
685
793
|
positionalName,
|
|
686
794
|
validateDatasetSpec,
|
|
687
795
|
validateParametricSpec
|
|
@@ -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,27 +96,50 @@ 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;
|
|
110
130
|
}
|
|
111
|
-
function
|
|
112
|
-
|
|
113
|
-
|
|
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;
|
|
114
139
|
}
|
|
115
|
-
|
|
116
|
-
|
|
140
|
+
const sopClass = rawScalarString(rawElements, "00080016") ?? (typeof dataset.SOPClassUID === "string" ? dataset.SOPClassUID : void 0);
|
|
141
|
+
if (sopClass !== void 0) {
|
|
142
|
+
meta.MediaStorageSOPClassUID = sopClass;
|
|
117
143
|
}
|
|
118
144
|
}
|
|
119
145
|
function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
|
|
@@ -153,7 +179,7 @@ function buildBaseImageDataset(uid, modality) {
|
|
|
153
179
|
...preset.attributes
|
|
154
180
|
};
|
|
155
181
|
}
|
|
156
|
-
function serializeDict(meta, dataset) {
|
|
182
|
+
function serializeDict(meta, dataset, rawElements) {
|
|
157
183
|
const dicomDict = new dcmjs.data.DicomDict(
|
|
158
184
|
dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
|
|
159
185
|
meta
|
|
@@ -162,14 +188,17 @@ function serializeDict(meta, dataset) {
|
|
|
162
188
|
dicomDict.dict = dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
|
|
163
189
|
dataset
|
|
164
190
|
);
|
|
191
|
+
if (rawElements) {
|
|
192
|
+
Object.assign(dicomDict.dict, rawElements);
|
|
193
|
+
}
|
|
165
194
|
return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
|
|
166
195
|
}
|
|
167
196
|
var MAX_DIMENSION = 65535;
|
|
168
|
-
function applySizing(dataset, meta, spec) {
|
|
197
|
+
function applySizing(dataset, meta, spec, rawElements) {
|
|
169
198
|
if (spec.targetSizeKb !== void 0) {
|
|
170
199
|
const clampDim = (n) => Math.min(MAX_DIMENSION, Math.max(1, Math.round(n)));
|
|
171
200
|
const minimalPixelBytes = dataset.PixelData.byteLength;
|
|
172
|
-
const overhead = serializeDict(meta, dataset).length - minimalPixelBytes;
|
|
201
|
+
const overhead = serializeDict(meta, dataset, rawElements).length - minimalPixelBytes;
|
|
173
202
|
const cells = Math.max(
|
|
174
203
|
1,
|
|
175
204
|
Math.round((spec.targetSizeKb * 1024 - overhead) / 2)
|
|
@@ -200,11 +229,11 @@ function buildImageBuffer(spec, uid) {
|
|
|
200
229
|
dataset.Laterality = "";
|
|
201
230
|
dataset.PatientWeight = "0";
|
|
202
231
|
}
|
|
203
|
-
applyTagOverrides(dataset, spec.tags);
|
|
232
|
+
const rawElements = applyTagOverrides(dataset, spec.tags);
|
|
204
233
|
const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
|
|
205
|
-
syncMetaWithDataset(meta, dataset);
|
|
206
|
-
applySizing(dataset, meta, spec);
|
|
207
|
-
return serializeDict(meta, dataset);
|
|
234
|
+
syncMetaWithDataset(meta, dataset, rawElements);
|
|
235
|
+
applySizing(dataset, meta, spec, rawElements);
|
|
236
|
+
return { buffer: serializeDict(meta, dataset, rawElements), rawElements };
|
|
208
237
|
}
|
|
209
238
|
function buildFakeSignatureBuffer() {
|
|
210
239
|
const buf = Buffer.alloc(200, 0);
|
|
@@ -226,11 +255,11 @@ function buildBufferForSpec(spec, uid) {
|
|
|
226
255
|
case "vendor-warnings-image":
|
|
227
256
|
return buildImageBuffer(spec, uid);
|
|
228
257
|
case "fake-signature":
|
|
229
|
-
return buildFakeSignatureBuffer();
|
|
258
|
+
return { buffer: buildFakeSignatureBuffer() };
|
|
230
259
|
case "non-dicom":
|
|
231
|
-
return buildNonDicomBuffer(spec);
|
|
260
|
+
return { buffer: buildNonDicomBuffer(spec) };
|
|
232
261
|
case "dicomdir":
|
|
233
|
-
return buildDicomdirBuffer();
|
|
262
|
+
return { buffer: buildDicomdirBuffer() };
|
|
234
263
|
case "large-image":
|
|
235
264
|
throw new Error(
|
|
236
265
|
"large-image cannot be generated in memory \u2014 use writeCollectionFromSpec (disk-only streaming)"
|
|
@@ -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,27 +82,50 @@ 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;
|
|
96
116
|
}
|
|
97
|
-
function
|
|
98
|
-
|
|
99
|
-
|
|
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;
|
|
100
125
|
}
|
|
101
|
-
|
|
102
|
-
|
|
126
|
+
const sopClass = rawScalarString(rawElements, "00080016") ?? (typeof dataset.SOPClassUID === "string" ? dataset.SOPClassUID : void 0);
|
|
127
|
+
if (sopClass !== void 0) {
|
|
128
|
+
meta.MediaStorageSOPClassUID = sopClass;
|
|
103
129
|
}
|
|
104
130
|
}
|
|
105
131
|
function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
|
|
@@ -139,7 +165,7 @@ function buildBaseImageDataset(uid, modality) {
|
|
|
139
165
|
...preset.attributes
|
|
140
166
|
};
|
|
141
167
|
}
|
|
142
|
-
function serializeDict(meta, dataset) {
|
|
168
|
+
function serializeDict(meta, dataset, rawElements) {
|
|
143
169
|
const dicomDict = new dcmjs.data.DicomDict(
|
|
144
170
|
dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
|
|
145
171
|
meta
|
|
@@ -148,6 +174,9 @@ function serializeDict(meta, dataset) {
|
|
|
148
174
|
dicomDict.dict = dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
|
|
149
175
|
dataset
|
|
150
176
|
);
|
|
177
|
+
if (rawElements) {
|
|
178
|
+
Object.assign(dicomDict.dict, rawElements);
|
|
179
|
+
}
|
|
151
180
|
return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
|
|
152
181
|
}
|
|
153
182
|
|
|
@@ -211,12 +240,12 @@ function writeNative(outPath, params, dims, zeroChunk) {
|
|
|
211
240
|
const { modality, uid, tags } = params;
|
|
212
241
|
const meta = buildMeta(uid, modality);
|
|
213
242
|
const dataset = buildBaseImageDataset(uid, modality);
|
|
214
|
-
applyTagOverrides(dataset, tags);
|
|
215
|
-
syncMetaWithDataset(meta, dataset);
|
|
243
|
+
const rawElements = applyTagOverrides(dataset, tags);
|
|
244
|
+
syncMetaWithDataset(meta, dataset, rawElements);
|
|
216
245
|
dataset.Rows = dims.rows;
|
|
217
246
|
dataset.Columns = dims.columns;
|
|
218
247
|
const minimalPixelBytes = dataset.PixelData.byteLength;
|
|
219
|
-
const probe = serializeDict(meta, dataset);
|
|
248
|
+
const probe = serializeDict(meta, dataset, rawElements);
|
|
220
249
|
const pixelElement = Buffer.alloc(12);
|
|
221
250
|
PIXEL_DATA_OW_HEADER.copy(pixelElement);
|
|
222
251
|
pixelElement.writeUInt32LE(minimalPixelBytes, 8);
|
|
@@ -245,11 +274,11 @@ function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk
|
|
|
245
274
|
TransferSyntaxUID: RLE_TRANSFER_SYNTAX_UID
|
|
246
275
|
};
|
|
247
276
|
const dataset = buildBaseImageDataset(uid, modality);
|
|
248
|
-
applyTagOverrides(dataset, tags);
|
|
249
|
-
syncMetaWithDataset(meta, dataset);
|
|
277
|
+
const rawElements = applyTagOverrides(dataset, tags);
|
|
278
|
+
syncMetaWithDataset(meta, dataset, rawElements);
|
|
250
279
|
dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
|
|
251
280
|
dataset._vrMap = { PixelData: "OB" };
|
|
252
|
-
const probe = serializeDict(meta, dataset);
|
|
281
|
+
const probe = serializeDict(meta, dataset, rawElements);
|
|
253
282
|
const idx = probe.indexOf(PIXEL_DATA_OB_HEADER);
|
|
254
283
|
if (idx < 0 || probe.readUInt32LE(idx + 8) !== 4294967295) {
|
|
255
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
|
+
keepPrivateTags?: boolean;
|
|
7
8
|
captureTree?: boolean;
|
|
8
9
|
};
|
|
9
10
|
export type DescribeResult = {
|
|
@@ -13,8 +14,18 @@ export type DescribeResult = {
|
|
|
13
14
|
skipped: number;
|
|
14
15
|
unknownModality: number;
|
|
15
16
|
nonDicomFiles: number;
|
|
17
|
+
privateBinarySkipped: number;
|
|
16
18
|
};
|
|
17
19
|
};
|
|
18
|
-
|
|
20
|
+
type RawDictElement = {
|
|
21
|
+
vr?: string;
|
|
22
|
+
Value?: unknown[];
|
|
23
|
+
};
|
|
24
|
+
export type ParsedFile = {
|
|
25
|
+
record: Record<string, unknown>;
|
|
26
|
+
rawDict: Record<string, RawDictElement>;
|
|
27
|
+
};
|
|
28
|
+
export declare function readHeaderOnly(path: string): ParsedFile | null;
|
|
19
29
|
export declare function tagValuesEqual(a: unknown, b: unknown): boolean;
|
|
20
30
|
export declare function describeDirectory(dir: string, options?: DescribeOptions): DescribeResult;
|
|
31
|
+
export {};
|
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,6 +1,11 @@
|
|
|
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 isPrivateHexTag(key: string): boolean;
|
|
5
|
+
export declare function isRawTagValue(value: unknown): value is {
|
|
6
|
+
vr: string;
|
|
7
|
+
value: unknown;
|
|
8
|
+
};
|
|
4
9
|
export declare function positionalName(prefix: string, ordinal: number): string;
|
|
5
10
|
export declare function validateDatasetSpec(raw: unknown): DatasetSpec;
|
|
6
11
|
export declare function validateParametricSpec(raw: unknown): ParametricSpec;
|
|
@@ -1,9 +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
|
|
4
|
-
|
|
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;
|
|
5
10
|
export declare function buildMeta(uid: UidSet, modality: Modality, transferSyntax?: TransferSyntax): Record<string, unknown>;
|
|
6
11
|
export declare function buildBaseImageDataset(uid: UidSet, modality: Modality): Record<string, unknown>;
|
|
7
|
-
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;
|
|
8
13
|
export declare function sizedNonDicomBytes(spec: NonDicomSpec): number | undefined;
|
|
9
|
-
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.18.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",
|