レイアウト
グリッドと余白
- 紙面サイズ(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”