""" 基于章节IR的HTML/PDF渲染器,实现与示例报告一致的交互与视觉。 """ from __future__ import annotations import html import json from typing import Any, Dict, List class HTMLRenderer: """Document IR → HTML 渲染器""" 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.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""" self.document = document_ir or {} self.widget_scripts = [] self.chart_counter = 0 self.heading_counter = 0 self.metadata = self.document.get("metadata", {}) or {} self.chapter_anchor_map = { chapter.get("chapterId"): chapter.get("anchor") for chapter in self.document.get("chapters", []) if chapter.get("chapterId") and chapter.get("anchor") } self.heading_label_map = self._compute_heading_labels(self.document.get("chapters", [])) self.toc_entries = self._collect_toc_entries( self.document.get("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 _render_head(self, title: str, theme_tokens: Dict[str, Any]) -> str: """渲染部分,加载主题CSS与必要的脚本依赖""" css = self._build_css(theme_tokens) return f""" {self._escape_html(title)} """.strip() def _render_body(self) -> str: """拼装结构,包含头部、导航、章节和脚本""" header = self._render_header() cover = self._render_cover() hero = self._render_hero() toc_section = self._render_toc_section() chapters = "".join( self._render_chapter(chapter) for chapter in self.document.get("chapters", []) ) widget_scripts = "\n".join(self.widget_scripts) hydration = self._hydration_script() return f""" {header}
{cover} {hero} {toc_section} {chapters}
{widget_scripts} {hydration} """.strip() # ====== Header / Meta / TOC ====== def _render_header(self) -> str: """渲染吸顶头部,包含标题、副标题与功能按钮""" metadata = self.metadata title = metadata.get("title") or "智能舆情分析报告" subtitle = metadata.get("subtitle") or metadata.get("templateName") or "自动生成" return f"""

{self._escape_html(title)}

{self._escape_html(subtitle)}

{self._render_tagline()}
""".strip() def _render_tagline(self) -> str: """渲染标题下方的标语,如无标语则返回空字符串""" tagline = self.metadata.get("tagline") if not tagline: return "" return f'

{self._escape_html(tagline)}

' def _render_cover(self) -> str: """文章开头的封面区,居中展示标题与“文章总览”提示""" 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(title)}

{self._escape_html(subtitle)}

""".strip() def _render_hero(self) -> str: """根据layout中的hero字段输出摘要/KPI/亮点区""" hero = self.metadata.get("hero") or {} if not hero: return "" summary = hero.get("summary") summary_html = f'

{self._escape_html(summary)}

' if summary else "" highlights = hero.get("highlights") or [] highlight_html = "".join( f'
  • {self._escape_html(text)}
  • ' for text in highlights ) actions = hero.get("actions") or [] actions_html = "".join( f'' for text in actions ) kpi_cards = "" for item in hero.get("kpis", []): delta = item.get("delta") tone = item.get("tone") or "neutral" delta_html = f'{self._escape_html(delta)}' if delta else "" kpi_cards += f"""
    {self._escape_html(item.get("label"))}
    {self._escape_html(item.get("value"))}
    {delta_html}
    """ return f"""
    {summary_html}
    {actions_html}
    {kpi_cards}
    """.strip() def _render_meta_panel(self) -> str: """当前需求不展示元信息,保留方法便于后续扩展""" return "" def _render_toc_section(self) -> str: """生成目录模块,如无目录数据则返回空字符串""" if not self.toc_entries: return "" toc_config = self.metadata.get("toc") or {} toc_title = toc_config.get("title") or "📚 目录" toc_items = "".join( self._format_toc_entry(entry) for entry in self.toc_entries ) return f""" """.strip() def _collect_toc_entries(self, chapters: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """根据metadata中的tocPlan或章节heading收集目录项""" metadata = self.metadata toc_config = metadata.get("toc") or {} custom_entries = toc_config.get("customEntries") entries: List[Dict[str, Any]] = [] if custom_entries: for entry in custom_entries: anchor = entry.get("anchor") or self.chapter_anchor_map.get(entry.get("chapterId")) if not anchor: continue entries.append( { "level": entry.get("level", 2), "text": entry.get("display") or entry.get("title") or "", "anchor": anchor, "description": entry.get("description"), } ) return entries for chapter in chapters or []: for block in chapter.get("blocks", []): if block.get("type") == "heading": anchor = block.get("anchor") or chapter.get("anchor") or "" if not anchor: continue mapped = self.heading_label_map.get(anchor, {}) entries.append( { "level": block.get("level", 2), "text": mapped.get("display") or block.get("text", ""), "anchor": anchor, "description": mapped.get("description"), } ) return entries def _format_toc_entry(self, entry: Dict[str, Any]) -> str: """将单个目录项转为带描述的HTML行""" desc = entry.get("description") desc_html = 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(entry["text"])}{desc_html}
  • ' def _compute_heading_labels(self, chapters: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: """预计算各级标题的编号(章:一、二;节:1.1;小节:1.1.1)""" label_map: Dict[str, Dict[str, Any]] = {} for chap_idx, chapter in enumerate(chapters or [], start=1): chapter_heading_seen = False section_idx = 0 subsection_idx = 0 deep_counters: Dict[int, int] = {} for block in chapter.get("blocks", []): if block.get("type") != "heading": continue level = block.get("level", 2) anchor = block.get("anchor") or chapter.get("anchor") if not anchor: continue raw_text = block.get("text", "") clean_title = self._strip_order_prefix(raw_text) label = None display_text = raw_text if not chapter_heading_seen: label = f"{self._to_chinese_numeral(chap_idx)}、" display_text = f"{label} {clean_title}".strip() chapter_heading_seen = True section_idx = 0 subsection_idx = 0 deep_counters.clear() elif level <= 2: section_idx += 1 subsection_idx = 0 deep_counters.clear() label = f"{chap_idx}.{section_idx}" display_text = f"{label} {clean_title}".strip() else: if section_idx == 0: section_idx = 1 if level == 3: subsection_idx += 1 deep_counters.clear() label = f"{chap_idx}.{section_idx}.{subsection_idx}" else: deep_counters[level] = deep_counters.get(level, 0) + 1 parts = [str(chap_idx), str(section_idx or 1), str(subsection_idx or 1)] for lvl in sorted(deep_counters.keys()): parts.append(str(deep_counters[lvl])) label = ".".join(parts) display_text = f"{label} {clean_title}".strip() label_map[anchor] = { "level": level, "display": display_text, "label": label, "title": clean_title, } return label_map @staticmethod def _strip_order_prefix(text: str) -> str: """移除形如“1.0 ”或“一、”的前缀,得到纯标题""" if not text: return "" separators = [" ", "、", ".", "."] stripped = text.lstrip() for sep in separators: parts = stripped.split(sep, 1) if len(parts) == 2 and parts[0]: return parts[1].strip() return stripped.strip() @staticmethod def _to_chinese_numeral(number: int) -> str: """将1/2/3映射为中文序号(十内)""" numerals = ["零", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"] if number <= 10: return numerals[number] tens, ones = divmod(number, 10) if number < 20: return "十" + (numerals[ones] if ones else "") words = "" if tens > 0: words += numerals[tens] + "十" if ones: words += numerals[ones] return words # ====== 章节 & Block 渲染 ====== def _render_chapter(self, chapter: Dict[str, Any]) -> str: """将章节blocks包裹进
    ,便于CSS控制""" section_id = self._escape_attr(chapter.get("anchor") or f"chapter-{chapter.get('chapterId', 'x')}") blocks_html = self._render_blocks(chapter.get("blocks", [])) return f'
    \n{blocks_html}\n
    ' def _render_blocks(self, blocks: List[Dict[str, Any]]) -> str: """顺序渲染章节内所有block""" return "".join(self._render_block(block) for block in blocks or []) def _render_block(self, block: Dict[str, Any]) -> str: """根据block.type分派到不同的渲染函数""" block_type = block.get("type") handlers = { "heading": self._render_heading, "paragraph": self._render_paragraph, "list": self._render_list, "table": self._render_table, "blockquote": self._render_blockquote, "hr": lambda b: "
    ", "code": self._render_code, "math": self._render_math, "figure": self._render_figure, "callout": self._render_callout, "kpiGrid": self._render_kpi_grid, "widget": self._render_widget, "toc": lambda b: self._render_toc_section(), } handler = handlers.get(block_type) if handler: return handler(block) 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'{self._escape_html(display_text)}{subtitle_html}' def _render_paragraph(self, block: Dict[str, Any]) -> str: """渲染段落,内部通过inline run保持混排样式""" inlines = "".join(self._render_inline(run) for run in block.get("inlines", [])) 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) items_html += f"
  • {content}
  • " class_attr = f' class="{extra_class}"' if extra_class else "" return f'<{tag}{class_attr}>{items_html}' def _render_table(self, block: Dict[str, Any]) -> str: """渲染表格,同时保留caption与单元格属性""" rows_html = "" for row in block.get("rows", []): row_cells = "" for cell in row.get("cells", []): cell_tag = "th" if cell.get("header") or cell.get("isHeader") else "td" attr = [] if cell.get("rowspan"): attr.append(f'rowspan="{int(cell["rowspan"])}"') if cell.get("colspan"): attr.append(f'colspan="{int(cell["colspan"])}"') if cell.get("align"): attr.append(f'class="align-{cell["align"]}"') attr_str = (" " + " ".join(attr)) if attr else "" content = self._render_blocks(cell.get("blocks", [])) row_cells += f"<{cell_tag}{attr_str}>{content}" rows_html += f"{row_cells}" caption = block.get("caption") caption_html = f"{self._escape_html(caption)}" if caption else "" return f'
    {caption_html}{rows_html}
    ' def _render_blockquote(self, block: Dict[str, Any]) -> str: """渲染引用块,可嵌套其他block""" inner = self._render_blocks(block.get("blocks", [])) return 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'
    $$ {latex} $$
    ' def _render_figure(self, block: Dict[str, Any]) -> str: """根据新规范默认不渲染外部图片,改为友好提示""" caption = block.get("caption") or "图像内容已省略(仅允许HTML原生图表与表格)" return f'
    {self._escape_html(caption)}
    ' def _render_callout(self, block: Dict[str, Any]) -> str: """渲染高亮提示盒,tone决定颜色""" tone = block.get("tone", "info") title = block.get("title") inner = self._render_blocks(block.get("blocks", [])) title_html = f"{self._escape_html(title)}" if title else "" return f'
    {title_html}{inner}
    ' def _render_kpi_grid(self, block: Dict[str, Any]) -> str: """渲染KPI卡片栅格,包含指标值与涨跌幅""" cards = "" for item in block.get("items", []): delta = item.get("delta") delta_tone = item.get("deltaTone") or "neutral" delta_html = f'{self._escape_html(delta)}' if delta else "" cards += f"""
    {self._escape_html(item.get("value", ""))}{self._escape_html(item.get("unit", ""))}
    {self._escape_html(item.get("label", ""))}
    {delta_html}
    """ return f'
    {cards}
    ' def _render_widget(self, block: Dict[str, Any]) -> str: """渲染Chart.js等交互组件的占位容器,并记录配置JSON""" self.chart_counter += 1 canvas_id = f"chart-{self.chart_counter}" config_id = f"chart-config-{self.chart_counter}" payload = { "widgetId": block.get("widgetId"), "widgetType": block.get("widgetType"), "props": block.get("props", {}), "data": block.get("data", {}), "dataRef": block.get("dataRef"), } config_json = json.dumps(payload, ensure_ascii=False).replace("{config_json}' ) title = block.get("props", {}).get("title") title_html = f'
    {self._escape_html(title)}
    ' if title else "" fallback_html = self._render_widget_fallback(block) return f"""
    {title_html}
    {fallback_html}
    """ def _render_widget_fallback(self, block: Dict[str, Any]) -> str: """渲染图表数据的文本兜底视图,避免Chart.js加载失败时出现空白""" data = block.get("data") or {} labels = data.get("labels") or [] datasets = data.get("datasets") or [] if not labels or not datasets: return "" header_cells = "".join( f"{self._escape_html(ds.get('label') or f'系列{idx + 1}')}" for idx, ds in enumerate(datasets) ) body_rows = "" for idx, label in enumerate(labels): row_cells = [f"{self._escape_html(label)}"] for ds in datasets: series = ds.get("data") or [] value = series[idx] if idx < len(series) else "" row_cells.append(f"{self._escape_html(value)}") body_rows += f"{''.join(row_cells)}" table_html = f"""
    {header_cells} {body_rows}
    类别
    """ return f"" # ====== Inline 渲染 ====== def _render_inline(self, run: Dict[str, Any]) -> str: """渲染单个inline run,支持多种marks叠加""" marks = run.get("marks") or [] math_mark = next((mark for mark in marks if mark.get("type") == "math"), None) if math_mark: latex = math_mark.get("value") if not isinstance(latex, str) or not latex.strip(): latex = run.get("text", "") return f'\\( {self._escape_html(latex)} \\)' text = self._escape_html(run.get("text", "")) styles: List[str] = [] prefix: List[str] = [] suffix: List[str] = [] for mark in marks: mark_type = mark.get("type") if mark_type == "bold": prefix.append("") suffix.insert(0, "") elif mark_type == "italic": prefix.append("") suffix.insert(0, "") elif mark_type == "code": prefix.append("") 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) # ====== CSS / JS ====== def _build_css(self, tokens: Dict[str, Any]) -> str: """根据主题token拼接整页CSS,包括响应式与打印样式""" colors = tokens.get("colors", {}) fonts = tokens.get("fonts", {}) spacing = tokens.get("spacing", {}) bg = colors.get("bg", "#f8f9fa") text_color = colors.get("text", "#212529") primary = colors.get("primary", "#007bff") secondary = colors.get("secondary", "#6c757d") card = colors.get("card", "#ffffff") border = colors.get("border", "#dee2e6") shadow = "rgba(0,0,0,0.08)" return f""" :root {{ --bg-color: {bg}; --text-color: {text_color}; --primary-color: {primary}; --secondary-color: {secondary}; --card-bg: {card}; --border-color: {border}; --shadow-color: {shadow}; }} .dark-mode {{ --bg-color: #121212; --text-color: #e0e0e0; --primary-color: #0d6efd; --secondary-color: #adb5bd; --card-bg: #1f1f1f; --border-color: #2c2c2c; --shadow-color: rgba(0, 0, 0, 0.4); }} * {{ box-sizing: border-box; }} body {{ margin: 0; font-family: {fonts.get("body", "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif")}; 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: {spacing.get("container", "1200px")}; margin: 40px auto; padding: {spacing.get("gutter", "24px")}; background: var(--card-bg); border-radius: 16px; box-shadow: 0 10px 30px var(--shadow-color); }} h1, h2, h3, h4, h5, h6 {{ font-family: {fonts.get("heading", fonts.get("body", "sans-serif"))}; 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 {{ margin-top: 12px; font-size: 0.85rem; overflow-x: auto; }} .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); }} 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() # ====== Utils ====== @staticmethod def _escape_html(value: Any) -> str: """HTML内容转义工具,避免XSS""" return html.escape(str(value)) if value is not None else "" @staticmethod def _escape_attr(value: Any) -> str: """HTML属性值转义工具""" return html.escape(str(value), quote=True) if value is not None else "" __all__ = ["HTMLRenderer"]