659 lines
22 KiB
Python
659 lines
22 KiB
Python
|
|
#!/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 = """<!DOCTYPE html>
|
|||
|
|
<html lang="zh-HK">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8">
|
|||
|
|
<style>
|
|||
|
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|||
|
|
body {{
|
|||
|
|
font-family: {font};
|
|||
|
|
width: 1280px; height: 720px;
|
|||
|
|
background: linear-gradient(135deg, {primary} 0%, #0D4F7A 100%);
|
|||
|
|
display: flex; flex-direction: column; padding: 40px;
|
|||
|
|
}}
|
|||
|
|
.header {{
|
|||
|
|
display: flex; align-items: center; margin-bottom: 24px;
|
|||
|
|
}}
|
|||
|
|
.accent-bar {{
|
|||
|
|
width: 6px; height: 48px; background: {gold}; border-radius: 3px;
|
|||
|
|
margin-right: 20px;
|
|||
|
|
}}
|
|||
|
|
.page-title {{
|
|||
|
|
font-size: 28px; font-weight: 600; color: {white}; letter-spacing: 1px;
|
|||
|
|
}}
|
|||
|
|
.chart-container {{
|
|||
|
|
flex: 1; display: flex; gap: 40px; align-items: stretch;
|
|||
|
|
}}
|
|||
|
|
.chart-area {{
|
|||
|
|
flex: 1; background: rgba(255,255,255,0.08); border-radius: 16px;
|
|||
|
|
padding: 24px; position: relative; display: flex; flex-direction: column;
|
|||
|
|
}}
|
|||
|
|
.narrative-card {{
|
|||
|
|
width: 320px; background: {white}; border-radius: 16px; padding: 28px;
|
|||
|
|
display: flex; flex-direction: column; justify-content: center;
|
|||
|
|
}}
|
|||
|
|
.narrative-label {{
|
|||
|
|
font-size: 13px; color: {teal}; font-weight: 600; letter-spacing: 2px;
|
|||
|
|
text-transform: uppercase; margin-bottom: 12px;
|
|||
|
|
}}
|
|||
|
|
.narrative-text {{
|
|||
|
|
font-size: 20px; color: {dark}; line-height: 1.6; font-weight: 500;
|
|||
|
|
}}
|
|||
|
|
.highlights {{
|
|||
|
|
margin-top: 24px; padding-top: 20px; border-top: 1px solid #EEE;
|
|||
|
|
}}
|
|||
|
|
.highlight-item {{
|
|||
|
|
display: flex; justify-content: space-between; margin: 8px 0;
|
|||
|
|
font-size: 14px;
|
|||
|
|
}}
|
|||
|
|
.highlight-label {{ color: #888; }}
|
|||
|
|
.highlight-value {{ color: {teal}; font-weight: 700; font-size: 16px; }}
|
|||
|
|
canvas {{ flex: 1; }}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<div class="header">
|
|||
|
|
<div class="accent-bar"></div>
|
|||
|
|
<div class="page-title">{title}</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="chart-container">
|
|||
|
|
<div class="chart-area">
|
|||
|
|
<canvas id="chart"></canvas>
|
|||
|
|
</div>
|
|||
|
|
<div class="narrative-card">
|
|||
|
|
<div class="narrative-label">核心叙事</div>
|
|||
|
|
<div class="narrative-text">{narrative}</div>
|
|||
|
|
<div class="highlights">
|
|||
|
|
{highlights_html}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
|||
|
|
<script>
|
|||
|
|
const ctx = document.getElementById('chart').getContext('2d');
|
|||
|
|
const gradient = ctx.createLinearGradient(0, 0, 0, 400);
|
|||
|
|
gradient.addColorStop(0, 'rgba(24,137,141,0.6)');
|
|||
|
|
gradient.addColorStop(1, 'rgba(24,137,141,0.05)');
|
|||
|
|
|
|||
|
|
new Chart(ctx, {{
|
|||
|
|
type: 'line',
|
|||
|
|
data: {{
|
|||
|
|
labels: {years_json},
|
|||
|
|
datasets: [{{
|
|||
|
|
label: '账户价值',
|
|||
|
|
data: {values_json},
|
|||
|
|
fill: true,
|
|||
|
|
backgroundColor: gradient,
|
|||
|
|
borderColor: '{teal}',
|
|||
|
|
borderWidth: 3,
|
|||
|
|
pointBackgroundColor: '{gold}',
|
|||
|
|
pointBorderColor: '{white}',
|
|||
|
|
pointBorderWidth: 2,
|
|||
|
|
pointRadius: 6,
|
|||
|
|
pointHoverRadius: 8,
|
|||
|
|
tension: 0.4
|
|||
|
|
}}]
|
|||
|
|
}},
|
|||
|
|
options: {{
|
|||
|
|
responsive: true,
|
|||
|
|
maintainAspectRatio: false,
|
|||
|
|
plugins: {{
|
|||
|
|
legend: {{ display: false }},
|
|||
|
|
tooltip: {{
|
|||
|
|
backgroundColor: '{primary}',
|
|||
|
|
titleColor: '{white}',
|
|||
|
|
bodyColor: '{white}',
|
|||
|
|
padding: 12,
|
|||
|
|
cornerRadius: 8,
|
|||
|
|
callbacks: {{
|
|||
|
|
label: ctx => `HK$ ${{ctx.raw.toLocaleString()}}`
|
|||
|
|
}}
|
|||
|
|
}}
|
|||
|
|
}},
|
|||
|
|
scales: {{
|
|||
|
|
x: {{
|
|||
|
|
grid: {{ color: 'rgba(255,255,255,0.1)' }},
|
|||
|
|
ticks: {{ color: 'rgba(255,255,255,0.7)', font: {{ size: 12 }} }}
|
|||
|
|
}},
|
|||
|
|
y: {{
|
|||
|
|
grid: {{ color: 'rgba(255,255,255,0.1)' }},
|
|||
|
|
ticks: {{
|
|||
|
|
color: 'rgba(255,255,255,0.7)',
|
|||
|
|
font: {{ size: 12 }},
|
|||
|
|
callback: v => `HK$ ${{(v/1000).toFixed(0)}}K`
|
|||
|
|
}}
|
|||
|
|
}}
|
|||
|
|
}}
|
|||
|
|
}}
|
|||
|
|
}});
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
TEMPLATE_KPI_CARDS = """<!DOCTYPE html>
|
|||
|
|
<html lang="zh-HK">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8">
|
|||
|
|
<style>
|
|||
|
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|||
|
|
body {{
|
|||
|
|
font-family: {font};
|
|||
|
|
width: 1280px; height: 720px;
|
|||
|
|
background: linear-gradient(135deg, {primary} 0%, #0D4F7A 100%);
|
|||
|
|
display: flex; flex-direction: column; padding: 48px;
|
|||
|
|
}}
|
|||
|
|
.header {{
|
|||
|
|
display: flex; align-items: center; margin-bottom: 36px;
|
|||
|
|
}}
|
|||
|
|
.accent-bar {{
|
|||
|
|
width: 6px; height: 48px; background: {gold}; border-radius: 3px;
|
|||
|
|
margin-right: 20px;
|
|||
|
|
}}
|
|||
|
|
.page-title {{
|
|||
|
|
font-size: 28px; font-weight: 600; color: {white}; letter-spacing: 1px;
|
|||
|
|
}}
|
|||
|
|
.kpi-grid {{
|
|||
|
|
flex: 1; display: grid; grid-template-columns: repeat(3, 1fr);
|
|||
|
|
grid-template-rows: repeat(2, 1fr);
|
|||
|
|
gap: 24px;
|
|||
|
|
}}
|
|||
|
|
.kpi-card {{
|
|||
|
|
background: {white}; border-radius: 20px; padding: 28px;
|
|||
|
|
display: flex; flex-direction: column; justify-content: space-between;
|
|||
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
|
|||
|
|
transition: transform 0.2s;
|
|||
|
|
}}
|
|||
|
|
.kpi-card:hover {{ transform: translateY(-4px); }}
|
|||
|
|
.kpi-label {{
|
|||
|
|
font-size: 13px; color: #888; letter-spacing: 2px; text-transform: uppercase;
|
|||
|
|
}}
|
|||
|
|
.kpi-value {{
|
|||
|
|
font-size: 42px; font-weight: 700; color: {teal}; line-height: 1.1;
|
|||
|
|
}}
|
|||
|
|
.kpi-unit {{
|
|||
|
|
font-size: 16px; color: {gold}; font-weight: 600; margin-left: 4px;
|
|||
|
|
}}
|
|||
|
|
.kpi-sub {{
|
|||
|
|
font-size: 13px; color: #AAA; margin-top: 4px;
|
|||
|
|
}}
|
|||
|
|
.narrative {{
|
|||
|
|
margin-top: 32px; text-align: center;
|
|||
|
|
}}
|
|||
|
|
.narrative-text {{
|
|||
|
|
font-size: 22px; color: rgba(255,255,255,0.9); font-weight: 500;
|
|||
|
|
letter-spacing: 1px;
|
|||
|
|
}}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<div class="header">
|
|||
|
|
<div class="accent-bar"></div>
|
|||
|
|
<div class="page-title">{title}</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="kpi-grid">
|
|||
|
|
{kpi_cards_html}
|
|||
|
|
</div>
|
|||
|
|
<div class="narrative">
|
|||
|
|
<div class="narrative-text">{narrative}</div>
|
|||
|
|
</div>
|
|||
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
|||
|
|
</body>
|
|||
|
|
</html>"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
TEMPLATE_COMPARISON = """<!DOCTYPE html>
|
|||
|
|
<html lang="zh-HK">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8">
|
|||
|
|
<style>
|
|||
|
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|||
|
|
body {{
|
|||
|
|
font-family: {font};
|
|||
|
|
width: 1280px; height: 720px;
|
|||
|
|
background: linear-gradient(135deg, {primary} 0%, #0D4F7A 100%);
|
|||
|
|
display: flex; flex-direction: column; padding: 40px;
|
|||
|
|
}}
|
|||
|
|
.header {{
|
|||
|
|
display: flex; align-items: center; margin-bottom: 28px;
|
|||
|
|
}}
|
|||
|
|
.accent-bar {{
|
|||
|
|
width: 6px; height: 48px; background: {gold}; border-radius: 3px;
|
|||
|
|
margin-right: 20px;
|
|||
|
|
}}
|
|||
|
|
.page-title {{
|
|||
|
|
font-size: 28px; font-weight: 600; color: {white}; letter-spacing: 1px;
|
|||
|
|
}}
|
|||
|
|
.comparison-container {{
|
|||
|
|
flex: 1; display: flex; gap: 20px;
|
|||
|
|
}}
|
|||
|
|
.product-col {{
|
|||
|
|
flex: 1; background: {white}; border-radius: 20px; padding: 28px;
|
|||
|
|
display: flex; flex-direction: column;
|
|||
|
|
}}
|
|||
|
|
.product-badge {{
|
|||
|
|
display: inline-block; padding: 6px 16px; border-radius: 20px;
|
|||
|
|
font-size: 13px; font-weight: 600; letter-spacing: 1px;
|
|||
|
|
margin-bottom: 20px; align-self: flex-start;
|
|||
|
|
}}
|
|||
|
|
.product-name {{
|
|||
|
|
font-size: 22px; font-weight: 700; color: {dark}; margin-bottom: 16px;
|
|||
|
|
}}
|
|||
|
|
.comparison-table {{
|
|||
|
|
flex: 1;
|
|||
|
|
}}
|
|||
|
|
.row {{
|
|||
|
|
display: flex; justify-content: space-between; padding: 14px 0;
|
|||
|
|
border-bottom: 1px solid #F0F0F0; font-size: 14px;
|
|||
|
|
}}
|
|||
|
|
.row:last-child {{ border-bottom: none; }}
|
|||
|
|
.row-label {{ color: #888; }}
|
|||
|
|
.row-value {{ color: {dark}; font-weight: 600; }}
|
|||
|
|
.highlight-row {{
|
|||
|
|
background: linear-gradient(90deg, rgba(24,137,141,0.08), transparent);
|
|||
|
|
border-radius: 8px; padding: 14px 12px;
|
|||
|
|
}}
|
|||
|
|
.narrative-bar {{
|
|||
|
|
margin-top: 24px; background: linear-gradient(135deg, {teal}, {primary});
|
|||
|
|
color: {white}; padding: 20px; border-radius: 12px; text-align: center;
|
|||
|
|
}}
|
|||
|
|
.narrative-text {{ font-size: 18px; font-weight: 500; }}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<div class="header">
|
|||
|
|
<div class="accent-bar"></div>
|
|||
|
|
<div class="page-title">{title}</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="comparison-container">
|
|||
|
|
{columns_html}
|
|||
|
|
</div>
|
|||
|
|
<div class="narrative-bar">
|
|||
|
|
<div class="narrative-text">{narrative}</div>
|
|||
|
|
</div>
|
|||
|
|
</body>
|
|||
|
|
</html>"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── 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"""
|
|||
|
|
<div class="highlight-item">
|
|||
|
|
<span class="highlight-label">{h.get('label','')}</span>
|
|||
|
|
<span class="highlight-value">{h.get('value','')}</span>
|
|||
|
|
</div>"""
|
|||
|
|
else:
|
|||
|
|
highlights_html += f"""
|
|||
|
|
<div class="highlight-item">
|
|||
|
|
<span class="highlight-label">{h}</span>
|
|||
|
|
<span class="highlight-value">—</span>
|
|||
|
|
</div>"""
|
|||
|
|
|
|||
|
|
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"""
|
|||
|
|
<div class="kpi-card">
|
|||
|
|
<div class="kpi-label">{kpi.get('label','')}</div>
|
|||
|
|
<div class="kpi-value">{kpi.get('value','')}<span class="kpi-unit">{kpi.get('unit','')}</span></div>
|
|||
|
|
<div class="kpi-sub">{kpi.get('sub','')}</div>
|
|||
|
|
</div>"""
|
|||
|
|
|
|||
|
|
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"""
|
|||
|
|
<div class="row">
|
|||
|
|
<span class="row-label">{row.get('label','')}</span>
|
|||
|
|
<span class="row-value">{row.get('value','')}</span>
|
|||
|
|
</div>"""
|
|||
|
|
|
|||
|
|
columns_html += f"""
|
|||
|
|
<div class="product-col">
|
|||
|
|
<div class="product-badge" style="background:{badge_color}20; color:{badge_color}">
|
|||
|
|
{col.get('badge','')}
|
|||
|
|
</div>
|
|||
|
|
<div class="product-name">{col.get('name','')}</div>
|
|||
|
|
<div class="comparison-table">{rows_html}</div>
|
|||
|
|
</div>"""
|
|||
|
|
|
|||
|
|
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"<th>{h}</th>" for h in header)
|
|||
|
|
rows_html = ""
|
|||
|
|
for row in rows:
|
|||
|
|
rows_html += "<tr>" + "".join(f"<td>{v}</td>" for v in row) + "</tr>"
|
|||
|
|
|
|||
|
|
return f"""<!DOCTYPE html>
|
|||
|
|
<html><head><meta charset="UTF-8">
|
|||
|
|
<style>
|
|||
|
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|||
|
|
body {{ font-family: {c['font']}; width: 1280px; height: 720px;
|
|||
|
|
background: linear-gradient(135deg, {c['primary']} 0%, #0D4F7A 100%);
|
|||
|
|
display: flex; flex-direction: column; padding: 40px; }}
|
|||
|
|
.header {{ display: flex; align-items: center; margin-bottom: 28px; }}
|
|||
|
|
.accent-bar {{ width: 6px; height: 48px; background: {c['gold']}; border-radius: 3px; margin-right: 20px; }}
|
|||
|
|
.page-title {{ font-size: 28px; font-weight: 600; color: {c['white']}; }}
|
|||
|
|
.table-wrap {{ flex: 1; background: white; border-radius: 16px; overflow: hidden; }}
|
|||
|
|
table {{ width: 100%; border-collapse: collapse; }}
|
|||
|
|
th {{ background: {c['primary']}; color: white; padding: 16px 20px; text-align: left; font-size: 14px; letter-spacing: 1px; }}
|
|||
|
|
td {{ padding: 14px 20px; border-bottom: 1px solid #F0F0F0; font-size: 14px; color: {c['dark']}; }}
|
|||
|
|
tr:last-child td {{ border-bottom: none; }}
|
|||
|
|
tr:nth-child(even) td {{ background: #F8F9FA; }}
|
|||
|
|
.narrative {{ margin-top: 24px; text-align: center; }}
|
|||
|
|
.narrative-text {{ font-size: 20px; color: rgba(255,255,255,0.9); font-weight: 500; }}
|
|||
|
|
</style></head>
|
|||
|
|
<body>
|
|||
|
|
<div class="header"><div class="accent-bar"></div><div class="page-title">{title}</div></div>
|
|||
|
|
<div class="table-wrap"><table><thead><tr>{header_html}</tr></thead><tbody>{rows_html}</tbody></table></div>
|
|||
|
|
<div class="narrative"><div class="narrative-text">{narrative}</div></div>
|
|||
|
|
</body></html>"""
|
|||
|
|
|
|||
|
|
def _build_simple(self, spec, title, narrative, c):
|
|||
|
|
return f"""<!DOCTYPE html>
|
|||
|
|
<html><head><meta charset="UTF-8">
|
|||
|
|
<style>
|
|||
|
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|||
|
|
body {{ font-family: {c['font']}; width: 1280px; height: 720px;
|
|||
|
|
background: linear-gradient(135deg, {c['primary']} 0%, #0D4F7A 100%);
|
|||
|
|
display: flex; align-items: center; justify-content: center; flex-direction: column; padding: 48px; }}
|
|||
|
|
.accent-bar {{ width: 8px; height: 64px; background: {c['gold']}; border-radius: 4px; margin-bottom: 32px; }}
|
|||
|
|
.page-title {{ font-size: 36px; font-weight: 700; color: {c['white']}; text-align: center; margin-bottom: 20px; }}
|
|||
|
|
.narrative-text {{ font-size: 22px; color: rgba(255,255,255,0.85); text-align: center; max-width: 800px; line-height: 1.6; }}
|
|||
|
|
</style></head>
|
|||
|
|
<body>
|
|||
|
|
<div class="accent-bar"></div>
|
|||
|
|
<div class="page-title">{title}</div>
|
|||
|
|
<div class="narrative-text">{narrative}</div>
|
|||
|
|
</body></html>"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── 批量渲染 ──────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
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)")
|