svv/pdf.py
author Tero Marttila <terom@fixme.fi>
Mon, 10 Jan 2011 17:51:08 +0200
changeset 53 06dad873204d
parent 31 e1b63e4d10f4
permissions -rw-r--r--
items: use DeleteItemForm for ItemView as well
# 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()

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

    )
    
    # normal text
    text = p = default

    # headers
    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,
    )


    # 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_p = styles.ParagraphStyle('ListText', samplestyles['Normal'],
            bulletIndent    = 0,
            leftIndent      = list_indent,
    )
    list_text = list_p

    # lookup styles by rule
    # searched in order, first match against end of node_path wins
    RULES = (
        ('li p',        list_p),
        ('li h1',       list_h1),
        ('li h2',       list_h2),
        ('li h3',       list_h3),

        ('p',           p),
        ('h1',          h1),
        ('h2',          h2),
        ('h3',          h3),

    )
    
    @classmethod
    def match (cls, name=None) :
        """
            Return appropriate style.
        """
        
        if name :
            return getattr(cls, name, cls.default)

        else :
            return cls.default

    def lookup (self, node_path) :
        """
            Return a suitable ParagraphStyle to use for a node
                
                node_path   - tag-path to node (list of tags as ancestry of node)
        """

        # XXX: find a better hack
        nodepath = ' '.join(node_path)

        for rule, style in self.RULES :
            if nodepath.endswith(rule) :
                return style

        # default
        return self.default

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()

class Markup (object) :
    """
        Generate Paragraphs from Markup docs.
    """
    
    def __init__ (self, styles) :
        """
            Initialize render state.
                
                styles          - Style instance to use

            This class is stateful and single-use.

        """
        
        # elment stack
        self.stack = []

        # styles
        self.styles = styles

    def lookup_style (self, elem=None) :
        """
            Lookup style for given element, which must be at the top of the stack.
        """
        
        # path to element
        path = self.stack

        if elem is not None and path[-1] is not elem :
            # XXX: sub-elem?
            path = path + [elem]

        # lookup
        return self.styles.lookup([elem.tag for elem in path])

    ## Basic text
    def render_p (self, elem, bullet=None) :
        """
            Normal paragraph.

            XXX: inline markup?
        """

        style = self.lookup_style(elem)

        yield rlpp.Paragraph(elem.text, style, bullet)
    
    def _render_hx (self, elem, bullet=None) :
        """
            Render given h1/h2/h3 element
        """
        
        # get style for <hX> tag
        style = self.lookup_style(elem)
        
        # styled text
        yield rlpp.Paragraph(elem.text, style, bullet)

    render_h1 = render_h2 = render_h3 = _render_hx
    
    ## Lists
    def list_ol_seq (self) :
        """
            Numeric ordered list numbering.
        """
        
        for idx in itertools.count(1) :
            yield "%d." % (idx, )
    
    def list_ul_seq (self) :
        """
            Bulleted unordered list numbering.
        """

        while True :
            yield "&bull;"

    def _render_list (self, elem, seq, bullet=None) :
        """
            Render <ul>/<ol> containing <li>s
        """

        indent = self.styles.list_indent

        # following lines, indented
        yield rlpp.Indenter(indent)

        # children
        for sub in elem :
            # get (optional) bullet for item
            bullet = next(seq, None) if seq else None

            # sub-render
            for ff in self.render(sub, bullet) :
                yield ff

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

    def render_ul (self, elem, bullet=None) : return self._render_list(elem, self.list_ul_seq(), bullet)
    def render_ol (self, elem, bullet=None) : return self._render_list(elem, self.list_ol_seq(), bullet)
 
    def render_li (self, elem, bullet) :
        """
            Render <li>, with given bullet on first line of text
        """

        children = list(elem)
        
        # render bullet on first item
        if elem.text :
            # style for text pseudo-element
            style = self.lookup_style(elem.makeelement('text', {}))

            # direct text, no sub-p or such
            yield rlpp.Paragraph(elem.text, style, bullet)
        
        elif children :
            sub = children.pop(0)

            # render first sub with bullet
            for ff in self.render(sub, bullet) :
                yield ff

        # render remaining elements
        for sub in children :
            for ff in self.render(sub) :
                yield ff
 
    ## Root element
    def render_root (self, elem, bullet) :
        """
            Render the given root element's children, ignoring the root element itself.
        """

        for sub in elem :
            for ff in self.render(sub) :
                yield ff
           
    ## Top-level element render dispatch
    def render (self, elem, bullet=None) :
        """
            Yield a series of Flowables for the given element and its children.

                elem            - Element to (recursively) render
                bullet          - optional bullet text to use for this element
        """

        log.debug("%r, bullet=%r", elem, bullet)

        try :
            # lookup
            func = getattr(self, 'render_%s' % elem.tag)

        except AttributeError, ex :
            raise Exception("Unhandled tag %r" % elem)

        # enter element
        self.stack.append(elem)

        # dispatch
        for ff in func(elem, bullet) :
            yield ff

        # exit element
        assert self.stack.pop(-1) is elem