@@ -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
@@ -812,6 +852,19 @@ def format_gb7714(rec: dict) -> str:
return body
def _sort_src_id ( sid : str ) - > tuple :
""" 为 src_id 生成排序键:按字母段分组(A/B/C/E/...),组内按数字升序。 """
m = re . match ( r " src_([A-Za-z]+)?( \ d+)?([A-Za-z0-9_ \ -]*) " , sid )
if not m :
return ( " ~ " , 0 , sid )
alpha , num , rest = m . group ( 1 ) or " " , m . group ( 2 ) or " 0 " , m . group ( 3 ) or " "
try :
num_int = int ( num )
except ValueError :
num_int = 0
return ( alpha , num_int , rest )
def build_references (
blocks : List [ Block ] ,
sources_path : Optional [ Path ] ,
@@ -819,7 +872,12 @@ def build_references(
) - > List :
""" 生成参考文献段落。
引用顺序:按正文首次出现的先后排列(GB/T 7714 顺序编码制)。
v0.7 改变:**不再按出现顺序重编号**(之前会导致正文中 `[src_E43]` 和参考文献
区的 `[27]` 对不上)。改为:
- 参考文献条目直接用原始 `src_id` 作为编号(如 `[src_E43] Alnylam..., 2025.`)
- 按 src_id 字母数字排序分组
- 缺失的 src_id 单独一段列出,明显标注供人工核查
- 顶部给一条 " 引文健康状态 " 小结
"""
story : list = [ ]
story . append ( Paragraph ( " 参考文献 " , styles [ " h1 " ] ) )
@@ -835,31 +893,67 @@ def build_references(
) )
return story
if not sources :
# 至少列出所有被引用的 ID,供人工回填
cited_set = set ( cited_ids )
matched = [ sid for sid in cited_ids if sid in sources ]
missing = [ sid for sid in cited_ids if sid not in sources ]
# sources.jsonl 里有但正文没引用的——列为"备选"不展示,只统计
unused = [ sid for sid in sources if sid not in cited_set ]
# 头部健康状态
health = (
f " 正文引用 <b> { len ( cited_set ) } </b> 条独立标识符; "
f " sources.jsonl 收录 <b> { len ( sources ) } </b> 条, "
f " <b> { len ( matched ) } </b> 条可对应, "
f " <b> { len ( missing ) } </b> 条在 sources.jsonl 中未找到。 "
)
if unused :
health + = f " 另有 { len ( unused ) } 条收录来源未在正文中引用,已省略展示。 "
story . append ( Paragraph (
f " (未找到 sources.jsonl 或其内容为空。以下为正文出现的 { len ( cited_ids ) } 个引用标识符) " ,
f " <font color= ' #6b7280 ' size=8>引文健康状态: { health } </font> " ,
styles [ " caption " ] ,
) )
for i , sid in enumerate ( cited_ids , 1 ) :
story . append ( Paragraph ( f " [ { i } ] { sid } " , styles [ " footnote " ] ) )
return story
story . append ( Spacer ( 1 , 0.3 * cm ) )
missing : list [ str ] = [ ]
for i , sid in enumerate ( cited_ids , 1 ) :
rec = sources . get ( sid )
if not rec :
missing . append ( sid )
# 主列表:按 src_id 字母数字排序
if matched :
story . append ( Paragraph (
f " [ { i } ] { sid } (来源记录缺失,请核查 sources.jsonl) " ,
styles [ " footnote " ] ,
" <b>收录来源</b> " ,
styles [ " h3 " ] ,
) )
continue
for sid in sorted ( matched , key = _sort_src_id ) :
rec = sources [ sid ]
text = format_gb7714 ( rec )
# 前面加序号,后面追加 [ sid] 便于正文回溯
entry = f " [ { i } ] { text } <font color= ' #6b7280 ' size=7>【 { sid } 】</font> "
# 编号就是原始 sid, 便于和 正文中的 [src_E43] 上标对应
entry = f " <b> [{ sid } ]</b> { text } "
story . append ( Paragraph ( entry , styles [ " footnote " ] ) )
# 缺失列表:明显标注
if missing :
story . append ( Spacer ( 1 , 0.4 * cm ) )
story . append ( Paragraph (
f " <b>未找到来源( { len ( missing ) } 条)</b> " ,
styles [ " h3 " ] ,
) )
story . append ( Paragraph (
" <font color= ' #b45309 ' size=8> "
" 以下标识符在正文中出现但未在 <code>sources.jsonl</code> 中找到对应记录。 "
" 可能是编写阶段的占位符未回填,或原始研究员引用不规范,请核查后补充。 "
" </font> " ,
styles [ " caption " ] ,
) )
# 按字母数字排序分组展示,一行三个,节省篇幅
sorted_missing = sorted ( missing , key = _sort_src_id )
# 每 4 个一行
row_size = 4
for k in range ( 0 , len ( sorted_missing ) , row_size ) :
chunk = sorted_missing [ k : k + row_size ]
row_text = " " . join ( f " [ { sid } ] " for sid in chunk )
story . append ( Paragraph (
f " <font color= ' #b45309 ' > { row_text } </font> " ,
styles [ " footnote " ] ,
) )
# 打印到 stderr
if missing :
print (
f " WARNING: { len ( missing ) } cited src_ids not found in sources.jsonl: "
@@ -939,127 +1033,16 @@ def render_table(md_table: str, styles: StyleSheet1) -> Table:
return table
def build_body (
blocks : List [ Block ] ,
base_dir : Path ,
styles : StyleSheet1 ,
* ,
sources_path : Optional [ Path ] = None ,
) - > List :
""" 把 Markdown blocks 渲染为 flowable。
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)。
v0.6 升级:
- 跳过正文开头的封面 H1 + 紧跟的元信息段(由 build_cover 独立生成,避免重复)
- 识别 " 目录 " 占位段落 → 自动生成 TOC
- 识别 " 参考文献 " 占位段落 → 自动读 sources.jsonl 生成 GB/T 7714 列表
- H1 triggers PageBreak; H2/H3 keepWithNext;表格 splitByRow
提取出来的帮助函数,给术语表内部循环和主循环复用。
"""
story : list = [ ]
first_h1_seen = False # 是否已跳过正文首个 H1
skipping_cover_meta = False # 是否在吞掉封面元信息段
# Summary 样式
in_summary = False
# 准备跳过标志:标题级别下一个 "目录""参考文献" 见到时替换掉它(包含其下紧跟的占位段)
# 采用简单索引遍历以便向前看。
i = 0
n = len ( blocks )
while i < n :
block = blocks [ i ]
# --- 跳过正文首个 H1(封面标题)+ 紧跟的元信息/hr ---
if not first_h1_seen and block . kind == " h1 " :
first_h1_seen = True
skipping_cover_meta = True
i + = 1
continue
if skipping_cover_meta :
# 吞掉 p(元信息)、hr、quote(副标题可能被当成加粗段)
# 遇到 h1/h2/h3 就停止吞
if block . kind in ( " h1 " , " h2 " , " h3 " ) :
skipping_cover_meta = False
# 不 continue,让当前 block 正常处理
elif block . kind == " p " and is_cover_frontmatter ( block . content ) :
i + = 1
continue
elif block . kind in ( " hr " , " quote " , " p " , " bullet " ) :
# 第一个 hr 标记封面结束
if block . kind == " hr " :
skipping_cover_meta = False
i + = 1
continue
# 普通段落:如果不是封面元信息,就认为封面已结束
if block . kind == " p " :
skipping_cover_meta = False
# fall through to normal handling
else :
i + = 1
continue
else :
i + = 1
continue
# --- H1 处理(非首个)---
if block . kind == " h1 " :
story . append ( PageBreak ( ) )
content = block . content
if any ( k in content for k in ( " 执行摘要 " , " Executive Summary " , " 管理层摘要 " ) ) :
in_summary = True
else :
in_summary = False
# 目录 / 参考文献:替换为自动生成的内容
title_low = content . strip ( ) . lower ( )
if any ( s in title_low for s in ( " 目录 " , " table of contents " ) ) :
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
if any ( s in title_low for s in ( " 参考文献 " , " references " ) ) :
story . extend ( build_references ( blocks , sources_path , styles ) )
j = i + 1
while j < n and blocks [ j ] . kind == " p " and _REF_PLACEHOLDER_RE . search ( blocks [ j ] . content ) :
j + = 1
i = j
continue
story . append ( Paragraph ( md_inline_to_rl ( content ) , styles [ " h1 " ] ) )
i + = 1
continue
# --- H2 同样检测占位符 ---
if block . kind == " h2 " :
title_low = block . content . strip ( ) . lower ( )
if any ( s in title_low for s in ( " 目录 " , " table of contents " ) ) :
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
if any ( s in title_low for s in ( " 参考文献 " , " references " ) ) :
story . extend ( build_references ( blocks , sources_path , styles ) )
j = i + 1
while j < n and blocks [ j ] . kind == " p " and _REF_PLACEHOLDER_RE . search ( blocks [ j ] . content ) :
j + = 1
i = j
continue
story . append ( Paragraph ( md_inline_to_rl ( block . content ) , styles [ " h2 " ] ) )
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
return
style = styles [ " summary " ] if in_summary else styles [ " body " ]
story . append ( Paragraph ( md_inline_to_rl ( block . content ) , style ) )
elif block . kind == " quote " :
@@ -1092,6 +1075,139 @@ def build_body(
except Exception as e :
story . append ( Paragraph ( f " [表格渲染失败: { e } ] " , styles [ " caption " ] ) )
def build_body (
blocks : List [ Block ] ,
base_dir : Path ,
styles : StyleSheet1 ,
* ,
sources_path : Optional [ Path ] = None ,
) - > List :
""" 把 Markdown blocks 渲染为 flowable。
v0.6 升级:
- 跳过正文开头的封面 H1 + 紧跟的元信息段(由 build_cover 独立生成,避免重复)
- 识别 " 目录 " 占位段落 → 自动生成 TOC
- 识别 " 参考文献 " 占位段落 → 自动读 sources.jsonl 生成 GB/T 7714 列表
- H1 triggers PageBreak; H2/H3 keepWithNext;表格 splitByRow
"""
story : list = [ ]
in_summary = False
# 第一步:跳过"封面块"——从正文开头一直跳到第一个 H2/H3 前。
# 封面块 = 首个 H1(主标题) + 副标题(加粗 p) + 元信息段(Confidentiality/Date/Version) + 分隔线(hr)。
# 这些已由 build_cover 从 manifest 独立生成,正文里再出现就是重复。
# 规则简单可靠:跳过所有 block 直到遇到第一个 H2/H3(如 "## 免责声明")。
n = len ( blocks )
first_section_idx = n
for k , b in enumerate ( blocks ) :
if b . kind in ( " h2 " , " h3 " ) :
first_section_idx = k
break
i = first_section_idx # 从第一个 section 开始处理
while i < n :
block = blocks [ i ]
# --- H1 处理(非首个;本循环内 first_section_idx 之后的 H1 都是真正的章节 H1)---
if block . kind == " h1 " :
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
# 目录:已经 PageBreak 了,build_toc 内部再 PageBreak(独立页)
if any ( s in title_low for s in ( " 目录 " , " table of contents " ) ) :
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
# 术语表: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
while j < n and blocks [ j ] . kind == " p " and _REF_PLACEHOLDER_RE . search ( blocks [ j ] . content ) :
j + = 1
i = j
continue
story . append ( Paragraph ( md_inline_to_rl ( content ) , styles [ " h1 " ] ) )
i + = 1
continue
# --- 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
while j < n and blocks [ j ] . kind == " p " and _REF_PLACEHOLDER_RE . search ( blocks [ j ] . content ) :
j + = 1
i = j
continue
story . append ( Paragraph ( md_inline_to_rl ( block . content ) , styles [ " h2 " ] ) )
i + = 1
continue
# 非 H1/H2 的 block:统一走 _render_generic_block
_render_generic_block ( block , story , base_dir , styles , in_summary = in_summary )
i + = 1
return story