diff --git a/services/ppt_generator.py b/services/ppt_generator.py index 9df9ad7..04055bf 100644 --- a/services/ppt_generator.py +++ b/services/ppt_generator.py @@ -74,6 +74,20 @@ _STRAT_COLORS = { _STRAT_ORDER = ['加碼', '機會', '收割', '觀察', '持穩'] +# ── 字體規範 (對齊 MOMO Pro design-tokens.css) ───────────────────────────────── +# momo-font-display: JetBrains Mono / Space Mono (點陣等寬機械感—數字/標驔) +# momo-font-family: Inter + PingFang TC / Microsoft JhengHei (中英混排內文) +_FONT_MONO = "Courier New" # 時作最靠論的等寬 fallback (點陣風格) +_FONT_BODY = "Microsoft JhengHei" # 中文圓體 / 黑體 (內文、表格) +_FONT_LABEL = "Arial" # 英文標籤 (OPENCLAW badge etc.) +_SLIDE_H = 19.05 # 投影片體高 cm +_FOOTER_Y = 18.38 # footer 起始位置(底部對齊) +_FOOTER_H = 0.67 # footer 高度 +_CONTENT_Y = 1.85 # 內容源頂(頁首後) +_CONTENT_B = 18.2 # 內容底面(footer 前 0.18cm) + + + def check_pptx_available() -> bool: try: import pptx # noqa: F401 @@ -117,7 +131,7 @@ def _add_rect(slide, l, t, w, h, fill_hex, line_hex=None): def _add_text(slide, text, l, t, w, h, bold=False, size=14, color=_WHITE, - align="left", valign="top", wrap=True): + align="left", valign="top", wrap=True, font_name=None): from pptx.util import Pt from pptx.enum.text import PP_ALIGN from pptx.dml.color import RGBColor @@ -140,58 +154,66 @@ def _add_text(slide, text, l, t, w, h, p.alignment = PP_ALIGN.CENTER elif align == "right": p.alignment = PP_ALIGN.RIGHT - for run in p.runs: run.font.bold = bold run.font.size = Pt(size) + if font_name: + run.font.name = font_name run.font.color.rgb = RGBColor( int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16)) return txb def _add_footer(slide, prs_w_cm=33.87): - """暖墨底色頁腳 + 焦糖橘品牌文字""" - _add_rect(slide, 0, 13.5, prs_w_cm, 0.79, _FOOTER_BG) - # 左側焦糖橘細條 - _add_rect(slide, 0, 13.5, 0.4, 0.79, _BRAND_OG) + """抖墨底色頁腳,固定在投影片最底部""" + _add_rect(slide, 0, _FOOTER_Y, prs_w_cm, _FOOTER_H, _FOOTER_BG) + _add_rect(slide, 0, _FOOTER_Y, 0.4, _FOOTER_H, _BRAND_OG) # 左焦糖橘細條 _add_text(slide, "♥ Powered by OpenClaw", - prs_w_cm - 7, 13.55, 6.8, 0.68, - size=9, color=_BRAND_OG, align="right") + prs_w_cm - 7.5, _FOOTER_Y + 0.05, 7.2, _FOOTER_H - 0.08, + size=8, color=_BRAND_OG, align="right", + font_name=_FONT_LABEL) def _add_header(slide, title_text, prs_w_cm=33.87): - """焦糖橘頁首帶,高度 1.7cm,左側深色加深條""" + """焦糖橘頁首帶 1.7cm,中文圓體標題""" _add_rect(slide, 0, 0, prs_w_cm, 1.7, _BRAND_OG) - _add_rect(slide, 0, 0, 0.5, 1.7, _BRAND_OG2) # 左加深條 + _add_rect(slide, 0, 0, 0.5, 1.7, _BRAND_OG2) _add_text(slide, title_text, 0.8, 0.1, prs_w_cm - 1.2, 1.5, - bold=True, size=18, color=_WHITE, valign="middle") + bold=True, size=18, color=_WHITE, valign="middle", + font_name=_FONT_BODY) def _add_empty_state(slide, title, detail, W=33.87): - """避免產出視覺空白頁;用明確診斷文字說明缺哪一段資料。""" + """避免產出視覺空白頁""" _add_rect(slide, 2.2, 5.0, W - 4.4, 3.8, _LIGHT_GRAY, line_hex=_SUBTLE) - _add_rect(slide, 2.2, 5.0, 0.4, 3.8, _BRAND_OG) # 左色條 + _add_rect(slide, 2.2, 5.0, 0.4, 3.8, _BRAND_OG) _add_text(slide, title, 3.0, 5.65, W - 5.6, 0.75, - bold=True, size=18, color=_DARK_TEXT, align="center") + bold=True, size=18, color=_DARK_TEXT, align="center", + font_name=_FONT_BODY) _add_text(slide, detail, 3.2, 6.75, W - 6.4, 1.0, - size=12, color=_SUBTEXT, align="center") + size=12, color=_SUBTEXT, align="center", + font_name=_FONT_BODY) def _kpi_card(slide, l, t, w, h, fill, label, value, sub=""): - """暖色 KPI 卡:label 10pt 上方,value 30pt 粗體置中,底部輔助文字 9pt""" + """暖色 KPI 卡:label 圓體小字,value 點陣等寬大字置中""" _add_rect(slide, l, t, w, h, fill) - # 左側白色細透明條,增加層次 - _add_rect(slide, l, t, 0.12, h, "FFFFFF") + _add_rect(slide, l, t, 0.12, h, "FFFFFF") # 左田白透明條 _add_text(slide, label, l + 0.35, t + 0.25, w - 0.55, 0.65, - bold=False, size=10, color="FAF7F0") + bold=False, size=10, color="FAF7F0", + font_name=_FONT_BODY) _add_text(slide, value, - l + 0.2, t + 0.85, w - 0.4, 1.6, - bold=True, size=30, color="FFFFFF", align="center", valign="middle") + l + 0.2, t + 0.85, w - 0.4, h - 1.55, + bold=True, size=30, color="FFFFFF", + align="center", valign="middle", + font_name=_FONT_MONO) # 數字用點陣等寬字體 if sub: _add_text(slide, sub, l + 0.2, t + h - 0.7, w - 0.4, 0.6, - size=9, color="FAF7F0", align="center") + size=9, color="FAF7F0", align="center", + font_name=_FONT_BODY) + def _horiz_bar(slide, l, t, h_row, label, value, total, fill_hex, max_w=14.0): @@ -393,84 +415,104 @@ def _cover_slide(prs, big_title: str, sub1: str, sub2: str = ""): return slide -# ── 通用商品表格投影片(含業績佔比視覺條)──────────────────────────────────────── -def _product_table_slide(prs, header_text, products, W=33.87): - slide = prs.slides.add_slide(prs.slide_layouts[6]) - _add_rect(slide, 0, 0, W, 19.05, _WHITE) - _add_header(slide, header_text) +# ── 分頁商品表格(支援 50 項,多頁)─────────────────────────────────────────────────── +def _product_table_slide(prs, header_text, products, W=33.87, max_items=50): + """分頁顯示商品表格,每頁最多 18 項,max_items 附預設 50 項""" + import math + HEADER_END = 1.85 # 頁首帶底部 + TABLE_HDR_H = 0.72 # 表頭高 + ROW_H = 0.88 # 標準行高 + AVAIL_H = _CONTENT_B - HEADER_END - TABLE_HDR_H # 每頁可用高度 + ROWS_PER_PAGE = max(1, int(AVAIL_H / ROW_H)) # 每頁行數≈ 18 - if not products: - _add_empty_state( - slide, - "本頁沒有可顯示的商品資料", - "請確認該日期或期間是否已有匯入業績資料,或改查最新有資料日期。", - W, - ) + all_prods = products[:max_items] + if not all_prods: + slide = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(slide, 0, 0, W, _SLIDE_H, _WHITE) + _add_header(slide, header_text) + _add_empty_state(slide, "本頁沒有可顯示的商品資料", + "請確認該期間是否已有匯入業績資料。", W) _add_footer(slide, W) - return slide + return - display = products[:10] - top_rev = max(float(p.get('revenue', 1)) for p in display) or 1 + top_rev = max(float(p.get('revenue', 1)) for p in all_prods) or 1 + total_pages = math.ceil(len(all_prods) / ROWS_PER_PAGE) - # ── 表頭 - _add_rect(slide, 0.4, 1.85, W - 0.8, 0.68, _BRAND_OG) - _add_rect(slide, 0.4, 1.85, 0.3, 0.68, _BRAND_OG2) # 左加深條 - _add_text(slide, "排名", 0.5, 1.89, 1.3, 0.60, bold=True, size=10, color=_WHITE, align="center") - _add_text(slide, "商品名稱", 1.95, 1.89, W - 18.5, 0.60, bold=True, size=10, color=_WHITE) - _add_text(slide, "業績佔比", W - 16.2, 1.89, 5.8, 0.60, bold=True, size=9, color=_WHITE, align="center") - _add_text(slide, "月業績", W - 9.8, 1.89, 5.5, 0.60, bold=True, size=10, color=_WHITE, align="right") - _add_text(slide, "訂單", W - 3.8, 1.89, 3.2, 0.60, bold=True, size=10, color=_WHITE, align="right") + for page in range(total_pages): + page_prods = all_prods[page * ROWS_PER_PAGE:(page + 1) * ROWS_PER_PAGE] + page_label = f" ({page + 1}/{total_pages})" + slide = prs.slides.add_slide(prs.slide_layouts[6]) + _add_rect(slide, 0, 0, W, _SLIDE_H, _WHITE) + _add_header(slide, header_text + page_label) - row_h = (13.5 - 2.65) / max(len(display), 1) # 動態行高,撐滿可用區 - row_h = max(0.75, min(row_h, 1.08)) # 限制 0.75~1.08cm + # ── 表頭 + tbl_y = HEADER_END + _add_rect(slide, 0.4, tbl_y, W - 0.8, TABLE_HDR_H, _BRAND_OG) + _add_rect(slide, 0.4, tbl_y, 0.3, TABLE_HDR_H, _BRAND_OG2) + _add_text(slide, "排名", 0.5, tbl_y + 0.06, 1.3, TABLE_HDR_H - 0.12, + bold=True, size=10, color=_WHITE, align="center", font_name=_FONT_BODY) + _add_text(slide, "商品名稱", 1.95, tbl_y + 0.06, W - 19.5, TABLE_HDR_H - 0.12, + bold=True, size=10, color=_WHITE, font_name=_FONT_BODY) + _add_text(slide, "業績佔比", W - 17.2, tbl_y + 0.06, 5.5, TABLE_HDR_H - 0.12, + bold=True, size=9, color=_WHITE, align="center", font_name=_FONT_BODY) + _add_text(slide, "月業績", W - 11.3, tbl_y + 0.06, 5.8, TABLE_HDR_H - 0.12, + bold=True, size=10, color=_WHITE, align="right", font_name=_FONT_BODY) + _add_text(slide, "訂單", W - 5.0, tbl_y + 0.06, 4.4, TABLE_HDR_H - 0.12, + bold=True, size=10, color=_WHITE, align="right", font_name=_FONT_BODY) - for i, p in enumerate(display): - bg = _LIGHT_GRAY if i % 2 == 0 else _WHITE - row_t = 2.65 + i * row_h - rev = float(p.get('revenue', 0)) - pct = rev / top_rev if top_rev else 0 - ord_v = p.get('orders', p.get('order_count', 0)) - font_sz = 10 if row_h >= 0.85 else 9 + # ── 資料行 + for j, p in enumerate(page_prods): + actual_rank = page * ROWS_PER_PAGE + j + 1 + bg = _LIGHT_GRAY if j % 2 == 0 else _WHITE + row_t = tbl_y + TABLE_HDR_H + j * ROW_H + rev = float(p.get('revenue', 0)) + pct = rev / top_rev if top_rev else 0 + ord_v = p.get('orders', p.get('order_count', 0)) - _add_rect(slide, 0.4, row_t, W - 0.8, row_h - 0.05, bg) + _add_rect(slide, 0.4, row_t, W - 0.8, ROW_H - 0.04, bg) - # 排名(TOP3 焦糖橘圓底) - rank_fill = _BRAND_OG if i < 3 else _SUBTLE - rank_color = _WHITE if i < 3 else _SUBTEXT - _add_rect(slide, 0.55, row_t + 0.1, 1.0, row_h - 0.25, rank_fill) - _add_text(slide, str(i + 1), - 0.55, row_t + 0.1, 1.0, row_h - 0.25, - bold=(i < 3), size=font_sz, color=rank_color, align="center", valign="middle") + # 排名圓框(TOP3 焦糖橘) + rank_fill = _BRAND_OG if actual_rank <= 3 else (_KPI_HONEY if actual_rank <= 10 else _SUBTLE) + rank_color = _WHITE if actual_rank <= 10 else _SUBTEXT + _add_rect(slide, 0.55, row_t + 0.1, 0.95, ROW_H - 0.22, rank_fill) + _add_text(slide, str(actual_rank), + 0.55, row_t + 0.1, 0.95, ROW_H - 0.22, + bold=(actual_rank <= 3), size=10, color=rank_color, + align="center", valign="middle", font_name=_FONT_MONO) - # 商品名稱 - _add_text(slide, str(p.get('name', ''))[:45], - 1.95, row_t + 0.1, W - 18.7, row_h - 0.2, - size=font_sz, color=_DARK_TEXT) + # 商品名稱 + _add_text(slide, str(p.get('name', ''))[:44], + 1.95, row_t + 0.1, W - 19.7, ROW_H - 0.2, + size=10, color=_DARK_TEXT, font_name=_FONT_BODY) + + # 業績佔比視覺條 + bar_max_w = 5.0 + bar_w = max(0.08, pct * bar_max_w) + bar_y = row_t + ROW_H * 0.38 + bar_h = 0.25 + _add_rect(slide, W - 17.0, bar_y, bar_max_w, bar_h, _SUBTLE) + _add_rect(slide, W - 17.0, bar_y, bar_w, bar_h, _BRAND_OG) + _add_text(slide, f"{pct*100:.0f}%", + W - 17.0, row_t + 0.08, bar_max_w, ROW_H - 0.2, + size=8, color=_TERTIARY, align="right", font_name=_FONT_MONO) + + # 月業績 + _add_text(slide, f"NT${rev:,.0f}", + W - 11.3, row_t + 0.1, 5.8, ROW_H - 0.2, + bold=(actual_rank == 1), size=10, color=_DARK_TEXT, + align="right", font_name=_FONT_MONO) + + # 訂單 + if ord_v: + _add_text(slide, f"{int(ord_v):,} 筆", + W - 5.0, row_t + 0.1, 4.4, ROW_H - 0.2, + size=9, color=_SUBTEXT, align="right", font_name=_FONT_MONO) + + _add_footer(slide, W) - # 業績佔比視覺條 - bar_max_w = 5.3 - bar_filled = max(0.1, pct * bar_max_w) - bar_y = row_t + row_h * 0.35 - bar_h_val = 0.28 - _add_rect(slide, W - 16.0, bar_y, bar_max_w, bar_h_val, _SUBTLE) - _add_rect(slide, W - 16.0, bar_y, bar_filled, bar_h_val, _BRAND_OG) - _add_text(slide, f"{pct*100:.0f}%", - W - 16.0, row_t + 0.08, bar_max_w, row_h - 0.2, - size=8, color=_SUBTEXT, align="right") - # 月業績 - _add_text(slide, f"NT${rev:,.0f}", - W - 9.8, row_t + 0.1, 5.5, row_h - 0.2, - bold=(i == 0), size=font_sz, color=_DARK_TEXT, align="right") - # 訂單 - if ord_v: - _add_text(slide, f"{int(ord_v):,} 筆", - W - 3.8, row_t + 0.1, 3.2, row_h - 0.2, - size=font_sz - 1, color=_SUBTEXT, align="right") - _add_footer(slide, W) - return slide # ── 日報 PPT(4頁)────────────────────────────────────────────────────────────