lib/exiffer/rewrite.ex

defmodule Exiffer.Rewrite do
  @moduledoc """
  Rewrite an image file, adding and removing arbitrary metadata
  """

  require Logger

  alias Exiffer.{Entry, IFD, IFDBlock}
  alias Exiffer.Header.APP1.EXIF

  def set_gps(%{} = input, gps) do
    Logger.info "Exiffer.Rewrite.set_gps/2"
    Logger.info "Parsing image"
    {image, remainder} = Exiffer.parse(input)
    Logger.info "Parsing complete"

    has_exif = has_exif?(image.headers)
    metadata = if has_exif do
      image.headers
    else
      [blank_exif() | image.headers]
    end

    Logger.info "Adding/updating GPS entry"
    entry = build_entry(gps)
    {:ok, metadata} = apply_gps(metadata, entry)

    Logger.info "Exiffer.Rewrite.set_gps/2 - complete"
    {:ok, metadata, remainder}
  end

  defp has_exif?(headers) do
    Enum.any?(headers, fn header -> header.__struct__ == EXIF end)
  end

  defp blank_exif do
    ifd_block = %IFDBlock{ifds: []}
    %EXIF{byte_order: :little, ifd_block: ifd_block}
  end

  defp build_entry(gps) do
    latitude_ref = if gps.latitude >= 0, do: "N", else: "S"
    longitude_ref = if gps.longitude >= 0, do: "E", else: "W"
    latitude = gps.latitude |> float_to_dms() |> dms_to_rational()
    longitude = gps.longitude |> float_to_dms() |> dms_to_rational()
    altitude = floor(gps.altitude)

    value = %IFD{
      entries: [
        Entry.new_by_type(:gps_latitude_ref, latitude_ref),
        Entry.new_by_type(:gps_latitude, latitude),
        Entry.new_by_type(:gps_longitude_ref, longitude_ref),
        Entry.new_by_type(:gps_longitude, longitude),
        Entry.new_by_type(:gps_altitude_ref, 0),
        Entry.new_by_type(:gps_altitude, {altitude, 1})
      ]
    }
    Entry.new_by_type(:gps_info, value)
  end

  defp float_to_dms(f) do
    abs = abs(f)
    degrees = floor(abs)
    degrees_remainder = abs - degrees
    minutes = floor(60 * degrees_remainder)
    minutes_remainder = degrees_remainder - minutes / 60
    seconds = 3600 * minutes_remainder
    {degrees, minutes, seconds}
  end

  defp dms_to_rational({d, m, s}) do
    mus = floor(s * 1_000_000)
    [{d, 1}, {m, 1}, {mus, 1_000_000}]
  end

  defp apply_gps(headers, entry) do
    {:ok, headers} = remove_gps(headers)
    {:ok, _headers} = add_gps(headers, entry)
  end

  defp add_gps(headers, entry) when is_list(headers) do
    headers = Enum.map(headers, &(add_gps(&1, entry)))
    {:ok, headers}
  end

  defp add_gps(%EXIF{} = exif, entry) do
    ifd_block = add_gps(exif.ifd_block, entry)
    struct!(exif, ifd_block: ifd_block)
  end

  defp add_gps(%IFDBlock{ifds: []} = ifd_block, entry) do
    ifd = %IFD{entries: []}
    ifd = add_gps(ifd, entry)
    struct!(ifd_block, ifds: [ifd])
  end

  defp add_gps(%IFDBlock{} = ifd_block, entry) do
    [ifd | others] = ifd_block.ifds
    ifd = add_gps(ifd, entry)
    struct!(ifd_block, ifds: [ifd | others])
  end

  defp add_gps(%IFD{} = ifd, entry) do
    entries = Enum.reverse([entry | Enum.reverse(ifd.entries)])
    struct!(ifd, entries: entries)
  end

  defp add_gps(item, _entry), do: item

  defp remove_gps(headers) when is_list(headers) do
    headers = Enum.map(headers, &(remove_gps(&1)))
    {:ok, headers}
  end

  defp remove_gps(%EXIF{} = exif) do
    ifd_block = remove_gps(exif.ifd_block)
    struct!(exif, ifd_block: ifd_block)
  end

  defp remove_gps(%IFDBlock{} = ifd_block) do
    ifds = Enum.map(ifd_block.ifds, &(remove_gps(&1)))
    struct!(ifd_block, ifds: ifds)
  end

  defp remove_gps(%IFD{} = ifd) do
    entries =
      ifd.entries
      |> Enum.map(&(remove_gps(&1)))
      |> Enum.filter(&(&1))
    struct!(ifd, entries: entries)
  end

  defp remove_gps(%Entry{type: :gps_info}) do
    nil
  end

  defp remove_gps(item) do
    item
  end
end