# Python으로 Money Forward 스타일 재무 PDF 리포트 만들기 - Part 8 ## matplotlib 차트 생성 ### 차트 종류 Money Forward 스타일 리포트에 필요한 차트: | 차트 유형 | 용도 | 페이지 | |----------|------|--------| | 막대 + 라인 콤보 | 매출/이익 추이 + 이익률 | 業績見通し | | 스택 막대 | 예산 vs 실적 비교 | 業績分析表 | | 라인 차트 | 캐시플로우 추이 | CF計算書 | | 도넛 차트 | 비용 구성 | 変動損益 | ### matplotlib 기본 설정 `src/charts/__init__.py`: ```python """차트 모듈 초기화""" import matplotlib.pyplot as plt import matplotlib.font_manager as fm from pathlib import Path # 일본어 폰트 설정 def setup_japanese_font(): """일본어 폰트 설정""" # 시스템 폰트 검색 font_candidates = [ 'Hiragino Sans', # macOS 'Yu Gothic', # Windows 'Noto Sans CJK JP', # Linux 'IPAGothic', ] for font_name in font_candidates: try: fm.findfont(font_name) plt.rcParams['font.family'] = font_name return font_name except: continue # 폰트를 찾지 못한 경우 기본값 사용 print("Warning: Japanese font not found, using default") return None # 공통 스타일 설정 def setup_chart_style(): """차트 공통 스타일""" plt.rcParams.update({ 'figure.facecolor': 'white', 'axes.facecolor': 'white', 'axes.grid': True, 'grid.alpha': 0.3, 'grid.linestyle': '-', 'axes.spines.top': False, 'axes.spines.right': False, }) # 초기화 실행 setup_japanese_font() setup_chart_style() ``` ### 막대 + 라인 콤보 차트 `src/charts/combo_chart.py`: ```python """콤보 차트 (막대 + 라인)""" import matplotlib.pyplot as plt import numpy as np from io import BytesIO from typing import Optional # 색상 상수 COLORS = { 'revenue': '#4472C4', # 매출 - 파랑 'gross_profit': '#70AD47', # 매출총이익 - 녹색 'operating': '#1F4E79', # 영업이익 - 진파랑 'net_income': '#2E75B6', # 당기순이익 - 중파랑 'gross_margin': '#FFC000', # 매출총이익률 - 주황 'op_margin': '#C00000', # 영업이익률 - 빨강 } def create_revenue_profit_chart( months: list[str], revenue: list[float], gross_profit: list[float], operating_income: list[float], net_income: list[float], gross_margin: Optional[list[float]] = None, operating_margin: Optional[list[float]] = None, width: float = 8, height: float = 4, ) -> BytesIO: """매출/이익 추이 + 이익률 콤보 차트 Args: months: 월 레이블 ['2025/04月', ...] revenue: 매출 데이터 gross_profit: 매출총이익 데이터 operating_income: 영업이익 데이터 net_income: 당기순이익 데이터 gross_margin: 매출총이익률 (%) operating_margin: 영업이익률 (%) width, height: 차트 크기 (인치) Returns: PNG 이미지 BytesIO """ fig, ax1 = plt.subplots(figsize=(width, height)) x = np.arange(len(months)) bar_width = 0.2 # 막대 그래프 (왼쪽 Y축) ax1.bar(x - 1.5*bar_width, revenue, bar_width, label='売上高', color=COLORS['revenue']) ax1.bar(x - 0.5*bar_width, gross_profit, bar_width, label='売上総利益', color=COLORS['gross_profit']) ax1.bar(x + 0.5*bar_width, operating_income, bar_width, label='営業利益', color=COLORS['operating']) ax1.bar(x + 1.5*bar_width, net_income, bar_width, label='当期純利益', color=COLORS['net_income']) ax1.set_ylabel('金額 (円)', fontsize=9) ax1.set_ylim(bottom=min(0, min(operating_income) * 1.2)) # Y축 포맷 (백만 단위) ax1.yaxis.set_major_formatter( plt.FuncFormatter(lambda x, p: f'{x/1e6:.0f}M' if abs(x) >= 1e6 else f'{x/1e3:.0f}K') ) # X축 레이블 ax1.set_xticks(x) ax1.set_xticklabels([m.replace('月', '') for m in months], rotation=45, ha='right', fontsize=8) # 이익률 라인 (오른쪽 Y축) if gross_margin or operating_margin: ax2 = ax1.twinx() if gross_margin: ax2.plot(x, gross_margin, 'o-', color=COLORS['gross_margin'], label='売上総利益率', linewidth=2) if operating_margin: ax2.plot(x, operating_margin, 's-', color=COLORS['op_margin'], label='営業利益率', linewidth=2) ax2.set_ylabel('利益率 (%)', fontsize=9) ax2.set_ylim(-250, 100) # 범위 조정 ax2.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'{x:.0f}%')) # 라인 범례 lines2, labels2 = ax2.get_legend_handles_labels() else: lines2, labels2 = [], [] # 범례 통합 lines1, labels1 = ax1.get_legend_handles_labels() ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper center', bbox_to_anchor=(0.5, -0.15), ncol=3, fontsize=8) plt.tight_layout() # BytesIO로 저장 buffer = BytesIO() plt.savefig(buffer, format='png', dpi=150, bbox_inches='tight') plt.close(fig) buffer.seek(0) return buffer def create_monthly_comparison_chart( months: list[str], budget: list[float], actual: list[float], item_name: str = "売上高", width: float = 6, height: float = 3, ) -> BytesIO: """예산 vs 실적 비교 막대 차트""" fig, ax = plt.subplots(figsize=(width, height)) x = np.arange(len(months)) bar_width = 0.35 ax.bar(x - bar_width/2, budget, bar_width, label=f'{item_name} [予算v2]', color='#4472C4') ax.bar(x + bar_width/2, actual, bar_width, label=f'{item_name} [実績]', color='#70AD47') ax.set_ylabel('金額 (円)', fontsize=9) ax.set_xticks(x) ax.set_xticklabels([m.split('/')[1] if '/' in m else m for m in months], fontsize=8) ax.yaxis.set_major_formatter( plt.FuncFormatter(lambda x, p: f'{x/1e6:.1f}M') ) ax.legend(loc='upper left', fontsize=8) plt.tight_layout() buffer = BytesIO() plt.savefig(buffer, format='png', dpi=150, bbox_inches='tight') plt.close(fig) buffer.seek(0) return buffer ``` ### 캐시플로우 라인 차트 `src/charts/line_chart.py`: ```python """라인 차트""" import matplotlib.pyplot as plt import numpy as np from io import BytesIO def create_cashflow_chart( months: list[str], operating_cf: list[float], investing_cf: list[float], financing_cf: list[float], cash_balance: list[float], width: float = 8, height: float = 4, ) -> BytesIO: """캐시플로우 추이 차트""" fig, ax1 = plt.subplots(figsize=(width, height)) x = np.arange(len(months)) bar_width = 0.25 # 막대: CF 항목 ax1.bar(x - bar_width, operating_cf, bar_width, label='営業CF', color='#1F4E79') ax1.bar(x, investing_cf, bar_width, label='投資CF', color='#4472C4') ax1.bar(x + bar_width, financing_cf, bar_width, label='財務CF', color='#70AD47') ax1.set_ylabel('CF (円)', fontsize=9) ax1.axhline(y=0, color='gray', linestyle='-', linewidth=0.5) # 라인: 현금 잔액 ax2 = ax1.twinx() ax2.plot(x, cash_balance, 'o-', color='#FFC000', linewidth=2, markersize=6, label='現金及び預金の月末残高') ax2.set_ylabel('残高 (円)', fontsize=9) # X축 ax1.set_xticks(x) ax1.set_xticklabels([m.replace('月', '') for m in months], rotation=45, ha='right', fontsize=8) # 범례 lines1, labels1 = ax1.get_legend_handles_labels() lines2, labels2 = ax2.get_legend_handles_labels() ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper center', bbox_to_anchor=(0.5, -0.15), ncol=4, fontsize=8) plt.tight_layout() buffer = BytesIO() plt.savefig(buffer, format='png', dpi=150, bbox_inches='tight') plt.close(fig) buffer.seek(0) return buffer def create_trend_line_chart( months: list[str], data_series: list[dict], # [{'label': '売上高', 'data': [...], 'color': '#xxx'}, ...] title: str = "", width: float = 6, height: float = 3, ) -> BytesIO: """일반 트렌드 라인 차트""" fig, ax = plt.subplots(figsize=(width, height)) x = np.arange(len(months)) for series in data_series: ax.plot(x, series['data'], marker='o', color=series.get('color', '#4472C4'), label=series['label'], linewidth=2) ax.set_title(title, fontsize=10) ax.set_xticks(x) ax.set_xticklabels([m.replace('月', '') for m in months], rotation=45, ha='right', fontsize=8) ax.legend(loc='best', fontsize=8) ax.yaxis.set_major_formatter( plt.FuncFormatter(lambda x, p: f'{x/1e6:.1f}M' if abs(x) >= 1e6 else f'{x:,.0f}') ) plt.tight_layout() buffer = BytesIO() plt.savefig(buffer, format='png', dpi=150, bbox_inches='tight') plt.close(fig) buffer.seek(0) return buffer ``` ### PDF에 차트 삽입 `src/pdf/components/chart_renderer.py`: ```python """차트 렌더러 - matplotlib 차트를 PDF에 삽입""" from reportlab.pdfgen.canvas import Canvas from reportlab.lib.utils import ImageReader from io import BytesIO class ChartRenderer: """차트를 PDF에 렌더링""" def __init__(self, canvas: Canvas): self.canvas = canvas def draw_chart( self, chart_buffer: BytesIO, x: float, y: float, width: float, height: float, ): """차트 이미지를 PDF에 그리기 Args: chart_buffer: matplotlib에서 생성한 PNG BytesIO x, y: 좌측 하단 좌표 width, height: 표시 크기 """ img = ImageReader(chart_buffer) self.canvas.drawImage(img, x, y, width, height) def draw_chart_with_border( self, chart_buffer: BytesIO, x: float, y: float, width: float, height: float, border_color='#D9D9D9', ): """테두리가 있는 차트""" from reportlab.lib.colors import HexColor # 차트 그리기 self.draw_chart(chart_buffer, x, y, width, height) # 테두리 self.canvas.setStrokeColor(HexColor(border_color)) self.canvas.rect(x, y, width, height) ``` ### 대시보드에 차트 통합 `src/pdf/pages/dashboard.py` (수정): ```python from ..components.chart_renderer import ChartRenderer from charts.combo_chart import create_revenue_profit_chart class DashboardPage: def __init__(self, canvas, report): self.canvas = canvas self.report = report self.chart_renderer = ChartRenderer(canvas) # ... def _draw_annual_section(self, x, y, page_width): # ... (KPI 카드 그리기) # 차트 생성 months = ['2025/04', '2025/05', '2025/06', '2025/07', '2025/08', '2025/09', '2025/10', '2025/11', '2025/12', '2026/01', '2026/02', '2026/03'] chart_buffer = create_revenue_profit_chart( months=months, revenue=[628802, 1708772, 5300000, 3380000, 550000, 28350000, 6100000, 16800000, 7600000, 17710000, 18910000, 18910000], gross_profit=[334783, 1622015, 4827347, 2912348, 167655, 27917265, 4898412, 6470000, 2710000, 6754000, 7114000, 7114000], operating_income=[-928565, 739510, 3382699, 1159455, -1150577, 26374335, 2134436, 4236208, 576208, 4620208, 4980208, 4980208], net_income=[-928565, 739518, 3383450, 1163011, -1148702, 26376416, 2135734, 4236208, 576208, 4620208, 4980208, 4980208], gross_margin=[53.24, 94.92, 91.08, 86.16, 30.48, 98.47, 80.30, 38.51, 35.66, 38.14, 37.62, 37.62], operating_margin=[-147.67, 43.28, 63.82, 34.30, -209.20, 93.03, 34.99, 25.22, 7.58, 26.09, 26.34, 26.34], ) # 차트 배치 chart_x = x + 400 chart_y = y - 260 chart_width = 380 chart_height = 250 self.chart_renderer.draw_chart( chart_buffer, chart_x, chart_y, chart_width, chart_height ) ``` ### 테스트 ```python # 차트 단독 테스트 from charts.combo_chart import create_revenue_profit_chart months = ['2025/04', '2025/05', '2025/06'] revenue = [1000000, 2000000, 3000000] # ... buffer = create_revenue_profit_chart(months, revenue, ...) # 이미지 파일로 저장 with open('test_chart.png', 'wb') as f: f.write(buffer.read()) ``` ### 다음 단계 Part 9에서는 레이더 차트, 손익분기점 차트 등 복합 차트를 구현합니다. --- **시리즈 네비게이션** - [x] Part 1: 프로젝트 소개 - [x] Part 2: 환경 설정 - [x] Part 3: Sheets API 연동 - [x] Part 4: 데이터 모델 - [x] Part 5: PDF 기본 레이아웃 - [x] Part 6: KPI 대시보드 - [x] Part 7: 테이블 구현 - [x] Part 8: 차트 생성 (현재) - [ ] Part 9: 복합 차트 - [ ] Part 10: 완성