Files
dicom-proxy/src/dimse/dicomJson.ts
padmanto 0c680b680f fix(metadata): stop fabricating display metadata and add DICOM JSON converter
Replace the hand-built metadata subset in parseMeta with a VR-aware
DICOM-to-DICOM-JSON converter. Window Center/Width, Rescale Intercept/Slope
are only emitted when the source element exists; multi-values are preserved
in source order. VOI/LUT, Presentation LUT and Pixel Padding tags are
preserved and Pixel Data is excluded.

- add src/dimse/dicomJson.ts (dictionary-aware converter)
- rewrite src/dimse/parseMeta.ts to use the converter
- add synthetic non-PHI DICOM writer and metadata tests
- add npm test script (node:test + ts-node) and skipLibCheck
2026-09-18 14:16:11 +07:00

287 lines
8.5 KiB
TypeScript

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<string, DicomJsonElement>;
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<string, 'readUint16' | 'readInt16' | 'readUint32' | 'readInt32' | 'readFloat' | 'readDouble'> = {
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<string, string> {
const [alphabetic, ideographic, phonetic] = value.split('=');
const personName: Record<string, string> = {};
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);
}