How To Create a PDF in Go: A Step-By-Step Tutorial

Marvin on 2022-09-29

Golang: How To Guide

How To Create a PDF in Go: a Step-by-Step Tutorial

Follow Along As We Generate a PDF Invoice From Scratch

Introduction

If you haven’t lived under a rock for the last 20 years, you’ll know that PDFs can be difficult to work with, especially when dealing with dynamic content (e.g., personalized information). In the case of invoices, this becomes increasingly prominent and clear.

I mean, nobody wants to manually change information on each PDF. Do they?

It would be much easier if it were stored in a database somehow and a script was created that extracted the necessary data to pass it to a library. That way you could scale it (and save yourself a few headaches in the process).

And that’s exactly what we’ll do here.

It’s worth noting that in some cases, a user can generate a PDF invoice on the front end, while in others, a system will generate one in the background and email it to the user at the end of a billing cycle. We’ll specifically address the latter.

📌 Disclaimer: I chose UniDoc to help me with this because it has a wide range of customization, extensive documentation, examples, and is quite honestly, simple to set up and use. But there are a couple other PDF Go packages that you can also use, such as Gopdf, Gofpdf (deprecated), and Pdfcpu. PDFTron also has a Go SDK, but it’s not in native Go so it requires some dependencies.

Just note that whatever your preference, the logic remains the same; a package is simply a means to an end.

What you will learn

  1. Why you should sketch a PDF invoice before writing code to generate it
  2. How to visualize a simple architectural design for it
  3. How to create a invoice-pdf.go file
  4. How to create a PDF invoice: general structure, header, letterhead, invoice items, and footer
  5. How to generate the final output of your invoice PDF
  6. [Bonus] Why you shouldn’t use float with money and currency

Prerequisites

Getting started

You can follow along here or have a look at the code base on this GitHub repository:

GitHub - marvinhosea/invoice-pdf-generator github.com

First, navigate to the directory where you can create your Go applications and create a new directory called invoice-pdf-generator.

Now navigate to the project root directory and create a Go module by running the instructions below in the terminal within the project root directory:

go mod init github.com/username/invoice-pdf-generator

👉 Note: Please substitute your Github username for [username] above

📖 Further Reading: We’ll be using Go modules to manage our project dependencies; you can learn more about them here.

PDF sketch

Now that we’ve set up our project and are ready to code, let’s create a simple visual draft of what our invoice PDF should look like:

Visualization of what our invoice PDF should ideally look like

💡 Before starting on any project, I’ve personally learned it best to envision what the output should be. This way you can stay focused on what needs-to-be-done, and linearly structure your problem-solving thought process.

From the image above you can see the design blocks of data for our invoice PDF.

Simple architecture design

Let’s understand the data flow a bit more, and visualize the architectural design:

Architectural design of our ideal invoice

Here, our invoice pdf generator service is a simple program that simulates accepting invoice data requests with the data provided to generate an invoice PDF, which is then stored in an s3 bucket or preferred storage medium.

Open the project in your favorite Go IDE and create amain.go file. Add the package declaration as main and the main function:

main.go

package main

func main() {
  
}

👉 Note: We will come back to the main.go file and add code later

Next, inside the project root directory, create a new directory called internal . Inside the internal directory, create invoice-data.goand copy the content below into invoice-data.go

📖 Further Reading: Read more about using internal directory in Go here)

invoice_data.go

package internal

import "errors"

type InvoiceData struct {
	Title       string
	Quantity    int64
	Price       int64
	TotalAmount int64
}

func (d *InvoiceData) CalculateTotalAmount() int64 {
	totalAmount := d.Quantity * d.Price
	return totalAmount
}

func (d *InvoiceData) ReturnItemTotalAmount() float64 {
	totalAmount := d.CalculateTotalAmount()
	converted := float64(totalAmount) / 100
	return converted
}

func (d *InvoiceData) ReturnItemPrice() float64 {
	converted := float64(d.Price) / 100
	return converted
}

func NewInvoiceData(title string, qty int64, price interface{}) (*InvoiceData, error) {
	var convertedPrice int64

	switch priceValue := price.(type) {
	case int64:
		convertedPrice = priceValue * 100
	case int:
		convertedPrice = int64(priceValue * 100)
	case float32:
		convertedPrice = int64(priceValue * 100)
	case float64:
		convertedPrice = int64(priceValue * 100)
	default:
		return nil, errors.New("type not permitted")
	}

	return &InvoiceData{
		Title:    title,
		Quantity: qty,
		Price:    convertedPrice,
	}, nil
}

The NewInvoiceData function is a simple constructor for our InvoiceData , a struct that is used to create new InvoiceData data.

The NewInvoiceData allows interface (any data type) and the switch statement filters the allowed data type. This is deliberately added.

👉 Note: I will refactor this code base with Go generics in a future article "Go Generics In-Depth.”

You may have noticed that on lines 33, 35, 37, and 39, I multiplied the priceValue by 100 and divided it by 100 in lines 19 and 25. As a result, multiplyingpriceValue by 100 (or the Euro cent approach) reduces missed calculations in our invoice service.

💡 Expert advice: When working with money or currency values, it is not recommended to use float. Computers treat floating numbers as base 2 rather than base 10 (which we use in money calculations). More information can be found here.

[Einstein being forcefully restrained]

👉 Note: CalculateTotalAmount calculates the total amount of a single invoice item by multiplying its quantity and price.

Within the same directory, please copy the following content into the invoice.go file:

invoice.go

package internal

type Invoice struct {
	Name         string
	Address      string
	InvoiceItems []*InvoiceData
}

func CreateInvoice(name string, address string, invoiceItems []*InvoiceData) *Invoice {
	return &Invoice{
		Name:         name,
		Address:      address,
		InvoiceItems: invoiceItems,
	}
}

func (i *Invoice) CalculateInvoiceTotalAmount() float64 {
	var invoiceTotalAmount int64 = 0
	for _, data := range i.InvoiceItems {
		amount := data.CalculateTotalAmount()
		invoiceTotalAmount += amount
	}

	totalAmount := float64(invoiceTotalAmount) / 100

	return totalAmount
}

Above, you’ll see we added theCreateInvoice function to theinvoice.go file, which accepts a businessname, address, and a slice ofinvoiceItems to create anInvoice instance.

CalculateInvoiceTotalAmount computed the total invoice amount by looping through theinvoiceItems slice (array) and obtaining eachInvoiceData TotalAmountvia theCalculateTotalAmount method.

On line 24, we converted the integer to a float and then divided it by 100 (see here for an explanation).

Now that that’s done, let’s get the API key we need to complete this invoice PDF.

Creating the invoice-pdf.go file

Please head to UniDoc and access a free API key by clicking here.

Don’t forget to copy the API key after creating it.

What your screen should look like once your API key is created successfully

🎥 Video: For a short tutorial, I found this on the web which may be helpful

Step-by-step instructions on how to get your free API key if you run into any trouble

Back to our project. In our project root directory, create an environment file .env and add the content below:

👉 Note: If you git clone the project from GitHub, copy the .env.example to .env file.

pdf.env

UNIDOC_LICENSE_API_KEY=<Your_API_Key>

🛑 DO NOT commit .env file with the API key.

Replace [Your_API_Key] with your account key from your UniDoc account.

Within the project root directory in the terminal, run this command to add thegodotenvpackage, which will help read our environment variables:

go get -u github.com/joho/godotenv

Next, let’s create a config directory in the project root directory and create aconfig.gofile inside it. Then copy the content below to it:

pdf.config.go

package config

import (
	"errors"
	"github.com/joho/godotenv"
	"os"
)

type UniDocConfig struct {
	Key string
}

func GetUniDocCred() (*UniDocConfig, error) {
	err := godotenv.Load()
	if err != nil {
		return nil, err
	}
	key := os.Getenv("UNIDOC_LICENSE_API_KEY")
	if len(key) == 0 {
		return nil, errors.New("uni doc key not found")
	}

	return &UniDocConfig{Key: key}, nil
}

On line 14, we loaded the.env file to get environment variables from the.env file which isUNIDOC_LICENSE_API_KEY in this project. The rest of the code is just checking if the key is set and returning the UniDocConfig struct or an error.

We are almost there. Let’s create theinvoice-pdf.gofile insideinternal directory and add our PDF-generating code to it, which is the soul of our simple invoice PDF generator project.

But before that, navigate back to the project root directory in the terminal and add the UniDoc Go package as shown below:

go get -u github.com/unidoc/unipdf/v3

👉 Note: This article is based on version 3 of the package

Now, navigate to the project's internal directory, and within the internal directory, create invoice-pdf.go and add the code below:

invoice_pdf.go

package internal

import (
	"fmt"
	"github.com/unidoc/unipdf/v3/common/license"
	"github.com/unidoc/unipdf/v3/creator"
	"marvinhosea/invoices/config"
	"strings"
)

type Client struct {
	creator *creator.Creator
}

type cellStyle struct {
	ColSpan         int
	HAlignment      creator.CellHorizontalAlignment
	BackgroundColor creator.Color
	BorderSide      creator.CellBorderSide
	BorderStyle     creator.CellBorderStyle
	BorderWidth     float64
	BorderColor     creator.Color
	Indent          float64
}

var cellStyles = map[string]cellStyle{
	"heading-left": {
		BackgroundColor: creator.ColorRGBFromHex("#332f3f"),
		HAlignment:      creator.CellHorizontalAlignmentLeft,
		BorderColor:     creator.ColorWhite,
		BorderSide:      creator.CellBorderSideAll,
		BorderStyle:     creator.CellBorderStyleSingle,
		BorderWidth:     6,
	},
	"heading-centered": {
		BackgroundColor: creator.ColorRGBFromHex("#332f3f"),
		HAlignment:      creator.CellHorizontalAlignmentCenter,
		BorderColor:     creator.ColorWhite,
		BorderSide:      creator.CellBorderSideAll,
		BorderStyle:     creator.CellBorderStyleSingle,
		BorderWidth:     6,
	},
	"left-highlighted": {
		BackgroundColor: creator.ColorRGBFromHex("#dde4e5"),
		HAlignment:      creator.CellHorizontalAlignmentLeft,
		BorderColor:     creator.ColorWhite,
		BorderSide:      creator.CellBorderSideAll,
		BorderStyle:     creator.CellBorderStyleSingle,
		BorderWidth:     6,
	},
	"centered-highlighted": {
		BackgroundColor: creator.ColorRGBFromHex("#dde4e5"),
		HAlignment:      creator.CellHorizontalAlignmentCenter,
		BorderColor:     creator.ColorWhite,
		BorderSide:      creator.CellBorderSideAll,
		BorderStyle:     creator.CellBorderStyleSingle,
		BorderWidth:     6,
	},
	"left": {
		HAlignment: creator.CellHorizontalAlignmentLeft,
	},
	"centered": {
		HAlignment: creator.CellHorizontalAlignmentCenter,
	},
	"gradingsys-head": {
		HAlignment: creator.CellHorizontalAlignmentLeft,
	},
	"gradingsys-row": {
		HAlignment: creator.CellHorizontalAlignmentCenter,
	},
	"conduct-head": {
		HAlignment: creator.CellHorizontalAlignmentLeft,
	},
	"conduct-key": {
		HAlignment: creator.CellHorizontalAlignmentLeft,
	},
	"conduct-val": {
		BackgroundColor: creator.ColorRGBFromHex("#dde4e5"),
		HAlignment:      creator.CellHorizontalAlignmentCenter,
		BorderColor:     creator.ColorWhite,
		BorderSide:      creator.CellBorderSideAll,
		BorderStyle:     creator.CellBorderStyleSingle,
		BorderWidth:     3,
	},
}

func GenerateInvoicePdf(invoice Invoice) error {
	conf, err := config.GetUniDocCred()
	if err != nil {
		return err
	}

	err = license.SetMeteredKey(conf.Key)
	if err != nil {
		return err
	}

	c := creator.New()
	c.SetPageMargins(40, 40, 0, 0)

	cr := &Client{creator: c}
	err = cr.generatePdf(invoice)
	if err != nil {
		return err
	}
	return nil
}

func (c *Client) generatePdf(invoice Invoice) error {
	rect := c.creator.NewRectangle(0, 0, creator.PageSizeLetter[0], 120)
	rect.SetFillColor(creator.ColorRGBFromHex("#dde4e5"))
	rect.SetBorderWidth(0)
	err := c.creator.Draw(rect)
	if err != nil {
		return err
	}

	headerStyle := c.creator.NewTextStyle()
	headerStyle.FontSize = 50

	table := c.creator.NewTable(1)
	table.SetMargins(0, 0, 20, 0)
	err = drawCell(table, c.newPara("Sample Invoice", headerStyle), cellStyles["centered"])
	if err != nil {
		return err
	}
	err = c.creator.Draw(table)
	if err != nil {
		return err
	}

	err = c.writeInvoice(invoice)
	if err != nil {
		return err
	}

	err = c.creator.WriteToFile(strings.ToLower(invoice.Name) + "_invoice.pdf")
	if err != nil {
		return err
	}
	return nil
}

func (c *Client) newPara(text string, textStyle creator.TextStyle) *creator.StyledParagraph {
	p := c.creator.NewStyledParagraph()
	p.Append(text).Style = textStyle
	p.SetEnableWrap(false)
	return p
}

func drawCell(table *creator.Table, content creator.VectorDrawable, cellStyle cellStyle) error {
	var cell *creator.TableCell
	if cellStyle.ColSpan > 1 {
		cell = table.MultiColCell(cellStyle.ColSpan)
	} else {
		cell = table.NewCell()
	}
	err := cell.SetContent(content)
	if err != nil {
		return err
	}
	cell.SetHorizontalAlignment(cellStyle.HAlignment)
	if cellStyle.BackgroundColor != nil {
		cell.SetBackgroundColor(cellStyle.BackgroundColor)
	}
	cell.SetBorder(cellStyle.BorderSide, cellStyle.BorderStyle, cellStyle.BorderWidth)
	if cellStyle.BorderColor != nil {
		cell.SetBorderColor(cellStyle.BorderColor)
	}
	if cellStyle.Indent > 0 {
		cell.SetIndent(cellStyle.Indent)
	}
	return nil
}

func (c *Client) writeInvoice(invoice Invoice) error {
	headerStyle := c.creator.NewTextStyle()
	// Invoice Header info table.
	table := c.creator.NewTable(2)
	table.SetMargins(0, 0, 50, 0)
	err := drawCell(table, c.newPara("Business: "+invoice.Name, headerStyle), cellStyles["left"])
	if err != nil {
		return err
	}
	err = drawCell(table, c.newPara("Address: "+invoice.Address, headerStyle), cellStyles["left"])
	if err != nil {
		return err
	}
	err = c.creator.Draw(table)
	if err != nil {
		return err
	}

	// Invoice items table.
	table = c.creator.NewTable(4)
	table.SetMargins(0, 0, 20, 0)
	err = table.SetColumnWidths(0.4, 0.2, 0.2, 0.2)
	if err != nil {
		return err
	}
	headingStyle := c.creator.NewTextStyle()
	headingStyle.FontSize = 20
	headingStyle.Color = creator.ColorRGBFromHex("#fdfdfd")
	regularStyle := c.creator.NewTextStyle()

	// Draw table header.
	err = drawCell(table, c.newPara(" Title", headingStyle), cellStyles["heading-left"])
	if err != nil {
		return err
	}
	err = drawCell(table, c.newPara("Quantity", headingStyle), cellStyles["heading-centered"])
	if err != nil {
		return err
	}
	err = drawCell(table, c.newPara("Price", headingStyle), cellStyles["heading-centered"])
	if err != nil {
		return err
	}
	err = drawCell(table, c.newPara("Total", headingStyle), cellStyles["heading-centered"])
	if err != nil {
		return err
	}
	for _, datum := range invoice.InvoiceItems {
		err = drawCell(table, c.newPara(" "+datum.Title, regularStyle), cellStyles["left-highlighted"])
		if err != nil {
			return err
		}
		err = drawCell(table, c.newPara(fmt.Sprintf("%v", datum.Quantity), regularStyle), cellStyles["centered-highlighted"])
		if err != nil {
			return err
		}
		err = drawCell(table, c.newPara(fmt.Sprintf("%v", datum.Price), regularStyle), cellStyles["centered-highlighted"])
		if err != nil {
			return err
		}
		err = drawCell(table, c.newPara(fmt.Sprintf("%v", datum.CalculateTotalAmount()), regularStyle), cellStyles["centered-highlighted"])
		if err != nil {
			return err
		}
	}
	err = c.creator.Draw(table)
	if err != nil {
		return err
	}

	boldStyle := c.creator.NewTextStyle()
	boldStyle.FontSize = 16
	grid := c.creator.NewTable(12)
	grid.SetMargins(0, 0, 50, 0)
	gradeInfoStyle := c.creator.NewTextStyle()

	table = c.creator.NewTable(2)
	err = table.SetColumnWidths(0.6, 0.4)
	if err != nil {
		return err
	}
	err = drawCell(table, c.newPara("Total Amount:", gradeInfoStyle), cellStyles["conduct-key"])
	if err != nil {
		return err
	}
	err = drawCell(table, c.newPara(fmt.Sprintf("%v", invoice.CalculateInvoiceTotalAmount()), gradeInfoStyle), cellStyles["conduct-val"])
	if err != nil {
		return err
	}
	err = grid.MultiColCell(5).SetContent(table)
	if err != nil {
		return err
	}
	err = c.creator.Draw(grid)
	if err != nil {
		return err
	}
	return nil
}

General structure: a quick analysis ☝️

On line 11, in theinvoice-pdf.gofile, we added a Client struct with a property creator which holds our PDF creator instance. The creator instance has methods that help in manipulating the PDF document and creating a nice invoice in PDF format.

On line 26, we defined the cell styles to be added to the document.

On line 88, using theconfigpackage with theGetUniDocCredfunction, we retrieved our API key from the environment variable that we added in the.env file. We’ll see an error returned if we fail to get the document.

On line 93, since our UniDoc usage is metered, we were required to add our account API key retrieved on line 88.

On line 98, we initialized and created a newcreatorinstance.

On line 99, we set the PDF page margins.

On line 101, we called theClient structgeneratePdfmethod and passed the invoice instance to it.

Let’s investigate a bit further into this method.

Creating the invoice header

Lines 110 to 116 began the creation of our invoice header, a 120-height rectangular block with a light gray background. You can experiment with the argument based on your preferences.

On line 118, we created a new text style and assign it a font size of 50 on line 119.

After initializing the style, we created a table with one column and a margin of zero on both the left, right, and bottom, and a top margin of 20 on lines 121 and 122.

On line 123, we created a new centered cell block with a new paragraph of text style created in line 118 and put it in the current position on our table. The new paragraph contains the header content.

On line 127, we made our first table block.

Creating the invoice business letterhead

Now that we have added the invoice header, let’s add the business information.

From line 132, we jumped to line 176 using thewriteInvoice method that accepts an invoice instance.

On line 177, all we did was create another text style for the table business letterhead.

From lines 179 to 192, we created another table with two columns (instead of one) and a top margin of 50. At this point, you should be familiar with the table block creation code.

Adding invoice items

From 195 to 204, we initialized the table that holds our invoice item data. The process is the same as before, but with some customization like column widths, font size, etc.

Then, from lines 207 to 222, we drew the invoice items' table header.

👉 Note: This should be very familiar if you know HTML and CSS

On line 223, we looped through each and every invoice item and added the respective data into the invoice item table cells. Then we positioned them accordingly inside the table at their current positions.

We finalized the invoice item table creation on line 241, which then made the table block.

[sorry, I had to 😂]

Creating the invoice footer

It is common knowledge that an invoice must have a summary of its items and a total sum. So let’s finish strong by adding this in the document footer:

Starting with line 246, we created another table with twelve columns and set the column widths in line 253.

On lines 257 and 261, we provided the footer table cell data.

👉 Note: if your code was successful, it would have returned a nil error back on line 132

Line 137 is where the magic happens. We wrote thecreatorblocks into the output document, which is a PDF in this case, and the method creates the PDF at the specified path.

☝️Remember: Do not forget to add.pdfextension to your path

Finally, let’s go back to ourmain.gofile and add the code below.

final.main.go

package main

import (
	"fmt"
	internal "marvinhosea/invoices/internal"
)

func main() {
	// Generate sample invoice data
	ecommerceInvoiceData, err := internal.NewInvoiceData("Ecommerce application", 1, 3000.50)
	if err != nil {
		panic(err)
	}
	laptopInvoiceData, err := internal.NewInvoiceData("Macbook Pro", 1, 200.70)
	if err != nil {
		panic(err)
	}
	// Invoice Items collection
	invoiceItems := []*internal.InvoiceData{ecommerceInvoiceData, laptopInvoiceData}

	// Create single invoice
	invoice := internal.CreateInvoice("Example Shop1", "Example address", invoiceItems)
	err = internal.GenerateInvoicePdf(*invoice)
	fmt.Printf("The Total Invoice Amount is: %f", invoice.CalculateInvoiceTotalAmount())
}

On lines 10 and 14, we simply gathered new invoice data objects. [Assume that this is the invoice data request]

And on line 19, we created a slice of InvoiceDataas an invoice items collection.

The created invoice items collection was used to create an invoice instance on line 22, which was used to generate an invoice PDF file on line 23.

Finishing up

Now go ahead and run the program in the terminal. If there is no error, you should get the following output:

The Total Invoice Amount is: 3201.200000

Now, go ahead and check the project root directory. You should find a pdf invoice file that looks like this:

Sample invoice PDF output from our code

And that’s it for a basic PDF invoice!

Questions/Troubles/Concerns? Post them below in the Comments👇 and I’ll make sure to answer them as quickly as possible.

Conclusion

Generating PDFs, especially those with dynamic content, is not easy. But luckily, there are a few options out there to help you — from the design to architecture to system implementation.

Obviously, I did not go into much detail with this article, but hopefully I set the building blocks in place for you to customize PDF invoices yourself using Go.

Takeaways:

🚀 Bonus Challenge: Add more implementation to the code base, such as storing PDF invoices files in an S3 bucket.

Source code

You can find the project’s source code at the GitHub link below:

GitHub — marvinhosea/invoice-pdf-generator github.com

Other Articles To Consider

How To Create a Go Private Module With Docker A Step-By-Step Frameworkmedium.com

Streamline Your Productivity With “Air” Go’s Best Method For Live Reload Development With Dockermedium.com

Disclosure: In accordance with Medium.com’s rules and guidelines, I publicly acknowledge financial compensation from UniDoc for this article. All thoughts, opinions, code, pictures, writing, etc. are my own.