""" 基于章节IR的HTML/PDF渲染器,实现与示例报告一致的交互与视觉。 """ from __future__ import annotations import ast import copy import html import json import os import re import base64 from pathlib import Path from typing import Any, Dict, List from loguru import logger from ReportEngine.utils.chart_validator import ( ChartValidator, ChartRepairer, create_chart_validator, create_chart_repairer ) from ReportEngine.utils.chart_repair_api import create_llm_repair_functions class HTMLRenderer: """ Document IR → HTML 渲染器。 - 读取 IR metadata/chapters,将结构映射为响应式HTML; - 动态构造目录、锚点、Chart.js脚本及互动逻辑; - 提供主题变量、编号映射等辅助功能。 """ CALLOUT_ALLOWED_TYPES = { "paragraph", "list", "table", "blockquote", "code", "math", "figure", "kpiGrid", } INLINE_ARTIFACT_KEYS = { "props", "widgetId", "widgetType", "data", "dataRef", "datasets", "labels", "config", "options", } TABLE_COMPLEX_CHARS = set( "@%%()(),,。;;::、??!!·…-—_+<>[]{}|\\/\"'`~$^&*#" ) def __init__(self, config: Dict[str, Any] | None = None): """初始化渲染器缓存并允许注入额外配置(如主题覆盖)""" self.config = config or {} self.document: Dict[str, Any] = {} self.widget_scripts: List[str] = [] self.chart_counter = 0 self.toc_entries: List[Dict[str, Any]] = [] self.heading_counter = 0 self.metadata: Dict[str, Any] = {} self.chapters: List[Dict[str, Any]] = [] self.chapter_anchor_map: Dict[str, str] = {} self.heading_label_map: Dict[str, Dict[str, Any]] = {} self.primary_heading_index = 0 self.secondary_heading_index = 0 self.toc_rendered = False self.hero_kpi_signature: tuple | None = None self._lib_cache: Dict[str, str] = {} self._pdf_font_base64: str | None = None # 初始化图表验证和修复器 self.chart_validator = create_chart_validator() llm_repair_fns = create_llm_repair_functions() self.chart_repairer = create_chart_repairer( validator=self.chart_validator, llm_repair_fns=llm_repair_fns ) # 统计信息 self.chart_validation_stats = { 'total': 0, 'valid': 0, 'repaired_locally': 0, 'repaired_api': 0, 'failed': 0 } @staticmethod def _get_lib_path() -> Path: """获取第三方库文件的目录路径""" return Path(__file__).parent / "libs" @staticmethod def _get_font_path() -> Path: """返回PDF导出所需字体的路径(使用优化后的子集字体)""" return Path(__file__).parent / "assets" / "fonts" / "SourceHanSerifSC-Medium-Subset.ttf" def _load_lib(self, filename: str) -> str: """ 加载指定的第三方库文件内容 参数: filename: 库文件名 返回: str: 库文件的JavaScript代码内容 """ if filename in self._lib_cache: return self._lib_cache[filename] lib_path = self._get_lib_path() / filename try: with open(lib_path, 'r', encoding='utf-8') as f: content = f.read() self._lib_cache[filename] = content return content except FileNotFoundError: print(f"警告: 库文件 {filename} 未找到,将使用CDN备用链接") return "" except Exception as e: print(f"警告: 读取库文件 {filename} 时出错: {e}") return "" def _load_pdf_font_data(self) -> str: """加载PDF字体的Base64数据,避免重复读取大型文件""" if self._pdf_font_base64 is not None: return self._pdf_font_base64 font_path = self._get_font_path() try: data = font_path.read_bytes() self._pdf_font_base64 = base64.b64encode(data).decode("ascii") return self._pdf_font_base64 except FileNotFoundError: logger.warning("PDF字体文件缺失:%s", font_path) except Exception as exc: logger.warning("读取PDF字体文件失败:%s (%s)", font_path, exc) self._pdf_font_base64 = "" return self._pdf_font_base64 # ====== 公共入口 ====== def render(self, document_ir: Dict[str, Any]) -> str: """ 接收Document IR,重置内部状态并输出完整HTML。 参数: document_ir: 由 DocumentComposer 生成的整本报告数据。 返回: str: 可直接写入磁盘的完整HTML文档。 """ self.document = document_ir or {} self.widget_scripts = [] self.chart_counter = 0 self.heading_counter = 0 self.metadata = self.document.get("metadata", {}) or {} raw_chapters = self.document.get("chapters", []) or [] self.toc_rendered = False self.chapters = self._prepare_chapters(raw_chapters) self.chapter_anchor_map = { chapter.get("chapterId"): chapter.get("anchor") for chapter in self.chapters if chapter.get("chapterId") and chapter.get("anchor") } self.heading_label_map = self._compute_heading_labels(self.chapters) self.toc_entries = self._collect_toc_entries(self.chapters) # 重置图表验证统计 self.chart_validation_stats = { 'total': 0, 'valid': 0, 'repaired_locally': 0, 'repaired_api': 0, 'failed': 0 } metadata = self.metadata theme_tokens = metadata.get("themeTokens") or self.document.get("themeTokens", {}) title = metadata.get("title") or metadata.get("query") or "智能舆情报告" hero_kpis = (metadata.get("hero") or {}).get("kpis") self.hero_kpi_signature = self._kpi_signature_from_items(hero_kpis) head = self._render_head(title, theme_tokens) body = self._render_body() # 输出图表验证统计 self._log_chart_validation_stats() return f"\n\n{head}\n{body}\n" # ====== 头部 / 正文 ====== def _resolve_color_value(self, value: Any, fallback: str) -> str: """从颜色token中提取字符串值""" if isinstance(value, str): value = value.strip() return value or fallback if isinstance(value, dict): for key in ("main", "value", "color", "base", "default"): candidate = value.get(key) if isinstance(candidate, str) and candidate.strip(): return candidate.strip() for candidate in value.values(): if isinstance(candidate, str) and candidate.strip(): return candidate.strip() return fallback def _resolve_color_family(self, value: Any, fallback: Dict[str, str]) -> Dict[str, str]: """解析主/亮/暗三色,缺失时回落到默认值""" result = { "main": fallback.get("main", "#007bff"), "light": fallback.get("light", fallback.get("main", "#007bff")), "dark": fallback.get("dark", fallback.get("main", "#007bff")), } if isinstance(value, str): stripped = value.strip() if stripped: result["main"] = stripped return result if isinstance(value, dict): result["main"] = self._resolve_color_value(value.get("main") or value, result["main"]) result["light"] = self._resolve_color_value(value.get("light") or value.get("lighter"), result["light"]) result["dark"] = self._resolve_color_value(value.get("dark") or value.get("darker"), result["dark"]) return result def _render_head(self, title: str, theme_tokens: Dict[str, Any]) -> str: """ 渲染
部分,加载主题CSS与必要的脚本依赖。 参数: title: 页面title标签内容。 theme_tokens: 主题变量,用于注入CSS。 返回: str: head片段HTML。 """ css = self._build_css(theme_tokens) # 加载第三方库 chartjs = self._load_lib("chart.js") chartjs_sankey = self._load_lib("chartjs-chart-sankey.js") html2canvas = self._load_lib("html2canvas.min.js") jspdf = self._load_lib("jspdf.umd.min.js") mathjax = self._load_lib("mathjax.js") # 如果库文件加载失败,使用CDN备用链接 chartjs_tag = f"" if chartjs else '' sankey_tag = f"" if chartjs_sankey else '' html2canvas_tag = f"" if html2canvas else '' jspdf_tag = f"" if jspdf else '' mathjax_tag = f"" if mathjax else '' # 加载PDF字体数据 pdf_font_data = self._load_pdf_font_data() pdf_font_script = f"" if pdf_font_data else "" return f"""{self._escape_html(subtitle)}
{self._render_tagline()}{self._escape_html(tagline)}
' def _render_cover(self) -> str: """ 文章开头的封面区,居中展示标题与“文章总览”提示。 返回: str: cover section HTML。 """ title = self.metadata.get("title") or "智能舆情报告" subtitle = self.metadata.get("subtitle") or self.metadata.get("templateName") or "" overview_hint = "文章总览" return f"""{overview_hint}
{self._escape_html(subtitle)}
{self._escape_html(summary)}
' if summary else "" highlights = hero.get("highlights") or [] highlight_html = "".join( f'{self._escape_html(desc)}
' if desc else "" level = entry.get("level", 2) css_level = 1 if level <= 2 else min(level, 4) return f'{self._escape_html(json.dumps(block, ensure_ascii=False, indent=2))}'
return self._wrap_error_block(fallback, block)
def _wrap_error_block(self, html_fragment: str, block: Dict[str, Any]) -> str:
"""若block标记了error元数据,则包裹提示容器并注入tooltip。"""
if not html_fragment:
return html_fragment
meta = block.get("meta") or {}
log_ref = meta.get("errorLogRef")
if not isinstance(log_ref, dict):
return html_fragment
raw_preview = (meta.get("rawJsonPreview") or "")[:1200]
error_message = meta.get("errorMessage") or "LLM返回块解析错误"
importance = meta.get("importance") or "standard"
ref_label = ""
if log_ref.get("relativeFile") and log_ref.get("entryId"):
ref_label = f"{log_ref['relativeFile']}#{log_ref['entryId']}"
tooltip = f"{error_message} | {ref_label}".strip()
attr_raw = self._escape_attr(raw_preview or tooltip)
attr_title = self._escape_attr(tooltip)
class_suffix = self._escape_attr(importance)
return (
f'{inlines}
" def _render_list(self, block: Dict[str, Any]) -> str: """渲染有序/无序/任务列表""" list_type = block.get("listType", "bullet") tag = "ol" if list_type == "ordered" else "ul" extra_class = "task-list" if list_type == "task" else "" items_html = "" for item in block.get("items", []): content = self._render_blocks(item) if not content.strip(): continue items_html += f"{inner}" def _render_code(self, block: Dict[str, Any]) -> str: """渲染代码块,附带语言信息""" lang = block.get("lang") or "" content = self._escape_html(block.get("content", "")) return f'
{content}'
def _render_math(self, block: Dict[str, Any]) -> str:
"""渲染数学公式,占位符交给外部MathJax或后处理"""
latex = self._escape_html(block.get("latex", ""))
return f'| 类别 | {header_cells}
|---|
")
suffix.insert(0, "")
elif mark_type == "highlight":
prefix.append("")
suffix.insert(0, "")
elif mark_type == "link":
href_raw = mark.get("href")
if href_raw and href_raw != "#":
href = self._escape_attr(href_raw)
title = self._escape_attr(mark.get("title") or "")
prefix.append(f'')
suffix.insert(0, "")
else:
prefix.append('')
suffix.insert(0, "")
elif mark_type == "color":
value = mark.get("value")
if value:
styles.append(f"color: {value}")
elif mark_type == "font":
family = mark.get("family")
size = mark.get("size")
weight = mark.get("weight")
if family:
styles.append(f"font-family: {family}")
if size:
styles.append(f"font-size: {size}")
if weight:
styles.append(f"font-weight: {weight}")
elif mark_type == "underline":
styles.append("text-decoration: underline")
elif mark_type == "strike":
styles.append("text-decoration: line-through")
elif mark_type == "subscript":
prefix.append("")
suffix.insert(0, "")
elif mark_type == "superscript":
prefix.append("")
suffix.insert(0, "")
if styles:
style_attr = "; ".join(styles)
prefix.insert(0, f'')
suffix.append("")
if not marks and "**" in (run.get("text") or ""):
return self._render_markdown_bold_fallback(run.get("text", ""))
return "".join(prefix) + text + "".join(suffix)
def _render_markdown_bold_fallback(self, text: str) -> str:
"""在LLM未使用marks时兜底转换**粗体**"""
if not text:
return ""
result: List[str] = []
cursor = 0
while True:
start = text.find("**", cursor)
if start == -1:
result.append(html.escape(text[cursor:]))
break
end = text.find("**", start + 2)
if end == -1:
result.append(html.escape(text[cursor:]))
break
result.append(html.escape(text[cursor:start]))
bold_content = html.escape(text[start + 2:end])
result.append(f"{bold_content}")
cursor = end + 2
return "".join(result)
# ====== 文本 / 安全工具 ======
def _clean_text_from_json_artifacts(self, text: Any) -> str:
"""
清理文本中的JSON片段和伪造的结构标记。
LLM有时会在文本字段中混入未完成的JSON片段,如:
"描述文本,{ \"chapterId\": \"S3" 或 "描述文本,{ \"level\": 2"
此方法会:
1. 移除不完整的JSON对象(以 { 开头但未正确闭合的)
2. 移除不完整的JSON数组(以 [ 开头但未正确闭合的)
3. 移除孤立的JSON键值对片段
参数:
text: 可能包含JSON片段的文本
返回:
str: 清理后的纯文本
"""
if not text:
return ""
text_str = self._safe_text(text)
# 模式1: 移除以逗号+空白+{开头的不完整JSON对象
# 例如: "文本,{ \"key\": \"value\"" 或 "文本,{\\n \"key\""
text_str = re.sub(r',\s*\{[^}]*$', '', text_str)
# 模式2: 移除以逗号+空白+[开头的不完整JSON数组
text_str = re.sub(r',\s*\[[^\]]*$', '', text_str)
# 模式3: 移除孤立的 { 加上后续内容(如果没有匹配的 })
# 检查是否有未闭合的 {
open_brace_pos = text_str.rfind('{')
if open_brace_pos != -1:
close_brace_pos = text_str.rfind('}')
if close_brace_pos < open_brace_pos:
# { 在 } 后面或没有 },说明是未闭合的
# 截断到 { 之前
text_str = text_str[:open_brace_pos].rstrip(',,、 \t\n')
# 模式4: 类似处理 [
open_bracket_pos = text_str.rfind('[')
if open_bracket_pos != -1:
close_bracket_pos = text_str.rfind(']')
if close_bracket_pos < open_bracket_pos:
# [ 在 ] 后面或没有 ],说明是未闭合的
text_str = text_str[:open_bracket_pos].rstrip(',,、 \t\n')
# 模式5: 移除看起来像JSON键值对的片段,如 "chapterId": "S3
# 这种情况通常出现在上面的模式之后
text_str = re.sub(r',?\s*"[^"]+"\s*:\s*"[^"]*$', '', text_str)
text_str = re.sub(r',?\s*"[^"]+"\s*:\s*[^,}\]]*$', '', text_str)
# 清理末尾的逗号和空白
text_str = text_str.rstrip(',,、 \t\n')
return text_str.strip()
def _safe_text(self, value: Any) -> str:
"""将任意值安全转换为字符串,None与复杂对象容错"""
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, (int, float, bool)):
return str(value)
try:
return json.dumps(value, ensure_ascii=False)
except (TypeError, ValueError):
return str(value)
def _escape_html(self, value: Any) -> str:
"""HTML文本上下文的转义"""
return html.escape(self._safe_text(value), quote=False)
def _escape_attr(self, value: Any) -> str:
"""HTML属性上下文转义并去掉危险换行"""
escaped = html.escape(self._safe_text(value), quote=True)
return escaped.replace("\n", " ").replace("\r", " ")
# ====== CSS / JS(样式与脚本) ======
def _build_css(self, tokens: Dict[str, Any]) -> str:
"""根据主题token拼接整页CSS,包括响应式与打印样式"""
# 安全获取各个配置项,确保都是字典类型
colors_raw = tokens.get("colors")
colors = colors_raw if isinstance(colors_raw, dict) else {}
typography_raw = tokens.get("typography")
typography = typography_raw if isinstance(typography_raw, dict) else {}
# 安全获取fonts,确保是字典类型
fonts_raw = tokens.get("fonts") or typography.get("fonts")
if isinstance(fonts_raw, dict):
fonts = fonts_raw
else:
# 如果fonts是字符串或None,构造一个字典
font_family = typography.get("fontFamily")
if isinstance(font_family, str):
fonts = {"body": font_family, "heading": font_family}
else:
fonts = {}
spacing_raw = tokens.get("spacing")
spacing = spacing_raw if isinstance(spacing_raw, dict) else {}
primary_palette = self._resolve_color_family(
colors.get("primary"),
{"main": "#1a365d", "light": "#2d3748", "dark": "#0f1a2d"},
)
secondary_palette = self._resolve_color_family(
colors.get("secondary"),
{"main": "#e53e3e", "light": "#fc8181", "dark": "#c53030"},
)
bg = self._resolve_color_value(
colors.get("bg") or colors.get("background") or colors.get("surface"),
"#f8f9fa",
)
text_color = self._resolve_color_value(
colors.get("text") or colors.get("onBackground"),
"#212529",
)
card = self._resolve_color_value(
colors.get("card") or colors.get("surfaceCard"),
"#ffffff",
)
border = self._resolve_color_value(
colors.get("border") or colors.get("divider"),
"#dee2e6",
)
shadow = "rgba(0,0,0,0.08)"
container_width = spacing.get("container") or spacing.get("containerWidth") or "1200px"
gutter = spacing.get("gutter") or spacing.get("pagePadding") or "24px"
body_font = fonts.get("body") or fonts.get("primary") or "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
heading_font = fonts.get("heading") or fonts.get("primary") or fonts.get("secondary") or body_font
return f"""
:root {{
--bg-color: {bg};
--text-color: {text_color};
--primary-color: {primary_palette["main"]};
--primary-color-light: {primary_palette["light"]};
--primary-color-dark: {primary_palette["dark"]};
--secondary-color: {secondary_palette["main"]};
--secondary-color-light: {secondary_palette["light"]};
--secondary-color-dark: {secondary_palette["dark"]};
--card-bg: {card};
--border-color: {border};
--shadow-color: {shadow};
}}
.dark-mode {{
--bg-color: #121212;
--text-color: #e0e0e0;
--primary-color: #6ea8fe;
--primary-color-light: #91caff;
--primary-color-dark: #1f6feb;
--secondary-color: #f28b82;
--secondary-color-light: #f9b4ae;
--secondary-color-dark: #d9655c;
--card-bg: #1f1f1f;
--border-color: #2c2c2c;
--shadow-color: rgba(0, 0, 0, 0.4);
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
font-family: {body_font};
background: linear-gradient(180deg, rgba(0,0,0,0.04), rgba(0,0,0,0)) fixed, var(--bg-color);
color: var(--text-color);
line-height: 1.7;
min-height: 100vh;
transition: background-color 0.45s ease, color 0.45s ease;
}}
.report-header, main, .hero-section, .chapter, .chart-card, .callout, .kpi-card, .toc, .table-wrap {{
transition: background-color 0.45s ease, color 0.45s ease, border-color 0.45s ease, box-shadow 0.45s ease;
}}
.report-header {{
position: sticky;
top: 0;
z-index: 10;
background: var(--card-bg);
padding: 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
box-shadow: 0 2px 6px var(--shadow-color);
}}
.tagline {{
margin: 4px 0 0;
color: var(--secondary-color);
font-size: 0.95rem;
}}
.hero-section {{
display: flex;
flex-wrap: wrap;
gap: 24px;
padding: 24px;
border-radius: 20px;
background: linear-gradient(135deg, rgba(0,123,255,0.1), rgba(23,162,184,0.1));
border: 1px solid rgba(0,0,0,0.08);
margin-bottom: 32px;
}}
.hero-content {{
flex: 2;
min-width: 260px;
}}
.hero-side {{
flex: 1;
min-width: 220px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}}
.hero-kpi {{
background: var(--card-bg);
border-radius: 14px;
padding: 16px;
box-shadow: 0 6px 16px var(--shadow-color);
}}
.hero-kpi .label {{
font-size: 0.9rem;
color: var(--secondary-color);
}}
.hero-kpi .value {{
font-size: 1.8rem;
font-weight: 700;
}}
.hero-highlights {{
list-style: none;
padding: 0;
margin: 16px 0;
display: flex;
flex-wrap: wrap;
gap: 10px;
}}
.hero-highlights li {{
margin: 0;
}}
.badge {{
display: inline-flex;
align-items: center;
padding: 6px 12px;
border-radius: 999px;
background: rgba(0,0,0,0.05);
font-size: 0.9rem;
}}
.broken-link {{
text-decoration: underline dotted;
color: var(--primary-color);
}}
.hero-actions {{
display: flex;
flex-wrap: wrap;
gap: 12px;
}}
.ghost-btn {{
border: 1px solid var(--primary-color);
background: transparent;
color: var(--primary-color);
border-radius: 999px;
padding: 8px 16px;
cursor: pointer;
}}
.hero-summary {{
font-size: 1.05rem;
font-weight: 500;
margin-top: 0;
}}
.llm-error-block {{
border: 1px dashed var(--secondary-color);
border-radius: 12px;
padding: 12px;
margin: 12px 0;
background: rgba(229,62,62,0.06);
position: relative;
}}
.llm-error-block.importance-critical {{
border-color: var(--secondary-color-dark);
background: rgba(229,62,62,0.12);
}}
.llm-error-block::after {{
content: attr(data-raw);
white-space: pre-wrap;
position: absolute;
left: 0;
right: 0;
bottom: 100%;
max-height: 240px;
overflow: auto;
background: rgba(0,0,0,0.85);
color: #fff;
font-size: 0.85rem;
padding: 12px;
border-radius: 10px;
margin-bottom: 8px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
z-index: 20;
}}
.llm-error-block:hover::after {{
opacity: 1;
}}
.report-header h1 {{
margin: 0;
font-size: 1.6rem;
color: var(--primary-color);
}}
.report-header .subtitle {{
margin: 4px 0 0;
color: var(--secondary-color);
}}
.header-actions {{
display: flex;
gap: 12px;
flex-wrap: wrap;
}}
.cover {{
text-align: center;
margin: 20px 0 40px;
}}
.cover h1 {{
font-size: 2.4rem;
margin: 0.4em 0;
}}
.cover-hint {{
letter-spacing: 0.4em;
color: var(--secondary-color);
font-size: 0.95rem;
}}
.cover-subtitle {{
color: var(--secondary-color);
margin: 0;
}}
.action-btn {{
border: none;
border-radius: 6px;
background: var(--primary-color);
color: #fff;
padding: 10px 16px;
cursor: pointer;
font-size: 0.95rem;
transition: transform 0.2s ease;
min-width: 160px;
white-space: nowrap;
display: inline-flex;
align-items: center;
justify-content: center;
}}
.action-btn:hover {{
transform: translateY(-1px);
}}
body.exporting {{
cursor: progress;
}}
.export-overlay {{
position: fixed;
inset: 0;
background: rgba(3, 9, 26, 0.55);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
z-index: 999;
}}
.export-overlay.active {{
opacity: 1;
pointer-events: all;
}}
.export-dialog {{
background: rgba(12, 19, 38, 0.92);
padding: 24px 32px;
border-radius: 18px;
color: #fff;
text-align: center;
min-width: 280px;
box-shadow: 0 16px 40px rgba(0,0,0,0.45);
}}
.export-spinner {{
width: 48px;
height: 48px;
border-radius: 50%;
border: 3px solid rgba(255,255,255,0.2);
border-top-color: var(--secondary-color);
margin: 0 auto 16px;
animation: export-spin 1s linear infinite;
}}
.export-status {{
margin: 0;
font-size: 1rem;
}}
.exporting *,
.exporting *::before,
.exporting *::after {{
animation: none !important;
transition: none !important;
}}
.export-progress {{
width: 220px;
height: 6px;
background: rgba(255,255,255,0.25);
border-radius: 999px;
overflow: hidden;
margin: 20px auto 0;
position: relative;
}}
.export-progress-bar {{
position: absolute;
top: 0;
bottom: 0;
width: 45%;
border-radius: inherit;
background: linear-gradient(90deg, var(--primary-color), var(--secondary-color));
animation: export-progress 1.4s ease-in-out infinite;
}}
@keyframes export-spin {{
from {{ transform: rotate(0deg); }}
to {{ transform: rotate(360deg); }}
}}
@keyframes export-progress {{
0% {{ left: -45%; }}
50% {{ left: 20%; }}
100% {{ left: 110%; }}
}}
main {{
max-width: {container_width};
margin: 40px auto;
padding: {gutter};
background: var(--card-bg);
border-radius: 16px;
box-shadow: 0 10px 30px var(--shadow-color);
}}
h1, h2, h3, h4, h5, h6 {{
font-family: {heading_font};
color: var(--text-color);
margin-top: 2em;
margin-bottom: 0.6em;
line-height: 1.35;
}}
h2 {{
font-size: 1.9rem;
}}
h3 {{
font-size: 1.4rem;
}}
h4 {{
font-size: 1.2rem;
}}
p {{
margin: 1em 0;
text-align: justify;
}}
ul, ol {{
margin-left: 1.5em;
padding-left: 0;
}}
img, canvas, svg {{
max-width: 100%;
height: auto;
}}
.meta-card {{
background: rgba(0,0,0,0.02);
border-radius: 12px;
padding: 20px;
border: 1px solid var(--border-color);
}}
.meta-card ul {{
list-style: none;
padding: 0;
margin: 0;
}}
.meta-card li {{
display: flex;
justify-content: space-between;
border-bottom: 1px dashed var(--border-color);
padding: 8px 0;
}}
.toc {{
margin-top: 30px;
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 20px;
background: rgba(0,0,0,0.01);
}}
.toc-title {{
font-weight: 600;
margin-bottom: 10px;
}}
.toc ul {{
list-style: none;
margin: 0;
padding: 0;
}}
.toc li {{
margin: 4px 0;
}}
.toc li.level-1 {{
font-size: 1.05rem;
font-weight: 600;
margin-top: 12px;
}}
.toc li.level-2 {{
margin-left: 12px;
}}
.toc li a {{
color: var(--primary-color);
text-decoration: none;
}}
.toc li.level-3 {{
margin-left: 16px;
font-size: 0.95em;
}}
.toc-desc {{
margin: 2px 0 0;
color: var(--secondary-color);
font-size: 0.9rem;
}}
.toc-desc {{
margin: 2px 0 0;
color: var(--secondary-color);
font-size: 0.9rem;
}}
.chapter {{
margin-top: 40px;
padding-top: 32px;
border-top: 1px solid rgba(0,0,0,0.05);
}}
.chapter:first-of-type {{
border-top: none;
padding-top: 0;
}}
blockquote {{
border-left: 4px solid var(--primary-color);
padding: 12px 16px;
background: rgba(0,0,0,0.04);
border-radius: 0 8px 8px 0;
}}
.table-wrap {{
overflow-x: auto;
margin: 20px 0;
}}
table {{
width: 100%;
border-collapse: collapse;
}}
table th, table td {{
padding: 12px;
border: 1px solid var(--border-color);
}}
table th {{
background: rgba(0,0,0,0.03);
}}
.align-center {{ text-align: center; }}
.align-right {{ text-align: right; }}
.callout {{
border-left: 4px solid var(--primary-color);
padding: 16px;
border-radius: 8px;
margin: 20px 0;
background: rgba(0,0,0,0.02);
}}
.callout.tone-warning {{ border-color: #ff9800; }}
.callout.tone-success {{ border-color: #2ecc71; }}
.callout.tone-danger {{ border-color: #e74c3c; }}
.kpi-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin: 20px 0;
}}
.kpi-card {{
padding: 16px;
border-radius: 12px;
background: rgba(0,0,0,0.02);
border: 1px solid var(--border-color);
}}
.kpi-value {{
font-size: 2rem;
font-weight: 700;
}}
.kpi-label {{
color: var(--secondary-color);
}}
.delta.up {{ color: #27ae60; }}
.delta.down {{ color: #e74c3c; }}
.delta.neutral {{ color: var(--secondary-color); }}
.chart-card {{
margin: 30px 0;
padding: 20px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: rgba(0,0,0,0.01);
}}
.chart-container {{
position: relative;
min-height: 320px;
}}
.chart-fallback {{
display: none;
margin-top: 12px;
font-size: 0.85rem;
overflow-x: auto;
}}
.no-js .chart-fallback {{
display: block;
}}
.no-js .chart-container {{
display: none;
}}
.chart-fallback table {{
width: 100%;
border-collapse: collapse;
}}
.chart-fallback th,
.chart-fallback td {{
border: 1px solid var(--border-color);
padding: 6px 8px;
text-align: left;
}}
.chart-fallback th {{
background: rgba(0,0,0,0.04);
}}
.chart-note {{
margin-top: 8px;
font-size: 0.85rem;
color: var(--secondary-color);
}}
figure {{
margin: 20px 0;
text-align: center;
}}
figure img {{
max-width: 100%;
border-radius: 12px;
}}
.figure-placeholder {{
padding: 16px;
border: 1px dashed var(--border-color);
border-radius: 12px;
color: var(--secondary-color);
text-align: center;
font-size: 0.95rem;
margin: 20px 0;
}}
.math-block {{
text-align: center;
font-size: 1.1rem;
margin: 24px 0;
}}
.math-inline {{
font-family: {fonts.get("heading", fonts.get("body", "sans-serif"))};
font-style: italic;
white-space: nowrap;
padding: 0 0.15em;
}}
pre.code-block {{
background: #1e1e1e;
color: #fff;
padding: 16px;
border-radius: 12px;
overflow-x: auto;
}}
@media (max-width: 768px) {{
.report-header {{
flex-direction: column;
align-items: flex-start;
}}
main {{
margin: 0;
border-radius: 0;
}}
}}
@media print {{
.no-print {{ display: none !important; }}
body {{
background: #fff;
}}
main {{
box-shadow: none;
margin: 0;
max-width: 100%;
}}
.chapter > *,
.hero-section,
.callout,
.chart-card,
.kpi-grid,
.table-wrap,
figure,
blockquote {{
break-inside: avoid;
page-break-inside: avoid;
max-width: 100%;
}}
.chapter h2,
.chapter h3,
.chapter h4 {{
break-after: avoid;
page-break-after: avoid;
break-inside: avoid;
}}
.chart-card,
.table-wrap {{
overflow: visible !important;
max-width: 100% !important;
box-sizing: border-box;
}}
.chart-card canvas {{
width: 100% !important;
height: auto !important;
max-width: 100% !important;
}}
.table-wrap {{
overflow-x: auto;
max-width: 100%;
}}
.table-wrap table {{
table-layout: fixed;
width: 100%;
max-width: 100%;
}}
.table-wrap table th,
.table-wrap table td {{
word-break: break-word;
overflow-wrap: break-word;
}}
/* 防止图片和图表溢出 */
img, canvas, svg {{
max-width: 100% !important;
height: auto !important;
}}
/* 确保所有容器不超出页面宽度 */
* {{
box-sizing: border-box;
max-width: 100%;
}}
}}
"""
def _hydration_script(self) -> str:
"""返回页面底部的JS,负责Chart.js注水与导出逻辑"""
return """
""".strip()
__all__ = ["HTMLRenderer"]