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 Document
s 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())
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 menulevel
: 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.