├── MANIFEST.in ├── pdfdocument ├── __init__.py ├── utils.py ├── elements.py └── document.py ├── .gitignore ├── tox.ini ├── setup.cfg ├── setup.py ├── CHANGELOG.rst ├── LICENSE └── README.rst /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | -------------------------------------------------------------------------------- /pdfdocument/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (4, 0, 0) 2 | __version__ = ".".join(map(str, VERSION)) 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *~ 3 | .*.swp 4 | \#*# 5 | .DS_Store 6 | ._* 7 | /dist 8 | MANIFEST 9 | build 10 | *.egg-info 11 | .tox 12 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [testenv] 2 | basepython = python3 3 | 4 | [testenv:style] 5 | deps = 6 | black 7 | flake8 8 | isort 9 | changedir = {toxinidir} 10 | commands = 11 | isort --recursive setup.py pdfdocument 12 | black setup.py pdfdocument 13 | flake8 . 14 | skip_install = true 15 | -------------------------------------------------------------------------------- /pdfdocument/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.http import HttpResponse 4 | 5 | from pdfdocument.document import PDFDocument 6 | 7 | 8 | FILENAME_RE = re.compile(r"[^A-Za-z0-9\-\.]+") 9 | 10 | 11 | def pdf_response(filename, as_attachment=True, pdfdocument=PDFDocument, **kwargs): 12 | response = HttpResponse(content_type="application/pdf") 13 | response["Content-Disposition"] = '%s; filename="%s.pdf"' % ( 14 | "attachment" if as_attachment else "inline", 15 | FILENAME_RE.sub("-", filename), 16 | ) 17 | 18 | return pdfdocument(response, **kwargs), response 19 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude=venv,build,docs,.tox,migrations 3 | ignore=E203,W503 4 | # max-complexity=10 5 | max-line-length=88 6 | 7 | [bdist_wheel] 8 | universal = 1 9 | 10 | [isort] 11 | multi_line_output=3 12 | include_trailing_comma=True 13 | force_grid_wrap=0 14 | use_parentheses=True 15 | line_length=88 16 | lines_after_imports=2 17 | known_django=django 18 | combine_as_imports=True 19 | sections=FUTURE,STDLIB,DJANGO,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 20 | 21 | [coverage:run] 22 | branch = True 23 | include = 24 | *pdfdocument* 25 | omit = 26 | *migrations* 27 | *tests* 28 | *.tox* 29 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | from io import open 5 | 6 | from setuptools import find_packages, setup 7 | 8 | 9 | def read(filename): 10 | path = os.path.join(os.path.dirname(__file__), filename) 11 | with open(path, encoding="utf-8") as handle: 12 | return handle.read() 13 | 14 | 15 | setup( 16 | name="pdfdocument", 17 | version=__import__("pdfdocument").__version__, 18 | description="Programmatic wrapper around ReportLab.", 19 | long_description=read("README.rst"), 20 | author="Matthias Kestenholz", 21 | author_email="mk@feinheit.ch", 22 | url="https://github.com/matthiask/pdfdocument/", 23 | packages=find_packages(exclude=["tests", "testapp"]), 24 | include_package_data=True, 25 | install_requires=["reportlab"], 26 | ) 27 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | .. _changelog: 2 | 3 | Change log 4 | ========== 5 | 6 | `Next version`_ 7 | ~~~~~~~~~~~~~~~ 8 | 9 | 10 | `v4.0`_ (2020-04-09) 11 | ~~~~~~~~~~~~~~~~~~~~ 12 | 13 | - Changed ``init_report`` and ``init_letter`` to not explicitly specify 14 | the font size of the document. This changes reports to use a default 15 | font size of ``9`` instead of ``8``. 16 | - Reformatted the code using ``black`` and ``isort``. 17 | 18 | 19 | `v3.3`_ (2019-03-04) 20 | ~~~~~~~~~~~~~~~~~~~~ 21 | 22 | - Fixed a grave bug where ``mini_html`` would silently drop content 23 | outside of HTML elements. 24 | 25 | 26 | `v3.2`_ (2017-04-13) 27 | ~~~~~~~~~~~~~~~~~~~~ 28 | 29 | - Imported ``reduce`` from ``functools`` for Python 3 compatibility. 30 | 31 | 32 | `v3.1`_ (2014-10-21) 33 | ~~~~~~~~~~~~~~~~~~~~ 34 | 35 | - Started building universal wheels. 36 | - Added an option to ``init_letter`` to specify the X position of the 37 | address block. 38 | 39 | 40 | `v3.0`_ (2014-01-03) 41 | ~~~~~~~~~~~~~~~~~~~~ 42 | 43 | - Added compatibility with Python 3. 44 | 45 | 46 | .. _v3.0: https://github.com/matthiask/pdfdocument/commit/fe085bdf9 47 | .. _v3.1: https://github.com/matthiask/pdfdocument/compare/v3.0...v3.1 48 | .. _v3.2: https://github.com/matthiask/pdfdocument/compare/v3.1...v3.2 49 | .. _v3.3: https://github.com/matthiask/pdfdocument/compare/v3.2...v3.3 50 | .. _v4.0: https://github.com/matthiask/pdfdocument/compare/v3.3...v4.0 51 | .. _Next version: https://github.com/matthiask/feincms3/compare/v4.0...master 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2009-2010, FEINHEIT GmbH and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of FEINHEIT GmbH nor the names of its contributors 15 | may be used to endorse or promote products derived from this software 16 | without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /pdfdocument/elements.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.conf import settings 4 | 5 | from pdfdocument.document import mm 6 | 7 | 8 | def create_stationery_fn(*fns): 9 | def _fn(canvas, document): 10 | for fn in fns: 11 | fn(canvas, document.PDFDocument) 12 | 13 | return _fn 14 | 15 | 16 | class ExampleStationery(object): 17 | def __call__(self, canvas, pdfdocument): 18 | left_offset = 28.6 * mm 19 | 20 | canvas.saveState() 21 | canvas.setFont("%s-Bold" % pdfdocument.style.fontName, 10) 22 | canvas.drawString(26 * mm, 284 * mm, "PLATA") 23 | canvas.setFont("%s" % pdfdocument.style.fontName, 10) 24 | canvas.drawString(26 * mm + left_offset, 284 * mm, "Django Shop Software") 25 | pdfdocument.draw_watermark(canvas) 26 | canvas.restoreState() 27 | 28 | canvas.saveState() 29 | canvas.setFont("%s" % pdfdocument.style.fontName, 6) 30 | for i, text in enumerate(reversed([pdfdocument.doc.page_index_string()])): 31 | canvas.drawRightString(190 * mm, (8 + 3 * i) * mm, text) 32 | 33 | for i, text in enumerate(reversed(["PLATA", "Something"])): 34 | canvas.drawString(26 * mm + left_offset, (8 + 3 * i) * mm, text) 35 | 36 | logo = getattr(settings, "PDF_LOGO_SETTINGS", None) 37 | if logo: 38 | canvas.drawImage( 39 | os.path.join( 40 | settings.APP_BASEDIR, "metronom", "reporting", "images", logo[0] 41 | ), 42 | **logo[1] 43 | ) 44 | 45 | canvas.restoreState() 46 | 47 | 48 | class PageFnWrapper(object): 49 | """ 50 | Wrap an old-style page setup function 51 | """ 52 | 53 | def __init__(self, fn): 54 | self.fn = fn 55 | 56 | def __call__(self, canvas, pdfdocument): 57 | self.fn(canvas, pdfdocument.doc) 58 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | PDFDocument 3 | =========== 4 | 5 | This is a wrapper for ReportLab which allows easy creation of PDF documents:: 6 | 7 | from io import BytesIO 8 | from pdfdocument.document import PDFDocument 9 | 10 | def say_hello(): 11 | f = BytesIO() 12 | pdf = PDFDocument(f) 13 | pdf.init_report() 14 | pdf.h1('Hello World') 15 | pdf.p('Creating PDFs made easy.') 16 | pdf.generate() 17 | return f.getvalue() 18 | 19 | 20 | Letters and reports 21 | =================== 22 | 23 | PDFDocument comes with two different PDF templates, letters and reports. The 24 | only difference is the layout of the first page: The letter has an additional 25 | frame for the address at the top and a smaller main content area. 26 | 27 | Usage is as follows:: 28 | 29 | pdf.init_report() 30 | # Or: 31 | pdf.init_letter() 32 | 33 | The letter generates default styles using 9 point fonts as base size, the report 34 | uses 8 points. This can be changed by calling ``pdf.generate_style`` again. 35 | 36 | There exists also a special type of report, the confidential report, the only 37 | differences being that the confidentiality is marked using a red cross at the 38 | top of the first page and a watermark in the background. 39 | 40 | 41 | Styles 42 | ====== 43 | 44 | The call to ``pdf.generate_style`` generates a set of predefined styles. (Yes 45 | it does!) That includes the following styles; this list is neither exhaustive 46 | nor a promise: 47 | 48 | - ``pdf.style.normal`` 49 | - ``pdf.style.heading1`` 50 | - ``pdf.style.heading2`` 51 | - ``pdf.style.heading3`` 52 | - ``pdf.style.small`` 53 | - ``pdf.style.bold`` 54 | - ``pdf.style.right`` 55 | - ``pdf.style.indented`` 56 | - ``pdf.style.paragraph`` 57 | - ``pdf.style.table`` 58 | 59 | Most of the time you will not use those attributes directly, except in the case 60 | of tables. Convenience methods exist for almost all styles as described in the 61 | next chapter. 62 | 63 | 64 | Content 65 | ======= 66 | 67 | All content passed to the following methods is escaped by default. ReportLab 68 | supports a HTML-like markup language, if you want to use it directly you'll 69 | have to either use only ``pdf.p_markup`` or resort to creating 70 | ``pdfdocument.document.MarkupParagraph`` instances by hand. 71 | 72 | 73 | Headings 74 | -------- 75 | 76 | ``pdf.h1``, ``pdf.h2``, ``pdf.h3`` 77 | 78 | 79 | Paragraphs 80 | ---------- 81 | 82 | ``pdf.p``, ``pdf.p_markup``, ``pdf.small``, ``pdf.smaller`` 83 | 84 | 85 | Unordered lists 86 | --------------- 87 | 88 | ``pdf.ul`` 89 | 90 | Mini-HTML 91 | --------- 92 | 93 | ``pdf.mini_html`` 94 | 95 | 96 | Various elements 97 | ---------------- 98 | 99 | ``pdf.hr``, ``pdf.hr_mini``, ``pdf.spacer``, ``pdf.pagebreak``, 100 | ``pdf.start_keeptogether``, ``pdf.end_keeptogether``, ``pdf.next_frame``, 101 | 102 | 103 | Tables 104 | ------ 105 | 106 | ``pdf.table``, ``pdf.bottom_table`` 107 | 108 | 109 | Canvas methods 110 | -------------- 111 | 112 | Canvas methods work with the canvas directly, and not with Platypus objects. 113 | They are mostly useful inside stationery functions. You'll mostly use 114 | ReportLab's canvas methods directly, and only resort to the following methods 115 | for special cases. 116 | 117 | ``pdf.confidential``, ``pdf.draw_watermark``, ``pdf.draw_svg`` 118 | 119 | 120 | Additional methods 121 | ------------------ 122 | 123 | ``pdf.append``, ``pdf.restart`` 124 | 125 | 126 | Django integration 127 | ================== 128 | 129 | PDFDocument has a few helpers for generating PDFs in Django views, most notably 130 | ``pdfdocument.utils.pdf_response``:: 131 | 132 | from pdfdocument.utils import pdf_response 133 | 134 | def pdf_view(request): 135 | pdf, response = pdf_response('filename_without_extension') 136 | # ... more code 137 | 138 | pdf.generate() 139 | return response 140 | 141 | 142 | The SVG support uses svglib by Dinu Gherman. It can be found on PyPI: 143 | 144 | -------------------------------------------------------------------------------- /pdfdocument/document.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | import copy 4 | import sys 5 | import unicodedata 6 | from functools import reduce 7 | 8 | from reportlab.lib import colors 9 | from reportlab.lib.enums import TA_RIGHT 10 | from reportlab.lib.fonts import addMapping 11 | from reportlab.lib.styles import getSampleStyleSheet 12 | from reportlab.lib.units import cm, mm 13 | from reportlab.pdfbase import pdfmetrics 14 | from reportlab.pdfbase.ttfonts import TTFont 15 | from reportlab.platypus import ( 16 | BaseDocTemplate, 17 | CondPageBreak, 18 | Frame, 19 | KeepTogether, 20 | NextPageTemplate, 21 | PageBreak, 22 | PageTemplate, 23 | Paragraph as _Paragraph, 24 | Spacer, 25 | Table, 26 | ) 27 | from reportlab.platypus.flowables import HRFlowable 28 | 29 | 30 | PY2 = sys.version_info[0] < 3 31 | 32 | if PY2: 33 | string_type = unicode # noqa 34 | else: 35 | string_type = str 36 | 37 | 38 | def register_fonts_from_paths( 39 | regular, italic=None, bold=None, bolditalic=None, font_name="Reporting" 40 | ): 41 | """ 42 | Pass paths to TTF files which should be used for the PDFDocument 43 | """ 44 | 45 | pdfmetrics.registerFont(TTFont("%s" % font_name, regular)) 46 | pdfmetrics.registerFont(TTFont("%s-Italic" % font_name, italic or regular)) 47 | pdfmetrics.registerFont(TTFont("%s-Bold" % font_name, bold or regular)) 48 | pdfmetrics.registerFont( 49 | TTFont("%s-BoldItalic" % font_name, bolditalic or bold or regular) 50 | ) 51 | 52 | addMapping("%s" % font_name, 0, 0, "%s" % font_name) 53 | addMapping("%s" % font_name, 0, 1, "%s-Italic" % font_name) 54 | addMapping("%s" % font_name, 1, 0, "%s-Bold" % font_name) 55 | addMapping("%s" % font_name, 1, 1, "%s-BoldItalic" % font_name) 56 | 57 | 58 | class Empty(object): 59 | pass 60 | 61 | 62 | def sanitize(text): 63 | REPLACE_MAP = [ 64 | (u"&", "&"), 65 | (u"<", "<"), 66 | (u">", ">"), 67 | (u"ç", "ç"), 68 | (u"Ç", "Ç"), 69 | (u"\n", "
"), 70 | (u"\r", ""), 71 | ] 72 | 73 | for p, q in REPLACE_MAP: 74 | text = text.replace(p, q) 75 | return text 76 | 77 | 78 | def normalize(text): 79 | """ 80 | Some layers of reportlab, PDF or font handling or whatever cannot handle 81 | german umlauts in decomposed form correctly. Normalize everything to 82 | NFKC. 83 | """ 84 | if not isinstance(text, string_type): 85 | text = string_type(text) 86 | return unicodedata.normalize("NFKC", text) 87 | 88 | 89 | def MarkupParagraph(txt, *args, **kwargs): 90 | if not txt: 91 | return _Paragraph(u"", *args, **kwargs) 92 | return _Paragraph(normalize(txt), *args, **kwargs) 93 | 94 | 95 | def Paragraph(txt, *args, **kwargs): 96 | if not txt: 97 | return _Paragraph(u"", *args, **kwargs) 98 | return _Paragraph(sanitize(normalize(txt)), *args, **kwargs) 99 | 100 | 101 | class BottomTable(Table): 102 | """ 103 | This table will automatically be moved to the bottom of the page using the 104 | BottomSpacer right before it. 105 | """ 106 | 107 | pass 108 | 109 | 110 | class BottomSpacer(Spacer): 111 | def wrap(self, availWidth, availHeight): 112 | my_height = availHeight - self._doc.bottomTableHeight 113 | 114 | if my_height <= 0: 115 | return (self.width, availHeight) 116 | else: 117 | return (self.width, my_height) 118 | 119 | 120 | class RestartPageBreak(PageBreak): 121 | """ 122 | Insert a page break and restart the page numbering. 123 | """ 124 | 125 | pass 126 | 127 | 128 | class ReportingDocTemplate(BaseDocTemplate): 129 | def __init__(self, *args, **kwargs): 130 | BaseDocTemplate.__init__(self, *args, **kwargs) 131 | self.bottomTableHeight = 0 132 | self.bottomTableIsLast = False 133 | self.numPages = 0 134 | self._lastNumPages = 0 135 | self.setProgressCallBack(self._onProgress_cb) 136 | 137 | # For batch reports with several PDFs concatenated 138 | self.restartDoc = False 139 | self.restartDocIndex = 0 140 | self.restartDocPageNumbers = [] 141 | 142 | def afterFlowable(self, flowable): 143 | self.numPages = max(self.canv.getPageNumber(), self.numPages) 144 | self.bottomTableIsLast = False 145 | 146 | if isinstance(flowable, BottomTable): 147 | self.bottomTableHeight = reduce(lambda p, q: p + q, flowable._rowHeights, 0) 148 | 149 | self.bottomTableIsLast = True 150 | 151 | elif isinstance(flowable, RestartPageBreak): 152 | self.restartDoc = True 153 | self.restartDocIndex += 1 154 | self.restartDocPageNumbers.append(self.page) 155 | 156 | # here the real hackery starts ... thanks Ralph 157 | def _allSatisfied(self): 158 | """ Called by multi-build - are all cross-references resolved? """ 159 | if self._lastNumPages < self.numPages: 160 | return 0 161 | return BaseDocTemplate._allSatisfied(self) 162 | 163 | def _onProgress_cb(self, what, arg): 164 | if what == "STARTED": 165 | self._lastNumPages = self.numPages 166 | self.restartDocIndex = 0 167 | # self.restartDocPageNumbers = [] 168 | 169 | def page_index(self): 170 | """ 171 | Return the current page index as a tuple (current_page, total_pages) 172 | 173 | This is the ugliest thing I've done in the last two years. 174 | For this I'll burn in programmer hell. 175 | 176 | At least it is contained here. 177 | 178 | (Determining the total number of pages in reportlab is a mess 179 | anyway...) 180 | """ 181 | 182 | current_page = self.page 183 | total_pages = self.numPages 184 | 185 | if self.restartDoc: 186 | if self.restartDocIndex: 187 | current_page = ( 188 | current_page 189 | - self.restartDocPageNumbers[self.restartDocIndex - 1] 190 | + 1 191 | ) 192 | if len(self.restartDocPageNumbers) > self.restartDocIndex: 193 | total_pages = ( 194 | self.restartDocPageNumbers[self.restartDocIndex] 195 | - self.restartDocPageNumbers[self.restartDocIndex - 1] 196 | + 1 197 | ) 198 | else: 199 | total_pages = self.restartDocPageNumbers[0] 200 | 201 | if self.bottomTableHeight: 202 | total_pages -= 1 203 | 204 | if self.bottomTableIsLast and current_page == 1: 205 | total_pages = max(1, total_pages) 206 | 207 | # Ensure total pages is always at least 1 208 | total_pages = max(1, total_pages) 209 | 210 | return (current_page, total_pages) 211 | 212 | def page_index_string(self): 213 | """ 214 | Return page index string for the footer. 215 | """ 216 | current_page, total_pages = self.page_index() 217 | 218 | return self.PDFDocument.page_index_string(current_page, total_pages) 219 | 220 | 221 | def dummy_stationery(c, doc): 222 | pass 223 | 224 | 225 | class PDFDocument(object): 226 | show_boundaries = False 227 | _watermark = None 228 | 229 | def __init__(self, *args, **kwargs): 230 | self.doc = ReportingDocTemplate(*args, **kwargs) 231 | self.doc.PDFDocument = self 232 | self.story = [] 233 | 234 | self.font_name = kwargs.get("font_name", "Helvetica") 235 | self.font_size = kwargs.get("font_size", 9) 236 | 237 | def page_index_string(self, current_page, total_pages): 238 | return "Page %(current_page)d of %(total_pages)d" % { 239 | "current_page": current_page, 240 | "total_pages": total_pages, 241 | } 242 | 243 | def generate_style(self, font_name=None, font_size=None): 244 | self.style = Empty() 245 | self.style.fontName = font_name or self.font_name 246 | self.style.fontSize = font_size or self.font_size 247 | 248 | _styles = getSampleStyleSheet() 249 | 250 | self.style.normal = _styles["Normal"] 251 | self.style.normal.fontName = "%s" % self.style.fontName 252 | self.style.normal.fontSize = self.style.fontSize 253 | self.style.normal.firstLineIndent = 0 254 | # normal.textColor = '#0e2b58' 255 | 256 | self.style.heading1 = copy.deepcopy(self.style.normal) 257 | self.style.heading1.fontName = "%s" % self.style.fontName 258 | self.style.heading1.fontSize = 1.5 * self.style.fontSize 259 | self.style.heading1.leading = 2 * self.style.fontSize 260 | # heading1.leading = 10*mm 261 | 262 | self.style.heading2 = copy.deepcopy(self.style.normal) 263 | self.style.heading2.fontName = "%s-Bold" % self.style.fontName 264 | self.style.heading2.fontSize = 1.25 * self.style.fontSize 265 | self.style.heading2.leading = 1.75 * self.style.fontSize 266 | # heading2.leading = 5*mm 267 | 268 | self.style.heading3 = copy.deepcopy(self.style.normal) 269 | self.style.heading3.fontName = "%s-Bold" % self.style.fontName 270 | self.style.heading3.fontSize = 1.1 * self.style.fontSize 271 | self.style.heading3.leading = 1.5 * self.style.fontSize 272 | self.style.heading3.textColor = "#666666" 273 | # heading3.leading = 5*mm 274 | 275 | self.style.small = copy.deepcopy(self.style.normal) 276 | self.style.small.fontSize = self.style.fontSize - 0.9 277 | 278 | self.style.smaller = copy.deepcopy(self.style.normal) 279 | self.style.smaller.fontSize = self.style.fontSize * 0.75 280 | 281 | self.style.bold = copy.deepcopy(self.style.normal) 282 | self.style.bold.fontName = "%s-Bold" % self.style.fontName 283 | 284 | self.style.boldr = copy.deepcopy(self.style.bold) 285 | self.style.boldr.alignment = TA_RIGHT 286 | 287 | self.style.right = copy.deepcopy(self.style.normal) 288 | self.style.right.alignment = TA_RIGHT 289 | 290 | self.style.indented = copy.deepcopy(self.style.normal) 291 | self.style.indented.leftIndent = 0.5 * cm 292 | 293 | self.style.tablenotes = copy.deepcopy(self.style.indented) 294 | self.style.tablenotes.fontName = "%s-Italic" % self.style.fontName 295 | 296 | self.style.paragraph = copy.deepcopy(self.style.normal) 297 | self.style.paragraph.spaceBefore = 1 298 | self.style.paragraph.spaceAfter = 1 299 | 300 | self.style.bullet = copy.deepcopy(self.style.normal) 301 | self.style.bullet.bulletFontName = "Symbol" 302 | self.style.bullet.bulletFontSize = 7 303 | self.style.bullet.bulletIndent = 6 304 | self.style.bullet.firstLineIndent = 0 305 | self.style.bullet.leftIndent = 15 306 | 307 | self.style.numberbullet = copy.deepcopy(self.style.normal) 308 | self.style.numberbullet.bulletFontName = self.style.paragraph.fontName 309 | self.style.numberbullet.bulletFontSize = self.style.paragraph.fontSize 310 | self.style.numberbullet.bulletIndent = 0 311 | self.style.numberbullet.firstLineIndent = 0 312 | self.style.numberbullet.leftIndent = 15 313 | 314 | # alignment = TA_RIGHT 315 | # leftIndent = 0.4*cm 316 | # spaceBefore = 0 317 | # spaceAfter = 0 318 | 319 | self.style.tableBase = ( 320 | ("FONT", (0, 0), (-1, -1), "%s" % self.style.fontName, self.style.fontSize), 321 | ("TOPPADDING", (0, 0), (-1, -1), 0), 322 | ("BOTTOMPADDING", (0, 0), (-1, -1), 1), 323 | ("LEFTPADDING", (0, 0), (-1, -1), 0), 324 | ("RIGHTPADDING", (0, 0), (-1, -1), 0), 325 | ("FIRSTLINEINDENT", (0, 0), (-1, -1), 0), 326 | ("VALIGN", (0, 0), (-1, -1), "TOP"), 327 | ) 328 | 329 | self.style.table = self.style.tableBase + ( 330 | ("ALIGN", (1, 0), (-1, -1), "RIGHT"), 331 | ) 332 | 333 | self.style.tableLLR = self.style.tableBase + ( 334 | ("ALIGN", (2, 0), (-1, -1), "RIGHT"), 335 | ("VALIGN", (0, 0), (-1, 0), "BOTTOM"), 336 | ) 337 | 338 | self.style.tableHead = self.style.tableBase + ( 339 | ( 340 | "FONT", 341 | (0, 0), 342 | (-1, 0), 343 | "%s-Bold" % self.style.fontName, 344 | self.style.fontSize, 345 | ), 346 | ("ALIGN", (1, 0), (-1, -1), "RIGHT"), 347 | ("TOPPADDING", (0, 0), (-1, -1), 1), 348 | ("BOTTOMPADDING", (0, 0), (-1, -1), 2), 349 | ("LINEABOVE", (0, 0), (-1, 0), 0.2, colors.black), 350 | ("LINEBELOW", (0, 0), (-1, 0), 0.2, colors.black), 351 | ) 352 | 353 | self.style.tableOptional = self.style.tableBase + ( 354 | ( 355 | "FONT", 356 | (0, 0), 357 | (-1, 0), 358 | "%s-Italic" % self.style.fontName, 359 | self.style.fontSize, 360 | ), 361 | ("ALIGN", (1, 0), (-1, -1), "RIGHT"), 362 | ("BOTTOMPADDING", (0, 0), (-1, -1), 5), 363 | ("RIGHTPADDING", (1, 0), (-1, -1), 2 * cm), 364 | ) 365 | 366 | def init_templates(self, page_fn, page_fn_later=None): 367 | self.doc.addPageTemplates( 368 | [ 369 | PageTemplate(id="First", frames=[self.frame], onPage=page_fn), 370 | PageTemplate( 371 | id="Later", frames=[self.frame], onPage=page_fn_later or page_fn 372 | ), 373 | ] 374 | ) 375 | self.story.append(NextPageTemplate("Later")) 376 | 377 | def init_report(self, page_fn=dummy_stationery, page_fn_later=None): 378 | frame_kwargs = { 379 | "showBoundary": self.show_boundaries, 380 | "leftPadding": 0, 381 | "rightPadding": 0, 382 | "topPadding": 0, 383 | "bottomPadding": 0, 384 | } 385 | 386 | full_frame = Frame(2.6 * cm, 2 * cm, 16.4 * cm, 25 * cm, **frame_kwargs) 387 | 388 | self.doc.addPageTemplates( 389 | [ 390 | PageTemplate(id="First", frames=[full_frame], onPage=page_fn), 391 | PageTemplate( 392 | id="Later", frames=[full_frame], onPage=page_fn_later or page_fn 393 | ), 394 | ] 395 | ) 396 | self.story.append(NextPageTemplate("Later")) 397 | 398 | self.generate_style() 399 | 400 | def init_confidential_report(self, page_fn=dummy_stationery, page_fn_later=None): 401 | if not page_fn_later: 402 | page_fn_later = page_fn 403 | 404 | def _first_page_fn(canvas, doc): 405 | page_fn(canvas, doc) 406 | doc.PDFDocument.confidential(canvas) 407 | doc.PDFDocument.watermark("CONFIDENTIAL") 408 | 409 | self.init_report(page_fn=_first_page_fn, page_fn_later=page_fn_later) 410 | 411 | def init_letter( 412 | self, 413 | page_fn=dummy_stationery, 414 | page_fn_later=None, 415 | address_y=None, 416 | address_x=None, 417 | ): 418 | frame_kwargs = { 419 | "showBoundary": self.show_boundaries, 420 | "leftPadding": 0, 421 | "rightPadding": 0, 422 | "topPadding": 0, 423 | "bottomPadding": 0, 424 | } 425 | 426 | address_frame = Frame( 427 | address_x or 2.6 * cm, 428 | address_y or 20.2 * cm, 429 | 16.4 * cm, 430 | 4 * cm, 431 | **frame_kwargs 432 | ) 433 | rest_frame = Frame(2.6 * cm, 2 * cm, 16.4 * cm, 18.2 * cm, **frame_kwargs) 434 | full_frame = Frame(2.6 * cm, 2 * cm, 16.4 * cm, 25 * cm, **frame_kwargs) 435 | 436 | self.doc.addPageTemplates( 437 | [ 438 | PageTemplate( 439 | id="First", frames=[address_frame, rest_frame], onPage=page_fn 440 | ), 441 | PageTemplate( 442 | id="Later", frames=[full_frame], onPage=page_fn_later or page_fn 443 | ), 444 | ] 445 | ) 446 | self.story.append(NextPageTemplate("Later")) 447 | 448 | self.generate_style() 449 | 450 | def watermark(self, watermark=None): 451 | self._watermark = watermark 452 | 453 | def restart(self): 454 | self.story.append(NextPageTemplate("First")) 455 | self.story.append(RestartPageBreak()) 456 | 457 | def p(self, text, style=None): 458 | self.story.append(Paragraph(text, style or self.style.normal)) 459 | 460 | def h1(self, text, style=None): 461 | self.story.append(Paragraph(text, style or self.style.heading1)) 462 | 463 | def h2(self, text, style=None): 464 | self.story.append(Paragraph(text, style or self.style.heading2)) 465 | 466 | def h3(self, text, style=None): 467 | self.story.append(Paragraph(text, style or self.style.heading3)) 468 | 469 | def small(self, text, style=None): 470 | self.story.append(Paragraph(text, style or self.style.small)) 471 | 472 | def smaller(self, text, style=None): 473 | self.story.append(Paragraph(text, style or self.style.smaller)) 474 | 475 | def p_markup(self, text, style=None): 476 | self.story.append(MarkupParagraph(text, style or self.style.normal)) 477 | 478 | def ul(self, items): 479 | for item in items: 480 | self.story.append(MarkupParagraph(item, self.style.bullet, bulletText=u"•")) 481 | 482 | def spacer(self, height=0.6 * cm): 483 | self.story.append(Spacer(1, height)) 484 | 485 | def table(self, data, columns, style=None): 486 | self.story.append(Table(data, columns, style=style or self.style.table)) 487 | 488 | def hr(self): 489 | self.story.append(HRFlowable(width="100%", thickness=0.2, color=colors.black)) 490 | 491 | def hr_mini(self): 492 | self.story.append(HRFlowable(width="100%", thickness=0.2, color=colors.grey)) 493 | 494 | def mini_html(self, html): 495 | """Convert a small subset of HTML into ReportLab paragraphs 496 | 497 | Requires lxml and BeautifulSoup.""" 498 | import lxml.html 499 | import lxml.html.soupparser 500 | 501 | TAG_MAP = { 502 | "strong": "b", 503 | "em": "i", 504 | "br": "br", # Leave br tags alone 505 | } 506 | 507 | BULLETPOINT = u"•" 508 | 509 | def _p(text, list_bullet_point, style=None): 510 | if list_bullet_point: 511 | self.story.append( 512 | MarkupParagraph( 513 | text, 514 | style or self.style.paragraph, 515 | bulletText=list_bullet_point, 516 | ) 517 | ) 518 | else: 519 | self.story.append(MarkupParagraph(text, style or self.style.paragraph)) 520 | 521 | def _remove_attributes(element): 522 | for key in element.attrib: 523 | del element.attrib[key] 524 | 525 | def _handle_element(element, list_bullet_point=False, style=None): 526 | _remove_attributes(element) 527 | 528 | if element.tag in TAG_MAP: 529 | element.tag = TAG_MAP[element.tag] 530 | 531 | if element.tag in ("ul",): 532 | for item in element: 533 | _handle_element( 534 | item, list_bullet_point=BULLETPOINT, style=self.style.bullet 535 | ) 536 | list_bullet_point = False 537 | elif element.tag in ("ol",): 538 | for counter, item in enumerate(element): 539 | _handle_element( 540 | item, 541 | list_bullet_point=u"{}.".format(counter + 1), 542 | style=self.style.numberbullet, 543 | ) 544 | list_bullet_point = False 545 | elif element.tag in ("p", "li"): 546 | for tag in reversed(list(element.iterdescendants())): 547 | _remove_attributes(tag) 548 | if tag.tag in TAG_MAP: 549 | tag.tag = TAG_MAP[tag.tag] 550 | else: 551 | tag.drop_tag() 552 | 553 | _p( 554 | lxml.html.tostring(element, method="xml", encoding=string_type), 555 | list_bullet_point, 556 | style, 557 | ) 558 | else: 559 | if element.text: 560 | _p(element.text, list_bullet_point, style) 561 | 562 | for item in element: 563 | _handle_element(item, list_bullet_point, style) 564 | 565 | if element.tail: 566 | _p(element.tail, list_bullet_point, style) 567 | 568 | soup = lxml.html.soupparser.fromstring(html) 569 | _handle_element(soup) 570 | 571 | def pagebreak(self): 572 | self.story.append(PageBreak()) 573 | 574 | def bottom_table(self, data, columns, style=None): 575 | obj = BottomSpacer(1, 1) 576 | obj._doc = self.doc 577 | self.story.append(obj) 578 | 579 | self.story.append(BottomTable(data, columns, style=style or self.style.table)) 580 | 581 | def append(self, data): 582 | self.story.append(data) 583 | 584 | def generate(self): 585 | self.doc.multiBuild(self.story) 586 | 587 | def confidential(self, canvas): 588 | canvas.saveState() 589 | 590 | canvas.translate(18.5 * cm, 27.4 * cm) 591 | 592 | canvas.setLineWidth(3) 593 | canvas.setFillColorRGB(1, 0, 0) 594 | canvas.setStrokeGray(0.5) 595 | 596 | p = canvas.beginPath() 597 | p.moveTo(10, 0) 598 | p.lineTo(20, 10) 599 | p.lineTo(30, 0) 600 | p.lineTo(40, 10) 601 | p.lineTo(30, 20) 602 | p.lineTo(40, 30) 603 | p.lineTo(30, 40) 604 | p.lineTo(20, 30) 605 | p.lineTo(10, 40) 606 | p.lineTo(0, 30) 607 | p.lineTo(10, 20) 608 | p.lineTo(0, 10) 609 | 610 | canvas.drawPath(p, fill=1, stroke=0) 611 | 612 | canvas.restoreState() 613 | 614 | def draw_watermark(self, canvas): 615 | if self._watermark: 616 | canvas.saveState() 617 | canvas.rotate(60) 618 | canvas.setFillColorRGB(0.9, 0.9, 0.9) 619 | canvas.setFont("%s" % self.style.fontName, 120) 620 | canvas.drawCentredString(195 * mm, -30 * mm, self._watermark) 621 | canvas.restoreState() 622 | 623 | def draw_svg(self, canvas, path, xpos=0, ypos=0, xsize=None, ysize=None): 624 | from reportlab.graphics import renderPDF 625 | from svglib.svglib import svg2rlg 626 | 627 | drawing = svg2rlg(path) 628 | xL, yL, xH, yH = drawing.getBounds() 629 | 630 | if xsize: 631 | drawing.renderScale = xsize / (xH - xL) 632 | if ysize: 633 | drawing.renderScale = ysize / (yH - yL) 634 | 635 | renderPDF.draw(drawing, canvas, xpos, ypos, showBoundary=self.show_boundaries) 636 | 637 | def next_frame(self): 638 | self.story.append(CondPageBreak(20 * cm)) 639 | 640 | def start_keeptogether(self): 641 | self.keeptogether_index = len(self.story) 642 | 643 | def end_keeptogether(self): 644 | keeptogether = KeepTogether(self.story[self.keeptogether_index :]) 645 | self.story = self.story[: self.keeptogether_index] 646 | self.story.append(keeptogether) 647 | 648 | def address_head(self, text): 649 | self.smaller(text) 650 | self.spacer(2 * mm) 651 | 652 | def address(self, obj, prefix=""): 653 | if type(obj) == dict: 654 | data = obj 655 | else: 656 | data = {} 657 | for field in ( 658 | "company", 659 | "manner_of_address", 660 | "first_name", 661 | "last_name", 662 | "address", 663 | "zip_code", 664 | "city", 665 | "full_override", 666 | ): 667 | attribute = "%s%s" % (prefix, field) 668 | data[field] = getattr(obj, attribute, u"").strip() 669 | 670 | address = [] 671 | if data.get("company", False): 672 | address.append(data["company"]) 673 | 674 | title = data.get("manner_of_address", "") 675 | if title: 676 | title += u" " 677 | 678 | if data.get("first_name", False): 679 | address.append( 680 | u"%s%s %s" 681 | % (title, data.get("first_name", ""), data.get("last_name", "")) 682 | ) 683 | else: 684 | address.append(u"%s%s" % (title, data.get("last_name", ""))) 685 | 686 | address.append(data.get("address")) 687 | address.append(u"%s %s" % (data.get("zip_code", ""), data.get("city", ""))) 688 | 689 | if data.get("full_override"): 690 | address = [ 691 | l.strip() 692 | for l in data.get("full_override").replace("\r", "").splitlines() 693 | ] 694 | 695 | self.p("\n".join(address)) 696 | --------------------------------------------------------------------------------