From 1394d98346f74a0a22303003af9235f744990b23 Mon Sep 17 00:00:00 2001 From: kai Date: Wed, 22 Apr 2026 16:05:45 +0800 Subject: [PATCH] v0.7.1: \u4fee\u590d emoji \u65b9\u6846 + \u5206\u9875\u95ee\u9898 + \u5220\u9664\u6458\u8981\u91cd\u590d \u7528\u6237\u53cd\u9988\u4e09\u4e2a P0 bug\uff1a 1. \u8868\u683c\u4e2d\u7684 \u2705/\u274c/\ud83d\udd36 \u6e32\u67d3\u4e3a\u65b9\u6846 \u539f\u56e0\uff1a\u601d\u6e90\u5b57\u4f53\u5b50\u96c6\u4e0d\u542b\u5f69\u8272 emoji\u7684 glyph\u3002\u9664\u975e\u6362\u5b57\u4f53\uff08\u8d85\u51fa\u9879\u76ee\u8303\u56f4\uff09\uff0c \u5426\u5219\u53ea\u80fd\u66ff\u6362\u6210\u5b57\u4f53\u6709\u7684\u7b26\u53f7\u3002 \u65b0\u589e _EMOJI_FALLBACK \u6620\u5c04\u8868\uff0c\u6db5\u76d6 20+ \u5e38\u7528 emoji\uff08\u5bf9\u5e94 CHECK/CROSS/DIAMOND/CIRCLE/STAR \u7b49\uff09\u3002 \u7528 fontTools \u9a8c\u8bc1\u8fc7\u5b57\u6bcd\u6709\u65e0\uff1a\u2713/\u00d7/\u25c6/\u25c7/\u25cf/\u25cb/\u2605/\u203b \u90fd\u6709\u3001\u2717/\u2611/\u2610 \u6ca1\u6709\uff0c\u5df2\u8c03\u6574\u76f8\u5e94\u66ff\u6362\u503c\u3002 2. \u76ee\u5f55/\u672f\u8bed\u8868\u4e0e\u4e0b\u4e00\u7ae0\u540c\u9875 / \u4e2d\u95f4\u7a7a\u9875 \u539f\u56e0\uff1abuild_toc \u672b\u5c3e\u52a0 PageBreak\uff0c\u7136\u540e\u4e0b\u4e00\u4e2a\u7ae0\u8282 H1 \u53c8 PageBreak\uff0c\u4e24\u4e2a PageBreak \u5939\u51fa\u7a7a\u9875\u3002 \u4fee\u590d\uff1abuild_toc / \u672f\u8bed\u8868\u6e32\u67d3\u672b\u5c3e\u53bb\u6389 PageBreak\uff0c\u4ec5\u9760\u4e0b\u4e2a H1 \u7684 PageBreak\u3002 \u5f00\u5934\u7684 PageBreak \u4fdd\u7559\uff0c\u4fdd\u8bc1\u201c\u76ee\u5f55 / \u672f\u8bed\u8868\u72ec\u7acb\u7b2c\u4e00\u9875\u5f00\u59cb\u201d\u3002 3. \u201c\u6267\u884c\u6458\u8981\u201d\u548c\u201c\u6458\u8981\u201d\u91cd\u590d \u6839\u636e\u7528\u6237\u9009\u62e9\uff1a\u4fdd\u7559\u6267\u884c\u6458\u8981\uff08Executive Summary\uff09\uff0c\u5220\u6389\u6458\u8981\uff08Abstract\uff09\u3002 build_body \u65b0\u589e\u201c\u8df3\u8fc7\u6574\u4e2a\u7ae0\u8282\u201d\u903b\u8f91\uff1a\u770b\u5230 "## \u6458\u8981" / "# \u6458\u8981" / "## Abstract" \u5c31\u8df3\u5230\u4e0b\u4e00\u4e2a H1/H2\u3002 \u91cd\u6784\u6e05\u7406\uff1a - \u63d0\u53d6 _render_generic_block \u5e2e\u52a9\u51fd\u6570\uff0c\u4ee3\u66ff build_body \u5185\u8054\u7684 p/quote/bullet/hr/image/table \u6e32\u67d3\u4ee3\u7801 - \u672f\u8bed\u8868\u90e8\u5206\u5faa\u73af\u590d\u7528\u8be5\u51fd\u6570 \u9a8c\u8bc1\u7ed3\u679c\uff1aPDF \u4ece 56 \u9875 \u2192 54 \u9875\uff08\u5220\u6389\u91cd\u590d\u7684\u6458\u8981 + \u6d88\u9664\u7a7a\u9875\uff09\u3002 \u5c55\u5f00\u7ae0\u8282\u8d77\u59cb\u9875\u987a\u5e8f\uff1a\u5c01\u9762(1) - \u514d\u8d23(2) - \u6267\u884c\u6458\u8981(2) - \u672f\u8bed\u8868(4) - \u76ee\u5f55(9) - \u6b63\u6587\u7b2c\u4e00\u7ae0(11) - \u7b2c\u5341\u7ae0(47) - \u53c2\u8003\u6587\u732e\u3002 remote \u914d\u7f6e\u4e3a http://192.168.50.45:3000/kai/deep_research.git\u3002 Co-authored-by: User --- .opencode/templates/report-template.py | 180 ++++++++++++++++++------- 1 file changed, 135 insertions(+), 45 deletions(-) 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