author | Tero Marttila <terom@fixme.fi> |
Thu, 23 Dec 2010 19:56:44 +0200 | |
changeset 15 | e098ee83b363 |
parent 4 | b3a1ab44f517 |
child 17 | 820c46308e45 |
permissions | -rw-r--r-- |
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 |
||
15
e098ee83b363
Implement OrderContractDocument
Tero Marttila <terom@fixme.fi>
parents:
4
diff
changeset
|
423 |
def render_buf (self, elements) : |
4 | 424 |
""" |
15
e098ee83b363
Implement OrderContractDocument
Tero Marttila <terom@fixme.fi>
parents:
4
diff
changeset
|
425 |
Build the document using the given list of Flowables, returning a StringIO containing the PDF. |
4 | 426 |
""" |
427 |
||
428 |
buf = StringIO() |
|
429 |
||
430 |
# build |
|
431 |
self.build(elements, buf) |
|
432 |
||
15
e098ee83b363
Implement OrderContractDocument
Tero Marttila <terom@fixme.fi>
parents:
4
diff
changeset
|
433 |
# prepare for read |
e098ee83b363
Implement OrderContractDocument
Tero Marttila <terom@fixme.fi>
parents:
4
diff
changeset
|
434 |
buf.seek(0) |
e098ee83b363
Implement OrderContractDocument
Tero Marttila <terom@fixme.fi>
parents:
4
diff
changeset
|
435 |
|
e098ee83b363
Implement OrderContractDocument
Tero Marttila <terom@fixme.fi>
parents:
4
diff
changeset
|
436 |
return buf |
e098ee83b363
Implement OrderContractDocument
Tero Marttila <terom@fixme.fi>
parents:
4
diff
changeset
|
437 |
|
e098ee83b363
Implement OrderContractDocument
Tero Marttila <terom@fixme.fi>
parents:
4
diff
changeset
|
438 |
def render_string (self, elements) : |
e098ee83b363
Implement OrderContractDocument
Tero Marttila <terom@fixme.fi>
parents:
4
diff
changeset
|
439 |
""" |
e098ee83b363
Implement OrderContractDocument
Tero Marttila <terom@fixme.fi>
parents:
4
diff
changeset
|
440 |
Build the document using the given list of Flowables, returning the PDF as a single str. |
e098ee83b363
Implement OrderContractDocument
Tero Marttila <terom@fixme.fi>
parents:
4
diff
changeset
|
441 |
""" |
e098ee83b363
Implement OrderContractDocument
Tero Marttila <terom@fixme.fi>
parents:
4
diff
changeset
|
442 |
|
e098ee83b363
Implement OrderContractDocument
Tero Marttila <terom@fixme.fi>
parents:
4
diff
changeset
|
443 |
# render |
e098ee83b363
Implement OrderContractDocument
Tero Marttila <terom@fixme.fi>
parents:
4
diff
changeset
|
444 |
buf = self.render_buf(elements) |
e098ee83b363
Implement OrderContractDocument
Tero Marttila <terom@fixme.fi>
parents:
4
diff
changeset
|
445 |
|
4 | 446 |
# binary data out |
447 |
return buf.getvalue() |
|
448 |