Create selectable PDF files with Lambda Python and ReportLab

shimo_s3

shimo

Posted on April 23, 2023

Create selectable PDF files with Lambda Python and ReportLab

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

(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

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
  • Create the SNS Topic

    • arn of the topic will be used later.
    • aws-doc

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
Enter fullscreen mode Exit fullscreen mode

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

zip -r my_lambda_layer.zip python
Enter fullscreen mode Exit fullscreen mode

Upload this zip file in the step of creating a layer.

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.
{
    "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"
            ]
        }
    ]
}
Enter fullscreen mode Exit fullscreen mode

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)
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.",
    }
Enter fullscreen mode Exit fullscreen mode

Result

You'll receive an email to download URL.

email example

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

💖 💪 🙅 🚩
shimo_s3
shimo

Posted on April 23, 2023

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

Sign up to receive the latest update from our blog.

Related