#!/usr/bin/env python3 """ SlideRenderer — Playwright截图管线 将HTML模板渲染为PNG背景图,用于嵌入PPT 配色方案: 深海蓝#0A3C5F + 青绿#18898D + 金色#C9A027 中文字体: Heiti TC (macOS内置) 使用方法: renderer = SlideRenderer() png_path = renderer.render_slide({ "type": "area_chart", "title": "账户价值增长", "data": {"years": [5,10,15,20], "values": [100,150,200,280]}, "narrative": "复利效应让财富持续增值" }) """ import os import sys import json import base64 import asyncio from pathlib import Path from typing import Optional # 品牌配色 BRAND_COLORS = { "primary": "#0A3C5F", # 深海蓝 "accent_teal": "#18898D", # 青绿 "accent_gold": "#C9A027", # 金色 "text_dark": "#1A1A2E", "text_light": "#FFFFFF", "bg_light": "#F8F9FA", "bg_card": "#FFFFFF", } FONT_FAMILY = "'Heiti TC', 'STHeiti', 'PingFang TC', 'Microsoft YaHei', sans-serif" # ─── HTML模板 ────────────────────────────────────────────── TEMPLATE_AREA_CHART = """
{title}
核心叙事
{narrative}
{highlights_html}
""" TEMPLATE_KPI_CARDS = """
{title}
{kpi_cards_html}
{narrative}
""" TEMPLATE_COMPARISON = """
{title}
{columns_html}
{narrative}
""" # ─── SlideRenderer主类 ────────────────────────────────────── class SlideRenderer: """Playwright截图管线""" def __init__( self, output_dir: str = "/tmp/slide_renders", viewport_width: int = 1280, viewport_height: int = 720, timeout_ms: int = 30000, ): self.output_dir = Path(output_dir) self.output_dir.mkdir(parents=True, exist_ok=True) self.viewport_width = viewport_width self.viewport_height = viewport_height self.timeout_ms = timeout_ms self._playwright = None self._browser = None # ─── Playwright生命周期 ──────────────────────────────── async def _ensure_playwright(self): """懒加载Playwright""" if self._playwright is None: from playwright.async_api import async_playwright self._playwright = await async_playwright().start() if self._browser is None: self._browser = await self._playwright.chromium.launch( headless=True, args=["--no-sandbox", "--disable-setuid-sandbox"] ) async def _close(self): """关闭浏览器""" if self._browser: await self._browser.close() self._browser = None # ─── 核心渲染方法 ────────────────────────────────────── async def render_async(self, slide_spec: dict) -> str: """ 异步渲染一张幻灯片 slide_spec: { "type": "area_chart" | "kpi_cards" | "comparison" | "table", "title": str, "narrative": str, "data": {...} # 类型相关数据 } 返回: PNG文件路径 """ await self._ensure_playwright() html = self._build_html(slide_spec) page = await self._browser.new_page( viewport={"width": self.viewport_width, "height": self.viewport_height} ) try: await page.set_content(html, timeout=self.timeout_ms) # 等待Chart.js渲染完成 if slide_spec.get("type") in ("area_chart", "line_chart", "bar_chart"): await page.wait_for_function( "typeof Chart !== 'undefined' && document.querySelectorAll('canvas').length > 0", timeout=10000 ) # 额外等待动画 await asyncio.sleep(1.5) # 生成PNG output_path = self.output_dir / f"slide_{slide_spec.get('_id', 'tmp')}.png" await page.screenshot( path=str(output_path), type="png", full_page=False, ) return str(output_path) finally: await page.close() def render(self, slide_spec: dict) -> str: """同步封装""" try: loop = asyncio.get_running_loop() except RuntimeError: # 没有运行中的loop,创建新的 return asyncio.run(self.render_async(slide_spec)) else: # 已有loop,在新线程中运行 import concurrent.futures with concurrent.futures.ThreadPoolExecutor() as executor: future = executor.submit(asyncio.run, self.render_async(slide_spec)) return future.result() # ─── HTML构建 ───────────────────────────────────────── def _build_html(self, spec: dict) -> str: t = spec["type"] title = spec.get("title", "") narrative = spec.get("narrative", "") common = { "font": FONT_FAMILY, "primary": BRAND_COLORS["primary"], "teal": BRAND_COLORS["accent_teal"], "gold": BRAND_COLORS["accent_gold"], "white": BRAND_COLORS["text_light"], "dark": BRAND_COLORS["text_dark"], } if t in ("area_chart", "line_chart"): return self._build_area_chart(spec, title, narrative, common) elif t == "kpi_cards": return self._build_kpi_cards(spec, title, narrative, common) elif t == "comparison": return self._build_comparison(spec, title, narrative, common) elif t == "table": return self._build_table(spec, title, narrative, common) else: return self._build_simple(spec, title, narrative, common) def _build_area_chart(self, spec, title, narrative, c): data = spec.get("data", {}) years = data.get("years", list(range(1, 21))) values = data.get("values", [100] * len(years)) highlights = spec.get("highlights", []) highlights_html = "" for h in highlights[:4]: if isinstance(h, dict): highlights_html += f"""
{h.get('label','')} {h.get('value','')}
""" else: highlights_html += f"""
{h}
""" return TEMPLATE_AREA_CHART.format( title=title, narrative=narrative, years_json=json.dumps(years), values_json=json.dumps(values), highlights_html=highlights_html, **c ) def _build_kpi_cards(self, spec, title, narrative, c): cards = spec.get("kpis", []) cards_html = "" for kpi in cards: badge_color = kpi.get("badge_color", c["teal"]) cards_html += f"""
{kpi.get('label','')}
{kpi.get('value','')}{kpi.get('unit','')}
{kpi.get('sub','')}
""" return TEMPLATE_KPI_CARDS.format( title=title, narrative=narrative, kpi_cards_html=cards_html, **c ) def _build_comparison(self, spec, title, narrative, c): columns = spec.get("columns", []) columns_html = "" for col in columns: badge_color = col.get("badge_color", c["teal"]) rows_html = "" for row in col.get("rows", []): rows_html += f"""
{row.get('label','')} {row.get('value','')}
""" columns_html += f"""
{col.get('badge','')}
{col.get('name','')}
{rows_html}
""" return TEMPLATE_COMPARISON.format( title=title, narrative=narrative, columns_html=columns_html, **c ) def _build_table(self, spec, title, narrative, c): # 简化表格模板 rows = spec.get("rows", []) header = spec.get("header", []) header_html = "".join(f"{h}" for h in header) rows_html = "" for row in rows: rows_html += "" + "".join(f"{v}" for v in row) + "" return f"""
{title}
{header_html}{rows_html}
{narrative}
""" def _build_simple(self, spec, title, narrative, c): return f"""
{title}
{narrative}
""" # ─── 批量渲染 ────────────────────────────────────────────── async def render_all_slides(slide_specs: list[dict], output_dir: str = "/tmp/slide_renders") -> list[str]: """批量渲染多张幻灯片""" renderer = SlideRenderer(output_dir=output_dir) paths = [] for i, spec in enumerate(slide_specs): spec["_id"] = i try: path = await renderer.render_async(spec) paths.append(path) print(f" ✓ Slide {i+1}: {path}") except Exception as e: print(f" ✗ Slide {i+1} failed: {e}") paths.append("") await renderer._close() return paths def render_all_slides_sync(slide_specs: list[dict], output_dir: str = "/tmp/slide_renders") -> list[str]: """批量渲染(同步封装)""" return asyncio.get_event_loop().run_until_complete(render_all_slides(slide_specs, output_dir)) # ─── CLI入口 ────────────────────────────────────────────── if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description="SlideRenderer — Playwright截图管线") parser.add_argument("--spec", type=str, help="JSON格式的幻灯片规格") parser.add_argument("--spec-file", type=str, help="幻灯片规格JSON文件") parser.add_argument("--output-dir", type=str, default="/tmp/slide_renders") args = parser.parse_args() if args.spec: spec = json.loads(args.spec) renderer = SlideRenderer(output_dir=args.output_dir) path = asyncio.get_event_loop().run_until_complete(renderer.render_async(spec)) print(path) asyncio.get_event_loop().run_until_complete(renderer._close()) elif args.spec_file: with open(args.spec_file) as f: specs = json.load(f) paths = render_all_slides_sync(specs, args.output_dir) print("\\n".join(paths)) else: # 测试模式:渲染示例幻灯片 test_specs = [ { "_id": 0, "type": "area_chart", "title": "账户价值增长", "narrative": "时间是最好的朋友,复利是最大的杠杆", "data": { "years": [1, 5, 10, 15, 20, 25, 30], "values": [500000, 580000, 720000, 950000, 1350000, 1900000, 2800000] }, "highlights": [ {"label": "回本年份", "value": "第5年"}, {"label": "20年倍数", "value": "2.7x"}, {"label": "30年倍数", "value": "5.6x"}, {"label": "IRR", "value": "6.2%"} ] }, { "_id": 1, "type": "kpi_cards", "title": "方案核心指标", "narrative": "用数据说话,让选择更清晰", "kpis": [ {"label": "年缴保费", "value": "100,000", "unit": "USD", "sub": "每年"}, {"label": "缴费期", "value": "5", "unit": "年", "sub": "短期规划"}, {"label": "回本年份", "value": "7", "unit": "年", "sub": "快速回本"}, {"label": "20年账户价值", "value": "135", "unit": "万USD", "sub": "稳健增值"}, {"label": "30年账户价值", "value": "280", "unit": "万USD", "sub": "代代传承"}, {"label": "预期IRR", "value": "6.2", "unit": "%", "sub": "超越定存"}, ] } ] print("Testing SlideRenderer with sample slides...") paths = render_all_slides_sync(test_specs, args.output_dir) print("\nRendered files:") for p in paths: if p: size = os.path.getsize(p) print(f" {p} ({size:,} bytes)")