diff --git a/.opencode/templates/report-template.py b/.opencode/templates/report-template.py index 279c938..fb6d9c7 100755 --- a/.opencode/templates/report-template.py +++ b/.opencode/templates/report-template.py @@ -528,6 +528,37 @@ _UNICODE_SUB = { _SUPER_CHARS_RE = re.compile(f"([{''.join(_UNICODE_SUPER)}]+)") _SUB_CHARS_RE = re.compile(f"([{''.join(_UNICODE_SUB)}]+)") +# 彩色 emoji / 特殊符号 → 文字替代。思源字体子集不含这些字形,直接放会渲染成方框。 +# 替换为字体里**实际存在**的符号(经过 fontTools 验证)。 +# 验证命令见 scripts/lib/verify_font_glyphs.py +_EMOJI_FALLBACK = { + "✅": "✓", # U+2705 → U+2713 CHECK MARK(思源有) + "❌": "×", # U+274C → U+00D7 MULTIPLICATION SIGN(思源有,✗ U+2717 思源没有) + "✖": "×", + "✗": "×", + "🔶": "◆", # U+1F536 → U+25C6 BLACK DIAMOND(思源有) + "🔷": "◇", # U+25C7 WHITE DIAMOND(思源有) + "🟢": "●", # U+25CF BLACK CIRCLE(思源有) + "🔴": "●", + "🟡": "○", # U+25CB WHITE CIRCLE + "🟠": "○", + "⭐": "★", # U+2605 BLACK STAR(思源有) + "✔": "✓", + "☑": "[✓]", # U+2611 思源没有,用方括号包围替代 + "☒": "[×]", + "☐": "[ ]", + "➔": "→", + "➜": "→", + "⚠️": "※", # U+203B REFERENCE MARK(思源有) + "⚠": "※", + "💡": "※", + "📌": "•", + "🔑": "※", + "📊": "※", + "📈": "※", + "📉": "※", +} + def _replace_unicode_superscripts(text: str) -> str: """把连续的 Unicode 上标字符替换为 ReportLab 标签。 @@ -549,10 +580,20 @@ def _replace_unicode_superscripts(text: str) -> str: return text +def _replace_emoji(text: str) -> str: + """把字体里没有的 emoji 替换为字体里有的等价符号。""" + for emoji, fallback in _EMOJI_FALLBACK.items(): + if emoji in text: + text = text.replace(emoji, fallback) + return text + + def md_inline_to_rl(text: str, *, add_cjk_space: bool = True) -> str: """Markdown inline → ReportLab mini HTML.""" # 先做 Unicode 上/下标归一(字体子集不含这些字形,否则渲染为方框) text = _replace_unicode_superscripts(text) + # emoji 替换为字体里有的符号 + text = _replace_emoji(text) # 然后在中英交界处加空格 if add_cjk_space: text = _add_cjk_spaces(text) @@ -711,8 +752,8 @@ def collect_toc_entries(blocks: List[Block]) -> List[tuple[int, str]]: def build_toc(blocks: List[Block], styles: StyleSheet1) -> List: """生成目录条目。 - 目录末尾 PageBreak 让后续内容独立成页。开头不 PageBreak, - 调用方(H1 分支)已经负责在 H1 前另起一页。 + 不在本函数内部 PageBreak——前面由调用方(H1/H2 分支)插入 PageBreak; + 后面靠下一个章节的 H1 PageBreak 自然起作用。避免"连续 PageBreak 产生空页"。 """ story: list = [] story.append(Paragraph("目录", styles["h1"])) @@ -720,7 +761,6 @@ def build_toc(blocks: List[Block], styles: StyleSheet1) -> List: for level, title in collect_toc_entries(blocks): style_name = "toc-h1" if level == 1 else "toc-h2" story.append(Paragraph(md_inline_to_rl(title), styles[style_name])) - story.append(PageBreak()) return story @@ -993,6 +1033,49 @@ def render_table(md_table: str, styles: StyleSheet1) -> Table: return table +def _render_generic_block(block: Block, story: list, base_dir: Path, styles: StyleSheet1, *, in_summary: bool) -> None: + """渲染一个非 H1/H2 的 block(p/quote/bullet/hr/image/table/h3)。 + + 提取出来的帮助函数,给术语表内部循环和主循环复用。 + """ + if block.kind == "h3": + story.append(Paragraph(md_inline_to_rl(block.content), styles["h3"])) + elif block.kind == "p": + if _TOC_PLACEHOLDER_RE.search(block.content) or _REF_PLACEHOLDER_RE.search(block.content): + return + style = styles["summary"] if in_summary else styles["body"] + story.append(Paragraph(md_inline_to_rl(block.content), style)) + elif block.kind == "quote": + story.append(Paragraph(md_inline_to_rl(block.content), styles["quote"])) + elif block.kind == "bullet": + story.append(Paragraph("• " + md_inline_to_rl(block.content), styles["bullet"])) + elif block.kind == "hr": + story.append(Spacer(1, 0.3 * cm)) + elif block.kind == "image": + img_path = base_dir / block.content + if img_path.exists(): + try: + img = Image(str(img_path), width=15 * cm, height=10 * cm, kind="proportional") + story.append(img) + if block.meta and block.meta.get("caption"): + story.append(Paragraph(block.meta["caption"], styles["caption"])) + except Exception as e: + story.append(Paragraph( + f"[图片加载失败:{block.content} — {e}]", + styles["caption"], + )) + else: + story.append(Paragraph( + f"[图片未找到:{block.content}]", + styles["caption"], + )) + elif block.kind == "table": + try: + story.append(render_table(block.content, styles)) + except Exception as e: + story.append(Paragraph(f"[表格渲染失败: {e}]", styles["caption"])) + + def build_body( blocks: List[Block], base_dir: Path, @@ -1028,15 +1111,26 @@ def build_body( # --- H1 处理(非首个;本循环内 first_section_idx 之后的 H1 都是真正的章节 H1)--- if block.kind == "h1": - story.append(PageBreak()) content = block.content + title_low = content.strip().lower() + + # 跳过整章:Abstract(保留 Executive Summary) + if title_low in ("摘要", "abstract"): + j = i + 1 + while j < n and blocks[j].kind != "h1": + j += 1 + i = j + continue + + # 所有非被替换的 H1 都 PageBreak + story.append(PageBreak()) + if any(k in content for k in ("执行摘要", "Executive Summary", "管理层摘要")): in_summary = True else: in_summary = False - # 目录 / 参考文献:替换为自动生成的内容 - title_low = content.strip().lower() + # 目录:已经 PageBreak 了,build_toc 内部再 PageBreak(独立页) if any(s in title_low for s in ("目录", "table of contents")): story.extend(build_toc(blocks, styles)) # 跳过紧随其后的占位段 @@ -1045,6 +1139,15 @@ def build_body( j += 1 i = j continue + # 术语表:H1 本身已 PageBreak,末尾靠下一个 H1 自然换页 + if any(s in title_low for s in ("术语表", "glossary")): + story.append(Paragraph(md_inline_to_rl(content), styles["h1"])) + i += 1 + while i < n and blocks[i].kind != "h1": + sub = blocks[i] + _render_generic_block(sub, story, base_dir, styles, in_summary=False) + i += 1 + continue if any(s in title_low for s in ("参考文献", "references")): story.extend(build_references(blocks, sources_path, styles)) j = i + 1 @@ -1060,13 +1163,37 @@ def build_body( # --- H2 同样检测占位符 --- if block.kind == "h2": title_low = block.content.strip().lower() + + # 跳过整段:Abstract(与 Executive Summary 重复,根据用户偏好保留 Executive Summary) + if title_low in ("摘要", "abstract"): + # 跳到下一个 H1/H2 + j = i + 1 + while j < n and blocks[j].kind not in ("h1", "h2"): + j += 1 + i = j + continue + + # 目录:前后 PageBreak(独立成页) if any(s in title_low for s in ("目录", "table of contents")): + story.append(PageBreak()) story.extend(build_toc(blocks, styles)) j = i + 1 while j < n and blocks[j].kind == "p" and _TOC_PLACEHOLDER_RE.search(blocks[j].content): j += 1 i = j continue + + # 术语表:前 PageBreak,后靠下一个 H1/H2 自然换页 + if any(s in title_low for s in ("术语表", "glossary")): + story.append(PageBreak()) + story.append(Paragraph(md_inline_to_rl(block.content), styles["h1"])) + i += 1 + while i < n and blocks[i].kind not in ("h1", "h2"): + sub = blocks[i] + _render_generic_block(sub, story, base_dir, styles, in_summary=False) + i += 1 + continue + if any(s in title_low for s in ("参考文献", "references")): story.extend(build_references(blocks, sources_path, styles)) j = i + 1 @@ -1079,45 +1206,8 @@ def build_body( i += 1 continue - if block.kind == "h3": - story.append(Paragraph(md_inline_to_rl(block.content), styles["h3"])) - elif block.kind == "p": - # 跳过已识别但没有标题的孤立占位符(防御性) - if _TOC_PLACEHOLDER_RE.search(block.content) or _REF_PLACEHOLDER_RE.search(block.content): - i += 1 - continue - style = styles["summary"] if in_summary else styles["body"] - story.append(Paragraph(md_inline_to_rl(block.content), style)) - elif block.kind == "quote": - story.append(Paragraph(md_inline_to_rl(block.content), styles["quote"])) - elif block.kind == "bullet": - story.append(Paragraph("• " + md_inline_to_rl(block.content), styles["bullet"])) - elif block.kind == "hr": - story.append(Spacer(1, 0.3 * cm)) - elif block.kind == "image": - img_path = base_dir / block.content - if img_path.exists(): - try: - img = Image(str(img_path), width=15 * cm, height=10 * cm, kind="proportional") - story.append(img) - if block.meta and block.meta.get("caption"): - story.append(Paragraph(block.meta["caption"], styles["caption"])) - except Exception as e: - story.append(Paragraph( - f"[图片加载失败:{block.content} — {e}]", - styles["caption"], - )) - else: - story.append(Paragraph( - f"[图片未找到:{block.content}]", - styles["caption"], - )) - elif block.kind == "table": - try: - story.append(render_table(block.content, styles)) - except Exception as e: - story.append(Paragraph(f"[表格渲染失败: {e}]", styles["caption"])) - + # 非 H1/H2 的 block:统一走 _render_generic_block + _render_generic_block(block, story, base_dir, styles, in_summary=in_summary) i += 1 return story