lib/image/options/meme.ex

defmodule Image.Options.Meme do
  @moduledoc """
  Options and option validation for `Image.meme/2`.

  """

  alias Image.Color

  @typedoc "Valid font weights"
  @type font_weight :: :ultralight | :light | :normal | :bold | :ultrabold | :heavy

  @typedoc "Valid type transforms"
  @type text_transform :: :capitalize | :upcase | :downcase | :none

  @typedoc "Options applicable to Image.meme/3"
  @type meme_options ::
          [
            {:text, String.t()}
            | {:font, String.t()}
            | {:weight, font_weight()}
            | {:color, Color.t()}
            | {:outline_color, Color.t()}
            | {:justify, boolean()}
            | {:transform, text_transform()}
            | {:width, pos_integer()}
          ]
          | map()

  @doc """
  Validate the options for `Image.meme/3`.

  See `t:Image.Options.Meme.meme_options/0`.

  """
  def validate_options(image, options) when is_list(options) do
    options = Keyword.merge(default_options(image), options)

    case Enum.reduce_while(options, options, &validate_option(&1, &2)) do
      {:error, value} ->
        {:error, value}

      options ->
        options =
          cond do
            options[:font] == "Impact" && options[:fontfile] == :default ->
              Keyword.put(options, :fontfile, font_file("Impact"))

            options[:fontfile] == :default ->
              Keyword.delete(options, :fontfile)

            true ->
              options
          end

        {:ok, Map.new(options)}
    end
  end

  def validate_options(%{} = options) do
    {:ok, options}
  end

  defp validate_option({:font, font}, options) when is_binary(font) do
    {:cont, options}
  end

  defp validate_option({:font_file, font_file}, options) when is_binary(font_file) do
    font_file = font_file(font_file)

    if File.exists?(font_file) do
      options =
        options
        |> Keyword.delete(:font_file)
        |> Keyword.put(:fontfile, font_file)

      {:cont, options}
    else
      {:halt, {:error, no_such_font_file(font_file)}}
    end
  end

  defp validate_option({:font_file, :default}, options) do
    options =
      options
      |> Keyword.delete(:font_file)
      |> Keyword.put(:fontfile, :default)

    {:cont, options}
  end

  defp validate_option({:margin, margin}, options) when is_integer(margin) and margin > 0 do
    {:cont, options}
  end

  defp validate_option({:text, text}, options) when is_binary(text) do
    {:cont, options}
  end

  defp validate_option({:weight, weight}, options)
       when weight in [:ultralight, :light, :normal, :bold, :ultrabold, :heavy] do
    {:cont, options}
  end

  defp validate_option({:transform, transform}, options)
       when transform in [:upcase, :downcase, :capitalize, :none] do
    {:cont, options}
  end

  defp validate_option({:justify, justify}, options) when is_boolean(justify) do
    {:cont, options}
  end

  defp validate_option({key, size}, options)
       when key in [:headline_size, :text_size] and is_integer(size) and size > 0 do
    {:cont, options}
  end

  defp validate_option({key, color} = option, options) when key in [:color, :outline_color] do
    case Color.rgb_color(color) do
      {:ok, hex: _hex, rgb: color} -> {:cont, Keyword.put(options, key, color)}
      {:ok, color} -> {:cont, Keyword.put(options, key, color)}
      _other -> {:halt, invalid_option(option)}
    end
  end

  defp validate_option(option, _options) do
    {:halt, {:error, invalid_option(option)}}
  end

  defp invalid_option(option) do
    "Invalid option or option value: #{inspect(option)}"
  end

  def no_such_font_file(file) do
    "Font file #{inspect(file)} could not be found"
  end

  defp default_options(image) do
    height = Image.height(image)

    [
      text: "",
      font: "Impact",
      font_file: :default,
      weight: :bold,
      color: :white,
      outline_color: :black,
      justify: false,
      transform: :upcase,
      headline_size: default_headline_size(height),
      text_size: default_text_size(height),
      margin: default_margin(image)
    ]
  end

  def default_margin(image) do
    div(Image.width(image), 20)
  end

  defp default_headline_size(height) do
    height_in_points(height) * 10
  end

  defp default_text_size(height) do
    height_in_points(height) * 6
  end

  defp height_in_points(height) do
    div(height, 72)
  end

  defp font_file("Impact") do
    Path.join(to_string(:code.priv_dir(:image)), "fonts/unicode.impact.ttf")
  end

  defp font_file(name) when is_binary(name) do
    name
  end
end