How to dynamically generate graphics and PDFs using Python an jinja

swssl

Simon

Posted on January 8, 2023

How to dynamically generate graphics and PDFs using Python an jinja

A few days ago I was searching the internet for a guide on how to use the jinja library to enable a flask application to generate PDF documents. Since I didn't find anything comprehensible on google, here are my findings and the (partially hacky) code i wrote.

1. What can you use it for?

In my case it was a web application that is based on the flask framework for backend and serves basic html-templates to the frontend. My goal was to add a URL endpoint that would return a PDF or SVG document containing a list of elements fetched from a database, all with a individual QR-code. I also wanted the current time, the operating username and some additional information printed on the output, for additional context.

Yet, i can image some other purposes:

  • Reports as output of a data analysis
  • batch processing of names, addresses, etc.

2. Third-party software I used

  • SVG Editor: Inkscape
  • Python libraries:
    • jinja: Default templating engine for and dependency of flask
    • segno: QR-code generator in python that is capable of SVG output
    • cairosvg: Provides the SVG-to-PDF Converter
  • Other Flask extensions that are rather optional for this but are part of the project
import json
from datetime import datetime as dt
from io import BytesIO
import cairosvg
from flask_login import current_user
from jinja2 import (
    Environment,
    FileSystemLoader,
)
import segno
...
Enter fullscreen mode Exit fullscreen mode

3. Preparing the templates

The template consists of an ordinary SVG file created with Inkscape. To insert text placeholder into the template, just create a new text object using jinja syntax. For more information about the syntax and the capabilities of jinja refer to the respective documentation.

Inkscape Screenshot. Shows the prepared template

Tip: To simpilfy the next step, create a group that contains all components of the item cell that later grows the hole grid

To save some space you can choose normal SVG instead of Inkscape-SVG as file format.

Generating a grid pattern in SVG code

Below the header it was intended to display a grid of items where every cell contains the QR-code and some information. To generate a grid like this, it is necessary to open the file in a text editor and insert some additional jinja code.

As the documentation states, we can loop over items of a list same as in native python.
To display the elements in a grid, we need to separate them into the respective rows using this loop statement:

{%for row in range((material_list|length/4)|round(method='ceil')|int)%}
...
{%endfor%}
Enter fullscreen mode Exit fullscreen mode

Here, row holds the current row number by taking the length of the whole list, dividing it by the grid's width (4 in this case) and ceiling it. More information on the usage of jinja's filters can be found here.

Inside of the above loop, we want to loop over the items of the respective row witch we can fetch by material_list[row: row+4] (Here, 4 also represents the grid's width).
The final nested loop statement looks like this:

{%for row in range((material_list|length/4)|round(method='ceil')|int)%}
    {%for m in material_list[row:row+4]%}
        ...
    {%endfor%}
{%endfor%}
Enter fullscreen mode Exit fullscreen mode

To change the position of the content for every item, we need some maths.
Given the fact, that we know the coordinates and size of the item in the upper left corner of the grid, we can use the transform tag of the SVG elements and some jinja logic to shift the other copies of the cell to the side.

(If you did not grouped the elements as written above, you will need to apply this step for every element.)

Edit the attribute section of the outer g element to look like this:

<g
       id="g354{{loop.index}}"
       {% if row is gt(0)%}
       transform="translate({{48.0*(loop.index-1)}}, {{25.0*reihe}})"
       {%elif loop.index is gt(1) %}
       transform="translate({{48.0*(loop.index-1)}})"
       {%endif%}>
       ...
</g>
Enter fullscreen mode Exit fullscreen mode

Here, 48.0 and 25.0 are width and height of one cell, so for the very upper left cell there won't be any transformations applied at all while the other cells are shifted to their respective coordinates relative to position of the first cell. Probably you could implement a margin by slightly increasing those measures, but I have not tested this.

Integration of QR-Codes

For generating the QR-codes you can use the segno library. Segno is capable of exporting the images in SVG format by calling segno.QRCode.svg_inline(). This returns a string that can be stored with the elements and forwarded to the template.

4. The Python code

In order to import it in another module, the program is implemented in two classes, called SVGGenerator and PDFGenerator. Since the latter inherits most of the methods from SVGGenerator, let's focus on this first:

class SVGGenerator():

    def __init__(self):
        self.jenv = Environment(loader=FileSystemLoader(template_folder_path))
Enter fullscreen mode Exit fullscreen mode

As you can see above, the __init__ method just initialises the jinja Environment. More information on this can be found in the jinja documentation.

The core functionality is implemented in generate_svg(). It takes a list as argument and returns the raw SVG code as string.

def generate_svg(self, elements: list = None) -> str:
    ...
Enter fullscreen mode Exit fullscreen mode

Next, we loop over the list and add the QR code to every element in the list.

for el in elements:
    code_string = f"{el.Category.name}/{el.id}\nQR-Code created: {dt.now():%d.%m.%Y %R}\nBy {current_user.name} ({current_user.username})"
    code_string = code_string + " " * (130 - len(code_string))
    el.qrcode = segno.make(content=code_string, micro=False).svg_inline(scale=3.2)
Enter fullscreen mode Exit fullscreen mode

To make every code be the same size, fill up the string to a certain number of symbols (130 in this case)

Afterwards the rendered template is returned. The following is the complete generate_svg() function.

def generate_svg(self, elements: list[Material] = None, template_id: int = None) -> str:
        for m in elements:
                code_string = f"{el.Category.name}/{el.id}\nQR-Code created: {dt.now():%d.%m.%Y %R}\nBy {current_user.name} ({current_user.username})"
                code_string = code_string + " " * (130 - len(code_string))
                el.qrcode = segno.make(content=code_string, micro=False).svg_inline(scale=3.2)
        template_info = self.all_templates.get(template_id)
        template = self.jenv.get_template(template_info.get("filename"))
        return template.render(username=current_user.name, dt=dt, material_list=elements)
Enter fullscreen mode Exit fullscreen mode

5. PDF generation

To convert SVG to PDF, I make use of the cairosvg library:

class PDFGenerator(SVGGenerator):

    def generate_pdf(self, elements: list[Material] = None, template_id: int = None) -> BytesIO:
            svg = self.generate_svg(elements=elements template_id=template_id)
            pdf = cairosvg.svg2pdf(bytestring=svg.encode("utf-8"))
            return BytesIO(pdf)
Enter fullscreen mode Exit fullscreen mode

6. Flask integration

To return the results as http response, I added a route to my flask code:

from .export.file_generators import (
    SVGGenerator,
    ExportError,
    PDFGenerator,
)

...
@api.route("/export", methods=['POST'])
@login_required
def export():
    if request.method != 'POST':
        abort(405) # Return Method Not allowed
    if not isinstance(request.json.get('element_ids'), list):
        abort(404) # Return Not found
    element_ids = request.json.get("element_ids")
    try:
        # Decide between pdf and svg output
        if request.args.get("type") == "pdf":
            generator = PDFGenerator()
            pdf = generator.generate_pdf(Element.query.filter(Element.id.in_(element_ids)).all())
            return send_file(pdf, mimetype="application/pdf") # pdf needs to be send with send_file()
        else:
            generator = SVGGenerator()
            return generator.generate_svg(Element.query.filter(Element.id.in_(element_ids)).all()) # svg can be displayed in the browser
    except ExportError as e:
        return {"success": False, "msg": e.args[0]}
Enter fullscreen mode Exit fullscreen mode
💖 💪 🙅 🚩
swssl
Simon

Posted on January 8, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related