Module fpdf.template
PDF Template Helpers for fpdf.py
Classes
class FlexTemplate (pdf, elements=None)
-
A flexible templating class.
Allows to apply one or several template definitions to any page of a document in any combination.
Arguments: pdf (fpdf.FPDF() instance): All content will be added to this object.
elements (list of dicts): A template definition in a list of dicts. If you omit this, then you need to call either load_elements() or parse_csv() before doing anything else.
Expand source code Browse git
class FlexTemplate: """ A flexible templating class. Allows to apply one or several template definitions to any page of a document in any combination. """ def __init__(self, pdf, elements=None): """ Arguments: pdf (fpdf.FPDF() instance): All content will be added to this object. elements (list of dicts): A template definition in a list of dicts. If you omit this, then you need to call either load_elements() or parse_csv() before doing anything else. """ if not isinstance(pdf, FPDF): raise TypeError("'pdf' must be an instance of fpdf.FPDF()") self.pdf = pdf self.splitting_pdf = None # for split_multicell() if elements: self.load_elements(elements) self.handlers = { "T": self._text, "L": self._line, "I": self._image, "B": self._rect, "E": self._ellipse, "BC": self._barcode, "C39": self._code39, "W": self._write, } self.texts = {} def load_elements(self, elements): """ Load a template definition. Arguments: elements (list of dicts): A template definition in a list of dicts """ key_config = { # key: type "name": (str, type(None)), "type": (str, type(None)), "x1": (int, float), "y1": (int, float), "x2": (int, float), "y2": (int, float), "font": (str, type(None)), "size": (int, float), "bold": object, # "bool or equivalent" "italic": object, "underline": object, "foreground": int, "background": int, "align": (str, type(None)), "text": (str, type(None)), "priority": int, "multiline": (bool, type(None)), "rotate": (int, float), "wrapmode": (str, type(None)), } self.elements = elements self.keys = [] for e in elements: # priority is optional, but we need a default for sorting. if not "priority" in e: e["priority"] = 0 for k in ("name", "type", "x1", "y1", "y2"): if k not in e: if e["type"] == "C39": # lots of legacy special casing. # We need to do that here, so that rotation and scaling # still work. if k == "x1" and "x" in e: e["x1"] = e["x"] continue if k == "y1" and "y" in e: e["y1"] = e["y"] continue if k == "y2" and "h" in e: e["y2"] = e["y1"] + e["h"] continue raise KeyError(f"Mandatory key '{k}' missing in input data") # x2 is optional for barcode types, but needed for offset rendering if "x2" not in e: if e["type"] in ["BC", "C39"]: e["x2"] = 0 else: raise KeyError("Mandatory key 'x2' missing in input data") if not "size" in e and e["type"] == "C39": if "w" in e: e["size"] = e["w"] for k, t in key_config.items(): if k in e and not isinstance(e[k], t): ttype = ( t.__name__ if isinstance(t, type) else " or ".join([f"'{x.__name__}'" for x in t]) ) raise TypeError( f"Value of element item '{k}' must be {ttype}, not '{type(e[k]).__name__}'." ) self.keys.append(e["name"].lower()) @staticmethod def _parse_colorcode(s): """Allow hex and oct values for colors""" if s[:2] in ["0x", "0X"]: return int(s, 16) if s[0] == "0": return int(s, 8) return int(s) @staticmethod def _parse_multiline(s): i = int(s) if i > 0: return True if i < 0: return False return None def parse_json(self, infile: os.PathLike, encoding: str = "utf-8"): """ Load the template definition from a JSON file. The data must be structured as an array of objects, with names and values exactly equivalent to what would get supplied to load_elements(), Arguments: infile (string or path-like object): The filepath of the JSON file. encoding (string): The character encoding of the file. Default is UTF-8. """ with open(infile, encoding=encoding) as f: data = json.load(f) for d in data: fgval = d.get("foreground") if fgval and isinstance(fgval, str): if fgval.lower().startswith("#"): d["foreground"] = int(fgval[1:], 16) else: raise ValueError( "If foreground is a string, it must have the form '#rrggbb'." ) bgval = d.get("background") if bgval and isinstance(bgval, str): if bgval.lower().startswith("#"): d["background"] = int(bgval[1:], 16) else: raise ValueError( "If background is a string, it must have the form '#rrggbb'." ) self.load_elements(data) def parse_csv( self, infile: os.PathLike, delimiter: str = ",", decimal_sep: str = ".", encoding: str = None, ): """ Load the template definition from a CSV file. Arguments: infile (string or path-like object): The filepath of the CSV file. delimiter (single character): The character that seperates the fields in the CSV file: Usually a comma, semicolon, or tab. decimal_sep (single character): The decimal separator used in the file. Usually either a point or a comma. encoding (string): The character encoding of the file. Default is the system default encoding. """ def _varsep_float(s, default="0"): """Convert to float with given decimal seperator""" # glad to have nonlocal scoping... return float((s.strip() or default).replace(decimal_sep, ".")) key_config = ( # key, converter, mandatory ("name", str, True), ("type", str, True), ("x1", _varsep_float, True), ("y1", _varsep_float, True), ("x2", _varsep_float, True), ("y2", _varsep_float, True), ("font", str, False), ("size", _varsep_float, False), ("bold", int, False), ("italic", int, False), ("underline", int, False), ("foreground", self._parse_colorcode, False), ("background", self._parse_colorcode, False), ("align", str, False), ("text", str, False), ("priority", int, False), ("multiline", self._parse_multiline, False), ("rotate", _varsep_float, False), ("wrapmode", str, False), ) self.elements = [] if encoding is None: encoding = locale.getpreferredencoding() with open(infile, encoding=encoding) as f: for row in csv.reader(f, delimiter=delimiter): # fill in blanks for any missing items row.extend([""] * (len(key_config) - len(row))) kargs = {} for val, cfg in zip(row, key_config): vs = val.strip() if not vs: if cfg[2]: # mandatory if cfg[0] == "x2" and row[1] in ["BC", "C39"]: # two types don't need x2, but offset rendering does pass else: raise FPDFException( f"Mandatory value '{cfg[0]}' missing in csv data" ) elif cfg[0] == "priority": # formally optional, but we need some value for sorting kargs["priority"] = 0 # otherwise, let the type handlers use their own defaults else: kargs[cfg[0]] = cfg[1](vs) self.elements.append(kargs) self.keys = [val["name"].lower() for val in self.elements] def __setitem__(self, name, value): assert isinstance( name, str ), f"name must be of type 'str', not '{type(name).__name__}'." # value has too many valid types to reasonably check here if name.lower() not in self.keys: raise FPDFException(f"Element not loaded, cannot set item: {name}") self.texts[name.lower()] = value # setitem shortcut (may be further extended) set = __setitem__ def __contains__(self, name): assert isinstance( name, str ), f"name must be of type 'str', not '{type(name).__name__}'." return name.lower() in self.keys def __getitem__(self, name): assert isinstance( name, str ), f"name must be of type 'str', not '{type(name).__name__}'." if name not in self.keys: raise KeyError(name) key = name.lower() if key in self.texts: # text for this page: return self.texts[key] # find first element for default text: return next( (x["text"] for x in self.elements if x["name"].lower() == key), None ) def split_multicell(self, text, element_name): """ Split a string between words, for the parts to fit into a given element width. Additional splits will be made replacing any '\\n' characters. Arguments: text (string): The input text string. element_name (string): The name of the template element to fit the text inside. Returns: A list of substrings, each of which will fit into the element width when rendered in the element font style and size. """ element = next( element for element in self.elements if element["name"].lower() == element_name.lower() ) if not self.splitting_pdf: self.splitting_pdf = FPDF() self.splitting_pdf.add_page() style = "" if element.get("bold"): style += "B" if element.get("italic"): style += "I" if element.get("underline"): style += "U" self.splitting_pdf.set_font(element["font"], style, element["size"]) return self.splitting_pdf.multi_cell( w=element["x2"] - element["x1"], h=element["y2"] - element["y1"], text=str(text), align=element.get("align", ""), dry_run=True, output="LINES", wrapmode=element.get("wrapmode", "WORD"), ) def _text( self, *_, x1=0, y1=0, x2=0, y2=0, text="", font="helvetica", size=10, scale=1.0, bold=False, italic=False, underline=False, align="", foreground=0, background=None, multiline=None, wrapmode="WORD", **__, ): if not text: return pdf = self.pdf if pdf.text_color != _rgb_as_str(foreground): pdf.set_text_color(*_rgb(foreground)) if background is None: fill = False else: fill = True if pdf.fill_color != _rgb_as_str(background): pdf.set_fill_color(*_rgb(background)) font = font.strip().lower() style = "" for tag in "B", "I", "U": if text.startswith(f"<{tag}>") and text.endswith(f"</{tag}>"): text = text[3:-4] style += tag if bold: style += "B" if italic: style += "I" if underline: style += "U" pdf.set_font(font, style, size * scale) pdf.set_xy(x1, y1) width, height = x2 - x1, y2 - y1 if multiline is None: # write without wrapping/trimming (default) pdf.cell(w=width, h=height, text=text, border=0, align=align, fill=fill) elif multiline: # automatic word - warp pdf.multi_cell( w=width, h=height, text=text, border=0, align=align, fill=fill, wrapmode=wrapmode, ) else: # trim to fit exactly the space defined text = pdf.multi_cell( w=width, h=height, text=text, align=align, wrapmode=wrapmode, dry_run=True, output="LINES", )[0] pdf.cell(w=width, h=height, text=text, border=0, align=align, fill=fill) def _line( self, *_, x1=0, y1=0, x2=0, y2=0, size=0, scale=1.0, foreground=0, **__, ): if self.pdf.draw_color.serialize().lower() != _rgb_as_str(foreground): self.pdf.set_draw_color(*_rgb(foreground)) self.pdf.set_line_width(size * scale) self.pdf.line(x1, y1, x2, y2) def _rect( self, *_, x1=0, y1=0, x2=0, y2=0, size=0, scale=1.0, foreground=0, background=None, **__, ): pdf = self.pdf if pdf.draw_color.serialize().lower() != _rgb_as_str(foreground): pdf.set_draw_color(*_rgb(foreground)) if background is None: style = "D" else: style = "FD" if pdf.fill_color != _rgb_as_str(background): pdf.set_fill_color(*_rgb(background)) pdf.set_line_width(size * scale) pdf.rect(x1, y1, x2 - x1, y2 - y1, style=style) def _ellipse( self, *_, x1=0, y1=0, x2=0, y2=0, size=0, scale=1.0, foreground=0, background=None, **__, ): pdf = self.pdf if pdf.draw_color.serialize().lower() != _rgb_as_str(foreground): pdf.set_draw_color(*_rgb(foreground)) if background is None: style = "D" else: style = "FD" if pdf.fill_color != _rgb_as_str(background): pdf.set_fill_color(*_rgb(background)) pdf.set_line_width(size * scale) pdf.ellipse(x1, y1, x2 - x1, y2 - y1, style=style) def _image(self, *_, x1=0, y1=0, x2=0, y2=0, text="", **__): if text: self.pdf.image(text, x1, y1, w=x2 - x1, h=y2 - y1, link="") def _barcode( self, *_, x1=0, y1=0, x2=0, y2=0, text="", font="interleaved 2of5 nt", size=1, scale=1.0, foreground=0, **__, ): # pylint: disable=unused-argument pdf = self.pdf if pdf.fill_color.serialize().lower() != _rgb_as_str(foreground): pdf.set_fill_color(*_rgb(foreground)) font = font.lower().strip() if font == "interleaved 2of5 nt": pdf.interleaved2of5(text, x1, y1, w=size * scale, h=y2 - y1) def _code39( self, *_, x1=0, y1=0, y2=0, text="", size=1.5, scale=1.0, foreground=0, x=None, y=None, w=None, h=None, **__, ): if x is not None or y is not None or w is not None or h is not None: warnings.warn( ( "code39 arguments x/y/w/h are deprecated since v2.4.4," " please use x1/y1/y2/size instead" ), DeprecationWarning, stacklevel=get_stack_level(), ) pdf = self.pdf if pdf.fill_color.serialize().lower() != _rgb_as_str(foreground): pdf.set_fill_color(*_rgb(foreground)) h = y2 - y1 if h <= 0: h = 5 pdf.code39(text, x1, y1, size * scale, h) # Added by Derek Schwalenberg Schwalenberg1013@gmail.com to allow (url) links in # templates (using write method) 2014-02-22 def _write( self, *_, x1=0, y1=0, x2=0, y2=0, text="", font="helvetica", size=10, scale=1.0, bold=False, italic=False, underline=False, link="", foreground=0, **__, ): # pylint: disable=unused-argument if not text: return pdf = self.pdf if pdf.text_color != _rgb_as_str(foreground): pdf.set_text_color(*_rgb(foreground)) font = font.strip().lower() style = "" for tag in "B", "I", "U": if text.startswith(f"<{tag}>") and text.endswith(f"</{tag}>"): text = text[3:-4] style += tag if bold: style += "B" if italic: style += "I" if underline: style += "U" pdf.set_font(font, style, size * scale) pdf.set_xy(x1, y1) pdf.write(5, text, link) def render(self, offsetx=0.0, offsety=0.0, rotate=0.0, scale=1.0): """ Add the contents of the template to the PDF document. Arguments: offsetx, offsety (float): Place the template to move its origin to the given coordinates. rotate (float): Rotate the inserted template around its (offset) origin. scale (float): Scale the inserted template by this factor. """ sorted_elements = sorted(self.elements, key=lambda x: x["priority"]) with self.pdf.local_context(): for element in sorted_elements: ele = element.copy() # don't want to modify the callers original ele["text"] = self.texts.get(ele["name"].lower(), ele.get("text", "")) if scale != 1.0: ele["x1"] = ele["x1"] * scale ele["y1"] = ele["y1"] * scale ele["x2"] = ele["x1"] + ((ele["x2"] - element["x1"]) * scale) ele["y2"] = ele["y1"] + ((ele["y2"] - element["y1"]) * scale) if offsetx: ele["x1"] = ele["x1"] + offsetx ele["x2"] = ele["x2"] + offsetx if offsety: ele["y1"] = ele["y1"] + offsety ele["y2"] = ele["y2"] + offsety ele["scale"] = scale handler_name = ele["type"].upper() if rotate: # don't rotate by 0.0 degrees with self.pdf.rotation(rotate, offsetx, offsety): if "rotate" in ele and ele["rotate"]: with self.pdf.rotation(ele["rotate"], ele["x1"], ele["y1"]): self.handlers[handler_name](**ele) else: self.handlers[handler_name](**ele) else: if "rotate" in ele and ele["rotate"]: with self.pdf.rotation(ele["rotate"], ele["x1"], ele["y1"]): self.handlers[handler_name](**ele) else: self.handlers[handler_name](**ele) self.texts = {} # reset modified entries for the next page
Subclasses
Methods
def load_elements(self, elements)
-
Load a template definition.
Arguments
elements (list of dicts): A template definition in a list of dicts
def parse_csv(self, infile: os.PathLike, delimiter: str = ',', decimal_sep: str = '.', encoding: str = None)
-
Load the template definition from a CSV file.
Arguments
infile (string or path-like object): The filepath of the CSV file.
delimiter (single character): The character that seperates the fields in the CSV file: Usually a comma, semicolon, or tab.
decimal_sep (single character): The decimal separator used in the file. Usually either a point or a comma.
encoding (string): The character encoding of the file. Default is the system default encoding.
def parse_json(self, infile: os.PathLike, encoding: str = 'utf-8')
-
Load the template definition from a JSON file. The data must be structured as an array of objects, with names and values exactly equivalent to what would get supplied to load_elements(),
Arguments
infile (string or path-like object): The filepath of the JSON file.
encoding (string): The character encoding of the file. Default is UTF-8.
def render(self, offsetx=0.0, offsety=0.0, rotate=0.0, scale=1.0)
-
Add the contents of the template to the PDF document.
Arguments
offsetx, offsety (float): Place the template to move its origin to the given coordinates.
rotate (float): Rotate the inserted template around its (offset) origin.
scale (float): Scale the inserted template by this factor.
def set(self, name, value)
def split_multicell(self, text, element_name)
-
Split a string between words, for the parts to fit into a given element width. Additional splits will be made replacing any '\n' characters.
Arguments
text (string): The input text string.
element_name (string): The name of the template element to fit the text inside.
Returns
A list of substrings, each of which will fit into the element width when rendered in the element font style and size.
class Template (infile=None, elements=None, format='A4', orientation='portrait', unit='mm', title='', author='', subject='', creator='', keywords='')
-
A simple templating class.
Allows to apply a single template definition to all pages of a document.
Arguments
infile (str): [DEPRECATED since 2.2.0] unused, will be removed in a later version
elements (list of dicts): A template definition in a list of dicts. If you omit this, then you need to call either load_elements() or parse_csv() before doing anything else.
format (str): The page format of the document (eg. "A4" or "letter").
orientation (str): The orientation of the document. Possible values are "portrait"/"P" or "landscape"/"L"
unit (str): The units used in the template definition. One of "mm", "cm", "in", "pt", or a number for points per unit.
title (str): The title of the document.
author (str): The author of the document.
subject (str): The subject matter of the document.
creator (str): The creator of the document.
Expand source code Browse git
class Template(FlexTemplate): """ A simple templating class. Allows to apply a single template definition to all pages of a document. """ # Disabling this check due to the "format" parameter below: # pylint: disable=redefined-builtin def __init__( self, infile=None, elements=None, format="A4", orientation="portrait", unit="mm", title="", author="", subject="", creator="", keywords="", ): """ Arguments: infile (str): [**DEPRECATED since 2.2.0**] unused, will be removed in a later version elements (list of dicts): A template definition in a list of dicts. If you omit this, then you need to call either load_elements() or parse_csv() before doing anything else. format (str): The page format of the document (eg. "A4" or "letter"). orientation (str): The orientation of the document. Possible values are "portrait"/"P" or "landscape"/"L" unit (str): The units used in the template definition. One of "mm", "cm", "in", "pt", or a number for points per unit. title (str): The title of the document. author (str): The author of the document. subject (str): The subject matter of the document. creator (str): The creator of the document. """ if infile: warnings.warn( '"infile" is deprecated since v2.2.0, unused and will soon be removed', DeprecationWarning, stacklevel=get_stack_level(), ) for arg in ( "format", "orientation", "unit", "title", "author", "subject", "creator", "keywords", ): # nosemgrep: python.lang.security.dangerous-globals-use.dangerous-globals-use if not isinstance(locals()[arg], str): raise TypeError(f'Argument "{arg}" must be of type str.') pdf = FPDF(format=format, orientation=orientation, unit=unit) pdf.set_title(title) pdf.set_author(author) pdf.set_creator(creator) pdf.set_subject(subject) pdf.set_keywords(keywords) super().__init__(pdf=pdf, elements=elements) def add_page(self): """Finish the current page, and proceed to the next one.""" if self.pdf.page: self.render() self.pdf.add_page() # pylint: disable=arguments-differ def render(self, outfile=None, dest=None): """ Finish the document and process all pending data. Arguments: outfile (str): If given, the PDF file will be written to this file name. Alternatively, the `.pdf.output()` method can be manually called. dest (str): [**DEPRECATED since 2.2.0**] unused, will be removed in a later version. """ if dest: warnings.warn( '"dest" is deprecated since v2.2.0, unused and will soon be removed', DeprecationWarning, stacklevel=get_stack_level(), ) self.pdf.set_font("helvetica", style="B", size=16) self.pdf.set_auto_page_break(False, margin=0) super().render() if outfile: self.pdf.output(outfile)
Ancestors
Methods
def add_page(self)
-
Finish the current page, and proceed to the next one.
def render(self, outfile=None, dest=None)
-
Finish the document and process all pending data.
Arguments
outfile (str): If given, the PDF file will be written to this file name. Alternatively, the
.pdf.output()
method can be manually called.dest (str): [DEPRECATED since 2.2.0] unused, will be removed in a later version.
Inherited members