1 | ## $Id: pdf.py 14196 2016-09-27 21:56:01Z henrik $ |
---|
2 | ## |
---|
3 | ## Copyright (C) 2012 Uli Fouquet & Henrik Bettermann |
---|
4 | ## This program is free software; you can redistribute it and/or modify |
---|
5 | ## it under the terms of the GNU General Public License as published by |
---|
6 | ## the Free Software Foundation; either version 2 of the License, or |
---|
7 | ## (at your option) any later version. |
---|
8 | ## |
---|
9 | ## This program is distributed in the hope that it will be useful, |
---|
10 | ## but WITHOUT ANY WARRANTY; without even the implied warranty of |
---|
11 | ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
---|
12 | ## GNU General Public License for more details. |
---|
13 | ## |
---|
14 | ## You should have received a copy of the GNU General Public License |
---|
15 | ## along with this program; if not, write to the Free Software |
---|
16 | ## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA |
---|
17 | ## |
---|
18 | """ |
---|
19 | Reusable components for pdf generation. |
---|
20 | """ |
---|
21 | import grok |
---|
22 | import os |
---|
23 | import pytz |
---|
24 | from cStringIO import StringIO |
---|
25 | from datetime import datetime |
---|
26 | from reportlab.graphics.barcode.qr import QrCodeWidget |
---|
27 | from reportlab.graphics.shapes import Drawing |
---|
28 | from reportlab.lib import colors |
---|
29 | from reportlab.lib.units import cm, inch, mm |
---|
30 | from reportlab.lib.pagesizes import A4, landscape, portrait |
---|
31 | from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle |
---|
32 | from reportlab.pdfgen.canvas import Canvas |
---|
33 | from reportlab.platypus import ( |
---|
34 | SimpleDocTemplate, Spacer, Paragraph, Image, Table) |
---|
35 | from zope.formlib.form import setUpEditWidgets |
---|
36 | from zope.i18n import translate |
---|
37 | from zope.publisher.browser import TestRequest |
---|
38 | from zope.component import getUtility, queryUtility |
---|
39 | from waeup.ikoba.browser.interfaces import IPDFCreator |
---|
40 | from waeup.ikoba.utils.helpers import now |
---|
41 | from waeup.ikoba.interfaces import IIkobaUtils |
---|
42 | from waeup.ikoba.interfaces import MessageFactory as _ |
---|
43 | |
---|
44 | |
---|
45 | #: A reportlab paragraph style for 'normal' output. |
---|
46 | NORMAL_STYLE = getSampleStyleSheet()['Normal'] |
---|
47 | |
---|
48 | #: A reportlab paragraph style for 'normal' output. |
---|
49 | HEADING3_STYLE = getSampleStyleSheet()['Heading3'] |
---|
50 | |
---|
51 | #: A reportlab paragraph style for headings. |
---|
52 | HEADING_STYLE = ParagraphStyle( |
---|
53 | name='Heading3', |
---|
54 | parent=HEADING3_STYLE, |
---|
55 | fontSize=11, |
---|
56 | ) |
---|
57 | |
---|
58 | #: A reportlab paragraph style for output of 'code'. |
---|
59 | CODE_STYLE = ParagraphStyle( |
---|
60 | name='Code', |
---|
61 | parent=NORMAL_STYLE, |
---|
62 | fontName='Courier', |
---|
63 | fontSize=10, |
---|
64 | leading=10, |
---|
65 | ) |
---|
66 | |
---|
67 | #: A reportlab paragraph style for regular form output. |
---|
68 | ENTRY1_STYLE = ParagraphStyle( |
---|
69 | name='Entry1', |
---|
70 | parent=NORMAL_STYLE, |
---|
71 | fontSize=11, |
---|
72 | leading=10, |
---|
73 | ) |
---|
74 | |
---|
75 | #: A reportlab paragraph style for smaller form output. |
---|
76 | SMALL_PARA_STYLE = ParagraphStyle( |
---|
77 | name='Small1', |
---|
78 | parent=NORMAL_STYLE, |
---|
79 | fontSize=8, |
---|
80 | ) |
---|
81 | |
---|
82 | #: A reportlab paragraph style for headlines or bold text in form output. |
---|
83 | HEADLINE1_STYLE = ParagraphStyle( |
---|
84 | name='Header1', |
---|
85 | parent=NORMAL_STYLE, |
---|
86 | fontName='Helvetica-Bold', |
---|
87 | fontSize=10, |
---|
88 | ) |
---|
89 | |
---|
90 | #: A reportlab paragraph style for notes output at end of documents. |
---|
91 | NOTE_STYLE = ParagraphStyle( |
---|
92 | name='Note', |
---|
93 | parent=NORMAL_STYLE, |
---|
94 | fontName='Helvetica', |
---|
95 | fontSize=10, |
---|
96 | #leading=9, |
---|
97 | ) |
---|
98 | |
---|
99 | #: Base style for signature tables |
---|
100 | SIGNATURE_TABLE_STYLE = [ |
---|
101 | ('VALIGN',(0,-1),(-1,-1),'TOP'), |
---|
102 | #('FONT', (0,0), (-1,-1), 'Helvetica-BoldOblique', 12), |
---|
103 | ('BOTTOMPADDING', (0,0), (-1,0), 36), |
---|
104 | ('TOPPADDING', (0,-1), (-1,-1), 0), |
---|
105 | ] |
---|
106 | |
---|
107 | |
---|
108 | def format_html(html): |
---|
109 | """Make HTML code usable for use in reportlab paragraphs. |
---|
110 | |
---|
111 | Main things fixed here: |
---|
112 | If html code: |
---|
113 | - remove newlines (not visible in HTML but visible in PDF) |
---|
114 | - add <br> tags after <div> (as divs break lines in HTML but not in PDF) |
---|
115 | If not html code: |
---|
116 | - just replace newlines by <br> tags |
---|
117 | """ |
---|
118 | if '</' in html: |
---|
119 | # Add br tag if widgets contain div tags |
---|
120 | # which are not supported by reportlab |
---|
121 | html = html.replace('</div>', '</div><br />') |
---|
122 | html = html.replace('\n', '') |
---|
123 | else: |
---|
124 | html = html.replace('\n', '<br />') |
---|
125 | return html |
---|
126 | |
---|
127 | def normalize_signature(signature_tuple): |
---|
128 | """Normalize a signature tuple. |
---|
129 | |
---|
130 | Returns a tuple ``(<PRE-TEXT>, <SIGNATURE>, <POST-TEXT>)`` from |
---|
131 | input tuple. The following rules apply:: |
---|
132 | |
---|
133 | (pre, sig, post) --> (pre, sig, post) |
---|
134 | (pre, sig) --> (pre, sig, None) |
---|
135 | (sig) --> (None, sig, None) |
---|
136 | |
---|
137 | Also simple strings are accepted as input:: |
---|
138 | |
---|
139 | sig --> (None, sig, None) |
---|
140 | |
---|
141 | If input is not a tuple nor a basestring or if the tuple contains |
---|
142 | an invalid number of elements, ``ValueError`` is raised. |
---|
143 | """ |
---|
144 | if isinstance(signature_tuple, basestring): |
---|
145 | return (None, signature_tuple, None) |
---|
146 | if not isinstance(signature_tuple, tuple): |
---|
147 | raise ValueError("signature_tuple must be a string or tuple") |
---|
148 | if len(signature_tuple) < 1 or len(signature_tuple) > 3: |
---|
149 | raise ValueError("signature_tuple must have 1, 2, or 3 elements") |
---|
150 | elif len(signature_tuple) == 1: |
---|
151 | signature_tuple = (None, signature_tuple[0], None) |
---|
152 | elif len(signature_tuple) == 2: |
---|
153 | signature_tuple = (signature_tuple[0], signature_tuple[1], None) |
---|
154 | return signature_tuple |
---|
155 | |
---|
156 | def vert_signature_cell(signature, date_field=True, date_text=_('Date:'), |
---|
157 | start_row=0, start_col=0, underline=True): |
---|
158 | """Generate a table part containing a vertical signature cell. |
---|
159 | |
---|
160 | Returns the table data as list of lists and an according style. |
---|
161 | |
---|
162 | `signature`: |
---|
163 | a signature tuple containing (<PRE-TEXT, SIGNATURE-TEXT, POST-TEXT>) |
---|
164 | |
---|
165 | `date_field`: |
---|
166 | boolean indicating that a 'Date:' text should be rendered into this |
---|
167 | signature cell (or not). |
---|
168 | |
---|
169 | `date_text`: |
---|
170 | the text to be rendered into the signature field as 'Date:' text. |
---|
171 | |
---|
172 | `start_row`: |
---|
173 | starting row of the signature cell inside a broader table. |
---|
174 | |
---|
175 | `start_col`: |
---|
176 | starting column of the signature cell inside a broader table. |
---|
177 | |
---|
178 | `underline`: |
---|
179 | boolean indicating that the signature cell should provide a line on |
---|
180 | top (`True` by default). |
---|
181 | |
---|
182 | Vertical signature cells look like this:: |
---|
183 | |
---|
184 | +------------+ |
---|
185 | |Pre | |
---|
186 | +------------+ |
---|
187 | |Date: | |
---|
188 | | | |
---|
189 | +------------+ |
---|
190 | | ---------- | |
---|
191 | | Signature | |
---|
192 | +------------+ |
---|
193 | |Post | |
---|
194 | +------------+ |
---|
195 | """ |
---|
196 | # split signature parts, replacing None with empty string |
---|
197 | pre, sig, post = [x or '' for x in signature] |
---|
198 | style = () |
---|
199 | x, y = start_col, start_row+2 |
---|
200 | if underline: |
---|
201 | style += (('LINEABOVE', (x, y), (x, y), 1, colors.black),) |
---|
202 | d_text = date_field and date_text or '' |
---|
203 | data = [[pre], [d_text], [sig], [post]] |
---|
204 | col_widths = [1.0] |
---|
205 | return data, style, col_widths |
---|
206 | |
---|
207 | def horiz_signature_cell(signature, date_field=True, date_text=_('Date'), |
---|
208 | start_row=0, start_col=0): |
---|
209 | """Generate a table part containing an horizontal signature cell |
---|
210 | |
---|
211 | Returns the table data as list of lists and an according style. |
---|
212 | |
---|
213 | `signature`: |
---|
214 | a signature tuple containing (<PRE-TEXT, SIGNATURE-TEXT, POST-TEXT>) |
---|
215 | |
---|
216 | `date_field`: |
---|
217 | boolean indicating that a 'Date:' text should be rendered into this |
---|
218 | signature cell (or not). |
---|
219 | |
---|
220 | `date_text`: |
---|
221 | the text to be rendered into the signature field as 'Date:' text. |
---|
222 | |
---|
223 | `start_row`: |
---|
224 | starting row of the signature cell inside a broader table. |
---|
225 | |
---|
226 | `start_col`: |
---|
227 | starting column of the signature cell inside a broader table. |
---|
228 | |
---|
229 | Horizontal signature cells look like this:: |
---|
230 | |
---|
231 | +------------+---+-----------+ |
---|
232 | |Pre text possibly filling | |
---|
233 | |the whole box | |
---|
234 | +------------+---+-----------+ |
---|
235 | | | | | |
---|
236 | | | | | |
---|
237 | +------------+---+-----------+ |
---|
238 | | ---------- | | --------- | |
---|
239 | | Date | | Signature | |
---|
240 | +------------+---+-----------+ |
---|
241 | |Post | |
---|
242 | +------------+---+-----------+ |
---|
243 | |
---|
244 | """ |
---|
245 | pre, sig, post = signature |
---|
246 | if not date_field: |
---|
247 | data, style, cols = vert_signature_cell(signature, date_field=False) |
---|
248 | return data, style, cols |
---|
249 | style = ( |
---|
250 | # let pre and post text span the whole signature cell |
---|
251 | ('SPAN', (start_col, start_row), (start_col+2, start_row)), |
---|
252 | ('SPAN', (start_col, start_row+3), (start_col+2, start_row+3)), |
---|
253 | ) |
---|
254 | # horizontal cells are buildt from vertical ones chained together |
---|
255 | cell1 = vert_signature_cell( # leftmost date col |
---|
256 | (pre, date_text, post), date_field=False, |
---|
257 | start_row=start_row, start_col=start_col) |
---|
258 | cell2 = vert_signature_cell( # spacer col (between date and sig) |
---|
259 | ('', '', ''), date_field=False, underline=False, |
---|
260 | start_row=start_row, start_col=start_col+1) |
---|
261 | cell3 = vert_signature_cell( # rightmost signature column |
---|
262 | ('', sig, ''), date_field=False, |
---|
263 | start_row=start_row, start_col=start_col+2) |
---|
264 | data = map(lambda x, y, z: x+y+z, cell1[0], cell2[0], cell3[0]) |
---|
265 | style = style + cell1[1] + cell2[1] + cell3[1] |
---|
266 | col_widths = [0.3, 0.03, 0.67] # sums up to 1.0 |
---|
267 | return data, style, col_widths |
---|
268 | |
---|
269 | def signature_row(signatures, start_row=0, horizontal=None, max_per_row=3): |
---|
270 | data = [[], [], [], []] |
---|
271 | style = () |
---|
272 | signatures = [normalize_signature(sig) for sig in signatures] |
---|
273 | start_col = 0 |
---|
274 | col_widths = [] |
---|
275 | |
---|
276 | if horizontal is None: |
---|
277 | horizontal = len(signatures) == 1 |
---|
278 | cell_maker = vert_signature_cell |
---|
279 | if horizontal: |
---|
280 | cell_maker = horiz_signature_cell |
---|
281 | main_cell_height = not horizontal and 36 or 18 |
---|
282 | |
---|
283 | for sig in signatures: |
---|
284 | sig_data, sig_style, sig_cols = cell_maker( |
---|
285 | sig, start_row=start_row, start_col=start_col) |
---|
286 | data = map(lambda x, y: x+y, data, sig_data) |
---|
287 | style += sig_style |
---|
288 | col_widths += sig_cols + [None,] |
---|
289 | |
---|
290 | start_col += 1 |
---|
291 | # add spacer |
---|
292 | spacer, spacer_style, cols = vert_signature_cell( |
---|
293 | ('', '', ''), date_field=False, underline=False, |
---|
294 | start_row=start_row, start_col=start_col) |
---|
295 | data = map(lambda x, y: x+y, data, spacer) |
---|
296 | style += spacer_style |
---|
297 | start_col += 1 |
---|
298 | |
---|
299 | y = start_row |
---|
300 | sig_row = start_row + 2 |
---|
301 | style = style + ( |
---|
302 | ('TOPPADDING', (0, y+2), (-1, y+2), 0), # signature row |
---|
303 | ('BOTTOMPADDING', (0, y+1), (-1, y+1), main_cell_height), |
---|
304 | ('LEFTPADDING', (0, y), (-1, y), 1), # pre row |
---|
305 | ('LEFTPADDING', (0, y+3), (-1, y+3), 1), # post row |
---|
306 | ) |
---|
307 | |
---|
308 | if len(signatures) == 1: |
---|
309 | # pre and post text should span whole table |
---|
310 | style += (('SPAN', (0, y), (-1, y)), |
---|
311 | ('SPAN', (0, y+3), (-1, y+3)), |
---|
312 | ) |
---|
313 | |
---|
314 | if data[0] == [''] * len(data[0]): |
---|
315 | # no pre text: hide pre row by minimizing padding |
---|
316 | style += (('TOPPADDING', (0,y), (-1, y), -6), |
---|
317 | ('BOTTOMPADDING', (0,y), (-1, y), -6), |
---|
318 | ) |
---|
319 | if data[-1] == [''] * len(data[0]): |
---|
320 | # no post text: hide post row by minimizing padding |
---|
321 | style += (('TOPPADDING', (0,y+3), (-1, y+3), -6), |
---|
322 | ('BOTTOMPADDING', (0,y+3), (-1, y+3), -6), |
---|
323 | ) |
---|
324 | |
---|
325 | if len(signatures) > 1: |
---|
326 | data = [x[:-1] for x in data] # strip last spacer |
---|
327 | col_widths = col_widths[:-1] |
---|
328 | return data, style, col_widths |
---|
329 | |
---|
330 | def sig_table(signatures, lang='en', max_per_row=3, horizontal=None, |
---|
331 | single_table=False, start_row=0, landscape=False): |
---|
332 | if landscape: |
---|
333 | space_width = 2.4 # width in cm of space between signatures |
---|
334 | table_width = 24.0 # supposed width of signature table in cms |
---|
335 | else: |
---|
336 | space_width = 0.4 # width in cm of space between signatures |
---|
337 | table_width = 16.0 # supposed width of signature table in cms |
---|
338 | # width of signature cells in cm... |
---|
339 | sig_num = len(signatures) |
---|
340 | sig_col_width = (table_width - ((sig_num - 1) * space_width)) / sig_num |
---|
341 | if sig_num == 1: |
---|
342 | sig_col_width = 0.6 * table_width # signature cell part |
---|
343 | space_width = table_width - sig_col_width # spacer part on the right |
---|
344 | |
---|
345 | if sig_num > max_per_row: |
---|
346 | if horizontal is None: |
---|
347 | horizontal = max_per_row == 1 |
---|
348 | sigs_by_row = [signatures[x:x+max_per_row] for x in range( |
---|
349 | 0, sig_num, max_per_row)] |
---|
350 | result = [] |
---|
351 | curr_row = 0 |
---|
352 | for num, row_sigs in enumerate(sigs_by_row): |
---|
353 | curr_row = 0 |
---|
354 | if single_table: |
---|
355 | curr_row = num * 4 |
---|
356 | result.append( |
---|
357 | sig_table(row_sigs, lang=lang, max_per_row=max_per_row, |
---|
358 | horizontal=horizontal, start_row=curr_row, |
---|
359 | landscape=landscape)[0], |
---|
360 | ) |
---|
361 | missing_num = len(result[-2][0][0]) - len(result[-1][0][0]) |
---|
362 | if missing_num: |
---|
363 | # last row contained less cells, fix it... |
---|
364 | result[-1] = ([x + [''] * missing_num for x in result[-1][0]], |
---|
365 | result[-1][1], result[-2][2]) |
---|
366 | return result |
---|
367 | |
---|
368 | data, style, cols = signature_row(signatures, horizontal=horizontal, |
---|
369 | start_row=start_row) |
---|
370 | style += (('VALIGN', (0,0), (-1,-1), 'TOP'),) |
---|
371 | |
---|
372 | # compute col widths... |
---|
373 | col_widths = [] |
---|
374 | for col in cols: |
---|
375 | if col is not None: |
---|
376 | col = col * sig_col_width * cm |
---|
377 | else: |
---|
378 | col = space_width * cm |
---|
379 | col_widths.append(col) |
---|
380 | |
---|
381 | # replace strings by paragraphs and translate all contents |
---|
382 | for rnum, row in enumerate(data): |
---|
383 | for cnum, cell in enumerate(row): |
---|
384 | if cell: |
---|
385 | content = translate(cell, lang) |
---|
386 | data[rnum][cnum] = Paragraph(content, NORMAL_STYLE) |
---|
387 | return [(data, style, col_widths),] |
---|
388 | |
---|
389 | def get_sig_tables(signatures, lang='en', max_per_row=3, horizontal=None, |
---|
390 | single_table=False, landscape=False): |
---|
391 | rows = sig_table(signatures, lang=lang, max_per_row=max_per_row, |
---|
392 | horizontal=horizontal, single_table=single_table, |
---|
393 | landscape=landscape) |
---|
394 | if single_table: |
---|
395 | result_data = [] |
---|
396 | result_style = () |
---|
397 | for row in rows: |
---|
398 | data, style, col_widths = row |
---|
399 | result_data += data |
---|
400 | result_style += style |
---|
401 | return [(result_data, result_style, col_widths),] |
---|
402 | return rows |
---|
403 | |
---|
404 | def get_signature_tables(signatures, lang='en', max_per_row=3, |
---|
405 | horizontal=None, single_table=False, |
---|
406 | landscape=False): |
---|
407 | """Get a list of reportlab flowables representing signature fields. |
---|
408 | |
---|
409 | `signatures` is a list of signatures. Each signature can be a |
---|
410 | simple string or a tuple of format:: |
---|
411 | |
---|
412 | (<PRE-TEXT>, <SIGNATURE>, <POST-TEXT>) |
---|
413 | |
---|
414 | where ``<PRE-TEXT>`` and ``<POST-TEXT>`` are texts that should |
---|
415 | appear on top (PRE) or below (POST) the signature cell. Both |
---|
416 | formats, string and tuple, can be mixed. A single signature would |
---|
417 | be given as ``[('Pre-Text', 'Signature', 'Post-Text'),]`` or |
---|
418 | simply as ``['Signature']`` if not pre or post-text is wanted. |
---|
419 | |
---|
420 | All texts (pre, sig, post) are rendered as paragraphs, so you can |
---|
421 | pass in also longer texts with basic HTML formatting like ``<b>``, |
---|
422 | ``<i>``, ``<br />``, etc. |
---|
423 | |
---|
424 | ``lang`` sets the language to use in I18n context. All texts are |
---|
425 | translated to the given language (``en`` by default) if a |
---|
426 | translation is available. |
---|
427 | |
---|
428 | ``max_per_row`` gives the maximum number of signatures to put into |
---|
429 | a single row. The default is 3. If more signatures are passed in, |
---|
430 | these signatures are put into a new row. So, for example by |
---|
431 | default 8 signatures would be spread over 3 rows. |
---|
432 | |
---|
433 | ``horizontal`` tells how the single signature cells should be |
---|
434 | rendered: horizontal or vertical. While horizontal cells render |
---|
435 | date and signature fields side by side, in vertical cells date is |
---|
436 | rendered on top of the signature. |
---|
437 | |
---|
438 | This parameter accepts *three* different values: ``True``, |
---|
439 | ``False``, or ``None``. While with ``True`` each cell is rendered |
---|
440 | in horizontal mode, ``False`` will create only vertical cells. |
---|
441 | |
---|
442 | The ``None`` value (set by default) is different: if set, the mode |
---|
443 | will be dependent on the number of signatures per row. If a row |
---|
444 | contains exactly one signature (because only one sig was passed |
---|
445 | in, or because ``max_per_row`` was set to ``1``), then this |
---|
446 | signature is rendered in horizontal mode. Otherwise (with more |
---|
447 | than one sig per row) each cell is rendered in vertical mode. This |
---|
448 | pseudo-smart behaviour can be switched off by setting |
---|
449 | ``horizontal`` explicitly to ``True`` or ``False``. |
---|
450 | |
---|
451 | ``single_table`` is a boolean defaulting to ``False``. By default |
---|
452 | we return the rows of a signature table in several tables, one of |
---|
453 | each row. This makes it easier for reportlab to perform pagebreaks |
---|
454 | in case the page is already full, without wasting space. If the |
---|
455 | parameter is set to ``True``, then always a list with exactly one |
---|
456 | table is returned, which will contain all rows in one table. |
---|
457 | |
---|
458 | Generally, if a row contains only one signature, only a part of |
---|
459 | the page width is used to render this signature. If two or more |
---|
460 | signatures are passed in, the complete page width will be filled |
---|
461 | and the single signature cells will be shrinked to fit. |
---|
462 | """ |
---|
463 | data_list = get_sig_tables( |
---|
464 | signatures, lang=lang, max_per_row=max_per_row, |
---|
465 | horizontal=horizontal, single_table=single_table, landscape=landscape) |
---|
466 | return [Table(row_data, style=row_style, colWidths=row_col_widths, |
---|
467 | repeatRows=4) |
---|
468 | for row_data, row_style, row_col_widths in data_list] |
---|
469 | |
---|
470 | def format_signatures(signatures, max_per_row=3, lang='en', |
---|
471 | single_table=False, |
---|
472 | date_field=True, date_text=_('Date'), |
---|
473 | base_style=SIGNATURE_TABLE_STYLE): |
---|
474 | result = [] |
---|
475 | signature_tuples = [normalize_signature(sig) for sig in signatures] |
---|
476 | for pre, sig, post in signature_tuples: |
---|
477 | row = [] |
---|
478 | if pre is not None: |
---|
479 | row.append([ |
---|
480 | translate(pre, lang), '', '', '']) |
---|
481 | row.append([ |
---|
482 | translate(_('Date'), lang), '', |
---|
483 | translate(sig, lang), '' |
---|
484 | ]) |
---|
485 | if post is not None: |
---|
486 | row.append([ |
---|
487 | translate(post, lang), '', '', '']) |
---|
488 | result.append((row, base_style)) |
---|
489 | return result |
---|
490 | |
---|
491 | |
---|
492 | |
---|
493 | class NumberedCanvas(Canvas): |
---|
494 | """A reportlab canvas for numbering pages after all docs are processed. |
---|
495 | |
---|
496 | Taken from |
---|
497 | http://code.activestate.com/recipes/546511-page-x-of-y-with-reportlab/ |
---|
498 | http://code.activestate.com/recipes/576832/ |
---|
499 | """ |
---|
500 | |
---|
501 | def __init__(self, *args, **kw): |
---|
502 | Canvas.__init__(self, *args, **kw) |
---|
503 | self._saved_page_states = [] |
---|
504 | return |
---|
505 | |
---|
506 | def showPage(self): |
---|
507 | self._saved_page_states.append(dict(self.__dict__)) |
---|
508 | self._startPage() |
---|
509 | return |
---|
510 | |
---|
511 | def save(self): |
---|
512 | """add page info to each page (page x of y)""" |
---|
513 | num_pages = len(self._saved_page_states) |
---|
514 | for state in self._saved_page_states: |
---|
515 | self.__dict__.update(state) |
---|
516 | self.draw_page_number(num_pages) |
---|
517 | Canvas.showPage(self) |
---|
518 | Canvas.save(self) |
---|
519 | return |
---|
520 | |
---|
521 | def draw_page_number(self, page_count): |
---|
522 | """draw string at bottom right with 'page x of y'. |
---|
523 | |
---|
524 | Location of the string is determined by canvas attributes |
---|
525 | `ikoba_footer_x_pos` and `ikoba_footer_y_pos` that have to be |
---|
526 | set manually. |
---|
527 | |
---|
528 | If this canvas also provides an attribute `ikoba_footer_text`, |
---|
529 | the contained text is rendered left of the ``page x of y`` |
---|
530 | string. |
---|
531 | """ |
---|
532 | self.setFont("Helvetica", 9) |
---|
533 | right_footer_text = _( |
---|
534 | '${footer_text} Page ${num1} of ${num2}', |
---|
535 | mapping = {'footer_text': self.ikoba_footer_text, |
---|
536 | 'num1':self._pageNumber, 'num2':page_count}) |
---|
537 | self.drawRightString( |
---|
538 | self.ikoba_footer_x_pos, self.ikoba_footer_y_pos, |
---|
539 | translate(right_footer_text)) |
---|
540 | return |
---|
541 | |
---|
542 | class PDFCreator(grok.GlobalUtility): |
---|
543 | """A utility to help with generating PDF docs. |
---|
544 | """ |
---|
545 | grok.implements(IPDFCreator) |
---|
546 | |
---|
547 | watermark_path = None |
---|
548 | header_logo_path = None |
---|
549 | header_logo_left_path = None |
---|
550 | watermark_pos = [0, 0] |
---|
551 | logo_pos = [0, 0, 0] |
---|
552 | logo_left_pos = [0, 0, 0] |
---|
553 | pagesize = portrait(A4) |
---|
554 | |
---|
555 | @classmethod |
---|
556 | def _setUpWidgets(cls, form_fields, context): |
---|
557 | """Setup simple display widgets. |
---|
558 | |
---|
559 | Returns the list of widgets. |
---|
560 | """ |
---|
561 | request = TestRequest() |
---|
562 | return setUpEditWidgets( |
---|
563 | form_fields, 'form', context, request, {}, |
---|
564 | for_display=True, ignore_request=True |
---|
565 | ) |
---|
566 | |
---|
567 | @classmethod |
---|
568 | def _drawSignatureBoxes(cls, canvas, width, height, signatures=[]): |
---|
569 | """Draw signature boxes into canvas. |
---|
570 | """ |
---|
571 | canvas.saveState() |
---|
572 | canvas.setFont("Helvetica", 10) |
---|
573 | mystring = "%r" % ([translate(sig) for sig in signatures]) |
---|
574 | for num, sig in enumerate(signatures): |
---|
575 | box_width = (width - 4.2*cm) / len(signatures) |
---|
576 | x_box = 2.1*cm + (box_width) * num |
---|
577 | y_box = 0.75*inch |
---|
578 | canvas.rect(x_box+0.1*cm, y_box, box_width-0.2*cm, 0.75*inch) |
---|
579 | canvas.drawString( |
---|
580 | x_box+0.2*cm, 1.35*inch, translate(sig)) |
---|
581 | canvas.restoreState() |
---|
582 | return canvas |
---|
583 | |
---|
584 | @classmethod |
---|
585 | def fromStringList(cls, string_list): |
---|
586 | """Generate a list of reportlab paragraphs out of a list of strings. |
---|
587 | |
---|
588 | Strings are formatted with :data:`CODE_STYLE` and a spacer is |
---|
589 | appended at end. |
---|
590 | """ |
---|
591 | result = [] |
---|
592 | for msg in string_list: |
---|
593 | result.append(Paragraph(msg, CODE_STYLE)) |
---|
594 | return result |
---|
595 | |
---|
596 | @classmethod |
---|
597 | def getImage(cls, image_path, orientation='LEFT'): |
---|
598 | """Get an image located at `image_path` as reportlab flowable. |
---|
599 | """ |
---|
600 | img = Image(image_path, width=4*cm, height=3*cm, kind='bound') |
---|
601 | img.hAlign=orientation |
---|
602 | return img |
---|
603 | |
---|
604 | def _getWidgetsTableData(self, widgets, separators, domain, |
---|
605 | lang, twoDataCols): |
---|
606 | row_num = 0 |
---|
607 | table_data = [] |
---|
608 | for widget in widgets: |
---|
609 | if separators and separators.get(widget.name): |
---|
610 | f_headline = translate( |
---|
611 | separators[widget.name], domain, target_language=lang) |
---|
612 | f_headline = Paragraph('<br />' + f_headline, HEADLINE1_STYLE) |
---|
613 | table_data.append([f_headline]) |
---|
614 | row_num += 1 |
---|
615 | f_label = translate(widget.label.strip(), domain, |
---|
616 | target_language=lang) |
---|
617 | f_label = Paragraph('%s:' % f_label, ENTRY1_STYLE) |
---|
618 | f_text = translate(widget(), domain, target_language=lang) |
---|
619 | f_text = format_html(f_text) |
---|
620 | #if f_text: |
---|
621 | # hint = ' <font size=9>' + widget.hint + '</font>' |
---|
622 | # f_text = f_text + hint |
---|
623 | f_text = Paragraph(f_text, ENTRY1_STYLE) |
---|
624 | table_data.append([f_label,f_text]) |
---|
625 | row_num += 1 |
---|
626 | return table_data, row_num |
---|
627 | |
---|
628 | def getWidgetsTable(self, form_fields, context, view, lang='en', |
---|
629 | domain='waeup.ikoba', separators=None, |
---|
630 | colWidths=None, twoDataCols=False): |
---|
631 | """Return a reportlab `Table` instance, created from widgets |
---|
632 | determined by `form_fields` and `context`. |
---|
633 | |
---|
634 | - `form_fields` |
---|
635 | is a list of schema fields as created by grok.AutoFields. |
---|
636 | - `context` |
---|
637 | is some object whose content is rendered here. |
---|
638 | - `view` |
---|
639 | is currently not used but supposed to be a view which is |
---|
640 | actually rendering a PDF document. |
---|
641 | - `lang` |
---|
642 | the portal language. Used for translations of strings. |
---|
643 | - `domain` |
---|
644 | the translation domain used for translations of strings. |
---|
645 | - `separators` |
---|
646 | a list of separators. |
---|
647 | - `colWidths` |
---|
648 | defines the the column widths of the data in the right column |
---|
649 | of base data (right to the passport image). |
---|
650 | - `twoDataCols` |
---|
651 | renders data widgets in a parent table with two columns. |
---|
652 | """ |
---|
653 | table_style = [('VALIGN', (0,0), (-1,-1), 'TOP'), |
---|
654 | ] |
---|
655 | widgets = self._setUpWidgets(form_fields, context) |
---|
656 | |
---|
657 | # Determine table data |
---|
658 | table_data, row_num = self._getWidgetsTableData( |
---|
659 | widgets, separators, domain, lang, twoDataCols) |
---|
660 | |
---|
661 | # render two-column table of tables if applicable |
---|
662 | lines = len(table_data) |
---|
663 | middle = lines/2 |
---|
664 | if twoDataCols is True and lines > 2: |
---|
665 | table_left = Table(table_data[:middle], |
---|
666 | style=table_style, colWidths=[3*cm, 6.3*cm]) |
---|
667 | table_right = Table(table_data[middle:], |
---|
668 | style=table_style, colWidths=[3*cm, 6.3*cm]) |
---|
669 | table_style.append(('LEFTPADDING', (0,0), (0,-1), 1.2*cm),) |
---|
670 | table = Table([[table_left, table_right],],style=table_style) |
---|
671 | return table |
---|
672 | |
---|
673 | # render single table |
---|
674 | table = Table( |
---|
675 | table_data,style=table_style, colWidths=colWidths) #, rowHeights=14) |
---|
676 | table.hAlign = 'LEFT' |
---|
677 | return table |
---|
678 | |
---|
679 | |
---|
680 | def paint_background(self, canvas, doc): |
---|
681 | """Paint background of a PDF, including watermark, title, etc. |
---|
682 | |
---|
683 | The `doc` is expected to be some reportlab SimpleDocTemplate |
---|
684 | or similar object. |
---|
685 | |
---|
686 | Text of headerline is extracted from doc.ikoba_headtitle, the |
---|
687 | document title (under the head) from doc.ikoba_title. |
---|
688 | |
---|
689 | This is a callback method that will be called from reportlab |
---|
690 | when creating PDFs with :meth:`create_pdf`. |
---|
691 | """ |
---|
692 | canvas.saveState() |
---|
693 | width, height = doc.width, doc.height |
---|
694 | width += doc.leftMargin + doc.rightMargin |
---|
695 | height += doc.topMargin + doc.bottomMargin |
---|
696 | |
---|
697 | # Watermark |
---|
698 | if self.watermark_path is not None: |
---|
699 | canvas.saveState() |
---|
700 | canvas.drawImage(self.watermark_path, |
---|
701 | self.watermark_pos[0], self.watermark_pos[1]) |
---|
702 | canvas.restoreState() |
---|
703 | |
---|
704 | # Header |
---|
705 | site_config = None |
---|
706 | site = grok.getSite() |
---|
707 | if site is not None: |
---|
708 | site_config = site.get('configuration', None) |
---|
709 | head_title = getattr( |
---|
710 | doc, 'ikoba_headtitle', getattr( |
---|
711 | site_config, 'name', |
---|
712 | u'Sample Company')) |
---|
713 | canvas.setFont("Helvetica-Bold", 18) |
---|
714 | if self.header_logo_left_path is not None: |
---|
715 | canvas.drawCentredString(width/2.0, height-1.7*cm, head_title) |
---|
716 | else: |
---|
717 | canvas.drawString(1.5*cm, height-1.7*cm, head_title) |
---|
718 | canvas.line(1.5*cm,height-1.9*cm,width-1.5*cm,height-1.9*cm) |
---|
719 | if self.header_logo_path is not None: |
---|
720 | canvas.drawImage(self.header_logo_path, |
---|
721 | self.logo_pos[0], self.logo_pos[1], width=self.logo_pos[2], |
---|
722 | preserveAspectRatio=True, anchor='ne') |
---|
723 | if self.header_logo_left_path is not None: |
---|
724 | canvas.drawImage(self.header_logo_left_path, |
---|
725 | self.logo_left_pos[0], self.logo_left_pos[1], |
---|
726 | width=self.logo_left_pos[2], |
---|
727 | preserveAspectRatio=True, anchor='ne') |
---|
728 | |
---|
729 | # Title |
---|
730 | canvas.saveState() |
---|
731 | canvas.setFont("Helvetica-Bold", 14) |
---|
732 | title = getattr(doc, 'ikoba_title', '') |
---|
733 | if '\n' in title: |
---|
734 | title_lines = title.split('\n') |
---|
735 | for num, line in enumerate(title_lines): |
---|
736 | canvas.drawCentredString( |
---|
737 | width/2.0, height-2.8*cm-(num*0.7*cm), line) |
---|
738 | elif title: |
---|
739 | canvas.drawCentredString(width/2.0, height-2.8*cm, title) |
---|
740 | canvas.restoreState() |
---|
741 | |
---|
742 | # Footer |
---|
743 | canvas.saveState() |
---|
744 | if getattr(doc, 'sigs_in_footer', False): |
---|
745 | self._drawSignatureBoxes( |
---|
746 | canvas, width, height, doc.sigs_in_footer) |
---|
747 | canvas.line(2.2*cm, 0.62*inch, width-2.2*cm, 0.62*inch) |
---|
748 | canvas.setFont("Helvetica", 9) |
---|
749 | if not getattr(doc, 'ikoba_nodate', False): |
---|
750 | tz = getattr(queryUtility(IIkobaUtils), 'tzinfo', pytz.utc) |
---|
751 | #tz = getUtility(IIkobaUtils).tzinfo |
---|
752 | today = now(tz).strftime('%d/%m/%Y %H:%M:%S %Z') |
---|
753 | canvas.drawString(2.2*cm, 0.5 * inch, |
---|
754 | translate(_(u'Date: ${a}', mapping = {'a': today}))) |
---|
755 | # set canves attributes needed to render `page x of y` |
---|
756 | canvas.ikoba_footer_x_pos = width-2.2*cm |
---|
757 | canvas.ikoba_footer_y_pos = 0.5 * inch |
---|
758 | canvas.ikoba_footer_text = doc.ikoba_footer |
---|
759 | canvas.restoreState() |
---|
760 | canvas.restoreState() |
---|
761 | |
---|
762 | # Metadata |
---|
763 | canvas.setAuthor(getattr(doc, 'ikoba_author', 'Unknown')) |
---|
764 | canvas.setSubject(title) |
---|
765 | canvas.setCreator(u'WAeUP Ikoba') |
---|
766 | return |
---|
767 | |
---|
768 | def create_pdf(self, data, headerline=None, title=None, author=None, |
---|
769 | footer='', note=None, sigs_in_footer=[], topMargin=1.5): |
---|
770 | """Returns a binary data stream which is a PDF document. |
---|
771 | """ |
---|
772 | pdf_stream = StringIO() |
---|
773 | bottomMargin = len(sigs_in_footer) and 1.9*inch or 1.2*inch |
---|
774 | doc = SimpleDocTemplate( |
---|
775 | pdf_stream, |
---|
776 | bottomMargin=bottomMargin, |
---|
777 | topMargin=topMargin*inch, |
---|
778 | title=title, |
---|
779 | pagesize=self.pagesize, |
---|
780 | showBoundary=False, |
---|
781 | ) |
---|
782 | # Set some attributes that are needed when rendering the background. |
---|
783 | if headerline is not None: |
---|
784 | doc.ikoba_headtitle = headerline |
---|
785 | doc.ikoba_title = title |
---|
786 | doc.ikoba_author = author |
---|
787 | doc.ikoba_footer = footer |
---|
788 | doc.sigs_in_footer = sigs_in_footer |
---|
789 | if note is not None: |
---|
790 | html = format_html(note) |
---|
791 | data.append(Paragraph(html, NOTE_STYLE)) |
---|
792 | doc.build(data, onFirstPage=self.paint_background, |
---|
793 | onLaterPages=self.paint_background, |
---|
794 | canvasmaker=NumberedCanvas |
---|
795 | ) |
---|
796 | result = pdf_stream.getvalue() |
---|
797 | pdf_stream.close() |
---|
798 | return result |
---|
799 | |
---|
800 | class LandscapePDFCreator(PDFCreator): |
---|
801 | """A utility to help with generating PDF docs in |
---|
802 | landscape format. |
---|
803 | """ |
---|
804 | grok.name('landscape') |
---|
805 | pagesize = landscape(A4) |
---|
806 | |
---|
807 | def get_qrcode(text, width=60.0): |
---|
808 | """Get a QR Code as Reportlab Flowable (actually a `Drawing`). |
---|
809 | |
---|
810 | `width` gives box width in pixels (I think) |
---|
811 | """ |
---|
812 | widget = QrCodeWidget(text) |
---|
813 | bounds = widget.getBounds() |
---|
814 | w_width = bounds[2] - bounds[0] |
---|
815 | w_height = bounds[3] - bounds[1] |
---|
816 | drawing = Drawing( |
---|
817 | width, width, |
---|
818 | transform=[width/w_width, 0, 0, width/w_height, 0, 0]) |
---|
819 | drawing.add(widget) |
---|
820 | return drawing |
---|