dicom-synth 1.16.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.
@@ -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 {
@@ -92,6 +92,92 @@ var VALID_VIOLATIONS = {
92
92
  };
93
93
  var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
94
94
  var KEYWORD_RE = /^[A-Z][A-Za-z0-9]*$/;
95
+ var VALID_VRS = /* @__PURE__ */ new Set([
96
+ "AE",
97
+ "AS",
98
+ "AT",
99
+ "CS",
100
+ "DA",
101
+ "DS",
102
+ "DT",
103
+ "FL",
104
+ "FD",
105
+ "IS",
106
+ "LO",
107
+ "LT",
108
+ "OB",
109
+ "OD",
110
+ "OF",
111
+ "OL",
112
+ "OV",
113
+ "OW",
114
+ "PN",
115
+ "SH",
116
+ "SL",
117
+ "SQ",
118
+ "SS",
119
+ "ST",
120
+ "SV",
121
+ "TM",
122
+ "UC",
123
+ "UI",
124
+ "UL",
125
+ "UN",
126
+ "UR",
127
+ "US",
128
+ "UT",
129
+ "UV"
130
+ ]);
131
+ function isPrivateHexTag(key) {
132
+ return HEX_TAG_RE.test(key) && Number.parseInt(key.slice(0, 4), 16) % 2 === 1;
133
+ }
134
+ function isRawTagValue(value) {
135
+ return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.vr === "string";
136
+ }
137
+ function validateRawTagValue(raw, path) {
138
+ for (const key of Object.keys(raw)) {
139
+ if (key !== "vr" && key !== "value") {
140
+ throw new Error(
141
+ `${path}: raw tag form accepts only "vr" and "value"; got "${key}"`
142
+ );
143
+ }
144
+ }
145
+ const vr = raw.vr;
146
+ if (!VALID_VRS.has(vr)) {
147
+ throw new Error(`${path}.vr: "${vr}" is not a standard DICOM VR`);
148
+ }
149
+ if (!("value" in raw)) {
150
+ throw new Error(`${path}.value: is required in the raw tag form`);
151
+ }
152
+ if (vr === "SQ") {
153
+ if (!Array.isArray(raw.value)) {
154
+ throw new Error(`${path}.value: an SQ raw tag requires an array of items`);
155
+ }
156
+ raw.value.forEach((item, i) => {
157
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
158
+ throw new Error(
159
+ `${path}.value[${i}]: must be an object of hex-tag entries`
160
+ );
161
+ }
162
+ for (const [tag, nested] of Object.entries(item)) {
163
+ if (!HEX_TAG_RE.test(tag)) {
164
+ throw new Error(
165
+ `${path}.value[${i}]: invalid item tag "${tag}" \u2014 SQ items use 8-hex-char tags`
166
+ );
167
+ }
168
+ if (!isRawTagValue(nested)) {
169
+ throw new Error(
170
+ `${path}.value[${i}].${tag}: SQ item entries must use the raw { vr, value } form`
171
+ );
172
+ }
173
+ validateRawTagValue(
174
+ nested,
175
+ `${path}.value[${i}].${tag}`
176
+ );
177
+ }
178
+ });
179
+ }
180
+ }
95
181
  function validatePositiveInt(value, path) {
96
182
  if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
97
183
  throw new Error(
@@ -289,13 +375,33 @@ function validateEntry(entry, path) {
289
375
  return e;
290
376
  }
291
377
  var MODALITY_TAG = "00080060";
292
- function validateTagValue(key, value, path) {
378
+ function validateModalityGuard(key, candidate, path) {
293
379
  const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
294
- if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
380
+ if (isModalityKey && typeof candidate === "string" && Object.hasOwn(VALID_MODALITIES, candidate)) {
295
381
  throw new Error(
296
382
  `${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`
297
383
  );
298
384
  }
385
+ }
386
+ function validateTagValue(key, value, path) {
387
+ if (isRawTagValue(value)) {
388
+ if (!HEX_TAG_RE.test(key)) {
389
+ throw new Error(
390
+ `${path}.${key}: the raw { vr, value } form requires an 8-hex-char tag key \u2014 keyword tags take plain values`
391
+ );
392
+ }
393
+ validateRawTagValue(value, `${path}.${key}`);
394
+ const raw = value.value;
395
+ const scalar = Array.isArray(raw) && raw.length === 1 ? raw[0] : raw;
396
+ validateModalityGuard(key, scalar, path);
397
+ return;
398
+ }
399
+ if (isPrivateHexTag(key)) {
400
+ throw new Error(
401
+ `${path}.${key}: private tags have no dictionary VR \u2014 use the raw form, e.g. { "vr": "LO", "value": \u2026 }`
402
+ );
403
+ }
404
+ validateModalityGuard(key, value, path);
299
405
  if (typeof value === "string") {
300
406
  for (const name of findTemplateTokens(value)) {
301
407
  if (!TEMPLATE_VOCAB.includes(name)) {
package/dist/esm/index.js CHANGED
@@ -95,6 +95,92 @@ var VALID_VIOLATIONS = {
95
95
  };
96
96
  var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
97
97
  var KEYWORD_RE = /^[A-Z][A-Za-z0-9]*$/;
98
+ var VALID_VRS = /* @__PURE__ */ new Set([
99
+ "AE",
100
+ "AS",
101
+ "AT",
102
+ "CS",
103
+ "DA",
104
+ "DS",
105
+ "DT",
106
+ "FL",
107
+ "FD",
108
+ "IS",
109
+ "LO",
110
+ "LT",
111
+ "OB",
112
+ "OD",
113
+ "OF",
114
+ "OL",
115
+ "OV",
116
+ "OW",
117
+ "PN",
118
+ "SH",
119
+ "SL",
120
+ "SQ",
121
+ "SS",
122
+ "ST",
123
+ "SV",
124
+ "TM",
125
+ "UC",
126
+ "UI",
127
+ "UL",
128
+ "UN",
129
+ "UR",
130
+ "US",
131
+ "UT",
132
+ "UV"
133
+ ]);
134
+ function isPrivateHexTag(key) {
135
+ return HEX_TAG_RE.test(key) && Number.parseInt(key.slice(0, 4), 16) % 2 === 1;
136
+ }
137
+ function isRawTagValue(value) {
138
+ return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.vr === "string";
139
+ }
140
+ function validateRawTagValue(raw, path) {
141
+ for (const key of Object.keys(raw)) {
142
+ if (key !== "vr" && key !== "value") {
143
+ throw new Error(
144
+ `${path}: raw tag form accepts only "vr" and "value"; got "${key}"`
145
+ );
146
+ }
147
+ }
148
+ const vr = raw.vr;
149
+ if (!VALID_VRS.has(vr)) {
150
+ throw new Error(`${path}.vr: "${vr}" is not a standard DICOM VR`);
151
+ }
152
+ if (!("value" in raw)) {
153
+ throw new Error(`${path}.value: is required in the raw tag form`);
154
+ }
155
+ if (vr === "SQ") {
156
+ if (!Array.isArray(raw.value)) {
157
+ throw new Error(`${path}.value: an SQ raw tag requires an array of items`);
158
+ }
159
+ raw.value.forEach((item, i) => {
160
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
161
+ throw new Error(
162
+ `${path}.value[${i}]: must be an object of hex-tag entries`
163
+ );
164
+ }
165
+ for (const [tag, nested] of Object.entries(item)) {
166
+ if (!HEX_TAG_RE.test(tag)) {
167
+ throw new Error(
168
+ `${path}.value[${i}]: invalid item tag "${tag}" \u2014 SQ items use 8-hex-char tags`
169
+ );
170
+ }
171
+ if (!isRawTagValue(nested)) {
172
+ throw new Error(
173
+ `${path}.value[${i}].${tag}: SQ item entries must use the raw { vr, value } form`
174
+ );
175
+ }
176
+ validateRawTagValue(
177
+ nested,
178
+ `${path}.value[${i}].${tag}`
179
+ );
180
+ }
181
+ });
182
+ }
183
+ }
98
184
  function validatePositiveInt(value, path) {
99
185
  if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
100
186
  throw new Error(
@@ -299,13 +385,33 @@ function validateEntry(entry, path) {
299
385
  return e;
300
386
  }
301
387
  var MODALITY_TAG = "00080060";
302
- function validateTagValue(key, value, path) {
388
+ function validateModalityGuard(key, candidate, path) {
303
389
  const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
304
- if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
390
+ if (isModalityKey && typeof candidate === "string" && Object.hasOwn(VALID_MODALITIES, candidate)) {
305
391
  throw new Error(
306
392
  `${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`
307
393
  );
308
394
  }
395
+ }
396
+ function validateTagValue(key, value, path) {
397
+ if (isRawTagValue(value)) {
398
+ if (!HEX_TAG_RE.test(key)) {
399
+ throw new Error(
400
+ `${path}.${key}: the raw { vr, value } form requires an 8-hex-char tag key \u2014 keyword tags take plain values`
401
+ );
402
+ }
403
+ validateRawTagValue(value, `${path}.${key}`);
404
+ const raw = value.value;
405
+ const scalar = Array.isArray(raw) && raw.length === 1 ? raw[0] : raw;
406
+ validateModalityGuard(key, scalar, path);
407
+ return;
408
+ }
409
+ if (isPrivateHexTag(key)) {
410
+ throw new Error(
411
+ `${path}.${key}: private tags have no dictionary VR \u2014 use the raw form, e.g. { "vr": "LO", "value": \u2026 }`
412
+ );
413
+ }
414
+ validateModalityGuard(key, value, path);
309
415
  if (typeof value === "string") {
310
416
  for (const name of findTemplateTokens(value)) {
311
417
  if (!TEMPLATE_VOCAB.includes(name)) {
@@ -802,27 +908,50 @@ function buildTagToKeyword() {
802
908
  return map;
803
909
  }
804
910
  var tagToKeyword = buildTagToKeyword();
911
+ function toRawElement(raw) {
912
+ const value = raw.vr === "SQ" ? raw.value.map(
913
+ (item) => Object.fromEntries(
914
+ Object.entries(item).map(([tag, nested]) => [
915
+ tag.toUpperCase(),
916
+ toRawElement(nested)
917
+ ])
918
+ )
919
+ ) : raw.value;
920
+ return { vr: raw.vr, Value: Array.isArray(value) ? value : [value] };
921
+ }
805
922
  function applyTagOverrides(dataset, tags) {
806
- if (!tags) return;
923
+ if (!tags) return void 0;
924
+ let raw;
807
925
  for (const [key, value] of Object.entries(tags)) {
808
- if (HEX_TAG_RE.test(key)) {
926
+ if (isRawTagValue(value)) {
927
+ raw ?? (raw = {});
928
+ raw[key.toUpperCase()] = toRawElement(value);
929
+ } else if (HEX_TAG_RE.test(key)) {
809
930
  const keyword = tagToKeyword.get(key.toUpperCase());
810
- if (keyword) {
811
- dataset[keyword] = value;
812
- } else {
813
- console.warn(`dicom-synth: unknown hex tag "${key}" \u2014 skipping`);
931
+ if (!keyword) {
932
+ throw new Error(
933
+ `dicom-synth: unknown hex tag "${key}" \u2014 dictionary tags take plain values; private/unknown tags require the raw { vr, value } form`
934
+ );
814
935
  }
936
+ dataset[keyword] = value;
815
937
  } else {
816
938
  dataset[key] = value;
817
939
  }
818
940
  }
941
+ return raw;
942
+ }
943
+ function rawScalarString(rawElements, tag) {
944
+ const value = rawElements?.[tag]?.Value;
945
+ return value?.length === 1 && typeof value[0] === "string" ? value[0] : void 0;
819
946
  }
820
- function syncMetaWithDataset(meta, dataset) {
821
- if (typeof dataset.SOPInstanceUID === "string") {
822
- meta.MediaStorageSOPInstanceUID = dataset.SOPInstanceUID;
947
+ function syncMetaWithDataset(meta, dataset, rawElements) {
948
+ const sopInstance = rawScalarString(rawElements, "00080018") ?? (typeof dataset.SOPInstanceUID === "string" ? dataset.SOPInstanceUID : void 0);
949
+ if (sopInstance !== void 0) {
950
+ meta.MediaStorageSOPInstanceUID = sopInstance;
823
951
  }
824
- if (typeof dataset.SOPClassUID === "string") {
825
- meta.MediaStorageSOPClassUID = dataset.SOPClassUID;
952
+ const sopClass = rawScalarString(rawElements, "00080016") ?? (typeof dataset.SOPClassUID === "string" ? dataset.SOPClassUID : void 0);
953
+ if (sopClass !== void 0) {
954
+ meta.MediaStorageSOPClassUID = sopClass;
826
955
  }
827
956
  }
828
957
  function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
@@ -862,7 +991,7 @@ function buildBaseImageDataset(uid, modality) {
862
991
  ...preset.attributes
863
992
  };
864
993
  }
865
- function serializeDict(meta, dataset) {
994
+ function serializeDict(meta, dataset, rawElements) {
866
995
  const dicomDict = new dcmjs.data.DicomDict(
867
996
  dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
868
997
  meta
@@ -871,14 +1000,17 @@ function serializeDict(meta, dataset) {
871
1000
  dicomDict.dict = dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
872
1001
  dataset
873
1002
  );
1003
+ if (rawElements) {
1004
+ Object.assign(dicomDict.dict, rawElements);
1005
+ }
874
1006
  return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
875
1007
  }
876
1008
  var MAX_DIMENSION = 65535;
877
- function applySizing(dataset, meta, spec) {
1009
+ function applySizing(dataset, meta, spec, rawElements) {
878
1010
  if (spec.targetSizeKb !== void 0) {
879
1011
  const clampDim = (n) => Math.min(MAX_DIMENSION, Math.max(1, Math.round(n)));
880
1012
  const minimalPixelBytes = dataset.PixelData.byteLength;
881
- const overhead = serializeDict(meta, dataset).length - minimalPixelBytes;
1013
+ const overhead = serializeDict(meta, dataset, rawElements).length - minimalPixelBytes;
882
1014
  const cells = Math.max(
883
1015
  1,
884
1016
  Math.round((spec.targetSizeKb * 1024 - overhead) / 2)
@@ -909,11 +1041,11 @@ function buildImageBuffer(spec, uid) {
909
1041
  dataset.Laterality = "";
910
1042
  dataset.PatientWeight = "0";
911
1043
  }
912
- applyTagOverrides(dataset, spec.tags);
1044
+ const rawElements = applyTagOverrides(dataset, spec.tags);
913
1045
  const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
914
- syncMetaWithDataset(meta, dataset);
915
- applySizing(dataset, meta, spec);
916
- return serializeDict(meta, dataset);
1046
+ syncMetaWithDataset(meta, dataset, rawElements);
1047
+ applySizing(dataset, meta, spec, rawElements);
1048
+ return { buffer: serializeDict(meta, dataset, rawElements), rawElements };
917
1049
  }
918
1050
  function buildFakeSignatureBuffer() {
919
1051
  const buf = Buffer.alloc(200, 0);
@@ -935,11 +1067,11 @@ function buildBufferForSpec(spec, uid) {
935
1067
  case "vendor-warnings-image":
936
1068
  return buildImageBuffer(spec, uid);
937
1069
  case "fake-signature":
938
- return buildFakeSignatureBuffer();
1070
+ return { buffer: buildFakeSignatureBuffer() };
939
1071
  case "non-dicom":
940
- return buildNonDicomBuffer(spec);
1072
+ return { buffer: buildNonDicomBuffer(spec) };
941
1073
  case "dicomdir":
942
- return buildDicomdirBuffer();
1074
+ return { buffer: buildDicomdirBuffer() };
943
1075
  case "large-image":
944
1076
  throw new Error(
945
1077
  "large-image cannot be generated in memory \u2014 use writeCollectionFromSpec (disk-only streaming)"
@@ -1031,12 +1163,12 @@ function writeNative(outPath, params, dims, zeroChunk) {
1031
1163
  const { modality, uid, tags } = params;
1032
1164
  const meta = buildMeta(uid, modality);
1033
1165
  const dataset = buildBaseImageDataset(uid, modality);
1034
- applyTagOverrides(dataset, tags);
1035
- syncMetaWithDataset(meta, dataset);
1166
+ const rawElements = applyTagOverrides(dataset, tags);
1167
+ syncMetaWithDataset(meta, dataset, rawElements);
1036
1168
  dataset.Rows = dims.rows;
1037
1169
  dataset.Columns = dims.columns;
1038
1170
  const minimalPixelBytes = dataset.PixelData.byteLength;
1039
- const probe = serializeDict(meta, dataset);
1171
+ const probe = serializeDict(meta, dataset, rawElements);
1040
1172
  const pixelElement = Buffer.alloc(12);
1041
1173
  PIXEL_DATA_OW_HEADER.copy(pixelElement);
1042
1174
  pixelElement.writeUInt32LE(minimalPixelBytes, 8);
@@ -1065,11 +1197,11 @@ function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk
1065
1197
  TransferSyntaxUID: RLE_TRANSFER_SYNTAX_UID
1066
1198
  };
1067
1199
  const dataset = buildBaseImageDataset(uid, modality);
1068
- applyTagOverrides(dataset, tags);
1069
- syncMetaWithDataset(meta, dataset);
1200
+ const rawElements = applyTagOverrides(dataset, tags);
1201
+ syncMetaWithDataset(meta, dataset, rawElements);
1070
1202
  dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
1071
1203
  dataset._vrMap = { PixelData: "OB" };
1072
- const probe = serializeDict(meta, dataset);
1204
+ const probe = serializeDict(meta, dataset, rawElements);
1073
1205
  const idx = probe.indexOf(PIXEL_DATA_OB_HEADER);
1074
1206
  if (idx < 0 || probe.readUInt32LE(idx + 8) !== 4294967295) {
1075
1207
  throw new Error("failed to locate encapsulated PixelData element header");
@@ -1187,7 +1319,7 @@ var VIOLATION_LEVEL = {
1187
1319
  "missing-meta-header": "byte",
1188
1320
  "malformed-sq-delimiter": "byte"
1189
1321
  };
1190
- function applyTagLevel(buffer, violations) {
1322
+ function applyTagLevel(buffer, violations, rawElements) {
1191
1323
  if (violations.length === 0) return buffer;
1192
1324
  let parsed;
1193
1325
  try {
@@ -1221,6 +1353,9 @@ function applyTagLevel(buffer, violations) {
1221
1353
  }
1222
1354
  }
1223
1355
  parsed.dict = dcmjsAny.data.DicomMetaDictionary.denaturalizeDataset(natural);
1356
+ if (rawElements) {
1357
+ Object.assign(parsed.dict, rawElements);
1358
+ }
1224
1359
  if (violations.includes("uid-too-long")) {
1225
1360
  ;
1226
1361
  parsed.dict["00080018"] = {
@@ -1253,11 +1388,14 @@ function applyByteLevel(buffer, violations) {
1253
1388
  }
1254
1389
  return result;
1255
1390
  }
1256
- function applyViolations(buffer, violations) {
1391
+ function applyViolations(buffer, violations, rawElements) {
1257
1392
  if (violations.length === 0) return buffer;
1258
1393
  const tagViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "tag");
1259
1394
  const byteViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "byte");
1260
- return applyByteLevel(applyTagLevel(buffer, tagViolations), byteViolations);
1395
+ return applyByteLevel(
1396
+ applyTagLevel(buffer, tagViolations, rawElements),
1397
+ byteViolations
1398
+ );
1261
1399
  }
1262
1400
 
1263
1401
  // src/collection/writer.ts
@@ -1331,10 +1469,11 @@ async function generateFile(spec, options) {
1331
1469
  ...spec,
1332
1470
  tags: resolveTagTemplates(spec.tags, { index, ...options?.context })
1333
1471
  } : spec;
1334
- let buffer = buildBufferForSpec(resolvedSpec, uid);
1472
+ const built = buildBufferForSpec(resolvedSpec, uid);
1473
+ let buffer = built.buffer;
1335
1474
  const violations = "violations" in spec ? spec.violations : void 0;
1336
1475
  if (violations?.length) {
1337
- buffer = applyViolations(buffer, violations);
1476
+ buffer = applyViolations(buffer, violations, built.rawElements);
1338
1477
  }
1339
1478
  const name = filename(spec.type, index, padWidth);
1340
1479
  return {
@@ -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)) {
@@ -682,6 +788,7 @@ function validateParametricSpec(raw) {
682
788
  export {
683
789
  HEX_TAG_RE,
684
790
  isImageType,
791
+ isRawTagValue,
685
792
  positionalName,
686
793
  validateDatasetSpec,
687
794
  validateParametricSpec
@@ -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,27 +96,50 @@ 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;
110
130
  }
111
- function syncMetaWithDataset(meta, dataset) {
112
- if (typeof dataset.SOPInstanceUID === "string") {
113
- meta.MediaStorageSOPInstanceUID = dataset.SOPInstanceUID;
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;
114
139
  }
115
- if (typeof dataset.SOPClassUID === "string") {
116
- meta.MediaStorageSOPClassUID = dataset.SOPClassUID;
140
+ const sopClass = rawScalarString(rawElements, "00080016") ?? (typeof dataset.SOPClassUID === "string" ? dataset.SOPClassUID : void 0);
141
+ if (sopClass !== void 0) {
142
+ meta.MediaStorageSOPClassUID = sopClass;
117
143
  }
118
144
  }
119
145
  function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
@@ -153,7 +179,7 @@ function buildBaseImageDataset(uid, modality) {
153
179
  ...preset.attributes
154
180
  };
155
181
  }
156
- function serializeDict(meta, dataset) {
182
+ function serializeDict(meta, dataset, rawElements) {
157
183
  const dicomDict = new dcmjs.data.DicomDict(
158
184
  dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
159
185
  meta
@@ -162,14 +188,17 @@ function serializeDict(meta, dataset) {
162
188
  dicomDict.dict = dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
163
189
  dataset
164
190
  );
191
+ if (rawElements) {
192
+ Object.assign(dicomDict.dict, rawElements);
193
+ }
165
194
  return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
166
195
  }
167
196
  var MAX_DIMENSION = 65535;
168
- function applySizing(dataset, meta, spec) {
197
+ function applySizing(dataset, meta, spec, rawElements) {
169
198
  if (spec.targetSizeKb !== void 0) {
170
199
  const clampDim = (n) => Math.min(MAX_DIMENSION, Math.max(1, Math.round(n)));
171
200
  const minimalPixelBytes = dataset.PixelData.byteLength;
172
- const overhead = serializeDict(meta, dataset).length - minimalPixelBytes;
201
+ const overhead = serializeDict(meta, dataset, rawElements).length - minimalPixelBytes;
173
202
  const cells = Math.max(
174
203
  1,
175
204
  Math.round((spec.targetSizeKb * 1024 - overhead) / 2)
@@ -200,11 +229,11 @@ function buildImageBuffer(spec, uid) {
200
229
  dataset.Laterality = "";
201
230
  dataset.PatientWeight = "0";
202
231
  }
203
- applyTagOverrides(dataset, spec.tags);
232
+ const rawElements = applyTagOverrides(dataset, spec.tags);
204
233
  const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
205
- syncMetaWithDataset(meta, dataset);
206
- applySizing(dataset, meta, spec);
207
- return serializeDict(meta, dataset);
234
+ syncMetaWithDataset(meta, dataset, rawElements);
235
+ applySizing(dataset, meta, spec, rawElements);
236
+ return { buffer: serializeDict(meta, dataset, rawElements), rawElements };
208
237
  }
209
238
  function buildFakeSignatureBuffer() {
210
239
  const buf = Buffer.alloc(200, 0);
@@ -226,11 +255,11 @@ function buildBufferForSpec(spec, uid) {
226
255
  case "vendor-warnings-image":
227
256
  return buildImageBuffer(spec, uid);
228
257
  case "fake-signature":
229
- return buildFakeSignatureBuffer();
258
+ return { buffer: buildFakeSignatureBuffer() };
230
259
  case "non-dicom":
231
- return buildNonDicomBuffer(spec);
260
+ return { buffer: buildNonDicomBuffer(spec) };
232
261
  case "dicomdir":
233
- return buildDicomdirBuffer();
262
+ return { buffer: buildDicomdirBuffer() };
234
263
  case "large-image":
235
264
  throw new Error(
236
265
  "large-image cannot be generated in memory \u2014 use writeCollectionFromSpec (disk-only streaming)"
@@ -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,27 +82,50 @@ 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;
96
116
  }
97
- function syncMetaWithDataset(meta, dataset) {
98
- if (typeof dataset.SOPInstanceUID === "string") {
99
- meta.MediaStorageSOPInstanceUID = dataset.SOPInstanceUID;
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;
100
125
  }
101
- if (typeof dataset.SOPClassUID === "string") {
102
- meta.MediaStorageSOPClassUID = dataset.SOPClassUID;
126
+ const sopClass = rawScalarString(rawElements, "00080016") ?? (typeof dataset.SOPClassUID === "string" ? dataset.SOPClassUID : void 0);
127
+ if (sopClass !== void 0) {
128
+ meta.MediaStorageSOPClassUID = sopClass;
103
129
  }
104
130
  }
105
131
  function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
@@ -139,7 +165,7 @@ function buildBaseImageDataset(uid, modality) {
139
165
  ...preset.attributes
140
166
  };
141
167
  }
142
- function serializeDict(meta, dataset) {
168
+ function serializeDict(meta, dataset, rawElements) {
143
169
  const dicomDict = new dcmjs.data.DicomDict(
144
170
  dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
145
171
  meta
@@ -148,6 +174,9 @@ function serializeDict(meta, dataset) {
148
174
  dicomDict.dict = dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
149
175
  dataset
150
176
  );
177
+ if (rawElements) {
178
+ Object.assign(dicomDict.dict, rawElements);
179
+ }
151
180
  return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
152
181
  }
153
182
 
@@ -211,12 +240,12 @@ function writeNative(outPath, params, dims, zeroChunk) {
211
240
  const { modality, uid, tags } = params;
212
241
  const meta = buildMeta(uid, modality);
213
242
  const dataset = buildBaseImageDataset(uid, modality);
214
- applyTagOverrides(dataset, tags);
215
- syncMetaWithDataset(meta, dataset);
243
+ const rawElements = applyTagOverrides(dataset, tags);
244
+ syncMetaWithDataset(meta, dataset, rawElements);
216
245
  dataset.Rows = dims.rows;
217
246
  dataset.Columns = dims.columns;
218
247
  const minimalPixelBytes = dataset.PixelData.byteLength;
219
- const probe = serializeDict(meta, dataset);
248
+ const probe = serializeDict(meta, dataset, rawElements);
220
249
  const pixelElement = Buffer.alloc(12);
221
250
  PIXEL_DATA_OW_HEADER.copy(pixelElement);
222
251
  pixelElement.writeUInt32LE(minimalPixelBytes, 8);
@@ -245,11 +274,11 @@ function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk
245
274
  TransferSyntaxUID: RLE_TRANSFER_SYNTAX_UID
246
275
  };
247
276
  const dataset = buildBaseImageDataset(uid, modality);
248
- applyTagOverrides(dataset, tags);
249
- syncMetaWithDataset(meta, dataset);
277
+ const rawElements = applyTagOverrides(dataset, tags);
278
+ syncMetaWithDataset(meta, dataset, rawElements);
250
279
  dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
251
280
  dataset._vrMap = { PixelData: "OB" };
252
- const probe = serializeDict(meta, dataset);
281
+ const probe = serializeDict(meta, dataset, rawElements);
253
282
  const idx = probe.indexOf(PIXEL_DATA_OB_HEADER);
254
283
  if (idx < 0 || probe.readUInt32LE(idx + 8) !== 4294967295) {
255
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,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,6 +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
+ };
4
8
  export declare function positionalName(prefix: string, ordinal: number): string;
5
9
  export declare function validateDatasetSpec(raw: unknown): DatasetSpec;
6
10
  export declare function validateParametricSpec(raw: unknown): ParametricSpec;
@@ -1,9 +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;
4
- export declare function syncMetaWithDataset(meta: Record<string, unknown>, dataset: Record<string, unknown>): 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;
5
10
  export declare function buildMeta(uid: UidSet, modality: Modality, transferSyntax?: TransferSyntax): Record<string, unknown>;
6
11
  export declare function buildBaseImageDataset(uid: UidSet, modality: Modality): Record<string, unknown>;
7
- 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;
8
13
  export declare function sizedNonDicomBytes(spec: NonDicomSpec): number | undefined;
9
- 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.16.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",