[8059] | 1 | ## $Id: pdf.py 12492 2015-01-19 08:15:05Z 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 |
---|
[9963] | 23 | import pytz |
---|
[8059] | 24 | from cStringIO import StringIO |
---|
| 25 | from datetime import datetime |
---|
[10595] | 26 | from reportlab.graphics.barcode.qr import QrCodeWidget |
---|
| 27 | from reportlab.graphics.shapes import Drawing |
---|
[9963] | 28 | from reportlab.lib import colors |
---|
[8113] | 29 | from reportlab.lib.units import cm, inch, mm |
---|
[8059] | 30 | from reportlab.lib.pagesizes import A4, landscape, portrait |
---|
[8091] | 31 | from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle |
---|
[8113] | 32 | from reportlab.pdfgen.canvas import Canvas |
---|
[8091] | 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 |
---|
[9963] | 38 | from zope.component import getUtility, queryUtility |
---|
[11949] | 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 _ |
---|
[8059] | 43 | |
---|
[10595] | 44 | |
---|
[8091] | 45 | #: A reportlab paragraph style for 'normal' output. |
---|
| 46 | NORMAL_STYLE = getSampleStyleSheet()['Normal'] |
---|
| 47 | |
---|
[9910] | 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 | |
---|
[8091] | 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, |
---|
[9910] | 64 | leading=10, |
---|
[8091] | 65 | ) |
---|
| 66 | |
---|
| 67 | #: A reportlab paragraph style for regular form output. |
---|
| 68 | ENTRY1_STYLE = ParagraphStyle( |
---|
| 69 | name='Entry1', |
---|
| 70 | parent=NORMAL_STYLE, |
---|
[9910] | 71 | fontSize=11, |
---|
| 72 | leading=10, |
---|
[8091] | 73 | ) |
---|
| 74 | |
---|
[8102] | 75 | #: A reportlab paragraph style for smaller form output. |
---|
| 76 | SMALL_PARA_STYLE = ParagraphStyle( |
---|
| 77 | name='Small1', |
---|
| 78 | parent=NORMAL_STYLE, |
---|
[9910] | 79 | fontSize=8, |
---|
[8102] | 80 | ) |
---|
| 81 | |
---|
[8091] | 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', |
---|
[9910] | 87 | fontSize=10, |
---|
[8091] | 88 | ) |
---|
| 89 | |
---|
[8257] | 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', |
---|
[9910] | 95 | fontSize=10, |
---|
[10333] | 96 | leading=9, |
---|
[8257] | 97 | ) |
---|
| 98 | |
---|
[9963] | 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 | |
---|
[8091] | 108 | def format_html(html): |
---|
| 109 | """Make HTML code usable for use in reportlab paragraphs. |
---|
| 110 | |
---|
| 111 | Main things fixed here: |
---|
[9052] | 112 | If html code: |
---|
[8091] | 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) |
---|
[9052] | 115 | If not html code: |
---|
| 116 | - just replace newlines by <br> tags |
---|
[8091] | 117 | """ |
---|
[9052] | 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 />') |
---|
[8091] | 125 | return html |
---|
| 126 | |
---|
[9963] | 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, |
---|
[10571] | 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 |
---|
[9963] | 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: |
---|
[9976] | 346 | if horizontal is None: |
---|
| 347 | horizontal = max_per_row == 1 |
---|
[9963] | 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, |
---|
[10571] | 358 | horizontal=horizontal, start_row=curr_row, |
---|
| 359 | landscape=landscape)[0], |
---|
| 360 | ) |
---|
[9963] | 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, |
---|
[10571] | 390 | single_table=False, landscape=False): |
---|
[9963] | 391 | rows = sig_table(signatures, lang=lang, max_per_row=max_per_row, |
---|
[10571] | 392 | horizontal=horizontal, single_table=single_table, |
---|
| 393 | landscape=landscape) |
---|
[9963] | 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, |
---|
[10571] | 405 | horizontal=None, single_table=False, |
---|
| 406 | landscape=False): |
---|
[9963] | 407 | """Get a list of reportlab flowables representing signature fields. |
---|
[9972] | 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. |
---|
[9963] | 462 | """ |
---|
| 463 | data_list = get_sig_tables( |
---|
| 464 | signatures, lang=lang, max_per_row=max_per_row, |
---|
[10571] | 465 | horizontal=horizontal, single_table=single_table, landscape=landscape) |
---|
[9963] | 466 | return [Table(row_data, style=row_style, colWidths=row_col_widths, |
---|
[9972] | 467 | repeatRows=4) |
---|
[9963] | 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 | |
---|
[8113] | 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 |
---|
[11949] | 525 | `ikoba_footer_x_pos` and `ikoba_footer_y_pos` that have to be |
---|
[8113] | 526 | set manually. |
---|
| 527 | |
---|
[11949] | 528 | If this canvas also provides an attribute `ikoba_footer_text`, |
---|
[8113] | 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}', |
---|
[11949] | 535 | mapping = {'footer_text': self.ikoba_footer_text, |
---|
[8113] | 536 | 'num1':self._pageNumber, 'num2':page_count}) |
---|
| 537 | self.drawRightString( |
---|
[11949] | 538 | self.ikoba_footer_x_pos, self.ikoba_footer_y_pos, |
---|
[8113] | 539 | translate(right_footer_text)) |
---|
| 540 | return |
---|
| 541 | |
---|
[8059] | 542 | class PDFCreator(grok.GlobalUtility): |
---|
[8091] | 543 | """A utility to help with generating PDF docs. |
---|
| 544 | """ |
---|
[8059] | 545 | grok.implements(IPDFCreator) |
---|
| 546 | |
---|
[8092] | 547 | watermark_path = None |
---|
| 548 | header_logo_path = None |
---|
[9980] | 549 | header_logo_left_path = None |
---|
[8092] | 550 | watermark_pos = [0, 0] |
---|
| 551 | logo_pos = [0, 0, 0] |
---|
[9980] | 552 | logo_left_pos = [0, 0, 0] |
---|
[10565] | 553 | pagesize = portrait(A4) |
---|
[8059] | 554 | |
---|
[8091] | 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 |
---|
[9547] | 568 | def _drawSignatureBoxes(cls, canvas, width, height, signatures=[]): |
---|
| 569 | """Draw signature boxes into canvas. |
---|
| 570 | """ |
---|
| 571 | canvas.saveState() |
---|
| 572 | canvas.setFont("Helvetica", 10) |
---|
[9555] | 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)) |
---|
[9547] | 581 | canvas.restoreState() |
---|
| 582 | return canvas |
---|
| 583 | |
---|
| 584 | @classmethod |
---|
[8091] | 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 | |
---|
[10319] | 604 | def _getWidgetsTableData(self, widgets, separators, domain, |
---|
[10328] | 605 | lang, twoDataCols): |
---|
[10318] | 606 | row_num = 0 |
---|
| 607 | table_data = [] |
---|
| 608 | for widget in widgets: |
---|
[10328] | 609 | if separators and separators.get(widget.name): |
---|
[10318] | 610 | f_headline = translate( |
---|
| 611 | separators[widget.name], domain, target_language=lang) |
---|
| 612 | f_headline = Paragraph(f_headline, HEADLINE1_STYLE) |
---|
[10319] | 613 | table_data.append([f_headline]) |
---|
[10318] | 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) |
---|
[12492] | 620 | #if f_text: |
---|
| 621 | # hint = ' <font size=9>' + widget.hint + '</font>' |
---|
| 622 | # f_text = f_text + hint |
---|
[10318] | 623 | f_text = Paragraph(f_text, ENTRY1_STYLE) |
---|
| 624 | table_data.append([f_label,f_text]) |
---|
| 625 | row_num += 1 |
---|
[10328] | 626 | return table_data, row_num |
---|
[10318] | 627 | |
---|
[8091] | 628 | def getWidgetsTable(self, form_fields, context, view, lang='en', |
---|
[11949] | 629 | domain='waeup.ikoba', separators=None, |
---|
[11947] | 630 | colWidths=None, twoDataCols=False): |
---|
[8091] | 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. |
---|
[10319] | 647 | - `colWidths` |
---|
| 648 | defines the the column widths of the data in the right column |
---|
| 649 | of base data (right to the passport image). |
---|
[10318] | 650 | - `twoDataCols` |
---|
[10319] | 651 | renders data widgets in a parent table with two columns. |
---|
[8091] | 652 | """ |
---|
[10319] | 653 | table_style = [('VALIGN', (0,0), (-1,-1), 'TOP'), |
---|
[8091] | 654 | ] |
---|
| 655 | widgets = self._setUpWidgets(form_fields, context) |
---|
| 656 | |
---|
[10318] | 657 | # Determine table data |
---|
[10328] | 658 | table_data, row_num = self._getWidgetsTableData( |
---|
| 659 | widgets, separators, domain, lang, twoDataCols) |
---|
[10318] | 660 | |
---|
[10328] | 661 | # render two-column table of tables if applicable |
---|
[10319] | 662 | lines = len(table_data) |
---|
| 663 | middle = lines/2 |
---|
| 664 | if twoDataCols is True and lines > 2: |
---|
[10328] | 665 | table_left = Table(table_data[:middle], |
---|
[10333] | 666 | style=table_style, colWidths=[3*cm, 6.3*cm]) |
---|
[10328] | 667 | table_right = Table(table_data[middle:], |
---|
[10333] | 668 | style=table_style, colWidths=[3*cm, 6.3*cm]) |
---|
| 669 | table_style.append(('LEFTPADDING', (0,0), (0,-1), 1.2*cm),) |
---|
[10319] | 670 | table = Table([[table_left, table_right],],style=table_style) |
---|
| 671 | return table |
---|
| 672 | |
---|
| 673 | # render single table |
---|
[10310] | 674 | table = Table( |
---|
| 675 | table_data,style=table_style, colWidths=colWidths) #, rowHeights=14) |
---|
[8091] | 676 | table.hAlign = 'LEFT' |
---|
| 677 | return table |
---|
| 678 | |
---|
| 679 | |
---|
[8059] | 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 | |
---|
[11949] | 686 | Text of headerline is extracted from doc.ikoba_headtitle, the |
---|
| 687 | document title (under the head) from doc.ikoba_title. |
---|
[8059] | 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() |
---|
[8092] | 700 | canvas.drawImage(self.watermark_path, |
---|
| 701 | self.watermark_pos[0], self.watermark_pos[1]) |
---|
[8059] | 702 | canvas.restoreState() |
---|
| 703 | |
---|
| 704 | # Header |
---|
[9963] | 705 | site_config = None |
---|
| 706 | site = grok.getSite() |
---|
| 707 | if site is not None: |
---|
| 708 | site_config = site.get('configuration', None) |
---|
[8059] | 709 | head_title = getattr( |
---|
[11949] | 710 | doc, 'ikoba_headtitle', getattr( |
---|
[9963] | 711 | site_config, 'name', |
---|
[11954] | 712 | u'Sample Company')) |
---|
[8059] | 713 | canvas.setFont("Helvetica-Bold", 18) |
---|
[9980] | 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) |
---|
[8159] | 718 | canvas.line(1.5*cm,height-1.9*cm,width-1.5*cm,height-1.9*cm) |
---|
[8059] | 719 | if self.header_logo_path is not None: |
---|
[8092] | 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') |
---|
[9980] | 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], |
---|
[9975] | 727 | preserveAspectRatio=True, anchor='ne') |
---|
[8059] | 728 | |
---|
| 729 | # Title |
---|
| 730 | canvas.saveState() |
---|
| 731 | canvas.setFont("Helvetica-Bold", 14) |
---|
[11949] | 732 | title = getattr(doc, 'ikoba_title', '') |
---|
[8059] | 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() |
---|
[9555] | 744 | if getattr(doc, 'sigs_in_footer', False): |
---|
| 745 | self._drawSignatureBoxes( |
---|
| 746 | canvas, width, height, doc.sigs_in_footer) |
---|
[8059] | 747 | canvas.line(2.2*cm, 0.62*inch, width-2.2*cm, 0.62*inch) |
---|
| 748 | canvas.setFont("Helvetica", 9) |
---|
[11949] | 749 | if not getattr(doc, 'ikoba_nodate', False): |
---|
| 750 | tz = getattr(queryUtility(IIkobaUtils), 'tzinfo', pytz.utc) |
---|
| 751 | #tz = getUtility(IIkobaUtils).tzinfo |
---|
[8234] | 752 | today = now(tz).strftime('%d/%m/%Y %H:%M:%S %Z') |
---|
[8094] | 753 | canvas.drawString(2.2*cm, 0.5 * inch, |
---|
| 754 | translate(_(u'Date: ${a}', mapping = {'a': today}))) |
---|
[8113] | 755 | # set canves attributes needed to render `page x of y` |
---|
[11949] | 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 |
---|
[8059] | 759 | canvas.restoreState() |
---|
| 760 | canvas.restoreState() |
---|
[8104] | 761 | |
---|
| 762 | # Metadata |
---|
[11949] | 763 | canvas.setAuthor(getattr(doc, 'ikoba_author', 'Unknown')) |
---|
[8104] | 764 | canvas.setSubject(title) |
---|
[11949] | 765 | canvas.setCreator(u'WAeUP Ikoba') |
---|
[8059] | 766 | return |
---|
| 767 | |
---|
[8104] | 768 | def create_pdf(self, data, headerline=None, title=None, author=None, |
---|
[10565] | 769 | footer='', note=None, sigs_in_footer=[], topMargin=1.5): |
---|
[8059] | 770 | """Returns a binary data stream which is a PDF document. |
---|
| 771 | """ |
---|
| 772 | pdf_stream = StringIO() |
---|
[9555] | 773 | bottomMargin = len(sigs_in_footer) and 1.9*inch or 1.2*inch |
---|
[8059] | 774 | doc = SimpleDocTemplate( |
---|
| 775 | pdf_stream, |
---|
[9547] | 776 | bottomMargin=bottomMargin, |
---|
[9913] | 777 | topMargin=topMargin*inch, |
---|
[8059] | 778 | title=title, |
---|
[10565] | 779 | pagesize=self.pagesize, |
---|
[8059] | 780 | showBoundary=False, |
---|
| 781 | ) |
---|
| 782 | # Set some attributes that are needed when rendering the background. |
---|
| 783 | if headerline is not None: |
---|
[11949] | 784 | doc.ikoba_headtitle = headerline |
---|
| 785 | doc.ikoba_title = title |
---|
| 786 | doc.ikoba_author = author |
---|
| 787 | doc.ikoba_footer = footer |
---|
[9555] | 788 | doc.sigs_in_footer = sigs_in_footer |
---|
[8257] | 789 | if note is not None: |
---|
| 790 | html = format_html(note) |
---|
| 791 | data.append(Paragraph(html, NOTE_STYLE)) |
---|
[8059] | 792 | doc.build(data, onFirstPage=self.paint_background, |
---|
[8113] | 793 | onLaterPages=self.paint_background, |
---|
| 794 | canvasmaker=NumberedCanvas |
---|
| 795 | ) |
---|
[8059] | 796 | result = pdf_stream.getvalue() |
---|
| 797 | pdf_stream.close() |
---|
| 798 | return result |
---|
[10565] | 799 | |
---|
| 800 | class LandscapePDFCreator(PDFCreator): |
---|
| 801 | """A utility to help with generating PDF docs in |
---|
| 802 | landscape format. |
---|
| 803 | """ |
---|
| 804 | grok.name('landscape') |
---|
[10595] | 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 |
---|