/** * Minimal, dependency-free DICOM Part 10 writer used to build synthetic, * non-PHI fixtures for unit tests. Only the explicit VR little endian * transfer syntax is produced, which is enough to exercise the metadata * converter and native frame extraction. */ export interface VrElement { /** Tag in 'ggggeeee' hex form, e.g. '00281050'. */ tag: string; vr: string; value?: string | number | number[] | Buffer; /** Sequence items, only used when vr === 'SQ'. Each item is itself a dataset. */ items?: Dataset[]; } export type Dataset = VrElement[]; const LONG_VRS = new Set(['OB', 'OD', 'OF', 'OL', 'OV', 'OW', 'SQ', 'SV', 'UC', 'UN', 'UR', 'UT', 'UV']); const NUMERIC_VRS: Record void }> = { US: { width: 2, write: (b, v, o) => b.writeUInt16LE(v, o) }, SS: { width: 2, write: (b, v, o) => b.writeInt16LE(v, o) }, UL: { width: 4, write: (b, v, o) => b.writeUInt32LE(v, o) }, SL: { width: 4, write: (b, v, o) => b.writeInt32LE(v, o) }, FL: { width: 4, write: (b, v, o) => b.writeFloatLE(v, o) }, FD: { width: 8, write: (b, v, o) => b.writeDoubleLE(v, o) }, }; function paddingByte(vr: string): number { return vr === 'UI' ? 0x00 : 0x20; } function encodeValue(element: VrElement): Buffer { const { vr, value } = element; if (vr === 'SQ') { const items = Array.isArray(element.items) ? element.items : []; return Buffer.concat(items.map(encodeItem)); } if (value === undefined) { return Buffer.alloc(0); } if (Buffer.isBuffer(value)) { return value; } if (NUMERIC_VRS[vr]) { const numbers = Array.isArray(value) ? value : [value as number]; const { width, write } = NUMERIC_VRS[vr]; const buffer = Buffer.alloc(numbers.length * width); numbers.forEach((number, index) => write(buffer, number, index * width)); return buffer; } const text = Array.isArray(value) ? value.join('\\') : String(value); return Buffer.from(text, 'ascii'); } function encodeItem(dataset: Dataset): Buffer { const body = encodeDataset(dataset); const header = Buffer.alloc(8); header.writeUInt16LE(0xfffe, 0); header.writeUInt16LE(0xe000, 2); header.writeUInt32LE(body.length, 4); return Buffer.concat([header, body]); } export function encodeElement(element: VrElement): Buffer { const group = Number.parseInt(element.tag.substring(0, 4), 16); const elementNumber = Number.parseInt(element.tag.substring(4, 8), 16); let value = encodeValue(element); if (value.length % 2 !== 0) { value = Buffer.concat([value, Buffer.from([paddingByte(element.vr)])]); } const isLong = LONG_VRS.has(element.vr); const header = Buffer.alloc(isLong ? 12 : 8); header.writeUInt16LE(group, 0); header.writeUInt16LE(elementNumber, 2); header.write(element.vr, 4, 2, 'ascii'); if (isLong) { header.writeUInt32LE(value.length, 8); } else { header.writeUInt16LE(value.length, 6); } return Buffer.concat([header, value]); } export function encodeDataset(dataset: Dataset): Buffer { return Buffer.concat(dataset.map(encodeElement)); } export interface Part10Options { transferSyntaxUID?: string; sopClassUID?: string; sopInstanceUID?: string; } export function buildPart10(dataset: Dataset, options: Part10Options = {}): Buffer { const transferSyntaxUID = options.transferSyntaxUID ?? '1.2.840.10008.1.2.1'; const sopClassUID = options.sopClassUID ?? '1.2.840.10008.5.1.4.1.1.7'; const sopInstanceUID = options.sopInstanceUID ?? '1.2.826.0.1.3680043.8.498.1'; const metaElements: Dataset = [ { tag: '00020001', vr: 'OB', value: Buffer.from([0x00, 0x01]) }, { tag: '00020002', vr: 'UI', value: sopClassUID }, { tag: '00020003', vr: 'UI', value: sopInstanceUID }, { tag: '00020010', vr: 'UI', value: transferSyntaxUID }, { tag: '00020012', vr: 'UI', value: '1.2.826.0.1.3680043.8.498.1' }, ]; const metaBody = encodeDataset(metaElements); const groupLength = encodeElement({ tag: '00020000', vr: 'UL', value: metaBody.length }); const preamble = Buffer.alloc(128); const magic = Buffer.from('DICM', 'ascii'); const datasetBytes = encodeDataset(dataset); return Buffer.concat([preamble, magic, groupLength, metaBody, datasetBytes]); } /** Build a synthetic single or multi frame MONOCHROME2 image. */ export function buildMonochromeImage(options: { rows: number; columns: number; bitsAllocated?: number; samplesPerPixel?: number; numberOfFrames?: number; pixelData: number[]; extra?: Dataset; transferSyntaxUID?: string; photometricInterpretation?: string; }): Buffer { const bitsAllocated = options.bitsAllocated ?? 8; const samplesPerPixel = options.samplesPerPixel ?? 1; const dataset: Dataset = [ { tag: '00080016', vr: 'UI', value: '1.2.840.10008.5.1.4.1.1.7' }, { tag: '00080018', vr: 'UI', value: '1.2.826.0.1.3680043.8.498.1' }, { tag: '00080060', vr: 'CS', value: 'CR' }, { tag: '0020000D', vr: 'UI', value: '1.2.826.0.1.3680043.8.498.100' }, { tag: '0020000E', vr: 'UI', value: '1.2.826.0.1.3680043.8.498.200' }, { tag: '00280002', vr: 'US', value: samplesPerPixel }, { tag: '00280004', vr: 'CS', value: options.photometricInterpretation ?? 'MONOCHROME2' }, { tag: '00280010', vr: 'US', value: options.rows }, { tag: '00280011', vr: 'US', value: options.columns }, { tag: '00280100', vr: 'US', value: bitsAllocated }, { tag: '00280101', vr: 'US', value: bitsAllocated }, { tag: '00280102', vr: 'US', value: bitsAllocated - 1 }, { tag: '00280103', vr: 'US', value: 0 }, ]; if (options.numberOfFrames !== undefined) { dataset.push({ tag: '00280008', vr: 'IS', value: String(options.numberOfFrames) }); } if (options.extra) { dataset.push(...options.extra); } const pixelData = bitsAllocated <= 8 ? Buffer.from(options.pixelData) : (() => { const buffer = Buffer.alloc(options.pixelData.length * 2); options.pixelData.forEach((value, index) => buffer.writeUInt16LE(value, index * 2)); return buffer; })(); dataset.push({ tag: '7FE00010', vr: bitsAllocated <= 8 ? 'OB' : 'OW', value: pixelData }); return buildPart10(dataset, { transferSyntaxUID: options.transferSyntaxUID }); }