lib/exiffer/rewrite.ex

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

  require Logger

  alias Exiffer.{Binary, GPS, JPEG}
  alias Exiffer.IO.Buffer
  alias Exiffer.JPEG.{Entry, IFD, IFDBlock}
  alias Exiffer.JPEG.Header.APP1.EXIF

  def set_date_time(source, destination, %DateTime{} = date_time) do
    set_date_time(source, destination, DateTime.to_naive(date_time))
  end

  def set_date_time(source, destination, %NaiveDateTime{} = date_time) do
    Logger.info("Exiffer.Rewrite.set_date_time/3")
    input = Buffer.new(source)
    {jpeg, input} = Exiffer.parse(input)
    headers = internal_set_date_time(jpeg.headers, date_time)

    Logger.debug "Setting initial byte order to :big"
    Binary.set_byte_order(:big)

    output = Buffer.new(destination, direction: :write)
    Buffer.write(output, JPEG.magic())
    :ok = Exiffer.Serialize.write(headers, output.io_device)

    :ok = Buffer.close(input)
    :ok = Buffer.close(output)

    {:ok}
  end

  def set_date_time(%JPEG{} = jpeg, %NaiveDateTime{} = date_time) do
    internal_set_date_time(jpeg.headers, date_time)
  end

  defp internal_set_date_time(headers, date_time) do
    date_time_text = NaiveDateTime.to_string(date_time)

    Logger.debug("Adding/updating date/time original entry")
    {headers, exif_index} = ensure_exif(headers)

    # Modification Date
    {headers, modification_date_index} = ensure_entry(headers, exif_index, :modification_date)
    modification_date = Entry.new_by_type(:modification_date, date_time_text)
    headers = update_entry(headers, exif_index, modification_date_index, modification_date)

    {headers, exif_block_index} = ensure_exif_block(headers, exif_index)

    # Date Time Original
    {headers, date_time_index} =
      ensure_exif_block_entry(headers, exif_index, exif_block_index, :date_time_original)

    date_time_original = Entry.new_by_type(:date_time_original, date_time_text)

    headers =
      update_exif_block_entry(
        headers,
        exif_index,
        exif_block_index,
        date_time_index,
        date_time_original
      )

    # Create Date
    {headers, create_date_index} =
      ensure_exif_block_entry(headers, exif_index, exif_block_index, :create_date)

    create_date = Entry.new_by_type(:create_date, date_time_text)

    update_exif_block_entry(
      headers,
      exif_index,
      exif_block_index,
      create_date_index,
      create_date
    )
  end

  def set_gps(source, destination, %GPS{} = gps) do
    Logger.info("Exiffer.Rewrite.set_gps/2")
    input = Buffer.new(source)
    {jpeg, input} = Exiffer.parse(input)

    Logger.debug("Adding/updating GPS entry")
    {headers, exif_index} = ensure_exif(jpeg.headers)
    {headers, gps_index} = ensure_entry(headers, exif_index, :gps_info)
    entry = build_gps_entry(gps)
    headers = update_entry(headers, exif_index, gps_index, entry)

    Logger.debug "Setting initial byte order to :big"
    Binary.set_byte_order(:big)

    output = Buffer.new(destination, direction: :write)
    Buffer.write(output, JPEG.magic())
    :ok = Exiffer.Serialize.write(headers, output.io_device)

    :ok = Buffer.close(input)
    :ok = Buffer.close(output)

    {:ok}
  end

  def set_gps(%JPEG{} = jpeg, %GPS{} = gps) do
    {headers, exif_index} = ensure_exif(jpeg.headers)
    {headers, gps_index} = ensure_entry(headers, exif_index, :gps_info)
    entry = build_gps_entry(gps)
    update_entry(headers, exif_index, gps_index, entry)
  end

  ###################
  # Top-level APP1 EXIF block

  defp ensure_exif(headers) do
    index = Enum.find_index(headers, fn header -> header.__struct__ == EXIF end)

    if index do
      {headers, index}
    else
      {List.insert_at(headers, 1, default_exif()), 1}
    end
  end

  defp default_exif do
    entries = [
      Entry.new_by_type(:x_resolution, {72, 1}),
      Entry.new_by_type(:y_resolution, {72, 1}),
      Entry.new_by_type(:resolution_unit, 2)
    ]
    %EXIF{
      byte_order: :little,
      ifd_block: %IFDBlock{
        ifds: [%IFD{entries: entries}]
      }
    }
  end

  ###################
  # APP1 EXIF IFD entries

  defp ensure_entry(headers, exif_index, type) do
    index = entry_index(headers, exif_index, type)

    if index do
      {headers, index}
    else
      headers =
        headers
        |> update_in(
          ifd_entries_path(exif_index),
          fn entries -> [Entry.new_by_type(type, nil) | entries] end
        )

      {headers, 0}
    end
  end

  defp update_entry(headers, exif_index, entry_index, entry) do
    headers
    |> update_in(
      ifd_entries_path(exif_index) ++ [Access.at(entry_index)],
      fn _existing -> entry end
    )
  end

  defp entry_index(headers, exif_index, type) do
    entries = ifd_entries(headers, exif_index)
    Enum.find_index(entries, fn ifd -> ifd.type == type end)
  end

  defp ifd_entries(headers, exif_index) do
    get_in(headers, ifd_entries_path(exif_index))
  end

  # We assume there is only one IFD in the EXIF block
  defp ifd_entries_path(exif_index) do
    [
      Access.at(exif_index),
      Access.key(:ifd_block),
      Access.key(:ifds),
      Access.at(0),
      Access.key(:entries)
    ]
  end

  ###################
  # APP1 EXIF IFD 'EXIF OFFSET' entry IFD entries

  defp ensure_exif_block(headers, exif_index) do
    index = entry_index(headers, exif_index, :exif_offset)

    if index do
      {headers, index}
    else
      headers =
        headers
        |> update_in(
          ifd_entries_path(exif_index),
          fn entries -> [Entry.new_by_type(:exif_offset, %IFD{}) | entries] end
        )

      {headers, 0}
    end
  end

  defp ensure_exif_block_entry(headers, exif_index, exif_block_index, type) do
    index = exif_block_entry_index(headers, exif_index, exif_block_index, type)

    if index do
      {headers, index}
    else
      headers =
        headers
        |> update_in(
          exif_block_entries_path(exif_index, exif_block_index),
          fn entries -> [Entry.new_by_type(type, nil) | entries] end
        )

      {headers, 0}
    end
  end

  defp update_exif_block_entry(headers, exif_index, exif_block_index, entry_index, entry) do
    headers
    |> update_in(
      exif_block_entries_path(exif_index, exif_block_index) ++ [Access.at(entry_index)],
      fn _existing -> entry end
    )
  end

  defp exif_block_entry_index(headers, exif_index, exif_block_index, type) do
    entries = exif_block_entries(headers, exif_index, exif_block_index)
    Enum.find_index(entries, fn ifd -> ifd.type == type end)
  end

  defp exif_block_entries(headers, exif_index, exif_block_index) do
    get_in(headers, exif_block_entries_path(exif_index, exif_block_index))
  end

  defp exif_block_entries_path(exif_index, exif_block_index) do
    ifd_entries_path(exif_index) ++
      [
        Access.at(exif_block_index),
        Access.key(:value),
        Access.key(:entries)
      ]
  end

  defp build_gps_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
end