Published on

Dynamic image generation with Elixir

Yesterday I shared the blog post detailing how I’ve built this blog. I felt the link preview was lacking without any image.

Mastodon post without preview image Slack post without preview image

Sure, I could add header images to the blog post, but what if I could just dynamically generate them on the fly? Already optimized for Open Graph?

Luckily that was super easy using Image library by the always diligent Kip Cole. What took me the longest was just figuring out what makes for a good preview image! I used the Method Draw SVG editor to design the SVG.

First, we’ll add Image to the dependencies:

# mix.exs
defmodule MyBlog.MixProject do
  use Mix.Project

  # ...

  def deps do
    [
      # ...
      {:image, "~> 0.33"}
    ]
  end

  #...
end

Now we’ll update our Blog.Post module to generate an Open Graph image that’s 1200 by 600 pixels.

# lib/my_blog/blog/post.ex
defmodule MyBlog.Blog.Post do
  @enforce_keys [:id, :og_image, :title, :body, :description, :tags, :date]
  defstruct [:id, :og_image, :title, :body, :description, :tags, :date]

  def build(filename, attrs, body) do
    [year, month_day_id] = filename |> Path.rootname() |> Path.split() |> Enum.take(-2)
    [month, day, id] = String.split(month_day_id, "-", parts: 3)
    date = Date.from_iso8601!("#{year}-#{month}-#{day}")
    attrs = Map.put(attrs, :og_image, generate_og_image(year, filename, attrs.title, attrs.tags))
    struct!(__MODULE__, [id: id, date: date, body: body] ++ Map.to_list(attrs))
  end

  defp generate_og_image(year, filename, title, tags) do
    {filename, basename} = og_image_paths(year, filename)
    {title_line_1, title_line_2} = og_image_title_lines(title)
    tags = Enum.join(tags, ", ")

    svg =
      """
      <svg viewbox="0 0 1200 600" width="1200" height="600" xmlns="http://www.w3.org/2000/svg">
        <defs>
          <linearGradient y2="1" x2="1" y1="0.14844" x1="0.53125" id="gradient">
          <stop offset="0" stop-opacity="0.99609" stop-color="#5b21b6"/>
          <stop offset="0.99219" stop-opacity="0.97656" stop-color="#ff8300"/>
          </linearGradient>
        </defs>
        <g>
          <rect stroke="#000" height="800" width="1800" y="0" x="0" stroke-width="0" fill="url(#gradient)"/>
          <text font-style="normal" font-weight="normal" xml:space="preserve" text-anchor="start" font-family="'Alumni Sans'" font-size="70" y="250" x="100" stroke-width="0" stroke="#000" fill="#f8fafc">#{title_line_1}</text>
          <text font-style="normal" font-weight="normal" xml:space="preserve" text-anchor="start" font-family="'Alumni Sans'" font-size="70" y="350" x="100" stroke-width="0" stroke="#000" fill="#f8fafc">#{title_line_2}</text>
          <text font-style="normal" font-weight="normal" xml:space="preserve" text-anchor="start" font-family="'Alumni Sans'" font-size="30" y="550" x="50" stroke-width="0" stroke="#000" fill="#f8fafc" opacity="0.5">By Dan Schultzer</text>
          <text font-style="normal" font-weight="normal" xml:space="preserve" text-anchor="end" font-family="'Alumni Sans'" font-size="30" y="550" x="1150" stroke-width="0" stroke="#000" fill="#f8fafc" opacity="0.5">#{tags}</text>
        </g>
      </svg>
      """

    write_og_image(filename, svg)

    %{year: year, basename: basename}
  end

  defp og_image_paths(year, filename) do
    [root_dir, file] = filename |> Path.rootname() |> String.split(Path.join("posts", year))
    basename = Path.basename(file, ".md") <> ".open-graph.png"
    filename = Path.join([root_dir, "static", "images", "posts", year, basename])

    File.mkdir_p!(Path.dirname(filename))

    {filename, basename}
  end

  @max_length 31

  defp og_image_title_lines(title) do
    title
    |> String.split(" ")
    |> Enum.reduce_while({"", ""}, fn word, {title_line_1, title_line_2} ->
      cond do
        String.length(title_line_1 <> " " <> word) <= @max_length -> {:cont, {title_line_1 <> " " <> word, title_line_2}}
        String.length(title_line_2 <> " " <> word) <= (@max_length - 3) -> {:cont, {title_line_1, title_line_2 <> " " <> word}}
        true -> {:halt, {title_line_1, title_line_2 <> "..."}}
      end
    end)
  end

  defp write_og_image(filename, svg) do
    {image, _} = Vix.Vips.Operation.svgload_buffer!(svg)

    Image.write!(image, filename)
  end
end

There are a few things happening here:

  • Derive a filename based on the filename of the blog post with .open-graph.png as the extension
  • Create images/posts/:year/ folder path inside priv/static/
  • Split the title into two lines as SVG doesn’t support multiline text
  • Truncate the second title line if it’s too long
  • Generate an image using SVG and save it

Now all you need is to add the Open Graph metatags to <head> for the pages with a post:

# lib/my_blog_web/components/layouts/root.html.heex
<%= if assigns[:post] do %>
  # ...
  <meta property="og:type" content="article" />
  <meta property="og:image" content={url(~p"/images/posts/#{@post.og_image.year}/#{@post.og_image.basename}")} />
  # ...
  <meta property="twitter:card" content="summary_large_image" />
  <meta property="twitter:image" content={url(~p"/images/posts/#{@post.og_image.year}/#{@post.og_image.basename}")} />
<% end %>

Since the images are autogenerated we should ignore them in .gitignore so they don’t show up in the working tree:

# .gitignore

# Ignore assets that are produced by build tools.
/priv/static/assets/
/priv/static/images/posts/*/*.open-graph.png

You are done! Spin up the server with mix phx.server and you should see images/posts/:year/*.open-graph.png file(s) in priv/static/ there should be a generated Open Graph image looking something like this:

Dynamically generated Open Graph image Link preview showing now with the Open Graph image

Wow, huh, guess this solidifies that Phoenix with NimblePublisher is an excellent choice for blogging.

Custom fonts

I’m using a font that doesn’t exist in the debian image I deploy and it ends up using a default system font. How do we fix that? Easy!

First, put your font in ./assets/fonts. Now in your Dockerfile, make sure the font is installed before RUN mix deps.compile gets called:

# Install font used for generated images
COPY ./assets/fonts/AlumniSans-VariableFont_wght.ttf ./
RUN mkdir -p /usr/share/fonts/truetype/
RUN install -m644 AlumniSans-VariableFont_wght.ttf /usr/share/fonts/truetype/
RUN rm ./AlumniSans-VariableFont_wght.ttf

Performance

I wondered how much of a performance penalty I added to my build process by doing this. After all, I do generate an image for every single blog post. If it takes any significant amount of time then I would have to look into caching now.

Let’s figure out what the median duration run looks like! I’m adding this code to my MyApp.Blog.Post.build/3 function:

1..100 #=> 1..100
|> Enum.map(fn _ ->
  start_time = System.monotonic_time()
  generate_og_image(year, filename, attrs.title, attrs.tags)
  System.monotonic_time() - start_time
end) #=> [...]
|> Enum.sort() #=> [...]
|> Enum.at(49) #=> 17326042
|> System.convert_time_unit(:native, :millisecond) #=> 17
|> dbg()

With a median duration of ~17ms (and an average of ~20ms) I don’t have to think about caching until I’m some hundred posts deep (and even then we’re only talking about seconds added to the build)!

Hi, I'm Dan Schultzer, I write in this blog, work a lot in Elixir, maintain several open source projects, and help companies streamline their development process