Creating Bingo Cards with Python and ReportLab


Bingo card example

Imagine the following scenario:

You are about to host a gathering of some sort and for whatever reason you can't come up with better entertainment than to have your guests play bingo. Being the Python hacking, bingo playing weirdo that you are, you'll clearly have create the bingo cards yourself.

So you set out to write a program creating an arbitrary number of bingo cards neatly contained inside a single PDF file.

Where there are random numbers, the random module can't be far:

import random

For PDF creation we will use the frightingly powerful ReportLab library. If you haven't already installed it you can do so using pip (pip install reportlab).

We will only need a few of ReportLab's many (many) components:

from reportlab.platypus import (
    BaseDocTemplate,
    PageTemplate,
    Frame,
    Table,
    TableStyle,
    PageBreak
)
from reportlab.lib.colors import lightgrey, black
from reportlab.lib.units import cm

First, let's create the random numbers needed to fill up the columns of a bingo card. This will be achieved by the function list_of_columns and involves a number of steps:

  1. We split the list of available numbers (1 through 75, typically) into sublists from which the columns of our bingo cards will be fed. The length of each slice is the total number of numbers (75) divided by the number of columns (5). Since we want all slices to be of the same length, we perform a floor division.
  2. From each slice we pick a random sample of n numbers where n is the number of rows (5).
  3. Lastly, we sort every sample so the numbers of each column will appear on the card in ascending order.

Weave those steps together into a list comprehension and you get:

def list_of_columns(
    numbers=range(1, 76),
    num_of_columns=5,
    num_of_rows=5
):
    slice_length = len(numbers) // num_of_columns
    return [
        sorted(
            random.sample(
                numbers[i * slice_length: (i + 1) * slice_length],
                num_of_rows
            )
        )
        for i in range(num_of_columns)
    ]

Isn't it beautiful? The function list_of_columns gives us all the numbers for a bingo card, neatly arranged in a list of columns.

Unfortunately this still isn't exactly what we need: To be able to later fill them into a table, we will need the numbers to be arranged by rows. How to we go about that?

Let's interpret our list of columns as a matrix. Then the list of rows is the transpose of that matrix.

So, next we define a function list_of_rows that takes as its only argument a matrix (the list of columns) and returns its transpose:

def list_of_rows(list_of_columns):
    return [list(row) for row in zip(*list_of_columns)]

Well, that wasn't too bad...

Later on, we will want a free space to appear in the center of the card. So next, we write a function insert_free_spaces which takes the following arguments:

The function's return value is a new matrix where the numbers at all given coordinates have been replaced with None.

If the second argument is omitted insert_free_spaces will replace the third number of the third row, which translates to the coordinates (2, 2).

def insert_free_spaces(numbers, coords=[(2, 2)]):
    return [
        [
            n if not (x, y) in coords else None
            for x, n in enumerate(numbers[y])
        ]
        for y in range(len(numbers))
    ]

The next function prepend_title_row is quite simple. It receives a matrix and returns a new one where a row of characters spelling 'BINGO' has been prepended. This will be used to add title rows to our bingo cards.

def prepend_title_row(numbers):
    return [['B', 'I', 'N', 'G', 'O']] + numbers

Phew! Now let's put it all together with the help of a new function called card_data.

It doesn't take any arguments and returns a list of lists (rows) containing all the data we need to create a bingo card.

def card_data():
    return prepend_title_row(
        insert_free_spaces(
            list_of_rows(
                list_of_columns()
            )
        )
    )

It's time to create the PDF document! Every page contains what is essentially a nicely styled table. Let's do the styling first:

All of these rules are stored in a TableStyle and will later be requested by calling the function stylesheet which returns a dictionary of styles. Well, in this case its just one style...

def stylesheet():
    return {
        'bingo': TableStyle(
            [
                ('FONTSIZE', (0, 0), (-1, -1), 28),
                ('LEADING', (0, 0), (-1, -1), 28),
                ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
                ('INNERGRID', (0, 0), (-1, -1), 0.25, black),
                ('BOX', (0, 0), (-1, 0), 2.0, black),
                ('BOX', (0, 1), (-1, -1), 2.0, black),
                ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
                ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
                ('BACKGROUND', (2, 3), (2, 3), lightgrey),
                ('BACKGROUND', (0, 0), (-1, 0), lightgrey),
            ]
        ),
    }

Next we create the pages of the document. Every page contains a table which, in turn, contains the data created by card_data.

Since we want exactly one table per page we need to insert a page break after each table. This is achieved by zipping the list of tables with an equally long list of page breaks.

The function pages takes as its arguments the number of pages that are to be created and a stylesheet like the one we've just built. The return value is a list of pages.

def pages(number_of_pages, stylesheet):
    pages = zip(
        [
            Table(
                card_data(),
                5 * [2.5 * cm],  # column widths
                6 * [2.5 * cm],  # row heights
                style=stylesheet['bingo']
            )
            for i in range(number_of_pages)
        ],
        [
            PageBreak()
        ] * number_of_pages
    )
    return [e for p in pages for e in p]

The last function is build_pdf which does what its name implies - it builds the PDF document and writes it to the file specified in the first argument.

As a second argument build_pdf expects a list of pages as created by the function pages.

The body of build_pdf contains mostly boilerplate which you will find useful for most ReportLab projects.

def build_pdf(filename, pages):
    doc = BaseDocTemplate(filename)
    doc.addPageTemplates(
        [
            PageTemplate(
                frames=[
                    Frame(
                        doc.leftMargin,
                        doc.bottomMargin,
                        doc.width,
                        doc.height,
                        id=None
                    ),
                ]
            ),
        ]
    )
    doc.build(pages)

And that's it! The only thing left to do is to actually call build_pdf and produce the desired number of bingo cards — 1000 in this example.

build_pdf('1000-bingo-cards.pdf', pages(1, stylesheet()))

What's next? A few suggestions: