Generating Social Images using Go and GG.
How to draw images, and different kinds of texts.
Hello there, fellow Gophers (non-Gophers, and yet-to-be-Gophers too). Today I’d like to share how to generate social media cover images (like the ones used by this blogpost, if you came from Twitter).
You can think of this article as an unofficial prequel to Jason Lengstorf’s “Automatically Generate Social Images for Blog Posts”, which I used as an inspiration for creating my own social media images. This time though we’ll be building a Go service ourselves, instead of depending on Cloudinary.
Reinventing the wheel can be fun.
If you are curious the exact codebase that I use for this blog exists here. It’s also pretty much the exact same code we’ll be building here, so you can go ahead and use it as a reference.
Dependencies
Let’s start by talking about the main package we will pull to do most of the hard work for us (OK, reinventing the axle is not as fun). https://github.com/fogleman/gg is a 2D rendering library that we’ll use to render our images, text, deal with line wrapping and other complex things.
Starting out
Create a new folder for your project. For your reference, this’ll be the project structure we’ll be aiming at. You don’t have to create all of those folders and files yet, we’ll get there.
project-name
cmd/
main.go
pkg/
drawer/
fonts.go
images.go
internal/
logo/
logo.go
go.mod
go.sum
Let’s start by setting up a Go module for ourselves. This will make working with dependencies easier, and is considered a standard at this point in time.
$ go mod init github.com/<your_username>/<your_project_name>
Note: You don’t have to usegithub.com
. The package won’t be public in any way. You don’t even have to use a URL. The package name could just as well bego mod init mypackage
. It’s a convention to use a domain so that the package names are guaranteed to be unique.
Drawing a rectangle
Let’s start by drawing a rectangle of the correct size with the correct colour. For most social media images you want to aim at 1280x668 pixels.
Let’s start in internal/logo/logo.go
. internal
folder has a special meaning in Go. Any packages declared in internal
directory cannot be pulled and used by others even if the whole repository is public. I like to use internal
directory for any project-specific packages, while keeping pkg
a collection of packages that could be reused by myself or others.
You might need to run go get -u github.com/fogleman/gg
if your code editor won’t do it for you.
Note: Don’t skip the comments in the code blocks! I provided a lot of details in there.
// internal/logo/logo.go
package logo
import (
"github.com/fogleman/gg"
)
const Width = 1280
const Height = 668
// TextRightMargin tells how much (in pixels) the text will end at from the right side.
const TextRightMargin = 60.0
func DrawLogo() error {
// Start by creating a "context" on which we'll paint.
dc := gg.NewContext(Width, Height)
// We declare a Rectangle with a given Width and Height, starting in the (0, 0) pixel.
dc.DrawRectangle(0, 0, Width, Height)
dc.SetHexColor("#FFFFFF") // We select it's colour.
dc.Fill() // And fill the context with it.
// Save the result to a .png file.
err := dc.SavePNG("output.png")
if err != nil {
return err
}
// All is fine - return no errors.
return nil
}
To get something actually running, in cmd/main.go
let’s run our function from the main
. In case of any errors, we’ll log them to stderr
and return an error code.
// cmd/main.go
package main
import (
"fmt"
"os"
// Note: instead of <...> use package name you've set in `go mod init` command!
"<...>/internal/logo/logo.go"
)
func main() {
err := logo.DrawLogo()
if err != nil {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
}
os.Exit(0)
}
We can test our code by running go run cmd/imagegen/main.go
. It should create a rectangle of your chosen colour in the root directory of your project.
Drawing text
Let’s move on to drawing the title and the subtitle. For that we’ll need:
- a free font (you can find one online, or use Ubuntu font which you can grab from the example repository),
- draw the text, allowing it to wrap nicely,
- accept input from the user as arguments to our program.
Let’s start by creating a helper package that will draw text for us using gg
. We’ll call it later in our internal/logo/logo.go
package. In pkg/drawer/fonts.go
, we’ll start by defining where to look for our font files, loading a font, and then drawing it in a correct place of our draw context.
// pkg/drawer/fonts.go
package drawer
// As in "a thing that draws"... I know, I know...
import (
"path/filepath"
"github.com/fogleman/gg"
)
// Our fonts will live in `assets/fonts/` folder (looking from the root directory).
var FontRootPath = filepath.Join("assets", "fonts")
// DrawText draws a string s with the color c in with a given fontname at the given lMargin while wrapping it at rMargin. yOffset is used to declare how far from the middle the text should start. ax tells whether the text should render towards the top, or towards the bottom of its starting coordinate.
func DrawText(dc *gg.Context, s, c, fontname string, lMargin, rMargin, yOffset, ax float64) error {
err := loadFont(dc, fontname)
if err != nil {
return err
}
// Divide the height by two, and add the offset. This is how we'll control were the text should be positioned relative to the center. You could select different anchor if it makes more sense for your image.
y := float64(dc.Height())/2.0 + yOffset
// Calculate the maximum width that the text could be by taking the width of the draw context and subtracting the amount of left margin and the amount of right margin.
maxWidth := float64(dc.Width()) - lMargin - rMargin
dc.SetHexColor(c) // Set the text colour.
// DrawStringWrapped will draw the text `s` at the given coordinates. It wraps after it reaches it's `maxWidth`. `ax` controls whether the text should be drawn upwards or downwards.
dc.DrawStringWrapped(s, lMargin, y, 0, ax, maxWidth, 1.5, gg.AlignLeft)
return nil
}
As you can see, we’re passing the draw context as a pointer downwards to all the functions that will work on it. We’ll also have to define the loadFont
function that will find a given font and load it from a file.
// pkg/drawer/fonts.go
func loadFont(dc *gg.Context, fontname string) error {
fontpath := filepath.Join(FontRootPath, fontname)
// We use the path we've created from the base path and the fontname. We also specify the size of the font.
if err := dc.LoadFontFace(fontpath, 56); err != nil {
return err
}
return nil
}
Let’s move back to our internal/logo/logo.go
and specify that we want to draw a title and a subtitle using our new helper package.
// internal/logo/logo.go
import (
// ...
"<...>/pkg/drawer"
)
func DrawLogo(title, subtitle string) error {
// ...creating a context and drawing a rectangle.
err := drawTitle(dc, title)
if err != nil {
return err
}
err = drawSubtitle(dc, subtitle)
if err != nil {
return err
}
// ...saving the file to .png.
}
We also need to create those functions: drawTitle
and drawSubtitle
, so let’s add them below. Both are very similar, they use our DrawText
function with set parameters.
// internal/logo/logo.go
// ...
func drawTitle(dc *gg.Context, s string) error {
err := drawer.DrawText(dc, s, "#5A67D8", "Ubuntu-Medium.ttf", 480.0, TextRightMargin, 0.0, 1)
if err != nil {
return err
}
return nil
}
func drawSubtitle(dc *gg.Context, s string) error {
err := drawer.DrawText(dc, s, "#000000", "Ubuntu-Light.ttf", 480.0, TextRightMargin, 45.0, 0)
if err != nil {
return err
}
return nil
}
As we have not provided any source for title
and subtitle
variables yet, this code will still throw compiler errors. Let's fix it together. We’ll accept those values from the user as command line arguments like so: imagegen "title" "subtitle"
.
Accepting input
In cmd/main.go
, we’ll use the already imported os
package.
// cmd/main.go
func main() {
// Arguments for the title and subtitle start at 1.
title := os.Args[1]
subtitle := os.Args[2]
// Remember to pass in the title and subtitle!
err := logo.DrawLogo(title, subtitle)
if err != nil {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
}
os.Exit(0)
}
Error handling
For brevity, I’m simplifying all the parts that are not immediately relevant to the drawing itself. The error handling for example. Instead of just returning errors, it might be a good idea to wrap those errors with additional information that each failing step would give. You can look it up in the GitHub repository. Be aware though, that the library I’ve used there (https://github.com/pkg/errors
) is currently looking for a new maintainer, so I can’t vouch for its future at the time of writing this post.
Drawing images
Drawing images is similar and comparatively simple to drawing text. We don’t have to deal with word wrapping for instance. Let’s separate our font code from our image code. In pkg/drawer/images.go
let’s add a familiarly looking code.
// pkg/drawer/images.go
package drawer
import (
"path/filename"
"github.com/fogleman/gg"
)
// ImageRootPath sets the path to where the fonts are.
var ImageRootPath = filepath.Join("assets", "images")
// DrawImage uses gg to render a specified image at a given location. The root folder from where the image is taken is configured by ImageRootPath.
func DrawImage(dc *gg.Context, imagepath string, x, y int) error {
ip := filepath.Join(ImageRootPath, imagepath)
image, err := gg.LoadImage(ip)
if err != nil {
return err
}
// This time all we need is just a loaded image and the coordinates of where it should go.
dc.DrawImage(image, x, y)
return nil
}
The only thing left now is to add the image drawing to our logo code. In internal/logo/logo.go
let’s add a new helper function and a piece of code that’ll call our package to draw the image for us.
// internal/logo/logo.go
import (
// ...
"<...>/pkg/drawer"
)
func DrawLogo(title, subtitle string) error {
// ...creating a context and drawing a rectangle.
// ...drawing the title and subtitle.
err = drawLogo(dc)
if err != nil {
return err
}
// ...saving the file to .png.
}
func drawLogo(dc *gg.Context) error {
err := drawer.DrawImage(dc, "logo.png", 80, 210)
if err != nil {
return err
}
return nil
}
Final steps
And that’s it! The next steps would involve integrating this program into your own workflow. You might to automatically push it to some S3 bucket on AWS or other file storage. Or maybe you’d like to have our Go program to be a part of some CI pipeline that will automatically generate the image for us? Maybe within a JAMstack page?
The next steps are up to you!