ちょっと話題の記事

画面がなくデータだけある状態からAWS LambdaでPDFのレポートを作成したくて方法を調査して最終的にWeasyPrint + Jinjaで生成したので手順をまとめてみた

AWS LambdaでPDFの1枚レポートを作るために、あれやこれや調査をして実装してみました。調査内容と具体的な実現手順をまとめました。
2023.03.09

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは、臼田です。

みなさん、レポート作ってますか?(挨拶

今回は、タイトルも前置きも長いですが、AWS Lambdaを使ってPDFのレポートを作りたい!というところから実現方法について調査して、結果的にWeasyPrintとJinjaを採用して実現したので、その調査した内容や実現方法を具体的な手順と一緒に紹介します。

ちなみに、この手順でこんな感じのワンページレポートを作成します。今回はAWSアカウント単位のSecurity Hubの月次レポートです。(内容が少ないので余白が大きいですが)

やりたいことの背景

長い話になるので、PDF作るところだけ知りたい方は実現方法のところまで飛ばしてください。

きっかけはふと、今作っているシステムで「AWS Lambdaで1枚ペラのサマリのレポートをPDFで出力したいなー」って思ったところからです。このシステムはフロントの画面が無く、レポートにしたいデータだけ存在している状態でした。

通常フロントの(例えばHTMLの)画面があれば、各種ライブラリやheadless chromeなどを利用してPDFを作成する手法が検索で色々見つかります。ただ今回はしばらくフロントの画面を作る予定も無いので、どういう手法がいいかなーということで技術調査をすることにしました。

技術調査メモ

PDFのレポートを実現する方法は大きく2通りあると考えています。

  • エクセルやスプレッドシートなどの表計算ソフトでレイアウトを整えてPDF化
  • HTMLをレンダリングしてPDF化

このあたりの記述は検索して調べた内容がメインなので、実現可能性が推測に基づくもであることをご了承ください。あとはかなり雑多なメモです。

エクセル路線

ざっくりメリット・デメリットをまとめるとこんな感じになると思います。

メリット

  • 表計算ソフトによるレイアウト構成を得られる
  • グラフが簡単に作れる
  • (比較的HTMLと比べて)調整できる人が多い、操作難易度が低い

デメリット

  • エクセル・Googleスプレッドシートなど、それぞれを動かすプラットフォームが必要
  • 上記のライセンスが必要で、費用・ライセンス管理コストに影響する
  • デザインが固くなりがち(レポートなので何ら問題ないけど)

Microsoft Excelを利用する場合はOfficeのライセンスが必要になり、AWSだと最近ライセンス入りAMIが出てきました。

これを使うとなったらEC2で仕組みを動かすことになるので取り回しが効かず、避けたいと私は感じました。AWS BatchやECSでライセンスのあるイメージを動かすこともできなさそうです。

エクセルではないですが、C#で .NET CoreベースのLambda関数を利用したPDF作成が検索で出てきましたが、DioDocsという製品を活用したものでまた少し違った感じでした。

Microsoft OfficeではなくLibreOfficeのheadlessを利用したLambda関数で実現するものもありました。記事ではgoで実現していますが、他のランタイムでも利用できそうです。Microsoft製ではないにしろ互換のものなのでそこそこ期待できそうな気もしますが、個人的に乗り気になりませんでした。ただ実現したいものとしては比較的これが近いですね。

Googleスプレッドシートの場合、GAS(Google Apps Script)を利用して連携する形となり、AWSとGASの間の連携を片方向でやるか、双方向でやるかなどアーキテクチャを考える必要があるのと、AWSの外側のスロットリングの管理やメンテナンスが必要になるので考慮事項が結構増えます。今回はAWSの中に閉じた管理をしたいと感じたので、あんまり深く調べずにこちらは断念しました。スケールするような仕組みを考慮する場合には、どれくらいのリクエストをさばけるのか確認する必要があります。

各種方法にデメリットは色々あるものの、表計算ソフトの構成能力とグラフ化能力の恩恵はでかいですし、扱える人が多く扱う難易度が低くレポート表現を調整するハードルが比較的低いのは強みです。

HTML路線

HTMLで扱う場合のメリット・デメリットは以下が考えられます。

メリット

  • (エクセルと比較して)表現が柔軟でレイアウトの自由度が高い
  • テンプレートなど参考になる情報がインターネット上に多い
  • ライセンスや実行環境を気にする必要があまりない
  • 将来的にフロントの画面を作る場合使い回せる

デメリット

  • (元のソースがない場合)デザインやレイアウトに気を使う必要がある
  • (エクセルと比較して)デザインのメンテナンス敷居が高い
  • HTMLのレンダリングエンジンによりサポートしていないCSS表現などがある

すでにPDFレポートにしたい画面などがある場合、何も迷う必要はなくこれをheadress chrome等でアクセスしてPDF化すればいいと思います。他にも、どちらにしろ必要になりがちなBIツールを導入して、これを使う方法もあるでしょう。レポートが内部的な利用にとどまり、データに複数のテナントのものが混入しても何も問題なければ、BIツールの画面をPDFにすることはあまり問題なさそうです。一方で、顧客に提示するレポートになると少し難しくなったりします。ツールや画面によっては、プルダウンリストやフィルターなどでレポート対象の情報を絞る機能などが掲載されることや、誤操作・誤プログラムにより別テナントのデータが混入するなどの致命的な問題に繋がりやすいため、かなり慎重に検討する必要があります。

最近ではAmazon QuickSightにページ単位に分割したレポートの生成機能が出てきました。BIツールのレポート生成機能に頼れるのであれば、この課題はマネージドな仕組みで解決が可能で非常にいいことです。この機能は月額$500からです。

他にもLookerでも顧客単位のレポート生成機能があるようです。

何かしらの仕組みでBIはやっていくことにはなりがちですから、これらを採用するのも十分選択肢に入れていいですが、今回はだいぶ小規模に始めたかったため、見送りました。お金の力は正義。

他にもHTMLに限らなければパッとPythonでPDFを作る、と調べたりするとAnvilというサービスが出てきました。CSVを流し込んでレポートを作ることができます。どこまでレイアウトをカスタマイズできるかはわかりません。この手のサービスはわんさか出てくるので、要件やコスト感、使いたいワクワク感などが重なれば頼ったほうがいいでしょう。自前でやるのは辛い道のりです。なんとなくやりたい表現があったのと、もっともっとコストを抑えて、外部サービスに依存したくなかったので今回はこれも見送っています。

HTMLからPDFを生成する方法を調べているとこちらのstackoverflowの参考情報も出てきました。翻訳引用します。

レポートを容易にする非常に多様なパッケージがすでに大量にあるようです。いくつか例を挙げると、xhtml2pdfweasyprintdjango-wkhtmltopdfがあります。

今回はこの中からWeasyPrintを採用しました。主な理由は下記のそのまま手順を実施すればとりあえず出来上がるようなサンプルコードや情報がでてきたから、です。

Creating PDF Reports with Pandas, Jinja and WeasyPrint - Practical Business Python

手探りで実現方法を探っているときは、ちゃんと実現するまでの手順が揃っていることにすごくメリットがあります。実現可能性のイメージが一番つくためです。逆にこれがないと、着手しても挫折することが多いです。この手順を見つけた後も周辺調査をもう少ししましたが、まずこれでやってみようと判断して今に至ります。なので、私もこの方法が最適であると思っているわけではありません。

WeasyPrintは元々ページングなどの処理が組み込まれているため、1枚のA4サイズのようなレポートをイメージしていた私にはぴったりでした。

あとはレポートのデザインですが、HTMLのものはかなりデザインにこだわったものも多く、Report HTML Templatesというサイトなど、デザインパターンの参考になりそうなものも検索で沢山見つかりました。HTMLに関係ないものでも、スライドテンプレートも見つかり、これはこれで参考にはなりました。

あとはグラフ表現として、HTMLとCSSで頑張ることもできますが、例えばPythonのmatplotlibでもいろんな表現ができるので、これを使うのもありです。複雑なグラフなら特にこちらを使うほうがいいかもしれません。matplotlibを使う方法を延長すると、レポート全体をほぼ画像で構成することもできます。全部画像にするとフォントや表示環境の違いによる崩れの心配がない強みがあります。ただ、レポート内のテキストのコピーができないなどデメリットもあるので、これも一長一短です。今回はコピーがしたいので、matplotlibを使うにしてもせめてグラフ表現のみにしたいと考えましたが、最終的にHTMLとCSSによるグラフも十分参考になるものがたくさんあったので、これを使っています。フロント画面ではjavascriptやcanvas要素でグラフを表現する場合もありますが、その場合PDF化するためのHTMLレンダリングエンジンでそれが評価できないといけなかったりしますので、また一段回制約が出ることも気にする必要があるでしょう。

共通課題

いずれの方式もパッといい感じに実現する方法はなく、仕組みを維持することにそれなりのコストが必要になりそうな印象です(特に金銭コストを削った場合)。また、ローカルのPCと違って日本語のフォントがLambda環境では存在せずフォントの導入とそのライセンスを気にする必要があること、PDF自体も環境のフォントにより表示に差異が出ることから、PDFをアウトプットとすることによるフォントの課題も共通的について回ります。このあたりは常に閲覧する端末での表現の差を気にしているフロントのエンジニアやデザイナーの方のほうが詳しいかもですね。PDFが重くなりますがフォントを全部埋め込むことも検討の余地があるかもです。

つまりいずれにせよ、PDFでレポートを生成する仕組みを作る段階で、結構なコストになると思ったほうがいいです。

PDF以外でレポートを生成して届けることも視野に入れて検討すべきでしょう。BIツールやレポーティングツールに頼れるならそれに越したことはないです。また、AWS Lambdaにこだわらず、コンテナやAWS Batchなど、別の仕組みも検討しましょう。

今回の実現方法

今回は下記手順をベースにWeasyPrintでHTMLのPDF化を、JinjaでHTMLの生成を行います。

Creating PDF Reports with Pandas, Jinja and WeasyPrint - Practical Business Python

上記からざっくりした仕組みの図を引用します。

WeasyPrintは現状でバージョンが58.1であり、そこそこ長いこと使われていること、ドキュメントがかなりしっかりしていることもあり採用してもいいと考えました。

JinjaはPythonで利用できるHTMLのテンプレートエンジンであり、こちらも参考情報が色々出てくること、HTTPサーバーを動かす必要がないことなどから採用をしてもいいと考えました。こちらに利用できそうなJinjaのテンプレートがたくさん見つかったのも動機の1つです(結局使いませんでしたが)。余談ですが、レポートのUIについて調べる時に、「ダッシュボード」も検索ワードに使うと結構管理系ダッシュボードに利用されるグラフなども一緒に見つかるので参考になりました。

今回は元の手順をベースに、必要なライブラリを入れたLambdaレイヤーを作成し、Lambda関数にテンプレートとなるHTMLとCSSを含め、S3からレポートのデータとなるxlsxをダウンロード、成果物のPDFをS3に保存することにしました。Lambdaレイヤーの作成のためにdockerを利用しています。

やってみた

手順はざっくり以下の通りです。

  • S3バケット作成
  • ローカルでWeasyPrintの実験とデザインの調整
  • WeasyPrint等のLambdaレイヤー作成
  • Lambda関数作成
  • レポート生成

S3バケット作成

今回はLambdaレイヤーのデータサイズがそこそこになったのもあり、Lambdaレイヤーのアップロード用と、PDF出力用としてまとめて1つのS3バケットを用意します。これは要件に応じて柔軟にどうぞ。最悪なくてもいいです。

AWSマネジメントコンソールでS3にアクセスし、バケットの作成をします。名前を適当に。その他の設定はデフォルトで問題ありません。

ローカルでWeasyPrintの実験とデザインの調整

まずはローカル環境でWeasyPrintを動かして、仕組みに慣れるとともに、デザインをしていきます。

セットアップするものは概ね以下の通りです。WeasyPrintのセットアップ要件もご確認ください。

  • Python
    • WeasyPrint
    • pandas
    • NumPy
    • Jinja2
    • openpyxl
  • OS
    • Pango

PythonのライブラリではないPangoを利用することも、Lambdaレイヤーを利用する大きな理由の1つです。Pangoは多言語を扱うライブラリです。

私の環境はpyenv + pyenv-virtualenvを使っているので、以下のように新しいpyenv環境を切り出します。Python3.10.7を利用していますが、深い意味は無く手元にあるので使いました。利用するLambdaのランタイムはPython3.8なので、あんまり良くないです。

# ディレクトリを作成しておく
pyenv virtualenv 3.10.7 weasyprint
pyenv local weasyprint

続いて各種ライブラリをセットアップします。下記内容のrequirements.txtを作成して適用します。

Brotli==1.0.9
cffi==1.15.1
cssselect2==0.7.0
et-xmlfile==1.1.0
fonttools==4.38.0
html5lib==1.1
Jinja2==3.1.2
MarkupSafe==2.1.2
numpy==1.24.2
openpyxl==3.1.1
pandas==1.5.3
Pillow==9.4.0
pycparser==2.21
pydyf==0.5.0
pyphen==0.13.2
python-dateutil==2.8.2
pytz==2022.7.1
six==1.16.0
tinycss2==1.2.1
weasyprint==58.0
webencodings==0.5.1
zopfli==0.2.2

これをセットアップします。

pip3 install -r requirements.txt

Pangoをセットアップします。私の環境はMacなのでbrewでセットアップします。

brew install pango

環境が整ったらPythonコードを書いていきます。参考元のコードはこちら。ファイル名はpdf-report.pyとしています。

pdf-report.py

import os
import pandas as pd
from jinja2 import Environment, FileSystemLoader
from weasyprint import HTML


data_key = 'securityhub_data.xlsx'


def get_report_data():
    # intで欠損値を扱えるようにする(試験的機能)
    pd.options.mode.use_inf_as_na = True
    df = pd.read_excel(data_key, dtype={
        'AccountID': str,
        'SecurityHubScore': int,
        'LastMonthSecurityHubScore': 'Int64',
        'HighCount': int})
    return df


def save_report(account_id, html_report):
    report_key = '{0}-report.pdf'.format(account_id)
    HTML(string=html_report).write_pdf(
        report_key, stylesheets=['style.css'])


def lambda_handler(event, context):
    report_date = '2023年2月'
    df = get_report_data()
    for _, row in df.iterrows():
        # get an account data
        account_id = row['AccountID']
        securityhub_score = row['SecurityHubScore']
        # Generate angles for drawing semi-pie charts
        score_rotate = 180 * (securityhub_score/100)
        lastmonth_score = row['LastMonthSecurityHubScore']
        diff_score = '-'
        if lastmonth_score is not pd.NA:
            diff_score = '{:+}%'.format(securityhub_score - lastmonth_score)
        high_count = row['HighCount']
        report_title = 'Security Hub {0}定期レポート - {1}'.format(
            report_date, account_id)
        # create Jinja template
        env = Environment(loader=FileSystemLoader('.'))
        template = env.get_template('report_template.html')
        template_vars = {
            'title': report_title,
            'account_id': account_id,
            'score_rotate': score_rotate,
            'securityhub_score': securityhub_score,
            'diff_score': diff_score,
            'high_count': high_count}
        # Render our file and create the PDF using our css style file
        html_report = template.render(template_vars)
        save_report(account_id, html_report)


# call lambda_handler
if __name__ == '__main__':
    lambda_handler({}, {})

データ取得やPDF出力部分はLambdaにすると色々調整するので、そこだけ関数を切り出していますが、とりあえず動かすことをメインにしていますのでコードのクオリティはあしからず。データと表現のための要件ですが、intの欠損値をpandasで扱えるようにpd.read_excel時に'Int64'を指定しています(参考)

レポートの元となるxlsxのデータを作ります。テスト用に以下のようなデータを用意しました。ファイル名はsecurityhub_data.xlsxとしています。

AccountID SecurityHubScore LastMonthSecurityHubScore HighCount
090909090909 82 98 1234
123456789012 100 0

HTML、CSSを用意します。今回はAWSマネジメントコンソール風のデザインにしてみました。特にSecurity Hubのセキュリティスコア表示が良かったので真似してみました。ファイル名はそれぞれreport_template.htmlstyle.cssとしています。

report_template.html

<!DOCTYPE html>
<html>

<head lang="jp">
    <meta charset="UTF-8">
    <link rel="stylesheet" href="./style.css">
    <title>{{ title }}</title>
</head>

<body class="main">
    <section class="page">
        <h2>{{ title }}</h2>
        <nav class="security-check">
            <p class="nav-title">■セキュリティチェック</p>
            <p class="nav-info">AWS Security Hubによる各種AWSリソースの設定状況をチェックした結果です。特に高レベルの問題は0件になるように目指し、AWS Security
                Hubのユーザーガイドを参考にAWSリソースの設定の修正あるいはリスクを許容して抑制してスコアを改善しましょう。</p>
            <ol class="card-container">
                <li class="cards cards2">
                    <div class="gauge-wrapper">
                        <p class="label">セキュリティスコア</p>
                        <div class="gauge-container">
                            <div class="gauge-bg">
                                <div class="gauge"
                                    style="background-color: #3184c2; transform: rotate({{ score_rotate }}deg);"></div>
                            </div>
                            <div class="gauge-content">
                                <span class="percentage" style="color: #16191f;">{{ securityhub_score }}%</span>
                            </div>
                        </div>
                    </div>
                </li>
                <li class="cards">
                    <div class="cards-contents-wrapper">
                        <p class="label">先月との差</p>
                        <div class="cards-contents-container">
                            <p class="cards-contents font-color-status-positive">{{ diff_score }}</p>
                        </div>
                    </div>
                </li>
                <li class="cards">
                    <div class="cards-contents-wrapper">
                        <p class="label">高レベルの問題</p>
                        <div class="cards-contents-container">
                            <p class="cards-contents font-color-status-high">{{ high_count }}件</p>
                        </div>
                    </div>
                </li>
            </ol>
        </nav>
    </section>
</body>

</html>

style.css

/* HTML
  -------------------------------------------------------------- */

@page {
    size: A4;
    margin: 0 auto;
    float: none;
}

html {
    font-size: 100.01%;
}

body {
    font-size: 75%;
    color: #222;
    background: #fff;
    font-family: "Helvetica Neue", Arial, Helvetica, "IPAPGothic", sans-serif;
}

nav {
    display: block;
    margin-bottom: 8px;
}

/* Headings
  -------------------------------------------------------------- */

h1,
h2,
h3,
h4,
h5,
h6 {
    font-weight: normal;
    color: #111;
}

h1 {
    font-size: 3em;
    line-height: 1;
    margin-bottom: 0.5em;
}

h2 {
    font-size: 2em;
    margin-bottom: 0.75em;
}

h3 {
    font-size: 1.5em;
    line-height: 1;
    margin-bottom: 1em;
}

h4 {
    font-size: 1.2em;
    line-height: 1.25;
    margin-bottom: 1.25em;
}

h5 {
    font-size: 1em;
    font-weight: bold;
    margin-bottom: 1.5em;
}

h6 {
    font-size: 1em;
    font-weight: bold;
}

h1 img,
h2 img,
h3 img,
h4 img,
h5 img,
h6 img {
    margin: 0;
}


/* Text elements
  -------------------------------------------------------------- */

.left {
    float: left !important;
}

p .left {
    margin: 1.5em 1.5em 1.5em 0;
    padding: 0;
}

p {
    display: block;
    margin-block-start: 1em;
    margin-block-end: 1em;
    margin-inline-start: 0px;
    margin-inline-end: 0px;
}

.right {
    float: right !important;
}

p .right {
    margin: 1.5em 0 1.5em 1.5em;
    padding: 0;
}

a:focus,
a:hover {
    color: #09f;
}

a {
    color: #06c;
    text-decoration: underline;
}

blockquote {
    margin: 1.5em;
    color: #666;
    font-style: italic;
}

strong,
dfn {
    font-weight: bold;
}

em,
dfn {
    font-style: italic;
}

sup,
sub {
    line-height: 0;
}

abbr,
acronym {
    border-bottom: 1px dotted #666;
}

address {
    margin: 0 0 1.5em;
    font-style: italic;
}

del {
    color: #666;
}

pre {
    margin: 1.5em 0;
    white-space: pre;
}

pre,
code,
tt {
    font: 1em 'andale mono', 'lucida console', monospace;
    line-height: 1.5;
}


/* Lists
  -------------------------------------------------------------- */

li ul,
li ol {
    margin: 0;
}

ul,
ol {
    margin: 0 1.5em 1.5em 0;
    padding-left: 1.5em;
}

ul {
    list-style-type: disc;
}

ol {
    list-style-type: decimal;
}

dl {
    margin: 0 0 1.5em 0;
}

dl dt {
    font-weight: bold;
}

dd {
    margin-left: 1.5em;
}


/* Main CSS
  -------------------------------------------------------------- */

.main {
    background-color: #f2f3f3;
    width: 200mm;
    height: 297mm;
    box-sizing: border-box;
    padding: 8mm;
    margin: auto;
}

.nav-title {
    font-size: 2em;
    line-height: 1;
    margin-bottom: 1em;
    font-weight: bold;
}

.cards .label {
    text-align: center;
    margin: 0.8em 0;
    font-size: 1.4em;
    font-weight: bold;
}

.gauge-wrapper {
    align-items: center;
}

.gauge-container {
    width: 200px;
    height: 102px;
    position: relative;
    overflow: hidden;
    z-index: 0;
    margin-bottom: 20px;
}

.gauge-bg {
    z-index: 1;
    position: absolute;
    width: 200px;
    height: 100px;
    top: 0px;
    border-radius: 100px 100px 0 0;
    background-color: #d5dbdb;
    overflow: hidden;
}

.gauge {
    z-index: 2;
    position: absolute;
    top: 100px;
    width: 200px;
    height: 100px;
    border-radius: 0 0 100px 100px;
    transform-origin: center top;
    transition: all 1.5s ease;
    transition-delay: 200ms;
}

.gauge-content {
    display: flex;
    flex-direction: column;
    justify-content: flex-end;
    align-items: center;
    z-index: 3;
    width: 160px;
    height: 80px;
    position: absolute;
    bottom: 0px;
    left: 20px;
    border-radius: 80px 80px 0 0;
    border-bottom: 1px solid #ffffff;
    background-color: #ffffff;
}

.percentage {
    font-size: 2.8rem;
}

.card-container {
    display: flex;
    flex-wrap: wrap;
    box-sizing: border-box;
    padding: 0;
    margin: 0;
    list-style: none;
    position: relative;
    width: 100%;
    min-width: 0;
}

.card-container .cards {
    margin: 0 4px 2px 4px;
    background-color: #fff;
    display: flex;
    flex: 1;
    flex-wrap: wrap;
    box-sizing: border-box;
    padding: 0 8px;
    position: relative;
    /* box-shadow: 0 1px 1px 0 rgb(0 28 36 / 30%), 1px 1px 1px 0 rgb(0 28 36 / 15%), -1px 1px 1px 0 rgb(0 28 36 / 15%); */
    border-top: 1px solid #eaeded;
    border-left: 1px solid rgba(102, 102, 102, 0.2);
    border-right: 1px solid rgba(102, 102, 102, 0.2);
    border-bottom: 1px solid rgba(48, 48, 48, 0.4);
    border-radius: 0;
    justify-content: center;
    align-items: center;
    overflow: hidden;
}

.card-container .cards.cards2 {
    flex: 2;
}

.cards-contents-container {
    height: 100px;
    margin: 0 0 20px 0;
    position: relative;
    display: flex;
    justify-content: center;
    align-items: center;
}

.cards-contents {
    margin: 0;
    font-size: 2.6rem;
}

.security-check-high-count {
    font-size: 2.6rem;
    ;
}

.note-contents {
    text-align: left;
    width: 100%;
}

/* font color */

.font-color-status-critical {
    color: #7d2105;
}

.font-color-status-high {
    color: #ba2e0f;
}

.font-color-status-medium {
    color: #cc5f21;
}

.font-color-status-low {
    color: #b49116;
}

.font-color-status-positive {
    color: #67a353;
}

.font-color-status-info {
    color: #3184c2;
}

.font-color-status-neutral {
    color: #879596;
}

HTMLやCSSに詳しいわけではありませんが、Printする前提のページデザインのためsize: A4;を指定したり、文字数が溢れてもレイアウトが崩れないように各所のblockをoverflow: hidden;にしておくなどは共通の考慮事項でしょうか。box-shadowがコメントアウトしてありますが、WeasyPrintの現状のCSSのエンジンではサポートされていませんでした。CSS仕様に対するカバレッジなどはこちらのAPIリファレンスに詳細が書かれていますので、動きを確認するときはこれを見ていきましょう。

WeasyPrint does not support the box shadow part of this module, including the box-shadow property. This feature has been implemented in a git branch that is not released, as it relies on raster implementation of shadows.


(翻訳)WeasyPrint は、box-shadow プロパティを含む、このモジュールのボックス シャドウ部分をサポートしていません。 この機能は、シャドウのラスター実装に依存しているため、リリースされていない git ブランチに実装されています。

これで一通り準備完了です。全てフラットな階層に設置してPythonスクリプトを実行します。

python3 pdf-report.py

以下のような1枚ページのレポートが作成されました。半円のパイチャートの角度が適切に処理されています。

もう1枚の方は、欠損値を適切に扱うことができました。

なお、このPDFでフォントがどういう扱いになっているかはpdffontsを利用することで確認が可能です。brewからインストールします。

brew install xpdf

確認するとこんな感じ。

pdffonts ./090909090909-report.pdf
name type emb sub uni prob object ID
---------------------------------------------- ----------------- --- --- --- ---- ---------
DVPEND+Helvetica-Neue CID TrueType yes yes yes 16 0
WYWXAG+Arial-Unicode-MS CID TrueType yes yes yes 20 0
FMADNF+Arial-Bold CID TrueType yes yes yes 24 0
UWEORH+Arial-Unicode-MS-Bold CID TrueType yes yes yes 28 0

意味はよくわかってないですが、どのフォントが割り当てられているかはなんとなく把握できます。しかし、それが実際PDFビューワーにどのように評価されているかは確認する方法がわかりません。

ちなみにブラウザ上では、最終的にどのフォントが割り当てられているかは開発者ツールで確認できます。詳しくはこちら

この段階でも、ブラウザのPDFビューワーとOSのPDFビューワーでフォントが特に違うなど、差異が見られるのである程度諦めも肝心でしょう。

とりあえずこれで、ローカル環境でWeasyPrintを動かして、PDFを得るところまで確認できました。デザインやレイアウトの調整も、Lambdaに持っていったりすると変わる可能性もあるので、要件に合うかを確認しているフェーズでは、作り込みすぎないようにして次に進みましょう。

WeasyPrint等のLambdaレイヤー作成

WeasyPrintのLambdaレイヤー実装はここで言及されており、こちらのCloud Print Utilsリポジトリにあります。こちらでは他にもwkhtmltopdfのレイヤーもあります。リポジトリ自体しばらくメンテナンスされていないので、利用には注意しておく必要はあります。

今回はLambdaレイヤーにフォントも一緒に組み込んでいきます。レイヤー作成にdockerを利用しています。

まずはリポジトリをcloneします。

git clone https://github.com/kotify/cloud-print-utils.git

続いて、2つのセットアップファイルをいじっていきます。

./fonts/layer_builder.shでは日本語のオープンソースフォントとしてIPAフォントを導入するようにします。diffはこんな感じ。

 # download fonts
yumdownloader \
- dejavu-sans-fonts \
- dejavu-fonts-common
+ ipa-gothic-fonts \
+ ipa-mincho-fonts \
+ ipa-pgothic-fonts \
+ ipa-pmincho-fonts

./weasyprint/layer_builder.shでは追加のPythonライブラリを導入します。diffはこんな感じ。

 mkdir -p "/opt/python/lib/$RUNTIME/site-packages"
python -m pip install "weasyprint<53.0" -t "/opt/python/lib/$RUNTIME/site-packages"
+python -m pip install "pandas" -t "/opt/python/lib/$RUNTIME/site-packages"
+python -m pip install "jinja2" -t "/opt/python/lib/$RUNTIME/site-packages"
+python -m pip install "openpyxl" -t "/opt/python/lib/$RUNTIME/site-packages"

なお、元となったコードは"weasyprint<53.0"としておりドキュメントにはベースイメージにしているAmazon LinuxのPangoのバージョンに依存するものとしているので、変更できる可能性がありそうです。今回はローカルバージョンとも乖離がありますが、とりあえず動いたのでそのままとしておきます。

dockerを立ち上げておき、makeします。

make build/weasyprint-layer-python3.8.zip

これでレイヤーのzipファイルが./build/weasyprint-layer-python3.8.zipに出力されます。これを使ってLambdaレイヤーを作成します。

まずはファイルサイズが大きいため、一旦S3にアップロードします。AWSマネジメントコンソールで最初に作成したS3を開き、ファイルをドラッグ・アンド・ドロップでアップロードします。

アップロードできたら、オブジェクトの詳細ページで「オブジェクトURL」を控えておきます。

AWS Lambdaを開き「レイヤー」から「レイヤーの作成」を実施します。

名前を適当に、今回はweasyprint-layerとします。S3からアップロードとして先程のURLを入れ、アーキテクチャに「x86_64」、ランタイムに「Python3.8」を選択して「作成」します。

これでLambdaレイヤーの作成が完了です。

Lambda関数作成

Lambda関数を作成します。Lambda関数の画面から「関数の作成」を開始し、「一から作成」で適当な関数名、今回はweasyprint-lambdaとし、ランタイムで「Python3.8」を選択して「関数の作成」します。

作成できたら周辺の設定をしていきます。作成した関数の詳細画面を下にスクロールし、「レイヤーの追加」を押します。

「カスタムレイヤー」を選択し、先ほど作成したレイヤーとバージョンを選択して「追加」します。

「設定 -> 一般設定」でタイムアウト値を伸ばします。「編集」を押します。

そこまで時間がかかる処理ではありませんが、伸ばすデメリットも少ないので5分にして「保存」します。ちなみに今回の実装と処理の場合は最終的に8秒程度でLambdaの実行が終わりました。

S3へのアクセス権限を追加します。「設定 -> アクセス権限」から自動生成されているIAMロールをクリックします。

IAMロールの詳細画面で「許可を追加 -> インラインポリシーを作成」で追加していきます。

JSONのエディタで下記ポリシードキュメントを記述します。対象のS3バケットの部分は環境に合わせてください。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "s3:PutObject",
                "s3:GetObject",
                "s3:ListBucket"
            ],
            "Resource": [
                "arn:aws:s3:::weasyprint-test-xxxx",
                "arn:aws:s3:::weasyprint-test-xxxx/*"
            ]
        }
    ]
}

もちろんビジュアルエディタで好きにいじってもいいです。「ポリシーの確認」へ進みます。

適当な名前、今回はweasyprint-bucket-policyとつけて保存します。

今回のレイヤーからフォントなどを利用するために環境変数を設定します。要件はこちらにあります。「設定 -> 環境変数」から「編集」します。

下記内容で環境変数を設定して「保存」します。

Key Value
FONTCONFIG_PATH /opt/fonts
GDK_PIXBUF_MODULE_FILE /opt/lib/loaders.cache
XDG_DATA_DIRS /opt/lib

Pythonコードなどを作っていきます。「コード」タブを開いてlambda_function.pyに下記コードを貼り付けます。

import boto3
import os
import pandas as pd
from jinja2 import Environment, FileSystemLoader
from weasyprint import HTML


data_key = 'securityhub_data.xlsx'
bucket_name = 'weasyprint-test-a87wtg'

s3 = boto3.resource('s3')
bucket = s3.Bucket(bucket_name)
data_path = os.path.join('/tmp', data_key)

def get_report_data():
    # intで欠損値を扱えるようにする(試験的機能)
    pd.options.mode.use_inf_as_na = True
    bucket.download_file(data_key, data_path)
    df = pd.read_excel(data_path, dtype={
        'AccountID': str,
        'SecurityHubScore': int,
        'LastMonthSecurityHubScore': 'Int64',
        'HighCount': int})
    return df


def save_report(account_id, html_report):
    report_key = '{0}-report.pdf'.format(account_id)
    report_path = os.path.join('/tmp', report_key)
    HTML(string=html_report).write_pdf(
        report_path, stylesheets=['style.css'])
    bucket.upload_file(report_path, report_key)


def lambda_handler(event, context):
    report_date = '2023年2月'
    df = get_report_data()
    for _, row in df.iterrows():
        # get a account data
        account_id = row['AccountID']
        securityhub_score = row['SecurityHubScore']
        # Generate angles for drawing semi-pie charts
        score_rotate = 180 * (securityhub_score/100)
        lastmonth_score = row['LastMonthSecurityHubScore']
        diff_score = '-'
        if lastmonth_score is not pd.NA:
            diff_score = '{:+}%'.format(securityhub_score - lastmonth_score)
        high_count = row['HighCount']
        report_title = 'Security Hub {0}定期レポート - {1}'.format(
            report_date, account_id)
        # create Jinja template
        env = Environment(loader=FileSystemLoader('.'))
        template = env.get_template('report_template.html')
        template_vars = {
            'title': report_title,
            'account_id': account_id,
            'score_rotate': score_rotate,
            'securityhub_score': securityhub_score,
            'diff_score': diff_score,
            'high_count': high_count}
        # Render our file and create the PDF using our css style file
        html_report = template.render(template_vars)
        save_report(account_id, html_report)

S3と連携するために先程のローカルのコードから編集しています。次にHTMLを作成します。コードエディタ内で「File -> New File」で新しいファイルを作成します。

先ほどと同じHTMLを貼り付けし、⌘ + sなどのショートカットやコードエディタ内のメニューからSave Asしてファイル名にreport_template.htmlをつけて保存します。

同じ手順でCSSも作成し、style.cssとして保存します。

一通り作成できたら、「Deploy」を押して反映します。

これで準備完了です。

レポート生成

いよいよPDFレポートの作成です。今回はAWS Lambdaの「テスト」タブから直接実行します。テストイベントの入力に関係なく動く仕組みなので、デフォルトの「hello-world」テンプレートをそのまま使います。イベント名で適当な名前、今回はhello-worldと入力して「保存」し「テスト」します。

しばらくすると成功します。今回は8秒程度で完了しました。

S3に出力されているので確認しに行きます。

2つのPDFレポートが保存されていることが確認できました。それぞれ選択してダウンロードしてみます。

ローカルで確認したときとだいたい同じ出力結果になりました。

これで完了です。

まとめ

AWS Lambdaを利用して、PDFのレポートを作成してみました。

調査も含め結構時間をかけたのと、実現方法も調べながら行ったり来たりしていたので大変でした。調査した内容と私なりの考察もまとめてありますので、要件に合わせて他の方法も検討してもらえればと思います。

最初は同じような目的に合わせた情報がたくさんあると思ったのですが、結局の所情報を寄せ集めたり、レポートのデザインも自分で書くところが多くなって、全然かんたんではありませんでした。世の中のBIツールは最高だと思うので、使えるなら絶対使ったほうがいいでしょう。

ただ今回の手法ではほぼLambda実行時間分の費用ですから、すごくコストは低いです。頑張って作成・維持するつもりがあれば採用してもいいのではないでしょうか?