"""平台渲染层 · 共享叶子原语(docx 三 profile + 部分 pdf 复用)。 放**真正同源、与 profile 无关**的底层件:字体 OOXML 助手、化学式下标白名单、 内联/块级 markdown 正则、表格行切分、图片路径解析。三套 docx profile (manuscript=paper/proposal、brief)都 import 这里,**单一事实源**—— 改化学式白名单 / 字体规范只动这一处,不再三处各拷一份。 历史:原先 skills/{brief,paper,proposal}/scripts/render_docx.py 各自带一份 拷贝(_CHEM_RE 三份逐字相同、易漏改)。2026-06 抽到平台层 rendering/。 """ from __future__ import annotations import re from pathlib import Path from docx.oxml import OxmlElement from docx.oxml.ns import qn from docx.shared import Cm, Pt # ───────────────────────── 字体 OOXML 助手 ───────────────────────── def set_run_fonts(run, *, cn_font: str = "宋体", en_font: str = "Times New Roman") -> None: """同时设置 run 的中文 (eastAsia) 和西文 (ascii/hAnsi) 字体。""" rPr = run._element.get_or_add_rPr() rFonts = rPr.find(qn("w:rFonts")) if rFonts is None: rFonts = OxmlElement("w:rFonts") rPr.append(rFonts) rFonts.set(qn("w:eastAsia"), cn_font) rFonts.set(qn("w:ascii"), en_font) rFonts.set(qn("w:hAnsi"), en_font) def set_style_fonts(style, *, cn_font: str = "宋体", en_font: str = "Times New Roman") -> None: """直接给 style 写 rFonts, 基于该 style 的所有段落都继承字体。""" el = style.element rPr = el.find(qn("w:rPr")) if rPr is None: rPr = OxmlElement("w:rPr") el.insert(0, rPr) rFonts = rPr.find(qn("w:rFonts")) if rFonts is None: rFonts = OxmlElement("w:rFonts") rPr.append(rFonts) rFonts.set(qn("w:eastAsia"), cn_font) rFonts.set(qn("w:ascii"), en_font) rFonts.set(qn("w:hAnsi"), en_font) def set_subscript(run) -> None: rPr = run._element.get_or_add_rPr() va = OxmlElement("w:vertAlign") va.set(qn("w:val"), "subscript") rPr.append(va) # ───────────────────────── emoji 字体回退 ───────────────────────── # 正文/标题字体是宋体/黑体/Times New Roman —— 这些字体**没有 emoji 字形**,直接把 # emoji 写进这类 run,Word 里会渲染成豆腐块/缺字(用户视角"图标缺失",踩过 task # d9c74a5a 的今日新闻 docx)。做法:识别 emoji 码点段,单独开 run 走 Segoe UI Emoji # (Windows Word 自带彩色 emoji 字体),其余文字仍走原中西文字体。范围只圈真正的 # emoji 常用块,不含文本箭头(2190-21FF)等易误伤的符号。 EMOJI_RE = re.compile( "([\U0001F300-\U0001FAFF" # 主 emoji 区(符号图形/补充/扩展-A,含 📰🔥📊🔹) "\U0001F1E6-\U0001F1FF" # 区域指示符(国旗,如 🇨🇳) "\U00002600-\U000027BF" # 杂项符号 + Dingbats(☀✅✨✂ 等) "\U00002300-\U000023FF" # ⌚⏰⏱ 等 "\U00002B00-\U00002BFF" # ⬆⭐ 等 "\U0000FE00-\U0000FE0F" # 变体选择符(VS16 强制 emoji 呈现,如 🏙️) "\U0000200D]+)" # ZWJ(emoji 连字) ) _EMOJI_FONT = "Segoe UI Emoji" def add_fonted_runs(paragraph, text: str, *, size=None, cn_font: str = "宋体", en_font: str = "Times New Roman", bold: bool = False, italic: bool = False) -> None: """把 text 写成若干 run:emoji 段走 Segoe UI Emoji(否则宋体/黑体缺字形→豆腐块), 其余段走给定中西文字体。size/bold/italic 施加到每个 run。""" if not text: return for part in EMOJI_RE.split(text): if not part: continue r = paragraph.add_run(part) if size is not None: r.font.size = size if bold: r.bold = True if italic: r.italic = True if EMOJI_RE.fullmatch(part): set_run_fonts(r, cn_font=_EMOJI_FONT, en_font=_EMOJI_FONT) else: set_run_fonts(r, cn_font=cn_font, en_font=en_font) # ───────────────────────── 内联 markdown 切分 ───────────────────────── # 顺序敏感:**bold** 必须先于 *italic* 匹配, 否则会被 italic 抢 INLINE_RE = re.compile( r"(?P\*\*(?P[^*\n]+?)\*\*)" r"|(?P(?[^*\n]+?)\*(?!\*))" r"|(?P`(?P[^`\n]+?)`)" ) def parse_inline(text: str) -> list[tuple[str, str]]: """切成 (style, segment) 列表; style ∈ plain/bold/italic/code。""" out: list[tuple[str, str]] = [] pos = 0 for m in INLINE_RE.finditer(text): if m.start() > pos: out.append(("plain", text[pos:m.start()])) if m.group("bold"): out.append(("bold", m.group("bold_t"))) elif m.group("italic"): out.append(("italic", m.group("italic_t"))) elif m.group("code"): out.append(("code", m.group("code_t"))) pos = m.end() if pos < len(text): out.append(("plain", text[pos:])) return out or [("plain", text)] # ── 化学式下标白名单(三 profile 共用同一份;单一事实源)── # 长的在前,\b 防误伤 LC3 / C595 / 2026;不收 Ca2+ 这类带电荷的(那是上标,白名单不收即天然避开) CHEM_RE = re.compile( r"Ca\(OH\)2|Mg\(OH\)2" r"|\b(?:Al2O3|Fe2O3|Fe3O4|Mn2O3|Cr2O3|P2O5|Na2SO4|K2SO4|CaSO4|CaCO3|MgCO3|" r"CaCl2|MgCl2|Na2O|K2O|SiO2|TiO2|ZrO2|SO4|SO3|SO2|CO3|CO2|NO3|NO2|PO4|" r"H2O|NH3|CH4|C4AF|C3S2|C2AS|C3S|C2S|C3A|O2|N2|H2)\b" ) # ───────────────────────── 块级行类型正则 ───────────────────────── HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$") TABLE_LINE_RE = re.compile(r"^\s*\|.*\|\s*$") BLOCKQUOTE_RE = re.compile(r"^\s*>\s?") HR_RE = re.compile(r"^\s*-{3,}\s*$|^\s*={3,}\s*$|^\s*_{3,}\s*$") FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})\s*(\S*)\s*$") IMAGE_LINE_RE = re.compile(r"^\s*!\[(?P[^\]]*)\]\((?P[^)\s]+)\)\s*$") def is_table_line(line: str) -> bool: return bool(TABLE_LINE_RE.match(line)) def is_heading(line: str) -> bool: return bool(HEADING_RE.match(line)) def is_blockquote(line: str) -> bool: return bool(BLOCKQUOTE_RE.match(line)) def is_hr(line: str) -> bool: return bool(HR_RE.match(line)) # ───────────────────────── 表格行切分 ───────────────────────── def split_md_row(line: str) -> list[str]: return [c.strip() for c in line.strip().strip("|").split("|")] 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) def resolve_image_path(src: str, base_dir: Path) -> Path | None: """图片相对路径以 base_dir (单个 .md 所在目录) 为锚。""" p = Path(src) if not p.is_absolute(): p = (base_dir / p).resolve() return p if p.is_file() else None # ───────────────────────── sections 输入收集 ───────────────────────── def collect_md_files(src: Path) -> list[Path]: """把 render 入口的 src 归一为 md 文件列表:单个 .md → [src],目录 → 内部 *.md 排序。空则 [](调用方报错)。render.py 三 docx profile 与 pdf 共用同一 "目录或单文件"契约 —— 此前 docx 侧只认目录,单文件报 `sections dir not found`, 逼模型 mkdir sections/ 再 cp 进去兜底(踩过 task d9c74a5a),现统一放开。""" if src.is_file(): return [src] if src.is_dir(): return sorted(src.glob("*.md")) return []