dicom-synth 1.8.0 → 1.10.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,5 +1,12 @@
1
1
  // src/describe/describe.ts
2
- import { readdirSync, readFileSync } from "node:fs";
2
+ import {
3
+ closeSync,
4
+ openSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ readSync,
8
+ statSync
9
+ } from "node:fs";
3
10
  import { join } from "node:path";
4
11
 
5
12
  // src/loadDcmjs.ts
@@ -21,11 +28,35 @@ function loadDcmjs() {
21
28
  }
22
29
  var dcmjs = loadDcmjs();
23
30
 
31
+ // src/schema/limits.ts
32
+ var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
33
+ var MAX_STREAM_BYTES = 50 * 1024 * 1024 * 1024;
34
+
35
+ // src/syntheticFixtures/tagTemplate.ts
36
+ var TEMPLATE_VOCAB = [
37
+ "index",
38
+ "studyIndex",
39
+ "seriesIndex",
40
+ "instanceNumber"
41
+ ];
42
+ var TEMPLATE_PART_RE = /\{\{|\}\}|\{([a-zA-Z]+)\}/g;
43
+ function escapeTagTemplate(value) {
44
+ return value.replace(/\{/g, "{{").replace(/\}/g, "}}");
45
+ }
46
+ function findTemplateTokens(value) {
47
+ const names = [];
48
+ for (const m of value.matchAll(TEMPLATE_PART_RE)) {
49
+ if (m[1] !== void 0) names.push(m[1]);
50
+ }
51
+ return names;
52
+ }
53
+
24
54
  // src/schema/validate.ts
25
55
  var TYPE_CAPABILITIES = {
26
56
  "valid-image": { image: true },
27
57
  "invalid-uid-image": { image: true },
28
58
  "vendor-warnings-image": { image: true },
59
+ "large-image": { image: true },
29
60
  "fake-signature": { image: false },
30
61
  "non-dicom": { image: false },
31
62
  dicomdir: { image: false }
@@ -50,7 +81,6 @@ var VALID_TRANSFER_SYNTAXES = {
50
81
  "explicit-vr-little-endian": true,
51
82
  "implicit-vr-little-endian": true
52
83
  };
53
- var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
54
84
  var VALID_VIOLATIONS = {
55
85
  "uid-too-long": true,
56
86
  "non-conformant-uid": true,
@@ -80,6 +110,43 @@ function validateSizeKbLimit(kb, path) {
80
110
  throw new Error(`${path}: exceeds the 512 MB limit`);
81
111
  }
82
112
  }
113
+ function validateLargeTargetBytes(value, path) {
114
+ if (typeof value !== "number" || !Number.isInteger(value)) {
115
+ throw new Error(`${path}: must be an integer; got ${JSON.stringify(value)}`);
116
+ }
117
+ if (value <= MAX_PIXEL_BYTES) {
118
+ throw new Error(
119
+ `${path}: must exceed 512 MB \u2014 use targetSizeKb for smaller files`
120
+ );
121
+ }
122
+ if (value > MAX_STREAM_BYTES) {
123
+ throw new Error(`${path}: exceeds the 50 GB limit`);
124
+ }
125
+ }
126
+ function validateLargeImageEntry(e, path) {
127
+ for (const field of [
128
+ "rows",
129
+ "columns",
130
+ "frames",
131
+ "targetSizeKb",
132
+ "violations",
133
+ "transferSyntax"
134
+ ]) {
135
+ if (field in e) {
136
+ throw new Error(
137
+ `${path}.${field}: not supported for type "large-image" \u2014 use targetBytes`
138
+ );
139
+ }
140
+ }
141
+ if (!("targetBytes" in e)) {
142
+ throw new Error(`${path}.targetBytes: is required for type "large-image"`);
143
+ }
144
+ validateLargeTargetBytes(e.targetBytes, `${path}.targetBytes`);
145
+ if ("modality" in e) {
146
+ validateEnum(e.modality, VALID_MODALITIES, `${path}.modality`);
147
+ }
148
+ if ("tags" in e) validateTags(e.tags, `${path}.tags`);
149
+ }
83
150
  function validateEnum(value, valid, path) {
84
151
  if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
85
152
  throw new Error(
@@ -103,6 +170,15 @@ function validateEntry(entry, path) {
103
170
  const type = e.type;
104
171
  const caps = TYPE_CAPABILITIES[type];
105
172
  if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
173
+ if (type === "large-image") {
174
+ validateLargeImageEntry(e, path);
175
+ return e;
176
+ }
177
+ if ("targetBytes" in e) {
178
+ throw new Error(
179
+ `${path}.targetBytes: only supported for type "large-image"`
180
+ );
181
+ }
106
182
  for (const field of [
107
183
  "modality",
108
184
  "rows",
@@ -162,12 +238,28 @@ function validateEntry(entry, path) {
162
238
  }
163
239
  return e;
164
240
  }
241
+ var MODALITY_TAG = "00080060";
165
242
  function validateTags(tags, path) {
166
243
  if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
167
244
  throw new Error(`${path}: must be an object`);
168
245
  }
169
- for (const key of Object.keys(tags)) {
246
+ for (const [key, value] of Object.entries(tags)) {
170
247
  validateTagKey(key, path);
248
+ const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
249
+ if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
250
+ throw new Error(
251
+ `${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`
252
+ );
253
+ }
254
+ if (typeof value === "string") {
255
+ for (const name of findTemplateTokens(value)) {
256
+ if (!TEMPLATE_VOCAB.includes(name)) {
257
+ throw new Error(
258
+ `${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
259
+ );
260
+ }
261
+ }
262
+ }
171
263
  }
172
264
  }
173
265
  function validateSeries(series, path) {
@@ -253,6 +345,13 @@ function validateDatasetSpec(raw) {
253
345
  var dcmjsAny = dcmjs;
254
346
  var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
255
347
  var SUPPORTED_MODALITIES = /* @__PURE__ */ new Set(["CT", "PT", "MR", "CR"]);
348
+ var RESERVED_KEEP_KEYWORDS = /* @__PURE__ */ new Set([
349
+ "Units",
350
+ "DecayCorrection",
351
+ "CorrectedImage",
352
+ "ScanningSequence",
353
+ "SequenceVariant"
354
+ ]);
256
355
  function buildHexToKeyword() {
257
356
  const map = /* @__PURE__ */ new Map();
258
357
  const nm = dcmjsAny.data.DicomMetaDictionary.nameMap;
@@ -268,20 +367,37 @@ function buildHexToKeyword() {
268
367
  function resolveKeepTags(keepTags) {
269
368
  const nameMap = dcmjsAny.data.DicomMetaDictionary.nameMap;
270
369
  let hexToKeyword;
271
- return keepTags.map((tag) => {
370
+ const kept = [];
371
+ for (const tag of keepTags) {
372
+ let keyword;
272
373
  if (HEX_TAG_RE2.test(tag)) {
273
374
  hexToKeyword ?? (hexToKeyword = buildHexToKeyword());
274
- const keyword = hexToKeyword.get(tag.toUpperCase());
275
- if (!keyword) {
375
+ const mapped = hexToKeyword.get(tag.toUpperCase());
376
+ if (!mapped) {
276
377
  throw new Error(`describe: unknown hex tag "${tag}"`);
277
378
  }
278
- return keyword;
379
+ keyword = mapped;
380
+ } else {
381
+ if (nameMap && !Object.hasOwn(nameMap, tag)) {
382
+ throw new Error(`describe: unknown tag keyword "${tag}"`);
383
+ }
384
+ keyword = tag;
385
+ }
386
+ if (RESERVED_KEEP_KEYWORDS.has(keyword)) {
387
+ console.warn(
388
+ `describe: ignoring keep-tag "${keyword}" \u2014 modality-coupled tags are reproduced from the modality field`
389
+ );
390
+ continue;
279
391
  }
280
- if (nameMap && !Object.hasOwn(nameMap, tag)) {
281
- throw new Error(`describe: unknown tag keyword "${tag}"`);
392
+ if (nameMap?.[keyword]?.vr === "UI") {
393
+ console.warn(
394
+ `describe: ignoring keep-tag "${keyword}" \u2014 UID-valued tags are not kept verbatim`
395
+ );
396
+ continue;
282
397
  }
283
- return tag;
284
- });
398
+ kept.push(keyword);
399
+ }
400
+ return kept;
285
401
  }
286
402
  function* walkFiles(dir) {
287
403
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -293,18 +409,77 @@ function* walkFiles(dir) {
293
409
  }
294
410
  }
295
411
  }
296
- function parseFile(path) {
412
+ function toArrayBuffer(buf) {
413
+ return buf.buffer.slice(
414
+ buf.byteOffset,
415
+ buf.byteOffset + buf.byteLength
416
+ );
417
+ }
418
+ function naturalize(ab) {
419
+ const parsed = dcmjsAny.data.DicomMessage.readFile(ab);
420
+ return dcmjsAny.data.DicomMetaDictionary.naturalizeDataset(parsed.dict);
421
+ }
422
+ function parseFull(path) {
297
423
  try {
298
- const buf = readFileSync(path);
299
- const ab = buf.buffer.slice(
300
- buf.byteOffset,
301
- buf.byteOffset + buf.byteLength
302
- );
303
- const parsed = dcmjsAny.data.DicomMessage.readFile(ab);
304
- const record = dcmjsAny.data.DicomMetaDictionary.naturalizeDataset(
305
- parsed.dict
306
- );
307
- return { record, size: buf.byteLength };
424
+ return naturalize(toArrayBuffer(readFileSync(path)));
425
+ } catch {
426
+ return null;
427
+ }
428
+ }
429
+ var PIXEL_DATA_OW = Buffer.from([224, 127, 16, 0, 79, 87]);
430
+ var PIXEL_DATA_OB = Buffer.from([224, 127, 16, 0, 79, 66]);
431
+ var EMPTY_OW_PIXEL_DATA = Buffer.from([
432
+ 224,
433
+ 127,
434
+ 16,
435
+ 0,
436
+ 79,
437
+ 87,
438
+ 0,
439
+ 0,
440
+ 0,
441
+ 0,
442
+ 0,
443
+ 0
444
+ ]);
445
+ var HEADER_READ_BYTES = 2 * 1024 * 1024;
446
+ function findPixelDataOffset(prefix) {
447
+ let best = -1;
448
+ for (const pattern of [PIXEL_DATA_OW, PIXEL_DATA_OB]) {
449
+ for (let from = 0; ; ) {
450
+ const i = prefix.indexOf(pattern, from);
451
+ if (i < 0) break;
452
+ if (prefix[i + 6] === 0 && prefix[i + 7] === 0) {
453
+ if (best < 0 || i < best) best = i;
454
+ break;
455
+ }
456
+ from = i + 1;
457
+ }
458
+ }
459
+ return best;
460
+ }
461
+ function readHeaderOnly(path) {
462
+ let prefix;
463
+ try {
464
+ const fd = openSync(path, "r");
465
+ try {
466
+ const buf = Buffer.alloc(HEADER_READ_BYTES);
467
+ const n = readSync(fd, buf, 0, HEADER_READ_BYTES, 0);
468
+ prefix = buf.subarray(0, n);
469
+ } finally {
470
+ closeSync(fd);
471
+ }
472
+ } catch {
473
+ return null;
474
+ }
475
+ const tagIdx = findPixelDataOffset(prefix);
476
+ if (tagIdx < 0) return null;
477
+ const headerOnly = Buffer.concat([
478
+ prefix.subarray(0, tagIdx),
479
+ EMPTY_OW_PIXEL_DATA
480
+ ]);
481
+ try {
482
+ return naturalize(toArrayBuffer(headerOnly));
308
483
  } catch {
309
484
  return null;
310
485
  }
@@ -314,10 +489,12 @@ function extractTags(record, keepKeywords) {
314
489
  let found = false;
315
490
  for (const keyword of keepKeywords) {
316
491
  const value = record[keyword];
317
- if (value !== void 0) {
318
- tags[keyword] = value;
319
- found = true;
492
+ if (value === void 0) continue;
493
+ if (keyword === "Modality" && typeof value === "string" && SUPPORTED_MODALITIES.has(value)) {
494
+ continue;
320
495
  }
496
+ tags[keyword] = typeof value === "string" ? escapeTagTemplate(value) : value;
497
+ found = true;
321
498
  }
322
499
  return found ? tags : void 0;
323
500
  }
@@ -326,14 +503,25 @@ function describeDirectory(dir, options = {}) {
326
503
  const studies = /* @__PURE__ */ new Map();
327
504
  const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
328
505
  for (const path of [...walkFiles(dir)].sort()) {
329
- const parsed = parseFile(path);
330
- const studyUid = parsed?.record.StudyInstanceUID;
331
- const seriesUid = parsed?.record.SeriesInstanceUID;
332
- if (!parsed || typeof studyUid !== "string" || typeof seriesUid !== "string") {
506
+ let size;
507
+ try {
508
+ size = statSync(path).size;
509
+ } catch {
510
+ stats.skipped++;
511
+ continue;
512
+ }
513
+ if (size > MAX_STREAM_BYTES) {
514
+ stats.skipped++;
515
+ continue;
516
+ }
517
+ const large = size > MAX_PIXEL_BYTES;
518
+ const record = large ? readHeaderOnly(path) : parseFull(path);
519
+ const studyUid = record?.StudyInstanceUID;
520
+ const seriesUid = record?.SeriesInstanceUID;
521
+ if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
333
522
  stats.skipped++;
334
523
  continue;
335
524
  }
336
- const { record } = parsed;
337
525
  stats.dicomFiles++;
338
526
  let modality;
339
527
  if (typeof record.Modality === "string") {
@@ -343,7 +531,7 @@ function describeDirectory(dir, options = {}) {
343
531
  stats.unknownModality++;
344
532
  }
345
533
  }
346
- const sizeKb = Math.max(1, Math.round(parsed.size / 1024));
534
+ const sizeKb = Math.max(1, Math.round(size / 1024));
347
535
  const tags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
348
536
  let study = studies.get(studyUid);
349
537
  if (!study) {
@@ -355,25 +543,27 @@ function describeDirectory(dir, options = {}) {
355
543
  series = /* @__PURE__ */ new Map();
356
544
  study.set(seriesUid, series);
357
545
  }
358
- const collapseKey = JSON.stringify([modality ?? null, sizeKb, tags ?? null]);
546
+ const collapseKey = JSON.stringify([
547
+ large,
548
+ modality ?? null,
549
+ large ? size : sizeKb,
550
+ tags ?? null
551
+ ]);
359
552
  const existing = series.get(collapseKey);
360
553
  if (existing) {
361
554
  existing.count++;
362
555
  } else {
363
- series.set(collapseKey, {
364
- modality,
365
- targetSizeKb: sizeKb,
366
- tags,
367
- count: 1
368
- });
556
+ series.set(collapseKey, { modality, large, bytes: size, tags, count: 1 });
369
557
  }
370
558
  }
371
559
  const studySpecs = [...studies.values()].map((study) => {
372
560
  const seriesSpecs = [...study.values()].map((series) => {
373
561
  const entries = [...series.values()].map((acc) => ({
374
- type: "valid-image",
375
562
  ...acc.modality !== void 0 ? { modality: acc.modality } : {},
376
- targetSizeKb: acc.targetSizeKb,
563
+ ...acc.large ? { type: "large-image", targetBytes: acc.bytes } : {
564
+ type: "valid-image",
565
+ targetSizeKb: Math.max(1, Math.round(acc.bytes / 1024))
566
+ },
377
567
  ...acc.tags !== void 0 ? { tags: acc.tags } : {},
378
568
  ...acc.count > 1 ? { count: acc.count } : {}
379
569
  }));
@@ -389,5 +579,6 @@ function describeDirectory(dir, options = {}) {
389
579
  return { spec, stats };
390
580
  }
391
581
  export {
392
- describeDirectory
582
+ describeDirectory,
583
+ readHeaderOnly
393
584
  };