import dicomParser, { DataSet, Element, readFixedString } from 'dicom-parser'; import { get_element } from '@iwharris/dicom-data-dictionary'; /** * Minimal DICOM JSON model as defined by DICOM PS3.18 F. * See https://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_F.2.html */ export interface DicomJsonElement { vr: string; Value?: unknown[]; InlineBinary?: string; BulkDataURI?: string; } export type DicomJsonDataSet = Record; export interface DicomJsonOptions { /** * Include private (odd group) data elements. Defaults to false to keep * responses small and to avoid leaking site-specific private data. */ includePrivate?: boolean; } /** String VRs that map to a JSON array of strings. */ const STRING_VRS = new Set([ 'AE', 'AS', 'CS', 'DA', 'DT', 'LO', 'LT', 'SH', 'ST', 'TM', 'UC', 'UI', 'UR', 'UT', ]); /** Numeric VRs and the byte-array parser reader used for each. */ const NUMBER_VRS: Record = { US: 'readUint16', SS: 'readInt16', UL: 'readUint32', SL: 'readInt32', FL: 'readFloat', FD: 'readDouble', }; /** VRs whose payload is binary and must be encoded as InlineBinary/BulkDataURI. */ const BINARY_VRS = new Set(['OB', 'OW', 'OF', 'OD', 'OL', 'OV', 'UN']); /** Pixel data and float pixel data are never part of the metadata response. */ const EXCLUDED_TAGS = new Set(['7FE00010', '7FE00008', '7FE00009']); function formatKey(key: string): string { return key.replace(/^x/i, '').toUpperCase(); } function groupOf(tag: string): number { return parseInt(tag.substring(0, 4), 16); } function isPrivateTag(tag: string): boolean { return (groupOf(tag) & 0x0001) === 1; } function isExcludedTag(tag: string): boolean { if (groupOf(tag) === 0x0002) { return true; } if (EXCLUDED_TAGS.has(tag)) { return true; } // Overlay data lives in groups 6000-60FF, element 3000. const group = groupOf(tag); if (group >= 0x6000 && group <= 0x60ff && tag.substring(4) === '3000') { return true; } return false; } function resolveAmbiguousVr(vr: string, tag: string, dataSet: DataSet): string { const options = vr.split(/\s+or\s+/i).map((v) => v.trim().toUpperCase()); if (options.length === 1) { return options[0]; } // Pixel Padding Value/Range Limit are US or SS depending on PixelRepresentation. if ((tag === '00280120' || tag === '00280121') && options.includes('US') && options.includes('SS')) { const pixelRepresentation = dataSet.uint16('x00280103'); return pixelRepresentation === 1 ? 'SS' : 'US'; } return options[0]; } function resolveVr(element: Element, tag: string, dataSet: DataSet): string { let vr = element.vr; if (!vr) { const dictionaryEntry = get_element(tag); if (dictionaryEntry) { vr = dictionaryEntry.vr; } } vr = (vr || 'UN').toUpperCase(); if (/\bor\b/i.test(vr)) { vr = resolveAmbiguousVr(vr, tag, dataSet); } return vr; } function byteArrayParserFor(element: Element, dataSet: DataSet) { return element.parser ?? dataSet.byteArrayParser; } function readBinary(dataSet: DataSet, element: Element): Buffer { const byteArray = dataSet.byteArray; if (Buffer.isBuffer(byteArray)) { return byteArray.subarray(element.dataOffset, element.dataOffset + element.length); } return Buffer.from(byteArray.buffer, byteArray.byteOffset + element.dataOffset, element.length); } function stripTrailingNul(value: string): string { let end = value.length; while (end > 0 && value.charCodeAt(end - 1) === 0) { end -= 1; } return value.slice(0, end); } function readStringValues(dataSet: DataSet, element: Element): string[] { const raw = readFixedString(dataSet.byteArray, element.dataOffset, element.length); const padded = stripTrailingNul(raw).replace(/ +$/g, ''); if (padded.length === 0) { return []; } return padded.split('\\').map((value) => value.replace(/ +$/g, '')); } function parsePersonName(value: string): Record { const [alphabetic, ideographic, phonetic] = value.split('='); const personName: Record = {}; if (alphabetic) { personName.Alphabetic = alphabetic; } if (ideographic) { personName.Ideographic = ideographic; } if (phonetic) { personName.Phonetic = phonetic; } return personName; } function readNumericValues(dataSet: DataSet, element: Element, vr: string): number[] { const readerName = NUMBER_VRS[vr]; const reader = byteArrayParserFor(element, dataSet)[readerName]; const width = readerName === 'readFloat' || readerName === 'readInt32' || readerName === 'readUint32' ? 4 : readerName === 'readDouble' ? 8 : 2; const count = Math.floor(element.length / width); const values: number[] = []; for (let i = 0; i < count; i += 1) { values.push(reader(dataSet.byteArray, element.dataOffset + i * width)); } return values; } function readAttributeTags(dataSet: DataSet, element: Element): string[] { const parser = byteArrayParserFor(element, dataSet); const count = Math.floor(element.length / 4); const values: string[] = []; for (let i = 0; i < count; i += 1) { const offset = element.dataOffset + i * 4; const group = parser.readUint16(dataSet.byteArray, offset); const elementNumber = parser.readUint16(dataSet.byteArray, offset + 2); values.push( `${ group.toString(16).padStart(4, '0') }${ elementNumber.toString(16).padStart(4, '0')}`.toUpperCase(), ); } return values; } function readDecimalStrings(dataSet: DataSet, element: Element): unknown[] { return readStringValues(dataSet, element).map((value) => { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : value; }); } function readIntegerStrings(dataSet: DataSet, element: Element): unknown[] { return readStringValues(dataSet, element).map((value) => { const parsed = Number.parseInt(value, 10); return Number.isFinite(parsed) ? parsed : value; }); } function convertSequence(dataSet: DataSet, element: Element, options: DicomJsonOptions): DicomJsonElement { const items: DicomJsonDataSet[] = []; if (element.items) { for (const item of element.items) { if (item.dataSet) { items.push(convertDataSetToJson(item.dataSet, options)); } } } return { vr: 'SQ', Value: items }; } /** * Convert a parsed DICOM dataset into DICOM JSON (PS3.18 F). * * The conversion is VR aware: it uses the explicit VR stored on the element * when available and falls back to the DICOM data dictionary otherwise. Absent * attributes are never synthesized, so a source file without Window Center or * Rescale tags will not gain fabricated values. */ export function convertElementToJson( dataSet: DataSet, element: Element, options: DicomJsonOptions = {}, ): DicomJsonElement { const tag = formatKey(element.tag); const vr = resolveVr(element, tag, dataSet); if (vr === 'SQ') { return convertSequence(dataSet, element, options); } if (element.length === 0) { return { vr, Value: [] }; } if (vr === 'PN') { return { vr, Value: readStringValues(dataSet, element).map(parsePersonName) }; } if (vr === 'DS') { return { vr, Value: readDecimalStrings(dataSet, element) }; } if (vr === 'IS') { return { vr, Value: readIntegerStrings(dataSet, element) }; } if (vr === 'AT') { return { vr, Value: readAttributeTags(dataSet, element) }; } if (NUMBER_VRS[vr]) { return { vr, Value: readNumericValues(dataSet, element, vr) }; } if (STRING_VRS.has(vr)) { return { vr, Value: readStringValues(dataSet, element) }; } if (BINARY_VRS.has(vr)) { return { vr, InlineBinary: readBinary(dataSet, element).toString('base64') }; } // Unknown VR: fall back to an inline binary payload rather than guessing. return { vr, InlineBinary: readBinary(dataSet, element).toString('base64') }; } export function convertDataSetToJson( dataSet: DataSet, options: DicomJsonOptions = {}, ): DicomJsonDataSet { const result: DicomJsonDataSet = {}; for (const key of Object.keys(dataSet.elements)) { const element = dataSet.elements[key]; const tag = formatKey(key); if (isExcludedTag(tag)) { continue; } if (!options.includePrivate && isPrivateTag(tag)) { continue; } result[tag] = convertElementToJson(dataSet, element, options); } return result; } export function parseDicomToJson( buffer: Buffer | Uint8Array, options: DicomJsonOptions = {}, ): DicomJsonDataSet { const dataSet = dicomParser.parseDicom(buffer); return convertDataSetToJson(dataSet, options); }