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 <human>
This commit is contained in:
kai
2026-04-22 16:05:45 +08:00
co-authored by User <human>
parent c88da4a20f
commit 1394d98346
+135 -45
View File
@@ -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 <super> 标签。
@@ -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 的 blockp/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