dicom-synth 1.8.0 → 1.9.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,16 @@ 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
+
24
35
  // src/schema/validate.ts
25
36
  var TYPE_CAPABILITIES = {
26
37
  "valid-image": { image: true },
27
38
  "invalid-uid-image": { image: true },
28
39
  "vendor-warnings-image": { image: true },
40
+ "large-image": { image: true },
29
41
  "fake-signature": { image: false },
30
42
  "non-dicom": { image: false },
31
43
  dicomdir: { image: false }
@@ -50,7 +62,6 @@ var VALID_TRANSFER_SYNTAXES = {
50
62
  "explicit-vr-little-endian": true,
51
63
  "implicit-vr-little-endian": true
52
64
  };
53
- var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
54
65
  var VALID_VIOLATIONS = {
55
66
  "uid-too-long": true,
56
67
  "non-conformant-uid": true,
@@ -80,6 +91,43 @@ function validateSizeKbLimit(kb, path) {
80
91
  throw new Error(`${path}: exceeds the 512 MB limit`);
81
92
  }
82
93
  }
94
+ function validateLargeTargetBytes(value, path) {
95
+ if (typeof value !== "number" || !Number.isInteger(value)) {
96
+ throw new Error(`${path}: must be an integer; got ${JSON.stringify(value)}`);
97
+ }
98
+ if (value <= MAX_PIXEL_BYTES) {
99
+ throw new Error(
100
+ `${path}: must exceed 512 MB \u2014 use targetSizeKb for smaller files`
101
+ );
102
+ }
103
+ if (value > MAX_STREAM_BYTES) {
104
+ throw new Error(`${path}: exceeds the 50 GB limit`);
105
+ }
106
+ }
107
+ function validateLargeImageEntry(e, path) {
108
+ for (const field of [
109
+ "rows",
110
+ "columns",
111
+ "frames",
112
+ "targetSizeKb",
113
+ "violations",
114
+ "transferSyntax"
115
+ ]) {
116
+ if (field in e) {
117
+ throw new Error(
118
+ `${path}.${field}: not supported for type "large-image" \u2014 use targetBytes`
119
+ );
120
+ }
121
+ }
122
+ if (!("targetBytes" in e)) {
123
+ throw new Error(`${path}.targetBytes: is required for type "large-image"`);
124
+ }
125
+ validateLargeTargetBytes(e.targetBytes, `${path}.targetBytes`);
126
+ if ("modality" in e) {
127
+ validateEnum(e.modality, VALID_MODALITIES, `${path}.modality`);
128
+ }
129
+ if ("tags" in e) validateTags(e.tags, `${path}.tags`);
130
+ }
83
131
  function validateEnum(value, valid, path) {
84
132
  if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
85
133
  throw new Error(
@@ -103,6 +151,15 @@ function validateEntry(entry, path) {
103
151
  const type = e.type;
104
152
  const caps = TYPE_CAPABILITIES[type];
105
153
  if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
154
+ if (type === "large-image") {
155
+ validateLargeImageEntry(e, path);
156
+ return e;
157
+ }
158
+ if ("targetBytes" in e) {
159
+ throw new Error(
160
+ `${path}.targetBytes: only supported for type "large-image"`
161
+ );
162
+ }
106
163
  for (const field of [
107
164
  "modality",
108
165
  "rows",
@@ -293,18 +350,77 @@ function* walkFiles(dir) {
293
350
  }
294
351
  }
295
352
  }
296
- function parseFile(path) {
353
+ function toArrayBuffer(buf) {
354
+ return buf.buffer.slice(
355
+ buf.byteOffset,
356
+ buf.byteOffset + buf.byteLength
357
+ );
358
+ }
359
+ function naturalize(ab) {
360
+ const parsed = dcmjsAny.data.DicomMessage.readFile(ab);
361
+ return dcmjsAny.data.DicomMetaDictionary.naturalizeDataset(parsed.dict);
362
+ }
363
+ function parseFull(path) {
297
364
  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 };
365
+ return naturalize(toArrayBuffer(readFileSync(path)));
366
+ } catch {
367
+ return null;
368
+ }
369
+ }
370
+ var PIXEL_DATA_OW = Buffer.from([224, 127, 16, 0, 79, 87]);
371
+ var PIXEL_DATA_OB = Buffer.from([224, 127, 16, 0, 79, 66]);
372
+ var EMPTY_OW_PIXEL_DATA = Buffer.from([
373
+ 224,
374
+ 127,
375
+ 16,
376
+ 0,
377
+ 79,
378
+ 87,
379
+ 0,
380
+ 0,
381
+ 0,
382
+ 0,
383
+ 0,
384
+ 0
385
+ ]);
386
+ var HEADER_READ_BYTES = 2 * 1024 * 1024;
387
+ function findPixelDataOffset(prefix) {
388
+ let best = -1;
389
+ for (const pattern of [PIXEL_DATA_OW, PIXEL_DATA_OB]) {
390
+ for (let from = 0; ; ) {
391
+ const i = prefix.indexOf(pattern, from);
392
+ if (i < 0) break;
393
+ if (prefix[i + 6] === 0 && prefix[i + 7] === 0) {
394
+ if (best < 0 || i < best) best = i;
395
+ break;
396
+ }
397
+ from = i + 1;
398
+ }
399
+ }
400
+ return best;
401
+ }
402
+ function readHeaderOnly(path) {
403
+ let prefix;
404
+ try {
405
+ const fd = openSync(path, "r");
406
+ try {
407
+ const buf = Buffer.alloc(HEADER_READ_BYTES);
408
+ const n = readSync(fd, buf, 0, HEADER_READ_BYTES, 0);
409
+ prefix = buf.subarray(0, n);
410
+ } finally {
411
+ closeSync(fd);
412
+ }
413
+ } catch {
414
+ return null;
415
+ }
416
+ const tagIdx = findPixelDataOffset(prefix);
417
+ if (tagIdx < 0) return null;
418
+ const headerOnly = Buffer.concat([
419
+ prefix.subarray(0, tagIdx),
420
+ EMPTY_OW_PIXEL_DATA
421
+ ]);
422
+ try {
423
+ return naturalize(toArrayBuffer(headerOnly));
308
424
  } catch {
309
425
  return null;
310
426
  }
@@ -326,14 +442,25 @@ function describeDirectory(dir, options = {}) {
326
442
  const studies = /* @__PURE__ */ new Map();
327
443
  const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
328
444
  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") {
445
+ let size;
446
+ try {
447
+ size = statSync(path).size;
448
+ } catch {
449
+ stats.skipped++;
450
+ continue;
451
+ }
452
+ if (size > MAX_STREAM_BYTES) {
453
+ stats.skipped++;
454
+ continue;
455
+ }
456
+ const large = size > MAX_PIXEL_BYTES;
457
+ const record = large ? readHeaderOnly(path) : parseFull(path);
458
+ const studyUid = record?.StudyInstanceUID;
459
+ const seriesUid = record?.SeriesInstanceUID;
460
+ if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
333
461
  stats.skipped++;
334
462
  continue;
335
463
  }
336
- const { record } = parsed;
337
464
  stats.dicomFiles++;
338
465
  let modality;
339
466
  if (typeof record.Modality === "string") {
@@ -343,7 +470,7 @@ function describeDirectory(dir, options = {}) {
343
470
  stats.unknownModality++;
344
471
  }
345
472
  }
346
- const sizeKb = Math.max(1, Math.round(parsed.size / 1024));
473
+ const sizeKb = Math.max(1, Math.round(size / 1024));
347
474
  const tags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
348
475
  let study = studies.get(studyUid);
349
476
  if (!study) {
@@ -355,25 +482,27 @@ function describeDirectory(dir, options = {}) {
355
482
  series = /* @__PURE__ */ new Map();
356
483
  study.set(seriesUid, series);
357
484
  }
358
- const collapseKey = JSON.stringify([modality ?? null, sizeKb, tags ?? null]);
485
+ const collapseKey = JSON.stringify([
486
+ large,
487
+ modality ?? null,
488
+ large ? size : sizeKb,
489
+ tags ?? null
490
+ ]);
359
491
  const existing = series.get(collapseKey);
360
492
  if (existing) {
361
493
  existing.count++;
362
494
  } else {
363
- series.set(collapseKey, {
364
- modality,
365
- targetSizeKb: sizeKb,
366
- tags,
367
- count: 1
368
- });
495
+ series.set(collapseKey, { modality, large, bytes: size, tags, count: 1 });
369
496
  }
370
497
  }
371
498
  const studySpecs = [...studies.values()].map((study) => {
372
499
  const seriesSpecs = [...study.values()].map((series) => {
373
500
  const entries = [...series.values()].map((acc) => ({
374
- type: "valid-image",
375
501
  ...acc.modality !== void 0 ? { modality: acc.modality } : {},
376
- targetSizeKb: acc.targetSizeKb,
502
+ ...acc.large ? { type: "large-image", targetBytes: acc.bytes } : {
503
+ type: "valid-image",
504
+ targetSizeKb: Math.max(1, Math.round(acc.bytes / 1024))
505
+ },
377
506
  ...acc.tags !== void 0 ? { tags: acc.tags } : {},
378
507
  ...acc.count > 1 ? { count: acc.count } : {}
379
508
  }));
@@ -389,5 +518,6 @@ function describeDirectory(dir, options = {}) {
389
518
  return { spec, stats };
390
519
  }
391
520
  export {
392
- describeDirectory
521
+ describeDirectory,
522
+ readHeaderOnly
393
523
  };