dicom-synth 1.11.0 → 1.12.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.
@@ -269,68 +269,37 @@ import { closeSync, mkdirSync as mkdirSync2, openSync, writeSync } from "node:fs
269
269
  import { resolve as resolve2 } from "node:path";
270
270
 
271
271
  // src/syntheticFixtures/uid.ts
272
- import { randomBytes } from "node:crypto";
273
- var ROLE_CODE = { study: 1, series: 2, sop: 3 };
274
- function lcg(seed) {
275
- let s = seed >>> 0;
276
- return () => {
277
- s = Math.imul(1664525, s) + 1013904223 >>> 0;
278
- return s;
279
- };
280
- }
281
- function formatUid(a, b, role) {
282
- return `2.25.${a}.${b}.${ROLE_CODE[role]}`;
272
+ import { createHash, randomBytes } from "node:crypto";
273
+ function hashUid(salt, key) {
274
+ const digest = createHash("sha256").update(salt).update(" ").update(key).digest();
275
+ const n = BigInt(`0x${digest.subarray(0, 16).toString("hex")}`);
276
+ return `2.25.${n.toString()}`;
283
277
  }
284
- function randomUid(role) {
285
- const buf = randomBytes(8);
286
- return formatUid(buf.readUInt32BE(0), buf.readUInt32BE(4), role);
278
+ function randomUid() {
279
+ const n = BigInt(`0x${randomBytes(16).toString("hex")}`);
280
+ return `2.25.${n.toString()}`;
287
281
  }
288
- function seededUid(next, role) {
289
- return formatUid(next(), next(), role);
282
+ function seedToSalt(seed) {
283
+ return seed === void 0 ? void 0 : `seed:${seed}`;
290
284
  }
291
- var GROUP_STREAM_OFFSET = 2654435769;
292
- function seededStream(seed, offset, a, b) {
293
- return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
294
- }
295
- function makeGroupUidGenerator(seed) {
296
- if (seed === void 0) {
297
- return {
298
- study: () => randomUid("study"),
299
- series: () => randomUid("series")
300
- };
285
+ function makeGroupUidGenerator(salt) {
286
+ if (salt === void 0) {
287
+ return { study: () => randomUid(), series: () => randomUid() };
301
288
  }
302
289
  return {
303
- study: (studyIndex) => seededUid(
304
- seededStream(seed, GROUP_STREAM_OFFSET, studyIndex + 1, 0),
305
- "study"
306
- ),
307
- series: (studyIndex, seriesIndex) => seededUid(
308
- seededStream(
309
- seed,
310
- GROUP_STREAM_OFFSET,
311
- studyIndex + 1,
312
- seriesIndex + 1
313
- ),
314
- "series"
315
- )
290
+ study: (studyIndex) => hashUid(salt, `study:${studyIndex}`),
291
+ series: (studyIndex, seriesIndex) => hashUid(salt, `series:${studyIndex}:${seriesIndex}`)
316
292
  };
317
293
  }
318
- function makeUidGenerator(seed) {
319
- if (seed === void 0) {
320
- return () => ({
321
- study: randomUid("study"),
322
- series: randomUid("series"),
323
- sop: randomUid("sop")
324
- });
294
+ function makeUidGenerator(salt) {
295
+ if (salt === void 0) {
296
+ return () => ({ study: randomUid(), series: randomUid(), sop: randomUid() });
325
297
  }
326
- return (fileIndex) => {
327
- const next = seededStream(seed, 0, fileIndex, 0);
328
- return {
329
- study: seededUid(next, "study"),
330
- series: seededUid(next, "series"),
331
- sop: seededUid(next, "sop")
332
- };
333
- };
298
+ return (fileIndex) => ({
299
+ study: hashUid(salt, `study:flat:${fileIndex}`),
300
+ series: hashUid(salt, `series:flat:${fileIndex}`),
301
+ sop: hashUid(salt, `sop:${fileIndex}`)
302
+ });
334
303
  }
335
304
 
336
305
  // src/syntheticFixtures/streamWrite.ts
@@ -443,7 +412,7 @@ function streamLargeImage(outPath, options) {
443
412
  fragmentBytes = FRAGMENT_MAX_BYTES
444
413
  } = options;
445
414
  const modality = options.modality ?? "CT";
446
- const uid = options.uid ?? makeUidGenerator(options.seed)(0);
415
+ const uid = options.uid ?? makeUidGenerator(options.uidSalt ?? seedToSalt(options.seed))(0);
447
416
  const identity = { modality, uid, tags: options.tags };
448
417
  mkdirSync2(resolve2(outPath, ".."), { recursive: true });
449
418
  const zeroChunk = Buffer.alloc(Math.max(1, chunkBytes));
@@ -622,13 +591,16 @@ function* seriesFiles(series) {
622
591
  }
623
592
  }
624
593
  }
625
- function resolveUid(seed, index, override) {
626
- return { ...makeUidGenerator(seed)(index), ...override };
594
+ function resolveUid(salt, index, override) {
595
+ return { ...makeUidGenerator(salt)(index), ...override };
596
+ }
597
+ function effectiveSalt(spec) {
598
+ return spec.uidSalt ?? seedToSalt(spec.seed);
627
599
  }
628
600
  async function generateFile(spec, options) {
629
601
  const index = options?.index ?? 0;
630
602
  const padWidth = options?.padWidth ?? 3;
631
- const uid = resolveUid(options?.seed, index, options?.uid);
603
+ const uid = resolveUid(effectiveSalt(options ?? {}), index, options?.uid);
632
604
  const resolvedSpec = "tags" in spec && spec.tags ? {
633
605
  ...spec,
634
606
  tags: resolveTagTemplates(spec.tags, { index, ...options?.context })
@@ -700,6 +672,9 @@ function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
700
672
  function withResolvedSeed(spec) {
701
673
  return spec.seed === void 0 ? { ...spec, seed: randomBytes2(4).readUInt32BE(0) } : spec;
702
674
  }
675
+ function withResolvedSalt(spec) {
676
+ return spec.uidSalt === void 0 ? { ...spec, uidSalt: randomBytes2(16).toString("hex") } : spec;
677
+ }
703
678
  function computeNames(type, index, padWidth, grouped, quirks) {
704
679
  let names;
705
680
  if (grouped) {
@@ -740,7 +715,7 @@ function* planCollection(spec) {
740
715
  globalIndex++;
741
716
  }
742
717
  }
743
- const groupUids = makeGroupUidGenerator(spec.seed);
718
+ const groupUids = makeGroupUidGenerator(effectiveSalt(spec));
744
719
  let studyOrdinal = 0;
745
720
  for (const study of studies) {
746
721
  const copies = study.count ?? 1;
@@ -782,21 +757,23 @@ function* planCollection(spec) {
782
757
  }
783
758
  }
784
759
  }
785
- async function materialisePlan(plan, seed) {
760
+ async function materialisePlan(plan, salt) {
786
761
  const file = await generateFile(plan.fileSpec, {
787
762
  index: plan.index,
788
- seed,
763
+ uidSalt: salt,
789
764
  uid: plan.uidOverride,
790
765
  context: plan.context
791
766
  });
792
767
  return { ...file, filename: plan.filename, relativePath: plan.relativePath };
793
768
  }
794
769
  async function* generateCollectionFromSpec(spec) {
770
+ const salt = effectiveSalt(spec);
795
771
  for (const plan of planCollection(spec)) {
796
- yield await materialisePlan(plan, spec.seed);
772
+ yield await materialisePlan(plan, salt);
797
773
  }
798
774
  }
799
775
  async function* previewCollection(spec) {
776
+ const salt = effectiveSalt(spec);
800
777
  for (const plan of planCollection(spec)) {
801
778
  if (plan.fileSpec.type === "large-image") {
802
779
  yield {
@@ -806,7 +783,7 @@ async function* previewCollection(spec) {
806
783
  approxBytes: plan.fileSpec.targetBytes
807
784
  };
808
785
  } else {
809
- const file = await materialisePlan(plan, spec.seed);
786
+ const file = await materialisePlan(plan, salt);
810
787
  yield {
811
788
  relativePath: plan.relativePath,
812
789
  type: plan.fileSpec.type,
@@ -820,6 +797,7 @@ async function writeCollectionFromSpec(spec, outDir) {
820
797
  const root = resolve3(outDir);
821
798
  mkdirSync3(root, { recursive: true });
822
799
  const manifest = [];
800
+ const salt = effectiveSalt(spec);
823
801
  const createdDirs = /* @__PURE__ */ new Set([root]);
824
802
  const ensureDir = (filePath) => {
825
803
  const dir = dirname(filePath);
@@ -838,7 +816,7 @@ async function writeCollectionFromSpec(spec, outDir) {
838
816
  `large-image targetBytes (${large.targetBytes}) exceeds the 50 GB limit`
839
817
  );
840
818
  }
841
- const uid = resolveUid(spec.seed, plan.index, plan.uidOverride);
819
+ const uid = resolveUid(salt, plan.index, plan.uidOverride);
842
820
  streamLargeImage(filePath, {
843
821
  targetBytes: large.targetBytes,
844
822
  modality: large.modality,
@@ -846,7 +824,7 @@ async function writeCollectionFromSpec(spec, outDir) {
846
824
  tags: resolveTagTemplates(large.tags, plan.context)
847
825
  });
848
826
  } else {
849
- const file = await materialisePlan(plan, spec.seed);
827
+ const file = await materialisePlan(plan, salt);
850
828
  await writeFile(filePath, file.buffer);
851
829
  }
852
830
  manifest.push({
@@ -861,6 +839,7 @@ export {
861
839
  generateCollectionFromSpec,
862
840
  generateFile,
863
841
  previewCollection,
842
+ withResolvedSalt,
864
843
  withResolvedSeed,
865
844
  writeCollectionFromSpec
866
845
  };
@@ -105,6 +105,13 @@ function validateSeed(value, path) {
105
105
  );
106
106
  }
107
107
  }
108
+ function validateUidSalt(value, path) {
109
+ if (typeof value !== "string" || value.length === 0) {
110
+ throw new Error(
111
+ `${path}: must be a non-empty string; got ${JSON.stringify(value)}`
112
+ );
113
+ }
114
+ }
108
115
  function validateSizeKbLimit(kb, path) {
109
116
  if (kb * 1024 > MAX_PIXEL_BYTES) {
110
117
  throw new Error(`${path}: exceeds the 512 MB limit`);
@@ -406,6 +413,7 @@ function validateDatasetSpec(raw) {
406
413
  (study, i) => validateStudy(study, `studies[${i}]`)
407
414
  );
408
415
  if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
416
+ if ("uidSalt" in r) validateUidSalt(r.uidSalt, "DatasetSpec.uidSalt");
409
417
  if ("layout" in r) {
410
418
  validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
411
419
  }
@@ -421,6 +429,7 @@ function validateDatasetSpec(raw) {
421
429
  ...entries.length > 0 ? { entries } : {},
422
430
  ...studies.length > 0 ? { studies } : {},
423
431
  ...r.seed !== void 0 ? { seed: r.seed } : {},
432
+ ...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
424
433
  ...r.layout !== void 0 ? { layout: r.layout } : {},
425
434
  ...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
426
435
  };
package/dist/esm/index.js CHANGED
@@ -154,6 +154,13 @@ function validateSeed(value, path) {
154
154
  );
155
155
  }
156
156
  }
157
+ function validateUidSalt(value, path) {
158
+ if (typeof value !== "string" || value.length === 0) {
159
+ throw new Error(
160
+ `${path}: must be a non-empty string; got ${JSON.stringify(value)}`
161
+ );
162
+ }
163
+ }
157
164
  function validateSizeKbLimit(kb, path) {
158
165
  if (kb * 1024 > MAX_PIXEL_BYTES) {
159
166
  throw new Error(`${path}: exceeds the 512 MB limit`);
@@ -455,6 +462,7 @@ function validateDatasetSpec(raw) {
455
462
  (study, i) => validateStudy(study, `studies[${i}]`)
456
463
  );
457
464
  if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
465
+ if ("uidSalt" in r) validateUidSalt(r.uidSalt, "DatasetSpec.uidSalt");
458
466
  if ("layout" in r) {
459
467
  validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
460
468
  }
@@ -470,6 +478,7 @@ function validateDatasetSpec(raw) {
470
478
  ...entries.length > 0 ? { entries } : {},
471
479
  ...studies.length > 0 ? { studies } : {},
472
480
  ...r.seed !== void 0 ? { seed: r.seed } : {},
481
+ ...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
473
482
  ...r.layout !== void 0 ? { layout: r.layout } : {},
474
483
  ...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
475
484
  };
@@ -585,6 +594,7 @@ function validateParametricSpec(raw) {
585
594
  }
586
595
  }
587
596
  if ("seed" in r) validateSeed(r.seed, "ParametricSpec.seed");
597
+ if ("uidSalt" in r) validateUidSalt(r.uidSalt, "ParametricSpec.uidSalt");
588
598
  if ("layout" in r) {
589
599
  validateEnum(r.layout, VALID_LAYOUTS, "ParametricSpec.layout");
590
600
  }
@@ -592,6 +602,7 @@ function validateParametricSpec(raw) {
592
602
  studies,
593
603
  ...edgeCases.length > 0 ? { edgeCases } : {},
594
604
  ...r.seed !== void 0 ? { seed: r.seed } : {},
605
+ ...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
595
606
  ...r.layout !== void 0 ? { layout: r.layout } : {}
596
607
  };
597
608
  }
@@ -779,8 +790,19 @@ import { closeSync, mkdirSync as mkdirSync2, openSync, writeSync } from "node:fs
779
790
  import { resolve as resolve2 } from "node:path";
780
791
 
781
792
  // src/syntheticFixtures/uid.ts
782
- import { randomBytes } from "node:crypto";
783
- var ROLE_CODE = { study: 1, series: 2, sop: 3 };
793
+ import { createHash, randomBytes } from "node:crypto";
794
+ function hashUid(salt, key) {
795
+ const digest = createHash("sha256").update(salt).update(" ").update(key).digest();
796
+ const n = BigInt(`0x${digest.subarray(0, 16).toString("hex")}`);
797
+ return `2.25.${n.toString()}`;
798
+ }
799
+ function randomUid() {
800
+ const n = BigInt(`0x${randomBytes(16).toString("hex")}`);
801
+ return `2.25.${n.toString()}`;
802
+ }
803
+ function seedToSalt(seed) {
804
+ return seed === void 0 ? void 0 : `seed:${seed}`;
805
+ }
784
806
  function lcg(seed) {
785
807
  let s = seed >>> 0;
786
808
  return () => {
@@ -788,60 +810,28 @@ function lcg(seed) {
788
810
  return s;
789
811
  };
790
812
  }
791
- function formatUid(a, b, role) {
792
- return `2.25.${a}.${b}.${ROLE_CODE[role]}`;
793
- }
794
- function randomUid(role) {
795
- const buf = randomBytes(8);
796
- return formatUid(buf.readUInt32BE(0), buf.readUInt32BE(4), role);
797
- }
798
- function seededUid(next, role) {
799
- return formatUid(next(), next(), role);
800
- }
801
- var GROUP_STREAM_OFFSET = 2654435769;
802
813
  var PARAMETRIC_STREAM_OFFSET = 2246822507;
803
814
  function seededStream(seed, offset, a, b) {
804
815
  return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
805
816
  }
806
- function makeGroupUidGenerator(seed) {
807
- if (seed === void 0) {
808
- return {
809
- study: () => randomUid("study"),
810
- series: () => randomUid("series")
811
- };
817
+ function makeGroupUidGenerator(salt) {
818
+ if (salt === void 0) {
819
+ return { study: () => randomUid(), series: () => randomUid() };
812
820
  }
813
821
  return {
814
- study: (studyIndex) => seededUid(
815
- seededStream(seed, GROUP_STREAM_OFFSET, studyIndex + 1, 0),
816
- "study"
817
- ),
818
- series: (studyIndex, seriesIndex) => seededUid(
819
- seededStream(
820
- seed,
821
- GROUP_STREAM_OFFSET,
822
- studyIndex + 1,
823
- seriesIndex + 1
824
- ),
825
- "series"
826
- )
822
+ study: (studyIndex) => hashUid(salt, `study:${studyIndex}`),
823
+ series: (studyIndex, seriesIndex) => hashUid(salt, `series:${studyIndex}:${seriesIndex}`)
827
824
  };
828
825
  }
829
- function makeUidGenerator(seed) {
830
- if (seed === void 0) {
831
- return () => ({
832
- study: randomUid("study"),
833
- series: randomUid("series"),
834
- sop: randomUid("sop")
835
- });
826
+ function makeUidGenerator(salt) {
827
+ if (salt === void 0) {
828
+ return () => ({ study: randomUid(), series: randomUid(), sop: randomUid() });
836
829
  }
837
- return (fileIndex) => {
838
- const next = seededStream(seed, 0, fileIndex, 0);
839
- return {
840
- study: seededUid(next, "study"),
841
- series: seededUid(next, "series"),
842
- sop: seededUid(next, "sop")
843
- };
844
- };
830
+ return (fileIndex) => ({
831
+ study: hashUid(salt, `study:flat:${fileIndex}`),
832
+ series: hashUid(salt, `series:flat:${fileIndex}`),
833
+ sop: hashUid(salt, `sop:${fileIndex}`)
834
+ });
845
835
  }
846
836
 
847
837
  // src/syntheticFixtures/streamWrite.ts
@@ -954,7 +944,7 @@ function streamLargeImage(outPath, options) {
954
944
  fragmentBytes = FRAGMENT_MAX_BYTES
955
945
  } = options;
956
946
  const modality = options.modality ?? "CT";
957
- const uid = options.uid ?? makeUidGenerator(options.seed)(0);
947
+ const uid = options.uid ?? makeUidGenerator(options.uidSalt ?? seedToSalt(options.seed))(0);
958
948
  const identity = { modality, uid, tags: options.tags };
959
949
  mkdirSync2(resolve2(outPath, ".."), { recursive: true });
960
950
  const zeroChunk = Buffer.alloc(Math.max(1, chunkBytes));
@@ -1006,6 +996,7 @@ function writeLargeImageFile(spec, outPath, options = {}) {
1006
996
  return streamLargeImage(outPath, {
1007
997
  targetBytes,
1008
998
  modality: spec.modality,
999
+ uidSalt: spec.uidSalt,
1009
1000
  seed: spec.seed,
1010
1001
  chunkBytes: options.chunkBytes
1011
1002
  });
@@ -1153,13 +1144,16 @@ function* seriesFiles(series) {
1153
1144
  }
1154
1145
  }
1155
1146
  }
1156
- function resolveUid(seed, index, override) {
1157
- return { ...makeUidGenerator(seed)(index), ...override };
1147
+ function resolveUid(salt, index, override) {
1148
+ return { ...makeUidGenerator(salt)(index), ...override };
1149
+ }
1150
+ function effectiveSalt(spec) {
1151
+ return spec.uidSalt ?? seedToSalt(spec.seed);
1158
1152
  }
1159
1153
  async function generateFile(spec, options) {
1160
1154
  const index = options?.index ?? 0;
1161
1155
  const padWidth = options?.padWidth ?? 3;
1162
- const uid = resolveUid(options?.seed, index, options?.uid);
1156
+ const uid = resolveUid(effectiveSalt(options ?? {}), index, options?.uid);
1163
1157
  const resolvedSpec = "tags" in spec && spec.tags ? {
1164
1158
  ...spec,
1165
1159
  tags: resolveTagTemplates(spec.tags, { index, ...options?.context })
@@ -1231,6 +1225,9 @@ function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
1231
1225
  function withResolvedSeed(spec) {
1232
1226
  return spec.seed === void 0 ? { ...spec, seed: randomBytes2(4).readUInt32BE(0) } : spec;
1233
1227
  }
1228
+ function withResolvedSalt(spec) {
1229
+ return spec.uidSalt === void 0 ? { ...spec, uidSalt: randomBytes2(16).toString("hex") } : spec;
1230
+ }
1234
1231
  function computeNames(type, index, padWidth, grouped, quirks) {
1235
1232
  let names;
1236
1233
  if (grouped) {
@@ -1271,7 +1268,7 @@ function* planCollection(spec) {
1271
1268
  globalIndex++;
1272
1269
  }
1273
1270
  }
1274
- const groupUids = makeGroupUidGenerator(spec.seed);
1271
+ const groupUids = makeGroupUidGenerator(effectiveSalt(spec));
1275
1272
  let studyOrdinal = 0;
1276
1273
  for (const study of studies) {
1277
1274
  const copies = study.count ?? 1;
@@ -1313,21 +1310,23 @@ function* planCollection(spec) {
1313
1310
  }
1314
1311
  }
1315
1312
  }
1316
- async function materialisePlan(plan, seed) {
1313
+ async function materialisePlan(plan, salt) {
1317
1314
  const file = await generateFile(plan.fileSpec, {
1318
1315
  index: plan.index,
1319
- seed,
1316
+ uidSalt: salt,
1320
1317
  uid: plan.uidOverride,
1321
1318
  context: plan.context
1322
1319
  });
1323
1320
  return { ...file, filename: plan.filename, relativePath: plan.relativePath };
1324
1321
  }
1325
1322
  async function* generateCollectionFromSpec(spec) {
1323
+ const salt = effectiveSalt(spec);
1326
1324
  for (const plan of planCollection(spec)) {
1327
- yield await materialisePlan(plan, spec.seed);
1325
+ yield await materialisePlan(plan, salt);
1328
1326
  }
1329
1327
  }
1330
1328
  async function* previewCollection(spec) {
1329
+ const salt = effectiveSalt(spec);
1331
1330
  for (const plan of planCollection(spec)) {
1332
1331
  if (plan.fileSpec.type === "large-image") {
1333
1332
  yield {
@@ -1337,7 +1336,7 @@ async function* previewCollection(spec) {
1337
1336
  approxBytes: plan.fileSpec.targetBytes
1338
1337
  };
1339
1338
  } else {
1340
- const file = await materialisePlan(plan, spec.seed);
1339
+ const file = await materialisePlan(plan, salt);
1341
1340
  yield {
1342
1341
  relativePath: plan.relativePath,
1343
1342
  type: plan.fileSpec.type,
@@ -1351,6 +1350,7 @@ async function writeCollectionFromSpec(spec, outDir) {
1351
1350
  const root = resolve3(outDir);
1352
1351
  mkdirSync3(root, { recursive: true });
1353
1352
  const manifest = [];
1353
+ const salt = effectiveSalt(spec);
1354
1354
  const createdDirs = /* @__PURE__ */ new Set([root]);
1355
1355
  const ensureDir = (filePath) => {
1356
1356
  const dir = dirname(filePath);
@@ -1369,7 +1369,7 @@ async function writeCollectionFromSpec(spec, outDir) {
1369
1369
  `large-image targetBytes (${large.targetBytes}) exceeds the 50 GB limit`
1370
1370
  );
1371
1371
  }
1372
- const uid = resolveUid(spec.seed, plan.index, plan.uidOverride);
1372
+ const uid = resolveUid(salt, plan.index, plan.uidOverride);
1373
1373
  streamLargeImage(filePath, {
1374
1374
  targetBytes: large.targetBytes,
1375
1375
  modality: large.modality,
@@ -1377,7 +1377,7 @@ async function writeCollectionFromSpec(spec, outDir) {
1377
1377
  tags: resolveTagTemplates(large.tags, plan.context)
1378
1378
  });
1379
1379
  } else {
1380
- const file = await materialisePlan(plan, spec.seed);
1380
+ const file = await materialisePlan(plan, salt);
1381
1381
  await writeFile(filePath, file.buffer);
1382
1382
  }
1383
1383
  manifest.push({
@@ -1759,7 +1759,7 @@ function loadCaseById(casesJsonPath, id) {
1759
1759
  }
1760
1760
 
1761
1761
  // src/public-fixtures/fetch.ts
1762
- import { createHash } from "node:crypto";
1762
+ import { createHash as createHash2 } from "node:crypto";
1763
1763
  import { existsSync, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
1764
1764
  import { homedir } from "node:os";
1765
1765
  import { join as join3 } from "node:path";
@@ -1768,7 +1768,7 @@ function caseCachePath(sha256, root = DEFAULT_CACHE_ROOT) {
1768
1768
  return join3(root, sha256, "file.dcm");
1769
1769
  }
1770
1770
  function verifySha256(buffer, expected) {
1771
- const h = createHash("sha256").update(buffer).digest("hex");
1771
+ const h = createHash2("sha256").update(buffer).digest("hex");
1772
1772
  if (h !== expected) {
1773
1773
  throw new Error(
1774
1774
  `SHA256 mismatch: expected ${expected}, got ${h}. Upstream may have changed; bump metadata after review.`
@@ -1900,6 +1900,9 @@ function resolveParametricSpec(spec) {
1900
1900
  ...entries.length > 0 ? { entries } : {},
1901
1901
  studies,
1902
1902
  seed,
1903
+ // Pass an explicit salt through; when absent, UIDs derive from `seed` so the
1904
+ // resolved spec stays deterministic ("same seed → identical resolved spec").
1905
+ ...validated.uidSalt !== void 0 ? { uidSalt: validated.uidSalt } : {},
1903
1906
  ...validated.layout !== void 0 ? { layout: validated.layout } : {}
1904
1907
  };
1905
1908
  }
@@ -1913,16 +1916,19 @@ export {
1913
1916
  fetchPublicCaseToCache,
1914
1917
  generateCollectionFromSpec,
1915
1918
  generateFile,
1919
+ hashUid,
1916
1920
  loadCaseById,
1917
1921
  loadCasesFromJson,
1918
1922
  loadDefaultCases,
1919
1923
  previewCollection,
1920
1924
  resolveParametricSpec,
1921
1925
  resolveTagTemplates,
1926
+ seedToSalt,
1922
1927
  streamLargeImage,
1923
1928
  validateDatasetSpec,
1924
1929
  validateParametricSpec,
1925
1930
  verifySha256,
1931
+ withResolvedSalt,
1926
1932
  withResolvedSeed,
1927
1933
  writeCollectionFromSpec,
1928
1934
  writeLargeImageFile
@@ -2,7 +2,7 @@
2
2
  import { randomBytes as randomBytes2 } from "node:crypto";
3
3
 
4
4
  // src/syntheticFixtures/uid.ts
5
- import { randomBytes } from "node:crypto";
5
+ import { createHash, randomBytes } from "node:crypto";
6
6
  function lcg(seed) {
7
7
  let s = seed >>> 0;
8
8
  return () => {
@@ -90,6 +90,13 @@ function validateSeed(value, path) {
90
90
  );
91
91
  }
92
92
  }
93
+ function validateUidSalt(value, path) {
94
+ if (typeof value !== "string" || value.length === 0) {
95
+ throw new Error(
96
+ `${path}: must be a non-empty string; got ${JSON.stringify(value)}`
97
+ );
98
+ }
99
+ }
93
100
  function validateSizeKbLimit(kb, path) {
94
101
  if (kb * 1024 > MAX_PIXEL_BYTES) {
95
102
  throw new Error(`${path}: exceeds the 512 MB limit`);
@@ -361,6 +368,7 @@ function validateParametricSpec(raw) {
361
368
  }
362
369
  }
363
370
  if ("seed" in r) validateSeed(r.seed, "ParametricSpec.seed");
371
+ if ("uidSalt" in r) validateUidSalt(r.uidSalt, "ParametricSpec.uidSalt");
364
372
  if ("layout" in r) {
365
373
  validateEnum(r.layout, VALID_LAYOUTS, "ParametricSpec.layout");
366
374
  }
@@ -368,6 +376,7 @@ function validateParametricSpec(raw) {
368
376
  studies,
369
377
  ...edgeCases.length > 0 ? { edgeCases } : {},
370
378
  ...r.seed !== void 0 ? { seed: r.seed } : {},
379
+ ...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
371
380
  ...r.layout !== void 0 ? { layout: r.layout } : {}
372
381
  };
373
382
  }
@@ -471,6 +480,9 @@ function resolveParametricSpec(spec) {
471
480
  ...entries.length > 0 ? { entries } : {},
472
481
  studies,
473
482
  seed,
483
+ // Pass an explicit salt through; when absent, UIDs derive from `seed` so the
484
+ // resolved spec stays deterministic ("same seed → identical resolved spec").
485
+ ...validated.uidSalt !== void 0 ? { uidSalt: validated.uidSalt } : {},
474
486
  ...validated.layout !== void 0 ? { layout: validated.layout } : {}
475
487
  };
476
488
  }
@@ -79,6 +79,13 @@ function validateSeed(value, path) {
79
79
  );
80
80
  }
81
81
  }
82
+ function validateUidSalt(value, path) {
83
+ if (typeof value !== "string" || value.length === 0) {
84
+ throw new Error(
85
+ `${path}: must be a non-empty string; got ${JSON.stringify(value)}`
86
+ );
87
+ }
88
+ }
82
89
  function validateSizeKbLimit(kb, path) {
83
90
  if (kb * 1024 > MAX_PIXEL_BYTES) {
84
91
  throw new Error(`${path}: exceeds the 512 MB limit`);
@@ -380,6 +387,7 @@ function validateDatasetSpec(raw) {
380
387
  (study, i) => validateStudy(study, `studies[${i}]`)
381
388
  );
382
389
  if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
390
+ if ("uidSalt" in r) validateUidSalt(r.uidSalt, "DatasetSpec.uidSalt");
383
391
  if ("layout" in r) {
384
392
  validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
385
393
  }
@@ -395,6 +403,7 @@ function validateDatasetSpec(raw) {
395
403
  ...entries.length > 0 ? { entries } : {},
396
404
  ...studies.length > 0 ? { studies } : {},
397
405
  ...r.seed !== void 0 ? { seed: r.seed } : {},
406
+ ...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
398
407
  ...r.layout !== void 0 ? { layout: r.layout } : {},
399
408
  ...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
400
409
  };
@@ -510,6 +519,7 @@ function validateParametricSpec(raw) {
510
519
  }
511
520
  }
512
521
  if ("seed" in r) validateSeed(r.seed, "ParametricSpec.seed");
522
+ if ("uidSalt" in r) validateUidSalt(r.uidSalt, "ParametricSpec.uidSalt");
513
523
  if ("layout" in r) {
514
524
  validateEnum(r.layout, VALID_LAYOUTS, "ParametricSpec.layout");
515
525
  }
@@ -517,6 +527,7 @@ function validateParametricSpec(raw) {
517
527
  studies,
518
528
  ...edgeCases.length > 0 ? { edgeCases } : {},
519
529
  ...r.seed !== void 0 ? { seed: r.seed } : {},
530
+ ...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
520
531
  ...r.layout !== void 0 ? { layout: r.layout } : {}
521
532
  };
522
533
  }
@@ -144,44 +144,28 @@ function serializeDict(meta, dataset) {
144
144
  }
145
145
 
146
146
  // src/syntheticFixtures/uid.ts
147
- import { randomBytes } from "node:crypto";
148
- var ROLE_CODE = { study: 1, series: 2, sop: 3 };
149
- function lcg(seed) {
150
- let s = seed >>> 0;
151
- return () => {
152
- s = Math.imul(1664525, s) + 1013904223 >>> 0;
153
- return s;
154
- };
155
- }
156
- function formatUid(a, b, role) {
157
- return `2.25.${a}.${b}.${ROLE_CODE[role]}`;
147
+ import { createHash, randomBytes } from "node:crypto";
148
+ function hashUid(salt, key) {
149
+ const digest = createHash("sha256").update(salt).update(" ").update(key).digest();
150
+ const n = BigInt(`0x${digest.subarray(0, 16).toString("hex")}`);
151
+ return `2.25.${n.toString()}`;
158
152
  }
159
- function randomUid(role) {
160
- const buf = randomBytes(8);
161
- return formatUid(buf.readUInt32BE(0), buf.readUInt32BE(4), role);
153
+ function randomUid() {
154
+ const n = BigInt(`0x${randomBytes(16).toString("hex")}`);
155
+ return `2.25.${n.toString()}`;
162
156
  }
163
- function seededUid(next, role) {
164
- return formatUid(next(), next(), role);
157
+ function seedToSalt(seed) {
158
+ return seed === void 0 ? void 0 : `seed:${seed}`;
165
159
  }
166
- function seededStream(seed, offset, a, b) {
167
- return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
168
- }
169
- function makeUidGenerator(seed) {
170
- if (seed === void 0) {
171
- return () => ({
172
- study: randomUid("study"),
173
- series: randomUid("series"),
174
- sop: randomUid("sop")
175
- });
160
+ function makeUidGenerator(salt) {
161
+ if (salt === void 0) {
162
+ return () => ({ study: randomUid(), series: randomUid(), sop: randomUid() });
176
163
  }
177
- return (fileIndex) => {
178
- const next = seededStream(seed, 0, fileIndex, 0);
179
- return {
180
- study: seededUid(next, "study"),
181
- series: seededUid(next, "series"),
182
- sop: seededUid(next, "sop")
183
- };
184
- };
164
+ return (fileIndex) => ({
165
+ study: hashUid(salt, `study:flat:${fileIndex}`),
166
+ series: hashUid(salt, `series:flat:${fileIndex}`),
167
+ sop: hashUid(salt, `sop:${fileIndex}`)
168
+ });
185
169
  }
186
170
 
187
171
  // src/syntheticFixtures/streamWrite.ts
@@ -294,7 +278,7 @@ function streamLargeImage(outPath, options) {
294
278
  fragmentBytes = FRAGMENT_MAX_BYTES
295
279
  } = options;
296
280
  const modality = options.modality ?? "CT";
297
- const uid = options.uid ?? makeUidGenerator(options.seed)(0);
281
+ const uid = options.uid ?? makeUidGenerator(options.uidSalt ?? seedToSalt(options.seed))(0);
298
282
  const identity = { modality, uid, tags: options.tags };
299
283
  mkdirSync2(resolve2(outPath, ".."), { recursive: true });
300
284
  const zeroChunk = Buffer.alloc(Math.max(1, chunkBytes));
@@ -346,6 +330,7 @@ function writeLargeImageFile(spec, outPath, options = {}) {
346
330
  return streamLargeImage(outPath, {
347
331
  targetBytes,
348
332
  modality: spec.modality,
333
+ uidSalt: spec.uidSalt,
349
334
  seed: spec.seed,
350
335
  chunkBytes: options.chunkBytes
351
336
  });
@@ -1,6 +1,17 @@
1
1
  // src/syntheticFixtures/uid.ts
2
- import { randomBytes } from "node:crypto";
3
- var ROLE_CODE = { study: 1, series: 2, sop: 3 };
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ function hashUid(salt, key) {
4
+ const digest = createHash("sha256").update(salt).update(" ").update(key).digest();
5
+ const n = BigInt(`0x${digest.subarray(0, 16).toString("hex")}`);
6
+ return `2.25.${n.toString()}`;
7
+ }
8
+ function randomUid() {
9
+ const n = BigInt(`0x${randomBytes(16).toString("hex")}`);
10
+ return `2.25.${n.toString()}`;
11
+ }
12
+ function seedToSalt(seed) {
13
+ return seed === void 0 ? void 0 : `seed:${seed}`;
14
+ }
4
15
  function lcg(seed) {
5
16
  let s = seed >>> 0;
6
17
  return () => {
@@ -8,64 +19,34 @@ function lcg(seed) {
8
19
  return s;
9
20
  };
10
21
  }
11
- function formatUid(a, b, role) {
12
- return `2.25.${a}.${b}.${ROLE_CODE[role]}`;
13
- }
14
- function randomUid(role) {
15
- const buf = randomBytes(8);
16
- return formatUid(buf.readUInt32BE(0), buf.readUInt32BE(4), role);
17
- }
18
- function seededUid(next, role) {
19
- return formatUid(next(), next(), role);
20
- }
21
- var GROUP_STREAM_OFFSET = 2654435769;
22
22
  var PARAMETRIC_STREAM_OFFSET = 2246822507;
23
23
  function seededStream(seed, offset, a, b) {
24
24
  return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
25
25
  }
26
- function makeGroupUidGenerator(seed) {
27
- if (seed === void 0) {
28
- return {
29
- study: () => randomUid("study"),
30
- series: () => randomUid("series")
31
- };
26
+ function makeGroupUidGenerator(salt) {
27
+ if (salt === void 0) {
28
+ return { study: () => randomUid(), series: () => randomUid() };
32
29
  }
33
30
  return {
34
- study: (studyIndex) => seededUid(
35
- seededStream(seed, GROUP_STREAM_OFFSET, studyIndex + 1, 0),
36
- "study"
37
- ),
38
- series: (studyIndex, seriesIndex) => seededUid(
39
- seededStream(
40
- seed,
41
- GROUP_STREAM_OFFSET,
42
- studyIndex + 1,
43
- seriesIndex + 1
44
- ),
45
- "series"
46
- )
31
+ study: (studyIndex) => hashUid(salt, `study:${studyIndex}`),
32
+ series: (studyIndex, seriesIndex) => hashUid(salt, `series:${studyIndex}:${seriesIndex}`)
47
33
  };
48
34
  }
49
- function makeUidGenerator(seed) {
50
- if (seed === void 0) {
51
- return () => ({
52
- study: randomUid("study"),
53
- series: randomUid("series"),
54
- sop: randomUid("sop")
55
- });
35
+ function makeUidGenerator(salt) {
36
+ if (salt === void 0) {
37
+ return () => ({ study: randomUid(), series: randomUid(), sop: randomUid() });
56
38
  }
57
- return (fileIndex) => {
58
- const next = seededStream(seed, 0, fileIndex, 0);
59
- return {
60
- study: seededUid(next, "study"),
61
- series: seededUid(next, "series"),
62
- sop: seededUid(next, "sop")
63
- };
64
- };
39
+ return (fileIndex) => ({
40
+ study: hashUid(salt, `study:flat:${fileIndex}`),
41
+ series: hashUid(salt, `series:flat:${fileIndex}`),
42
+ sop: hashUid(salt, `sop:${fileIndex}`)
43
+ });
65
44
  }
66
45
  export {
67
46
  PARAMETRIC_STREAM_OFFSET,
47
+ hashUid,
68
48
  makeGroupUidGenerator,
69
49
  makeUidGenerator,
50
+ seedToSalt,
70
51
  seededStream
71
52
  };
@@ -16,11 +16,13 @@ export type CollectionManifest = Array<{
16
16
  export declare function generateFile(spec: FileSpec, options?: {
17
17
  index?: number;
18
18
  seed?: number;
19
+ uidSalt?: string;
19
20
  padWidth?: number;
20
21
  uid?: Partial<UidSet>;
21
22
  context?: Partial<TagContext>;
22
23
  }): Promise<GeneratedFile>;
23
24
  export declare function withResolvedSeed(spec: DatasetSpec): DatasetSpec;
25
+ export declare function withResolvedSalt(spec: DatasetSpec): DatasetSpec;
24
26
  export declare function generateCollectionFromSpec(spec: DatasetSpec): AsyncGenerator<GeneratedFile>;
25
27
  export type CollectionPreviewItem = {
26
28
  relativePath: string;
@@ -1,4 +1,4 @@
1
- export { type CollectionManifest, type CollectionPreviewItem, type GeneratedFile, generateCollectionFromSpec, generateFile, previewCollection, withResolvedSeed, writeCollectionFromSpec, } from './collection/writer.js';
1
+ export { type CollectionManifest, type CollectionPreviewItem, type GeneratedFile, generateCollectionFromSpec, generateFile, previewCollection, withResolvedSalt, withResolvedSeed, writeCollectionFromSpec, } from './collection/writer.js';
2
2
  export { type DescribeOptions, type DescribeResult, describeDirectory, } from './describe/describe.js';
3
3
  export { defaultPublicCasesPath, loadCaseById, loadCasesFromJson, loadDefaultCases, type PublicCaseRecord, type PublicCaseSource, } from './public-fixtures/catalog.js';
4
4
  export { caseCachePath, fetchPublicCaseToCache, verifySha256, } from './public-fixtures/fetch.js';
@@ -8,3 +8,4 @@ export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeS
8
8
  export { validateDatasetSpec, validateParametricSpec, } from './schema/validate.js';
9
9
  export { type LargeFileEncoding, type LargeFileResult, type LargeImageSpec, type StreamLargeImageOptions, streamLargeImage, writeLargeImageFile, } from './syntheticFixtures/streamWrite.js';
10
10
  export { resolveTagTemplates, type TagContext, TEMPLATE_VOCAB, } from './syntheticFixtures/tagTemplate.js';
11
+ export { hashUid, seedToSalt } from './syntheticFixtures/uid.js';
@@ -74,6 +74,7 @@ export type DatasetSpec = {
74
74
  entries?: EntrySpec[];
75
75
  studies?: StudySpec[];
76
76
  seed?: number;
77
+ uidSalt?: string;
77
78
  layout?: DatasetLayout;
78
79
  pathQuirks?: PathQuirk[];
79
80
  };
@@ -96,6 +97,7 @@ export type ParametricEdgeCase = EntrySpec & {
96
97
  };
97
98
  export type ParametricSpec = {
98
99
  seed?: number;
100
+ uidSalt?: string;
99
101
  studies: ParametricStudies;
100
102
  edgeCases?: ParametricEdgeCase[];
101
103
  layout?: DatasetLayout;
@@ -4,7 +4,9 @@ export type LargeImageSpec = {
4
4
  /** Approximate total file size in bytes (must exceed the 512 MB cap). */
5
5
  targetBytes: number;
6
6
  modality?: Modality;
7
- /** Drawn when omitted; fixes the instance UIDs for reproducibility. */
7
+ /** UID namespace (hash-derived UIDs); random when both salt and seed omitted. */
8
+ uidSalt?: string;
9
+ /** Bridged to a salt when uidSalt is omitted; fixes the instance UIDs. */
8
10
  seed?: number;
9
11
  };
10
12
  export type LargeFileEncoding = 'native' | 'encapsulated';
@@ -18,7 +20,9 @@ export type StreamLargeImageOptions = {
18
20
  /** Approximate total file size in bytes (no floor — small values are valid). */
19
21
  targetBytes: number;
20
22
  modality?: Modality;
21
- /** Drawn when omitted; fixes the instance UIDs for reproducibility. */
23
+ /** UID namespace (hash-derived UIDs); random when both salt and seed omitted. */
24
+ uidSalt?: string;
25
+ /** Bridged to a salt when uidSalt is omitted; fixes the instance UIDs. */
22
26
  seed?: number;
23
27
  /** Explicit UID set — used as-is (e.g. collection threading shared study/series UIDs). */
24
28
  uid?: UidSet;
@@ -3,12 +3,14 @@ export type UidSet = {
3
3
  series: string;
4
4
  sop: string;
5
5
  };
6
+ export declare function hashUid(salt: string, key: string): string;
7
+ export declare function seedToSalt(seed: number | undefined): string | undefined;
6
8
  export declare const PARAMETRIC_STREAM_OFFSET = 2246822507;
7
9
  export declare function seededStream(seed: number, offset: number, a: number, b: number): () => number;
8
10
  export type GroupUidGenerator = {
9
11
  study: (studyIndex: number) => string;
10
12
  series: (studyIndex: number, seriesIndex: number) => string;
11
13
  };
12
- export declare function makeGroupUidGenerator(seed?: number): GroupUidGenerator;
14
+ export declare function makeGroupUidGenerator(salt?: string): GroupUidGenerator;
13
15
  export type UidGenerator = (fileIndex: number) => UidSet;
14
- export declare function makeUidGenerator(seed?: number): UidGenerator;
16
+ export declare function makeUidGenerator(salt?: string): UidGenerator;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dicom-synth",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "Toolkit for synthetic DICOM fixtures and public fixture fetch/cache.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",