Creating PDF Invoices in Python with borb

Introduction

The Portable Document Format (PDF) is not a WYSIWYG (What You See is What You Get) format. It was developed to be platform-agnostic, independent of the underlying operating system and rendering engines.

To achieve this, PDF was constructed to be interacted with via something more like a programming language, and relies on a series of instructions and operations to achieve a result. In fact, PDF is based on a scripting language - PostScript, which was the first device-independent Page Description Language.

In this guide, we'll be using borb - a Python library dedicated to reading, manipulating and generating PDF documents. It offers both a low-level model (allowing you access to the exact coordinates and layout if you choose to use those) and a high-level model (where you can delegate the precise calculations of margins, positions, etc to a layout manager).

We'll take a look at how to create a PDF invoice in Python using borb.

Installing borb

borb can be downloaded from source on GitHub, or installed via pip:

$ pip install borb

Creating a PDF Invoice in Python with borb

borb has two intuitive key classes - Document and Page, which represent a document and the pages within it. Additionally, the PDF class represents an API for loading and saving the Documents we create.

Let's create a Document() and Page() as a blank canvas that we can add the invoice to:

from borb.pdf.document import Document
from borb.pdf.page.page import Page

# Create document
pdf = Document()

# Add page
page = Page()
pdf.append_page(page)

Since we don't want to deal with calculating coordinates - we can delegate this to a PageLayout which manages all of the content and its positions:

# New imports
from borb.pdf.canvas.layout.page_layout.multi_column_layout import SingleColumnLayout
from decimal import Decimal

page_layout = SingleColumnLayout(page)
page_layout.vertical_margin = page.get_page_info().get_height() * Decimal(0.02)

Here, we're using a SingleColumnLayout since all of the content should be in a single column - we won't have a left and right side of the invoice. We're also making the vertical margin smaller here. The default value is to trim the top 10% of the page height as the margin, and we're reducing it down to 2%, since we'll want to use this space for the company logo/name.

Speaking of which, let's add the company logo to the layout:

# New import
from borb.pdf.canvas.layout.image.image import Image


page_layout.add(    
        Image(        
        "https://s3.stackabuse.com/media/articles/creating-an-invoice-in-python-with-ptext-1.png",        
        width=Decimal(128),        
        height=Decimal(128),    
        ))

Here, we're adding an element to the layout - an Image(). Through its constructor, we're adding a URL pointing to the image resource and setting its width and height.

Beneath the image, we'll want to add our imaginary company info (name, address, website, phone) as well as the invoice information (invoice number, date, due date). A common format for brevity (which incidentally also makes the code cleaner) is to use a table to store invoice data. Let's create a separate helper method to build the invoice information in a table, which we can then use to simply add a table to the invoice in our main method:

# New imports
from borb.pdf.canvas.layout.table.fixed_column_width_table import FixedColumnWidthTable as Table
from borb.pdf.canvas.layout.text.paragraph import Paragraph
from borb.pdf.canvas.layout.layout_element import Alignment
from datetime import datetime
import random

def _build_invoice_information():    
    table_001 = Table(number_of_rows=5, number_of_columns=3)
    
    table_001.add(Paragraph("[Street Address]"))    
    table_001.add(Paragraph("Date", font="Helvetica-Bold", horizontal_alignment=Alignment.RIGHT))    
    now = datetime.now()    
    table_001.add(Paragraph("%d/%d/%d" % (now.day, now.month, now.year)))
    
    table_001.add(Paragraph("[City, State, ZIP Code]"))    
    table_001.add(Paragraph("Invoice #", font="Helvetica-Bold", horizontal_alignment=Alignment.RIGHT))
    table_001.add(Paragraph("%d" % random.randint(1000, 10000)))   
    
    table_001.add(Paragraph("[Phone]"))    
    table_001.add(Paragraph("Due Date", font="Helvetica-Bold", horizontal_alignment=Alignment.RIGHT))
    table_001.add(Paragraph("%d/%d/%d" % (now.day, now.month, now.year))) 
    
    table_001.add(Paragraph("[Email Address]"))    
    table_001.add(Paragraph(" "))
    table_001.add(Paragraph(" "))

    table_001.add(Paragraph("[Company Website]"))
    table_001.add(Paragraph(" "))
    table_001.add(Paragraph(" "))

    table_001.set_padding_on_all_cells(Decimal(2), Decimal(2), Decimal(2), Decimal(2))    		
    table_001.no_borders()
    return table_001

Here, we're making a simple Table with 5 rows and 3 columns. The rows correspond to the street address, city/state, phone, email address and company website. Each row will have 0..3 values (columns). Each text element is added as a Paragraph, which we've aligned to the right via Alignment.RIGHT, and accept styling arguments such as font.

Finally, we've added padding to all the cells to make sure we don't place the text awkwardly near the confounds of the cells.

Now, back in our main method, we can call _build_invoice_information() to populate a table and add it to our layout:

page_layout = SingleColumnLayout(page)
page_layout.vertical_margin = page.get_page_info().get_height() * Decimal(0.02)
page_layout.add(    
    Image(        
        "https://s3.stackabuse.com/media/articles/creating-an-invoice-in-python-with-ptext-1.png",        
        width=Decimal(128),        
        height=Decimal(128),    
        ))

# Invoice information table  
page_layout.add(_build_invoice_information())  
  
# Empty paragraph for spacing  
page_layout.add(Paragraph(" "))

Now, let's build this PDF document real quick to see what it looks like. For this, we'll use the PDF module:

# New import
from borb.pdf.pdf import PDF

with open("output.pdf", "wb") as pdf_file_handle:
    PDF.dumps(pdf_file_handle, pdf)

Great! Now we'll want to add the billing and shipping information as well. It'll conveniently be placed in a table, just like the company information. For brevity's sake, we'll also opt to make a separate helper function to build this info, and then we can simply add it in our main method:

# New imports
from borb.pdf.canvas.color.color import HexColor, X11Color

def _build_billing_and_shipping_information():  
    table_001 = Table(number_of_rows=6, number_of_columns=2)  
    table_001.add(  
        Paragraph(  
            "BILL TO",  
            background_color=HexColor("263238"),  
            font_color=X11Color("White"),  
        )  
    )  
    table_001.add(  
        Paragraph(  
            "SHIP TO",  
            background_color=HexColor("263238"),  
            font_color=X11Color("White"),  
        )  
    )  
    table_001.add(Paragraph("[Recipient Name]"))        # BILLING  
    table_001.add(Paragraph("[Recipient Name]"))        # SHIPPING  
    table_001.add(Paragraph("[Company Name]"))          # BILLING  
    table_001.add(Paragraph("[Company Name]"))          # SHIPPING  
    table_001.add(Paragraph("[Street Address]"))        # BILLING  
    table_001.add(Paragraph("[Street Address]"))        # SHIPPING  
    table_001.add(Paragraph("[City, State, ZIP Code]")) # BILLING  
    table_001.add(Paragraph("[City, State, ZIP Code]")) # SHIPPING  
    table_001.add(Paragraph("[Phone]"))                 # BILLING  
    table_001.add(Paragraph("[Phone]"))                 # SHIPPING  
    table_001.set_padding_on_all_cells(Decimal(2), Decimal(2), Decimal(2), Decimal(2))  
    table_001.no_borders()  
    return table_001

We've set the background_color of the initial paragraphs to #263238 (gray-blue) to match the color of the logo, and the font_color to White.

Let's call this in the main method as well:

# Invoice information table
page_layout.add(_build_invoice_information())

# Empty paragraph for spacing
page_layout.add(Paragraph(" "))

# Billing and shipping information table
page_layout.add(_build_billing_and_shipping_information())
Free eBook: Git Essentials

Check out our hands-on, practical guide to learning Git, with best-practices, industry-accepted standards, and included cheat sheet. Stop Googling Git commands and actually learn it!

Once we run the script again, this results in a new PDF file that contains more information:

With our basic information sorted out (company info and billing/shipping info) - we'll want to add an itemized description. These will be the goods/services that our supposed company offered to someone and are also typically done in a table-like fashion beneath the information we've already added.

Again, let's create a helper function that generates a table and populates it with data, which we can simply add to our layout later on:

# New import
from borb.pdf.canvas.layout.table.fixed_column_width_table import FixedColumnWidthTable as Table
from borb.pdf.canvas.layout.table.table import TableCell


def _build_itemized_description_table(self):  
    table_001 = Table(number_of_rows=15, number_of_columns=4)  
    for h in ["DESCRIPTION", "QTY", "UNIT PRICE", "AMOUNT"]:  
        table_001.add(  
            TableCell(  
                Paragraph(h, font_color=X11Color("White")),  
                background_color=HexColor("016934"),  
            )  
        )  
  
    odd_color = HexColor("BBBBBB")  
    even_color = HexColor("FFFFFF")  
    for row_number, item in enumerate([("Product 1", 2, 50), ("Product 2", 4, 60), ("Labor", 14, 60)]):  
        c = even_color if row_number % 2 == 0 else odd_color  
        table_001.add(TableCell(Paragraph(item[0]), background_color=c))  
        table_001.add(TableCell(Paragraph(str(item[1])), background_color=c))  
        table_001.add(TableCell(Paragraph("$ " + str(item[2])), background_color=c))  
        table_001.add(TableCell(Paragraph("$ " + str(item[1] * item[2])), background_color=c))  
      
    # Optionally add some empty rows to have a fixed number of rows for styling purposes
    for row_number in range(3, 10):  
        c = even_color if row_number % 2 == 0 else odd_color  
        for _ in range(0, 4):  
            table_001.add(TableCell(Paragraph(" "), background_color=c))  
  
    table_001.add(TableCell(Paragraph("Subtotal", font="Helvetica-Bold", horizontal_alignment=Alignment.RIGHT,), col_span=3,))  
    table_001.add(TableCell(Paragraph("$ 1,180.00", horizontal_alignment=Alignment.RIGHT)))  
    table_001.add(TableCell(Paragraph("Discounts", font="Helvetica-Bold", horizontal_alignment=Alignment.RIGHT,),col_span=3,))  
    table_001.add(TableCell(Paragraph("$ 177.00", horizontal_alignment=Alignment.RIGHT)))  
    table_001.add(TableCell(Paragraph("Taxes", font="Helvetica-Bold", horizontal_alignment=Alignment.RIGHT), col_span=3,))  
    table_001.add(TableCell(Paragraph("$ 100.30", horizontal_alignment=Alignment.RIGHT)))  
    table_001.add(TableCell(Paragraph("Total", font="Helvetica-Bold", horizontal_alignment=Alignment.RIGHT  ), col_span=3,))  
    table_001.add(TableCell(Paragraph("$ 1163.30", horizontal_alignment=Alignment.RIGHT)))  
    table_001.set_padding_on_all_cells(Decimal(2), Decimal(2), Decimal(2), Decimal(2))  
    table_001.no_borders()  
    return table_001

In practice, you'd substitute the hard-coded strings related to the subtotal, taxes and total prices with calculations of the actual prices - though, this heavily depends on the underlying implementation of your Product models, so we've added a stand-in for abstraction. Once we add this table to the document as well - we can rebuild it and take a look.

The entire main method should now look something along the lines of:

# Create document
pdf = Document()

# Add page
page = Page()
pdf.append_page(page)

page_layout = SingleColumnLayout(page)
page_layout.vertical_margin = page.get_page_info().get_height() * Decimal(0.02)

page_layout.add(
        Image(
        "https://s3.stackabuse.com/media/articles/creating-an-invoice-in-python-with-ptext-1.png",
        width=Decimal(128),
        height=Decimal(128),
        ))


# Invoice information table
page_layout.add(_build_invoice_information())

# Empty paragraph for spacing
page_layout.add(Paragraph(" "))

# Billing and shipping information table
page_layout.add(_build_billing_and_shipping_information())

# Itemized description
page_layout.add(_build_itemized_description_table())

with open("output2.pdf", "wb") as pdf_file_handle:
    PDF.dumps(pdf_file_handle, pdf)

Running this piece of code results in:

Creating an Outline

Our PDF is done and ready to be served - though, we can take it up a notch with two little additions. First, we can add an Outline, which helps readers like Adobe navigate and generate a menu for your PDFs:

# New import
from borb.pdf.page.page import DestinationType

# Outline  
pdf.add_outline("Your Invoice", 0, DestinationType.FIT, page_nr=0)

The add_outline() function accepts a few arguments:

  • title : the title that will be displayed in the side menu
  • level : how deep down the tree something will be. Level 0 is root-level.
  • Several arguments that make up a "destination"

Destinations can be thought of as targets for hyperlinks. You can link to an entire page (which is what we are doing in this example), but you can also link to specific parts of a page (for instance - exactly at y-coordinate 350).

Furthermore, you need to specify how the reader should present that page - for instance, do you want to simply scroll to that page and not zoom? Do you want to display only a target area, with the reader completely zoomed into that particular area?

In this line of code, we are asking the reader to display page 0 (the first page) and ensure it fits the reader window (zooming in/out if needed).

Once you've added the outline, you should see it appear in the reader of your choice:

With multiple pages - you can create a more complex outline and link to them via add_outline() for easier navigation.

Embedding JSON Documents in PDF Invoices

Since PDFs aren't very computer-friendly (in terms of reading and unambiguously decoding) - sometimes, we might want to add more computer-friendly formats as well if someone would like to process invoices automatically.

A Germany-originating invoice standard called ZUGFeRD (later adopted by the EU) enables us to make PDF invoices with more computer-legible file formats such as XML - which describes the invoice and is easily parsable. In addition to these, you can also embed other documents related to your invoice such as terms and agreements, a refund policy, etc.

To embed any sort of additional file in a PDF file, using borb - we can use the append_embedded_file() function.

Let's first go ahead and create a dictionary to store our invoice data in JSON, which we'll then save into an invoice_json file:

import json

# Creating a JSON file
invoice_json = {  
"items": [  
    {  
        "Description": "Product1",  
        "Quantity": 2,  
        "Unit Price": 50,  
        "Amount": 100,  
    },  
    {  
        "Description": "Product2",  
        "Quantity": 4,  
        "Unit Price": 60,  
        "Amount": 100,  
    },  
    {  
        "Description": "Labor",  
        "Quantity": 14,  
        "Unit Price": 60,  
        "Amount": 100,  
    },  
],  
"Subtotal": 1180,  
"Discounts": 177,  
"Taxes": 100.30,  
"Total": 1163.30,  
}  
invoice_json_bytes = bytes(json.dumps(invoice_json, indent=4), encoding="latin1")

Now, we can simply embed this file into our PDF invoice:

pdf.append_embedded_file("invoice.json", invoice_json_bytes)

Once we run the script again and store the document, we've go:

Conclusion

In this guide, we've taken a look at how to create an invoice in Python using borb. We've then added an outline to the PDF file for ease of navigation and taken a look at how to add attachments/embedded files for programmatic access to the contents of the PDF.

Last Updated: March 2nd, 2023
Was this article helpful?

Improve your dev skills!

Get tutorials, guides, and dev jobs in your inbox.

No spam ever. Unsubscribe at any time. Read our Privacy Policy.

Joris SchellekensAuthor

I'm a software architect from Belgium, with a passion for machine learning, knowledge-based systems and graph algorithms. I'm also the author of borb, the pure python PDF library.

© 2013-2024 Stack Abuse. All rights reserved.

AboutDisclosurePrivacyTerms