How to create customisable PDF reports in Python using fpdf2

Holistic AI Engineering
8 min readJan 20, 2023

--

Do you want to create a PDF report to display the results of your work to a client, a teacher or even for yourself, but you don’t want to do it manually each time? Well, fpdf2 might be the solution 😄

Official image fpdf2

The fpdf2 is a basic Python library that can handle almost anything you want to see in a PDF report, from lists and tables to handling RTF text. The documentation page can be found here.

From my experience working with fpdf2, I can say that the simplicity of this library is both a pro and a con:

  • on the positive side, having the cell (a panel/rectangle in which you add your text) as its unit of construction, means that you can pretty much construct anything using this unit: paragraphs, lists, tables etc.
  • on the negative side, it makes the construction of some structures even more complex and time/work consuming.

Hello World

Let’s have a look at how you can create a simple PDF report:

from fpdf import FPDF

pdf = FPDF(orientation='P', unit='mm', format='A4')
pdf.set_margins(left=25, top=20, right=25)
pdf.set_auto_page_break(True, margin=10)
# supported starting only with version 2.5.7
pdf.set_page_background(background="image.jpg")

pdf.add_page()

pdf.set_font(family='Times', size=30)
pdf.set_text_color(r=255, g=0, b=0)
pdf.set_y(pdf.h / 2 - 15)
pdf.cell(w=pdf.w - 50, txt="Hello World!", align="C")

pdf.output("example.pdf")
Background by rawpixel.com on Freepick

As you can see from the above example you can configure quite a lot of options regarding the page aspect: orientation, format, margins, background as well as a text font and colour.

You might be wondering what’s with this pdf.set_y(). You shouldn’t look at fpdf2 as being a tool that “writes” content, but as one that “draws” content in a PDF. So, for anything that you might want to add to your PDF (lists, tables, images, figures etc) you will need to specify coordinates (x and y - otherwise, your content will start at the margin of the page, or from where your last item ended).

This is one of the features with huge pros and cons because it gives you endless possibilities, but it’s also very time-consuming if you have a lot of items to place on a page. A double-edged sword?

Regarding pdf.h / 2 - 15, I’m simply trying to centre the text top-bottom by getting the height of the page and subtracting half of the text size. Same for w=pdf.w — 50, I’m only accounting for the left and right margins of the page.

You can also add and use other font families as fpdf2 supports only 5 font families by default:

pdf.add_font("Calibri", fname="Calibri Regular.ttf")
pdf.add_font("Calibri", style="B", fname="Calibri Bold.ttf")
pdf.add_font("Calibri", style="I", fname="Calibri Italic.ttf")
pdf.add_font("CalibriLight", fname="Calibri Light.ttf")
pdf.add_font("Calibri", style="BI", fname="Calibri Bold Italic.ttf")

What else can you do in fpdf2?

Lists

black = (0, 0, 0)
white = (255, 255, 255)
blue = (0, 0, 255)
light_gray = (239, 241, 244)

unordered_list = ["lists", "tables", "shapes", "images"]


def add_title(x, y, txt, align, w=None):
pdf.set_xy(x=x, y=y)
pdf.set_font(family='Times', style='B', size=16)
pdf.set_text_color(*black)
pdf.cell(w=w, txt=txt, align=align)


def build_unordered_list():
def build_one_item(text):
pdf.set_fill_color(*black)
pdf.set_draw_color(*black)
pdf.circle(x=pdf.l_margin + 2, y=pdf.get_y() + 1.85, r=0.5, style='DF')

pdf.set_x(x=pdf.l_margin + 4)
pdf.set_font(family='Times', size=14)
pdf.set_text_color(*black)
pdf.cell(txt=text, align="L")

pdf.ln()

add_title(x=pdf.l_margin, y=pdf.get_y(), txt="1. List", align="L")
pdf.ln(7)

for item in unordered_list:
build_one_item(item)

Tables

def build_table():
def build_column(txt_color, draw_color, fill_color, x, txt_width, name, email, role):
padding = " "

pdf.set_text_color(*txt_color)
pdf.set_draw_color(*draw_color)
pdf.set_fill_color(*fill_color)

pdf.set_xy(x=x, y=73)
pdf.cell(w=txt_width, h=10, txt=padding + name, align='L', border=1, fill=True)
pdf.set_xy(x=x, y=83)
pdf.cell(w=txt_width, h=10, txt=padding + email, align='L', border=1, fill=True)
pdf.set_xy(x=x, y=93)
pdf.cell(w=txt_width, h=10, txt=padding + role, align='L', border=1, fill=True)

pdf.ln()

add_title(x=pdf.l_margin, y=60, txt="2. Table", align="C", w=pdf.w - 50)

table_width = pdf.w - 2 * pdf.l_margin
first_column_width = 0.25 * table_width
other_columns_width = (table_width - first_column_width) / 2

pdf.set_font(family='Times', size=12)
pdf.set_line_width(width=0.3)

# first column
build_column(white, white, blue, pdf.l_margin, first_column_width, 'Name', 'Email', 'Role')

# second column
build_column(black, white, light_gray, pdf.l_margin + first_column_width, other_columns_width,
"Mark. M", "mark.m@gmail.com", "Squad Lead")

# third column
build_column(black, white, light_gray, pdf.l_margin + first_column_width + other_columns_width,
other_columns_width, "Spencer. D", "spencer.d@gmail.com", "Engineer")

Shapes and Images

def build_shape():
add_title(x=110, y=135, txt="3. Shape", align="L")
pdf.set_fill_color(*white)
pdf.set_draw_color(*white)
pdf.rect(x=140, y=120, w=40, h=80, style='DF', round_corners=True, corner_radius=5)


def build_image():
add_title(x=132, y=240, txt="4. Image", align="L")
pdf.image(name="image2.jpg", x=pdf.l_margin, y=190, w=100)

And this is the result:

Background by rawpixel.com on Freepick and Light bulb image by Daniel Calabrese from Pixabay

But wait… there’s more

Test content before putting it in the PDF

There might be cases where you have content that might extend on multiple pages. Take, for example, a list. If one of the bullet points takes a couple of lines and you’re approaching the end of the page, you might not want to keep half of the bullet point on that page and the other half on the next. In order to understand when that will happen, cleanly break the page and put the whole bullet point on the next page you will need to use the offset_rendering method.

def build_list(items):
def render_item(item):
# render the item

for item in items:
with pdf.offset_rendering() as rendering:
render_item(item)

# edge-case: page break in the middle of text
if rendering.page_break_triggered:
pdf.add_page()
# set up the new page

render_item(item)

Inside offset_rendering you can add your bullet point in a dummy PDF and you can check if that will make the page break or not. If it will break the page you can make the necessary changes (adding a new page, setting it up) and add the bullet point.

Combine an already created PDF with the PDF you’re creating

By itself, fpdf2, is not able to combine already created PDFs with the PDF you’re currently constructing. Luckily, fpdf2 can be used together with other libraries, one of them being PyPDF2.

import io
import re

from PyPDF2 import PdfReader, PdfWriter
from fpdf import FPDF

def create_new_page():
pdf = FPDF(orientation='P', unit='mm', format='A4')
pdf.set_margins(left=25, top=20, right=25)
pdf.set_auto_page_break(True, margin=10)
pdf.set_page_background(background="image.jpg")

pdf.add_page()

return pdf.output()


def add_new_page_to_existing_pdf():
writer = PdfWriter()
static_pdf_content = PdfReader("static_file.pdf")
dynamic_pdf_content = PdfReader(io.BytesIO(create_new_page()))
num_pages_static_pdf = len(static_pdf_content.pages)

for i in range(0, num_pages_static_pdf):
writer.add_page(static_pdf_content.pages[i])

writer.add_page(dynamic_pdf_content.pages[0]) # we know that it's only one page

writer.add_page(static_pdf_content.pages[num_pages_static_pdf - 1]) # add the last page

with open("example.pdf", "wb") as outputStream:
writer.write(outputStream)

In the above example, the current PDF output is stored in a PdfReader object and the static PDF (already generated one) is also read in another PdfReader object.

Then the first n-1 pages from the static PDF are added to a PdfWriter object, followed by the page generated by fpdf2 and the final page from the static PDF. Then the whole content is written into a new PDF file. In this way, you can add your generated page in the static PDF, in the second to last position.

Add a Table of Contents (TOC)

Since we’re talking about a PDF report, you most likely want to add a table of contents as well.

You can either do it manually (the “hardcoded” way), using the PyPDF2 classes from the previous section:

from PyPDF2 import PdfReader, PdfWriter, PdfFileReader
from fpdf import FPDF

# (Title, Indent Level)
SECTIONS = [("Section 1", 1), ("Section 1.1", 2), ("Section 1.1.1", 3), ("Section 2", 1),
("Section 2.1", 2), ("Section 2.2", 2)]


def find_first_occurrence(reader, s):
for i, page in enumerate(reader.pages):
text = page.extract_text()
res = re.search(s, text.replace('\00', '')) # replace null byte so you can make the search
if res is not None:
return i # return first instance
return None


def add_toc():
writer = PdfWriter()
reader_full_pdf = PdfFileReader("file.pdf")

pdf.set_font(family='Times', style='B', size=15)
pdf.cell(h=10, txt='CONTENTS:', new_x="LMARGIN", new_y="NEXT")
for section in SECTIONS:
page_number = find_first_occurrence(reader_full_pdf, section[0])
if page_number is None:
page_number = -1

if section[1] == 1:
pdf.set_font(family='Calibri', style='B', size=13)
pdf.cell(h=10, txt=section[0].upper())
pdf.set_x(182)
pdf.cell(h=10, txt=str(page_number), new_x="LMARGIN", new_y="NEXT")
if section[1] == 2:
pdf.set_font(family='Calibri', style='B', size=12)
pdf.set_x(30)
pdf.cell(h=10, txt=section[0])
pdf.set_x(182)
pdf.cell(h=10, txt=str(page_number), new_x="LMARGIN", new_y="NEXT")
if section[1] == 3:
pdf.set_font(family='Calibri', size=11)
pdf.set_x(34)
pdf.cell(h=10, txt=section[0])
pdf.set_x(182)
pdf.cell(h=10, txt=str(page_number), new_x="LMARGIN", new_y="NEXT")

reader_toc = PdfReader(io.BytesIO(pdf.output()))

writer.add_page(reader_full_pdf.pages[0]) # add first page
writer.add_page(reader_toc.pages[0]) # add toc

for i in range(1, reader_full_pdf):
writer.add_page(reader_full_pdf.pages[i]) # pages 2-end

with open("example.pdf", "wb") as outputStream:
writer.write(outputStream)

Or by using the insert_toc_placeholder function from fpdf2. To be completely honest, I haven’t dwelled that much on this so I’m not sure how easy it is to use it, but based on the description you might be able to create the ToC and also automatically add the links to the sections without too much work from your side. I’d love to know if you’ve tried this, please let us know in the comments👇

Handling rich text format (RTF)

This can allow you to convert the content of an RTF form directly to PDF, or to create HTML templates with placeholders that can be replaced later.

<!DOCTYPE html>
<html>
<body>
<h2 style='margin: 0 0 8.0pt;line-height:107%;font-size:18px;font-family:"Calibri",sans-serif;'><b>This is an example of a {}.</b></h2>
</body>

</html>
from fpdf import FPDF, HTMLMixin

# you need this to be able to run the "write_html" function
class MyFPDF(FPDF, HTMLMixin):
pass

def write_html():
pdf = MyFPDF(orientation='P', unit='mm', format='A4')
pdf.set_margins(left=25, top=20, right=25)
pdf.set_auto_page_break(True, margin=10)
pdf.set_page_background(background="image.jpg")

pdf.add_page()

f = open("file.html", "r")
content = f.read()
content = content.format("placeholder")
pdf.write_html(content)
f.close()

pdf.output("example.pdf")

And this is the result:

Background by rawpixel.com on Freepick

Although this is nice and cool, there are a couple of downsides that we’ve noticed during our tests:

  • the text will always be dark-red (as we can see from the above example, it’s hardcoded this way);
  • the text family will always be Times, no matter what you set in the HTML file since it is hardcoded this way;
  • the bullet points text cannot be arranged left-right;
  • and probably others as well.

There was a time when you could simply use your own version of the fpdf2 HTML configuration class, but that “bug” was patched in the last version of the library 😅.

Conclusion

After spending a considerable amount of time playing with this library I can say that it’s definitely useful and it’s worth investigating further. You can implement almost everything (limited to what I mentioned previously) that you might want in a PDF report. But depending on your requirements the difficulty of using this library might vary widely.

We’ve also explored other PDF solutions such as WeasyPrint and Python Docx Template.

Let me know what you think in the comments, always happy to learn!

It’s not a bug, it’s a feature 🧨

Holistic AI is an AI risk management company that aims to empower enterprises to adopt and scale AI confidently. We have pioneered the field of AI risk management and have deep practical experience auditing AI systems, having reviewed over 100+ enterprise AI projects covering 20k+ different algorithms. Our clients and partners include Fortune 500 corporations, SMEs, governments and regulators.

We’re hiring :)

--

--

Holistic AI Engineering

We are the engineers at Holistic AI, the company that wants to change the way humans interact with AI systems. Check us out here https://www.holisticai.com