1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[Python(FastAPI)] ReportLab × Matplotlibでの帳票出力メモ

Posted at

レイアウト

グリッドと余白

  • 紙面サイズ(A4/B5 など)と 安全マージン (MARGIN_X, MARGIN_Y) を決め打ち
  • 4 mm ~ 5 mm 単位の ベースライン・グリッド にスナップさせてテキスト・図・表の整合性を保つ
  • チャート貼付け用の 最大幅・高さ は mm で定数化しておくと、データ量で崩れない
# 共通レイアウト定数
PAGE_W, PAGE_H = A4
MARGIN_X, MARGIN_Y = 20 * mm, 15 * mm
MAX_BUBBLE_W = 85 * mm  # 吹き出し最大幅
LINE_H, GAP_Y = 5 * mm, 4 * mm

再利用コンポーネント

以下は現場で頻繁に使う帳票コンポーネントと、その実装時の勘どころです。サンプルコードは雰囲気が伝わる最小限に留めています。

  • タイトル (Heading)
    canvas.drawCentredString() で中央寄せ。フォントサイズと余白は定数で一元管理。

  • サマリー表 (SummaryTable)
    列幅を mm 単位で固定し、偶数行に薄いシェーディングを入れて視認性を確保。セル内余白は 1.5 mm 程度が目安

  • 吹き出し (ChatBubble)
    話者ごとに左右寄せと背景色を分けて識別。行高は (行数 × LINE_H) + padding で動的に算出し、改ページ前に高さをチェック

  • 感情タグ (TagBadge)
    roundRect で角丸矩形を描き、中央に 3 文字程度のラベルを配置。色は感情ラベルごとに定数化すると保守が楽

日本語 & 多言語フォント運用

ReportLab と Matplotlib はフォント登録方法が異なるため、単一ユーティリティで両方に登録しておくと管理が楽です。

# utils/jp_font.py
from pathlib import Path
import matplotlib
import matplotlib.font_manager as fm
from reportlab.pdfbase import pdfmetrics, ttfonts

FONT_PATH = Path("utils/fonts/NotoSansJP-Regular.ttf")
fm.fontManager.addfont(str(FONT_PATH))
font_name = fm.FontProperties(fname=str(FONT_PATH)).get_name()
matplotlib.rcParams["font.family"] = font_name
pdfmetrics.registerFont(ttfonts.TTFont(font_name, str(FONT_PATH)))
  • 全角=2 / 半角=1 で文字幅を判定し、折り返し関数に渡すとレイアウト崩れを防げます
def _split_mixed_width(txt: str, cols: int) -> list[str]:
    lines, buf, width = [], "", 0
    for ch in txt:
        w = 2 if ord(ch) > 255 else 1
        if width + w > cols * 2:
            lines.append(buf); buf, width = ch, w
        else:
            buf += ch; width += w
    if buf:
        lines.append(buf)
    return lines

チャート PDF 一体化

Matplotlib → ReportLab

import io
from PIL import Image

def _fig_to_img(fig):
    buf = io.BytesIO()
    fig.savefig(buf, format="png", dpi=150, bbox_inches="tight")
    buf.seek(0)
    return Image.open(buf).convert("RGB")

# 貼り付け
img = _fig_to_img(build_overall_chart(report))
c.drawInlineImage(img, MARGIN_X, y0 - 50*mm, width=60*mm, height=60*mm)

チャートビルダ

# 円グラフ
labels, counts = zip(*Counter(s.sentiment for s in report.segments).items())
fig, ax = plt.subplots()
ax.pie(counts, labels=labels, autopct="%1.1f%%", startangle=140)
# 折れ線グラフ(5秒バケット)
df["bucket"] = (df["start"] // 5).astype(int)
by_bucket = df.groupby("bucket").mean()
ax.plot(by_bucket.index * 5, by_bucket["positive"], marker="o")
# 面グラフ(話者別)
grouped.plot.area(ax=ax, legend=False)
ax.set_ylim(0, 1)

データ受け渡し

方針は一行だけ: “フロントが使う JSON をそのまま Python サービスが GET して PDF に落とす”。 変換レイヤーや専用エンドポイントは不要。

class SentimentApiHttpAdapter(SentimentApiPort):
    async def fetch_report(self, date: str, key: str) -> SentimentReport:
        url = f"{BASE_URL}/sentiments/{date}/{key}"
        async with httpx.AsyncClient() as client:
            resp = await client.get(url)
        return SentimentReport.model_validate(resp.json()["json"])

つまるところ

  • フォント埋め込み漏れ → Acrobat で“代替フォント”警告
  • 改ページ判定 → 次要素の高さを計算してから showPage()
  • Matplotlib リソース → plt.close(fig) を忘れない

PdfGenerator クラス全体像

用途: この記事の実装例

class PdfGeneratorReportLabAdapter(PdfGeneratorPort):
    FONT = "Noto Sans JP"
    PAGE_W, PAGE_H = A4
    MAX_BUBBLE_W = 85 * mm
    # ... 定数略 ...

    async def generate(self, report: SentimentReport) -> bytes:
        buf = io.BytesIO(); c = canvas.Canvas(buf, pagesize=A4)
        self._draw_summary_with_charts(c, report)
        self._draw_chat(c, report)
        c.save(); buf.seek(0)
        return buf.getvalue()

    # _draw_summary_with_charts / _draw_chat

まとめ

  • 余白 × グリッド × コンポーネント化でレイアウト崩れを防ぐ
  • jp_font.py で Matplotlib と ReportLab のフォントを一元管理
  • データは“既存 JSON を直 GET”
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?