😸

HTMLからPDF生成Lambdaを作る

2024/05/04に公開

はじめに

昨今ペーパーレスペーパーレス言ってますね。と言いつつも紙の文化はまだまだ健在ですが。
今回はHTMLで作成された請求書をPDFにしてダウンロードする機能をAWS Lambdaに実装したいと思います。
Excelとかで請求書はよく使われるイメージですが、HTMLのほうが管理も楽でデザインも柔軟なので個人的には、というかエンジニア的には推していきたいところ。

対象読者

  • AWS Lambdaを使ったことがある方
  • Pythonを使ったことがある方
  • Dockerを使ったことがある方
  • AWS Lambdaで日本語HTMLのPDFを生成したい方

技術スタック

  • Python 3.11
  • AWS Lambda
  • AWS SAM CLI
  • Jinja2
  • WeasyPrint
  • Docker

概要

FlaskのHTMLテンプレートエンジンとしても採用されているJinja2ライブラリを使用して、請求書HTMLを作成します。
作成したHTMLからWeasyPrintライブラリを使用してPDFを生成します。
AWS Lambdaの関数URLを使用したAPIとして実装しています。

全体的な流れとしては

  1. 請求書情報を取得
  2. 請求書HTMLに1の情報を埋め込み
  3. 2をPDFに変換
  4. 3をBase64変換してレスポンスを返却

今回のソースコードはGithubに置いてあるので動かしてみたい方はぜひ。
SAM CLIですぐデプロイできます。
https://github.com/Tomoaki-Moriya/html-to-pdf-lambda

コード解説

InvoiceService

InvoiceServiceは、顧客と会社の情報を持つInvoiceオブジェクトを生成します。
今回はサンプルなのでハードコーディングしていますが、想定されるケースはアプリで使っているデータソースから請求書情報を取得するイメージです。

src/invoice_service.py
from dataclasses import dataclass


@dataclass(frozen=True)
class Item:
    name: str
    price: int
    quantity: int


@dataclass(frozen=True)
class Customer:
    name: str
    postal_code: str
    address: str


@dataclass(frozen=True)
class Company:
    name: str
    postal_code: str
    address: str
    invoice_number: int
    registration_number: str
    bank_name: str
    bank_branch_name: str
    bank_no: str


@dataclass(frozen=True)
class Invoice:
    issued_date: str
    customer: Customer
    company: Company
    items: list[Item]
    total: int


class InvoiceService:

    def get(self) -> Invoice:
        customer = Customer(
            name="株式会社サンプル",
            postal_code="123-4567",
            address="東京都新宿区1-2-3"
        )
        company = Company(
            name="株式会社PDF",

HtmlService

HtmlServiceは、Invoiceオブジェクトを受け取り、Jinja2テンプレートを使用してHTMLを生成します。やってることはasdictにして渡してるだけです。
HTMLファイルはsrc/template配下に置いてあり、そこから今回の請求書のHTMLであるinvoive.htmlを読み込んでいます。
number_formatは数値をカンマ区切りにする関数で、Jinja2のフィルターに登録して、金額を見やすくするために使います。

src/html_service.py
from dataclasses import asdict
from typing import Final
from jinja2 import Environment, FileSystemLoader

from invoice_service import Invoice


def number_format(price: int) -> str:
    return "{:,}".format(price)


class HtmlService:
    def __init__(self):
        self._env: Final = Environment(loader=FileSystemLoader("./templates"))
        self._env.filters["number_format"] = number_format

    def render_invoice(self, invoice: Invoice) -> str:
        template = self._env.get_template("invoice.html")
        params = asdict(invoice)
        html = template.render(params)
        return html

PdfService

PdfServiceはHTML文字列を受け取ってPDFのバイナリを作成して返却します。
WeasyPrintのHTMLクラスを使って文字列を変換する際に、引数としてファイルパスを渡せばファイルにバイナリを書き込み、渡さなければバイナリを返却してくれます。
今回はファイルには書き込まないのでNoneチェックだけして返しています。

src/pdf_service.py

from weasyprint import HTML


class PdfService:

    def create_from_html(self, content: str) -> bytes:
        pdf = HTML(string=content,
                   encoding="utf-8").write_pdf()
        if not pdf:
            raise ValueError("PDF generation failed")
        return pdf

lambda_handler

上記で説明した各種サービスを使ってPDFを生成してクライアントにレスポンスを返却します。
ヘッダー部分の'Content-Disposition': 'attachment; filename=invoice.pdf'
はハードコーディングしていますが、filename=invoice.pdfの部分はダウンロードするPDFによって可変にするのがいいと思います。

src/app.py
import base64

from html_service import HtmlService
from invoice_service import InvoiceService
from pdf_service import PdfService

html_service = HtmlService()
invoice_service = InvoiceService()
pdf_service = PdfService()


def lambda_handler(event, context):
    invoice = invoice_service.get()
    html = html_service.render_invoice(invoice)
    pdf = pdf_service.create_from_html(html)
    pdf_base64 = base64.b64encode(pdf).decode("utf-8")
    return {
        "statusCode": 200,
        "headers": {
            "Content-Type": "application/pdf",
            "Content-Disposition": "attachment; filename=invoice.pdf"
        },
        "body": pdf_base64,
        "isBase64Encoded": True
    }

Dockerfile

Dockerfileでは、AWS LambdaのPython3.11イメージをベースに使っています。
以下の処理を行います。

  • pangoのインストール
    • WeasyPrintを使う上でpangoが必要です
    • pangoは多言語を処理するためのライブラリみたいです。
  • 日本語フォントのインストール
    • 今回はBIZ UDGothicを使用します
    • /usr/share/fonts配下に配置することでfontconfigが認知します
  • ソースコードをLambdaのタスクルートにコピー
  • 依存モジュールをpipでインストール
FROM public.ecr.aws/lambda/python:3.11

RUN yum -y update && yum install -y pango curl
RUN curl https://raw.githubusercontent.com/googlefonts/morisawa-biz-ud-gothic/main/fonts/ttf/BIZUDGothic-Bold.ttf -o /usr/share/fonts/BIZUDGothic-Bold.ttf \
  & curl https://raw.githubusercontent.com/googlefonts/morisawa-biz-ud-gothic/main/fonts/ttf/BIZUDGothic-Regular.ttf -o /usr/share/fonts/BIZUDGothic-Regular.ttf \
  & curl https://raw.githubusercontent.com/googlefonts/morisawa-biz-ud-gothic/main/fonts/ttf/BIZUDPGothic-Bold.ttf -o /usr/share/fonts/BIZUDPGothic-Bold.ttf \
  & curl https://raw.githubusercontent.com/googlefonts/morisawa-biz-ud-gothic/main/fonts/ttf/BIZUDPGothic-Regular.ttf -o /usr/share/fonts/BIZUDPGothic-Regular.ttf
COPY src $LAMBDA_TASK_ROOT/
WORKDIR $LAMBDA_TASK_ROOT
RUN pip install -r requirements.txt

CMD ["app.lambda_handler"]

デプロイ

AWS SAM CLIを使用してデプロイします。
Docker Imageを使用したLambdaのデプロイで、特に解説するほどではないので今回は割愛します。
READMEを参照していただければと思います。

sam deployでデプロイ後、出力された関数URLをコピーしてブラウザでアクセスしてみましょう。

CloudFormation outputs from deployed stack
------------------------------------------------------------------------------------------------------------------------------------------------------------------
Outputs
------------------------------------------------------------------------------------------------------------------------------------------------------------------
Key                 FunctionUrl
Description         -
Value               https://xxxxxx.lambda-url.xxxxxx.on.aws/
------------------------------------------------------------------------------------------------------------------------------------------------------------------

関数URLにアクセス

以下のPDFがダウンロードされました。
HTMLGithub Copilot君に適当につくってもらった質素な請求書です。

最後に

今回は簡単なチュートリアル目的だったので関数URLを使用していますが、実際にアプリに組み込んだりと考えるとAPI GatewayなどをCognitoのオーソライザーなどで認証つけてあげれば結構Productionでも使える代物になるんじゃないかと思いました。

帳票出力系は意外とアプリででてくる部分だと思いますのでこういうミニマムな解決策もありかと。
みなさんも素敵なペーパーレスライフをお送りください。

Discussion