dicom-synth 1.16.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/dist/esm/collection/writer.js +68 -32
- package/dist/esm/describe/describe.js +108 -2
- package/dist/esm/index.js +173 -34
- package/dist/esm/schema/parametric.js +108 -2
- package/dist/esm/schema/validate.js +109 -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/index.d.ts +1 -1
- package/dist/types/schema/types.d.ts +4 -0
- package/dist/types/schema/validate.d.ts +4 -0
- package/dist/types/syntheticFixtures/generator.d.ts +12 -4
- package/dist/types/syntheticFixtures/violations.d.ts +2 -1
- package/package.json +3 -1
|
@@ -56,6 +56,9 @@ function isImageType(type) {
|
|
|
56
56
|
return TYPE_CAPABILITIES[type].image;
|
|
57
57
|
}
|
|
58
58
|
var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
|
|
59
|
+
function isRawTagValue(value) {
|
|
60
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.vr === "string";
|
|
61
|
+
}
|
|
59
62
|
function positionalName(prefix, ordinal) {
|
|
60
63
|
return `${prefix}-${String(ordinal).padStart(3, "0")}`;
|
|
61
64
|
}
|
|
@@ -148,27 +151,50 @@ function buildTagToKeyword() {
|
|
|
148
151
|
return map;
|
|
149
152
|
}
|
|
150
153
|
var tagToKeyword = buildTagToKeyword();
|
|
154
|
+
function toRawElement(raw) {
|
|
155
|
+
const value = raw.vr === "SQ" ? raw.value.map(
|
|
156
|
+
(item) => Object.fromEntries(
|
|
157
|
+
Object.entries(item).map(([tag, nested]) => [
|
|
158
|
+
tag.toUpperCase(),
|
|
159
|
+
toRawElement(nested)
|
|
160
|
+
])
|
|
161
|
+
)
|
|
162
|
+
) : raw.value;
|
|
163
|
+
return { vr: raw.vr, Value: Array.isArray(value) ? value : [value] };
|
|
164
|
+
}
|
|
151
165
|
function applyTagOverrides(dataset, tags) {
|
|
152
|
-
if (!tags) return;
|
|
166
|
+
if (!tags) return void 0;
|
|
167
|
+
let raw;
|
|
153
168
|
for (const [key, value] of Object.entries(tags)) {
|
|
154
|
-
if (
|
|
169
|
+
if (isRawTagValue(value)) {
|
|
170
|
+
raw ?? (raw = {});
|
|
171
|
+
raw[key.toUpperCase()] = toRawElement(value);
|
|
172
|
+
} else if (HEX_TAG_RE.test(key)) {
|
|
155
173
|
const keyword = tagToKeyword.get(key.toUpperCase());
|
|
156
|
-
if (keyword) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
174
|
+
if (!keyword) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`dicom-synth: unknown hex tag "${key}" \u2014 dictionary tags take plain values; private/unknown tags require the raw { vr, value } form`
|
|
177
|
+
);
|
|
160
178
|
}
|
|
179
|
+
dataset[keyword] = value;
|
|
161
180
|
} else {
|
|
162
181
|
dataset[key] = value;
|
|
163
182
|
}
|
|
164
183
|
}
|
|
184
|
+
return raw;
|
|
165
185
|
}
|
|
166
|
-
function
|
|
167
|
-
|
|
168
|
-
|
|
186
|
+
function rawScalarString(rawElements, tag) {
|
|
187
|
+
const value = rawElements?.[tag]?.Value;
|
|
188
|
+
return value?.length === 1 && typeof value[0] === "string" ? value[0] : void 0;
|
|
189
|
+
}
|
|
190
|
+
function syncMetaWithDataset(meta, dataset, rawElements) {
|
|
191
|
+
const sopInstance = rawScalarString(rawElements, "00080018") ?? (typeof dataset.SOPInstanceUID === "string" ? dataset.SOPInstanceUID : void 0);
|
|
192
|
+
if (sopInstance !== void 0) {
|
|
193
|
+
meta.MediaStorageSOPInstanceUID = sopInstance;
|
|
169
194
|
}
|
|
170
|
-
|
|
171
|
-
|
|
195
|
+
const sopClass = rawScalarString(rawElements, "00080016") ?? (typeof dataset.SOPClassUID === "string" ? dataset.SOPClassUID : void 0);
|
|
196
|
+
if (sopClass !== void 0) {
|
|
197
|
+
meta.MediaStorageSOPClassUID = sopClass;
|
|
172
198
|
}
|
|
173
199
|
}
|
|
174
200
|
function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
|
|
@@ -208,7 +234,7 @@ function buildBaseImageDataset(uid, modality) {
|
|
|
208
234
|
...preset.attributes
|
|
209
235
|
};
|
|
210
236
|
}
|
|
211
|
-
function serializeDict(meta, dataset) {
|
|
237
|
+
function serializeDict(meta, dataset, rawElements) {
|
|
212
238
|
const dicomDict = new dcmjs.data.DicomDict(
|
|
213
239
|
dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
|
|
214
240
|
meta
|
|
@@ -217,14 +243,17 @@ function serializeDict(meta, dataset) {
|
|
|
217
243
|
dicomDict.dict = dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
|
|
218
244
|
dataset
|
|
219
245
|
);
|
|
246
|
+
if (rawElements) {
|
|
247
|
+
Object.assign(dicomDict.dict, rawElements);
|
|
248
|
+
}
|
|
220
249
|
return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
|
|
221
250
|
}
|
|
222
251
|
var MAX_DIMENSION = 65535;
|
|
223
|
-
function applySizing(dataset, meta, spec) {
|
|
252
|
+
function applySizing(dataset, meta, spec, rawElements) {
|
|
224
253
|
if (spec.targetSizeKb !== void 0) {
|
|
225
254
|
const clampDim = (n) => Math.min(MAX_DIMENSION, Math.max(1, Math.round(n)));
|
|
226
255
|
const minimalPixelBytes = dataset.PixelData.byteLength;
|
|
227
|
-
const overhead = serializeDict(meta, dataset).length - minimalPixelBytes;
|
|
256
|
+
const overhead = serializeDict(meta, dataset, rawElements).length - minimalPixelBytes;
|
|
228
257
|
const cells = Math.max(
|
|
229
258
|
1,
|
|
230
259
|
Math.round((spec.targetSizeKb * 1024 - overhead) / 2)
|
|
@@ -255,11 +284,11 @@ function buildImageBuffer(spec, uid) {
|
|
|
255
284
|
dataset.Laterality = "";
|
|
256
285
|
dataset.PatientWeight = "0";
|
|
257
286
|
}
|
|
258
|
-
applyTagOverrides(dataset, spec.tags);
|
|
287
|
+
const rawElements = applyTagOverrides(dataset, spec.tags);
|
|
259
288
|
const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
|
|
260
|
-
syncMetaWithDataset(meta, dataset);
|
|
261
|
-
applySizing(dataset, meta, spec);
|
|
262
|
-
return serializeDict(meta, dataset);
|
|
289
|
+
syncMetaWithDataset(meta, dataset, rawElements);
|
|
290
|
+
applySizing(dataset, meta, spec, rawElements);
|
|
291
|
+
return { buffer: serializeDict(meta, dataset, rawElements), rawElements };
|
|
263
292
|
}
|
|
264
293
|
function buildFakeSignatureBuffer() {
|
|
265
294
|
const buf = Buffer.alloc(200, 0);
|
|
@@ -281,11 +310,11 @@ function buildBufferForSpec(spec, uid) {
|
|
|
281
310
|
case "vendor-warnings-image":
|
|
282
311
|
return buildImageBuffer(spec, uid);
|
|
283
312
|
case "fake-signature":
|
|
284
|
-
return buildFakeSignatureBuffer();
|
|
313
|
+
return { buffer: buildFakeSignatureBuffer() };
|
|
285
314
|
case "non-dicom":
|
|
286
|
-
return buildNonDicomBuffer(spec);
|
|
315
|
+
return { buffer: buildNonDicomBuffer(spec) };
|
|
287
316
|
case "dicomdir":
|
|
288
|
-
return buildDicomdirBuffer();
|
|
317
|
+
return { buffer: buildDicomdirBuffer() };
|
|
289
318
|
case "large-image":
|
|
290
319
|
throw new Error(
|
|
291
320
|
"large-image cannot be generated in memory \u2014 use writeCollectionFromSpec (disk-only streaming)"
|
|
@@ -366,12 +395,12 @@ function writeNative(outPath, params, dims, zeroChunk) {
|
|
|
366
395
|
const { modality, uid, tags } = params;
|
|
367
396
|
const meta = buildMeta(uid, modality);
|
|
368
397
|
const dataset = buildBaseImageDataset(uid, modality);
|
|
369
|
-
applyTagOverrides(dataset, tags);
|
|
370
|
-
syncMetaWithDataset(meta, dataset);
|
|
398
|
+
const rawElements = applyTagOverrides(dataset, tags);
|
|
399
|
+
syncMetaWithDataset(meta, dataset, rawElements);
|
|
371
400
|
dataset.Rows = dims.rows;
|
|
372
401
|
dataset.Columns = dims.columns;
|
|
373
402
|
const minimalPixelBytes = dataset.PixelData.byteLength;
|
|
374
|
-
const probe = serializeDict(meta, dataset);
|
|
403
|
+
const probe = serializeDict(meta, dataset, rawElements);
|
|
375
404
|
const pixelElement = Buffer.alloc(12);
|
|
376
405
|
PIXEL_DATA_OW_HEADER.copy(pixelElement);
|
|
377
406
|
pixelElement.writeUInt32LE(minimalPixelBytes, 8);
|
|
@@ -400,11 +429,11 @@ function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk
|
|
|
400
429
|
TransferSyntaxUID: RLE_TRANSFER_SYNTAX_UID
|
|
401
430
|
};
|
|
402
431
|
const dataset = buildBaseImageDataset(uid, modality);
|
|
403
|
-
applyTagOverrides(dataset, tags);
|
|
404
|
-
syncMetaWithDataset(meta, dataset);
|
|
432
|
+
const rawElements = applyTagOverrides(dataset, tags);
|
|
433
|
+
syncMetaWithDataset(meta, dataset, rawElements);
|
|
405
434
|
dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
|
|
406
435
|
dataset._vrMap = { PixelData: "OB" };
|
|
407
|
-
const probe = serializeDict(meta, dataset);
|
|
436
|
+
const probe = serializeDict(meta, dataset, rawElements);
|
|
408
437
|
const idx = probe.indexOf(PIXEL_DATA_OB_HEADER);
|
|
409
438
|
if (idx < 0 || probe.readUInt32LE(idx + 8) !== 4294967295) {
|
|
410
439
|
throw new Error("failed to locate encapsulated PixelData element header");
|
|
@@ -501,7 +530,7 @@ var VIOLATION_LEVEL = {
|
|
|
501
530
|
"missing-meta-header": "byte",
|
|
502
531
|
"malformed-sq-delimiter": "byte"
|
|
503
532
|
};
|
|
504
|
-
function applyTagLevel(buffer, violations) {
|
|
533
|
+
function applyTagLevel(buffer, violations, rawElements) {
|
|
505
534
|
if (violations.length === 0) return buffer;
|
|
506
535
|
let parsed;
|
|
507
536
|
try {
|
|
@@ -535,6 +564,9 @@ function applyTagLevel(buffer, violations) {
|
|
|
535
564
|
}
|
|
536
565
|
}
|
|
537
566
|
parsed.dict = dcmjsAny.data.DicomMetaDictionary.denaturalizeDataset(natural);
|
|
567
|
+
if (rawElements) {
|
|
568
|
+
Object.assign(parsed.dict, rawElements);
|
|
569
|
+
}
|
|
538
570
|
if (violations.includes("uid-too-long")) {
|
|
539
571
|
;
|
|
540
572
|
parsed.dict["00080018"] = {
|
|
@@ -567,11 +599,14 @@ function applyByteLevel(buffer, violations) {
|
|
|
567
599
|
}
|
|
568
600
|
return result;
|
|
569
601
|
}
|
|
570
|
-
function applyViolations(buffer, violations) {
|
|
602
|
+
function applyViolations(buffer, violations, rawElements) {
|
|
571
603
|
if (violations.length === 0) return buffer;
|
|
572
604
|
const tagViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "tag");
|
|
573
605
|
const byteViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "byte");
|
|
574
|
-
return applyByteLevel(
|
|
606
|
+
return applyByteLevel(
|
|
607
|
+
applyTagLevel(buffer, tagViolations, rawElements),
|
|
608
|
+
byteViolations
|
|
609
|
+
);
|
|
575
610
|
}
|
|
576
611
|
|
|
577
612
|
// src/collection/writer.ts
|
|
@@ -645,10 +680,11 @@ async function generateFile(spec, options) {
|
|
|
645
680
|
...spec,
|
|
646
681
|
tags: resolveTagTemplates(spec.tags, { index, ...options?.context })
|
|
647
682
|
} : spec;
|
|
648
|
-
|
|
683
|
+
const built = buildBufferForSpec(resolvedSpec, uid);
|
|
684
|
+
let buffer = built.buffer;
|
|
649
685
|
const violations = "violations" in spec ? spec.violations : void 0;
|
|
650
686
|
if (violations?.length) {
|
|
651
|
-
buffer = applyViolations(buffer, violations);
|
|
687
|
+
buffer = applyViolations(buffer, violations, built.rawElements);
|
|
652
688
|
}
|
|
653
689
|
const name = filename(spec.type, index, padWidth);
|
|
654
690
|
return {
|
|
@@ -92,6 +92,92 @@ var VALID_VIOLATIONS = {
|
|
|
92
92
|
};
|
|
93
93
|
var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
|
|
94
94
|
var KEYWORD_RE = /^[A-Z][A-Za-z0-9]*$/;
|
|
95
|
+
var VALID_VRS = /* @__PURE__ */ new Set([
|
|
96
|
+
"AE",
|
|
97
|
+
"AS",
|
|
98
|
+
"AT",
|
|
99
|
+
"CS",
|
|
100
|
+
"DA",
|
|
101
|
+
"DS",
|
|
102
|
+
"DT",
|
|
103
|
+
"FL",
|
|
104
|
+
"FD",
|
|
105
|
+
"IS",
|
|
106
|
+
"LO",
|
|
107
|
+
"LT",
|
|
108
|
+
"OB",
|
|
109
|
+
"OD",
|
|
110
|
+
"OF",
|
|
111
|
+
"OL",
|
|
112
|
+
"OV",
|
|
113
|
+
"OW",
|
|
114
|
+
"PN",
|
|
115
|
+
"SH",
|
|
116
|
+
"SL",
|
|
117
|
+
"SQ",
|
|
118
|
+
"SS",
|
|
119
|
+
"ST",
|
|
120
|
+
"SV",
|
|
121
|
+
"TM",
|
|
122
|
+
"UC",
|
|
123
|
+
"UI",
|
|
124
|
+
"UL",
|
|
125
|
+
"UN",
|
|
126
|
+
"UR",
|
|
127
|
+
"US",
|
|
128
|
+
"UT",
|
|
129
|
+
"UV"
|
|
130
|
+
]);
|
|
131
|
+
function isPrivateHexTag(key) {
|
|
132
|
+
return HEX_TAG_RE.test(key) && Number.parseInt(key.slice(0, 4), 16) % 2 === 1;
|
|
133
|
+
}
|
|
134
|
+
function isRawTagValue(value) {
|
|
135
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.vr === "string";
|
|
136
|
+
}
|
|
137
|
+
function validateRawTagValue(raw, path) {
|
|
138
|
+
for (const key of Object.keys(raw)) {
|
|
139
|
+
if (key !== "vr" && key !== "value") {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`${path}: raw tag form accepts only "vr" and "value"; got "${key}"`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const vr = raw.vr;
|
|
146
|
+
if (!VALID_VRS.has(vr)) {
|
|
147
|
+
throw new Error(`${path}.vr: "${vr}" is not a standard DICOM VR`);
|
|
148
|
+
}
|
|
149
|
+
if (!("value" in raw)) {
|
|
150
|
+
throw new Error(`${path}.value: is required in the raw tag form`);
|
|
151
|
+
}
|
|
152
|
+
if (vr === "SQ") {
|
|
153
|
+
if (!Array.isArray(raw.value)) {
|
|
154
|
+
throw new Error(`${path}.value: an SQ raw tag requires an array of items`);
|
|
155
|
+
}
|
|
156
|
+
raw.value.forEach((item, i) => {
|
|
157
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`${path}.value[${i}]: must be an object of hex-tag entries`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
for (const [tag, nested] of Object.entries(item)) {
|
|
163
|
+
if (!HEX_TAG_RE.test(tag)) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`${path}.value[${i}]: invalid item tag "${tag}" \u2014 SQ items use 8-hex-char tags`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (!isRawTagValue(nested)) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`${path}.value[${i}].${tag}: SQ item entries must use the raw { vr, value } form`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
validateRawTagValue(
|
|
174
|
+
nested,
|
|
175
|
+
`${path}.value[${i}].${tag}`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
95
181
|
function validatePositiveInt(value, path) {
|
|
96
182
|
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
97
183
|
throw new Error(
|
|
@@ -289,13 +375,33 @@ function validateEntry(entry, path) {
|
|
|
289
375
|
return e;
|
|
290
376
|
}
|
|
291
377
|
var MODALITY_TAG = "00080060";
|
|
292
|
-
function
|
|
378
|
+
function validateModalityGuard(key, candidate, path) {
|
|
293
379
|
const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
|
|
294
|
-
if (isModalityKey && typeof
|
|
380
|
+
if (isModalityKey && typeof candidate === "string" && Object.hasOwn(VALID_MODALITIES, candidate)) {
|
|
295
381
|
throw new Error(
|
|
296
382
|
`${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`
|
|
297
383
|
);
|
|
298
384
|
}
|
|
385
|
+
}
|
|
386
|
+
function validateTagValue(key, value, path) {
|
|
387
|
+
if (isRawTagValue(value)) {
|
|
388
|
+
if (!HEX_TAG_RE.test(key)) {
|
|
389
|
+
throw new Error(
|
|
390
|
+
`${path}.${key}: the raw { vr, value } form requires an 8-hex-char tag key \u2014 keyword tags take plain values`
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
validateRawTagValue(value, `${path}.${key}`);
|
|
394
|
+
const raw = value.value;
|
|
395
|
+
const scalar = Array.isArray(raw) && raw.length === 1 ? raw[0] : raw;
|
|
396
|
+
validateModalityGuard(key, scalar, path);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (isPrivateHexTag(key)) {
|
|
400
|
+
throw new Error(
|
|
401
|
+
`${path}.${key}: private tags have no dictionary VR \u2014 use the raw form, e.g. { "vr": "LO", "value": \u2026 }`
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
validateModalityGuard(key, value, path);
|
|
299
405
|
if (typeof value === "string") {
|
|
300
406
|
for (const name of findTemplateTokens(value)) {
|
|
301
407
|
if (!TEMPLATE_VOCAB.includes(name)) {
|
package/dist/esm/index.js
CHANGED
|
@@ -95,6 +95,92 @@ var VALID_VIOLATIONS = {
|
|
|
95
95
|
};
|
|
96
96
|
var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
|
|
97
97
|
var KEYWORD_RE = /^[A-Z][A-Za-z0-9]*$/;
|
|
98
|
+
var VALID_VRS = /* @__PURE__ */ new Set([
|
|
99
|
+
"AE",
|
|
100
|
+
"AS",
|
|
101
|
+
"AT",
|
|
102
|
+
"CS",
|
|
103
|
+
"DA",
|
|
104
|
+
"DS",
|
|
105
|
+
"DT",
|
|
106
|
+
"FL",
|
|
107
|
+
"FD",
|
|
108
|
+
"IS",
|
|
109
|
+
"LO",
|
|
110
|
+
"LT",
|
|
111
|
+
"OB",
|
|
112
|
+
"OD",
|
|
113
|
+
"OF",
|
|
114
|
+
"OL",
|
|
115
|
+
"OV",
|
|
116
|
+
"OW",
|
|
117
|
+
"PN",
|
|
118
|
+
"SH",
|
|
119
|
+
"SL",
|
|
120
|
+
"SQ",
|
|
121
|
+
"SS",
|
|
122
|
+
"ST",
|
|
123
|
+
"SV",
|
|
124
|
+
"TM",
|
|
125
|
+
"UC",
|
|
126
|
+
"UI",
|
|
127
|
+
"UL",
|
|
128
|
+
"UN",
|
|
129
|
+
"UR",
|
|
130
|
+
"US",
|
|
131
|
+
"UT",
|
|
132
|
+
"UV"
|
|
133
|
+
]);
|
|
134
|
+
function isPrivateHexTag(key) {
|
|
135
|
+
return HEX_TAG_RE.test(key) && Number.parseInt(key.slice(0, 4), 16) % 2 === 1;
|
|
136
|
+
}
|
|
137
|
+
function isRawTagValue(value) {
|
|
138
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.vr === "string";
|
|
139
|
+
}
|
|
140
|
+
function validateRawTagValue(raw, path) {
|
|
141
|
+
for (const key of Object.keys(raw)) {
|
|
142
|
+
if (key !== "vr" && key !== "value") {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`${path}: raw tag form accepts only "vr" and "value"; got "${key}"`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const vr = raw.vr;
|
|
149
|
+
if (!VALID_VRS.has(vr)) {
|
|
150
|
+
throw new Error(`${path}.vr: "${vr}" is not a standard DICOM VR`);
|
|
151
|
+
}
|
|
152
|
+
if (!("value" in raw)) {
|
|
153
|
+
throw new Error(`${path}.value: is required in the raw tag form`);
|
|
154
|
+
}
|
|
155
|
+
if (vr === "SQ") {
|
|
156
|
+
if (!Array.isArray(raw.value)) {
|
|
157
|
+
throw new Error(`${path}.value: an SQ raw tag requires an array of items`);
|
|
158
|
+
}
|
|
159
|
+
raw.value.forEach((item, i) => {
|
|
160
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`${path}.value[${i}]: must be an object of hex-tag entries`
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
for (const [tag, nested] of Object.entries(item)) {
|
|
166
|
+
if (!HEX_TAG_RE.test(tag)) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`${path}.value[${i}]: invalid item tag "${tag}" \u2014 SQ items use 8-hex-char tags`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (!isRawTagValue(nested)) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`${path}.value[${i}].${tag}: SQ item entries must use the raw { vr, value } form`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
validateRawTagValue(
|
|
177
|
+
nested,
|
|
178
|
+
`${path}.value[${i}].${tag}`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
98
184
|
function validatePositiveInt(value, path) {
|
|
99
185
|
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
100
186
|
throw new Error(
|
|
@@ -299,13 +385,33 @@ function validateEntry(entry, path) {
|
|
|
299
385
|
return e;
|
|
300
386
|
}
|
|
301
387
|
var MODALITY_TAG = "00080060";
|
|
302
|
-
function
|
|
388
|
+
function validateModalityGuard(key, candidate, path) {
|
|
303
389
|
const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
|
|
304
|
-
if (isModalityKey && typeof
|
|
390
|
+
if (isModalityKey && typeof candidate === "string" && Object.hasOwn(VALID_MODALITIES, candidate)) {
|
|
305
391
|
throw new Error(
|
|
306
392
|
`${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`
|
|
307
393
|
);
|
|
308
394
|
}
|
|
395
|
+
}
|
|
396
|
+
function validateTagValue(key, value, path) {
|
|
397
|
+
if (isRawTagValue(value)) {
|
|
398
|
+
if (!HEX_TAG_RE.test(key)) {
|
|
399
|
+
throw new Error(
|
|
400
|
+
`${path}.${key}: the raw { vr, value } form requires an 8-hex-char tag key \u2014 keyword tags take plain values`
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
validateRawTagValue(value, `${path}.${key}`);
|
|
404
|
+
const raw = value.value;
|
|
405
|
+
const scalar = Array.isArray(raw) && raw.length === 1 ? raw[0] : raw;
|
|
406
|
+
validateModalityGuard(key, scalar, path);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (isPrivateHexTag(key)) {
|
|
410
|
+
throw new Error(
|
|
411
|
+
`${path}.${key}: private tags have no dictionary VR \u2014 use the raw form, e.g. { "vr": "LO", "value": \u2026 }`
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
validateModalityGuard(key, value, path);
|
|
309
415
|
if (typeof value === "string") {
|
|
310
416
|
for (const name of findTemplateTokens(value)) {
|
|
311
417
|
if (!TEMPLATE_VOCAB.includes(name)) {
|
|
@@ -802,27 +908,50 @@ function buildTagToKeyword() {
|
|
|
802
908
|
return map;
|
|
803
909
|
}
|
|
804
910
|
var tagToKeyword = buildTagToKeyword();
|
|
911
|
+
function toRawElement(raw) {
|
|
912
|
+
const value = raw.vr === "SQ" ? raw.value.map(
|
|
913
|
+
(item) => Object.fromEntries(
|
|
914
|
+
Object.entries(item).map(([tag, nested]) => [
|
|
915
|
+
tag.toUpperCase(),
|
|
916
|
+
toRawElement(nested)
|
|
917
|
+
])
|
|
918
|
+
)
|
|
919
|
+
) : raw.value;
|
|
920
|
+
return { vr: raw.vr, Value: Array.isArray(value) ? value : [value] };
|
|
921
|
+
}
|
|
805
922
|
function applyTagOverrides(dataset, tags) {
|
|
806
|
-
if (!tags) return;
|
|
923
|
+
if (!tags) return void 0;
|
|
924
|
+
let raw;
|
|
807
925
|
for (const [key, value] of Object.entries(tags)) {
|
|
808
|
-
if (
|
|
926
|
+
if (isRawTagValue(value)) {
|
|
927
|
+
raw ?? (raw = {});
|
|
928
|
+
raw[key.toUpperCase()] = toRawElement(value);
|
|
929
|
+
} else if (HEX_TAG_RE.test(key)) {
|
|
809
930
|
const keyword = tagToKeyword.get(key.toUpperCase());
|
|
810
|
-
if (keyword) {
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
931
|
+
if (!keyword) {
|
|
932
|
+
throw new Error(
|
|
933
|
+
`dicom-synth: unknown hex tag "${key}" \u2014 dictionary tags take plain values; private/unknown tags require the raw { vr, value } form`
|
|
934
|
+
);
|
|
814
935
|
}
|
|
936
|
+
dataset[keyword] = value;
|
|
815
937
|
} else {
|
|
816
938
|
dataset[key] = value;
|
|
817
939
|
}
|
|
818
940
|
}
|
|
941
|
+
return raw;
|
|
942
|
+
}
|
|
943
|
+
function rawScalarString(rawElements, tag) {
|
|
944
|
+
const value = rawElements?.[tag]?.Value;
|
|
945
|
+
return value?.length === 1 && typeof value[0] === "string" ? value[0] : void 0;
|
|
819
946
|
}
|
|
820
|
-
function syncMetaWithDataset(meta, dataset) {
|
|
821
|
-
|
|
822
|
-
|
|
947
|
+
function syncMetaWithDataset(meta, dataset, rawElements) {
|
|
948
|
+
const sopInstance = rawScalarString(rawElements, "00080018") ?? (typeof dataset.SOPInstanceUID === "string" ? dataset.SOPInstanceUID : void 0);
|
|
949
|
+
if (sopInstance !== void 0) {
|
|
950
|
+
meta.MediaStorageSOPInstanceUID = sopInstance;
|
|
823
951
|
}
|
|
824
|
-
|
|
825
|
-
|
|
952
|
+
const sopClass = rawScalarString(rawElements, "00080016") ?? (typeof dataset.SOPClassUID === "string" ? dataset.SOPClassUID : void 0);
|
|
953
|
+
if (sopClass !== void 0) {
|
|
954
|
+
meta.MediaStorageSOPClassUID = sopClass;
|
|
826
955
|
}
|
|
827
956
|
}
|
|
828
957
|
function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
|
|
@@ -862,7 +991,7 @@ function buildBaseImageDataset(uid, modality) {
|
|
|
862
991
|
...preset.attributes
|
|
863
992
|
};
|
|
864
993
|
}
|
|
865
|
-
function serializeDict(meta, dataset) {
|
|
994
|
+
function serializeDict(meta, dataset, rawElements) {
|
|
866
995
|
const dicomDict = new dcmjs.data.DicomDict(
|
|
867
996
|
dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
|
|
868
997
|
meta
|
|
@@ -871,14 +1000,17 @@ function serializeDict(meta, dataset) {
|
|
|
871
1000
|
dicomDict.dict = dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
|
|
872
1001
|
dataset
|
|
873
1002
|
);
|
|
1003
|
+
if (rawElements) {
|
|
1004
|
+
Object.assign(dicomDict.dict, rawElements);
|
|
1005
|
+
}
|
|
874
1006
|
return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
|
|
875
1007
|
}
|
|
876
1008
|
var MAX_DIMENSION = 65535;
|
|
877
|
-
function applySizing(dataset, meta, spec) {
|
|
1009
|
+
function applySizing(dataset, meta, spec, rawElements) {
|
|
878
1010
|
if (spec.targetSizeKb !== void 0) {
|
|
879
1011
|
const clampDim = (n) => Math.min(MAX_DIMENSION, Math.max(1, Math.round(n)));
|
|
880
1012
|
const minimalPixelBytes = dataset.PixelData.byteLength;
|
|
881
|
-
const overhead = serializeDict(meta, dataset).length - minimalPixelBytes;
|
|
1013
|
+
const overhead = serializeDict(meta, dataset, rawElements).length - minimalPixelBytes;
|
|
882
1014
|
const cells = Math.max(
|
|
883
1015
|
1,
|
|
884
1016
|
Math.round((spec.targetSizeKb * 1024 - overhead) / 2)
|
|
@@ -909,11 +1041,11 @@ function buildImageBuffer(spec, uid) {
|
|
|
909
1041
|
dataset.Laterality = "";
|
|
910
1042
|
dataset.PatientWeight = "0";
|
|
911
1043
|
}
|
|
912
|
-
applyTagOverrides(dataset, spec.tags);
|
|
1044
|
+
const rawElements = applyTagOverrides(dataset, spec.tags);
|
|
913
1045
|
const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
|
|
914
|
-
syncMetaWithDataset(meta, dataset);
|
|
915
|
-
applySizing(dataset, meta, spec);
|
|
916
|
-
return serializeDict(meta, dataset);
|
|
1046
|
+
syncMetaWithDataset(meta, dataset, rawElements);
|
|
1047
|
+
applySizing(dataset, meta, spec, rawElements);
|
|
1048
|
+
return { buffer: serializeDict(meta, dataset, rawElements), rawElements };
|
|
917
1049
|
}
|
|
918
1050
|
function buildFakeSignatureBuffer() {
|
|
919
1051
|
const buf = Buffer.alloc(200, 0);
|
|
@@ -935,11 +1067,11 @@ function buildBufferForSpec(spec, uid) {
|
|
|
935
1067
|
case "vendor-warnings-image":
|
|
936
1068
|
return buildImageBuffer(spec, uid);
|
|
937
1069
|
case "fake-signature":
|
|
938
|
-
return buildFakeSignatureBuffer();
|
|
1070
|
+
return { buffer: buildFakeSignatureBuffer() };
|
|
939
1071
|
case "non-dicom":
|
|
940
|
-
return buildNonDicomBuffer(spec);
|
|
1072
|
+
return { buffer: buildNonDicomBuffer(spec) };
|
|
941
1073
|
case "dicomdir":
|
|
942
|
-
return buildDicomdirBuffer();
|
|
1074
|
+
return { buffer: buildDicomdirBuffer() };
|
|
943
1075
|
case "large-image":
|
|
944
1076
|
throw new Error(
|
|
945
1077
|
"large-image cannot be generated in memory \u2014 use writeCollectionFromSpec (disk-only streaming)"
|
|
@@ -1031,12 +1163,12 @@ function writeNative(outPath, params, dims, zeroChunk) {
|
|
|
1031
1163
|
const { modality, uid, tags } = params;
|
|
1032
1164
|
const meta = buildMeta(uid, modality);
|
|
1033
1165
|
const dataset = buildBaseImageDataset(uid, modality);
|
|
1034
|
-
applyTagOverrides(dataset, tags);
|
|
1035
|
-
syncMetaWithDataset(meta, dataset);
|
|
1166
|
+
const rawElements = applyTagOverrides(dataset, tags);
|
|
1167
|
+
syncMetaWithDataset(meta, dataset, rawElements);
|
|
1036
1168
|
dataset.Rows = dims.rows;
|
|
1037
1169
|
dataset.Columns = dims.columns;
|
|
1038
1170
|
const minimalPixelBytes = dataset.PixelData.byteLength;
|
|
1039
|
-
const probe = serializeDict(meta, dataset);
|
|
1171
|
+
const probe = serializeDict(meta, dataset, rawElements);
|
|
1040
1172
|
const pixelElement = Buffer.alloc(12);
|
|
1041
1173
|
PIXEL_DATA_OW_HEADER.copy(pixelElement);
|
|
1042
1174
|
pixelElement.writeUInt32LE(minimalPixelBytes, 8);
|
|
@@ -1065,11 +1197,11 @@ function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk
|
|
|
1065
1197
|
TransferSyntaxUID: RLE_TRANSFER_SYNTAX_UID
|
|
1066
1198
|
};
|
|
1067
1199
|
const dataset = buildBaseImageDataset(uid, modality);
|
|
1068
|
-
applyTagOverrides(dataset, tags);
|
|
1069
|
-
syncMetaWithDataset(meta, dataset);
|
|
1200
|
+
const rawElements = applyTagOverrides(dataset, tags);
|
|
1201
|
+
syncMetaWithDataset(meta, dataset, rawElements);
|
|
1070
1202
|
dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
|
|
1071
1203
|
dataset._vrMap = { PixelData: "OB" };
|
|
1072
|
-
const probe = serializeDict(meta, dataset);
|
|
1204
|
+
const probe = serializeDict(meta, dataset, rawElements);
|
|
1073
1205
|
const idx = probe.indexOf(PIXEL_DATA_OB_HEADER);
|
|
1074
1206
|
if (idx < 0 || probe.readUInt32LE(idx + 8) !== 4294967295) {
|
|
1075
1207
|
throw new Error("failed to locate encapsulated PixelData element header");
|
|
@@ -1187,7 +1319,7 @@ var VIOLATION_LEVEL = {
|
|
|
1187
1319
|
"missing-meta-header": "byte",
|
|
1188
1320
|
"malformed-sq-delimiter": "byte"
|
|
1189
1321
|
};
|
|
1190
|
-
function applyTagLevel(buffer, violations) {
|
|
1322
|
+
function applyTagLevel(buffer, violations, rawElements) {
|
|
1191
1323
|
if (violations.length === 0) return buffer;
|
|
1192
1324
|
let parsed;
|
|
1193
1325
|
try {
|
|
@@ -1221,6 +1353,9 @@ function applyTagLevel(buffer, violations) {
|
|
|
1221
1353
|
}
|
|
1222
1354
|
}
|
|
1223
1355
|
parsed.dict = dcmjsAny.data.DicomMetaDictionary.denaturalizeDataset(natural);
|
|
1356
|
+
if (rawElements) {
|
|
1357
|
+
Object.assign(parsed.dict, rawElements);
|
|
1358
|
+
}
|
|
1224
1359
|
if (violations.includes("uid-too-long")) {
|
|
1225
1360
|
;
|
|
1226
1361
|
parsed.dict["00080018"] = {
|
|
@@ -1253,11 +1388,14 @@ function applyByteLevel(buffer, violations) {
|
|
|
1253
1388
|
}
|
|
1254
1389
|
return result;
|
|
1255
1390
|
}
|
|
1256
|
-
function applyViolations(buffer, violations) {
|
|
1391
|
+
function applyViolations(buffer, violations, rawElements) {
|
|
1257
1392
|
if (violations.length === 0) return buffer;
|
|
1258
1393
|
const tagViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "tag");
|
|
1259
1394
|
const byteViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "byte");
|
|
1260
|
-
return applyByteLevel(
|
|
1395
|
+
return applyByteLevel(
|
|
1396
|
+
applyTagLevel(buffer, tagViolations, rawElements),
|
|
1397
|
+
byteViolations
|
|
1398
|
+
);
|
|
1261
1399
|
}
|
|
1262
1400
|
|
|
1263
1401
|
// src/collection/writer.ts
|
|
@@ -1331,10 +1469,11 @@ async function generateFile(spec, options) {
|
|
|
1331
1469
|
...spec,
|
|
1332
1470
|
tags: resolveTagTemplates(spec.tags, { index, ...options?.context })
|
|
1333
1471
|
} : spec;
|
|
1334
|
-
|
|
1472
|
+
const built = buildBufferForSpec(resolvedSpec, uid);
|
|
1473
|
+
let buffer = built.buffer;
|
|
1335
1474
|
const violations = "violations" in spec ? spec.violations : void 0;
|
|
1336
1475
|
if (violations?.length) {
|
|
1337
|
-
buffer = applyViolations(buffer, violations);
|
|
1476
|
+
buffer = applyViolations(buffer, violations, built.rawElements);
|
|
1338
1477
|
}
|
|
1339
1478
|
const name = filename(spec.type, index, padWidth);
|
|
1340
1479
|
return {
|
|
@@ -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,7 @@ function validateParametricSpec(raw) {
|
|
|
682
788
|
export {
|
|
683
789
|
HEX_TAG_RE,
|
|
684
790
|
isImageType,
|
|
791
|
+
isRawTagValue,
|
|
685
792
|
positionalName,
|
|
686
793
|
validateDatasetSpec,
|
|
687
794
|
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
|
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,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
|
+
};
|
|
4
8
|
export declare function positionalName(prefix: string, ordinal: number): string;
|
|
5
9
|
export declare function validateDatasetSpec(raw: unknown): DatasetSpec;
|
|
6
10
|
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.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",
|