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/dist/esm/index.js CHANGED
@@ -1,12 +1,46 @@
1
- // src/collection/writer.ts
2
- import { randomBytes as randomBytes2 } from "node:crypto";
3
- import { mkdirSync as mkdirSync3 } from "node:fs";
4
- import { writeFile } from "node:fs/promises";
5
- import { dirname, resolve as resolve3 } from "node:path";
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/schema/limits.ts
8
- var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
9
- var MAX_STREAM_BYTES = 50 * 1024 * 1024 * 1024;
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 = Buffer.alloc(256, 0);
849
- buf.write("DICM", 128, 4, "ascii");
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
- buf.writeUInt16LE(2, off);
888
+ view.setUint16(off, 2, true);
852
889
  off += 2;
853
- buf.writeUInt16LE(2, off);
890
+ view.setUint16(off, 2, true);
854
891
  off += 2;
855
- buf.write("UI", off, 2, "ascii");
892
+ buf.set(utf8Bytes("UI"), off);
856
893
  off += 2;
857
- buf.writeUInt16LE(uid.length, off);
894
+ view.setUint16(off, uid.length, true);
858
895
  off += 2;
859
- buf.write(uid, off, uid.length, "ascii");
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 Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
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 = Buffer.alloc(200, 0);
1052
- buf.write("XXXX", 128, 4, "ascii");
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 Buffer.alloc(bytes, "not dicom ");
1061
- return Buffer.from(spec.content ?? "not dicom", "utf8");
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/syntheticFixtures/streamWrite.ts
1083
- import { closeSync, mkdirSync as mkdirSync2, openSync, writeSync } from "node:fs";
1084
- import { resolve as resolve2 } from "node:path";
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 = createHash("sha256").update(salt).update(" ").update(key).digest();
1090
- const n = BigInt(`0x${digest.subarray(0, 16).toString("hex")}`);
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${randomBytes(16).toString("hex")}`);
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 Buffer.from(parsed.write({ allowInvalidVRLength: true }));
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 = Buffer.alloc(8);
1377
- delimiter.writeUInt16LE(65534, 0);
1378
- delimiter.writeUInt16LE(57565, 2);
1379
- delimiter.writeUInt32LE(4, 4);
1380
- result = Buffer.concat([result, delimiter]);
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/writer.ts
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: randomBytes2(4).readUInt32BE(0) } : spec;
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: randomBytes2(16).toString("hex") } : spec;
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 = resolve3(outDir);
1745
- mkdirSync3(root, { recursive: true });
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
- mkdirSync3(dir, { recursive: true });
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 = resolve3(root, plan.relativePath);
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 as createHash2 } from "node:crypto";
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 naturalize(ab) {
2123
+ function parseParts(ab) {
1871
2124
  const parsed = dcmjsAny2.data.DicomMessage.readFile(ab);
1872
- return dcmjsAny2.data.DicomMetaDictionary.naturalizeDataset(parsed.dict);
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 naturalize(toArrayBuffer(readFileSync(path)));
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 naturalize(toArrayBuffer(headerOnly));
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 = createHash2("sha256");
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, record: large ? readHeaderOnly(path) : parseFull(path) };
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, keepKeywords) {
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, record } = scanned;
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
- keepKeywords.length ? extractTags(record, keepKeywords) : void 0
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 keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
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, keepKeywords);
2597
+ return describeTree(dir, options, keep);
2314
2598
  }
2315
2599
  const uidMap = /* @__PURE__ */ new Map();
2316
- const useRecords = keepKeywords.length > 0 || preserveUids;
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, record } = scanned;
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 = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
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
- hashRecordUids(rec, salt, uidMap, sweptKeepTags);
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 createHash3 } from "node:crypto";
2446
- import { existsSync, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
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(sha256, root = DEFAULT_CACHE_ROOT) {
2451
- return join3(root, sha256, "file.dcm");
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 = createHash3("sha256").update(buffer).digest("hex");
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
- mkdirSync4(join3(cacheRoot, record.sha256), { recursive: true });
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
- writeFileSync2(dest, buf);
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 ?? randomBytes3(4).readUInt32BE(0);
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 = [];