How to dynamically generate graphics and PDFs using Python an jinja
Tags: python, tutorial, webdev, qrcode
Published: 2023-01-08 11:03:31

A few days ago I was searching the internet for a guide on how to use the jinja library to enable a [flask](https://github.com/pallets/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](https://inkscape.org/de/)
+ Python libraries:
  + [jinja](https://github.com/pallets/jinja): Default templating engine for and dependency of flask
  + [segno](https://github.com/heuer/segno): QR-code generator in python that is capable of SVG output
  + [cairosvg](https://github.com/Kozea/CairoSVG): Provides the SVG-to-PDF Converter
+ Other Flask extensions that are rather optional for this but are part of the project
  + [flask-sqlalchemy](https://github.com/pallets-eco/flask-sqlalchemy): ORM for database access
  + [flask-login](https://github.com/maxcountryman/flask-login): Provides access to the current username

```Python
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
...
```

## 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](https://jinja.palletsprojects.com/en/latest/templates/).


![Inkscape Screenshot. Shows the prepared template](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ip1t6njsqusor7e6ro4w.png)

> **Tip:** To simpilfy the next step, [create a group](https://inkscape-manuals.readthedocs.io/en/latest/grouping.html) 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](https://jinja.palletsprojects.com/en/latest/templates/#for), 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:

```jinja
{%for row in range((material_list|length/4)|round(method='ceil')|int)%}
...
{%endfor%}
```
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](https://jinja.palletsprojects.com/en/latest/templates/#filters).

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:

```jinja
{%for row in range((material_list|length/4)|round(method='ceil')|int)%}
    {%for m in material_list[row:row+4]%}
        ...
    {%endfor%}
{%endfor%}
```
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](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/transform#translate) 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:

```xml
<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>
```
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:

```Python
class SVGGenerator():

    def __init__(self):
        self.jenv = Environment(loader=FileSystemLoader(template_folder_path))
```

As you can see above, the `__init__` method just initialises the jinja Environment. More information on this can be found in the [jinja documentation](https://jinja.palletsprojects.com/en/latest/api/#basics).

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

```Python
def generate_svg(self, elements: list = None) -> str:
    ...
```
Next, we loop over the list and add the QR code to every element in the list.

```Python
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)
```
> 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.

```Python
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)
```

## 5. PDF generation

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

```Python
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)
```

## 6. Flask integration

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

```Python
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]}
```