dicom-synth 1.17.0 → 1.19.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/README.md +1 -1
- package/bin/dicom-synth-describe.mjs +12 -1
- package/dist/esm/collection/generate.js +977 -0
- package/dist/esm/collection/writer.js +243 -109
- package/dist/esm/describe/describe.js +281 -25
- package/dist/esm/index.js +535 -243
- package/dist/esm/nonStandardDicom/dicomdir.js +14 -15
- package/dist/esm/platform/bytes.js +39 -0
- package/dist/esm/platform/random.js +22 -0
- package/dist/esm/platform/sha256.js +131 -0
- package/dist/esm/schema/parametric.js +73 -4
- package/dist/esm/schema/validate.js +1 -0
- package/dist/esm/syntheticFixtures/generator.js +31 -14
- package/dist/esm/syntheticFixtures/streamWrite.js +156 -13
- package/dist/esm/syntheticFixtures/uid.js +148 -4
- package/dist/esm/syntheticFixtures/violations.js +18 -6
- package/dist/types/collection/generate.d.ts +43 -0
- package/dist/types/collection/writer.d.ts +0 -27
- package/dist/types/describe/describe.d.ts +12 -1
- package/dist/types/index.d.ts +2 -1
- package/dist/types/nonStandardDicom/dicomdir.d.ts +1 -6
- package/dist/types/platform/bytes.d.ts +4 -0
- package/dist/types/platform/random.d.ts +2 -0
- package/dist/types/platform/sha256.d.ts +1 -0
- package/dist/types/schema/validate.d.ts +1 -0
- package/dist/types/syntheticFixtures/generator.d.ts +2 -2
- package/dist/types/syntheticFixtures/violations.d.ts +1 -1
- package/package.json +1 -1
package/dist/esm/index.js
CHANGED
|
@@ -1,12 +1,46 @@
|
|
|
1
|
-
// src/
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
// src/platform/bytes.ts
|
|
2
|
+
function bytesToHex(bytes) {
|
|
3
|
+
let hex = "";
|
|
4
|
+
for (const byte of bytes) {
|
|
5
|
+
hex += byte.toString(16).padStart(2, "0");
|
|
6
|
+
}
|
|
7
|
+
return hex;
|
|
8
|
+
}
|
|
9
|
+
function utf8Bytes(text) {
|
|
10
|
+
return new TextEncoder().encode(text);
|
|
11
|
+
}
|
|
12
|
+
function concatBytes(...parts) {
|
|
13
|
+
const out = new Uint8Array(parts.reduce((sum, p) => sum + p.length, 0));
|
|
14
|
+
let offset = 0;
|
|
15
|
+
for (const part of parts) {
|
|
16
|
+
out.set(part, offset);
|
|
17
|
+
offset += part.length;
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
function patternBytes(length, pattern) {
|
|
22
|
+
const out = new Uint8Array(length);
|
|
23
|
+
const seed = utf8Bytes(pattern);
|
|
24
|
+
if (length === 0 || seed.length === 0) return out;
|
|
25
|
+
out.set(seed.subarray(0, Math.min(seed.length, length)));
|
|
26
|
+
let filled = Math.min(seed.length, length);
|
|
27
|
+
while (filled < length) {
|
|
28
|
+
const chunk = Math.min(filled, length - filled);
|
|
29
|
+
out.copyWithin(filled, 0, chunk);
|
|
30
|
+
filled += chunk;
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
6
34
|
|
|
7
|
-
// src/
|
|
8
|
-
|
|
9
|
-
|
|
35
|
+
// src/platform/random.ts
|
|
36
|
+
function randomUint32() {
|
|
37
|
+
return globalThis.crypto.getRandomValues(new Uint32Array(1))[0];
|
|
38
|
+
}
|
|
39
|
+
function randomHex(byteLength) {
|
|
40
|
+
return bytesToHex(
|
|
41
|
+
globalThis.crypto.getRandomValues(new Uint8Array(byteLength))
|
|
42
|
+
);
|
|
43
|
+
}
|
|
10
44
|
|
|
11
45
|
// src/syntheticFixtures/tagTemplate.ts
|
|
12
46
|
var TEMPLATE_VOCAB = [
|
|
@@ -52,6 +86,10 @@ function resolveString(value, context) {
|
|
|
52
86
|
});
|
|
53
87
|
}
|
|
54
88
|
|
|
89
|
+
// src/schema/limits.ts
|
|
90
|
+
var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
|
|
91
|
+
var MAX_STREAM_BYTES = 50 * 1024 * 1024 * 1024;
|
|
92
|
+
|
|
55
93
|
// src/schema/validate.ts
|
|
56
94
|
var TYPE_CAPABILITIES = {
|
|
57
95
|
"valid-image": { image: true },
|
|
@@ -840,23 +878,22 @@ function loadDcmjs() {
|
|
|
840
878
|
var dcmjs = loadDcmjs();
|
|
841
879
|
|
|
842
880
|
// src/nonStandardDicom/dicomdir.ts
|
|
843
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
844
|
-
import { resolve } from "node:path";
|
|
845
881
|
var DICOMDIR_SOP_CLASS_UID = "1.2.840.10008.1.3.10";
|
|
846
882
|
function buildDicomdirBuffer() {
|
|
847
883
|
const uid = DICOMDIR_SOP_CLASS_UID;
|
|
848
|
-
const buf =
|
|
849
|
-
|
|
884
|
+
const buf = new Uint8Array(256);
|
|
885
|
+
const view = new DataView(buf.buffer);
|
|
886
|
+
buf.set(utf8Bytes("DICM"), 128);
|
|
850
887
|
let off = 132;
|
|
851
|
-
|
|
888
|
+
view.setUint16(off, 2, true);
|
|
852
889
|
off += 2;
|
|
853
|
-
|
|
890
|
+
view.setUint16(off, 2, true);
|
|
854
891
|
off += 2;
|
|
855
|
-
buf.
|
|
892
|
+
buf.set(utf8Bytes("UI"), off);
|
|
856
893
|
off += 2;
|
|
857
|
-
|
|
894
|
+
view.setUint16(off, uid.length, true);
|
|
858
895
|
off += 2;
|
|
859
|
-
buf.
|
|
896
|
+
buf.set(utf8Bytes(uid), off);
|
|
860
897
|
off += uid.length;
|
|
861
898
|
return buf.subarray(0, off);
|
|
862
899
|
}
|
|
@@ -1003,7 +1040,7 @@ function serializeDict(meta, dataset, rawElements) {
|
|
|
1003
1040
|
if (rawElements) {
|
|
1004
1041
|
Object.assign(dicomDict.dict, rawElements);
|
|
1005
1042
|
}
|
|
1006
|
-
return
|
|
1043
|
+
return new Uint8Array(dicomDict.write({ allowInvalidVRLength: true }));
|
|
1007
1044
|
}
|
|
1008
1045
|
var MAX_DIMENSION = 65535;
|
|
1009
1046
|
function applySizing(dataset, meta, spec, rawElements) {
|
|
@@ -1048,8 +1085,8 @@ function buildImageBuffer(spec, uid) {
|
|
|
1048
1085
|
return { buffer: serializeDict(meta, dataset, rawElements), rawElements };
|
|
1049
1086
|
}
|
|
1050
1087
|
function buildFakeSignatureBuffer() {
|
|
1051
|
-
const buf =
|
|
1052
|
-
buf.
|
|
1088
|
+
const buf = new Uint8Array(200);
|
|
1089
|
+
buf.set(utf8Bytes("XXXX"), 128);
|
|
1053
1090
|
return buf;
|
|
1054
1091
|
}
|
|
1055
1092
|
function sizedNonDicomBytes(spec) {
|
|
@@ -1057,8 +1094,8 @@ function sizedNonDicomBytes(spec) {
|
|
|
1057
1094
|
}
|
|
1058
1095
|
function buildNonDicomBuffer(spec) {
|
|
1059
1096
|
const bytes = sizedNonDicomBytes(spec);
|
|
1060
|
-
if (bytes !== void 0) return
|
|
1061
|
-
return
|
|
1097
|
+
if (bytes !== void 0) return patternBytes(bytes, "not dicom ");
|
|
1098
|
+
return utf8Bytes(spec.content ?? "not dicom");
|
|
1062
1099
|
}
|
|
1063
1100
|
function buildBufferForSpec(spec, uid) {
|
|
1064
1101
|
switch (spec.type) {
|
|
@@ -1079,19 +1116,143 @@ function buildBufferForSpec(spec, uid) {
|
|
|
1079
1116
|
}
|
|
1080
1117
|
}
|
|
1081
1118
|
|
|
1082
|
-
// src/
|
|
1083
|
-
|
|
1084
|
-
|
|
1119
|
+
// src/platform/sha256.ts
|
|
1120
|
+
var K = new Uint32Array([
|
|
1121
|
+
1116352408,
|
|
1122
|
+
1899447441,
|
|
1123
|
+
3049323471,
|
|
1124
|
+
3921009573,
|
|
1125
|
+
961987163,
|
|
1126
|
+
1508970993,
|
|
1127
|
+
2453635748,
|
|
1128
|
+
2870763221,
|
|
1129
|
+
3624381080,
|
|
1130
|
+
310598401,
|
|
1131
|
+
607225278,
|
|
1132
|
+
1426881987,
|
|
1133
|
+
1925078388,
|
|
1134
|
+
2162078206,
|
|
1135
|
+
2614888103,
|
|
1136
|
+
3248222580,
|
|
1137
|
+
3835390401,
|
|
1138
|
+
4022224774,
|
|
1139
|
+
264347078,
|
|
1140
|
+
604807628,
|
|
1141
|
+
770255983,
|
|
1142
|
+
1249150122,
|
|
1143
|
+
1555081692,
|
|
1144
|
+
1996064986,
|
|
1145
|
+
2554220882,
|
|
1146
|
+
2821834349,
|
|
1147
|
+
2952996808,
|
|
1148
|
+
3210313671,
|
|
1149
|
+
3336571891,
|
|
1150
|
+
3584528711,
|
|
1151
|
+
113926993,
|
|
1152
|
+
338241895,
|
|
1153
|
+
666307205,
|
|
1154
|
+
773529912,
|
|
1155
|
+
1294757372,
|
|
1156
|
+
1396182291,
|
|
1157
|
+
1695183700,
|
|
1158
|
+
1986661051,
|
|
1159
|
+
2177026350,
|
|
1160
|
+
2456956037,
|
|
1161
|
+
2730485921,
|
|
1162
|
+
2820302411,
|
|
1163
|
+
3259730800,
|
|
1164
|
+
3345764771,
|
|
1165
|
+
3516065817,
|
|
1166
|
+
3600352804,
|
|
1167
|
+
4094571909,
|
|
1168
|
+
275423344,
|
|
1169
|
+
430227734,
|
|
1170
|
+
506948616,
|
|
1171
|
+
659060556,
|
|
1172
|
+
883997877,
|
|
1173
|
+
958139571,
|
|
1174
|
+
1322822218,
|
|
1175
|
+
1537002063,
|
|
1176
|
+
1747873779,
|
|
1177
|
+
1955562222,
|
|
1178
|
+
2024104815,
|
|
1179
|
+
2227730452,
|
|
1180
|
+
2361852424,
|
|
1181
|
+
2428436474,
|
|
1182
|
+
2756734187,
|
|
1183
|
+
3204031479,
|
|
1184
|
+
3329325298
|
|
1185
|
+
]);
|
|
1186
|
+
var rotr = (x, n) => x >>> n | x << 32 - n;
|
|
1187
|
+
function sha256(input) {
|
|
1188
|
+
const data2 = typeof input === "string" ? new TextEncoder().encode(input) : input;
|
|
1189
|
+
const padded = new Uint8Array((data2.length + 8 >> 6) + 1 << 6);
|
|
1190
|
+
padded.set(data2);
|
|
1191
|
+
padded[data2.length] = 128;
|
|
1192
|
+
new DataView(padded.buffer).setBigUint64(
|
|
1193
|
+
padded.length - 8,
|
|
1194
|
+
BigInt(data2.length * 8),
|
|
1195
|
+
false
|
|
1196
|
+
);
|
|
1197
|
+
const h = new Uint32Array([
|
|
1198
|
+
1779033703,
|
|
1199
|
+
3144134277,
|
|
1200
|
+
1013904242,
|
|
1201
|
+
2773480762,
|
|
1202
|
+
1359893119,
|
|
1203
|
+
2600822924,
|
|
1204
|
+
528734635,
|
|
1205
|
+
1541459225
|
|
1206
|
+
]);
|
|
1207
|
+
const w = new Uint32Array(64);
|
|
1208
|
+
const view = new DataView(padded.buffer);
|
|
1209
|
+
for (let offset = 0; offset < padded.length; offset += 64) {
|
|
1210
|
+
for (let t = 0; t < 16; t++) w[t] = view.getUint32(offset + t * 4, false);
|
|
1211
|
+
for (let t = 16; t < 64; t++) {
|
|
1212
|
+
const s0 = rotr(w[t - 15], 7) ^ rotr(w[t - 15], 18) ^ w[t - 15] >>> 3;
|
|
1213
|
+
const s1 = rotr(w[t - 2], 17) ^ rotr(w[t - 2], 19) ^ w[t - 2] >>> 10;
|
|
1214
|
+
w[t] = w[t - 16] + s0 + w[t - 7] + s1 >>> 0;
|
|
1215
|
+
}
|
|
1216
|
+
let [a, b, c, d, e, f, g, hh] = h;
|
|
1217
|
+
for (let t = 0; t < 64; t++) {
|
|
1218
|
+
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
|
|
1219
|
+
const ch = e & f ^ ~e & g;
|
|
1220
|
+
const temp1 = hh + S1 + ch + K[t] + w[t] >>> 0;
|
|
1221
|
+
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
|
|
1222
|
+
const maj = a & b ^ a & c ^ b & c;
|
|
1223
|
+
const temp2 = S0 + maj >>> 0;
|
|
1224
|
+
hh = g;
|
|
1225
|
+
g = f;
|
|
1226
|
+
f = e;
|
|
1227
|
+
e = d + temp1 >>> 0;
|
|
1228
|
+
d = c;
|
|
1229
|
+
c = b;
|
|
1230
|
+
b = a;
|
|
1231
|
+
a = temp1 + temp2 >>> 0;
|
|
1232
|
+
}
|
|
1233
|
+
h[0] = h[0] + a >>> 0;
|
|
1234
|
+
h[1] = h[1] + b >>> 0;
|
|
1235
|
+
h[2] = h[2] + c >>> 0;
|
|
1236
|
+
h[3] = h[3] + d >>> 0;
|
|
1237
|
+
h[4] = h[4] + e >>> 0;
|
|
1238
|
+
h[5] = h[5] + f >>> 0;
|
|
1239
|
+
h[6] = h[6] + g >>> 0;
|
|
1240
|
+
h[7] = h[7] + hh >>> 0;
|
|
1241
|
+
}
|
|
1242
|
+
const out = new Uint8Array(32);
|
|
1243
|
+
const outView = new DataView(out.buffer);
|
|
1244
|
+
for (let i = 0; i < 8; i++) outView.setUint32(i * 4, h[i], false);
|
|
1245
|
+
return out;
|
|
1246
|
+
}
|
|
1085
1247
|
|
|
1086
1248
|
// src/syntheticFixtures/uid.ts
|
|
1087
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
1088
1249
|
function hashUid(salt, key) {
|
|
1089
|
-
const digest =
|
|
1090
|
-
const n = BigInt(`0x${digest.subarray(0, 16)
|
|
1250
|
+
const digest = sha256(`${salt} ${key}`);
|
|
1251
|
+
const n = BigInt(`0x${bytesToHex(digest.subarray(0, 16))}`);
|
|
1091
1252
|
return `2.25.${n.toString()}`;
|
|
1092
1253
|
}
|
|
1093
1254
|
function randomUid() {
|
|
1094
|
-
const n = BigInt(`0x${
|
|
1255
|
+
const n = BigInt(`0x${randomHex(16)}`);
|
|
1095
1256
|
return `2.25.${n.toString()}`;
|
|
1096
1257
|
}
|
|
1097
1258
|
function seedToSalt(seed) {
|
|
@@ -1128,176 +1289,6 @@ function makeUidGenerator(salt) {
|
|
|
1128
1289
|
});
|
|
1129
1290
|
}
|
|
1130
1291
|
|
|
1131
|
-
// src/syntheticFixtures/streamWrite.ts
|
|
1132
|
-
var OW_MAX_BYTES = 4294967294;
|
|
1133
|
-
var FRAGMENT_MAX_BYTES = 4294967294;
|
|
1134
|
-
var DEFAULT_CHUNK_BYTES = 8 * 1024 * 1024;
|
|
1135
|
-
var RLE_TRANSFER_SYNTAX_UID = "1.2.840.10008.1.2.5";
|
|
1136
|
-
var PIXEL_DATA_OW_HEADER = Buffer.from([224, 127, 16, 0, 79, 87]);
|
|
1137
|
-
var PIXEL_DATA_OB_HEADER = Buffer.from([224, 127, 16, 0, 79, 66]);
|
|
1138
|
-
var ITEM_TAG_GROUP = 65534;
|
|
1139
|
-
var ITEM_TAG_ELEMENT = 57344;
|
|
1140
|
-
var SEQUENCE_DELIMITER_ELEMENT = 57565;
|
|
1141
|
-
function itemTag(element, byteLength = 0) {
|
|
1142
|
-
const b = Buffer.alloc(8);
|
|
1143
|
-
b.writeUInt16LE(ITEM_TAG_GROUP, 0);
|
|
1144
|
-
b.writeUInt16LE(element, 2);
|
|
1145
|
-
b.writeUInt32LE(byteLength, 4);
|
|
1146
|
-
return b;
|
|
1147
|
-
}
|
|
1148
|
-
function nativeDimensions(pixelBytes) {
|
|
1149
|
-
const cells = Math.max(1, Math.floor(pixelBytes / 2));
|
|
1150
|
-
const rows = Math.min(65535, Math.max(1, Math.round(Math.sqrt(cells))));
|
|
1151
|
-
const columns = Math.min(65535, Math.max(1, Math.floor(cells / rows)));
|
|
1152
|
-
return { rows, columns, bytes: rows * columns * 2 };
|
|
1153
|
-
}
|
|
1154
|
-
function streamZeros(fd, totalBytes, zeroChunk) {
|
|
1155
|
-
let remaining = totalBytes;
|
|
1156
|
-
while (remaining > 0) {
|
|
1157
|
-
const n = Math.min(zeroChunk.length, remaining);
|
|
1158
|
-
writeSync(fd, zeroChunk, 0, n);
|
|
1159
|
-
remaining -= n;
|
|
1160
|
-
}
|
|
1161
|
-
}
|
|
1162
|
-
function writeNative(outPath, params, dims, zeroChunk) {
|
|
1163
|
-
const { modality, uid, tags } = params;
|
|
1164
|
-
const meta = buildMeta(uid, modality);
|
|
1165
|
-
const dataset = buildBaseImageDataset(uid, modality);
|
|
1166
|
-
const rawElements = applyTagOverrides(dataset, tags);
|
|
1167
|
-
syncMetaWithDataset(meta, dataset, rawElements);
|
|
1168
|
-
dataset.Rows = dims.rows;
|
|
1169
|
-
dataset.Columns = dims.columns;
|
|
1170
|
-
const minimalPixelBytes = dataset.PixelData.byteLength;
|
|
1171
|
-
const probe = serializeDict(meta, dataset, rawElements);
|
|
1172
|
-
const pixelElement = Buffer.alloc(12);
|
|
1173
|
-
PIXEL_DATA_OW_HEADER.copy(pixelElement);
|
|
1174
|
-
pixelElement.writeUInt32LE(minimalPixelBytes, 8);
|
|
1175
|
-
const owIdx = probe.indexOf(pixelElement);
|
|
1176
|
-
if (owIdx < 0) {
|
|
1177
|
-
throw new Error("failed to locate native PixelData element header");
|
|
1178
|
-
}
|
|
1179
|
-
const dataStart = owIdx + 12;
|
|
1180
|
-
const header = Buffer.from(probe.subarray(0, dataStart));
|
|
1181
|
-
header.writeUInt32LE(dims.bytes, owIdx + 8);
|
|
1182
|
-
const trailing = probe.subarray(dataStart + minimalPixelBytes);
|
|
1183
|
-
const fd = openSync(outPath, "w");
|
|
1184
|
-
try {
|
|
1185
|
-
writeSync(fd, header);
|
|
1186
|
-
streamZeros(fd, dims.bytes, zeroChunk);
|
|
1187
|
-
if (trailing.length > 0) writeSync(fd, trailing);
|
|
1188
|
-
} finally {
|
|
1189
|
-
closeSync(fd);
|
|
1190
|
-
}
|
|
1191
|
-
return header.length + dims.bytes + trailing.length;
|
|
1192
|
-
}
|
|
1193
|
-
function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk) {
|
|
1194
|
-
const { modality, uid, tags } = params;
|
|
1195
|
-
const meta = {
|
|
1196
|
-
...buildMeta(uid, modality),
|
|
1197
|
-
TransferSyntaxUID: RLE_TRANSFER_SYNTAX_UID
|
|
1198
|
-
};
|
|
1199
|
-
const dataset = buildBaseImageDataset(uid, modality);
|
|
1200
|
-
const rawElements = applyTagOverrides(dataset, tags);
|
|
1201
|
-
syncMetaWithDataset(meta, dataset, rawElements);
|
|
1202
|
-
dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
|
|
1203
|
-
dataset._vrMap = { PixelData: "OB" };
|
|
1204
|
-
const probe = serializeDict(meta, dataset, rawElements);
|
|
1205
|
-
const idx = probe.indexOf(PIXEL_DATA_OB_HEADER);
|
|
1206
|
-
if (idx < 0 || probe.readUInt32LE(idx + 8) !== 4294967295) {
|
|
1207
|
-
throw new Error("failed to locate encapsulated PixelData element header");
|
|
1208
|
-
}
|
|
1209
|
-
const prefix = Buffer.from(probe.subarray(0, idx + 12));
|
|
1210
|
-
const fd = openSync(outPath, "w");
|
|
1211
|
-
let bytesWritten = 0;
|
|
1212
|
-
try {
|
|
1213
|
-
writeSync(fd, prefix);
|
|
1214
|
-
bytesWritten += prefix.length;
|
|
1215
|
-
const bot = itemTag(ITEM_TAG_ELEMENT, 0);
|
|
1216
|
-
writeSync(fd, bot);
|
|
1217
|
-
bytesWritten += bot.length;
|
|
1218
|
-
let remaining = pixelBytes;
|
|
1219
|
-
while (remaining > 0) {
|
|
1220
|
-
const fragLen = Math.min(fragmentBytes, remaining);
|
|
1221
|
-
const fh = itemTag(ITEM_TAG_ELEMENT, fragLen);
|
|
1222
|
-
writeSync(fd, fh);
|
|
1223
|
-
streamZeros(fd, fragLen, zeroChunk);
|
|
1224
|
-
bytesWritten += fh.length + fragLen;
|
|
1225
|
-
remaining -= fragLen;
|
|
1226
|
-
}
|
|
1227
|
-
const delimiter = itemTag(SEQUENCE_DELIMITER_ELEMENT);
|
|
1228
|
-
writeSync(fd, delimiter);
|
|
1229
|
-
bytesWritten += delimiter.length;
|
|
1230
|
-
} finally {
|
|
1231
|
-
closeSync(fd);
|
|
1232
|
-
}
|
|
1233
|
-
return bytesWritten;
|
|
1234
|
-
}
|
|
1235
|
-
function streamLargeImage(outPath, options) {
|
|
1236
|
-
const {
|
|
1237
|
-
targetBytes,
|
|
1238
|
-
chunkBytes = DEFAULT_CHUNK_BYTES,
|
|
1239
|
-
owMaxBytes = OW_MAX_BYTES,
|
|
1240
|
-
fragmentBytes = FRAGMENT_MAX_BYTES
|
|
1241
|
-
} = options;
|
|
1242
|
-
const modality = options.modality ?? "CT";
|
|
1243
|
-
const uid = options.uid ?? makeUidGenerator(options.uidSalt ?? seedToSalt(options.seed))(0);
|
|
1244
|
-
const identity = { modality, uid, tags: options.tags };
|
|
1245
|
-
mkdirSync2(resolve2(outPath, ".."), { recursive: true });
|
|
1246
|
-
const zeroChunk = Buffer.alloc(Math.max(1, chunkBytes));
|
|
1247
|
-
const probe = serializeDict(
|
|
1248
|
-
buildMeta(uid, modality),
|
|
1249
|
-
buildBaseImageDataset(uid, modality)
|
|
1250
|
-
);
|
|
1251
|
-
const nativeHeaderLen = probe.length - 2;
|
|
1252
|
-
const requestedPixel = Math.max(2, targetBytes - nativeHeaderLen);
|
|
1253
|
-
if (requestedPixel <= owMaxBytes) {
|
|
1254
|
-
const dims = nativeDimensions(requestedPixel);
|
|
1255
|
-
const bytesWritten2 = writeNative(outPath, identity, dims, zeroChunk);
|
|
1256
|
-
return {
|
|
1257
|
-
path: outPath,
|
|
1258
|
-
bytesWritten: bytesWritten2,
|
|
1259
|
-
encoding: "native",
|
|
1260
|
-
pixelBytes: dims.bytes
|
|
1261
|
-
};
|
|
1262
|
-
}
|
|
1263
|
-
const estimateFrags = Math.max(
|
|
1264
|
-
1,
|
|
1265
|
-
Math.ceil((targetBytes - nativeHeaderLen - 16) / fragmentBytes)
|
|
1266
|
-
);
|
|
1267
|
-
const overhead = nativeHeaderLen + 8 + estimateFrags * 8 + 8;
|
|
1268
|
-
let pixelBytes = Math.max(2, targetBytes - overhead);
|
|
1269
|
-
pixelBytes -= pixelBytes % 2;
|
|
1270
|
-
const bytesWritten = writeEncapsulated(
|
|
1271
|
-
outPath,
|
|
1272
|
-
identity,
|
|
1273
|
-
pixelBytes,
|
|
1274
|
-
fragmentBytes - fragmentBytes % 2,
|
|
1275
|
-
zeroChunk
|
|
1276
|
-
);
|
|
1277
|
-
return { path: outPath, bytesWritten, encoding: "encapsulated", pixelBytes };
|
|
1278
|
-
}
|
|
1279
|
-
function writeLargeImageFile(spec, outPath, options = {}) {
|
|
1280
|
-
const { targetBytes } = spec;
|
|
1281
|
-
if (!Number.isInteger(targetBytes)) {
|
|
1282
|
-
throw new Error("writeLargeImageFile: targetBytes must be an integer");
|
|
1283
|
-
}
|
|
1284
|
-
if (targetBytes <= MAX_PIXEL_BYTES) {
|
|
1285
|
-
throw new Error(
|
|
1286
|
-
"writeLargeImageFile is for files above the 512 MB in-memory limit \u2014 use targetSizeKb for smaller files"
|
|
1287
|
-
);
|
|
1288
|
-
}
|
|
1289
|
-
if (targetBytes > MAX_STREAM_BYTES) {
|
|
1290
|
-
throw new Error("writeLargeImageFile: targetBytes exceeds the 50 GB limit");
|
|
1291
|
-
}
|
|
1292
|
-
return streamLargeImage(outPath, {
|
|
1293
|
-
targetBytes,
|
|
1294
|
-
modality: spec.modality,
|
|
1295
|
-
uidSalt: spec.uidSalt,
|
|
1296
|
-
seed: spec.seed,
|
|
1297
|
-
chunkBytes: options.chunkBytes
|
|
1298
|
-
});
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
1292
|
// src/syntheticFixtures/violations.ts
|
|
1302
1293
|
var dcmjsAny = dcmjs;
|
|
1303
1294
|
function parseBuffer(buffer) {
|
|
@@ -1308,7 +1299,7 @@ function parseBuffer(buffer) {
|
|
|
1308
1299
|
return dcmjsAny.data.DicomMessage.readFile(ab);
|
|
1309
1300
|
}
|
|
1310
1301
|
function reserialize(parsed) {
|
|
1311
|
-
return
|
|
1302
|
+
return new Uint8Array(parsed.write({ allowInvalidVRLength: true }));
|
|
1312
1303
|
}
|
|
1313
1304
|
var OVERLONG_UID = `2.25.${"0".repeat(60)}`;
|
|
1314
1305
|
var VIOLATION_LEVEL = {
|
|
@@ -1373,11 +1364,12 @@ function applyByteLevel(buffer, violations) {
|
|
|
1373
1364
|
result = result.subarray(132);
|
|
1374
1365
|
break;
|
|
1375
1366
|
case "malformed-sq-delimiter": {
|
|
1376
|
-
const delimiter =
|
|
1377
|
-
delimiter.
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1367
|
+
const delimiter = new Uint8Array(8);
|
|
1368
|
+
const view = new DataView(delimiter.buffer);
|
|
1369
|
+
view.setUint16(0, 65534, true);
|
|
1370
|
+
view.setUint16(2, 57565, true);
|
|
1371
|
+
view.setUint32(4, 4, true);
|
|
1372
|
+
result = concatBytes(result, delimiter);
|
|
1381
1373
|
break;
|
|
1382
1374
|
}
|
|
1383
1375
|
default:
|
|
@@ -1398,7 +1390,7 @@ function applyViolations(buffer, violations, rawElements) {
|
|
|
1398
1390
|
);
|
|
1399
1391
|
}
|
|
1400
1392
|
|
|
1401
|
-
// src/collection/
|
|
1393
|
+
// src/collection/generate.ts
|
|
1402
1394
|
var NO_EXTENSION_TYPES = /* @__PURE__ */ new Set([
|
|
1403
1395
|
"fake-signature",
|
|
1404
1396
|
"non-dicom"
|
|
@@ -1538,10 +1530,10 @@ function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
|
|
|
1538
1530
|
};
|
|
1539
1531
|
}
|
|
1540
1532
|
function withResolvedSeed(spec) {
|
|
1541
|
-
return spec.seed === void 0 ? { ...spec, seed:
|
|
1533
|
+
return spec.seed === void 0 ? { ...spec, seed: randomUint32() } : spec;
|
|
1542
1534
|
}
|
|
1543
1535
|
function withResolvedSalt(spec) {
|
|
1544
|
-
return spec.uidSalt === void 0 ? { ...spec, uidSalt:
|
|
1536
|
+
return spec.uidSalt === void 0 ? { ...spec, uidSalt: randomHex(16) } : spec;
|
|
1545
1537
|
}
|
|
1546
1538
|
function computeNames(type, index, padWidth, grouped, quirks) {
|
|
1547
1539
|
let names;
|
|
@@ -1740,21 +1732,203 @@ async function* previewCollection(spec) {
|
|
|
1740
1732
|
}
|
|
1741
1733
|
}
|
|
1742
1734
|
}
|
|
1735
|
+
|
|
1736
|
+
// src/collection/writer.ts
|
|
1737
|
+
import { mkdirSync as mkdirSync2 } from "node:fs";
|
|
1738
|
+
import { writeFile } from "node:fs/promises";
|
|
1739
|
+
import { dirname, resolve as resolve2 } from "node:path";
|
|
1740
|
+
|
|
1741
|
+
// src/syntheticFixtures/streamWrite.ts
|
|
1742
|
+
import { closeSync, mkdirSync, openSync, writeSync } from "node:fs";
|
|
1743
|
+
import { resolve } from "node:path";
|
|
1744
|
+
var OW_MAX_BYTES = 4294967294;
|
|
1745
|
+
var FRAGMENT_MAX_BYTES = 4294967294;
|
|
1746
|
+
var DEFAULT_CHUNK_BYTES = 8 * 1024 * 1024;
|
|
1747
|
+
var RLE_TRANSFER_SYNTAX_UID = "1.2.840.10008.1.2.5";
|
|
1748
|
+
var PIXEL_DATA_OW_HEADER = Buffer.from([224, 127, 16, 0, 79, 87]);
|
|
1749
|
+
var PIXEL_DATA_OB_HEADER = Buffer.from([224, 127, 16, 0, 79, 66]);
|
|
1750
|
+
var ITEM_TAG_GROUP = 65534;
|
|
1751
|
+
var ITEM_TAG_ELEMENT = 57344;
|
|
1752
|
+
var SEQUENCE_DELIMITER_ELEMENT = 57565;
|
|
1753
|
+
function asBuffer(bytes) {
|
|
1754
|
+
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
1755
|
+
}
|
|
1756
|
+
function itemTag(element, byteLength = 0) {
|
|
1757
|
+
const b = Buffer.alloc(8);
|
|
1758
|
+
b.writeUInt16LE(ITEM_TAG_GROUP, 0);
|
|
1759
|
+
b.writeUInt16LE(element, 2);
|
|
1760
|
+
b.writeUInt32LE(byteLength, 4);
|
|
1761
|
+
return b;
|
|
1762
|
+
}
|
|
1763
|
+
function nativeDimensions(pixelBytes) {
|
|
1764
|
+
const cells = Math.max(1, Math.floor(pixelBytes / 2));
|
|
1765
|
+
const rows = Math.min(65535, Math.max(1, Math.round(Math.sqrt(cells))));
|
|
1766
|
+
const columns = Math.min(65535, Math.max(1, Math.floor(cells / rows)));
|
|
1767
|
+
return { rows, columns, bytes: rows * columns * 2 };
|
|
1768
|
+
}
|
|
1769
|
+
function streamZeros(fd, totalBytes, zeroChunk) {
|
|
1770
|
+
let remaining = totalBytes;
|
|
1771
|
+
while (remaining > 0) {
|
|
1772
|
+
const n = Math.min(zeroChunk.length, remaining);
|
|
1773
|
+
writeSync(fd, zeroChunk, 0, n);
|
|
1774
|
+
remaining -= n;
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
function writeNative(outPath, params, dims, zeroChunk) {
|
|
1778
|
+
const { modality, uid, tags } = params;
|
|
1779
|
+
const meta = buildMeta(uid, modality);
|
|
1780
|
+
const dataset = buildBaseImageDataset(uid, modality);
|
|
1781
|
+
const rawElements = applyTagOverrides(dataset, tags);
|
|
1782
|
+
syncMetaWithDataset(meta, dataset, rawElements);
|
|
1783
|
+
dataset.Rows = dims.rows;
|
|
1784
|
+
dataset.Columns = dims.columns;
|
|
1785
|
+
const minimalPixelBytes = dataset.PixelData.byteLength;
|
|
1786
|
+
const probe = asBuffer(serializeDict(meta, dataset, rawElements));
|
|
1787
|
+
const pixelElement = Buffer.alloc(12);
|
|
1788
|
+
PIXEL_DATA_OW_HEADER.copy(pixelElement);
|
|
1789
|
+
pixelElement.writeUInt32LE(minimalPixelBytes, 8);
|
|
1790
|
+
const owIdx = probe.indexOf(pixelElement);
|
|
1791
|
+
if (owIdx < 0) {
|
|
1792
|
+
throw new Error("failed to locate native PixelData element header");
|
|
1793
|
+
}
|
|
1794
|
+
const dataStart = owIdx + 12;
|
|
1795
|
+
const header = Buffer.from(probe.subarray(0, dataStart));
|
|
1796
|
+
header.writeUInt32LE(dims.bytes, owIdx + 8);
|
|
1797
|
+
const trailing = probe.subarray(dataStart + minimalPixelBytes);
|
|
1798
|
+
const fd = openSync(outPath, "w");
|
|
1799
|
+
try {
|
|
1800
|
+
writeSync(fd, header);
|
|
1801
|
+
streamZeros(fd, dims.bytes, zeroChunk);
|
|
1802
|
+
if (trailing.length > 0) writeSync(fd, trailing);
|
|
1803
|
+
} finally {
|
|
1804
|
+
closeSync(fd);
|
|
1805
|
+
}
|
|
1806
|
+
return header.length + dims.bytes + trailing.length;
|
|
1807
|
+
}
|
|
1808
|
+
function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk) {
|
|
1809
|
+
const { modality, uid, tags } = params;
|
|
1810
|
+
const meta = {
|
|
1811
|
+
...buildMeta(uid, modality),
|
|
1812
|
+
TransferSyntaxUID: RLE_TRANSFER_SYNTAX_UID
|
|
1813
|
+
};
|
|
1814
|
+
const dataset = buildBaseImageDataset(uid, modality);
|
|
1815
|
+
const rawElements = applyTagOverrides(dataset, tags);
|
|
1816
|
+
syncMetaWithDataset(meta, dataset, rawElements);
|
|
1817
|
+
dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
|
|
1818
|
+
dataset._vrMap = { PixelData: "OB" };
|
|
1819
|
+
const probe = asBuffer(serializeDict(meta, dataset, rawElements));
|
|
1820
|
+
const idx = probe.indexOf(PIXEL_DATA_OB_HEADER);
|
|
1821
|
+
if (idx < 0 || probe.readUInt32LE(idx + 8) !== 4294967295) {
|
|
1822
|
+
throw new Error("failed to locate encapsulated PixelData element header");
|
|
1823
|
+
}
|
|
1824
|
+
const prefix = Buffer.from(probe.subarray(0, idx + 12));
|
|
1825
|
+
const fd = openSync(outPath, "w");
|
|
1826
|
+
let bytesWritten = 0;
|
|
1827
|
+
try {
|
|
1828
|
+
writeSync(fd, prefix);
|
|
1829
|
+
bytesWritten += prefix.length;
|
|
1830
|
+
const bot = itemTag(ITEM_TAG_ELEMENT, 0);
|
|
1831
|
+
writeSync(fd, bot);
|
|
1832
|
+
bytesWritten += bot.length;
|
|
1833
|
+
let remaining = pixelBytes;
|
|
1834
|
+
while (remaining > 0) {
|
|
1835
|
+
const fragLen = Math.min(fragmentBytes, remaining);
|
|
1836
|
+
const fh = itemTag(ITEM_TAG_ELEMENT, fragLen);
|
|
1837
|
+
writeSync(fd, fh);
|
|
1838
|
+
streamZeros(fd, fragLen, zeroChunk);
|
|
1839
|
+
bytesWritten += fh.length + fragLen;
|
|
1840
|
+
remaining -= fragLen;
|
|
1841
|
+
}
|
|
1842
|
+
const delimiter = itemTag(SEQUENCE_DELIMITER_ELEMENT);
|
|
1843
|
+
writeSync(fd, delimiter);
|
|
1844
|
+
bytesWritten += delimiter.length;
|
|
1845
|
+
} finally {
|
|
1846
|
+
closeSync(fd);
|
|
1847
|
+
}
|
|
1848
|
+
return bytesWritten;
|
|
1849
|
+
}
|
|
1850
|
+
function streamLargeImage(outPath, options) {
|
|
1851
|
+
const {
|
|
1852
|
+
targetBytes,
|
|
1853
|
+
chunkBytes = DEFAULT_CHUNK_BYTES,
|
|
1854
|
+
owMaxBytes = OW_MAX_BYTES,
|
|
1855
|
+
fragmentBytes = FRAGMENT_MAX_BYTES
|
|
1856
|
+
} = options;
|
|
1857
|
+
const modality = options.modality ?? "CT";
|
|
1858
|
+
const uid = options.uid ?? makeUidGenerator(options.uidSalt ?? seedToSalt(options.seed))(0);
|
|
1859
|
+
const identity = { modality, uid, tags: options.tags };
|
|
1860
|
+
mkdirSync(resolve(outPath, ".."), { recursive: true });
|
|
1861
|
+
const zeroChunk = Buffer.alloc(Math.max(1, chunkBytes));
|
|
1862
|
+
const probe = serializeDict(
|
|
1863
|
+
buildMeta(uid, modality),
|
|
1864
|
+
buildBaseImageDataset(uid, modality)
|
|
1865
|
+
);
|
|
1866
|
+
const nativeHeaderLen = probe.length - 2;
|
|
1867
|
+
const requestedPixel = Math.max(2, targetBytes - nativeHeaderLen);
|
|
1868
|
+
if (requestedPixel <= owMaxBytes) {
|
|
1869
|
+
const dims = nativeDimensions(requestedPixel);
|
|
1870
|
+
const bytesWritten2 = writeNative(outPath, identity, dims, zeroChunk);
|
|
1871
|
+
return {
|
|
1872
|
+
path: outPath,
|
|
1873
|
+
bytesWritten: bytesWritten2,
|
|
1874
|
+
encoding: "native",
|
|
1875
|
+
pixelBytes: dims.bytes
|
|
1876
|
+
};
|
|
1877
|
+
}
|
|
1878
|
+
const estimateFrags = Math.max(
|
|
1879
|
+
1,
|
|
1880
|
+
Math.ceil((targetBytes - nativeHeaderLen - 16) / fragmentBytes)
|
|
1881
|
+
);
|
|
1882
|
+
const overhead = nativeHeaderLen + 8 + estimateFrags * 8 + 8;
|
|
1883
|
+
let pixelBytes = Math.max(2, targetBytes - overhead);
|
|
1884
|
+
pixelBytes -= pixelBytes % 2;
|
|
1885
|
+
const bytesWritten = writeEncapsulated(
|
|
1886
|
+
outPath,
|
|
1887
|
+
identity,
|
|
1888
|
+
pixelBytes,
|
|
1889
|
+
fragmentBytes - fragmentBytes % 2,
|
|
1890
|
+
zeroChunk
|
|
1891
|
+
);
|
|
1892
|
+
return { path: outPath, bytesWritten, encoding: "encapsulated", pixelBytes };
|
|
1893
|
+
}
|
|
1894
|
+
function writeLargeImageFile(spec, outPath, options = {}) {
|
|
1895
|
+
const { targetBytes } = spec;
|
|
1896
|
+
if (!Number.isInteger(targetBytes)) {
|
|
1897
|
+
throw new Error("writeLargeImageFile: targetBytes must be an integer");
|
|
1898
|
+
}
|
|
1899
|
+
if (targetBytes <= MAX_PIXEL_BYTES) {
|
|
1900
|
+
throw new Error(
|
|
1901
|
+
"writeLargeImageFile is for files above the 512 MB in-memory limit \u2014 use targetSizeKb for smaller files"
|
|
1902
|
+
);
|
|
1903
|
+
}
|
|
1904
|
+
if (targetBytes > MAX_STREAM_BYTES) {
|
|
1905
|
+
throw new Error("writeLargeImageFile: targetBytes exceeds the 50 GB limit");
|
|
1906
|
+
}
|
|
1907
|
+
return streamLargeImage(outPath, {
|
|
1908
|
+
targetBytes,
|
|
1909
|
+
modality: spec.modality,
|
|
1910
|
+
uidSalt: spec.uidSalt,
|
|
1911
|
+
seed: spec.seed,
|
|
1912
|
+
chunkBytes: options.chunkBytes
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
// src/collection/writer.ts
|
|
1743
1917
|
async function writeCollectionFromSpec(spec, outDir) {
|
|
1744
|
-
const root =
|
|
1745
|
-
|
|
1918
|
+
const root = resolve2(outDir);
|
|
1919
|
+
mkdirSync2(root, { recursive: true });
|
|
1746
1920
|
const manifest = [];
|
|
1747
1921
|
const salt = effectiveSalt(spec);
|
|
1748
1922
|
const createdDirs = /* @__PURE__ */ new Set([root]);
|
|
1749
1923
|
const ensureDir = (filePath) => {
|
|
1750
1924
|
const dir = dirname(filePath);
|
|
1751
1925
|
if (!createdDirs.has(dir)) {
|
|
1752
|
-
|
|
1926
|
+
mkdirSync2(dir, { recursive: true });
|
|
1753
1927
|
createdDirs.add(dir);
|
|
1754
1928
|
}
|
|
1755
1929
|
};
|
|
1756
1930
|
for (const plan of planCollection(spec)) {
|
|
1757
|
-
const filePath =
|
|
1931
|
+
const filePath = resolve2(root, plan.relativePath);
|
|
1758
1932
|
ensureDir(filePath);
|
|
1759
1933
|
if (plan.fileSpec.type === "large-image") {
|
|
1760
1934
|
const large = plan.fileSpec;
|
|
@@ -1784,7 +1958,7 @@ async function writeCollectionFromSpec(spec, outDir) {
|
|
|
1784
1958
|
}
|
|
1785
1959
|
|
|
1786
1960
|
// src/describe/describe.ts
|
|
1787
|
-
import { createHash
|
|
1961
|
+
import { createHash } from "node:crypto";
|
|
1788
1962
|
import {
|
|
1789
1963
|
closeSync as closeSync2,
|
|
1790
1964
|
openSync as openSync2,
|
|
@@ -1816,13 +1990,28 @@ function buildHexToKeyword() {
|
|
|
1816
1990
|
}
|
|
1817
1991
|
return map;
|
|
1818
1992
|
}
|
|
1993
|
+
function buildKeepPlan(options) {
|
|
1994
|
+
const { keywords, privateHex } = options.keepTags ? resolveKeepTags(options.keepTags) : { keywords: [], privateHex: /* @__PURE__ */ new Set() };
|
|
1995
|
+
const allPrivate = options.keepPrivateTags === true;
|
|
1996
|
+
return {
|
|
1997
|
+
keywords,
|
|
1998
|
+
privateHex,
|
|
1999
|
+
allPrivate,
|
|
2000
|
+
capturingPrivate: allPrivate || privateHex.size > 0
|
|
2001
|
+
};
|
|
2002
|
+
}
|
|
1819
2003
|
function resolveKeepTags(keepTags) {
|
|
1820
2004
|
const nameMap = dcmjsAny2.data.DicomMetaDictionary.nameMap;
|
|
1821
2005
|
let hexToKeyword;
|
|
1822
2006
|
const kept = [];
|
|
2007
|
+
const privateHex = /* @__PURE__ */ new Set();
|
|
1823
2008
|
for (const tag of keepTags) {
|
|
1824
2009
|
let keyword;
|
|
1825
2010
|
if (HEX_TAG_RE2.test(tag)) {
|
|
2011
|
+
if (isPrivateHexTag(tag)) {
|
|
2012
|
+
privateHex.add(tag.toUpperCase());
|
|
2013
|
+
continue;
|
|
2014
|
+
}
|
|
1826
2015
|
hexToKeyword ?? (hexToKeyword = buildHexToKeyword());
|
|
1827
2016
|
const mapped = hexToKeyword.get(tag.toUpperCase());
|
|
1828
2017
|
if (!mapped) {
|
|
@@ -1849,7 +2038,71 @@ function resolveKeepTags(keepTags) {
|
|
|
1849
2038
|
}
|
|
1850
2039
|
kept.push(keyword);
|
|
1851
2040
|
}
|
|
1852
|
-
return kept;
|
|
2041
|
+
return { keywords: kept, privateHex };
|
|
2042
|
+
}
|
|
2043
|
+
function isUnrepresentableValue(value) {
|
|
2044
|
+
return value === null || value === void 0 || typeof value === "number" && !Number.isFinite(value) || value instanceof ArrayBuffer || ArrayBuffer.isView(value);
|
|
2045
|
+
}
|
|
2046
|
+
function toRawCapture(el, skipped) {
|
|
2047
|
+
const vr = el.vr;
|
|
2048
|
+
if (typeof vr !== "string") {
|
|
2049
|
+
skipped.count++;
|
|
2050
|
+
return null;
|
|
2051
|
+
}
|
|
2052
|
+
if (vr === "SQ") {
|
|
2053
|
+
const items = [];
|
|
2054
|
+
for (const item of el.Value ?? []) {
|
|
2055
|
+
if (typeof item !== "object" || item === null) continue;
|
|
2056
|
+
const converted = {};
|
|
2057
|
+
for (const [tag, nested] of Object.entries(item)) {
|
|
2058
|
+
if (!HEX_TAG_RE2.test(tag)) continue;
|
|
2059
|
+
const capture = toRawCapture(nested, skipped);
|
|
2060
|
+
if (capture) converted[tag.toUpperCase()] = capture;
|
|
2061
|
+
}
|
|
2062
|
+
items.push(converted);
|
|
2063
|
+
}
|
|
2064
|
+
return { vr, value: items };
|
|
2065
|
+
}
|
|
2066
|
+
const values = el.Value ?? [];
|
|
2067
|
+
if (values.some(isUnrepresentableValue)) {
|
|
2068
|
+
skipped.count++;
|
|
2069
|
+
return null;
|
|
2070
|
+
}
|
|
2071
|
+
return { vr, value: values.length === 1 ? values[0] : values };
|
|
2072
|
+
}
|
|
2073
|
+
function capturePrivateTags(rawDict, keep, skipped) {
|
|
2074
|
+
const captured = {};
|
|
2075
|
+
let found = false;
|
|
2076
|
+
for (const [tag, el] of Object.entries(rawDict)) {
|
|
2077
|
+
if (!HEX_TAG_RE2.test(tag) || !isPrivateHexTag(tag)) continue;
|
|
2078
|
+
if (!keep.allPrivate && !keep.privateHex.has(tag.toUpperCase())) continue;
|
|
2079
|
+
const capture = toRawCapture(el, skipped);
|
|
2080
|
+
if (capture) {
|
|
2081
|
+
captured[tag.toUpperCase()] = capture;
|
|
2082
|
+
found = true;
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
return found ? captured : void 0;
|
|
2086
|
+
}
|
|
2087
|
+
function hashRawCaptureUids(capture, salt, map) {
|
|
2088
|
+
if (capture.vr === "UI") {
|
|
2089
|
+
return isUidLeaf(capture.value) ? { vr: "UI", value: hashUidValue(capture.value, salt, map) } : capture;
|
|
2090
|
+
}
|
|
2091
|
+
if (capture.vr === "SQ") {
|
|
2092
|
+
const items = capture.value;
|
|
2093
|
+
return {
|
|
2094
|
+
vr: "SQ",
|
|
2095
|
+
value: items.map(
|
|
2096
|
+
(item) => Object.fromEntries(
|
|
2097
|
+
Object.entries(item).map(([tag, nested]) => [
|
|
2098
|
+
tag,
|
|
2099
|
+
hashRawCaptureUids(nested, salt, map)
|
|
2100
|
+
])
|
|
2101
|
+
)
|
|
2102
|
+
)
|
|
2103
|
+
};
|
|
2104
|
+
}
|
|
2105
|
+
return capture;
|
|
1853
2106
|
}
|
|
1854
2107
|
function* walkFiles(dir) {
|
|
1855
2108
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -1867,13 +2120,16 @@ function toArrayBuffer(buf) {
|
|
|
1867
2120
|
buf.byteOffset + buf.byteLength
|
|
1868
2121
|
);
|
|
1869
2122
|
}
|
|
1870
|
-
function
|
|
2123
|
+
function parseParts(ab) {
|
|
1871
2124
|
const parsed = dcmjsAny2.data.DicomMessage.readFile(ab);
|
|
1872
|
-
return
|
|
2125
|
+
return {
|
|
2126
|
+
record: dcmjsAny2.data.DicomMetaDictionary.naturalizeDataset(parsed.dict),
|
|
2127
|
+
rawDict: parsed.dict
|
|
2128
|
+
};
|
|
1873
2129
|
}
|
|
1874
2130
|
function parseFull(path) {
|
|
1875
2131
|
try {
|
|
1876
|
-
return
|
|
2132
|
+
return parseParts(toArrayBuffer(readFileSync(path)));
|
|
1877
2133
|
} catch {
|
|
1878
2134
|
return null;
|
|
1879
2135
|
}
|
|
@@ -1931,7 +2187,7 @@ function readHeaderOnly(path) {
|
|
|
1931
2187
|
EMPTY_OW_PIXEL_DATA
|
|
1932
2188
|
]);
|
|
1933
2189
|
try {
|
|
1934
|
-
return
|
|
2190
|
+
return parseParts(toArrayBuffer(headerOnly));
|
|
1935
2191
|
} catch {
|
|
1936
2192
|
return null;
|
|
1937
2193
|
}
|
|
@@ -2048,7 +2304,7 @@ function hashUidTags(uids, salt, map) {
|
|
|
2048
2304
|
return tags;
|
|
2049
2305
|
}
|
|
2050
2306
|
function deriveSalt(studyUids) {
|
|
2051
|
-
const h =
|
|
2307
|
+
const h = createHash("sha256");
|
|
2052
2308
|
for (const uid of [...studyUids].sort()) h.update(uid).update("\n");
|
|
2053
2309
|
return h.digest("hex");
|
|
2054
2310
|
}
|
|
@@ -2057,6 +2313,20 @@ function mergeTags(a, b) {
|
|
|
2057
2313
|
if (!b) return a;
|
|
2058
2314
|
return { ...a, ...b };
|
|
2059
2315
|
}
|
|
2316
|
+
function mergePrivateTags(rec, salt, uidMap) {
|
|
2317
|
+
if (!rec.privateTags) return;
|
|
2318
|
+
const hashed = Object.fromEntries(
|
|
2319
|
+
Object.entries(rec.privateTags).map(([tag, capture]) => [
|
|
2320
|
+
tag,
|
|
2321
|
+
salt === void 0 ? capture : hashRawCaptureUids(
|
|
2322
|
+
capture,
|
|
2323
|
+
salt,
|
|
2324
|
+
uidMap
|
|
2325
|
+
)
|
|
2326
|
+
])
|
|
2327
|
+
);
|
|
2328
|
+
rec.tags = mergeTags(rec.tags, hashed);
|
|
2329
|
+
}
|
|
2060
2330
|
function scanFile(path) {
|
|
2061
2331
|
let size;
|
|
2062
2332
|
try {
|
|
@@ -2066,7 +2336,7 @@ function scanFile(path) {
|
|
|
2066
2336
|
}
|
|
2067
2337
|
if (size > MAX_STREAM_BYTES) return null;
|
|
2068
2338
|
const large = size > MAX_PIXEL_BYTES;
|
|
2069
|
-
return { size, large,
|
|
2339
|
+
return { size, large, parsed: large ? readHeaderOnly(path) : parseFull(path) };
|
|
2070
2340
|
}
|
|
2071
2341
|
function presetModalityOf(record) {
|
|
2072
2342
|
if (typeof record.Modality !== "string") return void 0;
|
|
@@ -2081,6 +2351,13 @@ function hashRecordUids(rec, salt, uidMap, sweptKeepTags) {
|
|
|
2081
2351
|
}
|
|
2082
2352
|
rec.tags = mergeTags(rec.tags, hashUidTags(rec.uidOriginals, salt, uidMap));
|
|
2083
2353
|
}
|
|
2354
|
+
function warnPrivateBinarySkipped(count) {
|
|
2355
|
+
if (count > 0) {
|
|
2356
|
+
console.warn(
|
|
2357
|
+
`describe: ${count} private element(s) with binary payloads were not captured \u2014 binary values cannot ride a JSON spec`
|
|
2358
|
+
);
|
|
2359
|
+
}
|
|
2360
|
+
}
|
|
2084
2361
|
function warnSweptKeepTags(sweptKeepTags) {
|
|
2085
2362
|
for (const keyword of sweptKeepTags) {
|
|
2086
2363
|
console.warn(
|
|
@@ -2226,14 +2503,16 @@ function emitTree(acc) {
|
|
|
2226
2503
|
}
|
|
2227
2504
|
return nodes;
|
|
2228
2505
|
}
|
|
2229
|
-
function describeTree(dir, options,
|
|
2506
|
+
function describeTree(dir, options, keep) {
|
|
2230
2507
|
const root = { dirs: /* @__PURE__ */ new Map(), files: [] };
|
|
2231
2508
|
const stats = {
|
|
2232
2509
|
dicomFiles: 0,
|
|
2233
2510
|
skipped: 0,
|
|
2234
2511
|
unknownModality: 0,
|
|
2235
|
-
nonDicomFiles: 0
|
|
2512
|
+
nonDicomFiles: 0,
|
|
2513
|
+
privateBinarySkipped: 0
|
|
2236
2514
|
};
|
|
2515
|
+
const privateSkipped = { count: 0 };
|
|
2237
2516
|
const studyUids = /* @__PURE__ */ new Set();
|
|
2238
2517
|
const dicomLeaves = [];
|
|
2239
2518
|
for (const path of [...walkFiles(dir)].sort()) {
|
|
@@ -2242,7 +2521,8 @@ function describeTree(dir, options, keepKeywords) {
|
|
|
2242
2521
|
stats.skipped++;
|
|
2243
2522
|
continue;
|
|
2244
2523
|
}
|
|
2245
|
-
const { size, large,
|
|
2524
|
+
const { size, large, parsed } = scanned;
|
|
2525
|
+
const record = parsed?.record;
|
|
2246
2526
|
const studyUid = record?.StudyInstanceUID;
|
|
2247
2527
|
const seriesUid = record?.SeriesInstanceUID;
|
|
2248
2528
|
const relDirs = relative(dir, path).split(sep).slice(0, -1);
|
|
@@ -2273,9 +2553,10 @@ function describeTree(dir, options, keepKeywords) {
|
|
|
2273
2553
|
modality,
|
|
2274
2554
|
tags: mergeTags(
|
|
2275
2555
|
instanceNumber,
|
|
2276
|
-
|
|
2556
|
+
keep.keywords.length ? extractTags(record, keep.keywords) : void 0
|
|
2277
2557
|
),
|
|
2278
|
-
uidOriginals: collectUidOriginals(record)
|
|
2558
|
+
uidOriginals: collectUidOriginals(record),
|
|
2559
|
+
privateTags: keep.capturingPrivate && parsed ? capturePrivateTags(parsed.rawDict, keep, privateSkipped) : void 0
|
|
2279
2560
|
},
|
|
2280
2561
|
studyUid,
|
|
2281
2562
|
seriesUid
|
|
@@ -2291,18 +2572,21 @@ function describeTree(dir, options, keepKeywords) {
|
|
|
2291
2572
|
const sweptKeepTags = /* @__PURE__ */ new Set();
|
|
2292
2573
|
for (const { rec, studyUid, seriesUid } of dicomLeaves) {
|
|
2293
2574
|
hashRecordUids(rec, salt, uidMap, sweptKeepTags);
|
|
2575
|
+
mergePrivateTags(rec, salt, uidMap);
|
|
2294
2576
|
rec.tags = mergeTags(rec.tags, {
|
|
2295
2577
|
StudyInstanceUID: hashUidVia(uidMap, salt, studyUid),
|
|
2296
2578
|
SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
|
|
2297
2579
|
});
|
|
2298
2580
|
}
|
|
2299
2581
|
warnSweptKeepTags(sweptKeepTags);
|
|
2582
|
+
stats.privateBinarySkipped = privateSkipped.count;
|
|
2583
|
+
warnPrivateBinarySkipped(privateSkipped.count);
|
|
2300
2584
|
const spec = { tree: emitTree(root), uidSalt: salt };
|
|
2301
2585
|
validateDatasetSpec(spec);
|
|
2302
2586
|
return { spec, stats };
|
|
2303
2587
|
}
|
|
2304
2588
|
function describeDirectory(dir, options = {}) {
|
|
2305
|
-
const
|
|
2589
|
+
const keep = buildKeepPlan(options);
|
|
2306
2590
|
const preserveUids = options.preserveUids ?? true;
|
|
2307
2591
|
if (options.captureTree) {
|
|
2308
2592
|
if (!preserveUids) {
|
|
@@ -2310,10 +2594,10 @@ function describeDirectory(dir, options = {}) {
|
|
|
2310
2594
|
"describe: captureTree requires preserveUids \u2014 the captured tree carries its grouping as hashed UID tags"
|
|
2311
2595
|
);
|
|
2312
2596
|
}
|
|
2313
|
-
return describeTree(dir, options,
|
|
2597
|
+
return describeTree(dir, options, keep);
|
|
2314
2598
|
}
|
|
2315
2599
|
const uidMap = /* @__PURE__ */ new Map();
|
|
2316
|
-
const useRecords =
|
|
2600
|
+
const useRecords = keep.keywords.length > 0 || preserveUids || keep.capturingPrivate;
|
|
2317
2601
|
const tolerance = options.sizeTolerance ?? 0;
|
|
2318
2602
|
if (!Number.isFinite(tolerance) || tolerance < 0) {
|
|
2319
2603
|
throw new Error(
|
|
@@ -2325,15 +2609,18 @@ function describeDirectory(dir, options = {}) {
|
|
|
2325
2609
|
dicomFiles: 0,
|
|
2326
2610
|
skipped: 0,
|
|
2327
2611
|
unknownModality: 0,
|
|
2328
|
-
nonDicomFiles: 0
|
|
2612
|
+
nonDicomFiles: 0,
|
|
2613
|
+
privateBinarySkipped: 0
|
|
2329
2614
|
};
|
|
2615
|
+
const privateSkipped = { count: 0 };
|
|
2330
2616
|
for (const path of [...walkFiles(dir)].sort()) {
|
|
2331
2617
|
const scanned = scanFile(path);
|
|
2332
2618
|
if (!scanned) {
|
|
2333
2619
|
stats.skipped++;
|
|
2334
2620
|
continue;
|
|
2335
2621
|
}
|
|
2336
|
-
const { size, large,
|
|
2622
|
+
const { size, large, parsed } = scanned;
|
|
2623
|
+
const record = parsed?.record;
|
|
2337
2624
|
const studyUid = record?.StudyInstanceUID;
|
|
2338
2625
|
const seriesUid = record?.SeriesInstanceUID;
|
|
2339
2626
|
if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
|
|
@@ -2344,7 +2631,7 @@ function describeDirectory(dir, options = {}) {
|
|
|
2344
2631
|
const preset = presetModalityOf(record);
|
|
2345
2632
|
if (preset === "unsupported") stats.unknownModality++;
|
|
2346
2633
|
const modality = preset === "unsupported" ? void 0 : preset;
|
|
2347
|
-
const keptTags =
|
|
2634
|
+
const keptTags = keep.keywords.length ? extractTags(record, keep.keywords) : void 0;
|
|
2348
2635
|
const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
|
|
2349
2636
|
const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
|
|
2350
2637
|
const fileRecord = {
|
|
@@ -2352,7 +2639,8 @@ function describeDirectory(dir, options = {}) {
|
|
|
2352
2639
|
bytes,
|
|
2353
2640
|
modality,
|
|
2354
2641
|
tags: keptTags,
|
|
2355
|
-
uidOriginals
|
|
2642
|
+
uidOriginals,
|
|
2643
|
+
privateTags: keep.capturingPrivate && parsed ? capturePrivateTags(parsed.rawDict, keep, privateSkipped) : void 0
|
|
2356
2644
|
};
|
|
2357
2645
|
let study = studies.get(studyUid);
|
|
2358
2646
|
if (!study) {
|
|
@@ -2371,18 +2659,23 @@ function describeDirectory(dir, options = {}) {
|
|
|
2371
2659
|
}
|
|
2372
2660
|
}
|
|
2373
2661
|
const salt = preserveUids ? options.uidSalt ?? deriveSalt([...studies.keys()]) : void 0;
|
|
2374
|
-
if (salt !== void 0) {
|
|
2662
|
+
if (salt !== void 0 || keep.capturingPrivate) {
|
|
2375
2663
|
const sweptKeepTags = /* @__PURE__ */ new Set();
|
|
2376
2664
|
for (const study of studies.values()) {
|
|
2377
2665
|
for (const series of study.values()) {
|
|
2378
2666
|
if (!Array.isArray(series)) continue;
|
|
2379
2667
|
for (const rec of series) {
|
|
2380
|
-
|
|
2668
|
+
if (salt !== void 0) {
|
|
2669
|
+
hashRecordUids(rec, salt, uidMap, sweptKeepTags);
|
|
2670
|
+
}
|
|
2671
|
+
mergePrivateTags(rec, salt, uidMap);
|
|
2381
2672
|
}
|
|
2382
2673
|
}
|
|
2383
2674
|
}
|
|
2384
2675
|
warnSweptKeepTags(sweptKeepTags);
|
|
2385
2676
|
}
|
|
2677
|
+
stats.privateBinarySkipped = privateSkipped.count;
|
|
2678
|
+
warnPrivateBinarySkipped(privateSkipped.count);
|
|
2386
2679
|
const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
|
|
2387
2680
|
...spec2,
|
|
2388
2681
|
tags: {
|
|
@@ -2442,16 +2735,16 @@ function loadCaseById(casesJsonPath, id) {
|
|
|
2442
2735
|
}
|
|
2443
2736
|
|
|
2444
2737
|
// src/public-fixtures/fetch.ts
|
|
2445
|
-
import { createHash as
|
|
2446
|
-
import { existsSync, mkdirSync as
|
|
2738
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
2739
|
+
import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync } from "node:fs";
|
|
2447
2740
|
import { homedir } from "node:os";
|
|
2448
2741
|
import { join as join3 } from "node:path";
|
|
2449
2742
|
var DEFAULT_CACHE_ROOT = join3(homedir(), ".cache", "dicom-synth-testcases");
|
|
2450
|
-
function caseCachePath(
|
|
2451
|
-
return join3(root,
|
|
2743
|
+
function caseCachePath(sha2562, root = DEFAULT_CACHE_ROOT) {
|
|
2744
|
+
return join3(root, sha2562, "file.dcm");
|
|
2452
2745
|
}
|
|
2453
2746
|
function verifySha256(buffer, expected) {
|
|
2454
|
-
const h =
|
|
2747
|
+
const h = createHash2("sha256").update(buffer).digest("hex");
|
|
2455
2748
|
if (h !== expected) {
|
|
2456
2749
|
throw new Error(
|
|
2457
2750
|
`SHA256 mismatch: expected ${expected}, got ${h}. Upstream may have changed; bump metadata after review.`
|
|
@@ -2470,7 +2763,7 @@ async function fetchPublicCaseToCache(record, cacheRoot = DEFAULT_CACHE_ROOT) {
|
|
|
2470
2763
|
verifySha256(buf2, record.sha256);
|
|
2471
2764
|
return dest;
|
|
2472
2765
|
}
|
|
2473
|
-
|
|
2766
|
+
mkdirSync3(join3(cacheRoot, record.sha256), { recursive: true });
|
|
2474
2767
|
const res = await fetch(record.source.url);
|
|
2475
2768
|
if (!res.ok) {
|
|
2476
2769
|
throw new Error(
|
|
@@ -2479,12 +2772,11 @@ async function fetchPublicCaseToCache(record, cacheRoot = DEFAULT_CACHE_ROOT) {
|
|
|
2479
2772
|
}
|
|
2480
2773
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
2481
2774
|
verifySha256(buf, record.sha256);
|
|
2482
|
-
|
|
2775
|
+
writeFileSync(dest, buf);
|
|
2483
2776
|
return dest;
|
|
2484
2777
|
}
|
|
2485
2778
|
|
|
2486
2779
|
// src/schema/parametric.ts
|
|
2487
|
-
import { randomBytes as randomBytes3 } from "node:crypto";
|
|
2488
2780
|
function sample(next, range) {
|
|
2489
2781
|
if (typeof range === "number") return range;
|
|
2490
2782
|
return range.min + next() % (range.max - range.min + 1);
|
|
@@ -2503,7 +2795,7 @@ function fraction(next) {
|
|
|
2503
2795
|
}
|
|
2504
2796
|
function resolveParametricSpec(spec) {
|
|
2505
2797
|
const validated = validateParametricSpec(spec);
|
|
2506
|
-
const seed = validated.seed ??
|
|
2798
|
+
const seed = validated.seed ?? randomUint32();
|
|
2507
2799
|
const next = seededStream(seed, PARAMETRIC_STREAM_OFFSET, 0, 0);
|
|
2508
2800
|
const params = validated.studies;
|
|
2509
2801
|
const studies = [];
|