294 lines
11 KiB
Python
294 lines
11 KiB
Python
"""
|
||
公式预处理模块
|
||
灵感来源于 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
|