Create selectable PDF files with Lambda Python and ReportLab
Tags: aws, python
Published: 2023-04-23 23:02:41


### Motivation

There are times when I need to create PDF files for reporting from data points using an AWS Lambda function.

This blog post was inspired by [a blog post from Classmethod (in Japanese)](https://dev.classmethod.jp/articles/create-pdf-report-with-lambda-weasyprint-jinja/), which demonstrates how to create PDF files with a Lambda function using WeasyPrint + Jinja. In this post, I want to share an alternative approach to create PDF files with Python Lambda using the ReportLab library.

Here is the image of the PDF file.
![PDF sample](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ztjctgwr8a3oazbl5l87.png)

(Note: We want the text in the PDF files to be selectable and copyable, so we won't be using Matplotlib this time.)

### Architecture

![Architecture of creating PDF files](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7z69c5z7bx6xdv633mux.png)

### Step by step deployment

Note: Python 3.9

#### Create S3 Bucket and SNS Topic

- Create the S3 Bucket
  - The default setting is OK. (No public access).
  - **your-bucket-name** will be used later.
  - [aws-doc](https://docs.aws.amazon.com/AmazonS3/latest/userguide/creating-bucket.html)

- Create the SNS Topic
  - **arn** of the topic will be used later.
  - [aws-doc](https://docs.aws.amazon.com/sns/latest/dg/sns-create-topic.html#create-topic-aws-console)


#### Create Lambda Layer

Start from any directory on your terminal.

Install the `reportlab` library to a directory named python (Note: the name must be python).

```
pip install reportlab -t python
```

Zip `python` directory with the name `my_lambda_layer.zip` (Note: arbitrary name).

```
zip -r my_lambda_layer.zip python
```

Upload this zip file in [the step of creating a layer](https://docs.aws.amazon.com/lambda/latest/dg/configuration-layers.html#configuration-layers-create).

You will use the arn of the layer later.

#### Create Lambda function

On the Lambda console, Create Lambda (Python).

##### Configuration
- General configuration
  - The default 3 sec timeout might be short. Change it a little longer, like 10 sec.

- Environmental variables
  - SNS_ARN: arn of the topic you have
  - BUCKET_NAME: name of the bucket you have

- IAM Policy
  - Select Permission, and Role name. Add an inline policy like the one below for sending SNS and uploading a file to S3. Change the arn of your SNS topic and S3 bucket name.
  - Make sure you don't change the IAM policy while your pre-singed URL will be used.

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "sns:Publish",
            "Resource": "arn:aws:sns:REGION:ACCOUNT_ID:your-sns-topic-arn"
        },
        {
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObject",
                "s3:ListBucket"
            ],
            "Resource": [
                "arn:aws:s3:::your-bucket-name/*",
                "arn:aws:s3:::your-bucket-name"
            ]
        }
    ]
}
```

#### Code

##### Add Lambda Layer

In the Code tab, add the Layer you've created. Specify the ARN of the layer you've created.

##### Write code

- In this code
  - Percentage of the year passed and left days are calculated.
  - The gauge for the percentage is generated with `draw_gauge()`.
  - Create an A4 PDF file. Upload it to the S3 bucket.
  - Set the pre-assigned URL to the file in the bucket
  - Send the URL with SNS.
- As the unit I used `mm`. `inch` is available by changing `unit = mm` to `inch`, but some modifications are needed with `x`, `y`.
- `on_grid` parameter in `create_pdf_days_passed_left()` is for designing layouts. (See Appendix)

```py
import io
import os
import uuid
from datetime import datetime

import boto3
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.units import inch, mm
from reportlab.pdfgen import canvas


def percentage_of_year_passed(start_of_year, end_of_year, now):
    total_days = (end_of_year - start_of_year).days + 1
    days_passed = (now - start_of_year).days + 1
    percentage_passed = int((days_passed / total_days) * 100)
    return percentage_passed


def calculate_days():
    now = datetime.now()
    start_of_year = datetime(now.year, 1, 1)
    end_of_year = datetime(now.year, 12, 31, 23, 59, 59)

    percentage_passed = percentage_of_year_passed(start_of_year, end_of_year, now)
    days_left = (end_of_year - now).days + 1

    return percentage_passed, days_left


def draw_grid(c, width, height, unit):
    step = 25 if unit == mm else 1
    c.setLineWidth(1 / unit)
    c.setStrokeColor(colors.grey)
    c.setFont("Helvetica", 25 / unit)

    for i in range(0, int(height) + 1, step):
        c.line(0, i, width, i)
        c.drawString(0, i, f"{i}")

    for i in range(0, int(width) + 1, step):
        c.line(i, 0, i, height)
        c.drawString(i, 0, f"{i}")


def draw_gauge(c, x, y, radius, percentage, base_color, fill_color):
    """
    Draw gauge with two arches: base 180 deg arch and filled with percentage.
    """
    # Draw base half-circle
    c.setLineWidth(15)
    c.setStrokeColor(base_color)
    c.arc(x - radius, y - radius, x + radius, y + radius, 180, -179.9)

    # Draw filled half-circle
    c.setStrokeColor(fill_color)
    gauge = -180 * percentage / 100 + 0.01
    c.arc(x - radius, y - radius, x + radius, y + radius, 180, gauge)


def draw_str(c, x, y, item, font_size, font_color="black"):
    c.setFont("Helvetica", font_size)
    c.setFillColor(font_color)
    c.drawCentredString(x, y, f"{item}")


def create_pdf_days_passed_left(page_size, x, y, radius, on_grid = False):

    unit = mm  # mm or inch
    buffer = io.BytesIO()
    c = canvas.Canvas(buffer, pagesize=page_size)
    c.scale(unit, unit)

    if on_grid:
        width = page_size[0] / unit
        height = page_size[1] / unit
        draw_grid(c, width, height, unit)

    base_color = colors.HexColor("#c8c8c8")  # light grey
    fill_color = colors.HexColor("#1f77b4")  # light blue

    percentage_passed, days_left = calculate_days()

    message = f'Created at {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}'
    draw_str(c, x + 50, y + 55, message, 8)
    draw_gauge(c, x, y, radius, percentage_passed, base_color, fill_color)
    draw_str(c, x, y, f"{percentage_passed}%", font_size=20)
    draw_str(c, x, y - 25, "This year passed", font_size=10)

    draw_str(c, x + 100, y, days_left, font_size=20, font_color="green")
    draw_str(c, x + 100, y - 25, "days left", font_size=10)

    c.showPage()
    c.save()
    buffer.seek(0)
    return buffer


def generate_n_char_id(n: int):
    unique_id = uuid.uuid4()
    short_id = str(unique_id)[:n]
    return short_id


def generate_presigned_url(bucket_name, object_name, expiration_sec=3600):
    s3_client = boto3.client("s3")
    response = s3_client.generate_presigned_url(
        "get_object",
        Params={"Bucket": bucket_name, "Key": object_name},
        ExpiresIn=expiration_sec,
    )

    return response


def send_sns_message(topic_arn, message):
    sns_client = boto3.client("sns")
    response = sns_client.publish(
        TopicArn=topic_arn,
        Message=message,
    )

    return response


def lambda_handler(event, context):
    s3 = boto3.client("s3")
    bucket_name = os.environ["BUCKET_NAME"]
    sns_topic_arn = os.environ["SNS_ARN"]

    dt = datetime.now().strftime("%Y%m%d-%H%M%S")
    uuid = generate_n_char_id(8)
    filename = f"demo-{dt}-{uuid}.pdf"

    page_size = landscape(A4)
    x, y = 100, 125  # Center of gauge. Use this point as anchoring
    radius = 40  # radius of gauge

    pdf_buffer = create_pdf_days_passed_left(page_size, x, y, radius)
    s3.upload_fileobj(pdf_buffer, bucket_name, filename)

    url = generate_presigned_url(bucket_name, filename, expiration_sec=3600)
    if url:
        print(f"Generated presigned URL: {url}")
        message = f"Download the PDF file here: {url}"
        send_sns_message(sns_topic_arn, message)
    else:
        print("Failed to generate presigned URL")

    return {
        "statusCode": 200,
        "body": "PDF created and uploaded to S3. Presigned url has sent with SNS.",
    }
```

### Result

You'll receive an email to download URL.

![email example](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/a6zsjc9kxpc1mxvzrx9t.png)



### Summary

I've demonstrated creating a PDF file using a Lambda Python function, using Reportlab library.


### Appendix

This is the example PDF file with `unit = mm` and `grid_on = True`, useful for designing the page. The dimension of the A4 landscape is 210 x 297 mm.

![grid example](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/g6waz0u49rm6d92bqcl2.png)