Current section
Files
Jump to
Current section
Files
lib/dicom/value.ex
defmodule Dicom.Value do @moduledoc """ DICOM value decoding and encoding. Converts between raw binary data element values and native Elixir types based on the Value Representation (VR). Reference: DICOM PS3.5 Section 6.2. """ @string_vrs Dicom.VR.string_vrs() @numeric_vrs Dicom.VR.numeric_vrs() @doc """ Decodes a raw binary value to a native Elixir type based on VR. Returns `nil` for empty binaries. """ @spec decode(binary(), Dicom.VR.t()) :: term() def decode(binary, vr), do: decode(binary, vr, :little) @doc """ Decodes a raw binary value using the given endianness. """ @spec decode(binary(), Dicom.VR.t(), :little | :big) :: term() def decode(<<>>, _vr, _endianness), do: nil # Numeric types def decode(<<value::little-unsigned-16>>, :US, :little), do: value def decode(<<value::big-unsigned-16>>, :US, :big), do: value def decode(binary, :US, :little) when rem(byte_size(binary), 2) == 0 do for <<v::little-unsigned-16 <- binary>>, do: v end def decode(binary, :US, :big) when rem(byte_size(binary), 2) == 0 do for <<v::big-unsigned-16 <- binary>>, do: v end def decode(<<value::little-signed-16>>, :SS, :little), do: value def decode(<<value::big-signed-16>>, :SS, :big), do: value def decode(binary, :SS, :little) when rem(byte_size(binary), 2) == 0 do for <<v::little-signed-16 <- binary>>, do: v end def decode(binary, :SS, :big) when rem(byte_size(binary), 2) == 0 do for <<v::big-signed-16 <- binary>>, do: v end def decode(<<value::little-unsigned-32>>, :UL, :little), do: value def decode(<<value::big-unsigned-32>>, :UL, :big), do: value def decode(binary, :UL, :little) when rem(byte_size(binary), 4) == 0 do for <<v::little-unsigned-32 <- binary>>, do: v end def decode(binary, :UL, :big) when rem(byte_size(binary), 4) == 0 do for <<v::big-unsigned-32 <- binary>>, do: v end def decode(<<value::little-signed-32>>, :SL, :little), do: value def decode(<<value::big-signed-32>>, :SL, :big), do: value def decode(binary, :SL, :little) when rem(byte_size(binary), 4) == 0 do for <<v::little-signed-32 <- binary>>, do: v end def decode(binary, :SL, :big) when rem(byte_size(binary), 4) == 0 do for <<v::big-signed-32 <- binary>>, do: v end def decode(<<value::little-float-32>>, :FL, :little), do: value def decode(<<value::big-float-32>>, :FL, :big), do: value def decode(binary, :FL, :little) when rem(byte_size(binary), 4) == 0 do for <<v::little-float-32 <- binary>>, do: v end def decode(binary, :FL, :big) when rem(byte_size(binary), 4) == 0 do for <<v::big-float-32 <- binary>>, do: v end def decode(<<value::little-float-64>>, :FD, :little), do: value def decode(<<value::big-float-64>>, :FD, :big), do: value def decode(binary, :FD, :little) when rem(byte_size(binary), 8) == 0 do for <<v::little-float-64 <- binary>>, do: v end def decode(binary, :FD, :big) when rem(byte_size(binary), 8) == 0 do for <<v::big-float-64 <- binary>>, do: v end # 64-bit integer types def decode(<<value::little-unsigned-64>>, :UV, :little), do: value def decode(<<value::big-unsigned-64>>, :UV, :big), do: value def decode(binary, :UV, :little) when rem(byte_size(binary), 8) == 0 do for <<v::little-unsigned-64 <- binary>>, do: v end def decode(binary, :UV, :big) when rem(byte_size(binary), 8) == 0 do for <<v::big-unsigned-64 <- binary>>, do: v end def decode(<<value::little-signed-64>>, :SV, :little), do: value def decode(<<value::big-signed-64>>, :SV, :big), do: value def decode(binary, :SV, :little) when rem(byte_size(binary), 8) == 0 do for <<v::little-signed-64 <- binary>>, do: v end def decode(binary, :SV, :big) when rem(byte_size(binary), 8) == 0 do for <<v::big-signed-64 <- binary>>, do: v end # Attribute Tag def decode(<<group::little-16, element::little-16>>, :AT, :little), do: {group, element} def decode(<<group::big-16, element::big-16>>, :AT, :big), do: {group, element} def decode(binary, :AT, :little) when rem(byte_size(binary), 4) == 0 do for <<group::little-16, element::little-16 <- binary>>, do: {group, element} end def decode(binary, :AT, :big) when rem(byte_size(binary), 4) == 0 do for <<group::big-16, element::big-16 <- binary>>, do: {group, element} end # UI ā trim null padding def decode(binary, :UI, _endianness), do: trim_trailing_byte(binary, 0x00) # DS ā Decimal String def decode(binary, :DS, _endianness) do binary |> String.trim() |> decode_multi_value(&parse_float/1) end # IS ā Integer String def decode(binary, :IS, _endianness) do binary |> String.trim() |> decode_multi_value(&parse_integer/1) end # String VRs with multi-value support (CS) def decode(binary, :CS, _endianness) do binary |> String.trim() |> decode_multi_value(&Function.identity/1) end # Other string VRs ā just trim def decode(binary, vr, _endianness) when vr in [:AE, :DA, :DT, :LO, :LT, :PN, :SH, :ST, :TM, :UC, :UR, :UT, :AS] do trim_trailing_byte(binary, 0x20) end # Binary VRs ā return as-is def decode(binary, _vr, _endianness), do: binary @doc """ Converts a DICOM DA string ("YYYYMMDD") to an Elixir `Date`. ## Examples iex> Dicom.Value.to_date("20240315") {:ok, ~D[2024-03-15]} iex> Dicom.Value.to_date("invalid") {:error, :invalid_date} """ @spec to_date(String.t()) :: {:ok, Date.t()} | {:error, :invalid_date} def to_date(<<y1, y2, y3, y4, m1, m2, d1, d2>>) when y1 in ?0..?9 and y2 in ?0..?9 and y3 in ?0..?9 and y4 in ?0..?9 and m1 in ?0..?9 and m2 in ?0..?9 and d1 in ?0..?9 and d2 in ?0..?9 do case Date.new( list_to_int([y1, y2, y3, y4]), list_to_int([m1, m2]), list_to_int([d1, d2]) ) do {:ok, _date} = ok -> ok {:error, _} -> {:error, :invalid_date} end end def to_date(_), do: {:error, :invalid_date} @doc """ Converts a DICOM TM string to an Elixir `Time`. Supports full ("HHMMSS.FFFFFF") and partial ("HHMM", "HH") formats. ## Examples iex> Dicom.Value.to_time("143022") {:ok, ~T[14:30:22]} iex> Dicom.Value.to_time("1430") {:ok, ~T[14:30:00]} """ @spec to_time(String.t()) :: {:ok, Time.t()} | {:error, :invalid_time} def to_time(str) when is_binary(str) do trimmed = String.trim(str) parse_dicom_time(trimmed) end def to_time(_), do: {:error, :invalid_time} @doc """ Converts a DICOM DT string to `DateTime` (with TZ offset) or `NaiveDateTime` (without). ## Examples iex> Dicom.Value.to_datetime("20240315143022") {:ok, ~N[2024-03-15 14:30:22]} """ @spec to_datetime(String.t()) :: {:ok, DateTime.t() | NaiveDateTime.t()} | {:error, :invalid_datetime} def to_datetime(str) when is_binary(str) do trimmed = String.trim(str) parse_dicom_datetime(trimmed) end def to_datetime(_), do: {:error, :invalid_datetime} @doc """ Converts an Elixir `Date` to a DICOM DA string ("YYYYMMDD"). ## Examples iex> Dicom.Value.from_date(~D[2024-03-15]) "20240315" """ @spec from_date(Date.t()) :: String.t() def from_date(%Date{year: y, month: m, day: d}) do pad4(y) <> pad2(m) <> pad2(d) end @doc """ Converts an Elixir `Time` to a DICOM TM string. Includes fractional seconds if microsecond precision > 0. ## Examples iex> Dicom.Value.from_time(~T[14:30:22]) "143022" """ @spec from_time(Time.t()) :: String.t() def from_time(%Time{hour: h, minute: m, second: s, microsecond: {us, precision}}) do base = pad2(h) <> pad2(m) <> pad2(s) if precision > 0 and us > 0 do frac = us |> Integer.to_string() |> String.pad_leading(6, "0") |> String.slice(0, precision) base <> "." <> frac else base end end @doc """ Converts a `DateTime` or `NaiveDateTime` to a DICOM DT string. ## Examples iex> Dicom.Value.from_datetime(~N[2024-03-15 14:30:22]) "20240315143022" """ @spec from_datetime(DateTime.t() | NaiveDateTime.t()) :: String.t() def from_datetime(%NaiveDateTime{} = ndt) do from_date(NaiveDateTime.to_date(ndt)) <> from_time(NaiveDateTime.to_time(ndt)) end def from_datetime(%DateTime{} = dt) do base = from_date(DateTime.to_date(dt)) <> from_time(DateTime.to_time(dt)) offset = format_tz_offset(dt.utc_offset + dt.std_offset) base <> offset end @doc """ Encodes a native Elixir value to binary for a given VR. """ @spec encode(term(), Dicom.VR.t()) :: binary() def encode(value, vr), do: encode(value, vr, :little) @doc """ Encodes a native Elixir value using the given endianness. """ @spec encode(term(), Dicom.VR.t(), :little | :big) :: binary() def encode(values, :US, endianness) when is_list(values), do: encode_numeric_list(values, :US, endianness) def encode(value, :US, :little) when is_integer(value) and value >= 0 and value <= 0xFFFF, do: <<value::little-unsigned-16>> def encode(value, :US, :big) when is_integer(value) and value >= 0 and value <= 0xFFFF, do: <<value::big-unsigned-16>> def encode(values, :SS, endianness) when is_list(values), do: encode_numeric_list(values, :SS, endianness) def encode(value, :SS, :little) when is_integer(value) and value >= -0x8000 and value <= 0x7FFF, do: <<value::little-signed-16>> def encode(value, :SS, :big) when is_integer(value) and value >= -0x8000 and value <= 0x7FFF, do: <<value::big-signed-16>> def encode(values, :UL, endianness) when is_list(values), do: encode_numeric_list(values, :UL, endianness) def encode(value, :UL, :little) when is_integer(value) and value >= 0 and value <= 0xFFFFFFFF, do: <<value::little-unsigned-32>> def encode(value, :UL, :big) when is_integer(value) and value >= 0 and value <= 0xFFFFFFFF, do: <<value::big-unsigned-32>> def encode(values, :SL, endianness) when is_list(values), do: encode_numeric_list(values, :SL, endianness) def encode(value, :SL, :little) when is_integer(value) and value >= -0x80000000 and value <= 0x7FFFFFFF, do: <<value::little-signed-32>> def encode(value, :SL, :big) when is_integer(value) and value >= -0x80000000 and value <= 0x7FFFFFFF, do: <<value::big-signed-32>> def encode(values, :FL, endianness) when is_list(values), do: encode_numeric_list(values, :FL, endianness) def encode(value, :FL, :little) when is_number(value), do: <<value::little-float-32>> def encode(value, :FL, :big) when is_number(value), do: <<value::big-float-32>> def encode(values, :FD, endianness) when is_list(values), do: encode_numeric_list(values, :FD, endianness) def encode(value, :FD, :little) when is_number(value), do: <<value::little-float-64>> def encode(value, :FD, :big) when is_number(value), do: <<value::big-float-64>> def encode(values, :UV, endianness) when is_list(values), do: encode_numeric_list(values, :UV, endianness) def encode(value, :UV, :little) when is_integer(value) and value >= 0 and value <= 0xFFFFFFFFFFFFFFFF, do: <<value::little-unsigned-64>> def encode(value, :UV, :big) when is_integer(value) and value >= 0 and value <= 0xFFFFFFFFFFFFFFFF, do: <<value::big-unsigned-64>> def encode(values, :SV, endianness) when is_list(values), do: encode_numeric_list(values, :SV, endianness) def encode(value, :SV, :little) when is_integer(value) and value >= -0x8000000000000000 and value <= 0x7FFFFFFFFFFFFFFF, do: <<value::little-signed-64>> def encode(value, :SV, :big) when is_integer(value) and value >= -0x8000000000000000 and value <= 0x7FFFFFFFFFFFFFFF, do: <<value::big-signed-64>> def encode(values, :AT, endianness) when is_list(values), do: encode_numeric_list(values, :AT, endianness) def encode({group, element}, :AT, :little) when group >= 0 and group <= 0xFFFF and element >= 0 and element <= 0xFFFF, do: <<group::little-16, element::little-16>> def encode({group, element}, :AT, :big) when group >= 0 and group <= 0xFFFF and element >= 0 and element <= 0xFFFF, do: <<group::big-16, element::big-16>> def encode(value, _vr, _endianness) when is_binary(value), do: value def encode(value, vr, _endianness) when vr in @string_vrs, do: to_string(value) def encode(_value, vr, _endianness) when vr in @numeric_vrs or vr == :AT do raise ArgumentError, "unsupported value for VR #{vr}" end def encode(_value, vr, _endianness) do raise ArgumentError, "unsupported value for VR #{vr}" end # Private helpers defp decode_multi_value(str, parser) do case String.split(str, "\\") do [single] -> parser.(single) multiple -> Enum.map(multiple, &parser.(String.trim(&1))) end end defp encode_numeric_list(values, vr, endianness) when values != [] do values |> Enum.map(&encode(&1, vr, endianness)) |> IO.iodata_to_binary() end defp encode_numeric_list(_values, vr, _endianness) do raise ArgumentError, "unsupported value for VR #{vr}" end defp parse_float(str) do case Float.parse(str) do {f, ""} -> f :error -> str _ -> str end end defp parse_integer(str) do case Integer.parse(str) do {i, ""} -> i :error -> str _ -> str end end defp trim_trailing_byte(binary, byte), do: trim_trailing_byte(binary, byte, byte_size(binary)) defp trim_trailing_byte(_binary, _byte, 0), do: <<>> defp trim_trailing_byte(binary, byte, size) do if :binary.last(binary) == byte do trim_trailing_byte(binary_part(binary, 0, size - 1), byte, size - 1) else binary end end # Date/time helpers defp list_to_int(chars), do: chars |> to_string() |> String.to_integer() defp pad2(n), do: n |> Integer.to_string() |> String.pad_leading(2, "0") defp pad4(n), do: n |> Integer.to_string() |> String.pad_leading(4, "0") defp parse_dicom_time(<<h1, h2, m1, m2, s1, s2, ".", frac::binary>>) when h1 in ?0..?9 and h2 in ?0..?9 and m1 in ?0..?9 and m2 in ?0..?9 and s1 in ?0..?9 and s2 in ?0..?9 do h = list_to_int([h1, h2]) m = list_to_int([m1, m2]) s = list_to_int([s1, s2]) {us, precision} = parse_fractional_seconds(frac) case Time.new(h, m, s, {us, precision}) do {:ok, _} = ok -> ok {:error, _} -> {:error, :invalid_time} end end defp parse_dicom_time(<<h1, h2, m1, m2, s1, s2>>) when h1 in ?0..?9 and h2 in ?0..?9 and m1 in ?0..?9 and m2 in ?0..?9 and s1 in ?0..?9 and s2 in ?0..?9 do case Time.new(list_to_int([h1, h2]), list_to_int([m1, m2]), list_to_int([s1, s2])) do {:ok, _} = ok -> ok {:error, _} -> {:error, :invalid_time} end end defp parse_dicom_time(<<h1, h2, m1, m2>>) when h1 in ?0..?9 and h2 in ?0..?9 and m1 in ?0..?9 and m2 in ?0..?9 do case Time.new(list_to_int([h1, h2]), list_to_int([m1, m2]), 0) do {:ok, _} = ok -> ok {:error, _} -> {:error, :invalid_time} end end defp parse_dicom_time(<<h1, h2>>) when h1 in ?0..?9 and h2 in ?0..?9 do case Time.new(list_to_int([h1, h2]), 0, 0) do {:ok, _} = ok -> ok {:error, _} -> {:error, :invalid_time} end end defp parse_dicom_time(_), do: {:error, :invalid_time} defp parse_fractional_seconds(frac) do padded = String.pad_trailing(frac, 6, "0") |> String.slice(0, 6) precision = min(byte_size(frac), 6) {String.to_integer(padded), precision} end defp parse_dicom_datetime(str) when byte_size(str) >= 8 do {date_part, rest} = String.split_at(str, 8) with {:ok, date} <- to_date(date_part) do parse_dt_time_and_offset(date, rest) else _ -> {:error, :invalid_datetime} end end defp parse_dicom_datetime(_), do: {:error, :invalid_datetime} defp parse_dt_time_and_offset(date, "") do {:ok, NaiveDateTime.new!(date, ~T[00:00:00])} end defp parse_dt_time_and_offset(date, time_str) do {time_part, offset_part} = split_tz_offset(time_str) with {:ok, time} <- parse_dicom_time(time_part) do if offset_part == "" do {:ok, NaiveDateTime.new!(date, time)} else build_datetime_with_offset(date, time, offset_part) end else _ -> {:error, :invalid_datetime} end end defp split_tz_offset(str) do case Regex.run(~r/^(.*?)([+-]\d{4})$/, str) do [_, time, offset] -> {time, offset} nil -> {str, ""} end end defp build_datetime_with_offset(date, time, <<sign, h1, h2, m1, m2>>) when sign in [?+, ?-] do hours = list_to_int([h1, h2]) minutes = list_to_int([m1, m2]) total_seconds = hours * 3600 + minutes * 60 offset = if sign == ?+, do: total_seconds, else: -total_seconds ndt = NaiveDateTime.new!(date, time) utc_ndt = NaiveDateTime.add(ndt, -offset, :second) {:ok, utc_dt} = DateTime.from_naive(utc_ndt, "Etc/UTC") {:ok, DateTime.add(utc_dt, offset, :second) |> Map.put(:utc_offset, offset) |> Map.put(:std_offset, 0)} end defp build_datetime_with_offset(_, _, _), do: {:error, :invalid_datetime} defp format_tz_offset(total_seconds) do sign = if total_seconds >= 0, do: "+", else: "-" abs_seconds = abs(total_seconds) hours = div(abs_seconds, 3600) minutes = rem(abs_seconds, 3600) |> div(60) sign <> pad2(hours) <> pad2(minutes) endend