# 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 "•"
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