Showing
1 changed file
with
229 additions
and
26 deletions
| @@ -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; |
-
Please register or login to post a comment