dicom-synth 1.10.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.
@@ -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`);
@@ -224,27 +231,30 @@ function validateEntry(entry, path) {
224
231
  return e;
225
232
  }
226
233
  var MODALITY_TAG = "00080060";
234
+ function validateTagValue(key, value, path) {
235
+ const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
236
+ if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
237
+ throw new Error(
238
+ `${path}.${key}: set a preset modality (${Object.keys(VALID_MODALITIES).join("/")}) via the "modality" field, not tags \u2014 it is coupled to SOPClassUID and type-1 attributes`
239
+ );
240
+ }
241
+ if (typeof value === "string") {
242
+ for (const name of findTemplateTokens(value)) {
243
+ if (!TEMPLATE_VOCAB.includes(name)) {
244
+ throw new Error(
245
+ `${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
246
+ );
247
+ }
248
+ }
249
+ }
250
+ }
227
251
  function validateTags(tags, path) {
228
252
  if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
229
253
  throw new Error(`${path}: must be an object`);
230
254
  }
231
255
  for (const [key, value] of Object.entries(tags)) {
232
256
  validateTagKey(key, path);
233
- const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
234
- if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
235
- throw new Error(
236
- `${path}.${key}: set a preset modality (${Object.keys(VALID_MODALITIES).join("/")}) via the "modality" field, not tags \u2014 it is coupled to SOPClassUID and type-1 attributes`
237
- );
238
- }
239
- if (typeof value === "string") {
240
- for (const name of findTemplateTokens(value)) {
241
- if (!TEMPLATE_VOCAB.includes(name)) {
242
- throw new Error(
243
- `${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
244
- );
245
- }
246
- }
247
- }
257
+ validateTagValue(key, value, path);
248
258
  }
249
259
  }
250
260
  function validateRange(value, path, options = {}) {
@@ -358,6 +368,7 @@ function validateParametricSpec(raw) {
358
368
  }
359
369
  }
360
370
  if ("seed" in r) validateSeed(r.seed, "ParametricSpec.seed");
371
+ if ("uidSalt" in r) validateUidSalt(r.uidSalt, "ParametricSpec.uidSalt");
361
372
  if ("layout" in r) {
362
373
  validateEnum(r.layout, VALID_LAYOUTS, "ParametricSpec.layout");
363
374
  }
@@ -365,6 +376,7 @@ function validateParametricSpec(raw) {
365
376
  studies,
366
377
  ...edgeCases.length > 0 ? { edgeCases } : {},
367
378
  ...r.seed !== void 0 ? { seed: r.seed } : {},
379
+ ...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
368
380
  ...r.layout !== void 0 ? { layout: r.layout } : {}
369
381
  };
370
382
  }
@@ -468,6 +480,9 @@ function resolveParametricSpec(spec) {
468
480
  ...entries.length > 0 ? { entries } : {},
469
481
  studies,
470
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 } : {},
471
486
  ...validated.layout !== void 0 ? { layout: validated.layout } : {}
472
487
  };
473
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`);
@@ -213,26 +220,100 @@ function validateEntry(entry, path) {
213
220
  return e;
214
221
  }
215
222
  var MODALITY_TAG = "00080060";
223
+ function validateTagValue(key, value, path) {
224
+ const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
225
+ if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
226
+ throw new Error(
227
+ `${path}.${key}: set a preset modality (${Object.keys(VALID_MODALITIES).join("/")}) via the "modality" field, not tags \u2014 it is coupled to SOPClassUID and type-1 attributes`
228
+ );
229
+ }
230
+ if (typeof value === "string") {
231
+ for (const name of findTemplateTokens(value)) {
232
+ if (!TEMPLATE_VOCAB.includes(name)) {
233
+ throw new Error(
234
+ `${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
235
+ );
236
+ }
237
+ }
238
+ }
239
+ }
216
240
  function validateTags(tags, path) {
217
241
  if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
218
242
  throw new Error(`${path}: must be an object`);
219
243
  }
220
244
  for (const [key, value] of Object.entries(tags)) {
221
245
  validateTagKey(key, path);
222
- const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
223
- if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
224
- throw new Error(
225
- `${path}.${key}: set a preset modality (${Object.keys(VALID_MODALITIES).join("/")}) via the "modality" field, not tags \u2014 it is coupled to SOPClassUID and type-1 attributes`
246
+ validateTagValue(key, value, path);
247
+ }
248
+ }
249
+ function validateArrayOfLength(value, count, path) {
250
+ if (!Array.isArray(value)) {
251
+ throw new Error(`${path}: must be an array`);
252
+ }
253
+ if (value.length !== count) {
254
+ throw new Error(
255
+ `${path}: length (${value.length}) must equal count (${count})`
256
+ );
257
+ }
258
+ return value;
259
+ }
260
+ function validateInstances(instances, path) {
261
+ if (typeof instances !== "object" || instances === null || Array.isArray(instances)) {
262
+ throw new Error(`${path}: must be an object`);
263
+ }
264
+ const inst = instances;
265
+ validatePositiveInt(inst.count, `${path}.count`);
266
+ const count = inst.count;
267
+ const hasKb = "targetSizeKb" in inst;
268
+ const hasBytes = "targetBytes" in inst;
269
+ if (hasKb === hasBytes) {
270
+ throw new Error(
271
+ `${path}: exactly one of "targetSizeKb" or "targetBytes" is required`
272
+ );
273
+ }
274
+ if (hasKb) {
275
+ const sizes = validateArrayOfLength(
276
+ inst.targetSizeKb,
277
+ count,
278
+ `${path}.targetSizeKb`
279
+ );
280
+ sizes.forEach((kb, i) => {
281
+ validatePositiveInt(kb, `${path}.targetSizeKb[${i}]`);
282
+ validateSizeKbLimit(kb, `${path}.targetSizeKb[${i}]`);
283
+ });
284
+ } else {
285
+ const sizes = validateArrayOfLength(
286
+ inst.targetBytes,
287
+ count,
288
+ `${path}.targetBytes`
289
+ );
290
+ sizes.forEach((b, i) => {
291
+ validateLargeTargetBytes(b, `${path}.targetBytes[${i}]`);
292
+ });
293
+ }
294
+ if ("modality" in inst) {
295
+ if (Array.isArray(inst.modality)) {
296
+ validateArrayOfLength(inst.modality, count, `${path}.modality`).forEach(
297
+ (m, i) => {
298
+ validateEnum(m, VALID_MODALITIES, `${path}.modality[${i}]`);
299
+ }
226
300
  );
301
+ } else {
302
+ validateEnum(inst.modality, VALID_MODALITIES, `${path}.modality`);
227
303
  }
228
- if (typeof value === "string") {
229
- for (const name of findTemplateTokens(value)) {
230
- if (!TEMPLATE_VOCAB.includes(name)) {
231
- throw new Error(
232
- `${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
233
- );
304
+ }
305
+ if ("tags" in inst) {
306
+ const tags = inst.tags;
307
+ if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
308
+ throw new Error(`${path}.tags: must be an object`);
309
+ }
310
+ for (const [key, arr] of Object.entries(tags)) {
311
+ validateTagKey(key, `${path}.tags`);
312
+ validateArrayOfLength(arr, count, `${path}.tags.${key}`).forEach(
313
+ (value, i) => {
314
+ validateTagValue(key, value, `${path}.tags[${i}]`);
234
315
  }
235
- }
316
+ );
236
317
  }
237
318
  }
238
319
  }
@@ -241,16 +322,27 @@ function validateSeries(series, path) {
241
322
  throw new Error(`${path}: must be an object`);
242
323
  }
243
324
  const s = series;
244
- if (!Array.isArray(s.entries) || s.entries.length === 0) {
245
- throw new Error(`${path}.entries: must be a non-empty array`);
325
+ const hasEntries = "entries" in s;
326
+ const hasInstances = "instances" in s;
327
+ if (hasEntries === hasInstances) {
328
+ throw new Error(
329
+ `${path}: must have exactly one of "entries" or "instances"`
330
+ );
246
331
  }
247
- for (let i = 0; i < s.entries.length; i++) {
248
- const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
249
- if (!TYPE_CAPABILITIES[e.type].image) {
250
- throw new Error(
251
- `${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
252
- );
332
+ if (hasEntries) {
333
+ if (!Array.isArray(s.entries) || s.entries.length === 0) {
334
+ throw new Error(`${path}.entries: must be a non-empty array`);
335
+ }
336
+ for (let i = 0; i < s.entries.length; i++) {
337
+ const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
338
+ if (!TYPE_CAPABILITIES[e.type].image) {
339
+ throw new Error(
340
+ `${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
341
+ );
342
+ }
253
343
  }
344
+ } else {
345
+ validateInstances(s.instances, `${path}.instances`);
254
346
  }
255
347
  if ("tags" in s) validateTags(s.tags, `${path}.tags`);
256
348
  return s;
@@ -295,6 +387,7 @@ function validateDatasetSpec(raw) {
295
387
  (study, i) => validateStudy(study, `studies[${i}]`)
296
388
  );
297
389
  if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
390
+ if ("uidSalt" in r) validateUidSalt(r.uidSalt, "DatasetSpec.uidSalt");
298
391
  if ("layout" in r) {
299
392
  validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
300
393
  }
@@ -310,6 +403,7 @@ function validateDatasetSpec(raw) {
310
403
  ...entries.length > 0 ? { entries } : {},
311
404
  ...studies.length > 0 ? { studies } : {},
312
405
  ...r.seed !== void 0 ? { seed: r.seed } : {},
406
+ ...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
313
407
  ...r.layout !== void 0 ? { layout: r.layout } : {},
314
408
  ...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
315
409
  };
@@ -425,6 +519,7 @@ function validateParametricSpec(raw) {
425
519
  }
426
520
  }
427
521
  if ("seed" in r) validateSeed(r.seed, "ParametricSpec.seed");
522
+ if ("uidSalt" in r) validateUidSalt(r.uidSalt, "ParametricSpec.uidSalt");
428
523
  if ("layout" in r) {
429
524
  validateEnum(r.layout, VALID_LAYOUTS, "ParametricSpec.layout");
430
525
  }
@@ -432,6 +527,7 @@ function validateParametricSpec(raw) {
432
527
  studies,
433
528
  ...edgeCases.length > 0 ? { edgeCases } : {},
434
529
  ...r.seed !== void 0 ? { seed: r.seed } : {},
530
+ ...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
435
531
  ...r.layout !== void 0 ? { layout: r.layout } : {}
436
532
  };
437
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,6 +1,7 @@
1
1
  import type { DatasetSpec } from '../schema/types.js';
2
2
  export type DescribeOptions = {
3
3
  keepTags?: string[];
4
+ sizeTolerance?: number;
4
5
  };
5
6
  export type DescribeResult = {
6
7
  spec: DatasetSpec;
@@ -11,4 +12,5 @@ export type DescribeResult = {
11
12
  };
12
13
  };
13
14
  export declare function readHeaderOnly(path: string): Record<string, unknown> | null;
15
+ export declare function tagValuesEqual(a: unknown, b: unknown): boolean;
14
16
  export declare function describeDirectory(dir: string, options?: DescribeOptions): DescribeResult;
@@ -1,10 +1,11 @@
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';
5
5
  export { MAX_PIXEL_BYTES, MAX_STREAM_BYTES } from './schema/limits.js';
6
6
  export { resolveParametricSpec } from './schema/parametric.js';
7
- export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidImageSpec, LargeFileSpec, Modality, NonDicomSpec, ParametricEdgeCase, ParametricSpec, ParametricStudies, PathQuirk, Range, SeriesEntrySpec, SeriesSpec, StudySpec, TransferSyntax, ValidImageSpec, VendorWarningsImageSpec, ViolationClass, } from './schema/types.js';
7
+ export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidImageSpec, LargeFileSpec, Modality, NonDicomSpec, ParametricEdgeCase, ParametricSpec, ParametricStudies, PathQuirk, Range, SeriesEntrySpec, SeriesInstances, SeriesSpec, StudySpec, TransferSyntax, ValidImageSpec, VendorWarningsImageSpec, ViolationClass, } from './schema/types.js';
8
8
  export { validateDatasetSpec, validateParametricSpec, } from './schema/validate.js';
9
9
  export { type LargeFileEncoding, type LargeFileResult, type LargeImageSpec, type StreamLargeImageOptions, streamLargeImage, writeLargeImageFile, } from './syntheticFixtures/streamWrite.js';
10
10
  export { resolveTagTemplates, type TagContext, TEMPLATE_VOCAB, } from './syntheticFixtures/tagTemplate.js';
11
+ export { hashUid, seedToSalt } from './syntheticFixtures/uid.js';
@@ -51,8 +51,16 @@ export type EntrySpec = FileSpec & {
51
51
  export type SeriesEntrySpec = (ImageSpec | LargeFileSpec) & {
52
52
  count?: number;
53
53
  };
54
+ export type SeriesInstances = {
55
+ count: number;
56
+ targetSizeKb?: number[];
57
+ targetBytes?: number[];
58
+ modality?: Modality | Modality[];
59
+ tags?: Record<string, unknown[]>;
60
+ };
54
61
  export type SeriesSpec = {
55
- entries: SeriesEntrySpec[];
62
+ entries?: SeriesEntrySpec[];
63
+ instances?: SeriesInstances;
56
64
  tags?: DicomTagOverrides;
57
65
  };
58
66
  export type StudySpec = {
@@ -66,6 +74,7 @@ export type DatasetSpec = {
66
74
  entries?: EntrySpec[];
67
75
  studies?: StudySpec[];
68
76
  seed?: number;
77
+ uidSalt?: string;
69
78
  layout?: DatasetLayout;
70
79
  pathQuirks?: PathQuirk[];
71
80
  };
@@ -88,6 +97,7 @@ export type ParametricEdgeCase = EntrySpec & {
88
97
  };
89
98
  export type ParametricSpec = {
90
99
  seed?: number;
100
+ uidSalt?: string;
91
101
  studies: ParametricStudies;
92
102
  edgeCases?: ParametricEdgeCase[];
93
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.10.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",