This post inspired me to write this article:

og-auto-q-reddit

Previously I wrote about setting Meta tags in a Rails app. Meta tags really make your web pages more “shareable”.

But generating OpenGraph images can be a challenge. There are many businesses built around this. For example:

But you are a great developer! You don’t need to pay for a tool that you can just build, right?

Let’s build an OpenGraph image generator!

We could use Rmagick to draw images with their API.

But maybe an easier approach would be to generate some HTML, open it in a browser, and take a screenshot.

For this we will use Gem Ferrum that is a headless Chrome API.

There are a few levels of coolness/complexity for taking screenshots of an url:

  • Level 1.1. Take a screenshot of an URL
  • Level 1.2. Take a screenshot of an URL selector (id/class)
  • Level 2.1. Take a screenshot of an URL with a dedicated template
  • Level 2.2. Visit web page, parse the meta %i[title, description, logo, date] & autocomplete your generic template

Level 1.1. Take a screenshot of an URL #

Example of final result - a screenshot of a page:

og-screenshot

We will leverage the Ferrum Screenshot API:

# rails g job UrlToImage
# url = "https://superails.com/posts/181-search-and-autocomplete-french-company-information"
# UrlToImageJob.perform_now(url)
class UrlToImageJob < ApplicationJob
  queue_as :default

  def perform(url)
    browser = Ferrum::Browser.new
    browser.resize(width: 1200, height: 630)
    browser.goto(url)
    # browser.screenshot(path: "tmp/screenshots/#{url.parameterize}.jpg")
    # browser.screenshot(path: "tmp/screenshots/#{url.parameterize}.jpg", quality: 40, format: "jpg")
    # browser.screenshot(path: "tmp/screenshots/#{url.parameterize}.jpg", quality: 40, format: "jpg", full: true)
    # sleep 0.5
    # browser.screenshot(path: "app/assets/images/opengraph/#{url.parameterize}.jpg", quality: 40, format: "jpg", selector: "main")
    browser.screenshot(path: "app/assets/images/opengraph/#{url.parameterize}.jpg", quality: 40, format: 'jpg')

    # tempfile = Tempfile.new
    # browser.screenshot(path: tempfile.path, format: 'jpg', quality: 40)
    # Post.first.image.attach(io: File.open(tempfile.path), filename: "#{post.url.parameterize}.jpg")
  ensure
    browser.quit
  end
end

ℹ️ Generating JPEG can be faster than PNG.

And here’s a helper to access the generated image based on the current URL:

# app/helpers/application_helper.rb
  def meta_opengraph_image_asset_path
    base_url = "https://superails.com"
    image_name = [base_url, request.path].join.parameterize
    full_path = "opengraph/#{image_name}.png"
    helpers.image_url(full_path)
  rescue StandardError
    image_url('logo.png')
  end

Display the image in meta tags

# app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
  <head>
+    <%= yield :head %>
  </head>
</html>
# posts/show.html.erb
<%= content_for :head do %>
  <%= tag.meta(property: 'og:image', content: meta_opengraph_image_asset_path) %>
  <%#= tag.meta(property: 'og:image', content: rails_blob_url(@event.og_image)) %>
  <%= tag.meta(property: 'twitter:card', content: "summary_large_image") %>
<% end %>

This way we store the image in our assets. It is for you to decide a better way to deliver these images to production.

Level 2.1. Take a screenshot of an URL with a dedicated template #

Example of final result:

og-ferrum-with-layout

Instead of visiting an URL, we can just render plain HTML in Ferrum and take a screenshot of it:

# https://github.com/rubycdp/ferrum/blob/main/lib/ferrum/frame.rb#L109
browser = Ferrum::Browser.new
browser.resize(width: 1200, height: 630)
frame = browser.frames.first
frame.body
# => "<html><head></head><body></body></html>"
frame.content = "<html><head></head><body>Voila!</body></html>"
frame.body
# => "<html><head></head><body>Voila!</body></html>"

Now we can create a layout and template for our open graph images!

A minimalistic CSS-only layout:

<!-- app/views/layouts/minimal.html.erb -->
<!DOCTYPE html>
<html>
  <head>
    <title>Og</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
  </head>
  <body style="background: linear-gradient(to right, #fdf497, #fdf497, #fd5949, #d6249f, #285AEB); display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0;">
    <div style="transform: rotate(4deg);">
      <%= yield %>
    </div>
  </body>
</html>

A template for generating post images:

<!-- app/views/posts/og.html.erb -->
<div style="border: 1px solid #ddd; padding: 2rem; background-color: #fff; border-radius: 15px; margin: 2rem; display: flex; flex-direction: column; align-items: center; gap: 1rem; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); max-width: 600px; margin: auto;">
  <div style="height: 80px; margin-bottom: 1rem;">
    <img src="http://localhost:3000/logolong.png" height="80">
    <%#= image_tag asset_url('logo.png'), style: 'width: 100%; height: 80px;' %>
  </div>
  <div style="word-break: break-word; font-weight: bold; font-size: 2rem; text-align: center; color: #333;">
    <%= @post.title %>
  </div>
  <div style="width: 100%; display: flex; justify-content: center; margin-bottom: 1rem;">
    <%= image_tag local_image_url(@post), style: 'max-width: 100%; height: auto; border-radius: 10px;' %>
  </div>
  <div style="font-size: 0.8rem; color: #777; font-style: italic; font-weight: bold;">
    <%= @post.published_at.strftime('%B %d, %Y') %>
  </div>
</div>

Notice the asset_url & local_image_url. Ferrum needs absolute, not relative image urls to display images

def local_image_url(post)
  "http://localhost:3000#{rails_blob_path(post.image)}"
  # "http://localhost:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6MSwicHVyIjoiYmxvYl9pZCJ9fQ==--8a5968eca5f43d315dfe06b402d555eddbcbc994/https-www-railsconf-com.jpg"
  # url_for(post.image)
  # rails_blob_url(post.image)
  # "#{request.base_url}#{rails_blob_path(post.image)}"
end

Finally, here’s the job to generate a screenshot from the above HMTL:

# post = Post.first
# PostToImageJob.perform_now(post)
class PostToImageJob < ApplicationJob
  queue_as :default

  def perform(post)
    @post = post
    html = ApplicationController.render(
      template: 'posts/og',
      layout: 'minimal',
      assigns: { post: @post })

    browser = Ferrum::Browser.new
    browser.resize(width: 1200, height: 630)
    frame = browser.frames.first
    # frame.content = "<html><head></head><body>Voila!</body></html>"
    frame.content = html

    # ensure all images are loaded!
    browser.network.wait_for_idle
    # double check if all images are loaded!!
    sleep 1

    browser.screenshot(path: Rails.root.join('tmp', 'screenshot.png'))
    browser.screenshot(path: "app/assets/images/opengraph/posts/#{post.id}.png", quality: 40, format: 'jpg')

    # tempfile = Tempfile.new
    # browser.screenshot(path: tempfile.path, format: 'jpg', quality: 40)
    # Post.first.og_image.attach(io: File.open(tempfile.path), filename: "#{post.url.parameterize}.jpg")
ensure
    browser.quit
  end
end

That’s it! 🤠

Related resoures: