144 lines
5.3 KiB
Python
144 lines
5.3 KiB
Python
"""平台渲染层 · 共享叶子原语(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)
|
|
|
|
|
|
# ───────────────────────── 内联 markdown 切分 ─────────────────────────
|
|
|
|
# 顺序敏感:**bold** 必须先于 *italic* 匹配, 否则会被 italic 抢
|
|
INLINE_RE = re.compile(
|
|
r"(?P<bold>\*\*(?P<bold_t>[^*\n]+?)\*\*)"
|
|
r"|(?P<italic>(?<![\*\w])\*(?P<italic_t>[^*\n]+?)\*(?!\*))"
|
|
r"|(?P<code>`(?P<code_t>[^`\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<cap>[^\]]*)\]\((?P<src>[^)\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 != "")
|
|
|
|
|
|
# ───────────────────────── 图片 ─────────────────────────
|
|
|
|
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
|