Open Graph Image Generation in Rails

2 weeks ago 2

If we have a site that publishes a considerable amount of content, we usually need to generate the assets that go with each piece of content.

For example, if it's a blog post like this one, we might need a cover, diagrams, screenshots, etc.

However, sometimes we neglect the Open Graph image, even if it's arguably one of the most important assets: it's what people see before they decide to read our content or not.

In this article we will learn how to generate Open Graph images with Ruby in a Rails application and how to automate the process using one or more templates.

Let's start by giving a quick glance at what Open Graph is and why you should care about it. If you already know about it, skip to the application setup section.

What is Open Graph

Open Graph is a protocol that uses metadata, specifically: data contained in meta tags, that controls how a webpage appears when its URL is shared on social media.

It was originally developed by Facebook but the protocol is widely used by many other platforms, pages and even mobile applications.

A basic OG tag looks like the following:

<meta name="og:title" content="Open Graph Image Generation with Ruby" />

As you can see, it's a meta tag but it uses the og prefix for each one of the attributes which can be title, description, url, type, among others.

An OG image tag, looks like this:

<meta name="og:image" content="https://d2g0xdrrde9ln1.cloudfront.net/d9otbdv362gs4d3cov13zgw47r98" />

If we share a page that doesn't have specific Open Graph metadata, most platforms will attempt to retrieve and generate a share card using other meta tags like the page title or description and some image that may appear relevant.

But we would rather not leave the way our content looks when shared on social media to the platform itself, we may not like the results.

So, to control the way our webpages look when shared on social media, at the very least we can customize the title and description by providing custom og:title and og:description values.

However, the real magic happens when we add a custom image that's well designed and can help our content stand out and encourage users to click on our content previews.

The standard Open Graph image size is 1200×630px which is the size of Facebook's posts. Some platforms specify a slightly different size which varies a couple of pixels but, for practical reasons, that size is fine.

What we will build

To demonstrate how to add this feature to a Rails application, we will work on a simple blogging application that has an Article model with a title, body and excerpt fields.

Using the title we will generate a custom open graph image after an article is created, upload it using Active Storage so we can access it anytime and then add the image as an OG tag.

To generate the image, we can use two different approaches:

  • Using ImageMagick with the MiniMagick gem: this approach is powerful but a bit more convoluted. We can't use CSS, and achieving the desired text and image positioning can be a bit tedious. However, it is best suited to produce programmatic images with many moving parts that are more difficult to customize with CSS.
  • Using Ferrum as a headless browser: using Ferrum we can render HTML/CSS and screenshot the result using a headless browser. This gives us the flexibility and familiarity of using CSS, but it has some disadvantages like the fact that we need to have Chrome/Chromium installed and that it can be a bit overkill for simple image generation.

For this tutorial, we will use both approaches so we can explore the pros and cons of each one of them in more depth.

We will start with a design that looks like this:

Open Graph image template design

And implement it with both methods so we can see how to implement the feature considering real-world constraints like a design specification.

Now that we know what we will be working on, let's move to the application setup.

If you're reading this article because you've heard that custom Open Graph images might help with SEO, don't hesitate to check our Rails SEO guide to see how you can improve your results.

Application Setup

Let's start by creating a new Rails application:

rails new og_image --css=tailwind --javascript=esbuild

Next, let's install Avo for our Rails Admin panel so we can easily create and work with the Article resource and any other resource we might need in the future:

bundle add avo && bundle install

We then run the Avo installation command which will mount the admin routes at /avo and generate an avo.rb configuration initializer:

bin/rails generate avo:install

Now, let's install Active Storage:

bin/rails active_storage:install

And run the recently created migrations:

We can now create the Article model by running the generate command which will also add an article.rb resource that we can use with Avo:

bin/rails generate model Article title excerpt body:text

We then add a validation to make sure every article has a title and add attachments for the article's cover and og_image:

# app/models/article.rb class Article < ApplicationRecord has_one_attached :cover has_one_attached :og_image validates :title, presence: true end

Now, we make sure to add the cover field to the article.rb Avo resource:

# app/avo/resources/article.rb class Avo::Resources::Article < Avo::BaseResource def fields field :id, as: :id field :title, as: :text field :excerpt, as: :text field :body, as: :textarea field :cover, as: :file end end

We then run the migration:

Now, we should be able to navigate to /avo/articles and see the following:

Avo admin panel article index

Before continuing with our task, we will be using the Satoshi font that you can download here for free.

After downloading it and decompressing it, let's move them to an app/assets/fonts folder so we can access them from our code.

We now have everything set up, we can start with the image generation using the MiniMagick gem:

OG image using MiniMagick

If you haven't used it before, MiniMagick is basically a low-memory replacement for RMagick, which is the go-to Ruby gem to interact with ImageMagick., a popular CLI image manipulation library.

ImageMagick allows us to perform many operations with images using a command line tool which is why it's widely used to dynamically generate images.

We need to make sure it is installed so let's start by running the following command:

Which should return something like this:

Version: ImageMagick 7.1.2-5 Q16-HDRI aarch64 23392 https://imagemagick.org Copyright: (C) 1999 ImageMagick Studio LLC License: https://imagemagick.org/script/license.php Features: Cipher DPC HDRI Modules OpenMP Delegates (built-in): bzlib fontconfig freetype heic jng jp2 jpeg jxl lcms lqr ltdl lzma openexr png raw tiff webp xml zip zlib zstd Compiler: clang (16.0.0)

If it doesn't, please refer to the installation instructions for your operating system and make sure the magick command is working before continuing.

Let's start by installing the mini_magick gem:

bundle add mini_magick && bundle install

Now we can create a class that can receive an Article instance and create an OG image based on the design specifications.

Let's add it in the models directory and start by generating a 1200×630 PNG that has the blue gradient that goes from the top left to the bottom right:

# app/models/og_image_generator.rb class OgImageGenerator OG_WIDTH, OG_HEIGHT = 1200, 630 attr_reader :article def initialize(article) @article = article end def generate image_path = Rails.root.join("tmp", "og_image_#{article.id}.png") create_gradient_background(image_path) image_path end private def create_gradient_background(path) MiniMagick.convert do |convert| convert.size "#{OG_WIDTH}x#{OG_HEIGHT}" convert.define "gradient:direction=NorthWest" convert << "gradient:#2E8CF0-#53BDFF" convert << path end end end

Here, we define a generate method where we will call the individual actions that perform most of the work.

In this case, the create_gradient_background method creates an image that has the dimensions we need with a gradient that goes from the top left to the bottom right corner of the image.

The MiniMagick library abstracts the ImageMagick command that's needed to generate the image we want in a nice and Ruby friendly manner.

The resulting image looks like this:

Image placeholder with a gradient

Certainly nothing to write home about but we're on our way. Let's continue our journey by adding the digital noise image on top of the gradient using the add_noise_overlay method which, in turn, uses the ImageMagick composite method, used to merge two or more images using a blending mode.

Let's add the method:

class OgImageGenerator attr_reader :article OG_WIDTH, OG_HEIGHT = 1200, 630 def initialize(article) @article = article end def generate image_path = Rails.root.join("tmp", "og_image_#{article.id}.png") create_gradient_background(image_path) add_noise_overlay(image_path) image_path end private # Rest of the code def add_noise_overlay(image_path) noise_path = Rails.root.join('app', 'assets', 'images', 'bit-noise.png') MiniMagick.convert do |convert| convert << image_path # Stack creates the parentheses: \( ... \) convert.stack do |stack| stack << noise_path stack.resize "#{OG_WIDTH}x#{OG_HEIGHT}!" stack.alpha "set" stack.channel "A" stack.evaluate "set", "6%" end convert.compose "multiply" convert.composite convert << image_path end end end

When we run this, we get the following result:

Image Magick with image overlay

We're making some progress. Let's start by adding the Avo logo located at the top left corner of the image with a margin of 80px to the borders.

To achieve this, we need to load the logo image, resize it and then generate a composite image with the logo on it

class OgImageGenerator # Rest of the code def add_logo(path) logo_path = Rails.root.join("app", "assets", "images", "logo-white.png") base_image = MiniMagick::Image.open(path) logo_image = MiniMagick::Image.open(logo_path) logo_image.resize "96x" result = base_image.composite(logo_image) do |c| c.geometry "+80+80" end result.write(path) end end

The result looks like this:

Add logo to image with ImageMagick

The next step is to add the text for the title. Let's start with a naive approach where we will display the text without considering the amount of lines:

class OgImageGenerator # Rest of the code def add_title_text(image_path) lines = wrap_text image = MiniMagick::Image.open(image_path) image.combine_options do |c| c.font font_path("black") c.fill "white" c.pointsize "64" c.antialias c.draw "text 80,315 '#{article.title}'" end image.write(image_path) end def font_path(weight = "regular") name = "Satoshi-#{weight_string(weight)}.otf" Rails.root.join("app", "assets", "fonts", name) end def weight_string(weight) { 300 => "Light", "light" => "Light", 400 => "Regular", "regular" => "Regular", 500 => "Medium", "medium" => "Medium", 700 => "Bold", "bold" => "Bold", 900 => "Black", "black" => "Black" }[weight] end end

This produces the following result:

Text generation without multiline in Image Magick

The typography is right so we know that it's loading correctly but, the text overflows the image and is not like the text in the sample design.

To solve this, we need to generate a method that splits the text into an n amount of string elements in an array.

If we check the design specification, the max length for a line is 20 characters long so let's add a wrap_text method that accepts a text and max_length as arguments and returns an array of lines that we can use to iterate over to generate our wrapped text.

class OgImageGenerator # Rest of the code def wrap_text(text, max_length = 20) words = text.split lines = [] current_line = [] words.each do |word| test_line = (current_line + [word]).join(" ") if test_line.length > max_length && current_line.any? lines << current_line.join(" ") current_line = [word] else current_line << word end end lines << current_line.join(" ") if current_line.any? lines end end

What this method does is define a words array together with a lines and current_line empty arrays.

Then it iterates over the list of words, defining a test_line variable that joins the current_line and each word and then tests to see if the test_line exceeds the max_length which is predefined at 20 characters.

If the test_line exceeds the length, it saves the current_line to the lines array and starts a new line with the current word. Otherwise, it simply adds the word to the current_line.

Finally, after processing all words, it adds the last current_line to lines if it contains any words, and returns the array of wrapped lines.

In other words, this method produces an array of words where each element of the array doesn't exceed the max length we previously defined.

Now, we can modify our add_title_text method to call the wrap_text and generate the lines that we iterate to add the title line by line:

class OgImageGenerator # Rest of the code def add_title_text(image_path) lines = wrap_text(article.title) image = MiniMagick::Image.open(image_path) lines.each_with_index do |line, index| y_position = 250 + (index * 72) image.combine_options do |c| c.font font_path("black") c.fill "white" c.pointsize "64" c.antialias c.draw "text 80,#{y_position} '#{line}'" end image.write(image_path) end end end

Now, if we run the generator with this incorporated:

article = Article.first gen = OgImageGenerator.new(article) gen.generate

We get the following result:

OG Image with multi-line text using ImageMagick

We've made some progress now! Let's add a method that adds the domain text:

class OgImageGenerator # Rest of the code def generate # Rest of the calls add_domain_text(image_path, "avohq.io") end def add_domain_text(image_path, domain) image = MiniMagick::Image.open(image_path) image.combine_options do |c| c.font font_path("bold") c.fill "#FFFFAA" c.pointsize 36 c.antialias c.gravity "southwest" c.draw "text 80,80 '#{domain}'" end image.write(image_path) end end

Here, we're adding a text at the 80,80 position with a southwest gravity which means it is positioned 80 pixels from the left and 80 pixels from the bottom of the image.

After we run the generator once again, we get the following result:

Open graph image with domain name

We're already there, our resulting image looks just like the design but, for the sake of it, let's add author information where the domain text and move the domain to the right of the image.

To avoid adding unnecessary code, let's hardcode the values for the author name and avatar to the Article model:

class Article < ApplicationRecord # Rest of the code def author_name "Exequiel Rozas" end def author_avatar_url "https://avatar.iran.liara.run/public/16" end end

Next, let's add a method to add an author avatar and the name:

class OgImageGenerator # Rest of the code def generate # Rest of the calls add_author_info(image_path) end def add_author_info(path) add_avatar(path, article.author_avatar_url) add_author_name(path, article.author_name) end def add_avatar(image_path, avatar_url) base_image = MiniMagick::Image.open(image_path) avatar_image = MiniMagick::Image.open(avatar_url) avatar_image.resize "48x48" result = base_image.composite(avatar_image) do |c| c.gravity "southwest" c.geometry "+80+80" end result.write(image_path) end def add_author_name(image_path, author_name) image = MiniMagick::Image.open(image_path) avatar_size = 48 font_size = 24 padding = 16 # Calculate position next to avatar, vertically centered author_x = 80 + avatar_size + padding author_y = 80 + (font_size / 2) image.combine_options do |c| c.font font_path("bold") c.fill "white" c.pointsize font_size c.interline_spacing 0 c.antialias c.gravity "southwest" c.draw "text #{author_x},#{author_y} '#{author_name}'" end image.write(image_path) end end

What's happening here is that we're adding an avatar that measures 48x48 starting at the bottom left 80px position.

Then, we add the author_name that we take from the article hardcoded value and locate it at 80 pixels from the bottom and add the font size divided by two to locate it.

The result looks like this:

Open graph image with author information

We have achieved our goal of building an open graph image using Ruby. Let's add the ability to attach the image to the Article using Active Storage by using a job so the attachment doesn't get stuck if anything goes wrong.

class AttachImageToArticleJob < ApplicationJob queue_as :default def perform(article_id, image_path) article = Article.find(article_id) article.og_image.attach( io: File.open(image_path), filename: File.basename(image_path), content_type: Marcel::MimeType.for(Pathname.new(image_path)) ) end end

Now, we can invoke the job when generating the image:

class OgImageGenerator # Rest of the code def generate # Rest of the calls attach_to_article(image_path) end def attach_to_article AttachImageToArticleJob.perform_later(article.id, path.to_s) end end

Note that we call to_s on the image path as the job class expects a string and not a Pathname instance.

Now, we can create a job to perform the image generation and add that to the Article callbacks:

# app/jobs/create_og_image_job.rb class CreateOgImageJob < ApplicationJob queue_as :default def perform(article_id) article = Article.find(article_id) OgImageGenerator.new(article).generate end end

The feature is now complete, if we test this we get the following result:

Once we know that everything's working correctly, we can improve the performance by avoiding unnecessary writes to disk so I joined methods that perform similar tasks.

The resulting code is the following:

# app/models/og_image_generator.rb class OgImageGenerator attr_reader :article OG_WIDTH, OG_HEIGHT = 1200, 630 GRADIENT_COLORS = ["#2E8CF0", "#53BDFF"] def initialize(article) @article = article @font_paths = {} end def generate image_path = Rails.root.join("tmp", "og_image_#{article.id}.png") create_gradient_with_noise(image_path) add_logo_and_avatar(image_path) add_all_text(image_path) attach_to_article(image_path) image_path end private def create_gradient_with_noise(path) noise_path = Rails.root.join('app', 'assets', 'images', 'bit-noise.png') MiniMagick.convert do |convert| convert.size "#{OG_WIDTH}x#{OG_HEIGHT}" convert.define "gradient:direction=NorthWest" convert << "gradient:#{GRADIENT_COLORS[0]}-#{GRADIENT_COLORS[1]}" convert.stack do |stack| stack << noise_path stack.resize "#{OG_WIDTH}x#{OG_HEIGHT}!" stack.alpha "set" stack.channel "A" stack.evaluate "set", "6%" end convert.compose "multiply" convert.composite convert << path end end def add_logo_and_avatar(image_path) base_image = MiniMagick::Image.open(image_path) logo_image = MiniMagick::Image.open(Rails.root.join("app", "assets", "images", "logo-white.png")) avatar_image = MiniMagick::Image.open(article.author_avatar_url) logo_image.resize "96x" avatar_image.resize "48x48" base_image = base_image.composite(logo_image) do |c| c.geometry "+80+80" end base_image = base_image.composite(avatar_image) do |c| c.gravity "southwest" c.geometry "+80+80" end base_image.write(image_path) end def add_all_text(image_path) image = MiniMagick::Image.open(image_path) lines = wrap_text(article.title) image.combine_options do |c| c.font font_path("black") c.fill "white" c.pointsize "64" c.antialias lines.each_with_index do |line, index| y_position = 250 + (index * 72) c.draw "text 80,#{y_position} '#{escape_title(line)}'" end c.font font_path("bold") c.fill "#FFFFAA" c.pointsize 36 c.gravity "southeast" c.draw "text 80,80 '#{escape_title("avohq.io")}'" c.fill "white" c.pointsize 24 c.gravity "southwest" author_x = 80 + 48 + 16 author_y = 80 + 12 c.draw "text #{author_x},#{author_y} '#{escape_title(article.author_name)}'" end image.write(image_path) end def wrap_text(text, max_length = 20) words = text.split lines = [] current_line = [] words.each do |word| test_line = (current_line + [word]).join(" ") if test_line.length > max_length && current_line.any? lines << current_line.join(" ") current_line = [word] else current_line << word end end lines << current_line.join(" ") if current_line.any? lines end def attach_to_article(path) AttachImageToArticleJob.perform_later(article.id, path.to_s) end def escape_title(title) title.to_s.gsub("'", "\\\\'").gsub('"', '\\"') end def font_path(weight = "regular") @font_paths[weight] ||= begin name = "Satoshi-#{weight_hash(weight)}.otf" Rails.root.join("app", "assets", "fonts", name) end end def weight_hash(weight) { 300 => "Light", "light" => "Light", 400 => "Regular", "regular" => "Regular", 500 => "Medium", "medium" => "Medium", 700 => "Bold", "bold" => "Bold", 900 => "Black", "black" => "Black" }[weight] end end

A next logical step could be to extract functionality like text generation into their helpers or classes so we can extend the template or make other templates without the need to duplicate the logic.

Please don't hesitate to explore that direction if you intend to add more templates or make an application that needs extensive use of image generation.

OG image using Ferrum

If you haven't heard about Ferrum, it exposes a high-level API to control Chrome. It runs in headless mode by default but we can configure it to run in headful mode if we need.

As it connects to the browser via the CDP protocol, there's no need for dependencies like Selenium so it provides a better experience.

The process to generate an Open Graph image using Ferrum is the following:

  • We create a view that contains the design for our OG image. That view is just like any html.erb view and can receive the article instance as a variable.
  • We perform a visit to that view using Ferrum, screenshot the view and then save the result as an image.
  • We attach that image to the model so we can use it in the view.

Assuming we already have Chrome or Chromium installed, let's start the process by adding the Ferrum gem to our project:

bundle add ferrum && bundle install

Next, let's add an ArticlesController where we will have a show action where we will put the tags later on, and an og_image action that we will use to take the screenshot for the image.

# config/routes.rb get "/articles/:id", to: "articles#show", as: :article get "/articles/:id/og-image", to: "articles#og_image", as: :article_og_image

We can now define the controller with the show and og_image actions:

class ArticlesController < ApplicationController def show @article = Article.find(params[:id]) end end

Then, we add the views under app/views/articles. Starting with the og_image.html.erb view with the code to generate our desired image:

<div class="w-[1200px] h-[630px] bg-gradient-to-br from-[#53BDFF] to-[#2E8CF0] relative"> <%= image_tag "bit-noise.png", class: "absolute inset-0 w-full h-full object-cover mix-blend-overlay pointer-events-none select-none" %> <div class="p-[80px] relative z-10 h-full flex flex-col"> <!-- Top: logo --> <div> <%= image_tag "logo-white.png", class: "w-[96px]" %> </div> <!-- Middle: title --> <div class="flex-1 flex items-center"> <h2 class="text-[64px] leading-[1.1] font-black text-white max-w-[720px]"><%= @article.title %></h2> </div> <!-- Bottom: author left, domain right --> <div class="flex items-center justify-between"> <div class="flex items-center space-x-3"> <%= image_tag @article.author_avatar_url, class: "w-12 h-12 rounded-full" %> <span class="text-white font-medium text-[24px]"><%= @article.author_name %></span> </div> <div> <span class="text-[#FFFFAA] text-[32px] font-medium">avohq.io</span> </div> </div> </div> </div>

Now, if we visit /articles/1/og_image we get the following result:

Open Graph image generated with ERB and Tailwind

As you can see, the result is pretty similar to what we had before but it took us a fraction of the time because the layout is simpler to resolve using HTML and Tailwind.

Let's crack the console open and generate a screenshot using Ferrum to see how it looks. We will open the browser using the width and height for the protocol:

article = Article.first browser = Ferrum::Browser.new(timeout: 15) browser.resize(width: 1200, height: 630) browser.go_to("http://localhost:3000/articles/#{article.id}/og-image") browser.screenshot(path: "og_image_#{article.id}.png") browser.quit

Notice that we're setting the timeout to 15 seconds, mainly because we're loading the avatar from a third-party site which might take a bit to return the avatar.

The next step is to resize the browser to take the width and height of our desired image.

After running this command, we get the following result:

Open Graph image generated with Ferrum

The result is great and it only took us a fraction of the time, mostly because we did the layout using HTML and CSS which are more familiar to us than using ImageMagick.

Now, we need to replicate what we did before and create a job so we can automate this process when creating or updating an article.

Let's start by adding the ability to access URL helpers from within jobs:

# app/jobs/application_job.rb class ApplicationJob < ActiveJob::Base include Rails.application.routes.url_helpers private def default_url_options Rails.application.config.action_mailer.default_url_options || {} end end

Then, let's add the code to make a screenshot in a OgImageFerrumJob to distinguish it from the job for the Minimagick method:

class OgImageFerrumJob < ApplicationJob queue_as :default def perform(article_id) article = Article.find(article_id) url = article_og_image_url(article) path = Rails.root.join("tmp/og_image_#{article.id}.png") browser = Ferrum::Browser.new(timeout: 15) browser.resize(width: 1200, height: 630) browser.go_to(url) browser.screenshot(path: path) browser.quit attach_to_record(article, path) delete_tmp_image(path) end private def attach_to_record(article, path) filename = File.basename(path) content_type = Marcel::MimeType.for(Pathname.new(path)) File.open(path) do |file| article.og_image.attach( io: file, filename: filename, content_type: content_type ) end end def delete_tmp_image(path) begin File.delete(path) if path && File.exist?(path) rescue => e Rails.logger.warn("Failed to delete tmp OG image #{path}: #{e.class}: #{e.message}") end end end

Notice that after generating the screenshot, we're attaching it to the article's og_image attachment with Active Storage and then removing the file that we're temporarily storing in the tmp folder.

The next step is to invoke the job when an article is created or updated:

class Article < ApplicationRecord # Rest of the code after_create_commit :create_og_image after_update_commit :create_og_image, if: :saved_change_to_title? # Rest of the code private def create_og_image OgImageFerrumJob.perform_later(self.id) end end

Now, let's temporarily add the og_image to the article's show page just to see that everything is working correctly:

Open graph image displayed in the article show page

Then, let's change the title to make sure that everything's working:

Open graph image with Ferrum and callbacks

Now everything is working as expected. Let's finish this by adding the Open Graph tags using the meta-tags gem:

Adding the Open Graph tags

We could add the OG tags manually using a partial but let's use the meta-tags gem which can help us further down the road.

Let's start by adding the gem and installing it:

bundle add meta-tags && bundle install

Then, we run the command to add an initializer in case we want to change any of the defaults:

bin/rails generate meta_tags:install

The next step is to display the meta tags in our application layout:

# app/views/layouts/application.html.erb <!DOCTYPE html> <html> <head> <!-- Rest of the code --> <%= display_meta_tags site: "AvoOG" %> </head> </html>

Now, we can add the tags in the ArticlesController:

class ArticlesController < ApplicationController before_action :set_article, only: [:show, :og_image] def show set_meta_tags( title: "#{@article.title} - AvoOG", description: @article.excerpt, site: false, og: { title: "#{@article.title} - AvoOG", description: @article.excerpt, site_name: :site, image: url_for(@article.og_image) } ) end # Rest of the code end

And this should produce the desired result:

Open Graph tags with the auto generated image

Summary

The Open Graph protocol allows us to control how our publications look when shared on social media.

The og:image tag is probably the most important because if we provide it with an image that looks impressive and inspires users to click it can lead to more qualified traffic to our site.

In this article we learned how to generate an OG image using Ruby with MiniMagick and Ferrum: two different approaches that have their pros and cons depending on what our goals are.

For each step we also learned how to generate them automatically when creating or updating resources in Rails so we don't have to worry too much about it for every individual post.

If your needs for Open Graph image generation are important, you can extend what we learned in this tutorial and make more templates and variations.

I hope you enjoyed this article and that it can be useful for you when implementing the feature in your applications.

Have a nice one and, happy coding!


Read Entire Article