Automatically Create Cover Images with Golang

cover.png
I’ve been trying to add more images through out my tutorials to make the site look better. The first thing I did was added a cover image to all tutorials. I create a basic Go script to do this and I’ll share it in this post.

Why? I’ve been using the same stolen image and it is the last 5% that makes a website that more polished.
unpolished.png
This could be:
sample1.png
It doesn’t need to be complicated - gradient image as a background, then a few icons that related to the tutorial and are evenly spaced. I started by creating a new project:

go mod init
go mod tidy

I then installed a resize library and YAML parser.

go get github.com/nfnt/resize
go get gopkg.in/yaml.v3

The YAML parser will be our configuration file, and it looks like:

colorA:
  - 207
  - 237
  - 237
colorB:
  - 181
  - 198
  - 198
a: 255
leftPadding: 50
rightPadding: 50
iconPadding: 10
overlayImageHeight: 200
outputPath: output.png
width: 1300
height: 500
images:
  - test_images/001-landing-page.png
  - test_images/002-browser.png
  - test_images/003-new-window.png

Each value here has some meaning, but the majority will remain static.

  • outputPath :: This is the file path where the output will be set.
  • images :: A list of icons that will be overlayed.
  • width and height :: The width and height of the final image.
  • overlayImageHeight :: The height of the icon image within the image.
  • iconPadding :: The padding between two icons.
  • leftPadding and rightPadding :: The padding on the outside of the image.
  • colorA and colorB :: This is what forms the gradient. They are rgb.
  • a :: The opacity of the final color.

What do I mean?

sample1_marked_up.png

Creating an Image

The first step is to create an image with a gradient background. Thank you to this post for providing a sample. I created a file named create.go and the contents are:

package main

import (
	"image"
	"image/color"
	"image/png"
	"os"
)

// https://varunpant.com/posts/create-linear-color-gradient-in-go/
func createImageWithGradientBackground(config Config) {
	width := config.Width
	height := config.Height
	dst := image.NewRGBA(image.Rect(0, 0, width, height)) //*NRGBA (image.Image interface)

	for x := 0; x < width; x++ {
		for y := 0; y < height; y++ {
			r, g, b := linearGradient(config, float64(x), float64(y), width)
			c := color.RGBA{

				r,
				g,
				b,
				uint8(config.A),
			}
			dst.Set(x, y, c)
		}
	}

	img, _ := os.Create(config.OutputPath)
	defer img.Close()
	png.Encode(img, dst) //Encode writes the Image m to w in PNG format.
}

func linearGradient(config Config, x float64, y float64, max int) (uint8, uint8, uint8) {
	colorA := config.ColorA
	colorB := config.ColorB

	d := x / float64(max)
	r := colorA[0] + d*(colorB[0]-colorA[0])
	g := colorA[1] + d*(colorB[1]-colorA[1])
	b := colorA[2] + d*(colorB[2]-colorA[2])
	return uint8(r), uint8(g), uint8(b)
}

Import Config

We want to create our configuration file, and decode the YAML file into a struct. I’ve added models.go and the contents are:

package main

import (
	yaml "gopkg.in/yaml.v3"
	"os"
)

type Config struct {
	ColorA             []float64 `yaml:"colorA"`
	ColorB             []float64 `yaml:"colorB"`
	A                  float64   `yaml:"a"`
	LeftPadding        float64   `yaml:"leftPadding"`
	RightPadding       float64   `yaml:"rightPadding"`
	IconPadding        float64   `yaml:"iconPadding"`
	OverlayImageHeight float64   `yaml:"overlayImageHeight"`
	OutputPath         string    `yaml:"outputPath"`
	Width              int       `yaml:"width"`
	Height             int       `yaml:"height"`
	Images             []string  `yaml:"images"`
}

func NewConfig(path string) Config {
	// Read in the config yml file
	f, err := os.ReadFile(path)
	if err != nil {
		panic(err.Error())
	}

	// Unmarshal the config yml file
	var config Config
	err = yaml.Unmarshal(f, &config)
	if err != nil {
		panic(err.Error())
	}
	return config
}

Overwrites

Next, I want to be able to easily swap in and out the output path and icons paths. I created a file called main.go and added this function.

func updateConfigWithAdditionalArgs(config Config) Config {
	if len(os.Args) > 2 {
		config.OutputPath = os.Args[2]
	}
	if len(os.Args) > 3 {
		config.Images = os.Args[3:]
	}
	return config
}

You pass in the config, and it returns the config. This will allow us to run:

./cover_image_gen config.yml
./cover_image_gen config.yml output.png icon1.png icon2.png icon3.png

You may want to create a configuration for each configuration, or create a single config then adjust the inputs.

Overlay Images

Let’s add the remaining code to main.go. There are three methods: main , updateConfigWithAdditionalArgs (explained above), and overlayImages.

package main

import (
	"fmt"
	"github.com/nfnt/resize"
	"image"
	"image/draw"
	_ "image/jpeg" // Register JPEG format
	"image/png"
	_ "image/png" // Register PNG  format
	"os"
)

func main() {
	if len(os.Args) < 2 {
		fmt.Printf("Usage: %s <config file>\n", os.Args[0])
		os.Exit(1)
	}
	config := NewConfig(os.Args[1])
	config = updateConfigWithAdditionalArgs(config)
	createImageWithGradientBackground(config)
	overlayImages(config)
}

Let’s explain this main function. First, we need a least 1 argument. The first argument is always the executable, so the second element will be the config. If not provided, exit.

Next, parse the config, and apply the overwrites (if applicable). We then create a gradient image. Finally, overlay the icons on top of the gradient image.


func updateConfigWithAdditionalArgs(config Config) Config {
	if len(os.Args) > 2 {
		config.OutputPath = os.Args[2]
	}
	if len(os.Args) > 3 {
		config.Images = os.Args[3:]
	}
	return config
}

This code was explained above.

func overlayImages(config Config) {
	baseImagePath := config.OutputPath
	additionalImagePaths := config.Images

	// Load in the base image
	baseImageFile, err := os.Open(baseImagePath)
	if err != nil {
		panic(err.Error())
	}
	img, _, err := image.Decode(baseImageFile)
	if err != nil {
		panic(err.Error())
	}

	// Determine dimensions, and column widths
	fmt.Printf("Base image is width=%d, height=%d\n", img.Bounds().Dx(), img.Bounds().Dy())
	workingSpace := img.Bounds().Dx() - int(config.LeftPadding) - int(config.RightPadding)
	fmt.Printf("Working space is %d\n", workingSpace)
	colWidth := workingSpace / len(additionalImagePaths)
	fmt.Printf("Column width is %d\n", colWidth)

	// Create a new image to place the overlay images on
	output := image.NewRGBA(img.Bounds())
	draw.Draw(output, img.Bounds(), img, image.Point{0, 0}, draw.Src)

	// Iterate through each image to be placed
	for i, additionalImagePath := range additionalImagePaths {
		// Read in the image being placed
		additionalImageFile, err := os.Open(additionalImagePath)
		if err != nil {
			panic(err.Error())
		}
		additionalImage, _, err := image.Decode(additionalImageFile)
		if err != nil {
			panic(err.Error())
		}
		fmt.Printf("Additional image is orginally width=%d, height=%d\n", additionalImage.Bounds().Dx(), additionalImage.Bounds().Dy())

		// Resize the image to make all equal height
		additionalImage = resize.Resize(uint(config.OverlayImageHeight), 0, additionalImage, resize.Lanczos3)

		fmt.Printf("Additional image is (after resize) width=%d, height=%d\n", additionalImage.Bounds().Dx(), additionalImage.Bounds().Dy())

		// Determine the center position of the column. This would be half the remaining space after subtracting the
		// overlay image.
		leftX := ((int(config.IconPadding) * 2) + colWidth - additionalImage.Bounds().Dx()) / 2
		// Math is remove padding. Take the column width, remove icon width. This leaves the remaining space. Divide
		// that by 2 to get the amount of space on either side.
		leftX += int(config.IconPadding) // Re-add the icon padding.
		leftX += i * colWidth            // Add the column width for each column to the left.
		leftX += int(config.LeftPadding) // Add the left padding.

		// Determine the center position of the image. This would be half the remaining space after subtracting the
		// overlay image.
		topY := (img.Bounds().Dy() - int(config.OverlayImageHeight)) / 2

		fmt.Printf("Placing image %d at %d, %d\n", i+1, leftX, topY)

		// Place the image on top of the page image
		draw.Draw(output, additionalImage.Bounds().Add(image.Point{X: leftX, Y: topY}), additionalImage, image.ZP, draw.Over)
	}

	// Save the output
	outputImg, _ := os.Create(config.OutputPath)
	defer outputImg.Close()
	png.Encode(outputImg, output) //Encode writes the Image m to w in PNG format.
}

I’ve done my best to comment throughout the code. The basic flow is:

  1. Grab the base iamge
  2. Determine the number of columns and the width of each.
  3. Draw a new output image with the base image
  4. Then iterate through each icon. The objective is to find the top left corner.
  5. Write all of it out.

That’s it! You can run it with:

go run ./ config.yml

Or you can build an executable to more easily add it to your CI/CD pipeline:

go build -o cover_image_gen .
GOOS=linux GOARCH=amd64 go build -o cover_image_gen_linux .

chmod +x ./cover_image_gen

./cover_image_gen sample.yml

# If you want to add it to your path
cp ./cover_image_gen /usr/local/bin/cover_image_gen
# Open a new terminal window
cover_image_gen sample.yml

Thanks for reading! Here are some samples:

sample1.png
sample2.png
sample3.png
sample4.png

Resources