lib/exexif.ex

defmodule Exexif do
  @moduledoc """
  Read TIFF and EXIF information from a JPEG-format image.

      iex> {:ok, info} = Exexif.exif_from_jpeg_buffer(buffer)
      iex> info.x_resolution
      72
      iex> info.model
      "DSC-RX100M2"
      ...> Exexif.Data.Gps.inspect info
      "41°23´16˝N,2°11´50˝E"
  """

  alias Exexif.{Decode, Tag}
  alias Exexif.Data.{Gps, Thumbnail}

  @type t :: %{
          :brightness_value => float(),
          :color_space => binary(),
          :component_configuration => binary(),
          :compressed_bits_per_pixel => non_neg_integer(),
          :contrast => binary(),
          :custom_rendered => binary(),
          :datetime_digitized => binary(),
          :datetime_original => binary(),
          :digital_zoom_ratio => non_neg_integer(),
          :exif_image_height => non_neg_integer(),
          :exif_image_width => non_neg_integer(),
          :exif_version => binary(),
          :exposure_mode => binary(),
          :exposure_bias_value => non_neg_integer(),
          :exposure_program => binary(),
          :exposure_time => binary(),
          :f_number => non_neg_integer(),
          :file_source => binary(),
          :flash => binary(),
          :flash_pix_version => binary(),
          :focal_length_in_35mm_film => non_neg_integer(),
          :focal_length => float(),
          :iso_speed_ratings => non_neg_integer(),
          :lens_info => [float()],
          :light_source => non_neg_integer(),
          :max_aperture_value => float(),
          :metering_mode => binary(),
          :recommended_exposure => non_neg_integer(),
          :saturation => binary(),
          :scene_capture_type => binary(),
          :scene_type => binary(),
          :sensitivity_type => binary(),
          :sharpness => binary(),
          :white_balance => binary()
        }

  # <<_::32>>
  @type value :: binary()
  @type context :: {value(), non_neg_integer(), (any() -> non_neg_integer())}

  @max_exif_len 2 * (65_536 + 2)

  @image_start_marker 0xFFD8
  # @image_end_marker     0xffd9 # NOT USED

  @app1_marker 0xFFE1

  @spec exif_from_jpeg_file(binary()) ::
          {:error, :no_exif_data_in_jpeg | :not_a_jpeg_file | :file.posix()}
          | {:ok, %{exif: t()}}
  @doc "Extracts EXIF from jpeg file"
  def exif_from_jpeg_file(name) when is_binary(name) do
    with {:ok, buffer} <- File.open(name, [:read], &IO.binread(&1, @max_exif_len)),
         do: exif_from_jpeg_buffer(buffer)
  end

  @doc "Extracts EXIF from jpeg file, raises on any error"
  @spec exif_from_jpeg_file!(binary()) :: %{exif: t()} | no_return()
  def exif_from_jpeg_file!(name) when is_binary(name) do
    case exif_from_jpeg_file(name) do
      {:ok, result} -> result
      {:error, error} -> raise(Exexif.ReadError, type: error, file: name)
    end
  end

  @spec exif_from_jpeg_buffer(binary()) ::
          {:error, :no_exif_data_in_jpeg | :not_a_jpeg_file} | {:ok, %{exif: t()}}
  @doc "Extracts EXIF from binary buffer"
  def exif_from_jpeg_buffer(<<@image_start_marker::16, rest::binary>>),
    do: read_exif(rest)

  def exif_from_jpeg_buffer(_), do: {:error, :not_a_jpeg_file}

  @spec exif_from_jpeg_buffer!(binary()) :: %{exif: t()} | no_return()
  @doc "Extracts EXIF from binary buffer, raises on any error"
  def exif_from_jpeg_buffer!(buffer) do
    case exif_from_jpeg_buffer(buffer) do
      {:ok, result} -> result
      {:error, error} -> raise Exexif.ReadError, type: error, file: nil
    end
  end

  @spec read_exif(binary()) :: {:error, :no_exif_data_in_jpeg} | {:ok, %{exif: t()}}
  def read_exif(<<
        @app1_marker::16,
        _len::16,
        "Exif"::binary,
        0::16,
        exif::binary
      >>) do
    <<
      byte_order::16,
      forty_two::binary-size(2),
      offset::binary-size(4),
      _rest::binary
    >> = exif

    endian =
      case byte_order do
        0x4949 -> :little
        0x4D4D -> :big
      end

    read_unsigned = &:binary.decode_unsigned(&1, endian)

    # sanity check
    42 = read_unsigned.(forty_two)
    offset = read_unsigned.(offset)

    {:ok, reshape(read_ifd({exif, offset, read_unsigned}))}
  end

  def read_exif(<<0xFF::8, _number::8, len::16, data::binary>>) do
    (len - 2)
    |> skip_segment(data)
    |> read_exif()
  end

  def read_exif(_), do: {:error, :no_exif_data_in_jpeg}

  @spec skip_segment(len :: non_neg_integer(), data :: binary()) :: binary()
  defp skip_segment(len, data) do
    <<_segment::size(len)-unit(8), rest::binary>> = data
    rest
  end

  @spec read_ifd(context :: context()) :: map()
  defp read_ifd({exif, offset, ru} = context) do
    case exif do
      <<_::binary-size(offset), tag_count::binary-size(2), tags::binary>> ->
        read_tags(ru.(tag_count), tags, context, :tiff, [])

      _ ->
        %{}
    end
  end

  @spec read_tags(non_neg_integer(), binary(), context(), any(), any()) :: map()
  defp read_tags(0, _tags, _context, _type, result), do: Map.new(result)

  defp read_tags(
         count,
         <<
           tag::binary-size(2),
           format::binary-size(2),
           component_count::binary-size(4),
           value::binary-size(4),
           rest::binary
         >>,
         {_exif, _offset, ru} = context,
         type,
         result
       ) do
    tag = ru.(tag)
    format = ru.(format)
    component_count = ru.(component_count)
    value = Tag.value(format, component_count, value, context)
    {name, description} = Decode.tag(type, tag, value)

    kv =
      case name do
        :exif -> {:exif, read_exif(value, context)}
        :gps -> {:gps, read_gps(value, context)}
        _ -> {name, description}
      end

    read_tags(count - 1, rest, context, type, [kv | result])
  end

  # Handle malformed data
  defp read_tags(_, _, _, _, result), do: Map.new(result)

  def read_exif(exif_offset, {exif, _offset, ru} = context) do
    <<_::binary-size(exif_offset), count::binary-size(2), tags::binary>> = exif
    count = ru.(count)
    read_tags(count, tags, context, :exif, [])
  end

  @spec read_gps(non_neg_integer(), context()) :: %Gps{}
  defp read_gps(gps_offset, {gps, _offset, ru} = context) do
    case gps do
      <<_::binary-size(gps_offset), count::binary-size(2), tags::binary>> ->
        struct(Gps, read_tags(ru.(count), tags, context, :gps, []))

      _ ->
        %Gps{}
    end
  end

  @spec reshape(%{exif: t()} | map()) :: %{exif: t()} | map()
  defp reshape(%{exif: %{} = _} = result), do: extract_thumbnail(result)
  defp reshape(result), do: result

  @spec extract_thumbnail(%{exif: t()}) :: %{exif: t()}
  defp extract_thumbnail(%{exif: exif} = result) do
    exif_keys = Map.keys(exif)

    result =
      if Enum.all?(Thumbnail.fields(), fn e -> Enum.any?(exif_keys, &(&1 == e)) end) do
        Map.put(
          result,
          :thumbnail,
          struct(
            Thumbnail,
            Thumbnail.fields()
            |> Enum.map(fn e -> {e, exif[e]} end)
            |> Enum.into(%{})
          )
        )
      else
        result
      end

    %{result | exif: Map.drop(exif, Thumbnail.fields())}
  end
end