dicom-synth 1.15.0 → 1.17.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.
@@ -69,6 +69,92 @@ var VALID_VIOLATIONS = {
69
69
  };
70
70
  var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
71
71
  var KEYWORD_RE = /^[A-Z][A-Za-z0-9]*$/;
72
+ var VALID_VRS = /* @__PURE__ */ new Set([
73
+ "AE",
74
+ "AS",
75
+ "AT",
76
+ "CS",
77
+ "DA",
78
+ "DS",
79
+ "DT",
80
+ "FL",
81
+ "FD",
82
+ "IS",
83
+ "LO",
84
+ "LT",
85
+ "OB",
86
+ "OD",
87
+ "OF",
88
+ "OL",
89
+ "OV",
90
+ "OW",
91
+ "PN",
92
+ "SH",
93
+ "SL",
94
+ "SQ",
95
+ "SS",
96
+ "ST",
97
+ "SV",
98
+ "TM",
99
+ "UC",
100
+ "UI",
101
+ "UL",
102
+ "UN",
103
+ "UR",
104
+ "US",
105
+ "UT",
106
+ "UV"
107
+ ]);
108
+ function isPrivateHexTag(key) {
109
+ return HEX_TAG_RE.test(key) && Number.parseInt(key.slice(0, 4), 16) % 2 === 1;
110
+ }
111
+ function isRawTagValue(value) {
112
+ return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.vr === "string";
113
+ }
114
+ function validateRawTagValue(raw, path) {
115
+ for (const key of Object.keys(raw)) {
116
+ if (key !== "vr" && key !== "value") {
117
+ throw new Error(
118
+ `${path}: raw tag form accepts only "vr" and "value"; got "${key}"`
119
+ );
120
+ }
121
+ }
122
+ const vr = raw.vr;
123
+ if (!VALID_VRS.has(vr)) {
124
+ throw new Error(`${path}.vr: "${vr}" is not a standard DICOM VR`);
125
+ }
126
+ if (!("value" in raw)) {
127
+ throw new Error(`${path}.value: is required in the raw tag form`);
128
+ }
129
+ if (vr === "SQ") {
130
+ if (!Array.isArray(raw.value)) {
131
+ throw new Error(`${path}.value: an SQ raw tag requires an array of items`);
132
+ }
133
+ raw.value.forEach((item, i) => {
134
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
135
+ throw new Error(
136
+ `${path}.value[${i}]: must be an object of hex-tag entries`
137
+ );
138
+ }
139
+ for (const [tag, nested] of Object.entries(item)) {
140
+ if (!HEX_TAG_RE.test(tag)) {
141
+ throw new Error(
142
+ `${path}.value[${i}]: invalid item tag "${tag}" \u2014 SQ items use 8-hex-char tags`
143
+ );
144
+ }
145
+ if (!isRawTagValue(nested)) {
146
+ throw new Error(
147
+ `${path}.value[${i}].${tag}: SQ item entries must use the raw { vr, value } form`
148
+ );
149
+ }
150
+ validateRawTagValue(
151
+ nested,
152
+ `${path}.value[${i}].${tag}`
153
+ );
154
+ }
155
+ });
156
+ }
157
+ }
72
158
  function validatePositiveInt(value, path) {
73
159
  if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
74
160
  throw new Error(
@@ -273,13 +359,33 @@ function validateEntry(entry, path) {
273
359
  return e;
274
360
  }
275
361
  var MODALITY_TAG = "00080060";
276
- function validateTagValue(key, value, path) {
362
+ function validateModalityGuard(key, candidate, path) {
277
363
  const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
278
- if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
364
+ if (isModalityKey && typeof candidate === "string" && Object.hasOwn(VALID_MODALITIES, candidate)) {
279
365
  throw new Error(
280
366
  `${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`
281
367
  );
282
368
  }
369
+ }
370
+ function validateTagValue(key, value, path) {
371
+ if (isRawTagValue(value)) {
372
+ if (!HEX_TAG_RE.test(key)) {
373
+ throw new Error(
374
+ `${path}.${key}: the raw { vr, value } form requires an 8-hex-char tag key \u2014 keyword tags take plain values`
375
+ );
376
+ }
377
+ validateRawTagValue(value, `${path}.${key}`);
378
+ const raw = value.value;
379
+ const scalar = Array.isArray(raw) && raw.length === 1 ? raw[0] : raw;
380
+ validateModalityGuard(key, scalar, path);
381
+ return;
382
+ }
383
+ if (isPrivateHexTag(key)) {
384
+ throw new Error(
385
+ `${path}.${key}: private tags have no dictionary VR \u2014 use the raw form, e.g. { "vr": "LO", "value": \u2026 }`
386
+ );
387
+ }
388
+ validateModalityGuard(key, value, path);
283
389
  if (typeof value === "string") {
284
390
  for (const name of findTemplateTokens(value)) {
285
391
  if (!TEMPLATE_VOCAB.includes(name)) {
@@ -61,6 +61,92 @@ var VALID_VIOLATIONS = {
61
61
  };
62
62
  var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
63
63
  var KEYWORD_RE = /^[A-Z][A-Za-z0-9]*$/;
64
+ var VALID_VRS = /* @__PURE__ */ new Set([
65
+ "AE",
66
+ "AS",
67
+ "AT",
68
+ "CS",
69
+ "DA",
70
+ "DS",
71
+ "DT",
72
+ "FL",
73
+ "FD",
74
+ "IS",
75
+ "LO",
76
+ "LT",
77
+ "OB",
78
+ "OD",
79
+ "OF",
80
+ "OL",
81
+ "OV",
82
+ "OW",
83
+ "PN",
84
+ "SH",
85
+ "SL",
86
+ "SQ",
87
+ "SS",
88
+ "ST",
89
+ "SV",
90
+ "TM",
91
+ "UC",
92
+ "UI",
93
+ "UL",
94
+ "UN",
95
+ "UR",
96
+ "US",
97
+ "UT",
98
+ "UV"
99
+ ]);
100
+ function isPrivateHexTag(key) {
101
+ return HEX_TAG_RE.test(key) && Number.parseInt(key.slice(0, 4), 16) % 2 === 1;
102
+ }
103
+ function isRawTagValue(value) {
104
+ return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.vr === "string";
105
+ }
106
+ function validateRawTagValue(raw, path) {
107
+ for (const key of Object.keys(raw)) {
108
+ if (key !== "vr" && key !== "value") {
109
+ throw new Error(
110
+ `${path}: raw tag form accepts only "vr" and "value"; got "${key}"`
111
+ );
112
+ }
113
+ }
114
+ const vr = raw.vr;
115
+ if (!VALID_VRS.has(vr)) {
116
+ throw new Error(`${path}.vr: "${vr}" is not a standard DICOM VR`);
117
+ }
118
+ if (!("value" in raw)) {
119
+ throw new Error(`${path}.value: is required in the raw tag form`);
120
+ }
121
+ if (vr === "SQ") {
122
+ if (!Array.isArray(raw.value)) {
123
+ throw new Error(`${path}.value: an SQ raw tag requires an array of items`);
124
+ }
125
+ raw.value.forEach((item, i) => {
126
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
127
+ throw new Error(
128
+ `${path}.value[${i}]: must be an object of hex-tag entries`
129
+ );
130
+ }
131
+ for (const [tag, nested] of Object.entries(item)) {
132
+ if (!HEX_TAG_RE.test(tag)) {
133
+ throw new Error(
134
+ `${path}.value[${i}]: invalid item tag "${tag}" \u2014 SQ items use 8-hex-char tags`
135
+ );
136
+ }
137
+ if (!isRawTagValue(nested)) {
138
+ throw new Error(
139
+ `${path}.value[${i}].${tag}: SQ item entries must use the raw { vr, value } form`
140
+ );
141
+ }
142
+ validateRawTagValue(
143
+ nested,
144
+ `${path}.value[${i}].${tag}`
145
+ );
146
+ }
147
+ });
148
+ }
149
+ }
64
150
  function validatePositiveInt(value, path) {
65
151
  if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
66
152
  throw new Error(
@@ -265,13 +351,33 @@ function validateEntry(entry, path) {
265
351
  return e;
266
352
  }
267
353
  var MODALITY_TAG = "00080060";
268
- function validateTagValue(key, value, path) {
354
+ function validateModalityGuard(key, candidate, path) {
269
355
  const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
270
- if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
356
+ if (isModalityKey && typeof candidate === "string" && Object.hasOwn(VALID_MODALITIES, candidate)) {
271
357
  throw new Error(
272
358
  `${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`
273
359
  );
274
360
  }
361
+ }
362
+ function validateTagValue(key, value, path) {
363
+ if (isRawTagValue(value)) {
364
+ if (!HEX_TAG_RE.test(key)) {
365
+ throw new Error(
366
+ `${path}.${key}: the raw { vr, value } form requires an 8-hex-char tag key \u2014 keyword tags take plain values`
367
+ );
368
+ }
369
+ validateRawTagValue(value, `${path}.${key}`);
370
+ const raw = value.value;
371
+ const scalar = Array.isArray(raw) && raw.length === 1 ? raw[0] : raw;
372
+ validateModalityGuard(key, scalar, path);
373
+ return;
374
+ }
375
+ if (isPrivateHexTag(key)) {
376
+ throw new Error(
377
+ `${path}.${key}: private tags have no dictionary VR \u2014 use the raw form, e.g. { "vr": "LO", "value": \u2026 }`
378
+ );
379
+ }
380
+ validateModalityGuard(key, value, path);
275
381
  if (typeof value === "string") {
276
382
  for (const name of findTemplateTokens(value)) {
277
383
  if (!TEMPLATE_VOCAB.includes(name)) {
@@ -407,6 +513,9 @@ function validateStudy(study, path) {
407
513
  if ("tags" in st) validateTags(st.tags, `${path}.tags`);
408
514
  return st;
409
515
  }
516
+ function positionalName(prefix, ordinal) {
517
+ return `${prefix}-${String(ordinal).padStart(3, "0")}`;
518
+ }
410
519
  var VALID_TREE_ROLES = {
411
520
  study: true,
412
521
  series: true
@@ -487,7 +596,7 @@ function validateTreeChildren(children, basePath, inStudy, inSeries) {
487
596
  let name = "name" in n ? n.name : void 0;
488
597
  if ("children" in n) {
489
598
  dirOrdinal++;
490
- name ?? (name = `dir-${String(dirOrdinal).padStart(3, "0")}`);
599
+ name ?? (name = positionalName("dir", dirOrdinal));
491
600
  }
492
601
  if (name === void 0) return;
493
602
  const prior = claimed.get(name);
@@ -679,6 +788,8 @@ function validateParametricSpec(raw) {
679
788
  export {
680
789
  HEX_TAG_RE,
681
790
  isImageType,
791
+ isRawTagValue,
792
+ positionalName,
682
793
  validateDatasetSpec,
683
794
  validateParametricSpec
684
795
  };
@@ -45,6 +45,9 @@ var MAX_STREAM_BYTES = 50 * 1024 * 1024 * 1024;
45
45
 
46
46
  // src/schema/validate.ts
47
47
  var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
48
+ function isRawTagValue(value) {
49
+ return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.vr === "string";
50
+ }
48
51
 
49
52
  // src/syntheticFixtures/generator.ts
50
53
  var MODALITY_PRESETS = {
@@ -93,20 +96,51 @@ function buildTagToKeyword() {
93
96
  return map;
94
97
  }
95
98
  var tagToKeyword = buildTagToKeyword();
99
+ function toRawElement(raw) {
100
+ const value = raw.vr === "SQ" ? raw.value.map(
101
+ (item) => Object.fromEntries(
102
+ Object.entries(item).map(([tag, nested]) => [
103
+ tag.toUpperCase(),
104
+ toRawElement(nested)
105
+ ])
106
+ )
107
+ ) : raw.value;
108
+ return { vr: raw.vr, Value: Array.isArray(value) ? value : [value] };
109
+ }
96
110
  function applyTagOverrides(dataset, tags) {
97
- if (!tags) return;
111
+ if (!tags) return void 0;
112
+ let raw;
98
113
  for (const [key, value] of Object.entries(tags)) {
99
- if (HEX_TAG_RE.test(key)) {
114
+ if (isRawTagValue(value)) {
115
+ raw ?? (raw = {});
116
+ raw[key.toUpperCase()] = toRawElement(value);
117
+ } else if (HEX_TAG_RE.test(key)) {
100
118
  const keyword = tagToKeyword.get(key.toUpperCase());
101
- if (keyword) {
102
- dataset[keyword] = value;
103
- } else {
104
- console.warn(`dicom-synth: unknown hex tag "${key}" \u2014 skipping`);
119
+ if (!keyword) {
120
+ throw new Error(
121
+ `dicom-synth: unknown hex tag "${key}" \u2014 dictionary tags take plain values; private/unknown tags require the raw { vr, value } form`
122
+ );
105
123
  }
124
+ dataset[keyword] = value;
106
125
  } else {
107
126
  dataset[key] = value;
108
127
  }
109
128
  }
129
+ return raw;
130
+ }
131
+ function rawScalarString(rawElements, tag) {
132
+ const value = rawElements?.[tag]?.Value;
133
+ return value?.length === 1 && typeof value[0] === "string" ? value[0] : void 0;
134
+ }
135
+ function syncMetaWithDataset(meta, dataset, rawElements) {
136
+ const sopInstance = rawScalarString(rawElements, "00080018") ?? (typeof dataset.SOPInstanceUID === "string" ? dataset.SOPInstanceUID : void 0);
137
+ if (sopInstance !== void 0) {
138
+ meta.MediaStorageSOPInstanceUID = sopInstance;
139
+ }
140
+ const sopClass = rawScalarString(rawElements, "00080016") ?? (typeof dataset.SOPClassUID === "string" ? dataset.SOPClassUID : void 0);
141
+ if (sopClass !== void 0) {
142
+ meta.MediaStorageSOPClassUID = sopClass;
143
+ }
110
144
  }
111
145
  function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
112
146
  return {
@@ -145,7 +179,7 @@ function buildBaseImageDataset(uid, modality) {
145
179
  ...preset.attributes
146
180
  };
147
181
  }
148
- function serializeDict(meta, dataset) {
182
+ function serializeDict(meta, dataset, rawElements) {
149
183
  const dicomDict = new dcmjs.data.DicomDict(
150
184
  dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
151
185
  meta
@@ -154,14 +188,17 @@ function serializeDict(meta, dataset) {
154
188
  dicomDict.dict = dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
155
189
  dataset
156
190
  );
191
+ if (rawElements) {
192
+ Object.assign(dicomDict.dict, rawElements);
193
+ }
157
194
  return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
158
195
  }
159
196
  var MAX_DIMENSION = 65535;
160
- function applySizing(dataset, meta, spec) {
197
+ function applySizing(dataset, meta, spec, rawElements) {
161
198
  if (spec.targetSizeKb !== void 0) {
162
199
  const clampDim = (n) => Math.min(MAX_DIMENSION, Math.max(1, Math.round(n)));
163
200
  const minimalPixelBytes = dataset.PixelData.byteLength;
164
- const overhead = serializeDict(meta, dataset).length - minimalPixelBytes;
201
+ const overhead = serializeDict(meta, dataset, rawElements).length - minimalPixelBytes;
165
202
  const cells = Math.max(
166
203
  1,
167
204
  Math.round((spec.targetSizeKb * 1024 - overhead) / 2)
@@ -192,10 +229,11 @@ function buildImageBuffer(spec, uid) {
192
229
  dataset.Laterality = "";
193
230
  dataset.PatientWeight = "0";
194
231
  }
195
- applyTagOverrides(dataset, spec.tags);
232
+ const rawElements = applyTagOverrides(dataset, spec.tags);
196
233
  const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
197
- applySizing(dataset, meta, spec);
198
- return serializeDict(meta, dataset);
234
+ syncMetaWithDataset(meta, dataset, rawElements);
235
+ applySizing(dataset, meta, spec, rawElements);
236
+ return { buffer: serializeDict(meta, dataset, rawElements), rawElements };
199
237
  }
200
238
  function buildFakeSignatureBuffer() {
201
239
  const buf = Buffer.alloc(200, 0);
@@ -217,11 +255,11 @@ function buildBufferForSpec(spec, uid) {
217
255
  case "vendor-warnings-image":
218
256
  return buildImageBuffer(spec, uid);
219
257
  case "fake-signature":
220
- return buildFakeSignatureBuffer();
258
+ return { buffer: buildFakeSignatureBuffer() };
221
259
  case "non-dicom":
222
- return buildNonDicomBuffer(spec);
260
+ return { buffer: buildNonDicomBuffer(spec) };
223
261
  case "dicomdir":
224
- return buildDicomdirBuffer();
262
+ return { buffer: buildDicomdirBuffer() };
225
263
  case "large-image":
226
264
  throw new Error(
227
265
  "large-image cannot be generated in memory \u2014 use writeCollectionFromSpec (disk-only streaming)"
@@ -234,5 +272,6 @@ export {
234
272
  buildBufferForSpec,
235
273
  buildMeta,
236
274
  serializeDict,
237
- sizedNonDicomBytes
275
+ sizedNonDicomBytes,
276
+ syncMetaWithDataset
238
277
  };
@@ -31,6 +31,9 @@ import { resolve } from "node:path";
31
31
 
32
32
  // src/schema/validate.ts
33
33
  var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
34
+ function isRawTagValue(value) {
35
+ return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.vr === "string";
36
+ }
34
37
 
35
38
  // src/syntheticFixtures/generator.ts
36
39
  var MODALITY_PRESETS = {
@@ -79,20 +82,51 @@ function buildTagToKeyword() {
79
82
  return map;
80
83
  }
81
84
  var tagToKeyword = buildTagToKeyword();
85
+ function toRawElement(raw) {
86
+ const value = raw.vr === "SQ" ? raw.value.map(
87
+ (item) => Object.fromEntries(
88
+ Object.entries(item).map(([tag, nested]) => [
89
+ tag.toUpperCase(),
90
+ toRawElement(nested)
91
+ ])
92
+ )
93
+ ) : raw.value;
94
+ return { vr: raw.vr, Value: Array.isArray(value) ? value : [value] };
95
+ }
82
96
  function applyTagOverrides(dataset, tags) {
83
- if (!tags) return;
97
+ if (!tags) return void 0;
98
+ let raw;
84
99
  for (const [key, value] of Object.entries(tags)) {
85
- if (HEX_TAG_RE.test(key)) {
100
+ if (isRawTagValue(value)) {
101
+ raw ?? (raw = {});
102
+ raw[key.toUpperCase()] = toRawElement(value);
103
+ } else if (HEX_TAG_RE.test(key)) {
86
104
  const keyword = tagToKeyword.get(key.toUpperCase());
87
- if (keyword) {
88
- dataset[keyword] = value;
89
- } else {
90
- console.warn(`dicom-synth: unknown hex tag "${key}" \u2014 skipping`);
105
+ if (!keyword) {
106
+ throw new Error(
107
+ `dicom-synth: unknown hex tag "${key}" \u2014 dictionary tags take plain values; private/unknown tags require the raw { vr, value } form`
108
+ );
91
109
  }
110
+ dataset[keyword] = value;
92
111
  } else {
93
112
  dataset[key] = value;
94
113
  }
95
114
  }
115
+ return raw;
116
+ }
117
+ function rawScalarString(rawElements, tag) {
118
+ const value = rawElements?.[tag]?.Value;
119
+ return value?.length === 1 && typeof value[0] === "string" ? value[0] : void 0;
120
+ }
121
+ function syncMetaWithDataset(meta, dataset, rawElements) {
122
+ const sopInstance = rawScalarString(rawElements, "00080018") ?? (typeof dataset.SOPInstanceUID === "string" ? dataset.SOPInstanceUID : void 0);
123
+ if (sopInstance !== void 0) {
124
+ meta.MediaStorageSOPInstanceUID = sopInstance;
125
+ }
126
+ const sopClass = rawScalarString(rawElements, "00080016") ?? (typeof dataset.SOPClassUID === "string" ? dataset.SOPClassUID : void 0);
127
+ if (sopClass !== void 0) {
128
+ meta.MediaStorageSOPClassUID = sopClass;
129
+ }
96
130
  }
97
131
  function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
98
132
  return {
@@ -131,7 +165,7 @@ function buildBaseImageDataset(uid, modality) {
131
165
  ...preset.attributes
132
166
  };
133
167
  }
134
- function serializeDict(meta, dataset) {
168
+ function serializeDict(meta, dataset, rawElements) {
135
169
  const dicomDict = new dcmjs.data.DicomDict(
136
170
  dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
137
171
  meta
@@ -140,6 +174,9 @@ function serializeDict(meta, dataset) {
140
174
  dicomDict.dict = dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
141
175
  dataset
142
176
  );
177
+ if (rawElements) {
178
+ Object.assign(dicomDict.dict, rawElements);
179
+ }
143
180
  return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
144
181
  }
145
182
 
@@ -203,11 +240,12 @@ function writeNative(outPath, params, dims, zeroChunk) {
203
240
  const { modality, uid, tags } = params;
204
241
  const meta = buildMeta(uid, modality);
205
242
  const dataset = buildBaseImageDataset(uid, modality);
206
- applyTagOverrides(dataset, tags);
243
+ const rawElements = applyTagOverrides(dataset, tags);
244
+ syncMetaWithDataset(meta, dataset, rawElements);
207
245
  dataset.Rows = dims.rows;
208
246
  dataset.Columns = dims.columns;
209
247
  const minimalPixelBytes = dataset.PixelData.byteLength;
210
- const probe = serializeDict(meta, dataset);
248
+ const probe = serializeDict(meta, dataset, rawElements);
211
249
  const pixelElement = Buffer.alloc(12);
212
250
  PIXEL_DATA_OW_HEADER.copy(pixelElement);
213
251
  pixelElement.writeUInt32LE(minimalPixelBytes, 8);
@@ -236,10 +274,11 @@ function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk
236
274
  TransferSyntaxUID: RLE_TRANSFER_SYNTAX_UID
237
275
  };
238
276
  const dataset = buildBaseImageDataset(uid, modality);
239
- applyTagOverrides(dataset, tags);
277
+ const rawElements = applyTagOverrides(dataset, tags);
278
+ syncMetaWithDataset(meta, dataset, rawElements);
240
279
  dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
241
280
  dataset._vrMap = { PixelData: "OB" };
242
- const probe = serializeDict(meta, dataset);
281
+ const probe = serializeDict(meta, dataset, rawElements);
243
282
  const idx = probe.indexOf(PIXEL_DATA_OB_HEADER);
244
283
  if (idx < 0 || probe.readUInt32LE(idx + 8) !== 4294967295) {
245
284
  throw new Error("failed to locate encapsulated PixelData element header");
@@ -38,7 +38,7 @@ var VIOLATION_LEVEL = {
38
38
  "missing-meta-header": "byte",
39
39
  "malformed-sq-delimiter": "byte"
40
40
  };
41
- function applyTagLevel(buffer, violations) {
41
+ function applyTagLevel(buffer, violations, rawElements) {
42
42
  if (violations.length === 0) return buffer;
43
43
  let parsed;
44
44
  try {
@@ -72,6 +72,9 @@ function applyTagLevel(buffer, violations) {
72
72
  }
73
73
  }
74
74
  parsed.dict = dcmjsAny.data.DicomMetaDictionary.denaturalizeDataset(natural);
75
+ if (rawElements) {
76
+ Object.assign(parsed.dict, rawElements);
77
+ }
75
78
  if (violations.includes("uid-too-long")) {
76
79
  ;
77
80
  parsed.dict["00080018"] = {
@@ -104,11 +107,14 @@ function applyByteLevel(buffer, violations) {
104
107
  }
105
108
  return result;
106
109
  }
107
- function applyViolations(buffer, violations) {
110
+ function applyViolations(buffer, violations, rawElements) {
108
111
  if (violations.length === 0) return buffer;
109
112
  const tagViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "tag");
110
113
  const byteViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "byte");
111
- return applyByteLevel(applyTagLevel(buffer, tagViolations), byteViolations);
114
+ return applyByteLevel(
115
+ applyTagLevel(buffer, tagViolations, rawElements),
116
+ byteViolations
117
+ );
112
118
  }
113
119
  export {
114
120
  applyViolations
@@ -4,6 +4,7 @@ export type DescribeOptions = {
4
4
  sizeTolerance?: number;
5
5
  preserveUids?: boolean;
6
6
  uidSalt?: string;
7
+ captureTree?: boolean;
7
8
  };
8
9
  export type DescribeResult = {
9
10
  spec: DatasetSpec;
@@ -11,6 +12,7 @@ export type DescribeResult = {
11
12
  dicomFiles: number;
12
13
  skipped: number;
13
14
  unknownModality: number;
15
+ nonDicomFiles: number;
14
16
  };
15
17
  };
16
18
  export declare function readHeaderOnly(path: string): Record<string, unknown> | null;
@@ -4,7 +4,7 @@ export { defaultPublicCasesPath, loadCaseById, loadCasesFromJson, loadDefaultCas
4
4
  export { caseCachePath, fetchPublicCaseToCache, verifySha256, } from './public-fixtures/fetch.js';
5
5
  export { MAX_PIXEL_BYTES, MAX_STREAM_BYTES } from './schema/limits.js';
6
6
  export { resolveParametricSpec } from './schema/parametric.js';
7
- export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, EntrySpec, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidImageSpec, LargeFileSpec, Modality, NonDicomSpec, ParametricEdgeCase, ParametricSpec, ParametricStudies, PathQuirk, Range, SeriesEntrySpec, SeriesInstances, SeriesSpec, StudySpec, TransferSyntax, TreeDirNode, TreeFileNode, TreeNode, ValidImageSpec, VendorWarningsImageSpec, ViolationClass, } from './schema/types.js';
7
+ export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, EntrySpec, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidImageSpec, LargeFileSpec, Modality, NonDicomSpec, ParametricEdgeCase, ParametricSpec, ParametricStudies, PathQuirk, Range, RawTagValue, SeriesEntrySpec, SeriesInstances, SeriesSpec, StudySpec, TransferSyntax, TreeDirNode, TreeFileNode, TreeNode, ValidImageSpec, VendorWarningsImageSpec, ViolationClass, } from './schema/types.js';
8
8
  export { validateDatasetSpec, validateParametricSpec, } from './schema/validate.js';
9
9
  export { type LargeFileEncoding, type LargeFileResult, type LargeImageSpec, type StreamLargeImageOptions, streamLargeImage, writeLargeImageFile, } from './syntheticFixtures/streamWrite.js';
10
10
  export { resolveTagTemplates, type TagContext, TEMPLATE_VOCAB, } from './syntheticFixtures/tagTemplate.js';
@@ -1,4 +1,8 @@
1
1
  export type TransferSyntax = 'explicit-vr-little-endian' | 'implicit-vr-little-endian';
2
+ export type RawTagValue = {
3
+ vr: string;
4
+ value: unknown;
5
+ };
2
6
  export type DicomTagOverrides = {
3
7
  PatientID?: string;
4
8
  PatientName?: string;
@@ -1,5 +1,10 @@
1
1
  import type { DatasetSpec, FileSpec, ParametricSpec } from './types.js';
2
2
  export declare function isImageType(type: FileSpec['type']): boolean;
3
3
  export declare const HEX_TAG_RE: RegExp;
4
+ export declare function isRawTagValue(value: unknown): value is {
5
+ vr: string;
6
+ value: unknown;
7
+ };
8
+ export declare function positionalName(prefix: string, ordinal: number): string;
4
9
  export declare function validateDatasetSpec(raw: unknown): DatasetSpec;
5
10
  export declare function validateParametricSpec(raw: unknown): ParametricSpec;
@@ -1,8 +1,17 @@
1
1
  import type { DicomTagOverrides, FileSpec, Modality, NonDicomSpec, TransferSyntax } from '../schema/types.js';
2
2
  import type { UidSet } from './uid.js';
3
- export declare function applyTagOverrides(dataset: Record<string, unknown>, tags: DicomTagOverrides | undefined): void;
3
+ export type RawElement = {
4
+ vr: string;
5
+ Value: unknown[];
6
+ };
7
+ export type RawElementMap = Record<string, RawElement>;
8
+ export declare function applyTagOverrides(dataset: Record<string, unknown>, tags: DicomTagOverrides | undefined): RawElementMap | undefined;
9
+ export declare function syncMetaWithDataset(meta: Record<string, unknown>, dataset: Record<string, unknown>, rawElements?: RawElementMap): void;
4
10
  export declare function buildMeta(uid: UidSet, modality: Modality, transferSyntax?: TransferSyntax): Record<string, unknown>;
5
11
  export declare function buildBaseImageDataset(uid: UidSet, modality: Modality): Record<string, unknown>;
6
- export declare function serializeDict(meta: Record<string, unknown>, dataset: Record<string, unknown>): Buffer;
12
+ export declare function serializeDict(meta: Record<string, unknown>, dataset: Record<string, unknown>, rawElements?: RawElementMap): Buffer;
7
13
  export declare function sizedNonDicomBytes(spec: NonDicomSpec): number | undefined;
8
- export declare function buildBufferForSpec(spec: FileSpec, uid: UidSet): Buffer;
14
+ export declare function buildBufferForSpec(spec: FileSpec, uid: UidSet): {
15
+ buffer: Buffer;
16
+ rawElements?: RawElementMap;
17
+ };
@@ -1,2 +1,3 @@
1
1
  import type { ViolationClass } from '../schema/types.js';
2
- export declare function applyViolations(buffer: Buffer, violations: ViolationClass[]): Buffer;
2
+ import type { RawElementMap } from './generator.js';
3
+ export declare function applyViolations(buffer: Buffer, violations: ViolationClass[], rawElements?: RawElementMap): Buffer;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dicom-synth",
3
- "version": "1.15.0",
3
+ "version": "1.17.0",
4
4
  "description": "Toolkit for synthetic DICOM fixtures and public fixture fetch/cache.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -49,6 +49,7 @@
49
49
  "build:data": "mkdir -p dist/data && cp data/public-cases.json dist/data/",
50
50
  "typecheck": "tsc --noEmit",
51
51
  "test": "vitest run",
52
+ "test:coverage": "vitest run --coverage",
52
53
  "test:watch": "vitest",
53
54
  "lint": "eslint src test scripts --ext .ts",
54
55
  "lint:fix": "eslint src test scripts --ext .ts --fix",
@@ -82,6 +83,7 @@
82
83
  "@types/node": "^22.15.3",
83
84
  "@typescript-eslint/eslint-plugin": "^8.46.1",
84
85
  "@typescript-eslint/parser": "^8.46.1",
86
+ "@vitest/coverage-v8": "3.2.6",
85
87
  "dcmjs": "^0.51.1",
86
88
  "esbuild": "^0.28.1",
87
89
  "eslint": "^9.38.0",