Module fpdf.template

PDF Template Helpers for fpdf.py

Expand source code Browse git
"""PDF Template Helpers for fpdf.py"""

__author__ = "Mariano Reingart <reingart@gmail.com>"
__copyright__ = "Copyright (C) 2010 Mariano Reingart"
__license__ = "LGPL 3.0"

import csv, locale, warnings

from .deprecation import get_stack_level
from .errors import FPDFException
from .fpdf import FPDF


def _rgb(col):
    return (col // 65536), (col // 256 % 256), (col % 256)


def _rgb_as_str(col):
    r, g, b = _rgb(col)
    if (r == 0 and g == 0 and b == 0) or g == -1:
        return f"{r / 255:.3f} g"
    return f"{r / 255:.3f} {g / 255:.3f} {b / 255:.3f} rg"


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),
        }

        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_csv(self, infile, delimiter=",", decimal_sep=".", encoding=None):
        """
        Load the template definition from a CSV file.

        Arguments:

            infile (string): The filename 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),
        )
        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",
        )

    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,
        **__,
    ):
        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
            )
        else:  # trim to fit exactly the space defined
            text = pdf.multi_cell(
                w=width,
                h=height,
                text=text,
                align=align,
                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


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", "B", 16)
        self.pdf.set_auto_page_break(False, margin=0)
        super().render()
        if outfile:
            self.pdf.output(outfile)

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),
        }

        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_csv(self, infile, delimiter=",", decimal_sep=".", encoding=None):
        """
        Load the template definition from a CSV file.

        Arguments:

            infile (string): The filename 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),
        )
        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",
        )

    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,
        **__,
    ):
        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
            )
        else:  # trim to fit exactly the space defined
            text = pdf.multi_cell(
                w=width,
                h=height,
                text=text,
                align=align,
                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

Expand source code Browse git
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),
    }

    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())
def parse_csv(self, infile, delimiter=',', decimal_sep='.', encoding=None)

Load the template definition from a CSV file.

Arguments

infile (string): The filename 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.

Expand source code Browse git
def parse_csv(self, infile, delimiter=",", decimal_sep=".", encoding=None):
    """
    Load the template definition from a CSV file.

    Arguments:

        infile (string): The filename 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),
    )
    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 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.

Expand source code Browse git
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
def set(self, name, value)
Expand source code Browse git
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
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.

Expand source code Browse git
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",
    )
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", "B", 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.

Expand source code Browse git
def add_page(self):
    """Finish the current page, and proceed to the next one."""
    if self.pdf.page:
        self.render()
    self.pdf.add_page()
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.

Expand source code Browse git
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", "B", 16)
    self.pdf.set_auto_page_break(False, margin=0)
    super().render()
    if outfile:
        self.pdf.output(outfile)

Inherited members