""" 基于章节IR的HTML/PDF渲染器,实现与示例报告一致的交互与视觉。 """ from __future__ import annotations import ast import copy import html import json from typing import Any, Dict, List 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 # ====== 公共入口 ====== 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.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) metadata = self.metadata theme_tokens = metadata.get("themeTokens") or self.document.get("themeTokens", {}) title = metadata.get("title") or metadata.get("query") or "智能舆情报告" head = self._render_head(title, theme_tokens) body = self._render_body() return f"\n\n{head}\n{body}\n" # ====== Head / Body ====== 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) 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))}'
def _render_heading(self, block: Dict[str, Any]) -> str:
"""渲染heading block,确保锚点存在"""
original_level = max(1, min(6, block.get("level", 2)))
if original_level <= 2:
level = 2
elif original_level == 3:
level = 3
else:
level = min(original_level, 6)
anchor = block.get("anchor")
if anchor:
anchor_attr = self._escape_attr(anchor)
else:
self.heading_counter += 1
anchor = f"heading-{self.heading_counter}"
anchor_attr = self._escape_attr(anchor)
mapping = self.heading_label_map.get(anchor, {})
display_text = mapping.get("display") or block.get("text", "")
subtitle = block.get("subtitle")
subtitle_html = f'{self._escape_html(subtitle)}' if subtitle else ""
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 _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 = tokens.get("colors") or {}
typography = tokens.get("typography") or {}
fonts = tokens.get("fonts") or typography.get("fontFamily") or {}
spacing = tokens.get("spacing") or {}
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;
}}
.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);
}}
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;
}}
.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;
}}
}}
"""
def _hydration_script(self) -> str:
"""返回页面底部的JS,负责Chart.js注水与导出逻辑"""
return """
""".strip()
__all__ = ["HTMLRenderer"]