(Bullshit) Bingo Card Generator

#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""
(Bullshit) Bingo Card Generator
===============================

**Bullshit Bingo** is a slight variation of `Buzzword Bingo`_.  The first player
completing a full row his/her card shouts "Bullshit!" to kindly inform the
presenter of a possible overuse of empty phrases.

Actually, this program is not restricted to this special type of Bingo.
Different kinds of words (and numbers, of course) can be used.

A word list (a plain text file with one term per word) is required as source
for the words that should be randomly used to create the bingo cards.

The randomly generated cards are put out as HTML and can be viewed and
printed with a common Web browser (e.g. Firefox_).

When generating multiple cards, two of them (with the default size) should fit
on one page of DIN A4 paper when printed.  Check your browser's printing
preview and, if necessary, adjust the CSS section of the HTML template.

Requires Python_ version 2.5 or greater, and Jinja2_ (tested with 2.6, earlier
versions may work). For a Python version before 2.7, the argparse_ module must
be installed separately.


Changelog
---------

- Read words file with UTF-8 encoding to support non-ASCII characters.

- Switched template engine from Genshi to Jinja 2 to get more control over
  whitespace handling.

- Changed argument parser from optparse_ to argparse_.


.. _Buzzword Bingo: http://en.wikipedia.org/wiki/Buzzword_bingo
.. _Firefox:        http://www.mozilla.com/firefox/
.. _Python:         http://www.python.org/
.. _Jinja2:         http://jinja.pocoo.org/
.. _argparse:       http://code.google.com/p/argparse/
.. _optparse:       http://docs.python.org/library/optparse.html

:Copyright: 2007-2012 `Jochen Kupperschmidt <http://homework.nwsnet.de/>`_
:Date: 12-Jun-2012 (previous release: 09-Nov-2007)
:License: MIT
"""

from __future__ import with_statement
import argparse
import codecs
from collections import namedtuple
from itertools import chain, islice, izip_longest, repeat
from random import randrange, sample
import sys

from jinja2 import Environment


Field = namedtuple('Field', ['type', 'value'])

FIELD_EMPTY = Field('empty', None)
FIELD_BONUS = Field('bonus', None)

TEMPLATE = """\
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8"/>
    <style>
      @media print {
        html,
        body {
          margin: 0;
        }
      }

      h1 {
        font-size: 6mm;
      }

      table {
        border-color: #666666;
        border-spacing: 0;
        border-style: solid;
        border-width: 0 0.25mm 0.25mm 0;
        margin-bottom: 1cm;
      }
      td {
        border-color: #666666;
        border-style: solid;
        border-width: 0.25mm 0 0 0.25mm;
        font-size: 4mm;
        height: 2cm;
        text-align: center;
        width: 3cm;
      }
      td strong {
        font-size: 120%;
        text-transform: uppercase;
      }
    </style>
    <title>Bullshit Bingo</title>
  </head>
  <body>
    {%- for table in tables %}

    <h1>Bullshit Bingo</h1>
    <table cellspacing="0">
      {%- for row in table %}
      <tr>
        {%- for field in row %}
        <td>
          {%- if field.type == 'normal' -%}
            {{ field.value }}
          {%- elif field.type == 'bonus' -%}
            <strong>Bingo!</strong>
          {%- elif field.type == 'empty' -%}
            -
          {%- endif -%}
        </td>
        {%- endfor %}
      </tr>
      {%- endfor %}
    </table>
    {%- endfor %}

  </body>
</html>"""

def parse_args():
    """Parse command line arguments."""
    parser = argparse.ArgumentParser()

    parser.add_argument('words_file',
        metavar='<words file>')

    parser.add_argument('-b', '--bonus-field',
        dest='include_bonus_field',
        action='store_true',
        default=False,
        help='add one bonus field per card')

    parser.add_argument('-c', '--num-cards',
        dest='num_cards',
        type=int,
        default=1,
        help='number of cards to create (default: 1)')

    parser.add_argument('-s', '--card-size',
        dest='card_size',
        type=int,
        default=5,
        help='number of rows and columns per card (default: 5)')

    return parser.parse_args()

def load_words(filename):
    """Load words from a file.

    Every line is to be considered as one word.
    """
    with codecs.open(filename, 'rb', 'utf-8') as f:
        for line in f:
            # Only yield non-empty, non-whitespace lines.
            line = line.strip()
            if line:
                yield line

def build_table(values, size, include_bonus_field=False):
    """Build a 2-dimensional table of the given size with unique, random
    values.

    If the table has more fields than values are available, the remaining
    fields will be empty.
    """
    field_total = size ** 2
    random_values = collect_random_sublist(values, field_total)
    fields = to_fields(random_values, field_total)

    if include_bonus_field:
        # Replace a field at a random position with a bonus field.
        fields[randrange(0, field_total)] = FIELD_BONUS

    # Group into rows.
    return izip_longest(*[iter(fields)] * size)

def collect_random_sublist(values, n):
    """Return unique, random elements from the values.

    Return `n` values or, if `n` is greater than the number of values, all
    values.
    """
    # The sample length must not exceed the number of available values.
    sample_length = min(n, len(values))
    return sample(values, sample_length)

def to_fields(values, n):
    """Create fields from the values, append infinite empty fields, and return
    the first `n` items.
    """
    normal_fields = (Field('normal', value) for value in values)
    fields = chain(normal_fields, repeat(FIELD_EMPTY))
    return take(fields, n)

def take(iterable, n):
    """Return the first `n` items of the iterable as a list."""
    return list(islice(iterable, n))

def render_html(tables):
    """Assemble an HTML page with cards from the word tables."""
    env = Environment(autoescape=True)
    template = env.from_string(TEMPLATE)
    return template.render(tables=tables)

if __name__ == '__main__':
    args = parse_args()

    # Load words from given file.
    words = frozenset(load_words(args.words_file))

    # Create tables of words.
    tables = [list(build_table(words, args.card_size, args.include_bonus_field))
        for _ in xrange(args.num_cards)]

    # Write HTML output to stdout.  To create a file, redirect the data by
    # appending something like ``> cards.html`` at the command line.
    html = render_html(tables).encode('utf-8')
    sys.stdout.write(html)