lib/image/options/crop.ex

defmodule Image.Options.Crop do
  @moduledoc """
  Options and option validation for `Image.crop/5`.

  """

  import Image,
    only: [
      is_size: 1,
      is_percent: 1,
      is_positive_percent: 1,
      is_box: 4
    ]

  @typedoc """
  Options applicable to cropping an
  image.

  Currently there are no options.

  """
  @type crop_options :: []

  @typedoc """
  Indicates how to determine where to crop
  an image to fill a target area.

  * `:none` means the image will be reduced
    to fit the required bounding box and
    no cropping will occur.

  * `:center` means crop from the center of
    the image. The central part of the image
    will be returned, cropped to fill the
    bounding box.

  * `:entropy` uses an entropy measure.

  * `:attention` means crop the image by looking
    for features likely to draw human attention.

  * `:low` means position the crop towards the
    low coordinate. This means the bottom part
    of the image remains after the crop.

  * `:high` means position the crop towards the
    high coordinate. This means the top part
    of the image remains after the crop.

  """
  @type crop_focus :: :none | :center | :entropy | :attention | :low | :high

  # The meaning of :low and :high are deliberately
  # Although the verb is crop (to remove) most
  # would expect this verb to describe what remains
  # after cropping. Indeed that is already the behaviour for
  # :center and :attention.

  # Note too that we use US English spelling as apposed
  # to the libvips British English spelling. The
  # assumption being that most developers expect
  # US English.

  @crop_map %{
    none: :VIPS_INTERESTING_NONE,
    center: :VIPS_INTERESTING_CENTRE,
    entropy: :VIPS_INTERESTING_ENTROPY,
    attention: :VIPS_INTERESTING_ATTENTION,
    low: :VIPS_INTERESTING_HIGH,
    high: :VIPS_INTERESTING_LOW
  }

  @crop Map.keys(@crop_map)
  @inverse_crop Map.values(@crop_map)

  @doc """
  Validates options to `Iamge.crop/5`.

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

      options ->
        {:ok, options}
    end
  end

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

  @doc false
  def validate_crop(crop, options) when crop in @crop do
    crop = Map.fetch!(@crop_map, crop)
    {:cont, Keyword.put(options, :crop, crop)}
  end

  def validate_crop(crop, options) when crop in @inverse_crop do
    {:cont, Keyword.put(options, :crop, crop)}
  end

  def validate_crop(crop, _options) do
    {:halt, {:error, invalid_crop(crop)}}
  end

  @doc false
  def normalize_box({w, _h} = dims, left, top, width, height) when is_percent(left) do
    normalize_box(dims, round(left * w), top, width, height)
  end

  def normalize_box({_w, h} = dims, left, top, width, height) when is_percent(top) do
    normalize_box(dims, left, round(top * h), width, height)
  end

  def normalize_box({w, _h} = dims, left, top, width, height) when is_positive_percent(width) do
    normalize_box(dims, left, top, round(width * w), height)
  end

  def normalize_box({_w, h} = dims, left, top, width, height) when is_positive_percent(height) do
    normalize_box(dims, left, top, width, round(height * h))
  end

  def normalize_box({w, h}, :center, top, width, height) when is_size(width) and is_size(height) do
    left = round((w - width) / 2)

    normalize_box({w, h}, left, top, width, height)
  end

  def normalize_box({w, h}, :left, top, width, height) when is_size(width) and is_size(height) do
    normalize_box({w, h}, 0, top, width, height)
  end

  def normalize_box({w, h}, :right, top, width, height) when is_size(width) and is_size(height) do
    normalize_box({w, h}, w - width, top, width, height)
  end

  def normalize_box({w, h}, left, :middle, width, height) when is_size(width) and is_size(height) do
    top = round((h - height) / 2)

    normalize_box({w, h}, left, top, width, height)
  end

  def normalize_box({w, h}, left, :top, width, height) when is_size(width) and is_size(height) do
    normalize_box({w, h}, left, 0, width, height)
  end

  def normalize_box({w, h}, left, :bottom, width, height) when is_size(width) and is_size(height) do
    normalize_box({w, h}, left, h - height, width, height)
  end

  def normalize_box(_dims, left, top, width, height) when is_box(left, top, width, height) do
    {left, top, width, height}
  end

  def normalize_box(_dims, _left, _top, width, _height)
      when not is_integer(width) and not is_positive_percent(width) do
    {:error, size_error("width", width)}
  end

  def normalize_box(_dims, _left, _top, _width, height)
      when not is_integer(height) and not is_positive_percent(height) do
    {:error, size_error("height", height)}
  end

  def normalize_box(_dims, left, _top, _width, _height)
      when not is_integer(left) and not is_positive_percent(left) do
    {:error, location_error("left", left)}
  end

  def normalize_box(_dims, _left, top, _width, _height)
      when not is_integer(top) and not is_positive_percent(top) do
    {:error, location_error("top", top)}
  end

  def normalize_box(dims, left, top, width, height) do
    {:error, "Could not normalize box with #{inspect({dims, left, top, width, height})}"}
  end

  @doc false
  def normalize_dims({w, _h} = dims, width, height) when is_positive_percent(width) do
    normalize_dims(dims, round(width * w), height)
  end

  def normalize_dims({_w, h} = dims, width, height) when is_positive_percent(height) do
    normalize_dims(dims, width, round(height * h))
  end

  def normalize_dims(_dims, width, height) when is_size(width) and is_size(height) do
    {width, height}
  end

  def normalize_dims(_dims, width, _height)
      when not is_integer(width) and not is_positive_percent(width) do
    {:error, size_error("width", width)}
  end

  def normalize_dims(_dims, _width, height)
      when not is_integer(height) and not is_positive_percent(height) do
    {:error, size_error("height", height)}
  end

  def normalize_dims(_dims, width, height) do
    {:error, "Could not normalize dimensions within #{width}, #{height}"}
  end

  defp size_error(dim, size) when is_number(size) do
    "#{dim} must be a percentage expressed as a float greater than 0.0 and " <>
      "less than or equal to 1.0. Found #{inspect(size)}"
  end

  defp location_error(dim, size) when is_number(size) do
    "#{dim} must be a percentage expressed as a float beteen -1.0 and 1.0. " <>
      "Found #{inspect(size)}"
  end

  defp invalid_crop(crop) do
    "Invalid crop option #{inspect(crop)}. Valid values are #{inspect(@crop)}"
  end
end