From f64f973f57d7ed7b98070bdd7986bb7999fba144 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A9=AC=E4=B8=80=E4=B8=81?= <1769123563@qq.com> Date: Fri, 14 Nov 2025 23:45:28 +0800 Subject: [PATCH] Improved Rendering --- ReportEngine/renderers/html_renderer.py | 255 +++++++++++++++++++++--- 1 file changed, 229 insertions(+), 26 deletions(-) diff --git a/ReportEngine/renderers/html_renderer.py b/ReportEngine/renderers/html_renderer.py index 48fe847..89a9c3f 100644 --- a/ReportEngine/renderers/html_renderer.py +++ b/ReportEngine/renderers/html_renderer.py @@ -54,6 +54,7 @@ class HTMLRenderer: 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 @@ -76,15 +77,15 @@ class HTMLRenderer: 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.document.get("chapters", []) + for chapter in self.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", []) - ) + 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", {}) @@ -96,6 +97,39 @@ class HTMLRenderer: # ====== 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与必要的脚本依赖。 @@ -151,10 +185,7 @@ class HTMLRenderer: 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", []) - ) + chapters = "".join(self._render_chapter(chapter) for chapter in self.chapters) widget_scripts = "\n".join(self.widget_scripts) hydration = self._hydration_script() @@ -350,6 +381,145 @@ class HTMLRenderer: ) return entries + def _prepare_chapters(self, chapters: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """复制章节并展开其中序列化的block,避免渲染缺失""" + prepared: List[Dict[str, Any]] = [] + for chapter in chapters or []: + chapter_copy = copy.deepcopy(chapter) + chapter_copy["blocks"] = self._expand_blocks_in_place(chapter_copy.get("blocks", [])) + prepared.append(chapter_copy) + return prepared + + def _expand_blocks_in_place(self, blocks: List[Dict[str, Any]] | None) -> List[Dict[str, Any]]: + """遍历block列表,将内嵌JSON串拆解为独立block""" + expanded: List[Dict[str, Any]] = [] + for block in blocks or []: + extras = self._extract_embedded_blocks(block) + expanded.append(block) + if extras: + expanded.extend(self._expand_blocks_in_place(extras)) + return expanded + + def _extract_embedded_blocks(self, block: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + 在block内部查找被误写成字符串的block列表,并返回补充的block + """ + extracted: List[Dict[str, Any]] = [] + + def traverse(node: Any) -> None: + if isinstance(node, dict): + for key, value in list(node.items()): + if key == "text" and isinstance(value, str): + decoded = self._decode_embedded_block_payload(value) + if decoded: + node[key] = "" + extracted.extend(decoded) + continue + traverse(value) + elif isinstance(node, list): + for item in node: + traverse(item) + + traverse(block) + return extracted + + def _decode_embedded_block_payload(self, raw: str) -> List[Dict[str, Any]] | None: + """ + 将字符串形式的block描述恢复为结构化列表。 + """ + if not isinstance(raw, str): + return None + stripped = raw.strip() + if not stripped or stripped[0] not in "{[": + return None + payload: Any | None = None + decode_targets = [stripped] + if stripped and stripped[0] != "[": + decode_targets.append(f"[{stripped}]") + for candidate in decode_targets: + try: + payload = json.loads(candidate) + break + except json.JSONDecodeError: + continue + if payload is None: + for candidate in decode_targets: + try: + payload = ast.literal_eval(candidate) + break + except (ValueError, SyntaxError): + continue + if payload is None: + return None + + blocks = self._collect_blocks_from_payload(payload) + return blocks or None + + @staticmethod + def _looks_like_block(payload: Dict[str, Any]) -> bool: + """粗略判断dict是否符合block结构""" + if not isinstance(payload, dict): + return False + if "type" in payload and isinstance(payload["type"], str): + return True + structural_keys = {"blocks", "rows", "items", "widgetId", "widgetType", "data"} + return any(key in payload for key in structural_keys) + + def _collect_blocks_from_payload(self, payload: Any) -> List[Dict[str, Any]]: + """递归收集payload中的block节点""" + collected: List[Dict[str, Any]] = [] + if isinstance(payload, dict): + block_list = payload.get("blocks") + block_type = payload.get("type") + if isinstance(block_list, list) and not block_type: + for candidate in block_list: + collected.extend(self._collect_blocks_from_payload(candidate)) + return collected + if payload.get("cells") and not block_type: + for cell in payload["cells"]: + collected.extend(self._collect_blocks_from_payload(cell.get("blocks"))) + return collected + if payload.get("items") and not block_type: + for item in payload["items"]: + collected.extend(self._collect_blocks_from_payload(item)) + return collected + appended = False + if block_type or payload.get("widgetId") or payload.get("rows"): + coerced = self._coerce_block_dict(payload) + if coerced: + collected.append(coerced) + appended = True + items = payload.get("items") + if isinstance(items, list) and not block_type: + for item in items: + collected.extend(self._collect_blocks_from_payload(item)) + return collected + if appended: + return collected + elif isinstance(payload, list): + for item in payload: + collected.extend(self._collect_blocks_from_payload(item)) + elif payload is None: + return collected + return collected + + def _coerce_block_dict(self, payload: Any) -> Dict[str, Any] | None: + """尝试将dict补充为合法block结构""" + if not isinstance(payload, dict): + return None + block = copy.deepcopy(payload) + block_type = block.get("type") + if not block_type: + if "widgetId" in block: + block_type = block["type"] = "widget" + elif "rows" in block or "cells" in block: + block_type = block["type"] = "table" + if "rows" not in block and isinstance(block.get("cells"), list): + block["rows"] = [{"cells": block.pop("cells")}] + elif "items" in block: + block_type = block["type"] = "list" + return block if block.get("type") else None + def _format_toc_entry(self, entry: Dict[str, Any]) -> str: """ 将单个目录项转为带描述的HTML行。 @@ -519,6 +689,8 @@ class HTMLRenderer: handler = handlers.get(block_type) if handler: return handler(block) + if isinstance(block.get("blocks"), list): + return self._render_blocks(block["blocks"]) return f'{self._escape_html(json.dumps(block, ensure_ascii=False, indent=2))}'
def _render_heading(self, block: Dict[str, Any]) -> str:
@@ -1085,23 +1257,50 @@ class HTMLRenderer:
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")
+ 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};
- --secondary-color: {secondary};
+ --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};
@@ -1109,8 +1308,12 @@ class HTMLRenderer:
.dark-mode {{
--bg-color: #121212;
--text-color: #e0e0e0;
- --primary-color: #0d6efd;
- --secondary-color: #adb5bd;
+ --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);
@@ -1118,7 +1321,7 @@ class HTMLRenderer:
* {{ box-sizing: border-box; }}
body {{
margin: 0;
- font-family: {fonts.get("body", "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif")};
+ 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;
@@ -1272,15 +1475,15 @@ body {{
transform: translateY(-1px);
}}
main {{
- max-width: {spacing.get("container", "1200px")};
+ max-width: {container_width};
margin: 40px auto;
- padding: {spacing.get("gutter", "24px")};
+ 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: {fonts.get("heading", fonts.get("body", "sans-serif"))};
+ font-family: {heading_font};
color: var(--text-color);
margin-top: 2em;
margin-bottom: 0.6em;