lib/image/exif.ex

defmodule Image.Exif do
  @moduledoc """
  Functions to extract and interpret image EXIF
  data.

  """
  alias Image.Exif.{Decode, Tag}
  alias Image.Exif.{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(),
          optional(binary()) => binary()
        }

  @type value :: binary()
  @type context :: {value(), non_neg_integer(), (any() -> non_neg_integer())}

  @copyright_header "exif-ifd0-Copyright"
  @artist_header "exif-ifd0-Artist"

  def field(:artist), do: @artist_header
  def field(:copyright), do: @copyright_header
  def field(other), do: to_string(other)

  @doc false
  def get_metadata(image, header) do
    case Vix.Vips.Image.header_value(image, field(header)) do
      {:ok, value} -> {:ok, value}
      {:error, "No such field"} -> {:ok, nil}
    end
  end

  @doc false
  def put_metadata(_mut_img, _field, nil), do: :ok
  def put_metadata(_mut_img, _field, ""), do: :ok

  def put_metadata(mut_img, field, value) do
    Vix.Vips.MutableImage.set(mut_img, field(field), :gchararray, value)
  end

  @doc """
  Extract EXIF data from a binary blob.

  """
  def extract_exif(exif) 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
    with 42 <- read_unsigned.(forty_two) do
      offset = read_unsigned.(offset)
      reshape(read_ifd({exif, offset, read_unsigned}))
    end
  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()}) :: %{exif: t()}
  defp reshape(result), do: extract_thumbnail(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(result.exif, Thumbnail.fields())}
  end

  defp extract_thumbnail(result) do
    result
  end
end