refactor(rendering): 块收集器与 inline 切分下沉 common——golden 零 diff 验收

审查 P1:docx_brief 与 docx_manuscript 的 render_md_block 各手写一套块级
扫描,同一 md 走两条路径最易漂移。按「分派语义留 profile、边界收集归 common」
拆分:

- common 新增 gather_fence(闭合判据单一事实源)/ gather_table /
  gather_blockquote / gather_paragraph(软换行并合,is_list 谓词注入)
  ——此前两份逐字拷贝的循环全部换成共享调用
- brief 的 add_inline_rich 手写 INLINE_RE 循环换 common.parse_inline 迭代
  (与 manuscript 同一切分事实源);brief 差异收敛到 plain 段处理
  (引文上标 + 化学式)一处
- 刻意不动:分派顺序与 brief 状态机(in_refs/expect_meta/in_tldr 会改变块
  边界语义,是 profile 的产品行为不是重复)、各 profile 样式

验收:golden 基线(上一提交)逐字节零 diff;319 测试全过。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-23 13:27:31 +08:00
parent 035aec44ac
commit 21e7dad7f1
3 changed files with 86 additions and 73 deletions

View File

@ -173,6 +173,66 @@ def is_separator_row(cells: list[str]) -> bool:
return all(re.match(r"^[-:\s]+$", c) for c in cells if c != "")
# ───────────────────────── 块收集器(docx_brief / docx_manuscript 共用)─────────────────────────
# render_md_block 的分派顺序与状态机(brief 的 in_refs/expect_meta/in_tldr)是各
# profile 的语义,留在各自文件;这里只收**块边界的收集逻辑**——fence 闭合判据、
# 表格聚块、blockquote 并合、段落软换行并合,这些此前两份逐字拷贝、最易漂移。
def gather_fence(lines: list[str], i: int, fence: str) -> tuple[list[str], int]:
"""FENCE_RE 命中开栅行后调用(i 指向开栅行,fence=开栅串)。收集代码行至闭合,
返回 (code_lines, next_i)闭合判据:同字符(` vs ~)且长度 >= 开栅;无闭合收到文末"""
code: list[str] = []
i += 1
n = len(lines)
while i < n:
m_close = FENCE_RE.match(lines[i])
if m_close and m_close.group(1)[0] == fence[0] and len(m_close.group(1)) >= len(fence):
i += 1
break
code.append(lines[i])
i += 1
return code, i
def gather_table(lines: list[str], i: int) -> tuple[list[str], int]:
"""从 i 起收集连续表格行,返回 (table_lines, next_i)。"""
block: list[str] = []
n = len(lines)
while i < n and is_table_line(lines[i]):
block.append(lines[i])
i += 1
return block, i
def gather_blockquote(lines: list[str], i: int) -> tuple[list[str], int]:
"""从 i 起收集连续 `>` 行(剥掉引导符,逐行 strip),返回 (texts, next_i)。"""
texts = [BLOCKQUOTE_RE.sub("", lines[i].rstrip()).strip()]
i += 1
n = len(lines)
while i < n and is_blockquote(lines[i]):
texts.append(BLOCKQUOTE_RE.sub("", lines[i]).strip())
i += 1
return texts, i
def gather_paragraph(lines: list[str], i: int, is_list) -> tuple[str, int]:
"""普通段落软换行并合:从 i 起吸后续行直到空行/标题/引用/表格/列表/HR,
返回 (合并后的段落文本, next_i)is_list profile 各自的列表判定谓词"""
buf = [lines[i].rstrip().strip()]
j = i + 1
n = len(lines)
while j < n:
nxt = lines[j].rstrip()
if not nxt.strip():
break
if (is_heading(nxt) or is_blockquote(nxt) or is_table_line(nxt)
or is_list(nxt) or is_hr(nxt)):
break
buf.append(nxt.strip())
j += 1
return " ".join(buf), j
# ───────────────────────── 图片 ─────────────────────────
MAX_IMG_WIDTH = Cm(15)

View File

@ -23,7 +23,7 @@ from .common import (
set_style_fonts as _set_style_fonts,
set_subscript as _set_subscript,
CHEM_RE as _CHEM_RE,
INLINE_RE as _INLINE_RE,
parse_inline as _parse_inline,
HEADING_RE as _HEADING_RE,
TABLE_LINE_RE as _TABLE_LINE_RE,
BLOCKQUOTE_RE as _BLOCKQUOTE_RE,
@ -35,6 +35,10 @@ from .common import (
resolve_image_path as _resolve_image_path,
MAX_IMG_WIDTH as _MAX_IMG_WIDTH,
collect_md_files as _collect_md_files,
gather_fence as _gather_fence,
gather_table as _gather_table,
gather_blockquote as _gather_blockquote,
gather_paragraph as _gather_paragraph,
)
# ───────────────────────── 主题色 ─────────────────────────
@ -243,29 +247,26 @@ def _set_subscript_super(run) -> None:
def add_inline_rich(paragraph, text: str, *, size_pt=12.0, cn_font="宋体",
make_citations=True) -> None:
pos = 0
for m in _INLINE_RE.finditer(text):
if m.start() > pos:
_emit_plain_with_cites(paragraph, text[pos:m.start()], size_pt=size_pt,
"""行内 markdown → runs:切分走 common.parse_inline(与 manuscript 同一事实源);
brief 的差异只在 plain 段处理(引文上标超链 + 化学式下标, _emit_plain_with_cites)"""
for style, seg in _parse_inline(text):
if style == "plain":
_emit_plain_with_cites(paragraph, seg, size_pt=size_pt,
cn_font=cn_font, make_citations=make_citations)
if m.group("bold"):
run = paragraph.add_run(m.group("bold_t"))
elif style == "bold":
run = paragraph.add_run(seg)
run.bold = True
run.font.size = Pt(size_pt)
_set_run_fonts(run, cn_font=cn_font)
elif m.group("italic"):
run = paragraph.add_run(m.group("italic_t"))
elif style == "italic":
run = paragraph.add_run(seg)
run.italic = True
run.font.size = Pt(size_pt)
_set_run_fonts(run, cn_font=cn_font)
elif m.group("code"):
run = paragraph.add_run(m.group("code_t"))
elif style == "code":
run = paragraph.add_run(seg)
run.font.size = Pt(size_pt)
_set_run_fonts(run, cn_font=cn_font, en_font="Consolas")
pos = m.end()
if pos < len(text):
_emit_plain_with_cites(paragraph, text[pos:], size_pt=size_pt,
cn_font=cn_font, make_citations=make_citations)
# ───────────────────────── 标题 / 段落 ─────────────────────────
@ -526,16 +527,7 @@ def render_md_block(doc: Document, md_text: str, ctx: dict) -> None:
m_fence = _FENCE_RE.match(line)
if m_fence:
fence = m_fence.group(1)
code = []
i += 1
while i < n:
mc = _FENCE_RE.match(lines[i])
if mc and mc.group(1)[0] == fence[0] and len(mc.group(1)) >= len(fence):
i += 1
break
code.append(lines[i])
i += 1
code, i = _gather_fence(lines, i, m_fence.group(1))
for ln in code:
p = doc.add_paragraph()
p.paragraph_format.first_line_indent = None
@ -546,10 +538,7 @@ def render_md_block(doc: Document, md_text: str, ctx: dict) -> None:
continue
if _TABLE_LINE_RE.match(line):
block = []
while i < n and _TABLE_LINE_RE.match(lines[i]):
block.append(lines[i])
i += 1
block, i = _gather_table(lines, i)
render_table(doc, block, color)
continue
@ -570,12 +559,8 @@ def render_md_block(doc: Document, md_text: str, ctx: dict) -> None:
if _BLOCKQUOTE_RE.match(line):
# 引用块:并合连续 > 行,做浅红 callout(说明 / 取舍纪律等)
buf = [_BLOCKQUOTE_RE.sub("", line).strip()]
i += 1
while i < n and _BLOCKQUOTE_RE.match(lines[i]):
buf.append(_BLOCKQUOTE_RE.sub("", lines[i]).strip())
i += 1
add_callout(doc, " ".join(buf), TLDR_FILL, color)
texts, i = _gather_blockquote(lines, i)
add_callout(doc, " ".join(texts), TLDR_FILL, color)
continue
# 参考文献条目
@ -609,17 +594,8 @@ def render_md_block(doc: Document, md_text: str, ctx: dict) -> None:
continue
# 普通段落:并合软换行
buf = [line.strip()]
j = i + 1
while j < n:
nxt = lines[j].rstrip()
if not nxt.strip() or _HEADING_RE.match(nxt) or _BLOCKQUOTE_RE.match(nxt) \
or _TABLE_LINE_RE.match(nxt) or is_list_item(nxt) or _HR_RE.match(nxt):
break
buf.append(nxt.strip())
j += 1
add_body_paragraph(doc, " ".join(buf), indent=True)
i = j
text, i = _gather_paragraph(lines, i, is_list_item)
add_body_paragraph(doc, text, indent=True)
# ───────────────────────── 入口 ─────────────────────────

View File

@ -349,17 +349,8 @@ def render_md_block(doc: Document, md_text: str, ctx: dict) -> None:
m_fence = common.FENCE_RE.match(line)
if m_fence:
fence = m_fence.group(1)
lang = m_fence.group(2) or ""
code: list[str] = []
i += 1
while i < n:
m_close = common.FENCE_RE.match(lines[i])
if m_close and m_close.group(1)[0] == fence[0] and len(m_close.group(1)) >= len(fence):
i += 1
break
code.append(lines[i])
i += 1
code, i = common.gather_fence(lines, i, m_fence.group(1))
if lang.lower() == "mermaid":
source = "\n".join(code)
@ -375,10 +366,7 @@ def render_md_block(doc: Document, md_text: str, ctx: dict) -> None:
continue
if common.is_table_line(line):
block: list[str] = []
while i < n and common.is_table_line(lines[i]):
block.append(lines[i])
i += 1
block, i = common.gather_table(lines, i)
render_table(doc, block)
continue
@ -398,19 +386,8 @@ def render_md_block(doc: Document, md_text: str, ctx: dict) -> None:
i += 1
continue
buf = [line.strip()]
j = i + 1
while j < n:
nxt = lines[j].rstrip()
if not nxt.strip():
break
if (common.is_heading(nxt) or common.is_blockquote(nxt) or common.is_table_line(nxt)
or is_list_item(nxt, prof) or common.is_hr(nxt)):
break
buf.append(nxt.strip())
j += 1
add_body_paragraph(doc, " ".join(buf), indent=True)
i = j
text, i = common.gather_paragraph(lines, i, lambda ln: is_list_item(ln, prof))
add_body_paragraph(doc, text, indent=True)
# ───────────────────────── 入口 ─────────────────────────