logo

cmdarek

02-08-2021

Generate thumbnails in Elixir

We are going to use excelent library exmagick which is a wrapper around graphicsmagick - in order to use it we have to install dependencies. On MacOs we can use:

                        
$ brew install libtool
$ brew install graphicsmagick
                        
                    

Our simple solution consist of two modules:

  • ExImage.ImageSize - it's main responsibility is to calculate size for scaled image
  • ExImage - it's responsilbe for generating resized image

Main reason to split this two functionalities into separate modules is better testability. When calculating size for thumbnails we have to consider few cases which is described visually. Green is image that we want to scale and rectangle with blue border is thumbnail we want to generate.

In order to calculate new sizes we have to get ratio for width and height and from those we pick width ration if it is smaller or equal to height ration otherwise we use height ratio. With calculated ration we can get new size which is old size multiplied by ratio.

                        
defmodule ExImage.ImageSize do
  @enforce_keys [:width, :height]
  defstruct [:width, :height]

  @type t :: %__MODULE__{}

  @spec scale(t, t) :: t
  def scale(image_size, preferred_size) do
    ratio_width = ratio(image_size.width, preferred_size.width)
    ratio_height = ratio(image_size.height, preferred_size.height)
    ratio = if ratio_width <= ratio_height, do: ratio_width, else: ratio_height

    %__MODULE__{width: trunc(image_size.width * ratio), height: trunc(image_size.height * ratio)}
  end

  defp ratio(original, preferred), do: preferred / original
end
                        
                    

To fully test it we have to cover each case descibed visually.

                        
defmodule ExImage.ImageSizeTest do
  use ExUnit.Case

  alias ExImage.ImageSize

  describe "scale/2" do
    test "width and height is equal" do
      original = %ImageSize{width: 400, height: 400}
      preferred = %ImageSize{width: 400, height: 400}

      scaled = ImageSize.scale(original, preferred)

      assert %ImageSize{width: 400, height: 400} == scaled
    end

    test "height is equal and width is greater" do
      original = %ImageSize{width: 800, height: 200}
      preferred = %ImageSize{width: 200, height: 200}

      scaled = ImageSize.scale(original, preferred)

      assert %ImageSize{width: 200, height: 50} == scaled
    end

    test "height is smaller and width is greater" do
      original = %ImageSize{width: 800, height: 200}
      preferred = %ImageSize{width: 400, height: 400}

      scaled = ImageSize.scale(original, preferred)

      assert %ImageSize{width: 400, height: 100} == scaled
    end

    test "height is greater and width is equal" do
      original = %ImageSize{width: 400, height: 800}
      preferred = %ImageSize{width: 400, height: 400}

      scaled = ImageSize.scale(original, preferred)

      assert %ImageSize{width: 200, height: 400} == scaled
    end

    test "height is greater and width is greater" do
      original = %ImageSize{width: 600, height: 800}
      preferred = %ImageSize{width: 400, height: 400}

      scaled = ImageSize.scale(original, preferred)

      assert %ImageSize{width: 300, height: 400} == scaled
    end

    test "height is greater and width is smaller" do
      original = %ImageSize{width: 200, height: 800}
      preferred = %ImageSize{width: 400, height: 400}

      scaled = ImageSize.scale(original, preferred)

      assert %ImageSize{width: 100, height: 400} == scaled
    end

    test "height is equal and width is smaller" do
      original = %ImageSize{width: 200, height: 400}
      preferred = %ImageSize{width: 400, height: 400}

      scaled = ImageSize.scale(original, preferred)

      assert %ImageSize{width: 200, height: 400} == scaled
    end

    test "height is smaller and width is equal" do
      original = %ImageSize{width: 400, height: 200}
      preferred = %ImageSize{width: 400, height: 100}

      scaled = ImageSize.scale(original, preferred)

      assert %ImageSize{width: 200, height: 100} == scaled
    end

    test "height is smaller and width is smaller" do
      original = %ImageSize{width: 400, height: 200}
      preferred = %ImageSize{width: 200, height: 100}

      scaled = ImageSize.scale(original, preferred)

      assert %ImageSize{width: 200, height: 100} == scaled
    end
  end
end
                        
                    

Generating thumbnails is quite easy with help of exmagick. We have to load image, get it's size, calculate new size using ExImage.ImageSize.scale(image_size, preffered_size), generate new image with new size.

                        
defmodule ExImage do
  alias ExImage.ImageSize

  require Logger

  @spec thumbnail(binary(), ImageSize.t()) :: {:ok, Path.t()} | {:error, String.t()}
  def thumbnail(image, preffered_size) do
    with {:ok, handler} <- ExMagick.init(),
         {:ok, loaded_image} <- ExMagick.image_load(handler, {:blob, image}),
         {:ok, image_size} <- ExMagick.size(loaded_image),
         thumbnail_size <- ImageSize.scale(image_size, preffered_size),
         {:ok, thumbnail} <-
           ExMagick.thumb(loaded_image, thumbnail_size.width, thumbnail_size.height),
         {:ok, resized_image} <- ExMagick.image_dump(thumbnail) do
      {:ok, resized_image}
    else
      {:error, message} = error ->
        Logger.error(message)
        error
    end
  catch
    error ->
      Logger.error(error)
      {:error, error.message}
  end
end
                        
                    

To check if we resized image with correct ratio we can use ExMagic again and assert size of generated thumbnail.

                        
defmodule ExImageTest do
  use ExUnit.Case

  alias ExImage.ImageSize

  describe "thumbnail/2" do
    test "generates thumbnail" do
      image = File.read!("test/fixtures/image.jpeg")

      {:ok, thumbnail} = ExImage.thumbnail(image, %ImageSize{width: 400, height: 400})

      with {:ok, handler} <- ExMagick.init(),
           {:ok, loaded_image} <- ExMagick.image_load(handler, {:blob, thumbnail}),
           {:ok, image_size} <- ExMagick.size(loaded_image) do
        assert image_size == %{width: 400, height: 266}
      end
    end

    test "returns error for incorrect file" do
      {:error, message} = ExImage.thumbnail("image", %ImageSize{width: 400, height: 400})

      assert message == "Unable to deduce image format"
    end
  end
end
                        
                    

You can view full code at: https://github.com/elpikel/ex_image