4
|
1 |
# coding: utf-8
|
|
2 |
|
|
3 |
"""
|
|
4 |
PDF output
|
|
5 |
"""
|
|
6 |
|
|
7 |
from reportlab import platypus as rlpp
|
|
8 |
from reportlab.lib.units import inch
|
|
9 |
from reportlab.lib import pagesizes, styles
|
|
10 |
|
|
11 |
from cStringIO import StringIO
|
|
12 |
import itertools
|
|
13 |
import logging
|
|
14 |
import datetime
|
|
15 |
|
|
16 |
|
|
17 |
log = logging.getLogger('svv.pdf')
|
|
18 |
|
|
19 |
class Styles (object) :
|
|
20 |
"""
|
|
21 |
Simple stylesheet
|
|
22 |
"""
|
|
23 |
|
|
24 |
samplestyles = styles.getSampleStyleSheet()
|
|
25 |
|
|
26 |
# normal text
|
|
27 |
h1 = styles.ParagraphStyle('Heading1', samplestyles['h1'],
|
|
28 |
fontName = 'Times-Bold',
|
|
29 |
fontSize = 22,
|
|
30 |
spaceBefore = 0,
|
|
31 |
spaceAfter = 0,
|
|
32 |
|
|
33 |
)
|
|
34 |
|
|
35 |
h2 = styles.ParagraphStyle('Heading2', samplestyles['h2'],
|
|
36 |
fontName = 'Times-Bold',
|
|
37 |
fontSize = 14,
|
|
38 |
spaceBefore = 6,
|
|
39 |
spaceAfter = 0,
|
|
40 |
)
|
|
41 |
|
|
42 |
h3 = styles.ParagraphStyle('Heading3', samplestyles['h3'],
|
|
43 |
fontName = 'Times-Italic',
|
|
44 |
fontSize = 12,
|
|
45 |
spaceBefore = 0,
|
|
46 |
spaceAfter = 0,
|
|
47 |
)
|
|
48 |
|
|
49 |
text = styles.ParagraphStyle('Text', samplestyles['Normal'],
|
|
50 |
|
|
51 |
)
|
|
52 |
|
|
53 |
# list indent level
|
|
54 |
list_indent = inch / 4
|
|
55 |
|
|
56 |
# root title
|
|
57 |
list_h1 = styles.ParagraphStyle('ListHeading1', samplestyles['h1'],
|
|
58 |
bulletIndent = 0,
|
|
59 |
leftIndent = 0,
|
|
60 |
)
|
|
61 |
|
|
62 |
# section
|
|
63 |
list_h2 = styles.ParagraphStyle('ListHeading2', samplestyles['h2'],
|
|
64 |
bulletIndent = 0,
|
|
65 |
leftIndent = list_indent,
|
|
66 |
fontName = 'Times-Bold',
|
|
67 |
fontSize = 10,
|
|
68 |
leading = 12,
|
|
69 |
spaceBefore = 6,
|
|
70 |
spaceAfter = 0,
|
|
71 |
)
|
|
72 |
|
|
73 |
# segment
|
|
74 |
list_h3 = styles.ParagraphStyle('ListHeading3', samplestyles['h3'],
|
|
75 |
bulletIndent = 0,
|
|
76 |
leftIndent = list_indent,
|
|
77 |
fontName = 'Times-Italic',
|
|
78 |
fontSize = 10,
|
|
79 |
leading = 12,
|
|
80 |
spaceBefore = 0,
|
|
81 |
spaceAfter = 0,
|
|
82 |
)
|
|
83 |
|
|
84 |
# infot
|
|
85 |
list_text = styles.ParagraphStyle('ListText', samplestyles['Normal'],
|
|
86 |
bulletIndent = 0,
|
|
87 |
leftIndent = list_indent,
|
|
88 |
)
|
|
89 |
|
|
90 |
class ListItem (object) :
|
|
91 |
"""
|
|
92 |
Indented/nested list
|
|
93 |
"""
|
|
94 |
|
|
95 |
@classmethod
|
|
96 |
def seq (cls) :
|
|
97 |
"""
|
|
98 |
List numbering.
|
|
99 |
|
|
100 |
Fixed as numeric only for now
|
|
101 |
"""
|
|
102 |
|
|
103 |
for idx in itertools.count(1) :
|
|
104 |
yield "%d." % (idx, )
|
|
105 |
|
|
106 |
|
|
107 |
def __init__ (self, title, title_style, text, subseq=None, sublist=None,
|
|
108 |
text_style=Styles.list_text, indent=Styles.list_indent) :
|
|
109 |
"""
|
|
110 |
title - title to display as first line
|
|
111 |
title_style - paragraph style for title line
|
|
112 |
text - multi-line texto to display on first or previous lines
|
|
113 |
subseq - sequence of bullet-texts to use for items in sublist
|
|
114 |
sublist - sequence of sub-nodes
|
|
115 |
|
|
116 |
text_style - paragraph style for text lines
|
|
117 |
indent - indentation for text and sub-lists
|
|
118 |
"""
|
|
119 |
|
|
120 |
self.title = title
|
|
121 |
self.title_style = title_style
|
|
122 |
self.text = text
|
|
123 |
|
|
124 |
self.subseq = subseq
|
|
125 |
self.sublist = sublist
|
|
126 |
|
|
127 |
self.text_style = text_style
|
|
128 |
self.indent = indent
|
|
129 |
|
|
130 |
def render_pdf (self, bullet=None) :
|
|
131 |
"""
|
|
132 |
Yield a series of PDF flowables for this list node and sub-nodes, useable for pdf.DocTemplateBase.build()
|
|
133 |
|
|
134 |
bullet - bullet text to use for this item's paragraph
|
|
135 |
"""
|
|
136 |
|
|
137 |
# first line, with possible bullet
|
|
138 |
if self.title :
|
|
139 |
yield rlpp.Paragraph(self.title, self.title_style, bullet)
|
|
140 |
|
|
141 |
elif self.text :
|
|
142 |
yield rlpp.Paragraph(self.text, self.text_style, bullet)
|
|
143 |
|
|
144 |
# indented text after title
|
|
145 |
if self.title and self.text :
|
|
146 |
yield rlpp.Paragraph(self.text, self.text_style)
|
|
147 |
|
|
148 |
if self.sublist :
|
|
149 |
# following lines, indented
|
|
150 |
yield rlpp.Indenter(self.indent)
|
|
151 |
|
|
152 |
# sub-items
|
|
153 |
for item in self.sublist :
|
|
154 |
# get (optional) bullet for item
|
|
155 |
bullet = next(self.subseq, None) if self.subseq else None
|
|
156 |
|
|
157 |
# render item as series of elements
|
|
158 |
for element in item.render_pdf(bullet) :
|
|
159 |
yield element
|
|
160 |
|
|
161 |
# de-dent
|
|
162 |
yield rlpp.Indenter(-self.indent)
|
|
163 |
|
|
164 |
|
|
165 |
|
|
166 |
class SignatureBlock (rlpp.Flowable) :
|
|
167 |
"""
|
|
168 |
A signature block, with multiple sets of multiple pre-fillied fields.
|
|
169 |
"""
|
|
170 |
|
|
171 |
# vertical space per field
|
|
172 |
FIELD_HEIGHT = 2 * inch / 4
|
|
173 |
|
|
174 |
# horizontal offset from top of field to field line
|
|
175 |
FIELD_OFFSET = FIELD_HEIGHT / 2
|
|
176 |
|
|
177 |
# maximum width to scale columns to
|
|
178 |
COL_WIDTH_MAX = 4 * inch
|
|
179 |
|
|
180 |
# empty space to leave below the fields
|
|
181 |
PADDING_BOTTOM = inch / 2
|
|
182 |
|
|
183 |
def __init__ (self, cols, fields, values) :
|
|
184 |
"""
|
|
185 |
cols - Column titles
|
|
186 |
fields - Fields titles, describing the horizontal fields
|
|
187 |
values - Pre-filled values as a {(col, field): value} dict
|
|
188 |
|
|
189 |
desc/value strings can contain formatting codes:
|
|
190 |
|
|
191 |
column - title of the current column
|
|
192 |
today - today's date in %d/%m/%Y format
|
|
193 |
"""
|
|
194 |
|
|
195 |
self.cols = cols
|
|
196 |
self.fields = fields
|
|
197 |
self.values = values
|
|
198 |
|
|
199 |
def wrap (self, width, height) :
|
|
200 |
"""
|
|
201 |
Calculate how much space we use up, returning (width, height)
|
|
202 |
"""
|
|
203 |
|
|
204 |
self.width = width
|
|
205 |
|
|
206 |
# consume all available height, to place us at the bottom
|
|
207 |
self.height = max(len(self.fields) * self.FIELD_HEIGHT, height)
|
|
208 |
|
|
209 |
return self.width, self.height
|
|
210 |
|
|
211 |
def formatString (self, text, col_title) :
|
|
212 |
"""
|
|
213 |
Format display string using context parameters
|
|
214 |
"""
|
|
215 |
|
|
216 |
return text % dict(
|
|
217 |
column = col_title,
|
|
218 |
today = datetime.date.today().strftime("%d/%m/%Y"),
|
|
219 |
)
|
|
220 |
|
|
221 |
# text above field line
|
|
222 |
VALUE_FONT = "Courier-Bold"
|
|
223 |
VALUE_FONT_SIZE = 14
|
|
224 |
VALUE_OFFSET = inch / 12
|
|
225 |
|
|
226 |
# text below field line
|
|
227 |
TITLE_FONT = "Times-Italic"
|
|
228 |
TITLE_FONT_SIZE = 10
|
|
229 |
TITLE_OFFSET = inch / 8
|
|
230 |
|
|
231 |
def draw (self) :
|
|
232 |
"""
|
|
233 |
Render full block onto our canvas
|
|
234 |
"""
|
|
235 |
|
|
236 |
# target canvas
|
|
237 |
canvas = self.canv
|
|
238 |
|
|
239 |
col_width = min(self.width / len(self.cols), self.COL_WIDTH_MAX)
|
|
240 |
col_margin = col_width * 0.1
|
|
241 |
col_height = len(self.fields) * self.FIELD_HEIGHT + self.PADDING_BOTTOM
|
|
242 |
|
|
243 |
for field_idx, (field_title) in enumerate(self.fields) :
|
|
244 |
h = self.FIELD_HEIGHT
|
|
245 |
y = col_height - h * (field_idx + 1)
|
|
246 |
|
|
247 |
for col_idx, (col_title) in enumerate(self.cols) :
|
|
248 |
w = col_width
|
|
249 |
x = w * col_idx
|
|
250 |
value = self.values.get((col_title, field_title))
|
|
251 |
title = field_title
|
|
252 |
|
|
253 |
value = self.formatString(value, col_title) if value else None
|
|
254 |
title = self.formatString(title, col_title) if title else None
|
|
255 |
|
|
256 |
if value :
|
|
257 |
canvas.setFont(self.VALUE_FONT, self.VALUE_FONT_SIZE)
|
|
258 |
canvas.drawString(x + col_margin + self.VALUE_OFFSET, y - self.FIELD_OFFSET + 2, value)
|
|
259 |
|
|
260 |
# field line
|
|
261 |
canvas.line(x + col_margin, y - self.FIELD_OFFSET, x + w - col_margin, y - h / 2)
|
|
262 |
|
|
263 |
# desc text
|
|
264 |
canvas.setFont(self.TITLE_FONT, self.TITLE_FONT_SIZE)
|
|
265 |
canvas.drawString(x + col_margin + self.TITLE_OFFSET, y - self.FIELD_OFFSET - self.TITLE_FONT_SIZE, title)
|
|
266 |
|
|
267 |
class PageTemplate (rlpp.PageTemplate) :
|
|
268 |
"""
|
|
269 |
A single-frame page with header and footer.
|
|
270 |
"""
|
|
271 |
|
|
272 |
# vertical space available for footer/header, fixed because we can't really flow text vertically
|
|
273 |
HEADER_HEIGHT = 1 * inch
|
|
274 |
FOOTER_HEIGHT = 1 * inch
|
|
275 |
|
|
276 |
COL_FONT_SIZE = 8
|
|
277 |
COL_TITLE_FONT_NAME, COL_TITLE_FONT_SIZE = COL_TITLE_FONT = ("Times-Bold", COL_FONT_SIZE)
|
|
278 |
COL_TEXT_FONT_NAME, COL_TEXT_FONT_SIZE = COL_TEXT_FONT = ("Times-Roman", COL_FONT_SIZE)
|
|
279 |
|
|
280 |
|
|
281 |
def __init__ (self, id='page', page_size=pagesizes.A4, margin=inch, header_columns=None, footer_columns=None) :
|
|
282 |
"""
|
|
283 |
id - identifier for this page template
|
|
284 |
page_size - the (width, height) of this page
|
|
285 |
margin - the base margin to use between elements
|
|
286 |
|
|
287 |
header_columns - (title, text) list of header columns
|
|
288 |
footer_columnss - (title, text) list of footer columns
|
|
289 |
"""
|
|
290 |
|
|
291 |
self.page_width, self.page_height = self.page_size = page_size
|
|
292 |
self.margin = margin
|
|
293 |
|
|
294 |
self.header_height = self.HEADER_HEIGHT
|
|
295 |
self.footer_height = self.FOOTER_HEIGHT
|
|
296 |
|
|
297 |
# calculate frame
|
|
298 |
self.frame_left = self.margin
|
|
299 |
self.frame_bottom = self.footer_height + self.margin / 2
|
|
300 |
self.frame_top = self.page_height - self.header_height - self.margin / 2
|
|
301 |
self.frame_right = self.page_width - self.margin
|
|
302 |
|
|
303 |
self.frame = rlpp.Frame(self.frame_left, self.frame_bottom, self.frame_right - self.frame_left, self.frame_top - self.frame_bottom)
|
|
304 |
|
|
305 |
# init base template
|
|
306 |
rlpp.PageTemplate.__init__(self, id, frames=[self.frame])
|
|
307 |
|
|
308 |
self.header_columns = header_columns
|
|
309 |
self.footer_columns = footer_columns
|
|
310 |
|
|
311 |
|
|
312 |
def fmt_string (self, text) :
|
|
313 |
"""
|
|
314 |
Prepare a string for display by handling format codes
|
|
315 |
"""
|
|
316 |
|
|
317 |
# XXX: unicode?
|
|
318 |
return str(text % dict(
|
|
319 |
today = datetime.date.today().strftime("%d / %m / %Y"),
|
|
320 |
))
|
|
321 |
|
|
322 |
|
|
323 |
def draw_column (self, canvas, x, y, width, title, lines, gray=None) :
|
|
324 |
"""
|
|
325 |
Draw a column in the specified position, with the specified lines of text
|
|
326 |
"""
|
|
327 |
|
|
328 |
text = canvas.beginText(x, y)
|
|
329 |
|
|
330 |
# grayscale text?
|
|
331 |
if gray :
|
|
332 |
text.setFillGray(gray)
|
|
333 |
|
|
334 |
# title
|
|
335 |
text.setFont(*self.COL_TITLE_FONT)
|
|
336 |
text.textLine(self.fmt_string(title))
|
|
337 |
|
|
338 |
# lines
|
|
339 |
text.setFont(*self.COL_TEXT_FONT)
|
|
340 |
text.textLines(self.fmt_string(lines))
|
|
341 |
|
|
342 |
# draw out
|
|
343 |
canvas.drawText(text)
|
|
344 |
|
|
345 |
|
|
346 |
def draw_columns (self, canvas, x, y, width, columns, **opts) :
|
|
347 |
"""
|
|
348 |
Draw a series of columns in the specified position
|
|
349 |
"""
|
|
350 |
|
|
351 |
col_count = len(columns)
|
|
352 |
col_width = width / col_count
|
|
353 |
|
|
354 |
x_base = x
|
|
355 |
|
|
356 |
for col_idx, (col_data) in enumerate(columns) :
|
|
357 |
x = x_base + (col_idx * col_width)
|
|
358 |
|
|
359 |
# draw column data at correct offset inside our space
|
|
360 |
self.draw_column(canvas, x, y - self.COL_FONT_SIZE, col_width, *col_data, **opts)
|
|
361 |
|
|
362 |
|
|
363 |
def draw_header (self, canvas) :
|
|
364 |
"""
|
|
365 |
Draw page header
|
|
366 |
"""
|
|
367 |
|
|
368 |
# offsets
|
|
369 |
x = self.margin
|
|
370 |
h = self.footer_height - self.margin / 4
|
|
371 |
w = self.page_width - self.margin * 2
|
|
372 |
|
|
373 |
# spacer
|
|
374 |
y = self.page_height - self.footer_height
|
|
375 |
|
|
376 |
canvas.setLineWidth(0.5)
|
|
377 |
canvas.line(x - self.margin / 2, y, self.page_width - self.margin / 2, y)
|
|
378 |
|
|
379 |
# columns
|
|
380 |
y = self.page_height - self.margin / 4
|
|
381 |
|
|
382 |
self.draw_columns(canvas, x, y, w, self.header_columns)
|
|
383 |
|
|
384 |
|
|
385 |
def draw_footer (self, canvas) :
|
|
386 |
"""
|
|
387 |
Draw page footer
|
|
388 |
"""
|
|
389 |
|
|
390 |
# offsets
|
|
391 |
x = self.margin
|
|
392 |
y = self.footer_height
|
|
393 |
w = self.page_width - self.margin * 2
|
|
394 |
|
|
395 |
# spacer
|
|
396 |
canvas.setLineWidth(0.5)
|
|
397 |
canvas.line(x - self.margin / 2, y, self.page_width - self.margin / 2, y)
|
|
398 |
|
|
399 |
# columns
|
|
400 |
self.draw_columns(canvas, x, y - inch / 8, w, self.footer_columns, gray=0.4)
|
|
401 |
|
|
402 |
|
|
403 |
def beforeDrawPage (self, canvas, document) :
|
|
404 |
"""
|
|
405 |
Draw page headers/footers
|
|
406 |
"""
|
|
407 |
|
|
408 |
self.draw_header(canvas)
|
|
409 |
self.draw_footer(canvas)
|
|
410 |
|
|
411 |
class DocumentTemplate (rlpp.BaseDocTemplate) :
|
|
412 |
def __init__ (self, page_templates, title, author, page_size=pagesizes.A4) :
|
|
413 |
"""
|
|
414 |
Initialize with fixed list of needed PageTemplates.
|
|
415 |
"""
|
|
416 |
|
|
417 |
# we supply the file later
|
|
418 |
rlpp.BaseDocTemplate.__init__(self, filename=None,
|
|
419 |
pageTemplates=page_templates, title=title, author=author,
|
|
420 |
pageSize=page_size
|
|
421 |
)
|
|
422 |
|
|
423 |
def render_string (self, elements) :
|
|
424 |
"""
|
|
425 |
Build the document using the given list of Flowables, returning the PDF as a single str.
|
|
426 |
"""
|
|
427 |
|
|
428 |
buf = StringIO()
|
|
429 |
|
|
430 |
# build
|
|
431 |
self.build(elements, buf)
|
|
432 |
|
|
433 |
# binary data out
|
|
434 |
return buf.getvalue()
|
|
435 |
|