dicom-synth 1.16.0 → 1.18.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.
@@ -6,7 +6,7 @@ import { describeDirectory } from '../dist/esm/index.js'
6
6
 
7
7
  function usage() {
8
8
  console.error(
9
- 'Usage: dicom-synth-describe <dir> [--out <spec.json[.gz]>] [--tree] [--keep-tags <a,b,...>] [--keep-tags-file <path>] [--size-tolerance <fraction>] [--no-preserve-uids] [--uid-salt <hex>] [--gzip]\n' +
9
+ 'Usage: dicom-synth-describe <dir> [--out <spec.json[.gz]>] [--tree] [--keep-tags <a,b,...>] [--keep-tags-file <path>] [--keep-private-tags] [--size-tolerance <fraction>] [--no-preserve-uids] [--uid-salt <hex>] [--gzip]\n' +
10
10
  '\n' +
11
11
  'Scans a DICOM tree and emits a DatasetSpec describing its shape\n' +
12
12
  '(studies/series, per-file size, modality).\n' +
@@ -22,6 +22,13 @@ function usage() {
22
22
  "responsibility, not this tool's.\n" +
23
23
  '--keep-tags-file reads the same allow-list from a file (one tag per\n' +
24
24
  'line, # comments); merged with any --keep-tags.\n' +
25
+ '--keep-private-tags captures ALL private (odd-group) elements as-is as\n' +
26
+ 'raw { vr, value } tag forms — creators, scalars, nested private\n' +
27
+ 'sequences. Private blocks routinely carry PHI/device detail: handling\n' +
28
+ "that is the caller's responsibility. UID-valued leaves are still\n" +
29
+ 'hashed unless --no-preserve-uids. Individual private tags can instead\n' +
30
+ 'be named (8-hex) in --keep-tags. Binary/unparseable payloads are\n' +
31
+ 'skipped and counted.\n' +
25
32
  '--size-tolerance folds near-identical file sizes (fraction, e.g. 0.05\n' +
26
33
  '= ±5%) into one entry; default exact.\n' +
27
34
  '--no-preserve-uids emits shape only with freshly minted UIDs (no link\n' +
@@ -52,6 +59,7 @@ let preserveUids = true
52
59
  let uidSalt
53
60
  let gzip = false
54
61
  let captureTree = false
62
+ let keepPrivateTags = false
55
63
  const keepTags = []
56
64
 
57
65
  for (let i = 0; i < args.length; i++) {
@@ -77,6 +85,8 @@ for (let i = 0; i < args.length; i++) {
77
85
  console.error('--size-tolerance must be a non-negative number')
78
86
  process.exit(1)
79
87
  }
88
+ } else if (args[i] === '--keep-private-tags') {
89
+ keepPrivateTags = true
80
90
  } else if (args[i] === '--no-preserve-uids') {
81
91
  preserveUids = false
82
92
  } else if (args[i] === '--tree') {
@@ -107,6 +117,7 @@ let result
107
117
  try {
108
118
  result = describeDirectory(dir, {
109
119
  ...(keepTags.length ? { keepTags } : {}),
120
+ ...(keepPrivateTags ? { keepPrivateTags: true } : {}),
110
121
  ...(sizeTolerance !== undefined ? { sizeTolerance } : {}),
111
122
  preserveUids,
112
123
  ...(uidSalt !== undefined ? { uidSalt } : {}),
@@ -56,6 +56,9 @@ function isImageType(type) {
56
56
  return TYPE_CAPABILITIES[type].image;
57
57
  }
58
58
  var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
59
+ function isRawTagValue(value) {
60
+ return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.vr === "string";
61
+ }
59
62
  function positionalName(prefix, ordinal) {
60
63
  return `${prefix}-${String(ordinal).padStart(3, "0")}`;
61
64
  }
@@ -148,27 +151,50 @@ function buildTagToKeyword() {
148
151
  return map;
149
152
  }
150
153
  var tagToKeyword = buildTagToKeyword();
154
+ function toRawElement(raw) {
155
+ const value = raw.vr === "SQ" ? raw.value.map(
156
+ (item) => Object.fromEntries(
157
+ Object.entries(item).map(([tag, nested]) => [
158
+ tag.toUpperCase(),
159
+ toRawElement(nested)
160
+ ])
161
+ )
162
+ ) : raw.value;
163
+ return { vr: raw.vr, Value: Array.isArray(value) ? value : [value] };
164
+ }
151
165
  function applyTagOverrides(dataset, tags) {
152
- if (!tags) return;
166
+ if (!tags) return void 0;
167
+ let raw;
153
168
  for (const [key, value] of Object.entries(tags)) {
154
- if (HEX_TAG_RE.test(key)) {
169
+ if (isRawTagValue(value)) {
170
+ raw ?? (raw = {});
171
+ raw[key.toUpperCase()] = toRawElement(value);
172
+ } else if (HEX_TAG_RE.test(key)) {
155
173
  const keyword = tagToKeyword.get(key.toUpperCase());
156
- if (keyword) {
157
- dataset[keyword] = value;
158
- } else {
159
- console.warn(`dicom-synth: unknown hex tag "${key}" \u2014 skipping`);
174
+ if (!keyword) {
175
+ throw new Error(
176
+ `dicom-synth: unknown hex tag "${key}" \u2014 dictionary tags take plain values; private/unknown tags require the raw { vr, value } form`
177
+ );
160
178
  }
179
+ dataset[keyword] = value;
161
180
  } else {
162
181
  dataset[key] = value;
163
182
  }
164
183
  }
184
+ return raw;
165
185
  }
166
- function syncMetaWithDataset(meta, dataset) {
167
- if (typeof dataset.SOPInstanceUID === "string") {
168
- meta.MediaStorageSOPInstanceUID = dataset.SOPInstanceUID;
186
+ function rawScalarString(rawElements, tag) {
187
+ const value = rawElements?.[tag]?.Value;
188
+ return value?.length === 1 && typeof value[0] === "string" ? value[0] : void 0;
189
+ }
190
+ function syncMetaWithDataset(meta, dataset, rawElements) {
191
+ const sopInstance = rawScalarString(rawElements, "00080018") ?? (typeof dataset.SOPInstanceUID === "string" ? dataset.SOPInstanceUID : void 0);
192
+ if (sopInstance !== void 0) {
193
+ meta.MediaStorageSOPInstanceUID = sopInstance;
169
194
  }
170
- if (typeof dataset.SOPClassUID === "string") {
171
- meta.MediaStorageSOPClassUID = dataset.SOPClassUID;
195
+ const sopClass = rawScalarString(rawElements, "00080016") ?? (typeof dataset.SOPClassUID === "string" ? dataset.SOPClassUID : void 0);
196
+ if (sopClass !== void 0) {
197
+ meta.MediaStorageSOPClassUID = sopClass;
172
198
  }
173
199
  }
174
200
  function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
@@ -208,7 +234,7 @@ function buildBaseImageDataset(uid, modality) {
208
234
  ...preset.attributes
209
235
  };
210
236
  }
211
- function serializeDict(meta, dataset) {
237
+ function serializeDict(meta, dataset, rawElements) {
212
238
  const dicomDict = new dcmjs.data.DicomDict(
213
239
  dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
214
240
  meta
@@ -217,14 +243,17 @@ function serializeDict(meta, dataset) {
217
243
  dicomDict.dict = dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
218
244
  dataset
219
245
  );
246
+ if (rawElements) {
247
+ Object.assign(dicomDict.dict, rawElements);
248
+ }
220
249
  return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
221
250
  }
222
251
  var MAX_DIMENSION = 65535;
223
- function applySizing(dataset, meta, spec) {
252
+ function applySizing(dataset, meta, spec, rawElements) {
224
253
  if (spec.targetSizeKb !== void 0) {
225
254
  const clampDim = (n) => Math.min(MAX_DIMENSION, Math.max(1, Math.round(n)));
226
255
  const minimalPixelBytes = dataset.PixelData.byteLength;
227
- const overhead = serializeDict(meta, dataset).length - minimalPixelBytes;
256
+ const overhead = serializeDict(meta, dataset, rawElements).length - minimalPixelBytes;
228
257
  const cells = Math.max(
229
258
  1,
230
259
  Math.round((spec.targetSizeKb * 1024 - overhead) / 2)
@@ -255,11 +284,11 @@ function buildImageBuffer(spec, uid) {
255
284
  dataset.Laterality = "";
256
285
  dataset.PatientWeight = "0";
257
286
  }
258
- applyTagOverrides(dataset, spec.tags);
287
+ const rawElements = applyTagOverrides(dataset, spec.tags);
259
288
  const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
260
- syncMetaWithDataset(meta, dataset);
261
- applySizing(dataset, meta, spec);
262
- return serializeDict(meta, dataset);
289
+ syncMetaWithDataset(meta, dataset, rawElements);
290
+ applySizing(dataset, meta, spec, rawElements);
291
+ return { buffer: serializeDict(meta, dataset, rawElements), rawElements };
263
292
  }
264
293
  function buildFakeSignatureBuffer() {
265
294
  const buf = Buffer.alloc(200, 0);
@@ -281,11 +310,11 @@ function buildBufferForSpec(spec, uid) {
281
310
  case "vendor-warnings-image":
282
311
  return buildImageBuffer(spec, uid);
283
312
  case "fake-signature":
284
- return buildFakeSignatureBuffer();
313
+ return { buffer: buildFakeSignatureBuffer() };
285
314
  case "non-dicom":
286
- return buildNonDicomBuffer(spec);
315
+ return { buffer: buildNonDicomBuffer(spec) };
287
316
  case "dicomdir":
288
- return buildDicomdirBuffer();
317
+ return { buffer: buildDicomdirBuffer() };
289
318
  case "large-image":
290
319
  throw new Error(
291
320
  "large-image cannot be generated in memory \u2014 use writeCollectionFromSpec (disk-only streaming)"
@@ -366,12 +395,12 @@ function writeNative(outPath, params, dims, zeroChunk) {
366
395
  const { modality, uid, tags } = params;
367
396
  const meta = buildMeta(uid, modality);
368
397
  const dataset = buildBaseImageDataset(uid, modality);
369
- applyTagOverrides(dataset, tags);
370
- syncMetaWithDataset(meta, dataset);
398
+ const rawElements = applyTagOverrides(dataset, tags);
399
+ syncMetaWithDataset(meta, dataset, rawElements);
371
400
  dataset.Rows = dims.rows;
372
401
  dataset.Columns = dims.columns;
373
402
  const minimalPixelBytes = dataset.PixelData.byteLength;
374
- const probe = serializeDict(meta, dataset);
403
+ const probe = serializeDict(meta, dataset, rawElements);
375
404
  const pixelElement = Buffer.alloc(12);
376
405
  PIXEL_DATA_OW_HEADER.copy(pixelElement);
377
406
  pixelElement.writeUInt32LE(minimalPixelBytes, 8);
@@ -400,11 +429,11 @@ function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk
400
429
  TransferSyntaxUID: RLE_TRANSFER_SYNTAX_UID
401
430
  };
402
431
  const dataset = buildBaseImageDataset(uid, modality);
403
- applyTagOverrides(dataset, tags);
404
- syncMetaWithDataset(meta, dataset);
432
+ const rawElements = applyTagOverrides(dataset, tags);
433
+ syncMetaWithDataset(meta, dataset, rawElements);
405
434
  dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
406
435
  dataset._vrMap = { PixelData: "OB" };
407
- const probe = serializeDict(meta, dataset);
436
+ const probe = serializeDict(meta, dataset, rawElements);
408
437
  const idx = probe.indexOf(PIXEL_DATA_OB_HEADER);
409
438
  if (idx < 0 || probe.readUInt32LE(idx + 8) !== 4294967295) {
410
439
  throw new Error("failed to locate encapsulated PixelData element header");
@@ -501,7 +530,7 @@ var VIOLATION_LEVEL = {
501
530
  "missing-meta-header": "byte",
502
531
  "malformed-sq-delimiter": "byte"
503
532
  };
504
- function applyTagLevel(buffer, violations) {
533
+ function applyTagLevel(buffer, violations, rawElements) {
505
534
  if (violations.length === 0) return buffer;
506
535
  let parsed;
507
536
  try {
@@ -535,6 +564,9 @@ function applyTagLevel(buffer, violations) {
535
564
  }
536
565
  }
537
566
  parsed.dict = dcmjsAny.data.DicomMetaDictionary.denaturalizeDataset(natural);
567
+ if (rawElements) {
568
+ Object.assign(parsed.dict, rawElements);
569
+ }
538
570
  if (violations.includes("uid-too-long")) {
539
571
  ;
540
572
  parsed.dict["00080018"] = {
@@ -567,11 +599,14 @@ function applyByteLevel(buffer, violations) {
567
599
  }
568
600
  return result;
569
601
  }
570
- function applyViolations(buffer, violations) {
602
+ function applyViolations(buffer, violations, rawElements) {
571
603
  if (violations.length === 0) return buffer;
572
604
  const tagViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "tag");
573
605
  const byteViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "byte");
574
- return applyByteLevel(applyTagLevel(buffer, tagViolations), byteViolations);
606
+ return applyByteLevel(
607
+ applyTagLevel(buffer, tagViolations, rawElements),
608
+ byteViolations
609
+ );
575
610
  }
576
611
 
577
612
  // src/collection/writer.ts
@@ -645,10 +680,11 @@ async function generateFile(spec, options) {
645
680
  ...spec,
646
681
  tags: resolveTagTemplates(spec.tags, { index, ...options?.context })
647
682
  } : spec;
648
- let buffer = buildBufferForSpec(resolvedSpec, uid);
683
+ const built = buildBufferForSpec(resolvedSpec, uid);
684
+ let buffer = built.buffer;
649
685
  const violations = "violations" in spec ? spec.violations : void 0;
650
686
  if (violations?.length) {
651
- buffer = applyViolations(buffer, violations);
687
+ buffer = applyViolations(buffer, violations, built.rawElements);
652
688
  }
653
689
  const name = filename(spec.type, index, padWidth);
654
690
  return {