dicom-synth 1.0.1 → 1.2.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,20 +1,30 @@
1
- // src/negativeCases/index.ts
2
- import { mkdirSync, writeFileSync } from "node:fs";
3
- import { resolve } from "node:path";
4
- function writeFakeDicomSignatureFile(filePath) {
5
- const buf = Buffer.alloc(200, 0);
6
- buf.write("XXXX", 128, 4, "ascii");
7
- mkdirSync(resolve(filePath, ".."), { recursive: true });
8
- writeFileSync(filePath, buf);
9
- }
10
- function writeNonDicomFile(filePath, content = "not dicom") {
11
- mkdirSync(resolve(filePath, ".."), { recursive: true });
12
- writeFileSync(filePath, content);
1
+ // src/collection/writer.ts
2
+ import { mkdirSync as mkdirSync2 } from "node:fs";
3
+ import { writeFile } from "node:fs/promises";
4
+ import { dirname, resolve as resolve2 } from "node:path";
5
+
6
+ // src/loadDcmjs.ts
7
+ import dcmjsDefaultImport, * as dcmjsNamespace from "dcmjs";
8
+ function loadDcmjs() {
9
+ if (dcmjsNamespace.data?.DicomDict) {
10
+ return dcmjsNamespace;
11
+ }
12
+ const d = dcmjsDefaultImport;
13
+ if (d?.data?.DicomDict) {
14
+ return d;
15
+ }
16
+ if (d?.default?.data?.DicomDict) {
17
+ return d.default;
18
+ }
19
+ throw new Error(
20
+ "dcmjs failed to load (missing data.DicomDict). Install dcmjs >= 0.29."
21
+ );
13
22
  }
23
+ var dcmjs = loadDcmjs();
14
24
 
15
25
  // src/nonStandardDicom/dicomdir.ts
16
- import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
17
- import { resolve as resolve2 } from "node:path";
26
+ import { mkdirSync, writeFileSync } from "node:fs";
27
+ import { resolve } from "node:path";
18
28
  var DICOMDIR_SOP_CLASS_UID = "1.2.840.10008.1.3.10";
19
29
  function buildDicomdirBuffer() {
20
30
  const uid = DICOMDIR_SOP_CLASS_UID;
@@ -33,17 +43,631 @@ function buildDicomdirBuffer() {
33
43
  off += uid.length;
34
44
  return buf.subarray(0, off);
35
45
  }
36
- function writeDicomdirFile(filePath) {
37
- mkdirSync2(resolve2(filePath, ".."), { recursive: true });
38
- writeFileSync2(filePath, buildDicomdirBuffer());
46
+
47
+ // src/schema/validate.ts
48
+ var TYPE_CAPABILITIES = {
49
+ "valid-ct": { tags: true, violations: true, transferSyntax: true },
50
+ "invalid-uid-ct": { tags: true, violations: true, transferSyntax: true },
51
+ "vendor-warnings-ct": { tags: true, violations: true, transferSyntax: true },
52
+ "large-ct": { tags: true, violations: true, transferSyntax: true },
53
+ "fake-signature": { tags: false, violations: false, transferSyntax: false },
54
+ "non-dicom": { tags: false, violations: false, transferSyntax: false },
55
+ dicomdir: { tags: false, violations: false, transferSyntax: false }
56
+ };
57
+ var VALID_LAYOUTS = {
58
+ flat: true,
59
+ hierarchical: true
60
+ };
61
+ var VALID_TRANSFER_SYNTAXES = {
62
+ "explicit-vr-little-endian": true,
63
+ "implicit-vr-little-endian": true
64
+ };
65
+ var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
66
+ var VALID_VIOLATIONS = {
67
+ "uid-too-long": true,
68
+ "non-conformant-uid": true,
69
+ "missing-meta-header": true,
70
+ "malformed-sq-delimiter": true,
71
+ "vr-max-length-exceeded": true,
72
+ "missing-type1-tag": true
73
+ };
74
+ var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
75
+ var KEYWORD_RE = /^[A-Z][A-Za-z0-9]*$/;
76
+ function validatePositiveInt(value, path) {
77
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
78
+ throw new Error(
79
+ `${path}: must be a positive integer; got ${JSON.stringify(value)}`
80
+ );
81
+ }
82
+ }
83
+ function validateTagKey(key, path) {
84
+ if (!HEX_TAG_RE.test(key) && !KEYWORD_RE.test(key)) {
85
+ throw new Error(
86
+ `${path}: invalid tag key "${key}" \u2014 must be a DICOM keyword name (e.g. "Modality") or an 8-hex-char tag (e.g. "00080060")`
87
+ );
88
+ }
89
+ }
90
+ function validateEntry(entry, path) {
91
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
92
+ throw new Error(`${path}: must be an object`);
93
+ }
94
+ const e = entry;
95
+ if (typeof e.type !== "string" || !Object.hasOwn(TYPE_CAPABILITIES, e.type)) {
96
+ throw new Error(
97
+ `${path}.type: must be one of ${Object.keys(TYPE_CAPABILITIES).join(", ")}; got ${JSON.stringify(e.type)}`
98
+ );
99
+ }
100
+ const type = e.type;
101
+ const caps = TYPE_CAPABILITIES[type];
102
+ if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
103
+ if ("transferSyntax" in e) {
104
+ if (!caps.transferSyntax) {
105
+ throw new Error(
106
+ `${path}.transferSyntax: not supported for type "${type}"`
107
+ );
108
+ }
109
+ if (!Object.hasOwn(VALID_TRANSFER_SYNTAXES, e.transferSyntax)) {
110
+ throw new Error(
111
+ `${path}.transferSyntax: must be one of ${Object.keys(VALID_TRANSFER_SYNTAXES).join(", ")}`
112
+ );
113
+ }
114
+ }
115
+ if (!caps.tags && "tags" in e)
116
+ throw new Error(`${path}.tags: not supported for type "${type}"`);
117
+ if (!caps.violations && "violations" in e)
118
+ throw new Error(`${path}.violations: not supported for type "${type}"`);
119
+ if ("tags" in e) validateTags(e.tags, `${path}.tags`);
120
+ if ("violations" in e) {
121
+ if (!Array.isArray(e.violations)) {
122
+ throw new Error(`${path}.violations: must be an array`);
123
+ }
124
+ for (const [i, v] of e.violations.entries()) {
125
+ if (typeof v !== "string" || !Object.hasOwn(VALID_VIOLATIONS, v)) {
126
+ throw new Error(
127
+ `${path}.violations[${i}]: must be one of ${Object.keys(VALID_VIOLATIONS).join(", ")}; got ${JSON.stringify(v)}`
128
+ );
129
+ }
130
+ }
131
+ }
132
+ if (type === "large-ct") {
133
+ validatePositiveInt(e.rows, `${path}.rows`);
134
+ validatePositiveInt(e.columns, `${path}.columns`);
135
+ if ("frames" in e) validatePositiveInt(e.frames, `${path}.frames`);
136
+ const rows = e.rows;
137
+ const columns = e.columns;
138
+ const frames = typeof e.frames === "number" ? e.frames : 1;
139
+ if (rows * columns * frames * 2 > MAX_PIXEL_BYTES) {
140
+ throw new Error(
141
+ `${path}: pixel data (${rows}\xD7${columns}\xD7${frames}\xD72 bytes) exceeds the 512 MB limit`
142
+ );
143
+ }
144
+ }
145
+ return e;
146
+ }
147
+ function validateTags(tags, path) {
148
+ if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
149
+ throw new Error(`${path}: must be an object`);
150
+ }
151
+ for (const key of Object.keys(tags)) {
152
+ validateTagKey(key, path);
153
+ }
154
+ }
155
+ function validateSeries(series, path) {
156
+ if (typeof series !== "object" || series === null || Array.isArray(series)) {
157
+ throw new Error(`${path}: must be an object`);
158
+ }
159
+ const s = series;
160
+ if (!Array.isArray(s.entries) || s.entries.length === 0) {
161
+ throw new Error(`${path}.entries: must be a non-empty array`);
162
+ }
163
+ for (let i = 0; i < s.entries.length; i++) {
164
+ const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
165
+ if (!TYPE_CAPABILITIES[e.type].tags) {
166
+ throw new Error(
167
+ `${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only types that support tags can be grouped`
168
+ );
169
+ }
170
+ }
171
+ if ("tags" in s) validateTags(s.tags, `${path}.tags`);
172
+ return s;
173
+ }
174
+ function validateStudy(study, path) {
175
+ if (typeof study !== "object" || study === null || Array.isArray(study)) {
176
+ throw new Error(`${path}: must be an object`);
177
+ }
178
+ const st = study;
179
+ if (!Array.isArray(st.series) || st.series.length === 0) {
180
+ throw new Error(`${path}.series: must be a non-empty array`);
181
+ }
182
+ for (let i = 0; i < st.series.length; i++) {
183
+ validateSeries(st.series[i], `${path}.series[${i}]`);
184
+ }
185
+ if ("count" in st) validatePositiveInt(st.count, `${path}.count`);
186
+ if ("tags" in st) validateTags(st.tags, `${path}.tags`);
187
+ return st;
188
+ }
189
+ function validateDatasetSpec(raw) {
190
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
191
+ throw new Error("DatasetSpec: must be a JSON object");
192
+ }
193
+ const r = raw;
194
+ if ("entries" in r && !Array.isArray(r.entries)) {
195
+ throw new Error("DatasetSpec.entries: must be an array");
196
+ }
197
+ if ("studies" in r && !Array.isArray(r.studies)) {
198
+ throw new Error("DatasetSpec.studies: must be an array");
199
+ }
200
+ const rawEntries = r.entries ?? [];
201
+ const rawStudies = r.studies ?? [];
202
+ if (rawEntries.length === 0 && rawStudies.length === 0) {
203
+ throw new Error(
204
+ 'DatasetSpec: must contain at least one of "entries" or "studies" (non-empty)'
205
+ );
206
+ }
207
+ const entries = rawEntries.map(
208
+ (entry, i) => validateEntry(entry, `entries[${i}]`)
209
+ );
210
+ const studies = rawStudies.map(
211
+ (study, i) => validateStudy(study, `studies[${i}]`)
212
+ );
213
+ if ("seed" in r) {
214
+ if (typeof r.seed !== "number" || !Number.isFinite(r.seed)) {
215
+ throw new Error(
216
+ `DatasetSpec.seed: must be a finite number; got ${JSON.stringify(r.seed)}`
217
+ );
218
+ }
219
+ }
220
+ if ("layout" in r) {
221
+ if (typeof r.layout !== "string" || !Object.hasOwn(VALID_LAYOUTS, r.layout)) {
222
+ throw new Error(
223
+ `DatasetSpec.layout: must be one of ${Object.keys(VALID_LAYOUTS).join(", ")}; got ${JSON.stringify(r.layout)}`
224
+ );
225
+ }
226
+ }
227
+ return {
228
+ ...entries.length > 0 ? { entries } : {},
229
+ ...studies.length > 0 ? { studies } : {},
230
+ ...r.seed !== void 0 ? { seed: r.seed } : {},
231
+ ...r.layout !== void 0 ? { layout: r.layout } : {}
232
+ };
233
+ }
234
+
235
+ // src/syntheticFixtures/generator.ts
236
+ var CT_SOP_CLASS = "1.2.840.10008.5.1.4.1.1.2";
237
+ var TRANSFER_SYNTAX_UID = {
238
+ "explicit-vr-little-endian": "1.2.840.10008.1.2.1",
239
+ "implicit-vr-little-endian": "1.2.840.10008.1.2"
240
+ };
241
+ function buildTagToKeyword() {
242
+ const map = /* @__PURE__ */ new Map();
243
+ const nm = dcmjs.data?.DicomMetaDictionary?.nameMap;
244
+ if (!nm) return map;
245
+ for (const [keyword, info] of Object.entries(nm)) {
246
+ if (info?.tag) {
247
+ const hex = info.tag.replace(/[(,)]/g, "").toUpperCase();
248
+ if (hex.length === 8) map.set(hex, keyword);
249
+ }
250
+ }
251
+ return map;
252
+ }
253
+ var tagToKeyword = buildTagToKeyword();
254
+ function applyTagOverrides(dataset, tags) {
255
+ if (!tags) return;
256
+ for (const [key, value] of Object.entries(tags)) {
257
+ if (HEX_TAG_RE.test(key)) {
258
+ const keyword = tagToKeyword.get(key.toUpperCase());
259
+ if (keyword) {
260
+ dataset[keyword] = value;
261
+ } else {
262
+ console.warn(`dicom-synth: unknown hex tag "${key}" \u2014 skipping`);
263
+ }
264
+ } else {
265
+ dataset[key] = value;
266
+ }
267
+ }
268
+ }
269
+ function buildMeta(uid, transferSyntax = "explicit-vr-little-endian") {
270
+ return {
271
+ FileMetaInformationVersion: new Uint8Array([0, 1]).buffer,
272
+ MediaStorageSOPClassUID: CT_SOP_CLASS,
273
+ MediaStorageSOPInstanceUID: uid.sop,
274
+ TransferSyntaxUID: TRANSFER_SYNTAX_UID[transferSyntax],
275
+ ImplementationClassUID: "1.2.3.4",
276
+ ImplementationVersionName: "SYNTH"
277
+ };
278
+ }
279
+ function buildBaseCtDataset(uid) {
280
+ return {
281
+ PatientName: "SYNTH^SUBJECT",
282
+ PatientID: "SYNTH_PID",
283
+ Modality: "CT",
284
+ SOPClassUID: CT_SOP_CLASS,
285
+ SOPInstanceUID: uid.sop,
286
+ SeriesInstanceUID: uid.series,
287
+ StudyInstanceUID: uid.study,
288
+ StudyDescription: "Synthetic study",
289
+ SeriesDescription: "Synthetic series",
290
+ Rows: 1,
291
+ Columns: 1,
292
+ BitsAllocated: 16,
293
+ BitsStored: 16,
294
+ HighBit: 15,
295
+ SamplesPerPixel: 1,
296
+ PhotometricInterpretation: "MONOCHROME2",
297
+ PixelRepresentation: 0,
298
+ PixelData: new Uint8Array([0, 0]).buffer,
299
+ // Declares PixelData's VR explicitly — without it dcmjs logs
300
+ // "No value representation given for PixelData" and falls back to OW anyway.
301
+ _vrMap: { PixelData: "OW" }
302
+ };
303
+ }
304
+ function serializeDict(meta, dataset) {
305
+ const dicomDict = new dcmjs.data.DicomDict(
306
+ dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
307
+ meta
308
+ )
309
+ );
310
+ dicomDict.dict = dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
311
+ dataset
312
+ );
313
+ return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
314
+ }
315
+ function buildValidCtBuffer(uid, tags, transferSyntax) {
316
+ const dataset = buildBaseCtDataset(uid);
317
+ applyTagOverrides(dataset, tags);
318
+ return serializeDict(buildMeta(uid, transferSyntax), dataset);
319
+ }
320
+ function buildInvalidUidCtBuffer(uid, tags, transferSyntax) {
321
+ const invalidUid = {
322
+ study: `2.25.invalid.study.${uid.study.slice(-6)}`,
323
+ series: `2.25.invalid.series.${uid.series.slice(-6)}`,
324
+ sop: `2.25.invalid.sop.${uid.sop.slice(-6)}`
325
+ };
326
+ const dataset = buildBaseCtDataset(invalidUid);
327
+ applyTagOverrides(dataset, tags);
328
+ return serializeDict(buildMeta(invalidUid, transferSyntax), dataset);
329
+ }
330
+ function buildVendorCtBuffer(uid, tags, transferSyntax) {
331
+ const dataset = buildBaseCtDataset(uid);
332
+ dataset.Laterality = "";
333
+ dataset.PatientWeight = "0";
334
+ applyTagOverrides(dataset, tags);
335
+ return serializeDict(buildMeta(uid, transferSyntax), dataset);
336
+ }
337
+ function buildLargeCtBuffer(uid, rows, columns, frames, tags, transferSyntax) {
338
+ const dataset = buildBaseCtDataset(uid);
339
+ dataset.Rows = rows;
340
+ dataset.Columns = columns;
341
+ dataset.NumberOfFrames = frames;
342
+ dataset.PixelData = new Uint8Array(rows * columns * frames * 2).buffer;
343
+ applyTagOverrides(dataset, tags);
344
+ return serializeDict(buildMeta(uid, transferSyntax), dataset);
345
+ }
346
+ function buildFakeSignatureBuffer() {
347
+ const buf = Buffer.alloc(200, 0);
348
+ buf.write("XXXX", 128, 4, "ascii");
349
+ return buf;
350
+ }
351
+ function buildNonDicomBuffer(content = "not dicom") {
352
+ return Buffer.from(content, "utf8");
353
+ }
354
+ function buildBufferForSpec(spec, uid) {
355
+ switch (spec.type) {
356
+ case "valid-ct":
357
+ return buildValidCtBuffer(uid, spec.tags, spec.transferSyntax);
358
+ case "invalid-uid-ct":
359
+ return buildInvalidUidCtBuffer(uid, spec.tags, spec.transferSyntax);
360
+ case "vendor-warnings-ct":
361
+ return buildVendorCtBuffer(uid, spec.tags, spec.transferSyntax);
362
+ case "large-ct":
363
+ return buildLargeCtBuffer(
364
+ uid,
365
+ spec.rows,
366
+ spec.columns,
367
+ spec.frames ?? 1,
368
+ spec.tags,
369
+ spec.transferSyntax
370
+ );
371
+ case "fake-signature":
372
+ return buildFakeSignatureBuffer();
373
+ case "non-dicom":
374
+ return buildNonDicomBuffer(spec.content);
375
+ case "dicomdir":
376
+ return buildDicomdirBuffer();
377
+ }
378
+ }
379
+
380
+ // src/syntheticFixtures/uid.ts
381
+ import { randomBytes } from "node:crypto";
382
+ var ROLE_CODE = { study: 1, series: 2, sop: 3 };
383
+ function lcg(seed) {
384
+ let s = seed >>> 0;
385
+ return () => {
386
+ s = Math.imul(1664525, s) + 1013904223 >>> 0;
387
+ return s;
388
+ };
389
+ }
390
+ function formatUid(a, b, role) {
391
+ return `2.25.${a}.${b}.${ROLE_CODE[role]}`;
392
+ }
393
+ function randomUid(role) {
394
+ const buf = randomBytes(8);
395
+ return formatUid(buf.readUInt32BE(0), buf.readUInt32BE(4), role);
396
+ }
397
+ function seededUid(next, role) {
398
+ return formatUid(next(), next(), role);
399
+ }
400
+ var GROUP_STREAM_OFFSET = 2654435769;
401
+ function seededStream(seed, offset, a, b) {
402
+ return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
403
+ }
404
+ function makeGroupUidGenerator(seed) {
405
+ if (seed === void 0) {
406
+ return {
407
+ study: () => randomUid("study"),
408
+ series: () => randomUid("series")
409
+ };
410
+ }
411
+ return {
412
+ study: (studyIndex) => seededUid(
413
+ seededStream(seed, GROUP_STREAM_OFFSET, studyIndex + 1, 0),
414
+ "study"
415
+ ),
416
+ series: (studyIndex, seriesIndex) => seededUid(
417
+ seededStream(
418
+ seed,
419
+ GROUP_STREAM_OFFSET,
420
+ studyIndex + 1,
421
+ seriesIndex + 1
422
+ ),
423
+ "series"
424
+ )
425
+ };
426
+ }
427
+ function makeUidGenerator(seed) {
428
+ if (seed === void 0) {
429
+ return () => ({
430
+ study: randomUid("study"),
431
+ series: randomUid("series"),
432
+ sop: randomUid("sop")
433
+ });
434
+ }
435
+ return (fileIndex) => {
436
+ const next = seededStream(seed, 0, fileIndex, 0);
437
+ return {
438
+ study: seededUid(next, "study"),
439
+ series: seededUid(next, "series"),
440
+ sop: seededUid(next, "sop")
441
+ };
442
+ };
443
+ }
444
+
445
+ // src/syntheticFixtures/violations.ts
446
+ var dcmjsAny = dcmjs;
447
+ function parseBuffer(buffer) {
448
+ const ab = buffer.buffer.slice(
449
+ buffer.byteOffset,
450
+ buffer.byteOffset + buffer.byteLength
451
+ );
452
+ return dcmjsAny.data.DicomMessage.readFile(ab);
453
+ }
454
+ function reserialize(parsed) {
455
+ return Buffer.from(parsed.write({ allowInvalidVRLength: true }));
456
+ }
457
+ var OVERLONG_UID = `2.25.${"0".repeat(60)}`;
458
+ var VIOLATION_LEVEL = {
459
+ "uid-too-long": "tag",
460
+ "non-conformant-uid": "tag",
461
+ "vr-max-length-exceeded": "tag",
462
+ "missing-type1-tag": "tag",
463
+ "missing-meta-header": "byte",
464
+ "malformed-sq-delimiter": "byte"
465
+ };
466
+ function applyTagLevel(buffer, violations) {
467
+ if (violations.length === 0) return buffer;
468
+ let parsed;
469
+ try {
470
+ parsed = parseBuffer(buffer);
471
+ } catch {
472
+ console.warn(
473
+ "dicom-synth: could not parse buffer for tag-level violations \u2014 skipping"
474
+ );
475
+ return buffer;
476
+ }
477
+ const natural = dcmjsAny.data.DicomMetaDictionary.naturalizeDataset(
478
+ parsed.dict
479
+ );
480
+ for (const v of violations) {
481
+ switch (v) {
482
+ case "uid-too-long":
483
+ break;
484
+ case "non-conformant-uid":
485
+ natural.SOPInstanceUID = "2.25.01.234.567";
486
+ break;
487
+ case "vr-max-length-exceeded":
488
+ natural.StudyDescription = "X".repeat(65);
489
+ break;
490
+ case "missing-type1-tag":
491
+ delete natural.SOPClassUID;
492
+ break;
493
+ default:
494
+ throw new Error(
495
+ `dicom-synth: unhandled tag-level violation "${v}"`
496
+ );
497
+ }
498
+ }
499
+ parsed.dict = dcmjsAny.data.DicomMetaDictionary.denaturalizeDataset(natural);
500
+ if (violations.includes("uid-too-long")) {
501
+ ;
502
+ parsed.dict["00080018"] = {
503
+ vr: "UI",
504
+ Value: [OVERLONG_UID]
505
+ };
506
+ }
507
+ return reserialize(parsed);
508
+ }
509
+ function applyByteLevel(buffer, violations) {
510
+ let result = buffer;
511
+ for (const v of violations) {
512
+ switch (v) {
513
+ case "missing-meta-header":
514
+ result = result.subarray(132);
515
+ break;
516
+ case "malformed-sq-delimiter": {
517
+ const delimiter = Buffer.alloc(8);
518
+ delimiter.writeUInt16LE(65534, 0);
519
+ delimiter.writeUInt16LE(57565, 2);
520
+ delimiter.writeUInt32LE(4, 4);
521
+ result = Buffer.concat([result, delimiter]);
522
+ break;
523
+ }
524
+ default:
525
+ throw new Error(
526
+ `dicom-synth: unhandled byte-level violation "${v}"`
527
+ );
528
+ }
529
+ }
530
+ return result;
531
+ }
532
+ function applyViolations(buffer, violations) {
533
+ if (violations.length === 0) return buffer;
534
+ const tagViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "tag");
535
+ const byteViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "byte");
536
+ return applyByteLevel(applyTagLevel(buffer, tagViolations), byteViolations);
537
+ }
538
+
539
+ // src/collection/writer.ts
540
+ var NO_EXTENSION_TYPES = /* @__PURE__ */ new Set([
541
+ "fake-signature",
542
+ "non-dicom"
543
+ ]);
544
+ function filename(type, index, padWidth) {
545
+ const padded = String(index).padStart(padWidth, "0");
546
+ const base = `${type}-${padded}`;
547
+ return NO_EXTENSION_TYPES.has(type) ? base : `${base}.dcm`;
548
+ }
549
+ function entryCount(entries) {
550
+ return entries.reduce((sum, e) => sum + (e.count ?? 1), 0);
551
+ }
552
+ function studyFileCount(studies) {
553
+ return studies.reduce(
554
+ (sum, study) => sum + (study.count ?? 1) * study.series.reduce((s, series) => s + entryCount(series.entries), 0),
555
+ 0
556
+ );
557
+ }
558
+ async function generateFile(spec, options) {
559
+ const index = options?.index ?? 0;
560
+ const padWidth = options?.padWidth ?? 3;
561
+ const uidGen = makeUidGenerator(options?.seed);
562
+ const uid = { ...uidGen(index), ...options?.uid };
563
+ let buffer = buildBufferForSpec(spec, uid);
564
+ const violations = "violations" in spec ? spec.violations : void 0;
565
+ if (violations?.length) {
566
+ buffer = applyViolations(buffer, violations);
567
+ }
568
+ const name = filename(spec.type, index, padWidth);
569
+ return {
570
+ filename: name,
571
+ relativePath: name,
572
+ buffer,
573
+ type: spec.type,
574
+ index
575
+ };
576
+ }
577
+ function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
578
+ const pad3 = (n) => String(n).padStart(3, "0");
579
+ const name = `${String(instanceNumber).padStart(5, "0")}.dcm`;
580
+ return {
581
+ filename: name,
582
+ relativePath: `study-${pad3(studyOrdinal + 1)}/series-${pad3(seriesIndex + 1)}/${name}`
583
+ };
584
+ }
585
+ async function* generateCollectionFromSpec(spec) {
586
+ const flatEntries = spec.entries ?? [];
587
+ const studies = spec.studies ?? [];
588
+ const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
589
+ const padWidth = String(totalFiles).length;
590
+ const hierarchical = spec.layout === "hierarchical";
591
+ let globalIndex = 0;
592
+ for (const entry of flatEntries) {
593
+ const { count = 1, ...fileSpec } = entry;
594
+ for (let i = 0; i < count; i++) {
595
+ yield generateFile(fileSpec, {
596
+ index: globalIndex,
597
+ seed: spec.seed,
598
+ padWidth
599
+ });
600
+ globalIndex++;
601
+ }
602
+ }
603
+ const groupUids = makeGroupUidGenerator(spec.seed);
604
+ let studyOrdinal = 0;
605
+ for (const study of studies) {
606
+ const copies = study.count ?? 1;
607
+ for (let copy = 0; copy < copies; copy++) {
608
+ const studyUid = groupUids.study(studyOrdinal);
609
+ for (const [seriesIndex, series] of study.series.entries()) {
610
+ const seriesUid = groupUids.series(studyOrdinal, seriesIndex);
611
+ let instanceNumber = 0;
612
+ for (const entry of series.entries) {
613
+ const { count = 1, ...fileSpec } = entry;
614
+ for (let i = 0; i < count; i++) {
615
+ instanceNumber++;
616
+ const tags = {
617
+ InstanceNumber: instanceNumber,
618
+ ...study.tags,
619
+ ...series.tags,
620
+ ...fileSpec.tags
621
+ };
622
+ const file = await generateFile(
623
+ { ...fileSpec, tags },
624
+ {
625
+ index: globalIndex,
626
+ seed: spec.seed,
627
+ padWidth,
628
+ uid: { study: studyUid, series: seriesUid }
629
+ }
630
+ );
631
+ yield hierarchical ? {
632
+ ...file,
633
+ ...hierarchicalName(
634
+ studyOrdinal,
635
+ seriesIndex,
636
+ instanceNumber
637
+ )
638
+ } : file;
639
+ globalIndex++;
640
+ }
641
+ }
642
+ }
643
+ studyOrdinal++;
644
+ }
645
+ }
646
+ }
647
+ async function writeCollectionFromSpec(spec, outDir) {
648
+ const root = resolve2(outDir);
649
+ mkdirSync2(root, { recursive: true });
650
+ const manifest = [];
651
+ const createdDirs = /* @__PURE__ */ new Set([root]);
652
+ for await (const file of generateCollectionFromSpec(spec)) {
653
+ const filePath = resolve2(root, file.relativePath);
654
+ const dir = dirname(filePath);
655
+ if (!createdDirs.has(dir)) {
656
+ mkdirSync2(dir, { recursive: true });
657
+ createdDirs.add(dir);
658
+ }
659
+ await writeFile(filePath, file.buffer);
660
+ manifest.push({ path: filePath, type: file.type, index: file.index });
661
+ }
662
+ return manifest;
39
663
  }
40
664
 
41
665
  // src/public-fixtures/catalog.ts
42
666
  import { readFileSync } from "node:fs";
43
- import { dirname, join } from "node:path";
667
+ import { dirname as dirname2, join } from "node:path";
44
668
  import { fileURLToPath } from "node:url";
45
669
  var bundledCatalogPath = join(
46
- dirname(fileURLToPath(import.meta.url)),
670
+ dirname2(fileURLToPath(import.meta.url)),
47
671
  "../../data/public-cases.json"
48
672
  );
49
673
  function defaultPublicCasesPath() {
@@ -71,7 +695,7 @@ function loadCaseById(casesJsonPath, id) {
71
695
 
72
696
  // src/public-fixtures/fetch.ts
73
697
  import { createHash } from "node:crypto";
74
- import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "node:fs";
698
+ import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
75
699
  import { homedir } from "node:os";
76
700
  import { join as join2 } from "node:path";
77
701
  var DEFAULT_CACHE_ROOT = join2(homedir(), ".cache", "dicom-synth-testcases");
@@ -107,173 +731,19 @@ async function fetchPublicCaseToCache(record, cacheRoot = DEFAULT_CACHE_ROOT) {
107
731
  }
108
732
  const buf = Buffer.from(await res.arrayBuffer());
109
733
  verifySha256(buf, record.sha256);
110
- writeFileSync3(dest, buf);
734
+ writeFileSync2(dest, buf);
111
735
  return dest;
112
736
  }
113
-
114
- // src/syntheticFixtures/generator.ts
115
- import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync4 } from "node:fs";
116
- import { resolve as resolve3 } from "node:path";
117
-
118
- // src/loadDcmjs.ts
119
- import dcmjsDefaultImport, * as dcmjsNamespace from "dcmjs";
120
- function loadDcmjs() {
121
- if (dcmjsNamespace.data?.DicomDict) {
122
- return dcmjsNamespace;
123
- }
124
- const d = dcmjsDefaultImport;
125
- if (d?.data?.DicomDict) {
126
- return d;
127
- }
128
- if (d?.default?.data?.DicomDict) {
129
- return d.default;
130
- }
131
- throw new Error(
132
- "dcmjs failed to load (missing data.DicomDict). Install dcmjs >= 0.29."
133
- );
134
- }
135
- var dcmjs = loadDcmjs();
136
-
137
- // src/syntheticFixtures/generator.ts
138
- var CT_SOP_CLASS = "1.2.840.10008.5.1.4.1.1.2";
139
- var VARIANT_INDEX = {
140
- "minimal-invalid-uid": 0,
141
- "valid-uid": 1,
142
- "vendor-warnings": 2
143
- };
144
- function uidFor(variant, role) {
145
- const i = VARIANT_INDEX[variant];
146
- if (variant === "minimal-invalid-uid") {
147
- return `2.25.100000000000000000000000000000.${role}.${i}`;
148
- }
149
- const uidRoots = [
150
- "1000000000000000000000000000000000001",
151
- "1000000000000000000000000000000000002",
152
- "1000000000000000000000000000000000003"
153
- ];
154
- const roleSuffix = role === "study" ? 1 : role === "series" ? 2 : 3;
155
- return `2.25.${uidRoots[i]}.${roleSuffix}`;
156
- }
157
- function naturalDataset(variant) {
158
- const studyUID = uidFor(variant, "study");
159
- const seriesUID = uidFor(variant, "series");
160
- const sopUID = uidFor(variant, "sop");
161
- const dataset = {
162
- PatientName: "SYNTH^SUBJECT",
163
- PatientID: "SYNTH_PID",
164
- Modality: "CT",
165
- SOPClassUID: CT_SOP_CLASS,
166
- SOPInstanceUID: sopUID,
167
- SeriesInstanceUID: seriesUID,
168
- StudyInstanceUID: studyUID,
169
- StudyDescription: "Synthetic study",
170
- SeriesDescription: "Synthetic series",
171
- Rows: 1,
172
- Columns: 1,
173
- BitsAllocated: 16,
174
- BitsStored: 16,
175
- HighBit: 15,
176
- SamplesPerPixel: 1,
177
- PhotometricInterpretation: "MONOCHROME2",
178
- PixelRepresentation: 0,
179
- PixelData: new Uint8Array([0, 0]).buffer
180
- };
181
- if (variant === "vendor-warnings") {
182
- dataset.Laterality = "";
183
- dataset.PatientWeight = "0";
184
- }
185
- return dataset;
186
- }
187
- function buildDicomDict(variant) {
188
- const meta = {
189
- FileMetaInformationVersion: new Uint8Array([0, 1]).buffer,
190
- MediaStorageSOPClassUID: CT_SOP_CLASS,
191
- MediaStorageSOPInstanceUID: uidFor(variant, "sop"),
192
- TransferSyntaxUID: "1.2.840.10008.1.2.1",
193
- ImplementationClassUID: "1.2.3.4",
194
- ImplementationVersionName: "SYNTH"
195
- };
196
- const dicomDict = new dcmjs.data.DicomDict(
197
- dcmjs.data.DicomMetaDictionary.denaturalizeDataset(meta)
198
- );
199
- dicomDict.dict = dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
200
- naturalDataset(variant)
201
- );
202
- return dicomDict;
203
- }
204
- function buildSyntheticCtBuffer(variant) {
205
- return Buffer.from(
206
- buildDicomDict(variant).write({ allowInvalidVRLength: true })
207
- );
208
- }
209
- var SYNTHETIC_FIXTURES = [
210
- {
211
- filename: "minimal-ct-0.dcm",
212
- variant: "minimal-invalid-uid",
213
- description: "Minimal CT with text in UIDs (many dciodvfy errors)"
214
- },
215
- {
216
- filename: "valid-uid-ct-0.dcm",
217
- variant: "valid-uid",
218
- description: "Minimal CT with numeric UIDs only (missing-module noise)"
219
- },
220
- {
221
- filename: "vendor-warnings-ct-0.dcm",
222
- variant: "vendor-warnings",
223
- description: "Valid UIDs plus empty Laterality and zero PatientWeight"
224
- }
225
- ];
226
- function writeMinimalDicomFile(filePath, tags) {
227
- const patientId = tags?.patientId ?? "TEST-PATIENT-001";
228
- const patientName = tags?.patientName ?? "TEST^PATIENT";
229
- const sopUID = tags?.sopInstanceUid ?? "1.2.3.4.5.6.7.8.9.0.1";
230
- const dataset = {
231
- PatientName: patientName,
232
- PatientID: patientId,
233
- Modality: "CT",
234
- SOPClassUID: CT_SOP_CLASS,
235
- SOPInstanceUID: sopUID,
236
- SeriesInstanceUID: "1.2.3.4.5.6.7.8",
237
- StudyInstanceUID: "1.2.3.4.5.6.7",
238
- SeriesNumber: "1",
239
- Rows: 1,
240
- Columns: 1,
241
- BitsAllocated: 16,
242
- PixelRepresentation: 0,
243
- PixelData: new Uint8Array([0, 0]).buffer
244
- };
245
- const dicomDict = new dcmjs.data.DicomDict({});
246
- dicomDict.dict = dcmjs.data.DicomMetaDictionary.denaturalizeDataset(dataset);
247
- const buffer = Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
248
- mkdirSync4(resolve3(filePath, ".."), { recursive: true });
249
- writeFileSync4(filePath, buffer);
250
- }
251
- function writeSyntheticFixturesToDir(outDir) {
252
- const root = resolve3(outDir);
253
- mkdirSync4(root, { recursive: true });
254
- const written = [];
255
- for (const { filename, variant } of SYNTHETIC_FIXTURES) {
256
- const path = resolve3(root, filename);
257
- writeFileSync4(path, buildSyntheticCtBuffer(variant));
258
- written.push(path);
259
- }
260
- return written;
261
- }
262
737
  export {
263
- SYNTHETIC_FIXTURES,
264
- buildDicomDict,
265
- buildDicomdirBuffer,
266
- buildSyntheticCtBuffer,
267
738
  caseCachePath,
268
739
  defaultPublicCasesPath,
269
740
  fetchPublicCaseToCache,
741
+ generateCollectionFromSpec,
742
+ generateFile,
270
743
  loadCaseById,
271
744
  loadCasesFromJson,
272
745
  loadDefaultCases,
746
+ validateDatasetSpec,
273
747
  verifySha256,
274
- writeDicomdirFile,
275
- writeFakeDicomSignatureFile,
276
- writeMinimalDicomFile,
277
- writeNonDicomFile,
278
- writeSyntheticFixturesToDir
748
+ writeCollectionFromSpec
279
749
  };