src_dicom_dicomElementsWrapper.js

import {
  DicomParser,
  getTransferSyntaxName
} from './dicomParser';
import {
  isPixelDataTag,
  isItemDelimitationItemTag,
  isSequenceDelimitationItemTag,
  getItemTag,
  getItemDelimitationItemTag,
  getSequenceDelimitationItemTag,
  getPixelDataTag,
  getTagFromKey
} from './dicomTag';
import {isNativeLittleEndian} from './dataReader';
import {getOrientationFromCosines} from '../math/orientation';
import {Spacing} from '../image/spacing';
import {logger} from '../utils/logger';

// doc imports
/* eslint-disable no-unused-vars */
import {Tag} from './dicomTag';
import {DataElement} from './dataElement';
import {Matrix33} from '../math/matrix';
/* eslint-enable no-unused-vars */

/**
 * @typedef {Object<string, DataElement>} DataElements
 */

/**
 * Dump the DICOM tags to a string in the same way as the
 * DCMTK `dcmdump` command (https://support.dcmtk.org/docs-dcmrt/dcmdump.html).
 *
 * @param {Object<string, DataElement>} dicomElements The dicom elements.
 * @returns {string} The dumped file.
 */
export function dcmdump(dicomElements) {
  const keys = Object.keys(dicomElements);
  let result = '\n';
  result += '# Dicom-File-Format\n';
  result += '\n';
  result += '# Dicom-Meta-Information-Header\n';
  result += '# Used TransferSyntax: ';
  if (isNativeLittleEndian()) {
    result += 'Little Endian Explicit\n';
  } else {
    result += 'NOT Little Endian Explicit\n';
  }
  let dicomElement = null;
  let tag = null;
  let checkHeader = true;
  for (let i = 0, leni = keys.length; i < leni; ++i) {
    dicomElement = dicomElements[keys[i]];
    tag = getTagFromKey(keys[i]);
    if (checkHeader && tag.getGroup() !== '0002') {
      result += '\n';
      result += '# Dicom-Data-Set\n';
      result += '# Used TransferSyntax: ';
      const syntax = dicomElements['00020010'].value[0];
      result += getTransferSyntaxName(syntax);
      result += '\n';
      checkHeader = false;
    }
    result += getElementAsString(tag, dicomElement) + '\n';
  }
  return result;
}

/**
 * Get a data element value as a string.
 *
 * @param {Tag} tag The DICOM tag.
 * @param {object} dicomElement The DICOM element.
 * @param {boolean} [pretty] When set to true, returns a 'pretified' content.
 * @returns {string} A string representation of the DICOM element.
 */
function getElementValueAsString(tag, dicomElement, pretty) {
  let str = '';
  const strLenLimit = 65;

  // dafault to pretty output
  if (typeof pretty === 'undefined') {
    pretty = true;
  }
  // check dicom element input
  if (typeof dicomElement === 'undefined' || dicomElement === null) {
    return str;
  }

  // Polyfill for Number.isInteger.
  const isInteger = Number.isInteger || function (value) {
    return typeof value === 'number' &&
      isFinite(value) &&
      Math.floor(value) === value;
  };

  // TODO Support sequences.

  if (dicomElement.vr !== 'SQ' &&
    dicomElement.value.length === 1 && dicomElement.value[0] === '') {
    str += '(no value available)';
  } else if (isPixelDataTag(tag) &&
    dicomElement.undefinedLength) {
    str = '(PixelSequence)';
  } else if (dicomElement.vr === 'DA' && pretty) {
    const daObj = getDate(dicomElement);
    const da = new Date(daObj.year, daObj.monthIndex, daObj.day);
    str = da.toLocaleDateString();
  } else if (dicomElement.vr === 'TM' && pretty) {
    const tmObj = getTime(dicomElement);
    str = tmObj.hours + ':' + tmObj.minutes + ':' + tmObj.seconds;
  } else {
    let isOtherVR = false;
    if (dicomElement.vr.length !== 0) {
      isOtherVR = (dicomElement.vr[0].toUpperCase() === 'O');
    }
    const isFloatNumberVR = (dicomElement.vr === 'FL' ||
      dicomElement.vr === 'FD' ||
      dicomElement.vr === 'DS');
    let valueStr = '';
    for (let k = 0, lenk = dicomElement.value.length; k < lenk; ++k) {
      valueStr = '';
      if (k !== 0) {
        valueStr += '\\';
      }
      if (isFloatNumberVR) {
        const num = Number(dicomElement.value[k]);
        if (!isInteger(num) && pretty) {
          valueStr += num.toPrecision(4);
        } else {
          valueStr += num.toString();
        }
      } else if (isOtherVR) {
        let tmp = dicomElement.value[k].toString(16);
        if (dicomElement.vr === 'OB') {
          tmp = '00'.substring(0, 2 - tmp.length) + tmp;
        } else {
          tmp = '0000'.substring(0, 4 - tmp.length) + tmp;
        }
        valueStr += tmp;
      } else {
        valueStr += dicomElement.value[k];
      }
      // check length
      if (str.length + valueStr.length <= strLenLimit) {
        str += valueStr;
      } else {
        str += '...';
        break;
      }
    }
  }
  return str;
}

/**
 * Get a data element as a string.
 *
 * @param {Tag} tag The DICOM tag.
 * @param {object} dicomElement The DICOM element.
 * @param {string} [prefix] A string to prepend this one.
 * @returns {string} The element as a string.
 */
function getElementAsString(tag, dicomElement, prefix) {
  // default prefix
  prefix = prefix || '';

  // get tag anme from dictionary
  const tagName = tag.getNameFromDictionary();

  let deSize = dicomElement.value.length;
  let isOtherVR = false;
  if (dicomElement.vr.length !== 0) {
    isOtherVR = (dicomElement.vr[0].toUpperCase() === 'O');
  }

  // no size for delimitations
  if (isItemDelimitationItemTag(tag) ||
    isSequenceDelimitationItemTag(tag)) {
    deSize = 0;
  } else if (isOtherVR) {
    deSize = 1;
  }

  const isPixSequence = (isPixelDataTag(tag) &&
    dicomElement.undefinedLength);

  let line = null;

  // (group,element)
  line = '(';
  line += tag.getGroup().toLowerCase();
  line += ',';
  line += tag.getElement().toLowerCase();
  line += ') ';
  // value representation
  line += dicomElement.vr;
  // value
  if (dicomElement.vr !== 'SQ' &&
    dicomElement.value.length === 1 &&
    dicomElement.value[0] === '') {
    line += ' (no value available)';
    deSize = 0;
  } else {
    // simple number display
    if (dicomElement.vr === 'na') {
      line += ' ';
      line += dicomElement.value[0];
    } else if (isPixSequence) {
      // pixel sequence
      line += ' (PixelSequence #=' + deSize + ')';
    } else if (dicomElement.vr === 'SQ') {
      line += ' (Sequence with';
      if (dicomElement.undefinedLength) {
        line += ' undefined';
      } else {
        line += ' explicit';
      }
      line += ' length #=';
      line += dicomElement.value.length;
      line += ')';
    } else if (isOtherVR ||
        dicomElement.vr === 'pi' ||
        dicomElement.vr === 'UL' ||
        dicomElement.vr === 'US' ||
        dicomElement.vr === 'SL' ||
        dicomElement.vr === 'SS' ||
        dicomElement.vr === 'FL' ||
        dicomElement.vr === 'FD' ||
        dicomElement.vr === 'AT') {
      // 'O'ther array, limited display length
      line += ' ';
      line += getElementValueAsString(tag, dicomElement, false);
    } else {
      // default
      line += ' [';
      line += getElementValueAsString(tag, dicomElement, false);
      line += ']';
    }
  }

  // align #
  const nSpaces = 55 - line.length;
  if (nSpaces > 0) {
    for (let s = 0; s < nSpaces; ++s) {
      line += ' ';
    }
  }
  line += ' # ';
  if (dicomElement.vl < 100) {
    line += ' ';
  }
  if (dicomElement.vl < 10) {
    line += ' ';
  }
  line += dicomElement.vl;
  line += ', ';
  line += deSize; //dictElement[1];
  line += ' ';
  if (tagName !== null) {
    line += tagName;
  } else {
    line += 'Unknown Tag & Data';
  }

  let message = null;

  // continue for sequence
  if (dicomElement.vr === 'SQ') {
    let item = null;
    for (let l = 0, lenl = dicomElement.value.length; l < lenl; ++l) {
      item = dicomElement.value[l];
      const itemKeys = Object.keys(item);
      if (itemKeys.length === 0) {
        continue;
      }

      // get the item element
      const itemTag = getItemTag();
      const itemElement = item['FFFEE000'];
      message = '(Item with';
      if (itemElement.undefinedLength) {
        message += ' undefined';
      } else {
        message += ' explicit';
      }
      message += ' length #=' + (itemKeys.length - 1) + ')';
      itemElement.value = [message];
      itemElement.vr = 'na';

      line += '\n';
      line += getElementAsString(itemTag, itemElement, prefix + '  ');

      for (let m = 0, lenm = itemKeys.length; m < lenm; ++m) {
        const itemTag = getTagFromKey(itemKeys[m]);
        if (itemKeys[m] !== 'xFFFEE000') {
          line += '\n';
          line += getElementAsString(itemTag, item[itemKeys[m]],
            prefix + '    ');
        }
      }

      message = '(ItemDelimitationItem';
      if (!itemElement.undefinedLength) {
        message += ' for re-encoding';
      }
      message += ')';
      const itemDelimTag = getItemDelimitationItemTag();
      const itemDelimElement = {
        vr: 'na',
        vl: '0',
        value: [message]
      };
      line += '\n';
      line += getElementAsString(
        itemDelimTag, itemDelimElement, prefix + '  ');

    }

    message = '(SequenceDelimitationItem';
    if (!dicomElement.undefinedLength) {
      message += ' for re-encod.';
    }
    message += ')';
    const sqDelimTag = getSequenceDelimitationItemTag();
    const sqDelimElement = {
      vr: 'na',
      vl: '0',
      value: [message]
    };
    line += '\n';
    line += getElementAsString(sqDelimTag, sqDelimElement, prefix);
  } else if (isPixSequence) {
    // pixel sequence
    let pixItem = null;
    for (let n = 0, lenn = dicomElement.value.length; n < lenn; ++n) {
      pixItem = dicomElement.value[n];
      line += '\n';
      pixItem.vr = 'pi';
      line += getElementAsString(
        getPixelDataTag(), pixItem, prefix + '  ');
    }

    const pixDelimTag = getSequenceDelimitationItemTag();
    const pixDelimElement = {
      vr: 'na',
      vl: '0',
      value: ['(SequenceDelimitationItem)']
    };
    line += '\n';
    line += getElementAsString(pixDelimTag, pixDelimElement, prefix);
  }

  return prefix + line;
}

/**
 * Extract the 2D size from dicom elements.
 *
 * @param {DataElements} elements The DICOM elements.
 * @returns {number[]} The size.
 */
export function getImage2DSize(elements) {
  // rows
  const rows = elements['00280010'];
  if (typeof rows === 'undefined') {
    throw new Error('Missing DICOM image number of rows');
  }
  if (rows.value.length === 0) {
    throw new Error('Empty DICOM image number of rows');
  }
  // columns
  const columns = elements['00280011'];
  if (typeof columns === 'undefined') {
    throw new Error('Missing DICOM image number of columns');
  }
  if (columns.value.length === 0) {
    throw new Error('Empty DICOM image number of columns');
  }
  return [columns.value[0], rows.value[0]];
}

/**
 * Get the pixel spacing from the different spacing tags.
 *
 * @param {DataElements} elements The DICOM elements.
 * @returns {Spacing} The read spacing or the default [1,1].
 */
export function getPixelSpacing(elements) {
  // default
  let rowSpacing = 1;
  let columnSpacing = 1;

  // 1. PixelSpacing
  // 2. ImagerPixelSpacing
  // 3. NominalScannedPixelSpacing
  // 4. PixelAspectRatio
  const keys = ['00280030', '00181164', '00182010', '00280034'];
  for (let k = 0; k < keys.length; ++k) {
    const spacing = elements[keys[k]];
    if (spacing && spacing.value.length === 2) {
      // spacing order: [row, column]
      rowSpacing = parseFloat(spacing.value[0]);
      columnSpacing = parseFloat(spacing.value[1]);
      break;
    }
  }

  // check
  if (columnSpacing === 0) {
    logger.warn('Zero column spacing.');
    columnSpacing = 1;
  }
  if (rowSpacing === 0) {
    logger.warn('Zero row spacing.');
    rowSpacing = 1;
  }

  // return
  // (slice spacing will be calculated using the image position patient)
  return new Spacing([columnSpacing, rowSpacing, 1]);
}

/**
 * Get the pixel data unit.
 *
 * @param {DataElements} elements The DICOM elements.
 * @returns {string|null} The unit value if available.
 */
export function getPixelUnit(elements) {
  let unit;
  // 1. RescaleType
  // 2. Units (for PET)
  const keys = ['00281054', '00541001'];
  for (let i = 0; i < keys.length; ++i) {
    const element = elements[keys[i]];
    if (typeof element !== 'undefined') {
      unit = element.value[0];
      break;
    }
  }
  // default rescale type for CT
  if (typeof unit === 'undefined') {
    const element = elements['00080060'];
    if (typeof element !== 'undefined') {
      const modality = element.value[0];
      if (modality === 'CT') {
        unit = 'HU';
      }
    }
  }
  return unit;
}

/**
 * Get a 'date' object with {year, monthIndex, day} ready for the
 *   Date constructor from a DICOM element with vr=DA.
 *
 * @param {object} element The DICOM element with date information.
 * @returns {{year, monthIndex, day}|undefined} The 'date' object.
 */
export function getDate(element) {
  if (typeof element === 'undefined') {
    return undefined;
  }
  if (element.value.length !== 1) {
    return undefined;
  }
  const daValue = element.value[0];
  // Two possible formats:
  // - standard 'YYYYMMDD'
  // - non-standard 'YYYY.MM.DD' (previous ACR-NEMA)
  let monthBeginIndex = 4;
  let dayBeginIndex = 6;
  if (daValue.length === 10) {
    monthBeginIndex = 5;
    dayBeginIndex = 8;
  }
  const daYears = parseInt(daValue.substring(0, 4), 10);
  // 0-11 range
  const daMonthIndex = daValue.length >= monthBeginIndex + 2
    ? parseInt(daValue.substring(
      monthBeginIndex, monthBeginIndex + 2), 10) - 1 : 0;
  const daDay = daValue.length === dayBeginIndex + 2
    ? parseInt(daValue.substring(
      dayBeginIndex, dayBeginIndex + 2), 10) : 0;
  return {
    year: daYears,
    monthIndex: daMonthIndex,
    day: daDay
  };
}

/**
 * Get a time object with {hours, minutes, seconds} ready for the
 *   Date constructor from a DICOM element with vr=TM.
 *
 * @param {object} element The DICOM element with date information.
 * @returns {{hours, minutes, seconds, milliseconds}|undefined} The time object.
 */
export function getTime(element) {
  if (typeof element === 'undefined') {
    return undefined;
  }
  if (element.value.length !== 1) {
    return undefined;
  }
  // format: HH[MMSS.FFFFFF]
  const tmValue = element.value[0];
  const tmHours = parseInt(tmValue.substring(0, 2), 10);
  const tmMinutes = tmValue.length >= 4
    ? parseInt(tmValue.substring(2, 4), 10) : 0;
  const tmSeconds = tmValue.length >= 6
    ? parseInt(tmValue.substring(4, 6), 10) : 0;
  const tmFracSecondsStr = tmValue.length >= 8
    ? tmValue.substring(7, 10) : 0;
  const tmMilliSeconds = tmFracSecondsStr === 0 ? 0
    : parseInt(tmFracSecondsStr, 10) *
      Math.pow(10, 3 - tmFracSecondsStr.length);
  return {
    hours: tmHours,
    minutes: tmMinutes,
    seconds: tmSeconds,
    milliseconds: tmMilliSeconds
  };
}

/**
 * Get a 'dateTime' object with {date, time} ready for the
 *   Date constructor from a DICOM element with vr=DT.
 *
 * @param {object} element The DICOM element with date-time information.
 * @returns {{date, time}|undefined} The time object.
 */
export function getDateTime(element) {
  if (typeof element === 'undefined') {
    return undefined;
  }
  if (element.value.length !== 1) {
    return undefined;
  }
  // format: YYYYMMDDHHMMSS.FFFFFF&ZZXX
  const dtFullValue = element.value[0];
  // remove offset (&ZZXX)
  const dtValue = dtFullValue.split('&')[0];
  const dtDate = getDate({value: [dtValue.substring(0, 8)]});
  const dtTime = dtValue.length >= 9
    ? getTime({value: [dtValue.substring(8)]}) : undefined;
  return {
    date: dtDate,
    time: dtTime
  };
}

/**
 * Pad an input string with a '0' to form a 2 digit one.
 *
 * @param {string} str The string to pad.
 * @returns {string} The padded string.
 */
function padZeroTwoDigit(str) {
  return ('0' + str).slice(-2);
}

/**
 * Get a DICOM formated date.
 *
 * @param {Date} date The date to format.
 * @returns {string} The formated date.
 */
export function getDicomDate(date) {
  // YYYYMMDD
  return (
    date.getFullYear().toString() +
    padZeroTwoDigit((date.getMonth() + 1).toString()) +
    padZeroTwoDigit(date.getDate().toString())
  );
}

/**
 * Get a DICOM formated time.
 *
 * @param {Date} date The date to format.
 * @returns {string} The formated time.
 */
export function getDicomTime(date) {
  // HHMMSS
  return (
    padZeroTwoDigit(date.getHours().toString()) +
    padZeroTwoDigit(date.getMinutes().toString()) +
    padZeroTwoDigit(date.getSeconds().toString())
  );
}

/**
 * Check the dimension organization from a dicom element.
 *
 * @param {DataElements} dataElements The root dicom element.
 * @returns {object} The dimension organizations and indices.
 */
export function getDimensionOrganization(dataElements) {
  // Dimension Organization Sequence (required)
  const orgSq = dataElements['00209221'];
  if (typeof orgSq === 'undefined' || orgSq.value.length !== 1) {
    throw new Error('Unsupported dimension organization sequence length');
  }
  // Dimension Organization UID
  const orgUID = orgSq.value[0]['00209164'].value[0];

  // Dimension Index Sequence (conditionally required)
  const indices = [];
  const indexSqElem = dataElements['00209222'];
  if (typeof indexSqElem !== 'undefined') {
    const indexSq = indexSqElem.value;
    // expecting 2D index
    if (indexSq.length !== 2) {
      throw new Error('Unsupported dimension index sequence length');
    }
    let indexPointer;
    for (let i = 0; i < indexSq.length; ++i) {
      // Dimension Organization UID (required)
      const indexOrg = indexSq[i]['00209164'].value[0];
      if (indexOrg !== orgUID) {
        throw new Error(
          'Dimension Index Sequence contains a unknown Dimension Organization');
      }
      // Dimension Index Pointer (required)
      indexPointer = indexSq[i]['00209165'].value[0];

      const index = {
        DimensionOrganizationUID: indexOrg,
        DimensionIndexPointer: indexPointer
      };
      // Dimension Description Label (optional)
      if (typeof indexSq[i]['00209421'] !== 'undefined') {
        index.DimensionDescriptionLabel = indexSq[i]['00209421'].value[0];
      }
      // store
      indices.push(index);
    }
    // expecting Image Position at last position
    if (indexPointer !== '(0020,0032)') {
      throw new Error('Unsupported non image position as last index');
    }
  }

  return {
    organizations: {
      value: [
        {
          DimensionOrganizationUID: orgUID
        }
      ]
    },
    indices: {
      value: indices
    }
  };
}

/**
 * Get a spacing object from a dicom measure element.
 *
 * @param {DataElements} dataElements The dicom element.
 * @returns {Spacing} A spacing object.
 */
export function getSpacingFromMeasure(dataElements) {
  // Pixel Spacing
  if (typeof dataElements['00280030'] === 'undefined') {
    return null;
  }
  const pixelSpacing = dataElements['00280030'];
  // spacing order: [row, column]
  const spacingValues = [
    parseFloat(pixelSpacing.value[1]),
    parseFloat(pixelSpacing.value[0]),
  ];
  // Slice Thickness
  if (typeof dataElements['00180050'] !== 'undefined') {
    spacingValues.push(parseFloat(dataElements['00180050'].value[0]));
  } else if (typeof dataElements['00180088'] !== 'undefined') {
    // Spacing Between Slices
    spacingValues.push(parseFloat(dataElements['00180088'].value[0]));
  }
  return new Spacing(spacingValues);
}

/**
 * Get an orientation matrix from a dicom orientation element.
 *
 * @param {DataElements} dataElements The dicom element.
 * @returns {Matrix33|undefined} The orientation matrix.
 */
export function getOrientationMatrix(dataElements) {
  const imageOrientationPatient = dataElements['00200037'];
  let orientationMatrix;
  // slice orientation (cosines are matrices' columns)
  // http://dicom.nema.org/medical/dicom/2022a/output/chtml/part03/sect_C.7.6.2.html#sect_C.7.6.2.1.1
  if (typeof imageOrientationPatient !== 'undefined') {
    orientationMatrix =
      getOrientationFromCosines(
        imageOrientationPatient.value.map((item) => parseFloat(item))
      );
  }
  return orientationMatrix;
}

/**
 * Get a dicom item from a measure sequence.
 *
 * @param {Spacing} spacing The spacing object.
 * @returns {object} The dicom item.
 */
export function getDicomMeasureItem(spacing) {
  return {
    SpacingBetweenSlices: spacing.get(2),
    PixelSpacing: [spacing.get(1), spacing.get(0)]
  };
}

/**
 * Get a dicom element from a plane orientation sequence.
 *
 * @param {Matrix33} orientation The image orientation.
 * @returns {object} The dicom element.
 */
export function getDicomPlaneOrientationItem(orientation) {
  return {
    ImageOrientationPatient: [
      orientation.get(0, 0),
      orientation.get(1, 0),
      orientation.get(2, 0),
      orientation.get(0, 1),
      orientation.get(1, 1),
      orientation.get(2, 1)
    ]
  };
}

/**
 * Check an input tag.
 *
 * @param {object} element The element to check.
 * @param {string} name The element name.
 * @param {Array} [values] The expected values.
 * @returns {string} A warning if the element is not as expected.
 */
function checkTag(element, name, values) {
  let warning = '';
  if (typeof element === 'undefined') {
    warning += ' ' + name + ' is undefined,';
  } else if (element.value.length === 0) {
    warning += ' ' + name + ' is empty,';
  } else {
    if (typeof values !== 'undefined') {
      for (let i = 0; i < values.length; ++i) {
        if (!element.value.includes(values[i])) {
          warning += ' ' + name + ' does not contain ' + values[i] +
            ' (value: ' + element.value + '),';
        }
      }
    }
  }
  return warning;
}

/**
 * Get the decayed dose (Bq).
 *
 * @param {object} elements The DICOM elements to check.
 * @returns {object} The value and a warning if
 *   the elements are not as expected.
 */
function getDecayedDose(elements) {
  let warning = '';
  let warn;

  // SeriesDate (type1)
  const seriesDateEl = elements['00080021'];
  const seriesDateObj = getDate(seriesDateEl);

  let totalDose;
  let halfLife;
  let radioStart;

  const radioInfoSqStr = 'RadiopharmaceuticalInformationSequence (00540016)';
  const radioInfoSq = elements['00540016'];
  warning += checkTag(radioInfoSq, radioInfoSqStr);
  if (typeof radioInfoSq !== 'undefined') {
    if (radioInfoSq.value.length !== 1) {
      logger.warn(
        'Found more than 1 istopes in RadiopharmaceuticalInformation Sequence.'
      );
    }

    // RadionuclideTotalDose (type3, Bq)
    const totalDoseStr = 'RadionuclideTotalDose (00181074)';
    const totalDoseEl = radioInfoSq.value[0]['00181074'];
    warn = checkTag(totalDoseEl, totalDoseStr);
    if (warn.length === 0) {
      totalDose = totalDoseEl.value[0];
    } else {
      warning += warn;
    }

    // RadionuclideHalfLife (type3, seconds)
    const halfLifeStr = 'RadionuclideHalfLife (00181075)';
    const halfLifeEl = radioInfoSq.value[0]['00181075'];
    warn = checkTag(halfLifeEl, halfLifeStr);
    if (warn.length === 0) {
      halfLife = parseFloat(halfLifeEl.value[0]);
    } else {
      warning += warn;
    }

    // RadiopharmaceuticalStartDateTime (type3)
    const radioStartDateTimeEl = radioInfoSq.value[0]['00181078'];
    let radioStartDateObj;
    let radioStartTimeObj;
    if (typeof radioStartDateTimeEl === 'undefined') {
      // use seriesDate as radioStartDate
      radioStartDateObj = seriesDateObj;
      // try RadiopharmaceuticalStartTime (type3)
      const radioStartTimeEl = radioInfoSq.value[0]['00181072'];
      radioStartTimeObj = getTime(radioStartTimeEl);
    } else {
      const radioStartDateTime = getDateTime(radioStartDateTimeEl);
      radioStartDateObj = radioStartDateTime.date;
      radioStartTimeObj = radioStartDateTime.time;
    }
    if (typeof radioStartTimeObj === 'undefined') {
      radioStartTimeObj = {
        hours: 0, minutes: 0, seconds: 0, milliseconds: 0
      };
    }
    radioStart = new Date(
      radioStartDateObj.year,
      radioStartDateObj.monthIndex,
      radioStartDateObj.day,
      radioStartTimeObj.hours,
      radioStartTimeObj.minutes,
      radioStartTimeObj.seconds,
      radioStartTimeObj.milliseconds
    );
  }

  // SeriesTime (type1)
  const seriesTimeEl = elements['00080031'];
  const seriesTimeObj = getTime(seriesTimeEl);
  // Series date/time
  let scanStart = new Date(
    seriesDateObj.year,
    seriesDateObj.monthIndex,
    seriesDateObj.day,
    seriesTimeObj.hours,
    seriesTimeObj.minutes,
    seriesTimeObj.seconds,
    seriesTimeObj.milliseconds
  );

  // scanStart Date check
  // AcquisitionDate (type3)
  const acqDateEl = elements['00080022'];
  // AcquisitionTime (type3)
  const acqTimeEl = elements['00080032'];
  if (typeof acqDateEl !== 'undefined' &&
    typeof acqTimeEl !== 'undefined') {
    const acqDateObj = getDate(acqDateEl);
    const acqTimeObj = getTime(acqTimeEl);
    const acqDate = new Date(
      acqDateObj.year,
      acqDateObj.monthIndex,
      acqDateObj.day,
      acqTimeObj.hours,
      acqTimeObj.minutes,
      acqTimeObj.seconds,
      acqTimeObj.milliseconds
    );

    if (scanStart > acqDate) {
      const diff = scanStart.getTime() - acqDate.getTime();
      const warn = 'Series date/time is after Aquisition date/time (diff=' +
        diff.toString() + 'ms) ';
      logger.debug(warn);

      // back compute from center (average count rate) of time window
      // for bed position (frame) in series (reliable in all cases)

      let frameRefTime = 0;
      const frameRefTimeElStr = 'FrameReferenceTime (00541300)';
      const frameRefTimeEl = elements['00541300'];
      warning += checkTag(frameRefTimeEl, frameRefTimeElStr);
      if (typeof frameRefTimeEl !== 'undefined') {
        frameRefTime = frameRefTimeEl.value[0];
      }
      let actualFrameDuration = 0;
      const actualFrameDurationElStr = 'ActualFrameDuration (0018,1242)';
      const actualFrameDurationEl = elements['00181242'];
      warning += checkTag(actualFrameDurationEl, actualFrameDurationElStr);
      if (typeof actualFrameDurationEl !== 'undefined') {
        actualFrameDuration = actualFrameDurationEl.value[0];
      }
      if (frameRefTime > 0 && actualFrameDuration > 0) {
        // convert to seconds
        actualFrameDuration = actualFrameDuration / 1000;
        frameRefTime = frameRefTime / 1000;
        const decayConstant = Math.log(2) / halfLife;
        const decayDuringFrame = decayConstant * actualFrameDuration;
        const averageCountRateTimeWithinFrame =
          1 /
          decayConstant *
          Math.log(decayDuringFrame / (1 - Math.exp(-decayDuringFrame)));
        const offsetSeconds = averageCountRateTimeWithinFrame - frameRefTime;
        scanStart = new Date(
          acqDateObj.year,
          acqDateObj.monthIndex,
          acqDateObj.day,
          acqTimeObj.hours,
          acqTimeObj.minutes,
          acqTimeObj.seconds + offsetSeconds,
          acqTimeObj.milliseconds
        );
      }
    }
  }

  // decayed dose (Bq)
  let decayedDose;
  if (typeof scanStart !== 'undefined' &&
    typeof radioStart !== 'undefined' &&
    typeof totalDose !== 'undefined' &&
    typeof halfLife !== 'undefined') {
    // decay time (s) (Date diff is in milliseconds)
    const decayTime = (scanStart.getTime() - radioStart.getTime()) / 1000;
    const decay = Math.pow(2, (-decayTime / halfLife));
    decayedDose = totalDose * decay;
  }

  return {
    value: decayedDose,
    warning: warning
  };
}

/**
 * Get the PET SUV factor.
 *
 * Ref:
 * - {@link https://qibawiki.rsna.org/index.php/Standardized_Uptake_Value_(SUV)#SUV_Calculation},
 * - {@link https://qibawiki.rsna.org/images/6/62/SUV_vendorneutral_pseudocode_happypathonly_20180626_DAC.pdf},
 * - {@link https://qibawiki.rsna.org/images/8/86/SUV_vendorneutral_pseudocode_20180626_DAC.pdf}.
 *
 * @param {object} elements The DICOM elements.
 * @returns {object} The value and a warning if
 *   the elements are not as expected.
 */
export function getSuvFactor(elements) {
  let warning = '';

  // CorrectedImage (type2): must contain ATTN and DECY
  const corrImageTagStr = 'Corrected Image (00280051)';
  const corrImageEl = elements['00280051'];
  warning += checkTag(corrImageEl, corrImageTagStr, ['ATTN', 'DECY']);
  // DecayCorrection (type1): must be START
  const decayCorrTagStr = 'Decay Correction (00541102)';
  const decayCorrEl = elements['00541102'];
  warning += checkTag(decayCorrEl, decayCorrTagStr, ['START']);
  // Units (type1): must be BQML
  const unitTagStr = 'Units (00541001)';
  const unitEl = elements['00541001'];
  warning += checkTag(unitEl, unitTagStr, ['BQML']);

  // PatientWeight (type3, kg)
  let patWeight;
  const patientWeightStr = ' PatientWeight (00101030)';
  const patWeightEl = elements['00101030'];
  const warn = checkTag(patWeightEl, patientWeightStr);
  if (warn.length === 0) {
    patWeight = patWeightEl.value[0];
  } else {
    warning += warn;
  }

  // Decayed dose (Bq)
  const decayedDose = getDecayedDose(elements);
  warning += decayedDose.warning;

  const result = {};
  if (warning.length !== 0) {
    result.warning = 'Cannot calculate PET SUV:' + warning;
  } else {
    // SUV factor (grams/Bq)
    result.value = (patWeight * 1000) / decayedDose.value;
  }

  return result;
}

/**
 * Get the file list from a DICOMDIR.
 *
 * @param {object} data The buffer data of the DICOMDIR.
 * @returns {Array|undefined} The file list as an array ordered by
 *   STUDY > SERIES > IMAGES.
 */
export function getFileListFromDicomDir(data) {
  // parse file
  const parser = new DicomParser();
  parser.parse(data);
  const elements = parser.getDicomElements();

  // Directory Record Sequence
  if (typeof elements['00041220'] === 'undefined' ||
    typeof elements['00041220'].value === 'undefined') {
    logger.warn('No Directory Record Sequence found in DICOMDIR.');
    return undefined;
  }
  const dirSeq = elements['00041220'].value;

  if (dirSeq.length === 0) {
    logger.warn('The Directory Record Sequence of the DICOMDIR is empty.');
    return undefined;
  }

  const records = [];
  let series = null;
  let study = null;
  for (let i = 0; i < dirSeq.length; ++i) {
    // Directory Record Type
    if (typeof dirSeq[i]['00041430'] === 'undefined' ||
      typeof dirSeq[i]['00041430'].value === 'undefined') {
      continue;
    }
    const recType = dirSeq[i]['00041430'].value[0];

    // supposed to come in order...
    if (recType === 'STUDY') {
      study = [];
      records.push(study);
    } else if (recType === 'SERIES') {
      series = [];
      study.push(series);
    } else if (recType === 'IMAGE') {
      // Referenced File ID
      if (typeof dirSeq[i]['00041500'] === 'undefined' ||
        typeof dirSeq[i]['00041500'].value === 'undefined') {
        continue;
      }
      const refFileIds = dirSeq[i]['00041500'].value;
      // join ids
      series.push(refFileIds.join('/'));
    }
  }
  return records;
}

/**
 * Methods used to extract values from DICOM elements.
 *
 * Implemented as class and method to allow for override via its prototype.
 */
export class TagValueExtractor {
  /**
   * Get the time.
   *
   * @param {Object<string, DataElement>} _elements The DICOM elements.
   * @returns {number|undefined} The time value if available.
   */
  getTime(_elements) {
    // default returns undefined
    return undefined;
  }
}