lib/riff.ex

defmodule RIFF do
  require Integer

  @moduledoc """
  This is an Elixir module for reading and writing [RIFF](https://en.wikipedia.org/wiki/Resource_Interchange_File_Format) files.

  ## Example

  ```
  > riff = File.read!("foo.webp")
  <<82, 73, 70, 70, 192, 199 ...>>

  > parsed = RIFF.parse(riff)
  %RIFF.ChunkWithSubChunks{
    id: "RIFF",
    size: 51136,
    format: "WEBP",
    sub_chunks: [
      %RIFF.Chunk{
        id: "VP8L",
        size: 51123,
        data: <<47 ...>>
      }
    ]
  }

  > RIFF.find_chunk(parsed, "VP8L")
  %RIFF.Chunk{
    id: "VP8L",
    size: 51123,
    data: <<47 ...>>
  }

  > data = RIFF.encode(parsed)
  <<82, 73, 70, 70, 192, 199 ...>>
  ```
  """

  @type chunk() :: simple_chunk() | chunk_with_sub_chunks()

  @type simple_chunk() :: %RIFF.Chunk{
    id: binary(),
    size: integer() | :auto,
    data: binary(),
  }

  @type chunk_with_sub_chunks() :: %RIFF.ChunkWithSubChunks{
    id: binary(),
    size: integer() | :auto,
    format: binary(),
    sub_chunks: list(chunk()),
  }

  defmodule Chunk do
    defstruct id: nil, size: 0, data: <<>>
  end

  defmodule ChunkWithSubChunks do
    defstruct id: nil, size: 0, format: <<>>, sub_chunks: []
  end

  @spec parse(binary()) :: chunk_with_sub_chunks()
  def parse(buffer) do
    <<id :: binary-size(4), size :: unsigned-little-size(32), format :: binary-size(4), rest::binary>> = buffer
    %ChunkWithSubChunks{
      id: id,
      size: size,
      format: format,
      sub_chunks: parse_sub_chunks(rest),
    }
  end

  @spec parse_sub_chunks(binary()) :: list(chunk())
  defp parse_sub_chunks(buffer) do
    <<id :: binary-size(4), size :: unsigned-little-size(32), rest :: binary>> = buffer
    if id == "LIST" do
      <<format :: binary-size(4), list_rest :: binary>> = rest
      [%ChunkWithSubChunks{
        id: id,
        size: size,
        format: format,
        sub_chunks: parse_sub_chunks(list_rest),
      }]
    else
      <<chunk_data::binary-size(size), another_chunk :: binary>> = rest
      current_chunk =  %Chunk{
        id: id,
        size: size,
        data: chunk_data,
      }
      another_chunk = cond do
        Integer.is_even(size) ->
          another_chunk
        byte_size(another_chunk) > 0 ->
          <<_ :: binary-size(1), rest :: binary>> = another_chunk
          rest
        true ->
          <<>>
      end
      if byte_size(another_chunk) > 8 do
        [current_chunk | parse_sub_chunks(another_chunk)]
      else
        [current_chunk]
      end
    end
  end

  @spec find_chunk(chunk_with_sub_chunks(), binary()) :: chunk()
  def find_chunk(%{ sub_chunks: chunks }, chunk_id) do
    Enum.find(chunks, fn %{id: id} -> id == chunk_id end)
  end

  @spec replace_sub_chunk(chunk_with_sub_chunks(), chunk()) :: chunk_with_sub_chunks()
  def replace_sub_chunk(parsed, new_sub_chunk) do
    index = Enum.find_index(parsed.sub_chunks, fn %{id: id} -> id == new_sub_chunk.id end)
    %{parsed | sub_chunks: List.replace_at(parsed.sub_chunks, index, new_sub_chunk)}
  end

  @doc """
    Encode a Chunk into binary. You can pass `:auto` to chunk's size and it will be automatically filled with the actual data size.
  """
  @spec encode(chunk_with_sub_chunks()) :: binary()
  def encode(parsed) do
    %{ id: id, size: size, format: format, sub_chunks: sub_chunks } = parsed
    rest_binary = Enum.reduce(sub_chunks, <<>>, fn x, acc -> acc <> encode_sub_chunk(x) end)
    actual_size = byte_size(rest_binary)
    output_size = if size == :auto do
      actual_size
    else
      if size != actual_size do
        IO.warn("RIFF Data size (#{size}) does not match actual size (#{actual_size})")
      end
      size
    end
    <<id :: binary, output_size :: unsigned-little-size(32), format :: binary, rest_binary :: binary>>
  end

  @spec encode_sub_chunk(chunk()) :: binary()
  defp encode_sub_chunk(sub_chunk) do
    %{ id: id, size: size } = sub_chunk
    if id == "LIST" do
      encode(sub_chunk)
    else
      %{ data: data } = sub_chunk
      actual_size = byte_size(data)
      output_size = if size == :auto do
        actual_size
      else
        if size != actual_size do
          IO.warn("RIFF Data size (#{size}) does not match actual size (#{actual_size})")
        end
        size
      end
      if Integer.is_even(actual_size) do
        << id :: binary, output_size :: unsigned-little-size(32), data :: binary >>
      else
        << id :: binary, output_size :: unsigned-little-size(32), data :: binary, 0 :: size(8) >>
      end
    end
  end
end