lib/image/options/text.ex

defmodule Image.Options.Text do
  @moduledoc """
  Options for text drawing.

  """

  alias Image.Color
  alias Vix.Vips.Image, as: Vimage

  @type t :: [
          {:font, String.t()}
          | {:font_size, pos_integer()}
          | {:text_fill_color, Color.t()}
          | {:text_stroke_color, Color.t()}
          | {:text_stroke_width, pos_integer()}
          | {:font_weigtht, atom()}
          | {:background_fill_color, Color.t()}
          | {:background_stroke_color, Color.t()}
          | {:background_stroke_width, pos_integer()}
          | {:background_stroke_opacity, float()}
          | {:background_fill_opacity, float()}
          | {:padding, [non_neg_integer(), ...]}
          | {:x, :center | :left | :right}
          | {:y, :middle | :top | :bottom}
          | {:autofit, boolean()}
          | {:width, pos_integer() | nil}
          | {:height, pos_integer() | nil}
          | {:fontfile, String.t() | nil}
          | {:align, :left | :right | :center}
          | {:justify, boolean()}
        ]

  def default_options do
    [
      font: "Helvetica",
      font_size: 50,
      text_fill_color: :white,
      text_stroke_color: :none,
      text_stroke_width: 1,
      font_weight: :normal,
      background_fill_color: :none,
      background_stroke_color: :none,
      background_stroke_width: 1,
      background_stroke_opacity: 0.7,
      background_fill_opacity: 0.7,
      padding: [0, 0],
      x: :center,
      y: :middle,
      autofit: false,
      fontfile: nil,
      align: :left,
      justify: false
    ]
  end

  @doc """
  Validate the options for `Image.Text.text/2`.

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

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

        options ->
          {:ok, options}
      end

    case options do
      {:ok, options} ->
        options
        |> Map.new()
        |> ensure_background_color_if_transparent_text()
        |> validate_size_if_autofit_true()

      other ->
        other
    end
  end

  defp validate_option({:autofit, autofit}, options) do
    autofit = if autofit, do: true, else: false
    options = Keyword.put(options, :autofit, autofit)
    {:cont, options}
  end

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

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

  defp validate_option({:x, x}, options) when x in [:left, :center, :right] do
    {:cont, options}
  end

  defp validate_option({:y, y}, options) when y in [:top, :middle, :bottom] do
    {:cont, options}
  end

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

  defp validate_option({:width, nil}, options) do
    {:cont, options}
  end

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

  defp validate_option({:height, nil}, options) do
    {:cont, options}
  end

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

  defp validate_option({:font_weight, font_weight}, options)
       when is_binary(font_weight) or is_atom(font_weight) do
    {:cont, options}
  end

  defp validate_option({:font_weight, font_weight}, options)
       when is_integer(font_weight) and font_weight in 1..1000 do
    {:cont, options}
  end

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

  defp validate_option({:background_fill_color = option, color}, options) do
    validate_color(option, color, options)
  end

  defp validate_option({:background_stroke_color = option, color}, options) do
    validate_color(option, color, options)
  end

  defp validate_option({:text_fill_color = option, color}, options) do
    validate_color(option, color, options)
  end

  defp validate_option({:text_stroke_color = option, color}, options) do
    validate_color(option, color, options)
  end

  defp validate_option({:background_fill_opacity = option, opacity}, options) do
    validate_opacity(option, opacity, options)
  end

  defp validate_option({:background_stroke_opacity = option, opacity}, options) do
    validate_opacity(option, opacity, options)
  end

  defp validate_option({:text_stroke_width = option, width}, options) do
    validate_stroke_width(option, width, options)
  end

  defp validate_option({:background_stroke_width = option, width}, options) do
    validate_stroke_width(option, width, options)
  end

  defp validate_option({:padding, [left, right]}, options)
       when is_integer(left) and is_integer(right) and left >= 0 and right >= 0 do
    {:cont, options}
  end

  defp validate_option({:padding = option, padding}, options)
       when is_integer(padding) and padding >= 0 do
    {:cont, Keyword.put(options, option, [padding, padding])}
  end

  defp validate_option({:padding = option, %Vimage{} = image}, options) do
    padding_left = div(Image.width(image), 2)
    padding_top = div(Image.height(image), 2)

    {:cont, Keyword.put(options, option, [padding_left, padding_top])}
  end

  defp validate_option({:fontfile, nil}, options) do
    {:cont, options}
  end

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

  defp validate_option({:align, :right}, options) do
    options = Keyword.put(options, :align, :VIPS_ALIGN_HIGH)
    {:cont, options}
  end

  defp validate_option({:align, :left}, options) do
    options = Keyword.put(options, :align, :VIPS_ALIGN_LOW)
    {:cont, options}
  end

  defp validate_option({:align, :center}, options) do
    options = Keyword.put(options, :align, :VIPS_ALIGN_CENTRE)
    {:cont, options}
  end

  defp validate_option({:align, align}, options)
       when align in [:VIPS_ALIGN_LOW, :VIPS_ALIGN_LOW, :VIPS_ALIGN_CENTRE] do
    {:cont, options}
  end

  defp validate_option({:justify, justify}, options) do
    justify = if justify, do: true, else: false
    {:cont, Keyword.put(options, :justify, justify)}
  end

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

  @doc false
  def validate_color(option, color, options) do
    cond do
      (is_binary(color) or is_atom(color)) && Map.get(Color.color_map(), Color.normalize(color)) ->
        {:cont, options}

      match?(<<"#", _rest::bytes-6>>, color) ->
        {:cont, options}

      match?([_r, _g, _b], color) ->
        [r, g, b] = color

        hex_color =
          ["#", convert_color(r), convert_color(g), convert_color(b)]
          |> :erlang.iolist_to_binary()

        {:cont, Keyword.put(options, option, hex_color)}

      color in [:none, "none"] ->
        {:cont, Keyword.put(options, option, :transparent)}

      color in [:transparent, "transparent"] ->
        {:cont, Keyword.put(options, option, :transparent)}

      (is_binary(color) or is_atom(color)) && String.downcase(to_string(color)) in ["none", ""] ->
        {:cont, Keyword.put(options, option, :none)}

      true ->
        {:halt, {:error, invalid_option(option, color)}}
    end
  end

  @doc false
  def validate_opacity(_option, opacity, options)
      when is_float(opacity) and opacity >= 0.0 and opacity <= 1.0 do
    {:cont, options}
  end

  def validate_opacity(option, opacity, _options) do
    {:halt, {:error, invalid_option(option, opacity)}}
  end

  @doc false
  def validate_stroke_width(_option, width, options) when is_integer(width) and width > 0 do
    {:cont, options}
  end

  def validate_stroke_width(option, width, _options) do
    {:halt, {:error, invalid_option(option, width)}}
  end

  @doc false
  def invalid_option(option) do
    "Invalid option or option value: #{inspect(option)}"
  end

  @doc false
  def invalid_option(option, value) do
    "Invalid option or option value: #{option}: #{inspect(value)}"
  end

  defp ensure_background_color_if_transparent_text(options) do
    case options do
      %{text_fill_color: :transparent, background_color: :none} ->
        Map.put(options, :background_color, "black")

      _other ->
        options
    end
  end

  defp convert_color(c) do
    c
    |> round()
    |> Integer.to_string(16)
    |> String.pad_leading(2, "0")
  end

  defp validate_size_if_autofit_true(%{autofit: false} = options) do
    wrap(options, :ok)
  end

  defp validate_size_if_autofit_true(%{autofit: true, height: height, width: width} = options) do
    if is_integer(height) and is_integer(width) do
      wrap(options, :ok)
    else
      {:error, ":height and :width must be specified when autofit: true"}
    end
  end

  @doc false
  def wrap(term, atom) do
    {atom, term}
  end
end