lib/exiffer/jpeg/ifd.ex

defmodule Exiffer.JPEG.IFD do
  @moduledoc """
  Documentation for `Exiffer.JPEG.IFD`.
  """

  require Logger


  alias Exiffer.Binary
  alias Exiffer.JPEG.Entry
  import Exiffer.Logging, only: [integer: 1]

  @enforce_keys ~w(entries)a
  defstruct ~w(entries)a

  defimpl Jason.Encoder  do
    @spec encode(%Exiffer.JPEG.IFD{}, Jason.Encode.opts()) :: String.t()
    def encode(entry, opts) do
      Jason.Encode.map(
        %{
          module: "Exiffer.JPEG.IFD",
          entries: entry.entries
        },
        opts
      )
    end
  end

  def read(%{} = buffer, opts \\ []) do
    {entry_count_bytes, buffer} = Exiffer.Buffer.consume(buffer, 2)
    entry_count = Binary.to_integer(entry_count_bytes)
    Logger.debug "IFD reading #{integer(entry_count)} entries"
    {entries, buffer} = read_entry(buffer, entry_count, [], opts)
    Logger.debug("IFD read #{length(entries)} entries")
    ifd = %__MODULE__{entries: Enum.reverse(entries)}
    read_entries = length(entries)
    if read_entries == entry_count do
      {:ok, ifd, buffer}
    else
      Logger.debug("IFD.read returning error as #{read_entries} entries were found, expected #{entry_count}")
      {:error, ifd, buffer}
    end
  end

  @doc """
  Returns a binary representation of the IFD block.

  Serializing IFDs is messy, as they need to "know" the offsets
  of their own "extra data" (strings and other data that doesn't fit inside 4 bytes)
  and also the offset of any following IFD block.
  """
  def binary(%__MODULE__{entries: entries}, offset, opts \\ []) do
    is_last = Keyword.get(opts, :is_last, true)
    count = length(entries)
    next_ifd_pointer_offset = offset + 2 + count * 12
    {end_of_block, headers, extras} = Enum.reduce(
      entries,
      {next_ifd_pointer_offset + 4, [], []},
      fn entry, {end_of_block, headers, extras} ->
        format = Entry.format_name(entry)
        content = Entry.text(entry)
        Logger.debug "Creating binary for #{format} Entry, value: #{inspect(content)}"
        {header, extra} = Entry.binary(entry, end_of_block)
        {
          end_of_block + byte_size(extra),
          [header | headers],
          [extra | extras]
        }
      end
    )
    count_binary = Binary.int16u_to_current(count)
    headers_binary = headers |> Enum.reverse() |> Enum.join()
    next_ifd_offset = if is_last, do: 0, else: end_of_block
    next_ifd_binary = Binary.int32u_to_current(next_ifd_offset)
    extras_binary = extras |> Enum.reverse() |> Enum.join()
    <<count_binary::binary, headers_binary::binary, next_ifd_binary::binary, extras_binary::binary>>
  end

  def puts(%__MODULE__{} = ifd) do
    ifd.entries
    |> Enum.flat_map(&(Entry.text(&1)))
    |> puts_texts()
  end

  defp puts_texts([]), do: :ok

  defp puts_texts(texts) do
    longest_label =
      texts
      |> Enum.map(fn {key, _value} -> String.length(key) end)
      |> Enum.max()

    texts
    |> Enum.each(fn {label, value} ->
      if value do
        IO.write String.pad_trailing("#{label}:", longest_label + 2)
        try do
          IO.puts value
        rescue _e ->
          IO.puts "???"
        end
      else
        # If there's no label, it's a subtitle
        IO.puts label
        IO.puts String.duplicate("-", String.length(label))
      end
    end)

    :ok
  end

  defp read_entry(buffer, 0, entries, _opts) do
    Logger.debug("Loading thumbnail, if specified")
    load_thumbnail(buffer, entries)
  end

  defp read_entry(%{} = buffer, count, entries, opts) do
    nth = length(entries)
    position = Exiffer.Buffer.tell(buffer)
    offset = buffer.offset
    Logger.debug "Reading Entry #{nth} at buffer position #{integer(position)}, (absolute #{integer(offset + position)})"
    {entry, buffer} = Entry.new(buffer, opts)
    if entry do
      format = Entry.format_name(entry)
      content = Entry.text(entry)
      Logger.debug "#{format} Entry #{nth} read: #{inspect(content)}"

      read_entry(buffer, count - 1, [entry | entries], opts)
    else
      Logger.debug "Entry #{nth} not read"
      read_entry(buffer, 0, entries, opts)
    end
  end

  defp load_thumbnail(%{} = buffer, entries) do
    case thumbnail_entries(entries) do
      {thumbnail_offset, thumbnail_length} ->
        thumbnail = Exiffer.Buffer.random(buffer, thumbnail_offset, thumbnail_length)
        # Replace thumbnail offset with the thumbnail binary
        entries = Enum.map(entries, fn entry ->
          if entry.type == :thumbnail_offset do
            struct!(entry, value: thumbnail)
          else
            entry
          end
        end)
        {entries, buffer}
      _ ->
        {entries, buffer}
    end
  end

  defp thumbnail_entries(entries) do
    thumbnail_offset = find_entry_value(entries, :thumbnail_offset)
    thumbnail_length = find_entry_value(entries, :thumbnail_length)
    if thumbnail_offset && thumbnail_length do
      {thumbnail_offset, thumbnail_length}
    end
  end

  defp find_entry_value(entries, type) do
    case Enum.find(entries, &(&1.type == type)) do
      nil -> nil
      entry -> entry.value
    end
  end
end