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
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;
|
|
819
942
|
}
|
|
820
|
-
function
|
|
821
|
-
|
|
822
|
-
|
|
943
|
+
function rawScalarString(rawElements, tag) {
|
|
944
|
+
const value = rawElements?.[tag]?.Value;
|
|
945
|
+
return value?.length === 1 && typeof value[0] === "string" ? value[0] : void 0;
|
|
946
|
+
}
|
|
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 {
|
|
@@ -1677,13 +1816,28 @@ function buildHexToKeyword() {
|
|
|
1677
1816
|
}
|
|
1678
1817
|
return map;
|
|
1679
1818
|
}
|
|
1819
|
+
function buildKeepPlan(options) {
|
|
1820
|
+
const { keywords, privateHex } = options.keepTags ? resolveKeepTags(options.keepTags) : { keywords: [], privateHex: /* @__PURE__ */ new Set() };
|
|
1821
|
+
const allPrivate = options.keepPrivateTags === true;
|
|
1822
|
+
return {
|
|
1823
|
+
keywords,
|
|
1824
|
+
privateHex,
|
|
1825
|
+
allPrivate,
|
|
1826
|
+
capturingPrivate: allPrivate || privateHex.size > 0
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1680
1829
|
function resolveKeepTags(keepTags) {
|
|
1681
1830
|
const nameMap = dcmjsAny2.data.DicomMetaDictionary.nameMap;
|
|
1682
1831
|
let hexToKeyword;
|
|
1683
1832
|
const kept = [];
|
|
1833
|
+
const privateHex = /* @__PURE__ */ new Set();
|
|
1684
1834
|
for (const tag of keepTags) {
|
|
1685
1835
|
let keyword;
|
|
1686
1836
|
if (HEX_TAG_RE2.test(tag)) {
|
|
1837
|
+
if (isPrivateHexTag(tag)) {
|
|
1838
|
+
privateHex.add(tag.toUpperCase());
|
|
1839
|
+
continue;
|
|
1840
|
+
}
|
|
1687
1841
|
hexToKeyword ?? (hexToKeyword = buildHexToKeyword());
|
|
1688
1842
|
const mapped = hexToKeyword.get(tag.toUpperCase());
|
|
1689
1843
|
if (!mapped) {
|
|
@@ -1710,7 +1864,71 @@ function resolveKeepTags(keepTags) {
|
|
|
1710
1864
|
}
|
|
1711
1865
|
kept.push(keyword);
|
|
1712
1866
|
}
|
|
1713
|
-
return kept;
|
|
1867
|
+
return { keywords: kept, privateHex };
|
|
1868
|
+
}
|
|
1869
|
+
function isUnrepresentableValue(value) {
|
|
1870
|
+
return value === null || value === void 0 || typeof value === "number" && !Number.isFinite(value) || value instanceof ArrayBuffer || ArrayBuffer.isView(value);
|
|
1871
|
+
}
|
|
1872
|
+
function toRawCapture(el, skipped) {
|
|
1873
|
+
const vr = el.vr;
|
|
1874
|
+
if (typeof vr !== "string") {
|
|
1875
|
+
skipped.count++;
|
|
1876
|
+
return null;
|
|
1877
|
+
}
|
|
1878
|
+
if (vr === "SQ") {
|
|
1879
|
+
const items = [];
|
|
1880
|
+
for (const item of el.Value ?? []) {
|
|
1881
|
+
if (typeof item !== "object" || item === null) continue;
|
|
1882
|
+
const converted = {};
|
|
1883
|
+
for (const [tag, nested] of Object.entries(item)) {
|
|
1884
|
+
if (!HEX_TAG_RE2.test(tag)) continue;
|
|
1885
|
+
const capture = toRawCapture(nested, skipped);
|
|
1886
|
+
if (capture) converted[tag.toUpperCase()] = capture;
|
|
1887
|
+
}
|
|
1888
|
+
items.push(converted);
|
|
1889
|
+
}
|
|
1890
|
+
return { vr, value: items };
|
|
1891
|
+
}
|
|
1892
|
+
const values = el.Value ?? [];
|
|
1893
|
+
if (values.some(isUnrepresentableValue)) {
|
|
1894
|
+
skipped.count++;
|
|
1895
|
+
return null;
|
|
1896
|
+
}
|
|
1897
|
+
return { vr, value: values.length === 1 ? values[0] : values };
|
|
1898
|
+
}
|
|
1899
|
+
function capturePrivateTags(rawDict, keep, skipped) {
|
|
1900
|
+
const captured = {};
|
|
1901
|
+
let found = false;
|
|
1902
|
+
for (const [tag, el] of Object.entries(rawDict)) {
|
|
1903
|
+
if (!HEX_TAG_RE2.test(tag) || !isPrivateHexTag(tag)) continue;
|
|
1904
|
+
if (!keep.allPrivate && !keep.privateHex.has(tag.toUpperCase())) continue;
|
|
1905
|
+
const capture = toRawCapture(el, skipped);
|
|
1906
|
+
if (capture) {
|
|
1907
|
+
captured[tag.toUpperCase()] = capture;
|
|
1908
|
+
found = true;
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
return found ? captured : void 0;
|
|
1912
|
+
}
|
|
1913
|
+
function hashRawCaptureUids(capture, salt, map) {
|
|
1914
|
+
if (capture.vr === "UI") {
|
|
1915
|
+
return isUidLeaf(capture.value) ? { vr: "UI", value: hashUidValue(capture.value, salt, map) } : capture;
|
|
1916
|
+
}
|
|
1917
|
+
if (capture.vr === "SQ") {
|
|
1918
|
+
const items = capture.value;
|
|
1919
|
+
return {
|
|
1920
|
+
vr: "SQ",
|
|
1921
|
+
value: items.map(
|
|
1922
|
+
(item) => Object.fromEntries(
|
|
1923
|
+
Object.entries(item).map(([tag, nested]) => [
|
|
1924
|
+
tag,
|
|
1925
|
+
hashRawCaptureUids(nested, salt, map)
|
|
1926
|
+
])
|
|
1927
|
+
)
|
|
1928
|
+
)
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
return capture;
|
|
1714
1932
|
}
|
|
1715
1933
|
function* walkFiles(dir) {
|
|
1716
1934
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -1728,13 +1946,16 @@ function toArrayBuffer(buf) {
|
|
|
1728
1946
|
buf.byteOffset + buf.byteLength
|
|
1729
1947
|
);
|
|
1730
1948
|
}
|
|
1731
|
-
function
|
|
1949
|
+
function parseParts(ab) {
|
|
1732
1950
|
const parsed = dcmjsAny2.data.DicomMessage.readFile(ab);
|
|
1733
|
-
return
|
|
1951
|
+
return {
|
|
1952
|
+
record: dcmjsAny2.data.DicomMetaDictionary.naturalizeDataset(parsed.dict),
|
|
1953
|
+
rawDict: parsed.dict
|
|
1954
|
+
};
|
|
1734
1955
|
}
|
|
1735
1956
|
function parseFull(path) {
|
|
1736
1957
|
try {
|
|
1737
|
-
return
|
|
1958
|
+
return parseParts(toArrayBuffer(readFileSync(path)));
|
|
1738
1959
|
} catch {
|
|
1739
1960
|
return null;
|
|
1740
1961
|
}
|
|
@@ -1792,7 +2013,7 @@ function readHeaderOnly(path) {
|
|
|
1792
2013
|
EMPTY_OW_PIXEL_DATA
|
|
1793
2014
|
]);
|
|
1794
2015
|
try {
|
|
1795
|
-
return
|
|
2016
|
+
return parseParts(toArrayBuffer(headerOnly));
|
|
1796
2017
|
} catch {
|
|
1797
2018
|
return null;
|
|
1798
2019
|
}
|
|
@@ -1918,6 +2139,20 @@ function mergeTags(a, b) {
|
|
|
1918
2139
|
if (!b) return a;
|
|
1919
2140
|
return { ...a, ...b };
|
|
1920
2141
|
}
|
|
2142
|
+
function mergePrivateTags(rec, salt, uidMap) {
|
|
2143
|
+
if (!rec.privateTags) return;
|
|
2144
|
+
const hashed = Object.fromEntries(
|
|
2145
|
+
Object.entries(rec.privateTags).map(([tag, capture]) => [
|
|
2146
|
+
tag,
|
|
2147
|
+
salt === void 0 ? capture : hashRawCaptureUids(
|
|
2148
|
+
capture,
|
|
2149
|
+
salt,
|
|
2150
|
+
uidMap
|
|
2151
|
+
)
|
|
2152
|
+
])
|
|
2153
|
+
);
|
|
2154
|
+
rec.tags = mergeTags(rec.tags, hashed);
|
|
2155
|
+
}
|
|
1921
2156
|
function scanFile(path) {
|
|
1922
2157
|
let size;
|
|
1923
2158
|
try {
|
|
@@ -1927,7 +2162,7 @@ function scanFile(path) {
|
|
|
1927
2162
|
}
|
|
1928
2163
|
if (size > MAX_STREAM_BYTES) return null;
|
|
1929
2164
|
const large = size > MAX_PIXEL_BYTES;
|
|
1930
|
-
return { size, large,
|
|
2165
|
+
return { size, large, parsed: large ? readHeaderOnly(path) : parseFull(path) };
|
|
1931
2166
|
}
|
|
1932
2167
|
function presetModalityOf(record) {
|
|
1933
2168
|
if (typeof record.Modality !== "string") return void 0;
|
|
@@ -1942,6 +2177,13 @@ function hashRecordUids(rec, salt, uidMap, sweptKeepTags) {
|
|
|
1942
2177
|
}
|
|
1943
2178
|
rec.tags = mergeTags(rec.tags, hashUidTags(rec.uidOriginals, salt, uidMap));
|
|
1944
2179
|
}
|
|
2180
|
+
function warnPrivateBinarySkipped(count) {
|
|
2181
|
+
if (count > 0) {
|
|
2182
|
+
console.warn(
|
|
2183
|
+
`describe: ${count} private element(s) with binary payloads were not captured \u2014 binary values cannot ride a JSON spec`
|
|
2184
|
+
);
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
1945
2187
|
function warnSweptKeepTags(sweptKeepTags) {
|
|
1946
2188
|
for (const keyword of sweptKeepTags) {
|
|
1947
2189
|
console.warn(
|
|
@@ -2087,14 +2329,16 @@ function emitTree(acc) {
|
|
|
2087
2329
|
}
|
|
2088
2330
|
return nodes;
|
|
2089
2331
|
}
|
|
2090
|
-
function describeTree(dir, options,
|
|
2332
|
+
function describeTree(dir, options, keep) {
|
|
2091
2333
|
const root = { dirs: /* @__PURE__ */ new Map(), files: [] };
|
|
2092
2334
|
const stats = {
|
|
2093
2335
|
dicomFiles: 0,
|
|
2094
2336
|
skipped: 0,
|
|
2095
2337
|
unknownModality: 0,
|
|
2096
|
-
nonDicomFiles: 0
|
|
2338
|
+
nonDicomFiles: 0,
|
|
2339
|
+
privateBinarySkipped: 0
|
|
2097
2340
|
};
|
|
2341
|
+
const privateSkipped = { count: 0 };
|
|
2098
2342
|
const studyUids = /* @__PURE__ */ new Set();
|
|
2099
2343
|
const dicomLeaves = [];
|
|
2100
2344
|
for (const path of [...walkFiles(dir)].sort()) {
|
|
@@ -2103,7 +2347,8 @@ function describeTree(dir, options, keepKeywords) {
|
|
|
2103
2347
|
stats.skipped++;
|
|
2104
2348
|
continue;
|
|
2105
2349
|
}
|
|
2106
|
-
const { size, large,
|
|
2350
|
+
const { size, large, parsed } = scanned;
|
|
2351
|
+
const record = parsed?.record;
|
|
2107
2352
|
const studyUid = record?.StudyInstanceUID;
|
|
2108
2353
|
const seriesUid = record?.SeriesInstanceUID;
|
|
2109
2354
|
const relDirs = relative(dir, path).split(sep).slice(0, -1);
|
|
@@ -2134,9 +2379,10 @@ function describeTree(dir, options, keepKeywords) {
|
|
|
2134
2379
|
modality,
|
|
2135
2380
|
tags: mergeTags(
|
|
2136
2381
|
instanceNumber,
|
|
2137
|
-
|
|
2382
|
+
keep.keywords.length ? extractTags(record, keep.keywords) : void 0
|
|
2138
2383
|
),
|
|
2139
|
-
uidOriginals: collectUidOriginals(record)
|
|
2384
|
+
uidOriginals: collectUidOriginals(record),
|
|
2385
|
+
privateTags: keep.capturingPrivate && parsed ? capturePrivateTags(parsed.rawDict, keep, privateSkipped) : void 0
|
|
2140
2386
|
},
|
|
2141
2387
|
studyUid,
|
|
2142
2388
|
seriesUid
|
|
@@ -2152,18 +2398,21 @@ function describeTree(dir, options, keepKeywords) {
|
|
|
2152
2398
|
const sweptKeepTags = /* @__PURE__ */ new Set();
|
|
2153
2399
|
for (const { rec, studyUid, seriesUid } of dicomLeaves) {
|
|
2154
2400
|
hashRecordUids(rec, salt, uidMap, sweptKeepTags);
|
|
2401
|
+
mergePrivateTags(rec, salt, uidMap);
|
|
2155
2402
|
rec.tags = mergeTags(rec.tags, {
|
|
2156
2403
|
StudyInstanceUID: hashUidVia(uidMap, salt, studyUid),
|
|
2157
2404
|
SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
|
|
2158
2405
|
});
|
|
2159
2406
|
}
|
|
2160
2407
|
warnSweptKeepTags(sweptKeepTags);
|
|
2408
|
+
stats.privateBinarySkipped = privateSkipped.count;
|
|
2409
|
+
warnPrivateBinarySkipped(privateSkipped.count);
|
|
2161
2410
|
const spec = { tree: emitTree(root), uidSalt: salt };
|
|
2162
2411
|
validateDatasetSpec(spec);
|
|
2163
2412
|
return { spec, stats };
|
|
2164
2413
|
}
|
|
2165
2414
|
function describeDirectory(dir, options = {}) {
|
|
2166
|
-
const
|
|
2415
|
+
const keep = buildKeepPlan(options);
|
|
2167
2416
|
const preserveUids = options.preserveUids ?? true;
|
|
2168
2417
|
if (options.captureTree) {
|
|
2169
2418
|
if (!preserveUids) {
|
|
@@ -2171,10 +2420,10 @@ function describeDirectory(dir, options = {}) {
|
|
|
2171
2420
|
"describe: captureTree requires preserveUids \u2014 the captured tree carries its grouping as hashed UID tags"
|
|
2172
2421
|
);
|
|
2173
2422
|
}
|
|
2174
|
-
return describeTree(dir, options,
|
|
2423
|
+
return describeTree(dir, options, keep);
|
|
2175
2424
|
}
|
|
2176
2425
|
const uidMap = /* @__PURE__ */ new Map();
|
|
2177
|
-
const useRecords =
|
|
2426
|
+
const useRecords = keep.keywords.length > 0 || preserveUids || keep.capturingPrivate;
|
|
2178
2427
|
const tolerance = options.sizeTolerance ?? 0;
|
|
2179
2428
|
if (!Number.isFinite(tolerance) || tolerance < 0) {
|
|
2180
2429
|
throw new Error(
|
|
@@ -2186,15 +2435,18 @@ function describeDirectory(dir, options = {}) {
|
|
|
2186
2435
|
dicomFiles: 0,
|
|
2187
2436
|
skipped: 0,
|
|
2188
2437
|
unknownModality: 0,
|
|
2189
|
-
nonDicomFiles: 0
|
|
2438
|
+
nonDicomFiles: 0,
|
|
2439
|
+
privateBinarySkipped: 0
|
|
2190
2440
|
};
|
|
2441
|
+
const privateSkipped = { count: 0 };
|
|
2191
2442
|
for (const path of [...walkFiles(dir)].sort()) {
|
|
2192
2443
|
const scanned = scanFile(path);
|
|
2193
2444
|
if (!scanned) {
|
|
2194
2445
|
stats.skipped++;
|
|
2195
2446
|
continue;
|
|
2196
2447
|
}
|
|
2197
|
-
const { size, large,
|
|
2448
|
+
const { size, large, parsed } = scanned;
|
|
2449
|
+
const record = parsed?.record;
|
|
2198
2450
|
const studyUid = record?.StudyInstanceUID;
|
|
2199
2451
|
const seriesUid = record?.SeriesInstanceUID;
|
|
2200
2452
|
if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
|
|
@@ -2205,7 +2457,7 @@ function describeDirectory(dir, options = {}) {
|
|
|
2205
2457
|
const preset = presetModalityOf(record);
|
|
2206
2458
|
if (preset === "unsupported") stats.unknownModality++;
|
|
2207
2459
|
const modality = preset === "unsupported" ? void 0 : preset;
|
|
2208
|
-
const keptTags =
|
|
2460
|
+
const keptTags = keep.keywords.length ? extractTags(record, keep.keywords) : void 0;
|
|
2209
2461
|
const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
|
|
2210
2462
|
const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
|
|
2211
2463
|
const fileRecord = {
|
|
@@ -2213,7 +2465,8 @@ function describeDirectory(dir, options = {}) {
|
|
|
2213
2465
|
bytes,
|
|
2214
2466
|
modality,
|
|
2215
2467
|
tags: keptTags,
|
|
2216
|
-
uidOriginals
|
|
2468
|
+
uidOriginals,
|
|
2469
|
+
privateTags: keep.capturingPrivate && parsed ? capturePrivateTags(parsed.rawDict, keep, privateSkipped) : void 0
|
|
2217
2470
|
};
|
|
2218
2471
|
let study = studies.get(studyUid);
|
|
2219
2472
|
if (!study) {
|
|
@@ -2232,18 +2485,23 @@ function describeDirectory(dir, options = {}) {
|
|
|
2232
2485
|
}
|
|
2233
2486
|
}
|
|
2234
2487
|
const salt = preserveUids ? options.uidSalt ?? deriveSalt([...studies.keys()]) : void 0;
|
|
2235
|
-
if (salt !== void 0) {
|
|
2488
|
+
if (salt !== void 0 || keep.capturingPrivate) {
|
|
2236
2489
|
const sweptKeepTags = /* @__PURE__ */ new Set();
|
|
2237
2490
|
for (const study of studies.values()) {
|
|
2238
2491
|
for (const series of study.values()) {
|
|
2239
2492
|
if (!Array.isArray(series)) continue;
|
|
2240
2493
|
for (const rec of series) {
|
|
2241
|
-
|
|
2494
|
+
if (salt !== void 0) {
|
|
2495
|
+
hashRecordUids(rec, salt, uidMap, sweptKeepTags);
|
|
2496
|
+
}
|
|
2497
|
+
mergePrivateTags(rec, salt, uidMap);
|
|
2242
2498
|
}
|
|
2243
2499
|
}
|
|
2244
2500
|
}
|
|
2245
2501
|
warnSweptKeepTags(sweptKeepTags);
|
|
2246
2502
|
}
|
|
2503
|
+
stats.privateBinarySkipped = privateSkipped.count;
|
|
2504
|
+
warnPrivateBinarySkipped(privateSkipped.count);
|
|
2247
2505
|
const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
|
|
2248
2506
|
...spec2,
|
|
2249
2507
|
tags: {
|