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.
@@ -0,0 +1,484 @@
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
+ );
22
+ }
23
+ var dcmjs = loadDcmjs();
24
+
25
+ // src/nonStandardDicom/dicomdir.ts
26
+ import { mkdirSync, writeFileSync } from "node:fs";
27
+ import { resolve } from "node:path";
28
+ var DICOMDIR_SOP_CLASS_UID = "1.2.840.10008.1.3.10";
29
+ function buildDicomdirBuffer() {
30
+ const uid = DICOMDIR_SOP_CLASS_UID;
31
+ const buf = Buffer.alloc(256, 0);
32
+ buf.write("DICM", 128, 4, "ascii");
33
+ let off = 132;
34
+ buf.writeUInt16LE(2, off);
35
+ off += 2;
36
+ buf.writeUInt16LE(2, off);
37
+ off += 2;
38
+ buf.write("UI", off, 2, "ascii");
39
+ off += 2;
40
+ buf.writeUInt16LE(uid.length, off);
41
+ off += 2;
42
+ buf.write(uid, off, uid.length, "ascii");
43
+ off += uid.length;
44
+ return buf.subarray(0, off);
45
+ }
46
+
47
+ // src/schema/validate.ts
48
+ var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
49
+ var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
50
+
51
+ // src/syntheticFixtures/generator.ts
52
+ var CT_SOP_CLASS = "1.2.840.10008.5.1.4.1.1.2";
53
+ var TRANSFER_SYNTAX_UID = {
54
+ "explicit-vr-little-endian": "1.2.840.10008.1.2.1",
55
+ "implicit-vr-little-endian": "1.2.840.10008.1.2"
56
+ };
57
+ function buildTagToKeyword() {
58
+ const map = /* @__PURE__ */ new Map();
59
+ const nm = dcmjs.data?.DicomMetaDictionary?.nameMap;
60
+ if (!nm) return map;
61
+ for (const [keyword, info] of Object.entries(nm)) {
62
+ if (info?.tag) {
63
+ const hex = info.tag.replace(/[(,)]/g, "").toUpperCase();
64
+ if (hex.length === 8) map.set(hex, keyword);
65
+ }
66
+ }
67
+ return map;
68
+ }
69
+ var tagToKeyword = buildTagToKeyword();
70
+ function applyTagOverrides(dataset, tags) {
71
+ if (!tags) return;
72
+ for (const [key, value] of Object.entries(tags)) {
73
+ if (HEX_TAG_RE.test(key)) {
74
+ const keyword = tagToKeyword.get(key.toUpperCase());
75
+ if (keyword) {
76
+ dataset[keyword] = value;
77
+ } else {
78
+ console.warn(`dicom-synth: unknown hex tag "${key}" \u2014 skipping`);
79
+ }
80
+ } else {
81
+ dataset[key] = value;
82
+ }
83
+ }
84
+ }
85
+ function buildMeta(uid, transferSyntax = "explicit-vr-little-endian") {
86
+ return {
87
+ FileMetaInformationVersion: new Uint8Array([0, 1]).buffer,
88
+ MediaStorageSOPClassUID: CT_SOP_CLASS,
89
+ MediaStorageSOPInstanceUID: uid.sop,
90
+ TransferSyntaxUID: TRANSFER_SYNTAX_UID[transferSyntax],
91
+ ImplementationClassUID: "1.2.3.4",
92
+ ImplementationVersionName: "SYNTH"
93
+ };
94
+ }
95
+ function buildBaseCtDataset(uid) {
96
+ return {
97
+ PatientName: "SYNTH^SUBJECT",
98
+ PatientID: "SYNTH_PID",
99
+ Modality: "CT",
100
+ SOPClassUID: CT_SOP_CLASS,
101
+ SOPInstanceUID: uid.sop,
102
+ SeriesInstanceUID: uid.series,
103
+ StudyInstanceUID: uid.study,
104
+ StudyDescription: "Synthetic study",
105
+ SeriesDescription: "Synthetic series",
106
+ Rows: 1,
107
+ Columns: 1,
108
+ BitsAllocated: 16,
109
+ BitsStored: 16,
110
+ HighBit: 15,
111
+ SamplesPerPixel: 1,
112
+ PhotometricInterpretation: "MONOCHROME2",
113
+ PixelRepresentation: 0,
114
+ PixelData: new Uint8Array([0, 0]).buffer,
115
+ // Declares PixelData's VR explicitly — without it dcmjs logs
116
+ // "No value representation given for PixelData" and falls back to OW anyway.
117
+ _vrMap: { PixelData: "OW" }
118
+ };
119
+ }
120
+ function serializeDict(meta, dataset) {
121
+ const dicomDict = new dcmjs.data.DicomDict(
122
+ dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
123
+ meta
124
+ )
125
+ );
126
+ dicomDict.dict = dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
127
+ dataset
128
+ );
129
+ return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
130
+ }
131
+ function buildValidCtBuffer(uid, tags, transferSyntax) {
132
+ const dataset = buildBaseCtDataset(uid);
133
+ applyTagOverrides(dataset, tags);
134
+ return serializeDict(buildMeta(uid, transferSyntax), dataset);
135
+ }
136
+ function buildInvalidUidCtBuffer(uid, tags, transferSyntax) {
137
+ const invalidUid = {
138
+ study: `2.25.invalid.study.${uid.study.slice(-6)}`,
139
+ series: `2.25.invalid.series.${uid.series.slice(-6)}`,
140
+ sop: `2.25.invalid.sop.${uid.sop.slice(-6)}`
141
+ };
142
+ const dataset = buildBaseCtDataset(invalidUid);
143
+ applyTagOverrides(dataset, tags);
144
+ return serializeDict(buildMeta(invalidUid, transferSyntax), dataset);
145
+ }
146
+ function buildVendorCtBuffer(uid, tags, transferSyntax) {
147
+ const dataset = buildBaseCtDataset(uid);
148
+ dataset.Laterality = "";
149
+ dataset.PatientWeight = "0";
150
+ applyTagOverrides(dataset, tags);
151
+ return serializeDict(buildMeta(uid, transferSyntax), dataset);
152
+ }
153
+ function buildLargeCtBuffer(uid, rows, columns, frames, tags, transferSyntax) {
154
+ const dataset = buildBaseCtDataset(uid);
155
+ dataset.Rows = rows;
156
+ dataset.Columns = columns;
157
+ dataset.NumberOfFrames = frames;
158
+ dataset.PixelData = new Uint8Array(rows * columns * frames * 2).buffer;
159
+ applyTagOverrides(dataset, tags);
160
+ return serializeDict(buildMeta(uid, transferSyntax), dataset);
161
+ }
162
+ function buildFakeSignatureBuffer() {
163
+ const buf = Buffer.alloc(200, 0);
164
+ buf.write("XXXX", 128, 4, "ascii");
165
+ return buf;
166
+ }
167
+ function buildNonDicomBuffer(content = "not dicom") {
168
+ return Buffer.from(content, "utf8");
169
+ }
170
+ function buildBufferForSpec(spec, uid) {
171
+ switch (spec.type) {
172
+ case "valid-ct":
173
+ return buildValidCtBuffer(uid, spec.tags, spec.transferSyntax);
174
+ case "invalid-uid-ct":
175
+ return buildInvalidUidCtBuffer(uid, spec.tags, spec.transferSyntax);
176
+ case "vendor-warnings-ct":
177
+ return buildVendorCtBuffer(uid, spec.tags, spec.transferSyntax);
178
+ case "large-ct":
179
+ return buildLargeCtBuffer(
180
+ uid,
181
+ spec.rows,
182
+ spec.columns,
183
+ spec.frames ?? 1,
184
+ spec.tags,
185
+ spec.transferSyntax
186
+ );
187
+ case "fake-signature":
188
+ return buildFakeSignatureBuffer();
189
+ case "non-dicom":
190
+ return buildNonDicomBuffer(spec.content);
191
+ case "dicomdir":
192
+ return buildDicomdirBuffer();
193
+ }
194
+ }
195
+
196
+ // src/syntheticFixtures/uid.ts
197
+ import { randomBytes } from "node:crypto";
198
+ var ROLE_CODE = { study: 1, series: 2, sop: 3 };
199
+ function lcg(seed) {
200
+ let s = seed >>> 0;
201
+ return () => {
202
+ s = Math.imul(1664525, s) + 1013904223 >>> 0;
203
+ return s;
204
+ };
205
+ }
206
+ function formatUid(a, b, role) {
207
+ return `2.25.${a}.${b}.${ROLE_CODE[role]}`;
208
+ }
209
+ function randomUid(role) {
210
+ const buf = randomBytes(8);
211
+ return formatUid(buf.readUInt32BE(0), buf.readUInt32BE(4), role);
212
+ }
213
+ function seededUid(next, role) {
214
+ return formatUid(next(), next(), role);
215
+ }
216
+ var GROUP_STREAM_OFFSET = 2654435769;
217
+ function seededStream(seed, offset, a, b) {
218
+ return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
219
+ }
220
+ function makeGroupUidGenerator(seed) {
221
+ if (seed === void 0) {
222
+ return {
223
+ study: () => randomUid("study"),
224
+ series: () => randomUid("series")
225
+ };
226
+ }
227
+ return {
228
+ study: (studyIndex) => seededUid(
229
+ seededStream(seed, GROUP_STREAM_OFFSET, studyIndex + 1, 0),
230
+ "study"
231
+ ),
232
+ series: (studyIndex, seriesIndex) => seededUid(
233
+ seededStream(
234
+ seed,
235
+ GROUP_STREAM_OFFSET,
236
+ studyIndex + 1,
237
+ seriesIndex + 1
238
+ ),
239
+ "series"
240
+ )
241
+ };
242
+ }
243
+ function makeUidGenerator(seed) {
244
+ if (seed === void 0) {
245
+ return () => ({
246
+ study: randomUid("study"),
247
+ series: randomUid("series"),
248
+ sop: randomUid("sop")
249
+ });
250
+ }
251
+ return (fileIndex) => {
252
+ const next = seededStream(seed, 0, fileIndex, 0);
253
+ return {
254
+ study: seededUid(next, "study"),
255
+ series: seededUid(next, "series"),
256
+ sop: seededUid(next, "sop")
257
+ };
258
+ };
259
+ }
260
+
261
+ // src/syntheticFixtures/violations.ts
262
+ var dcmjsAny = dcmjs;
263
+ function parseBuffer(buffer) {
264
+ const ab = buffer.buffer.slice(
265
+ buffer.byteOffset,
266
+ buffer.byteOffset + buffer.byteLength
267
+ );
268
+ return dcmjsAny.data.DicomMessage.readFile(ab);
269
+ }
270
+ function reserialize(parsed) {
271
+ return Buffer.from(parsed.write({ allowInvalidVRLength: true }));
272
+ }
273
+ var OVERLONG_UID = `2.25.${"0".repeat(60)}`;
274
+ var VIOLATION_LEVEL = {
275
+ "uid-too-long": "tag",
276
+ "non-conformant-uid": "tag",
277
+ "vr-max-length-exceeded": "tag",
278
+ "missing-type1-tag": "tag",
279
+ "missing-meta-header": "byte",
280
+ "malformed-sq-delimiter": "byte"
281
+ };
282
+ function applyTagLevel(buffer, violations) {
283
+ if (violations.length === 0) return buffer;
284
+ let parsed;
285
+ try {
286
+ parsed = parseBuffer(buffer);
287
+ } catch {
288
+ console.warn(
289
+ "dicom-synth: could not parse buffer for tag-level violations \u2014 skipping"
290
+ );
291
+ return buffer;
292
+ }
293
+ const natural = dcmjsAny.data.DicomMetaDictionary.naturalizeDataset(
294
+ parsed.dict
295
+ );
296
+ for (const v of violations) {
297
+ switch (v) {
298
+ case "uid-too-long":
299
+ break;
300
+ case "non-conformant-uid":
301
+ natural.SOPInstanceUID = "2.25.01.234.567";
302
+ break;
303
+ case "vr-max-length-exceeded":
304
+ natural.StudyDescription = "X".repeat(65);
305
+ break;
306
+ case "missing-type1-tag":
307
+ delete natural.SOPClassUID;
308
+ break;
309
+ default:
310
+ throw new Error(
311
+ `dicom-synth: unhandled tag-level violation "${v}"`
312
+ );
313
+ }
314
+ }
315
+ parsed.dict = dcmjsAny.data.DicomMetaDictionary.denaturalizeDataset(natural);
316
+ if (violations.includes("uid-too-long")) {
317
+ ;
318
+ parsed.dict["00080018"] = {
319
+ vr: "UI",
320
+ Value: [OVERLONG_UID]
321
+ };
322
+ }
323
+ return reserialize(parsed);
324
+ }
325
+ function applyByteLevel(buffer, violations) {
326
+ let result = buffer;
327
+ for (const v of violations) {
328
+ switch (v) {
329
+ case "missing-meta-header":
330
+ result = result.subarray(132);
331
+ break;
332
+ case "malformed-sq-delimiter": {
333
+ const delimiter = Buffer.alloc(8);
334
+ delimiter.writeUInt16LE(65534, 0);
335
+ delimiter.writeUInt16LE(57565, 2);
336
+ delimiter.writeUInt32LE(4, 4);
337
+ result = Buffer.concat([result, delimiter]);
338
+ break;
339
+ }
340
+ default:
341
+ throw new Error(
342
+ `dicom-synth: unhandled byte-level violation "${v}"`
343
+ );
344
+ }
345
+ }
346
+ return result;
347
+ }
348
+ function applyViolations(buffer, violations) {
349
+ if (violations.length === 0) return buffer;
350
+ const tagViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "tag");
351
+ const byteViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "byte");
352
+ return applyByteLevel(applyTagLevel(buffer, tagViolations), byteViolations);
353
+ }
354
+
355
+ // src/collection/writer.ts
356
+ var NO_EXTENSION_TYPES = /* @__PURE__ */ new Set([
357
+ "fake-signature",
358
+ "non-dicom"
359
+ ]);
360
+ function filename(type, index, padWidth) {
361
+ const padded = String(index).padStart(padWidth, "0");
362
+ const base = `${type}-${padded}`;
363
+ return NO_EXTENSION_TYPES.has(type) ? base : `${base}.dcm`;
364
+ }
365
+ function entryCount(entries) {
366
+ return entries.reduce((sum, e) => sum + (e.count ?? 1), 0);
367
+ }
368
+ function studyFileCount(studies) {
369
+ return studies.reduce(
370
+ (sum, study) => sum + (study.count ?? 1) * study.series.reduce((s, series) => s + entryCount(series.entries), 0),
371
+ 0
372
+ );
373
+ }
374
+ async function generateFile(spec, options) {
375
+ const index = options?.index ?? 0;
376
+ const padWidth = options?.padWidth ?? 3;
377
+ const uidGen = makeUidGenerator(options?.seed);
378
+ const uid = { ...uidGen(index), ...options?.uid };
379
+ let buffer = buildBufferForSpec(spec, uid);
380
+ const violations = "violations" in spec ? spec.violations : void 0;
381
+ if (violations?.length) {
382
+ buffer = applyViolations(buffer, violations);
383
+ }
384
+ const name = filename(spec.type, index, padWidth);
385
+ return {
386
+ filename: name,
387
+ relativePath: name,
388
+ buffer,
389
+ type: spec.type,
390
+ index
391
+ };
392
+ }
393
+ function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
394
+ const pad3 = (n) => String(n).padStart(3, "0");
395
+ const name = `${String(instanceNumber).padStart(5, "0")}.dcm`;
396
+ return {
397
+ filename: name,
398
+ relativePath: `study-${pad3(studyOrdinal + 1)}/series-${pad3(seriesIndex + 1)}/${name}`
399
+ };
400
+ }
401
+ async function* generateCollectionFromSpec(spec) {
402
+ const flatEntries = spec.entries ?? [];
403
+ const studies = spec.studies ?? [];
404
+ const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
405
+ const padWidth = String(totalFiles).length;
406
+ const hierarchical = spec.layout === "hierarchical";
407
+ let globalIndex = 0;
408
+ for (const entry of flatEntries) {
409
+ const { count = 1, ...fileSpec } = entry;
410
+ for (let i = 0; i < count; i++) {
411
+ yield generateFile(fileSpec, {
412
+ index: globalIndex,
413
+ seed: spec.seed,
414
+ padWidth
415
+ });
416
+ globalIndex++;
417
+ }
418
+ }
419
+ const groupUids = makeGroupUidGenerator(spec.seed);
420
+ let studyOrdinal = 0;
421
+ for (const study of studies) {
422
+ const copies = study.count ?? 1;
423
+ for (let copy = 0; copy < copies; copy++) {
424
+ const studyUid = groupUids.study(studyOrdinal);
425
+ for (const [seriesIndex, series] of study.series.entries()) {
426
+ const seriesUid = groupUids.series(studyOrdinal, seriesIndex);
427
+ let instanceNumber = 0;
428
+ for (const entry of series.entries) {
429
+ const { count = 1, ...fileSpec } = entry;
430
+ for (let i = 0; i < count; i++) {
431
+ instanceNumber++;
432
+ const tags = {
433
+ InstanceNumber: instanceNumber,
434
+ ...study.tags,
435
+ ...series.tags,
436
+ ...fileSpec.tags
437
+ };
438
+ const file = await generateFile(
439
+ { ...fileSpec, tags },
440
+ {
441
+ index: globalIndex,
442
+ seed: spec.seed,
443
+ padWidth,
444
+ uid: { study: studyUid, series: seriesUid }
445
+ }
446
+ );
447
+ yield hierarchical ? {
448
+ ...file,
449
+ ...hierarchicalName(
450
+ studyOrdinal,
451
+ seriesIndex,
452
+ instanceNumber
453
+ )
454
+ } : file;
455
+ globalIndex++;
456
+ }
457
+ }
458
+ }
459
+ studyOrdinal++;
460
+ }
461
+ }
462
+ }
463
+ async function writeCollectionFromSpec(spec, outDir) {
464
+ const root = resolve2(outDir);
465
+ mkdirSync2(root, { recursive: true });
466
+ const manifest = [];
467
+ const createdDirs = /* @__PURE__ */ new Set([root]);
468
+ for await (const file of generateCollectionFromSpec(spec)) {
469
+ const filePath = resolve2(root, file.relativePath);
470
+ const dir = dirname(filePath);
471
+ if (!createdDirs.has(dir)) {
472
+ mkdirSync2(dir, { recursive: true });
473
+ createdDirs.add(dir);
474
+ }
475
+ await writeFile(filePath, file.buffer);
476
+ manifest.push({ path: filePath, type: file.type, index: file.index });
477
+ }
478
+ return manifest;
479
+ }
480
+ export {
481
+ generateCollectionFromSpec,
482
+ generateFile,
483
+ writeCollectionFromSpec
484
+ };