dicom-synth 1.9.0 → 1.11.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.
@@ -1,3 +1,19 @@
1
+ // src/syntheticFixtures/tagTemplate.ts
2
+ var TEMPLATE_VOCAB = [
3
+ "index",
4
+ "studyIndex",
5
+ "seriesIndex",
6
+ "instanceNumber"
7
+ ];
8
+ var TEMPLATE_PART_RE = /\{\{|\}\}|\{([a-zA-Z]+)\}/g;
9
+ function findTemplateTokens(value) {
10
+ const names = [];
11
+ for (const m of value.matchAll(TEMPLATE_PART_RE)) {
12
+ if (m[1] !== void 0) names.push(m[1]);
13
+ }
14
+ return names;
15
+ }
16
+
1
17
  // src/schema/limits.ts
2
18
  var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
3
19
  var MAX_STREAM_BYTES = 50 * 1024 * 1024 * 1024;
@@ -196,12 +212,102 @@ function validateEntry(entry, path) {
196
212
  }
197
213
  return e;
198
214
  }
215
+ var MODALITY_TAG = "00080060";
216
+ function validateTagValue(key, value, path) {
217
+ const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
218
+ if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
219
+ throw new Error(
220
+ `${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`
221
+ );
222
+ }
223
+ if (typeof value === "string") {
224
+ for (const name of findTemplateTokens(value)) {
225
+ if (!TEMPLATE_VOCAB.includes(name)) {
226
+ throw new Error(
227
+ `${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
228
+ );
229
+ }
230
+ }
231
+ }
232
+ }
199
233
  function validateTags(tags, path) {
200
234
  if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
201
235
  throw new Error(`${path}: must be an object`);
202
236
  }
203
- for (const key of Object.keys(tags)) {
237
+ for (const [key, value] of Object.entries(tags)) {
204
238
  validateTagKey(key, path);
239
+ validateTagValue(key, value, path);
240
+ }
241
+ }
242
+ function validateArrayOfLength(value, count, path) {
243
+ if (!Array.isArray(value)) {
244
+ throw new Error(`${path}: must be an array`);
245
+ }
246
+ if (value.length !== count) {
247
+ throw new Error(
248
+ `${path}: length (${value.length}) must equal count (${count})`
249
+ );
250
+ }
251
+ return value;
252
+ }
253
+ function validateInstances(instances, path) {
254
+ if (typeof instances !== "object" || instances === null || Array.isArray(instances)) {
255
+ throw new Error(`${path}: must be an object`);
256
+ }
257
+ const inst = instances;
258
+ validatePositiveInt(inst.count, `${path}.count`);
259
+ const count = inst.count;
260
+ const hasKb = "targetSizeKb" in inst;
261
+ const hasBytes = "targetBytes" in inst;
262
+ if (hasKb === hasBytes) {
263
+ throw new Error(
264
+ `${path}: exactly one of "targetSizeKb" or "targetBytes" is required`
265
+ );
266
+ }
267
+ if (hasKb) {
268
+ const sizes = validateArrayOfLength(
269
+ inst.targetSizeKb,
270
+ count,
271
+ `${path}.targetSizeKb`
272
+ );
273
+ sizes.forEach((kb, i) => {
274
+ validatePositiveInt(kb, `${path}.targetSizeKb[${i}]`);
275
+ validateSizeKbLimit(kb, `${path}.targetSizeKb[${i}]`);
276
+ });
277
+ } else {
278
+ const sizes = validateArrayOfLength(
279
+ inst.targetBytes,
280
+ count,
281
+ `${path}.targetBytes`
282
+ );
283
+ sizes.forEach((b, i) => {
284
+ validateLargeTargetBytes(b, `${path}.targetBytes[${i}]`);
285
+ });
286
+ }
287
+ if ("modality" in inst) {
288
+ if (Array.isArray(inst.modality)) {
289
+ validateArrayOfLength(inst.modality, count, `${path}.modality`).forEach(
290
+ (m, i) => {
291
+ validateEnum(m, VALID_MODALITIES, `${path}.modality[${i}]`);
292
+ }
293
+ );
294
+ } else {
295
+ validateEnum(inst.modality, VALID_MODALITIES, `${path}.modality`);
296
+ }
297
+ }
298
+ if ("tags" in inst) {
299
+ const tags = inst.tags;
300
+ if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
301
+ throw new Error(`${path}.tags: must be an object`);
302
+ }
303
+ for (const [key, arr] of Object.entries(tags)) {
304
+ validateTagKey(key, `${path}.tags`);
305
+ validateArrayOfLength(arr, count, `${path}.tags.${key}`).forEach(
306
+ (value, i) => {
307
+ validateTagValue(key, value, `${path}.tags[${i}]`);
308
+ }
309
+ );
310
+ }
205
311
  }
206
312
  }
207
313
  function validateSeries(series, path) {
@@ -209,16 +315,27 @@ function validateSeries(series, path) {
209
315
  throw new Error(`${path}: must be an object`);
210
316
  }
211
317
  const s = series;
212
- if (!Array.isArray(s.entries) || s.entries.length === 0) {
213
- throw new Error(`${path}.entries: must be a non-empty array`);
318
+ const hasEntries = "entries" in s;
319
+ const hasInstances = "instances" in s;
320
+ if (hasEntries === hasInstances) {
321
+ throw new Error(
322
+ `${path}: must have exactly one of "entries" or "instances"`
323
+ );
214
324
  }
215
- for (let i = 0; i < s.entries.length; i++) {
216
- const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
217
- if (!TYPE_CAPABILITIES[e.type].image) {
218
- throw new Error(
219
- `${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
220
- );
325
+ if (hasEntries) {
326
+ if (!Array.isArray(s.entries) || s.entries.length === 0) {
327
+ throw new Error(`${path}.entries: must be a non-empty array`);
328
+ }
329
+ for (let i = 0; i < s.entries.length; i++) {
330
+ const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
331
+ if (!TYPE_CAPABILITIES[e.type].image) {
332
+ throw new Error(
333
+ `${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
334
+ );
335
+ }
221
336
  }
337
+ } else {
338
+ validateInstances(s.instances, `${path}.instances`);
222
339
  }
223
340
  if ("tags" in s) validateTags(s.tags, `${path}.tags`);
224
341
  return s;
@@ -0,0 +1,49 @@
1
+ // src/syntheticFixtures/tagTemplate.ts
2
+ var TEMPLATE_VOCAB = [
3
+ "index",
4
+ "studyIndex",
5
+ "seriesIndex",
6
+ "instanceNumber"
7
+ ];
8
+ var TEMPLATE_PART_RE = /\{\{|\}\}|\{([a-zA-Z]+)\}/g;
9
+ function escapeTagTemplate(value) {
10
+ return value.replace(/\{/g, "{{").replace(/\}/g, "}}");
11
+ }
12
+ function findTemplateTokens(value) {
13
+ const names = [];
14
+ for (const m of value.matchAll(TEMPLATE_PART_RE)) {
15
+ if (m[1] !== void 0) names.push(m[1]);
16
+ }
17
+ return names;
18
+ }
19
+ function resolveTagTemplates(tags, context) {
20
+ if (!tags) return tags;
21
+ const resolved = {};
22
+ for (const [key, value] of Object.entries(tags)) {
23
+ resolved[key] = typeof value === "string" ? resolveString(value, context) : value;
24
+ }
25
+ return resolved;
26
+ }
27
+ function resolveString(value, context) {
28
+ return value.replace(TEMPLATE_PART_RE, (match, name) => {
29
+ if (name === void 0) return match === "{{" ? "{" : "}";
30
+ if (!TEMPLATE_VOCAB.includes(name)) {
31
+ throw new Error(
32
+ `tag template: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
33
+ );
34
+ }
35
+ const resolved = context[name];
36
+ if (resolved === void 0) {
37
+ throw new Error(
38
+ `tag template: placeholder "{${name}}" is not available here \u2014 it only applies inside grouped studies`
39
+ );
40
+ }
41
+ return String(resolved);
42
+ });
43
+ }
44
+ export {
45
+ TEMPLATE_VOCAB,
46
+ escapeTagTemplate,
47
+ findTemplateTokens,
48
+ resolveTagTemplates
49
+ };
@@ -1,4 +1,5 @@
1
1
  import type { DatasetSpec, FileSpec } from '../schema/types.js';
2
+ import { type TagContext } from '../syntheticFixtures/tagTemplate.js';
2
3
  import type { UidSet } from '../syntheticFixtures/uid.js';
3
4
  export type GeneratedFile = {
4
5
  filename: string;
@@ -17,6 +18,7 @@ export declare function generateFile(spec: FileSpec, options?: {
17
18
  seed?: number;
18
19
  padWidth?: number;
19
20
  uid?: Partial<UidSet>;
21
+ context?: Partial<TagContext>;
20
22
  }): Promise<GeneratedFile>;
21
23
  export declare function withResolvedSeed(spec: DatasetSpec): DatasetSpec;
22
24
  export declare function generateCollectionFromSpec(spec: DatasetSpec): AsyncGenerator<GeneratedFile>;
@@ -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;
@@ -4,6 +4,7 @@ export { defaultPublicCasesPath, loadCaseById, loadCasesFromJson, loadDefaultCas
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
+ export { resolveTagTemplates, type TagContext, TEMPLATE_VOCAB, } from './syntheticFixtures/tagTemplate.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 = {
@@ -0,0 +1,11 @@
1
+ import type { DicomTagOverrides } from '../schema/types.js';
2
+ export type TagContext = {
3
+ index: number;
4
+ studyIndex?: number;
5
+ seriesIndex?: number;
6
+ instanceNumber?: number;
7
+ };
8
+ export declare const TEMPLATE_VOCAB: readonly ["index", "studyIndex", "seriesIndex", "instanceNumber"];
9
+ export declare function escapeTagTemplate(value: string): string;
10
+ export declare function findTemplateTokens(value: string): string[];
11
+ export declare function resolveTagTemplates(tags: DicomTagOverrides | undefined, context: TagContext): DicomTagOverrides | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dicom-synth",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "description": "Toolkit for synthetic DICOM fixtures and public fixture fetch/cache.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",