Files
to_docx/server/formula_processor.py
Chen Xiao 0b64e2de94 First commit.
Signed-off-by: Chen Xiao <abigwc@gmail.com>
2026-05-08 14:43:16 +08:00

294 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
公式预处理模块
灵感来源于 https://github.com/GALVINLAI/formatting
在将 Markdown 交给 Pandoc 转换之前,统一各种 AI (GPT/Claude) 生成内容中
常见的非标准公式定界符,使其变成 Pandoc 可识别的标准格式。
默认启用的处理流程:
0. 修复粗体 **...** → <strong>...</strong>(解决 CJK 标点场景下的 Pandoc 定界符问题)
1. 修复 GPT 缺失反斜杠:独立行的 [ ... ] → \[ ... \]
2. 修复 GPT 缺失反斜杠:疑似数学的 ( ... ) → \( ... \)(保守策略)
3. \( ... \) → $ ... $(行内公式)
4. \[ ... \] → $$ ... $$(行间公式)
"""
import re
# ====================================
# 内部辅助函数
# ====================================
# 已存在的数学环境匹配(用于跳过,防止重复处理)
_MATH_SPAN_RE = re.compile(
r'('
r'\$\$.*?\$\$'
r'|\\\[.*?\\\]'
r'|\\begin\{equation\*?\}.*?\\end\{equation\*?\}'
r'|\\begin\{align\*?\}.*?\\end\{align\*?\}'
r'|\\\(.*?\\\)'
r'|(?<!\$)\$[^$\n]+?\$(?!\$)'
r')',
flags=re.DOTALL
)
_OPEN_LINE_RE = re.compile(r'^\s*\[\s*$')
_CLOSE_LINE_RE = re.compile(r'^\s*\]\s*$')
def _is_mathish(text: str) -> bool:
"""判断文本是否看起来像数学表达式(保守策略)"""
stripped = text.strip()
if not stripped:
return False
# 含中日韩字符的不是数学
if re.search(r'[\u4e00-\u9fff\u30a0-\u30ff\u3040-\u309f\uac00-\ud7af]', stripped):
return False
# 去掉 LaTeX 命令后判断
stripped_no_cmd = re.sub(r'\\[a-zA-Z]+', '', stripped)
if re.search(r'[A-Za-z]{3,}', stripped_no_cmd):
has_cmd = re.search(r'\\[a-zA-Z]+', stripped) is not None
has_eq = re.search(r'[=<>]', stripped) is not None
has_arith = re.search(
r'(?:[A-Za-z0-9]{1,2}|\\[a-zA-Z]+)\s*[+*/-]\s*(?:[A-Za-z0-9]{1,2}|\\[a-zA-Z]+)',
stripped
) is not None
has_scripts = re.search(r'[\^_]', stripped) is not None
has_abs = re.search(r'\|[^|]+\|', stripped) is not None
words = re.findall(r'[A-Za-z]{2,}', stripped_no_cmd)
has_sentence_punct = re.search(r'[,:;.]', stripped) is not None
if has_cmd and len(words) == 0:
return True
if ' ' in stripped:
stopwords = {
'or', 'and', 'between', 'for', 'if', 'then', 'as', 'such', 'that', 'which',
'where', 'there', 'exists', 'only', 'with', 'without', 'on', 'in', 'at',
'from', 'to', 'by', 'of', 'the', 'a', 'an'
}
lower_words = {w.lower() for w in words}
if lower_words & stopwords and not (has_eq or has_arith):
return False
if len(words) >= 2 and has_scripts and not (has_eq or has_arith or has_abs):
return False
if ' ' in stripped and len(words) >= 2 and has_sentence_punct and not (has_eq or has_arith):
return False
if ' ' in stripped and len(words) >= 2 and not (has_eq or has_arith or has_scripts or has_abs):
return False
if ' ' in stripped and len(words) >= 1 and has_cmd and not (has_eq or has_arith or has_scripts or has_abs):
return False
if not (has_cmd or has_eq or has_arith or has_scripts or has_abs):
return False
if re.search(r'\\[a-zA-Z]+', stripped):
return True
if re.search(r'[\^_]', stripped):
return True
if re.search(r'[=<>]', stripped):
return True
if re.search(r'\|[^|]+\|', stripped):
return True
return False
def _is_escaped(text: str, idx: int) -> bool:
backslashes = 0
j = idx - 1
while j >= 0 and text[j] == '\\':
backslashes += 1
j -= 1
return backslashes % 2 == 1
def _find_parentheses_pairs(text: str):
stack = []
pairs = []
for i, ch in enumerate(text):
if ch == '(' and not _is_escaped(text, i):
stack.append(i)
elif ch == ')' and not _is_escaped(text, i):
if stack:
start = stack.pop()
pairs.append((start, i))
return pairs
def _repair_inline_parentheses_plain(text: str) -> str:
"""将文本中疑似数学的 (…) 替换为 \(…\)"""
pairs = _find_parentheses_pairs(text)
if not pairs:
return text
candidates = []
for start, end in pairs:
inner = text[start + 1:end]
if '\n' in inner:
continue
if start > 0 and re.match(r'[A-Za-z0-9_\\]', text[start - 1]):
continue
if end + 1 < len(text) and re.match(r'[A-Za-z0-9_\\]', text[end + 1]):
continue
if not _is_mathish(inner):
continue
candidates.append((start, end))
if not candidates:
return text
# 选择外层优先,避免嵌套 \( \)
candidates.sort(key=lambda p: (p[0], -(p[1] - p[0])))
selected = []
for start, end in candidates:
overlaps = any(not (end < s_start or start > s_end) for s_start, s_end in selected)
if not overlaps:
selected.append((start, end))
if not selected:
return text
out = text
for start, end in sorted(selected, key=lambda p: p[0], reverse=True):
inner = out[start + 1:end]
out = out[:start] + f'\\({inner}\\)' + out[end + 1:]
return out
# ====================================
# 核心公开处理函数
# ====================================
def repair_display_brackets(content: str) -> str:
"""
将 GPT 输出中独立成行的 [ ... ] 识别为行间公式,改为 \[ ... \]。
只在 [ 和 ] 各自单独成行时才转换,以避免误伤普通方括号。
"""
lines = content.splitlines(keepends=True)
out = []
i = 0
while i < len(lines):
line = lines[i]
if _OPEN_LINE_RE.match(line):
j = i + 1
while j < len(lines) and not _CLOSE_LINE_RE.match(lines[j]):
j += 1
if j < len(lines):
out.append(line.replace('[', r'\[', 1))
out.extend(lines[i + 1:j])
out.append(lines[j].replace(']', r'\]', 1))
i = j + 1
continue
out.append(line)
i += 1
return ''.join(out)
def repair_inline_parentheses(content: str) -> str:
"""
保守策略:将非数学环境中疑似数学的 (…) 替换为 \(…\)。
已在 $...$ 或 \[...\] 等数学环境内的括号不受影响。
"""
parts = _MATH_SPAN_RE.split(content)
for i in range(0, len(parts), 2):
parts[i] = _repair_inline_parentheses_plain(parts[i])
return ''.join(parts)
def square_brackets_to_dollars(content: str) -> str:
"""将 \[ ... \] 行间公式环境替换为 $$ ... $$"""
return re.sub(r'\\\[|\\\]', '$$', content)
def parentheses_to_single_dollar(content: str) -> str:
"""将 \( ... \) 行内公式环境替换为 $ ... $"""
return re.sub(r'\\\(|\\\)', '$', content)
# ====================================
# 粗体修复(CJK 兼容)
# ====================================
# 用于分离代码块(避免修改代码块内的 ** 标记)
_CODE_BLOCK_RE = re.compile(r'(```[\s\S]*?```|`[^`\n]+`)', re.DOTALL)
# 会导致 ** 开头定界符失效的 Unicode 标点字符(涵盖 ASCII 和 CJK 引号、括号等)
_OPEN_PUNCT_AFTER_BOLD = re.compile(
r'(\*\*)' # 匹配 **
r'([\'\"' # ASCII 标点 ' "
r'\u2018\u2019\u201c\u201d' # 弯引号 ' ' " "
r'\u00ab\u00bb\u2039\u203a' # 《》‹›
r'\u300c\u300d\u300e\u300f' # 「」『』
r'\u2014\u2013\u2026' # —
r'\uff01\uff08\uff09\uff0c\uff1a\uff1b\uff1f' # !(),:;?
r'])'
)
# 会导致 ** 结尾定界符失效的 Unicode 标点字符
_CLOSE_PUNCT_BEFORE_BOLD = re.compile(
r'([\'\"' # ASCII 标点 ' "
r'\u2018\u2019\u201c\u201d' # 弯引号 ' ' " "
r'\u00ab\u00bb\u2039\u203a' # 《》‹›
r'\u300c\u300d\u300e\u300f' # 「」『』
r'\u2014\u2013\u2026' # —
r'\uff01\uff08\uff09\uff0c\uff1a\uff1b\uff1f' # !(),:;?
r'\u3002' # 句号 。
r'])'
r'(\*\*)' # 匹配 **
)
def fix_bold_markers(content: str) -> str:
"""
修复 ** 粗体定界符在 CJK 标点场景下失效的问题。
【问题根源】
CommonMark 对定界符侧翼规则有严格要求:
1. 左侧定界符:若 ** 后面是标点,则前面必须是空白或标点。
2. 右侧定界符:若 ** 前面是标点,则后面必须是空白或标点。
当中英文混排时(例如 `的**"文本"**。`),上述条件常常不满足,导致 Pandoc 识别失败。
【修复方案】
使用 U+200B (零宽空格),其类别为 Cf(格式字符),既非标点也非空白。
- 左侧修复:在 `**` 和后接标点之间插入 U+200B。这使得 `**` 后面紧跟的变成 Cf,不属于标点,满足左侧条件。
- 右侧修复:在先接标点和 `**` 之间插入 U+200B。这使得 `**` 前面紧跟的变成 Cf,不属于标点,满足右侧条件。
"""
segments = _CODE_BLOCK_RE.split(content)
result = []
ZWS = '\u200b'
for i, seg in enumerate(segments):
if i % 2 == 1:
result.append(seg)
else:
# 修复右侧定界符(如 "文本"** -> "文本"[ZWS]**
seg = _CLOSE_PUNCT_BEFORE_BOLD.sub(lambda m: m.group(1) + ZWS + m.group(2), seg)
# 修复左侧定界符(如 **"文本" -> **[ZWS]"文本"
seg = _OPEN_PUNCT_AFTER_BOLD.sub(lambda m: m.group(1) + ZWS + m.group(2), seg)
result.append(seg)
return ''.join(result)
# ====================================
# 主入口:对 Markdown 进行公式预处理
# ====================================
def preprocess_formulas(content: str) -> str:
"""
对 Markdown 内容进行格式标准化处理,再交给 Pandoc 转换。
处理顺序:
0. **粗体** → <strong>(修复 CJK 标点场景下 Pandoc 定界符失效)
1. 修复独立行的 [ ... ] → \[ ... \]
2. 修复疑似数学的 ( ... ) → \( ... \)(保守策略)
3. \( ... \) → $ ... $
4. \[ ... \] → $$ ... $$
Args:
content: 原始 Markdown 字符串
Returns:
处理后的 Markdown 字符串
"""
# 步骤0:修复粗体标记(必须在公式处理之前,避免 $ 内的 ** 被误处理)
content = fix_bold_markers(content)
# 步骤1:修复 GPT 缺失反斜杠的行间公式 [ ... ] → \[ ... \]
content = repair_display_brackets(content)
# 步骤2:修复 GPT 缺失反斜杠的行内公式 ( ... ) → \( ... \)
content = repair_inline_parentheses(content)
# 步骤3\( ... \) → $ ... $
content = parentheses_to_single_dollar(content)
# 步骤4\[ ... \] → $$ ... $$
content = square_brackets_to_dollars(content)
return content