Blog post

Generative metatag images in Rust

2022-06-23T15:00:00

14 minute read

This blog post is powered by shuttle! The serverless platform built for Rust.

What open graph tags are

Links are bare and unreadable. They can contain symbols to parse and cannot contain spaces.

https://www.shuttle.rs/blog/2022/06/16/a-short-introduction-to-async-rust

The above url isn't the most user friendly way of understanding what the post contains. With referrer parameters and such it only gets more unreadable.

Meta tags are special HTML elements that you can add to HTML responses which show nicer previews:

You can get this additional preview by setting the following HTML elements inside the <head> tag.

1<!-- Primary Meta Tags -->

2<meta name="title" content="...">

3<meta name="description" content="...">

4

5<!-- Open Graph / Facebook -->

6<meta property="og:type" content="website">

7<meta property="og:url" content="...">

8<meta property="og:title" content="...">

9<meta property="og:description" content="...">

10<meta property="og:image" content="...">

11

12<!-- Twitter -->

13<meta property="twitter:card" content="summary_large_image">

14<meta property="twitter:url" content="...">

15<meta property="twitter:title" content="...">

16<meta property="twitter:description" content="...">

17<meta property="twitter:image" content="...">
18

These tags are easily scrapable1 by bots which allow them to be added to places where links can be shared, such as messages and tweets. Unfortuantly meta tags don't really have a specification which is why it is best to include both the open graph protocol and the twitter card specific tags.

When links are shared with these tags in the response, the platform can add the adornments to the message.

These previews make it easier to see what the content is before following the link. https://metatags.io/ is a great site if you want to preview what the meta tags look like.

Creating open graph tags and images

The specific tag of interest here is:

1<meta property="og:image" content="...">

2<meta property="twitter:image" content="...">
3

Here the content is a url to an image. In the case of a blog post, we create specific graphics for it, upload it as an asset then set the URL to the path of the uploaded asset.

This is fine for static content. However for dynamic pages which may be user generated content, manual image creation isn't really possible. You can also use this method if you don't have individual custom meta images for each of your static posts.

A while back GitHub added custom images for links on pull request, which includes information about the pull request. They even wrote a blog post about how they did it.

I really like the result, however wondered if there was a alternative to the way they implemented it. GitHub uses a headless browser2 to do this which is less portable and includes spinning up a execessive process to generate a simple image.

In this post we'll attempt to create similar graphics using lowerlevel libraries and Rust.

Image generation in Rust

To easily create graphics we will be using SVG. It's the most used format for vector graphics and supports embedding text, images and shapes. Since it's a vector graphic, shapes and text remain crisp no matter the size of the output image. It's readable and easily modifiable.

The problem is that open graph images don't support displaying SVGs in previews due to the fact they are more complex of a format to render.

So we have to make a step to turn our SVGs into another image format.

The scalable vector graphic format

We will start exploring the format with a simple SVG with three shapes in different colors and a rectangle used to give the graphic a white background.

1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 
630">

2    <rect width="1200" height="630" fill="white"></rect>

3    <rect x="260" y="215" width="200" height="200" fill="red"></rect>

4    <circle cx="600" cy="315" r="100" fill="green"></circle>

5    <path d="M 740 415 l 100 -200 l 100 200 z" fill="blue"></rect>

6</svg>
7

SVGs renders line by line, so the white box will be at the behind, rendering the shapes in front.

Turning SVGS into WEBP images with Rust

We can draw images in Rust using resvg which handles rendering SVGs. It expects a parsed svg tree from usvg so we'll also be needing that. Internally it uses tiny_skia which is a "tiny Skia subset ported to Rust". Resvg and tiny skia have all the building blocks we need to do basic image generation. We'll also use Pixmap which holds the pixels that we will be generating and then encode it in webp format3 to minimize output file size.

cargo add resvg tiny-skia usvg webp

1use resvg::render;

2use std::{error::Error, fs, time::Instant};

3use tiny_skia::{Pixmap, Transform};

4use usvg::{Options, Tree};

5use std::fs;

6

7const WIDTH: u32 = 1200;

8const HEIGHT: u32 = 630;

9

10fn main() -> Result<(), Box<dyn Error>> {

11    // Read in the svg template we have 

12    let svg = include_str!("shapes.svg");

13    

14    // Create a new pixmap buffer to render to

15    let mut pixmap = Pixmap::new(WIDTH, HEIGHT)

16        .ok_or("Pixmap allocation error")?;

17    

18    // Use default settings

19    let mut options = Options::default();

20    

21    // Build our string into a svg tree

22    let tree = Tree::from_str(svg, &options.to_ref())?;

23    

24    // Render our tree to the pixmap buffer, using default fit and transformation settings

25    render(

26        &tree,

27        usvg::FitTo::Original,

28        Transform::default(),

29        pixmap.as_mut(),

30    );

31    

32    // Encode our pixmap buffer into a webp image

33    let encoded_buffer =

34        webp::Encoder::new(pixmap.data(), webp::PixelLayout::Rgba, WIDTH, HEIGHT).encode_lossless();

35    let result = encoded_buffer.deref();

36    

37    // Write the result

38    fs::write("image.webp", result)?; 

39    

40    Ok(())

41}
42

The above code generate a image.webp with the colorful shape image shown above.

Going further

Lets add some text to the graphic. We could use the default Times New Roman font - but let's get a little more fancy. Google Fonts is a great resource for free font files. You can download any of the families on there and extract the specific .ttf font you want in and include it in the binary using include_bytes!(). In this demo I am using Inter.

1// ...

2let mut options = Options::default();

3

4options

5    .fontdb

6    .load_font_data(include_bytes!("Inter.ttf").to_vec());

7

8// ...
9

Templating

As our page will be dynamic, we'd like to insert strings defined in our Rust code onto the SVG.

To do this we'll use the templating engine liquid. (cargo add liquid)

1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630">

2    <rect width="1200" height="630" fill="black" />

3    <text 
        x="600" 
        y="315" 
        text-anchor="middle" 
        font-size="100px" 
        fill="white" 
        font-family="Inter"
    >

4        {{ text }}

5    </text>

6</svg>
7

We can add a new text node that is positioned in the center of the graphic. Using font-family="Inter" we can specify the font to be Inter. Liquid uses double braces {{ ... }} for interpolation.

In our Rust code we'll change the string we use to build the tree to be the output of our of liquid template. The liquid::object! macro sets the data we we'll be rendering.

1let template = liquid::ParserBuilder::with_stdlib()

2    .build()

3    .unwrap()

4    .parse(include_str!("template.svg"))

5    .unwrap();

6

7let globals = liquid::object!({

8    "text": "test"

9});

10

11let svg = template.render(&globals).unwrap();

12// Build our string into a svg tree

13let tree = Tree::from_str(&svg, &options.to_ref())?;
14

Which will render the following:

Adding images

So far we have seen shapes and text. We're gonna step it up a bit by adding an image to the SVG. There are many ways to add a images to an SVG but we will use <pattern>

1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630">

2    <defs>

3        <pattern id="img" x="0" y="0" width="1" height="1" viewBox="0 0 500 500" preserveAspectRatio="xMidYMid slice">

4            <image width="500" height="500" href="https://presentations.bltavares.com/ouvi-falar-de-rust/ferris.png"/>

5        </pattern>

6    </defs>

7    <rect width="1200" height="630" fill="black" />

8    <rect width="400" height="400" x="200" y="115" fill="url(#img)" />

9    <text x="630" y="330" font-size="100px" fill="#ffffff" font-family="Inter" >

10        {{ text }}

11    </text>

12</svg>
13

The pattern includes a <image> with a href that points to the one and only ferris.

However resvg looks up images in the filesystem by default so we have to rewrire the handler which turns paths into binary image representation. We can do that using reqwest and its blocking client (add reqwest using cargo add reqwest -F blocking).

We change the options to a custom function which gets the response, figures out the encoding and pulls out the images bytes:

1let mut options = Options {

2    image_href_resolver: ImageHrefResolver {

3        resolve_string: Box::new(move |path: &str, _| {

4            let response = reqwest::blocking::get(path).ok()?;

5            let content_type = response

6                .headers()

7                .get("content-type")

8                .and_then(|hv| hv.to_str().ok())?

9                .to_owned();

10            let image_buffer = response.bytes().ok()?.into_iter().collect::<Vec<u8>>();

11            match content_type.as_str() {

12                "image/png" => Some(ImageKind::PNG(Arc::new(image_buffer))),

13                // ... excluding other content types

14                _ => None,

15            }

16        }),

17        ..Default::default()

18    },

19    ..Default::default()

20};
21

And now we have:

Benchmarking

Compared to the headless browser technique this process is faster. While doing a lot of the same things that the headless browser process was doing, we have picked out the only part we wanted, the svg renderer.

With some rough benchmarks the Rust loading, rendering and encoded was two times faster than the nodejs puppeteer equivalent (100ms vs 200ms).

This time accounts for the startup time in the nodejs version. If you aren't retaining the browser window then the results are even more noticable. Generating one of images (cold start) the Rust version is 7x faster.

Aside from rendering performance the Rust version is self contained, only the compiled binary is needed to generate the image. No having to worry about whether chromium is in the environment. This is huge benefit if you are doing image generation in a serverless environment.

Also the headless browser version was harder to work with, importing fonts and setting the output size was considerably more complicated.

Conclusion

Hopefully this was a interesting post and taught some things about generating images in Rust. This technique can be used for other types of image generation not just for meta tag results.

To complete the result all you need to do is hook up a service for the url in the meta tag. Rather than saving the image to the file system you would then return send the bytes back over the wire. For images that aren't updated you should be caching the images that are generated as to not regenerate them on every request.

Full code for the demo is here

And if you are looking for a service to host your new procedurally generated meta tag images, why not try shuttle:

Shuttle: Stateful Serverless for Rust

Deploying and managing your Rust web apps can be an expensive, anxious and time consuming process.

If you want a batteries included and ops-free experience, try out Shuttle.


Footnotes

  1. HTML elements in a response can easily parsed without having to run JavaScript

  2. A browser which is controlled via code. puppeteer and selenium are good examples.

  3. If you just want png output then: let encoded_buffer = pixels.encode_png().unwrap();

Share this article

Let's make Rust the next language of cloud-native

We love you Go, but Rust is just better.