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