马一丁

Improved Rendering

@@ -54,6 +54,7 @@ class HTMLRenderer: @@ -54,6 +54,7 @@ class HTMLRenderer:
54 self.toc_entries: List[Dict[str, Any]] = [] 54 self.toc_entries: List[Dict[str, Any]] = []
55 self.heading_counter = 0 55 self.heading_counter = 0
56 self.metadata: Dict[str, Any] = {} 56 self.metadata: Dict[str, Any] = {}
  57 + self.chapters: List[Dict[str, Any]] = []
57 self.chapter_anchor_map: Dict[str, str] = {} 58 self.chapter_anchor_map: Dict[str, str] = {}
58 self.heading_label_map: Dict[str, Dict[str, Any]] = {} 59 self.heading_label_map: Dict[str, Dict[str, Any]] = {}
59 self.primary_heading_index = 0 60 self.primary_heading_index = 0
@@ -76,15 +77,15 @@ class HTMLRenderer: @@ -76,15 +77,15 @@ class HTMLRenderer:
76 self.chart_counter = 0 77 self.chart_counter = 0
77 self.heading_counter = 0 78 self.heading_counter = 0
78 self.metadata = self.document.get("metadata", {}) or {} 79 self.metadata = self.document.get("metadata", {}) or {}
  80 + raw_chapters = self.document.get("chapters", []) or []
  81 + self.chapters = self._prepare_chapters(raw_chapters)
79 self.chapter_anchor_map = { 82 self.chapter_anchor_map = {
80 chapter.get("chapterId"): chapter.get("anchor") 83 chapter.get("chapterId"): chapter.get("anchor")
81 - for chapter in self.document.get("chapters", []) 84 + for chapter in self.chapters
82 if chapter.get("chapterId") and chapter.get("anchor") 85 if chapter.get("chapterId") and chapter.get("anchor")
83 } 86 }
84 - self.heading_label_map = self._compute_heading_labels(self.document.get("chapters", []))  
85 - self.toc_entries = self._collect_toc_entries(  
86 - self.document.get("chapters", [])  
87 - ) 87 + self.heading_label_map = self._compute_heading_labels(self.chapters)
  88 + self.toc_entries = self._collect_toc_entries(self.chapters)
88 89
89 metadata = self.metadata 90 metadata = self.metadata
90 theme_tokens = metadata.get("themeTokens") or self.document.get("themeTokens", {}) 91 theme_tokens = metadata.get("themeTokens") or self.document.get("themeTokens", {})
@@ -96,6 +97,39 @@ class HTMLRenderer: @@ -96,6 +97,39 @@ class HTMLRenderer:
96 97
97 # ====== Head / Body ====== 98 # ====== Head / Body ======
98 99
  100 + def _resolve_color_value(self, value: Any, fallback: str) -> str:
  101 + """从颜色token中提取字符串值"""
  102 + if isinstance(value, str):
  103 + value = value.strip()
  104 + return value or fallback
  105 + if isinstance(value, dict):
  106 + for key in ("main", "value", "color", "base", "default"):
  107 + candidate = value.get(key)
  108 + if isinstance(candidate, str) and candidate.strip():
  109 + return candidate.strip()
  110 + for candidate in value.values():
  111 + if isinstance(candidate, str) and candidate.strip():
  112 + return candidate.strip()
  113 + return fallback
  114 +
  115 + def _resolve_color_family(self, value: Any, fallback: Dict[str, str]) -> Dict[str, str]:
  116 + """解析主/亮/暗三色,缺失时回落到默认值"""
  117 + result = {
  118 + "main": fallback.get("main", "#007bff"),
  119 + "light": fallback.get("light", fallback.get("main", "#007bff")),
  120 + "dark": fallback.get("dark", fallback.get("main", "#007bff")),
  121 + }
  122 + if isinstance(value, str):
  123 + stripped = value.strip()
  124 + if stripped:
  125 + result["main"] = stripped
  126 + return result
  127 + if isinstance(value, dict):
  128 + result["main"] = self._resolve_color_value(value.get("main") or value, result["main"])
  129 + result["light"] = self._resolve_color_value(value.get("light") or value.get("lighter"), result["light"])
  130 + result["dark"] = self._resolve_color_value(value.get("dark") or value.get("darker"), result["dark"])
  131 + return result
  132 +
99 def _render_head(self, title: str, theme_tokens: Dict[str, Any]) -> str: 133 def _render_head(self, title: str, theme_tokens: Dict[str, Any]) -> str:
100 """ 134 """
101 渲染<head>部分,加载主题CSS与必要的脚本依赖。 135 渲染<head>部分,加载主题CSS与必要的脚本依赖。
@@ -151,10 +185,7 @@ class HTMLRenderer: @@ -151,10 +185,7 @@ class HTMLRenderer:
151 cover = self._render_cover() 185 cover = self._render_cover()
152 hero = self._render_hero() 186 hero = self._render_hero()
153 toc_section = self._render_toc_section() 187 toc_section = self._render_toc_section()
154 - chapters = "".join(  
155 - self._render_chapter(chapter)  
156 - for chapter in self.document.get("chapters", [])  
157 - ) 188 + chapters = "".join(self._render_chapter(chapter) for chapter in self.chapters)
158 widget_scripts = "\n".join(self.widget_scripts) 189 widget_scripts = "\n".join(self.widget_scripts)
159 hydration = self._hydration_script() 190 hydration = self._hydration_script()
160 191
@@ -350,6 +381,145 @@ class HTMLRenderer: @@ -350,6 +381,145 @@ class HTMLRenderer:
350 ) 381 )
351 return entries 382 return entries
352 383
  384 + def _prepare_chapters(self, chapters: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
  385 + """复制章节并展开其中序列化的block,避免渲染缺失"""
  386 + prepared: List[Dict[str, Any]] = []
  387 + for chapter in chapters or []:
  388 + chapter_copy = copy.deepcopy(chapter)
  389 + chapter_copy["blocks"] = self._expand_blocks_in_place(chapter_copy.get("blocks", []))
  390 + prepared.append(chapter_copy)
  391 + return prepared
  392 +
  393 + def _expand_blocks_in_place(self, blocks: List[Dict[str, Any]] | None) -> List[Dict[str, Any]]:
  394 + """遍历block列表,将内嵌JSON串拆解为独立block"""
  395 + expanded: List[Dict[str, Any]] = []
  396 + for block in blocks or []:
  397 + extras = self._extract_embedded_blocks(block)
  398 + expanded.append(block)
  399 + if extras:
  400 + expanded.extend(self._expand_blocks_in_place(extras))
  401 + return expanded
  402 +
  403 + def _extract_embedded_blocks(self, block: Dict[str, Any]) -> List[Dict[str, Any]]:
  404 + """
  405 + 在block内部查找被误写成字符串的block列表,并返回补充的block
  406 + """
  407 + extracted: List[Dict[str, Any]] = []
  408 +
  409 + def traverse(node: Any) -> None:
  410 + if isinstance(node, dict):
  411 + for key, value in list(node.items()):
  412 + if key == "text" and isinstance(value, str):
  413 + decoded = self._decode_embedded_block_payload(value)
  414 + if decoded:
  415 + node[key] = ""
  416 + extracted.extend(decoded)
  417 + continue
  418 + traverse(value)
  419 + elif isinstance(node, list):
  420 + for item in node:
  421 + traverse(item)
  422 +
  423 + traverse(block)
  424 + return extracted
  425 +
  426 + def _decode_embedded_block_payload(self, raw: str) -> List[Dict[str, Any]] | None:
  427 + """
  428 + 将字符串形式的block描述恢复为结构化列表。
  429 + """
  430 + if not isinstance(raw, str):
  431 + return None
  432 + stripped = raw.strip()
  433 + if not stripped or stripped[0] not in "{[":
  434 + return None
  435 + payload: Any | None = None
  436 + decode_targets = [stripped]
  437 + if stripped and stripped[0] != "[":
  438 + decode_targets.append(f"[{stripped}]")
  439 + for candidate in decode_targets:
  440 + try:
  441 + payload = json.loads(candidate)
  442 + break
  443 + except json.JSONDecodeError:
  444 + continue
  445 + if payload is None:
  446 + for candidate in decode_targets:
  447 + try:
  448 + payload = ast.literal_eval(candidate)
  449 + break
  450 + except (ValueError, SyntaxError):
  451 + continue
  452 + if payload is None:
  453 + return None
  454 +
  455 + blocks = self._collect_blocks_from_payload(payload)
  456 + return blocks or None
  457 +
  458 + @staticmethod
  459 + def _looks_like_block(payload: Dict[str, Any]) -> bool:
  460 + """粗略判断dict是否符合block结构"""
  461 + if not isinstance(payload, dict):
  462 + return False
  463 + if "type" in payload and isinstance(payload["type"], str):
  464 + return True
  465 + structural_keys = {"blocks", "rows", "items", "widgetId", "widgetType", "data"}
  466 + return any(key in payload for key in structural_keys)
  467 +
  468 + def _collect_blocks_from_payload(self, payload: Any) -> List[Dict[str, Any]]:
  469 + """递归收集payload中的block节点"""
  470 + collected: List[Dict[str, Any]] = []
  471 + if isinstance(payload, dict):
  472 + block_list = payload.get("blocks")
  473 + block_type = payload.get("type")
  474 + if isinstance(block_list, list) and not block_type:
  475 + for candidate in block_list:
  476 + collected.extend(self._collect_blocks_from_payload(candidate))
  477 + return collected
  478 + if payload.get("cells") and not block_type:
  479 + for cell in payload["cells"]:
  480 + collected.extend(self._collect_blocks_from_payload(cell.get("blocks")))
  481 + return collected
  482 + if payload.get("items") and not block_type:
  483 + for item in payload["items"]:
  484 + collected.extend(self._collect_blocks_from_payload(item))
  485 + return collected
  486 + appended = False
  487 + if block_type or payload.get("widgetId") or payload.get("rows"):
  488 + coerced = self._coerce_block_dict(payload)
  489 + if coerced:
  490 + collected.append(coerced)
  491 + appended = True
  492 + items = payload.get("items")
  493 + if isinstance(items, list) and not block_type:
  494 + for item in items:
  495 + collected.extend(self._collect_blocks_from_payload(item))
  496 + return collected
  497 + if appended:
  498 + return collected
  499 + elif isinstance(payload, list):
  500 + for item in payload:
  501 + collected.extend(self._collect_blocks_from_payload(item))
  502 + elif payload is None:
  503 + return collected
  504 + return collected
  505 +
  506 + def _coerce_block_dict(self, payload: Any) -> Dict[str, Any] | None:
  507 + """尝试将dict补充为合法block结构"""
  508 + if not isinstance(payload, dict):
  509 + return None
  510 + block = copy.deepcopy(payload)
  511 + block_type = block.get("type")
  512 + if not block_type:
  513 + if "widgetId" in block:
  514 + block_type = block["type"] = "widget"
  515 + elif "rows" in block or "cells" in block:
  516 + block_type = block["type"] = "table"
  517 + if "rows" not in block and isinstance(block.get("cells"), list):
  518 + block["rows"] = [{"cells": block.pop("cells")}]
  519 + elif "items" in block:
  520 + block_type = block["type"] = "list"
  521 + return block if block.get("type") else None
  522 +
353 def _format_toc_entry(self, entry: Dict[str, Any]) -> str: 523 def _format_toc_entry(self, entry: Dict[str, Any]) -> str:
354 """ 524 """
355 将单个目录项转为带描述的HTML行。 525 将单个目录项转为带描述的HTML行。
@@ -519,6 +689,8 @@ class HTMLRenderer: @@ -519,6 +689,8 @@ class HTMLRenderer:
519 handler = handlers.get(block_type) 689 handler = handlers.get(block_type)
520 if handler: 690 if handler:
521 return handler(block) 691 return handler(block)
  692 + if isinstance(block.get("blocks"), list):
  693 + return self._render_blocks(block["blocks"])
522 return f'<pre class="unknown-block">{self._escape_html(json.dumps(block, ensure_ascii=False, indent=2))}</pre>' 694 return f'<pre class="unknown-block">{self._escape_html(json.dumps(block, ensure_ascii=False, indent=2))}</pre>'
523 695
524 def _render_heading(self, block: Dict[str, Any]) -> str: 696 def _render_heading(self, block: Dict[str, Any]) -> str:
@@ -1085,23 +1257,50 @@ class HTMLRenderer: @@ -1085,23 +1257,50 @@ class HTMLRenderer:
1085 1257
1086 def _build_css(self, tokens: Dict[str, Any]) -> str: 1258 def _build_css(self, tokens: Dict[str, Any]) -> str:
1087 """根据主题token拼接整页CSS,包括响应式与打印样式""" 1259 """根据主题token拼接整页CSS,包括响应式与打印样式"""
1088 - colors = tokens.get("colors", {})  
1089 - fonts = tokens.get("fonts", {})  
1090 - spacing = tokens.get("spacing", {})  
1091 - bg = colors.get("bg", "#f8f9fa")  
1092 - text_color = colors.get("text", "#212529")  
1093 - primary = colors.get("primary", "#007bff")  
1094 - secondary = colors.get("secondary", "#6c757d")  
1095 - card = colors.get("card", "#ffffff")  
1096 - border = colors.get("border", "#dee2e6") 1260 + colors = tokens.get("colors") or {}
  1261 + typography = tokens.get("typography") or {}
  1262 + fonts = tokens.get("fonts") or typography.get("fontFamily") or {}
  1263 + spacing = tokens.get("spacing") or {}
  1264 + primary_palette = self._resolve_color_family(
  1265 + colors.get("primary"),
  1266 + {"main": "#1a365d", "light": "#2d3748", "dark": "#0f1a2d"},
  1267 + )
  1268 + secondary_palette = self._resolve_color_family(
  1269 + colors.get("secondary"),
  1270 + {"main": "#e53e3e", "light": "#fc8181", "dark": "#c53030"},
  1271 + )
  1272 + bg = self._resolve_color_value(
  1273 + colors.get("bg") or colors.get("background") or colors.get("surface"),
  1274 + "#f8f9fa",
  1275 + )
  1276 + text_color = self._resolve_color_value(
  1277 + colors.get("text") or colors.get("onBackground"),
  1278 + "#212529",
  1279 + )
  1280 + card = self._resolve_color_value(
  1281 + colors.get("card") or colors.get("surfaceCard"),
  1282 + "#ffffff",
  1283 + )
  1284 + border = self._resolve_color_value(
  1285 + colors.get("border") or colors.get("divider"),
  1286 + "#dee2e6",
  1287 + )
1097 shadow = "rgba(0,0,0,0.08)" 1288 shadow = "rgba(0,0,0,0.08)"
  1289 + container_width = spacing.get("container") or spacing.get("containerWidth") or "1200px"
  1290 + gutter = spacing.get("gutter") or spacing.get("pagePadding") or "24px"
  1291 + body_font = fonts.get("body") or fonts.get("primary") or "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
  1292 + heading_font = fonts.get("heading") or fonts.get("primary") or fonts.get("secondary") or body_font
1098 1293
1099 return f""" 1294 return f"""
1100 :root {{ 1295 :root {{
1101 --bg-color: {bg}; 1296 --bg-color: {bg};
1102 --text-color: {text_color}; 1297 --text-color: {text_color};
1103 - --primary-color: {primary};  
1104 - --secondary-color: {secondary}; 1298 + --primary-color: {primary_palette["main"]};
  1299 + --primary-color-light: {primary_palette["light"]};
  1300 + --primary-color-dark: {primary_palette["dark"]};
  1301 + --secondary-color: {secondary_palette["main"]};
  1302 + --secondary-color-light: {secondary_palette["light"]};
  1303 + --secondary-color-dark: {secondary_palette["dark"]};
1105 --card-bg: {card}; 1304 --card-bg: {card};
1106 --border-color: {border}; 1305 --border-color: {border};
1107 --shadow-color: {shadow}; 1306 --shadow-color: {shadow};
@@ -1109,8 +1308,12 @@ class HTMLRenderer: @@ -1109,8 +1308,12 @@ class HTMLRenderer:
1109 .dark-mode {{ 1308 .dark-mode {{
1110 --bg-color: #121212; 1309 --bg-color: #121212;
1111 --text-color: #e0e0e0; 1310 --text-color: #e0e0e0;
1112 - --primary-color: #0d6efd;  
1113 - --secondary-color: #adb5bd; 1311 + --primary-color: #6ea8fe;
  1312 + --primary-color-light: #91caff;
  1313 + --primary-color-dark: #1f6feb;
  1314 + --secondary-color: #f28b82;
  1315 + --secondary-color-light: #f9b4ae;
  1316 + --secondary-color-dark: #d9655c;
1114 --card-bg: #1f1f1f; 1317 --card-bg: #1f1f1f;
1115 --border-color: #2c2c2c; 1318 --border-color: #2c2c2c;
1116 --shadow-color: rgba(0, 0, 0, 0.4); 1319 --shadow-color: rgba(0, 0, 0, 0.4);
@@ -1118,7 +1321,7 @@ class HTMLRenderer: @@ -1118,7 +1321,7 @@ class HTMLRenderer:
1118 * {{ box-sizing: border-box; }} 1321 * {{ box-sizing: border-box; }}
1119 body {{ 1322 body {{
1120 margin: 0; 1323 margin: 0;
1121 - font-family: {fonts.get("body", "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif")}; 1324 + font-family: {body_font};
1122 background: linear-gradient(180deg, rgba(0,0,0,0.04), rgba(0,0,0,0)) fixed, var(--bg-color); 1325 background: linear-gradient(180deg, rgba(0,0,0,0.04), rgba(0,0,0,0)) fixed, var(--bg-color);
1123 color: var(--text-color); 1326 color: var(--text-color);
1124 line-height: 1.7; 1327 line-height: 1.7;
@@ -1272,15 +1475,15 @@ body {{ @@ -1272,15 +1475,15 @@ body {{
1272 transform: translateY(-1px); 1475 transform: translateY(-1px);
1273 }} 1476 }}
1274 main {{ 1477 main {{
1275 - max-width: {spacing.get("container", "1200px")}; 1478 + max-width: {container_width};
1276 margin: 40px auto; 1479 margin: 40px auto;
1277 - padding: {spacing.get("gutter", "24px")}; 1480 + padding: {gutter};
1278 background: var(--card-bg); 1481 background: var(--card-bg);
1279 border-radius: 16px; 1482 border-radius: 16px;
1280 box-shadow: 0 10px 30px var(--shadow-color); 1483 box-shadow: 0 10px 30px var(--shadow-color);
1281 }} 1484 }}
1282 h1, h2, h3, h4, h5, h6 {{ 1485 h1, h2, h3, h4, h5, h6 {{
1283 - font-family: {fonts.get("heading", fonts.get("body", "sans-serif"))}; 1486 + font-family: {heading_font};
1284 color: var(--text-color); 1487 color: var(--text-color);
1285 margin-top: 2em; 1488 margin-top: 2em;
1286 margin-bottom: 0.6em; 1489 margin-bottom: 0.6em;