Let's Build a Proof of Concept Dynamic Image Generator with Elixir & Phoenix

The problem

GIFs are great but sometimes it could be nice to have a little bit of dynamism on a site without having a GIF changing in a loop and distracting the user or just without adding the file size of a GIF to the page load.

The solution

What we're going to build is a small Phoenix web app that has a single endpoint, /quote.png, that will return an image of a random quote and attribution from an array of user defined quotes. Demo:

Example quote generation

The how

The first question is how we'll get the list of quotes and attributions from the user. In a "real" app this would probably be backed by a database and a nice UI for adding quotes. Remember that this is only a proof of concept though so we'll go with the simplest method I could think of: putting them in the query string.

The syntax for an array in a query string is key[]=value&key[]=value2 so if we create an array of quotes and an array of attributions we'll end up with something like this (q for quotes and a for attributions to help keep a long URL a bit shorter):

/quote.png?q[]=It%27s%20awesome&a[]=Mum&q[]=Pretty%20fun&a[]=Bob&q[]=Great%20stuff&a[]=Alice&q[]=Average%20at%20best&a[]=Theo

Now we know how the data is going to be passed into our endpoint, let's start doing something with it!

💡 First you'll need to install Phoenix. The project maintains a very good installation page so I won't repeat its contents. You can skip the node.js and Postgres sections if you don't already have them installed, we won't be needing them.

Once you have Phoenix installed, create a new project and install the dependencies when prompted. We won't need a DB or Webpack so we can skip those with flags.

mix phx.new image_gen_poc --no-ecto --no-webpack

Open the project in your editor of choice and let's get to it!

We'll start be defining the endpoint. Go to image_gen_poc/lib/image_gen_poc_web/router.ex and update line 19 to the following:

get "/quote.png", PageController, :index

We can see above that the new /quote.png endpoint is handled by the index function in the PageController so that's where we'll go next.

First things first we need to extract our quotes and attributions from the URL which can be done very easily with Elixir's pattern matching:

# image_gen_poc/lib/image_gen_poc_web/controllers/page_controller.ex

defmodule ImageGenPocWeb.PageController do
  use ImageGenPocWeb, :controller

  def index(conn, %{"q" => quotes, "a" => attributions}) do

    # ...

  end
end

The array of quotes (q) is assigned to quotes and the array of attributions (a) is assigned to attributions. Before we go any further let's make sure this is all working so far. We'll require Logger then update our index function as follows:

defmodule ImageGenPocWeb.PageController do
  use ImageGenPocWeb, :controller
  require Logger

  def index(conn, %{"q" => quotes, "a" => attributions}) do
    Logger.info(quotes)
    Logger.info(attributions)

    render(conn, "index.html")
  end
end

cd into your project directory and start the server with mix phx.server. Now go to your browser and paste the following URL:

http://localhost:4000/quote.png?q[]=It%27s%20awesome&a[]=Mum&q[]=Pretty%20fun&a[]=Bob&q[]=Great%20stuff&a[]=Alice&q[]=Average%20at%20best&a[]=Theo

Coming back to your terminal you should see something like this:

[info] GET /quote.png
[debug] Processing with ImageGenPocWeb.PageController.index/2
  Parameters: %{"a" => ["Mum", "Bob", "Alice", "Theo"], "q" => ["It's awesome", "Pretty fun", "Great stuff", "Average at best"]}
  Pipelines: [:browser]
[info] It's awesomePretty funGreat stuffAverage at best
[info] MumBobAliceTheo
[info] Sent 200 in 1ms

It's working, great!

Next we need to get a quote and its matching attribution.

def index(conn, %{"q" => quotes, "a" => attributions}) do
  random_index = Enum.random(0..(length(quotes) - 1))
  quote = Enum.at(quotes, random_index)
  attribution = Enum.at(attributions, random_index)

  Logger.info(quote)
  Logger.info(attribution)

  render(conn, "index.html")
end

If we were only dealing with one list we could have just used Enum.random/1 directly on it but as we need to get a value from the same random index in 2 sperate lists we're just using it to generate an index. We can then use Enum.at/2 to get a value from the same random index in 2 different lists.

Going back to your browser and refreshing the page should now output something like this in your terminal:

[info] GET /quote.png
[debug] Processing with ImageGenPocWeb.PageController.index/2
  Parameters: %{"a" => ["Mum", "Bob", "Alice", "Theo"], "q" => ["It's awesome", "Pretty fun", "Great stuff", "Average at best"]}
  Pipelines: [:browser]
[info] Great stuff
[info] Alice
[info] Sent 200 in 3ms

The quote and attribution match so the next job is to generate the image! We're going to hand the work involved in this off to ImageMagick via System.cmd/2. Installing ImageMagick on macOS is just a case of brew install imagemagick, for other OSs check out their downloads page.

System.cmd/2 returns a tuple containing the collected result of the command and the exit status code (0 means the command was successful) which we'll pattern match.

def index(conn, %{"q" => quotes, "a" => attributions}) do
  random_index = Enum.random(0..(length(quotes) - 1))
  quote = Enum.at(quotes, random_index)
  attribution = Enum.at(attributions, random_index)

  {output, 0} =
    System.cmd("convert", [
      "-background",
      "#333333",
      "-fill",
      "white",
      "-font",
      "Helvetica",
      "-pointsize",
      "56",
      "label:#{quote}", # [1]
      "-fill",
      "grey",
      "-font",
      "Helvetica-Oblique",
      "-pointsize",
      "32",
      "label:- #{attribution}", #[2]
      "-append",
      "-background",
      "#333333",
      "-gravity",
      "center",
      "-extent",
      "800x400",
      "png:-" # [3]
    ])

  render(conn, "index.html")
end

There's a lot of ImageMagick config going on there and we're going to ignore most of it as this isn't really an ImageMagick tutorial. I've numbered the lines that are of interest to us:

  1. The first label is passed the value of quote, the quote will be escaped automatically so we don't have to worry about it containing spaces or anything.
  2. Similarly, the value of attribution is passed to the second label.
  3. png:- tells ImageMagick to output the generated image data as a PNG to STDOUT. This is what's collected in output.

We now have the image data in output, all that's left to do is return it to the client. Replace render/2 with the following:

def index(conn, %{"q" => quotes, "a" => attributions}) do

  # ...

  conn
  |> put_resp_content_type("image/png")
  |> send_resp(200, output)
end

put_resp_content_type/2 assigns the appropriate Content-Type header for the PNG we'll be returning, then we send the response back with a status code of 200 and the image data.

If you now go back to your browser and refresh it you should see a quotation image like the one at the start of this article generated. Keep refreshing and it should change randomly!

Example quote generation

Thanks for reading! You'll find the source code here if you need it and if you have any questions, feel free to ask on dev.to 💬