svv/pdf.py
author Tero Marttila <terom@fixme.fi>
Thu, 30 Dec 2010 23:45:00 +0200
changeset 23 26ae1b1db3c7
parent 17 820c46308e45
child 31 e1b63e4d10f4
permissions -rw-r--r--
pdf: add SignatureBlock(fullheight=) param
# coding: utf-8

"""
    PDF output
"""

from reportlab import platypus as rlpp
from reportlab.lib.units import inch
from reportlab.lib import pagesizes, styles

from cStringIO import StringIO
import itertools
import logging
import datetime


log = logging.getLogger('svv.pdf')

class Styles (object) :
    """
        Simple stylesheet
    """

    samplestyles = styles.getSampleStyleSheet()

    # normal text
    h1 = styles.ParagraphStyle('Heading1', samplestyles['h1'],
            fontName        = 'Times-Bold',
            fontSize        = 22,
            spaceBefore     = 0,
            spaceAfter      = 0,

    )

    h2 = styles.ParagraphStyle('Heading2', samplestyles['h2'],
            fontName        = 'Times-Bold',
            fontSize        = 14,
            spaceBefore     = 6,
            spaceAfter      = 0,
    )
    
    h3 = styles.ParagraphStyle('Heading3', samplestyles['h3'],
            fontName        = 'Times-Italic',
            fontSize        = 12,
            spaceBefore     = 0,
            spaceAfter      = 0,
    )

    text = styles.ParagraphStyle('Text', samplestyles['Normal'],

    )

    # list indent level
    list_indent = inch / 4

    # root title
    list_h1 = styles.ParagraphStyle('ListHeading1', samplestyles['h1'],
            bulletIndent    = 0,
            leftIndent      = 0,
    )

    # section
    list_h2 = styles.ParagraphStyle('ListHeading2', samplestyles['h2'],
            bulletIndent    = 0,
            leftIndent      = list_indent,
            fontName        = 'Times-Bold',
            fontSize        = 10,
            leading         = 12,
            spaceBefore     = 6,
            spaceAfter      = 0,
    )
    
    # segment
    list_h3 = styles.ParagraphStyle('ListHeading3', samplestyles['h3'],
            bulletIndent    = 0,
            leftIndent      = list_indent,
            fontName        = 'Times-Italic',
            fontSize        = 10,
            leading         = 12,
            spaceBefore     = 0,
            spaceAfter      = 0,
    )
    
    # infot
    list_text = styles.ParagraphStyle('ListText', samplestyles['Normal'],
            bulletIndent    = 0,
            leftIndent      = list_indent,
    )

class ListItem (object) :
    """
        Indented/nested list
    """

    @classmethod
    def seq (cls) :
        """
            List numbering.

            Fixed as numeric only for now
        """
        
        for idx in itertools.count(1) :
            yield "%d." % (idx, )


    def __init__ (self, title, title_style, text, subseq=None, sublist=None, 
            text_style=Styles.list_text, indent=Styles.list_indent) :
        """
                title       - title to display as first line
                title_style - paragraph style for title line
                text        - multi-line texto to display on first or previous lines
                subseq      - sequence of bullet-texts to use for items in sublist
                sublist     - sequence of sub-nodes

                text_style  - paragraph style for text lines
                indent      - indentation for text and sub-lists
        """

        self.title = title
        self.title_style = title_style
        self.text = text

        self.subseq = subseq
        self.sublist = sublist

        self.text_style = text_style
        self.indent = indent

    def render_pdf (self, bullet=None) :
        """
            Yield a series of PDF flowables for this list node and sub-nodes, useable for pdf.DocTemplateBase.build()

                bullet      - bullet text to use for this item's paragraph
        """

        # first line, with possible bullet
        if self.title :
            yield rlpp.Paragraph(self.title, self.title_style, bullet)

        elif self.text :
            yield rlpp.Paragraph(self.text, self.text_style, bullet)

        # indented text after title
        if self.title and self.text :
            yield rlpp.Paragraph(self.text, self.text_style)

        if self.sublist :
            # following lines, indented
            yield rlpp.Indenter(self.indent)

            # sub-items
            for item in self.sublist :
                # get (optional) bullet for item
                bullet = next(self.subseq, None) if self.subseq else None

                # render item as series of elements
                for element in item.render_pdf(bullet) :
                    yield element

            # de-dent
            yield rlpp.Indenter(-self.indent)

    

class SignatureBlock (rlpp.Flowable) :
    """
        A signature block, with multiple sets of multiple pre-fillied fields.
    """

    # vertical space per field
    FIELD_HEIGHT = 2 * inch / 4

    # horizontal offset from top of field to field line
    FIELD_OFFSET = FIELD_HEIGHT / 2
    
    # maximum width to scale columns to
    COL_WIDTH_MAX = 4 * inch
    
    # empty space to leave below the fields
    PADDING_BOTTOM = inch / 2

    def __init__ (self, cols, fields, values={}, fullheight=None) :
        """
            cols        - Column titles
            fields      - Fields titles, describing the horizontal fields
            values      - Pre-filled values as a {(col, field): value} dict
            fullheight  - Consume full vertical height to place at bottom of page

            desc/value strings can contain formatting codes:

                column      - title of the current column
                today       - today's date in %d/%m/%Y format
        """

        self.cols = cols
        self.fields = fields
        self.values = values
        self.fullheight = fullheight

    def wrap (self, width, height) :
        """
            Calculate how much space we use up, returning (width, height)
        """

        self.width = width

        self.height = len(self.fields) * self.FIELD_HEIGHT

        if self.fullheight :
            # consume all available height, to place us at the bottom
            self.height = max(self.height, height)

        return self.width, self.height
    
    def formatString (self, text, col_title) :
        """
            Format display string using context parameters
        """
        
        # XXX: the canvas.drawString we use here does support unicode?
        return text % dict(
                column      = col_title,
                today       = datetime.date.today().strftime("%d/%m/%Y"),
        )
    
    # text above field line
    VALUE_FONT = "Courier-Bold"
    VALUE_FONT_SIZE = 14
    VALUE_OFFSET = inch / 12

    # text below field line
    TITLE_FONT = "Times-Italic"
    TITLE_FONT_SIZE = 10
    TITLE_OFFSET = inch / 8

    def draw (self) :
        """
            Render full block onto our canvas
        """

        # target canvas
        canvas = self.canv

        col_width = min(self.width / len(self.cols), self.COL_WIDTH_MAX)
        col_margin = col_width * 0.1
        col_height = len(self.fields) * self.FIELD_HEIGHT + self.PADDING_BOTTOM

        for field_idx, (field_title) in enumerate(self.fields) :
            h = self.FIELD_HEIGHT
            y = col_height - h * (field_idx + 1)

            for col_idx, (col_title) in enumerate(self.cols) :
                w = col_width
                x = w * col_idx
                value = self.values.get((col_title, field_title))
                title = field_title

                value = self.formatString(value, col_title) if value else None
                title = self.formatString(title, col_title) if title else None

                if value :
                    canvas.setFont(self.VALUE_FONT, self.VALUE_FONT_SIZE)
                    canvas.drawString(x + col_margin + self.VALUE_OFFSET, y - self.FIELD_OFFSET + 2, value)
                
                # field line
                canvas.line(x + col_margin, y - self.FIELD_OFFSET, x + w - col_margin, y - h / 2)

                # desc text
                canvas.setFont(self.TITLE_FONT, self.TITLE_FONT_SIZE)
                canvas.drawString(x + col_margin + self.TITLE_OFFSET, y - self.FIELD_OFFSET - self.TITLE_FONT_SIZE, title)

class PageTemplate (rlpp.PageTemplate) :
    """
        A single-frame page with header and footer.
    """

    # vertical space available for footer/header, fixed because we can't really flow text vertically
    HEADER_HEIGHT = 1 * inch
    FOOTER_HEIGHT = 1 * inch

    COL_FONT_SIZE = 8
    COL_TITLE_FONT_NAME, COL_TITLE_FONT_SIZE = COL_TITLE_FONT = ("Times-Bold", COL_FONT_SIZE)
    COL_TEXT_FONT_NAME, COL_TEXT_FONT_SIZE = COL_TEXT_FONT = ("Times-Roman", COL_FONT_SIZE)


    def __init__ (self, id='page', page_size=pagesizes.A4, margin=inch, header_columns=None, footer_columns=None) :
        """
                id          - identifier for this page template
                page_size   - the (width, height) of this page
                margin      - the base margin to use between elements

                header_columns  - (title, text) list of header columns
                footer_columnss - (title, text) list of footer columns
        """
        
        self.page_width, self.page_height = self.page_size = page_size
        self.margin = margin

        self.header_height = self.HEADER_HEIGHT
        self.footer_height = self.FOOTER_HEIGHT

        # calculate frame
        self.frame_left = self.margin
        self.frame_bottom = self.footer_height + self.margin / 2
        self.frame_top = self.page_height - self.header_height - self.margin / 2
        self.frame_right = self.page_width - self.margin

        self.frame = rlpp.Frame(self.frame_left, self.frame_bottom, self.frame_right - self.frame_left, self.frame_top - self.frame_bottom)
        
        # init base template
        rlpp.PageTemplate.__init__(self, id, frames=[self.frame])

        self.header_columns = header_columns
        self.footer_columns = footer_columns


    def fmt_string (self, text) :
        """
            Prepare a string for display by handling format codes
        """
        
        return unicode(text % dict(
            today = datetime.date.today().strftime("%d / %m / %Y"),
        ))

    def draw_column (self, canvas, x, y, width, title, lines, gray=None) :
        """
            Draw a column in the specified position, with the specified lines of text
        """

        text = canvas.beginText(x, y)

        # grayscale text?
        if gray :
            text.setFillGray(gray)

        # title
        text.setFont(*self.COL_TITLE_FONT)
        text.textLine(self.fmt_string(title))

        # lines
        text.setFont(*self.COL_TEXT_FONT)

        # XXX: textobject's textLine fails at unicode, but textLine should work...
        for line in lines :
            text.textLine(self.fmt_string(line))
        
        # draw out
        canvas.drawText(text)


    def draw_columns (self, canvas, x, y, width, columns, **opts) :
        """
            Draw a series of columns in the specified position
        """

        col_count = len(columns)
        col_width = width / col_count

        x_base = x

        for col_idx, (col_data) in enumerate(columns) :
            x = x_base + (col_idx * col_width)

            # draw column data at correct offset inside our space
            self.draw_column(canvas, x, y - self.COL_FONT_SIZE, col_width, *col_data, **opts)


    def draw_header (self, canvas) :
        """
            Draw page header
        """
        
        # offsets
        x = self.margin
        h = self.footer_height - self.margin / 4
        w = self.page_width - self.margin * 2

        # spacer
        y = self.page_height - self.footer_height

        canvas.setLineWidth(0.5)
        canvas.line(x - self.margin / 2, y, self.page_width - self.margin / 2, y)

        # columns
        y = self.page_height - self.margin / 4

        self.draw_columns(canvas, x, y, w, self.header_columns)


    def draw_footer (self, canvas) :
        """
            Draw page footer
        """
        
        # offsets
        x = self.margin
        y = self.footer_height
        w = self.page_width - self.margin * 2

        # spacer
        canvas.setLineWidth(0.5)
        canvas.line(x - self.margin / 2, y, self.page_width - self.margin / 2, y)

        # columns
        self.draw_columns(canvas, x, y - inch / 8, w, self.footer_columns, gray=0.4)


    def beforeDrawPage (self, canvas, document) :
        """
            Draw page headers/footers
        """

        self.draw_header(canvas)
        self.draw_footer(canvas)

class DocumentTemplate (rlpp.BaseDocTemplate) :
    def __init__ (self, page_templates, title, author, page_size=pagesizes.A4) :
        """
            Initialize with fixed list of needed PageTemplates.
        """

        # we supply the file later
        rlpp.BaseDocTemplate.__init__(self, filename=None, 
                pageTemplates=page_templates, title=title, author=author,
                pageSize=page_size
        )

    def render_buf (self, elements) :
        """
            Build the document using the given list of Flowables, returning a StringIO containing the PDF.
        """

        buf = StringIO()

        # build
        self.build(elements, buf)

        # prepare for read
        buf.seek(0)

        return buf

    def render_string (self, elements) :
        """
            Build the document using the given list of Flowables, returning the PDF as a single str.
        """
        
        # render
        buf = self.render_buf(elements)

        # binary data out
        return buf.getvalue()