Files
DoLuEdu-WordFileParse/scripts/omml_mathml2latex/strategies/math_strategy.py
Quentin d4c2fd1fca fix(omml2latex): 修复多字母标识符被拆开的问题
OMML2MML 会把 Word 里连写的多字母标识符(线段名 BC、三角形 △ABC、
角度 45° 等)拆成相邻的 <mi>/<mn> 元素,原转换器用空格 join 导致
'BC' 被渲染成 'B C'(B×C),'45°' 变成 '4 5°'。

三处修复:
- mrow_visitor: 连续单字母 <mi> 拼接
- legacy_adapter: <math> 根节点多子元素时连续单字母 <mi> 拼接
- math_strategy: Unicode+字母混合内容按'LaTeX命令后跟字母才加空格'规则 join

回归验证:test_mixed.docx 130 个 OMML 公式,16 个发生变化且全部为
正确改善(线段名/角度/三角形/隐式乘法拼接),零误伤。
2026-10-15 01:01:10 +08:00

461 lines
18 KiB
Python
Raw 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.

"""
Math strategy for standard mathematical notation.
This strategy handles formatting for general mathematical equations,
including function recognition, operator mapping, and number formatting.
"""
from lxml import etree as ET
from typing import Set, Optional
from ..core.interfaces import Strategy
# Import from existing data module
from ..data import MATH_FUNCTIONS, UNICODE_TO_LATEX, UNICODE_PASSTHROUGH
from ..data.chemistry_data import (
ALL_CHEMICAL_ELEMENTS,
CHEMISTRY_VARIABLES,
)
from ..utils.chemistry_detection import detect_chemistry_equation, is_chemistry_variable
class MathStrategy(Strategy):
"""
Standard mathematical notation strategy.
This strategy provides formatting for general mathematical content:
- Function recognition (sin, cos, log → \\sin, \\cos, \\log)
- Operator symbol mapping (Unicode → LaTeX)
- Standard number formatting
- Variables remain italic (LaTeX default)
- Sophisticated identifier wrapping based on context
"""
# Mathematical functions that get special LaTeX commands
FUNCTIONS: Set[str] = MATH_FUNCTIONS
# Mathematical sets that should use \mathbf{} (bold upright) - ONLY when mathvariant is set
MATH_SETS = {'R', 'Z', 'N', 'Q', 'C', 'ℝ', 'ℤ', 'ℕ', 'ℚ', 'ℂ'}
# CRITICAL: In pure math mode, NO letters are always wrapped
# Wrapping only happens with explicit mathvariant="normal" attribute
# This set is kept for compatibility but should be empty for math mode
ALWAYS_WRAPPED_LETTERS = set() # Empty - no automatic wrapping in math mode
def __init__(self, wrap_identifiers_in_mathrm: bool = False):
"""
Initialize the math strategy.
Args:
wrap_identifiers_in_mathrm: If True, wrap multi-char identifiers in \\mathrm{}
Default is False to match original converter behavior
"""
self._equation_text: Optional[str] = None
self._chemical_elements = set() # Will be populated if needed
self._wrap_identifiers_in_mathrm = wrap_identifiers_in_mathrm
self._root_element: Optional[ET.Element] = None
self._is_chemistry_cached: Optional[bool] = None
def format_identifier(self, text: str, node: ET.Element) -> str:
"""
Format an identifier (<mi> element) for mathematics.
Args:
text: The identifier text content
node: The <mi> element node
Returns:
Formatted LaTeX string
Logic:
1. Mathematical functions → \\function (e.g., "sin" → "\\sin")
2. Single letters → check wrapping rules (most stay italic)
3. Multi-letter → check wrapping rules (depends on context)
Examples:
"sin" → "\\sin"
"cos" → "\\cos"
"x" → "x" (italic)
"R" → "R" (italic in math context)
"H" → "\\mathrm{H}" (always upright)
"velocity" → "velocity" (italic if math context)
"""
# CRITICAL: Strip whitespace before checking (matches original converter behavior)
# Original converter does: clean_text = text.strip()
clean_text = text.strip()
# Check if it's a mathematical function (case-insensitive)
# Original converter does: if clean_text.lower() in MATHEMATICAL_FUNCTIONS
if clean_text.lower() in self.FUNCTIONS:
# Special handling for logarithms with bases
if clean_text == "log2":
return "\\log_{2}"
elif clean_text == "log10":
return "\\log_{10}"
# Use lowercase version for function name
return f"\\{clean_text.lower()}"
# Check for function with subscript (e.g., "limn→∞")
for func in ['lim', 'limsup', 'liminf', 'sup', 'inf', 'max', 'min']:
if clean_text.startswith(func):
remaining = clean_text[len(func):]
if remaining:
# Convert special symbols in subscript
remaining = (remaining
.replace('→', '\\to ')
.replace('∞', '\\infty ')
.replace('≤', '\\leqslant ')
.replace('≥', '\\geqslant ')
.replace('≠', '\\ne ')
.replace('±', '\\pm '))
import re
remaining = re.sub(r'\s+', ' ', remaining).strip()
return f"\\{func}\\limits_{{{remaining}}}"
else:
return f"\\{func}"
# CRITICAL: Unicode math set symbols (ℝ, ℤ, etc.) are already handled by UNICODE_TO_LATEX
# These map to \mathbb{R}, \mathbb{Z}, etc. (not \mathbf{})
# This is correct because these Unicode symbols ARE blackboard bold characters
# So we don't need special handling here - just fall through to UNICODE_TO_LATEX check below
# UNICODE_PASSTHROUGH: Keep certain symbols as Unicode for MathJax compatibility
if clean_text in UNICODE_PASSTHROUGH:
return clean_text
# Check for Unicode symbols
if clean_text in UNICODE_TO_LATEX:
return UNICODE_TO_LATEX[clean_text]
# Handle multi-character strings with Unicode symbols
if len(clean_text) > 1:
has_unicode = any(char in UNICODE_TO_LATEX for char in clean_text)
has_passthrough = any(char in UNICODE_PASSTHROUGH for char in clean_text)
if has_unicode or has_passthrough:
converted_parts = []
for char in clean_text:
if char in UNICODE_PASSTHROUGH:
converted_parts.append(char)
elif char in UNICODE_TO_LATEX:
converted_parts.append(UNICODE_TO_LATEX[char])
elif char.isspace():
converted_parts.append(' ')
else:
converted_parts.append(char)
# Join with intelligent spacing: a LaTeX command followed by a
# letter/digit needs a space (otherwise "\triangleA" becomes an
# undefined command), but plain letters stay adjacent so that a
# multi-letter identifier like "△ABC" becomes "\triangle ABC",
# not "\triangle A B C".
result_parts = []
for idx, part in enumerate(converted_parts):
result_parts.append(part)
if idx < len(converted_parts) - 1:
nxt = converted_parts[idx + 1]
if part.startswith('\\') and nxt and (nxt[0].isalpha() or nxt[0].isdigit()):
result_parts.append(' ')
return ''.join(result_parts)
# USER REQUIREMENT: "use the mathvariant to determine whether it's mathrm"
# REMOVED: Chemistry detection heuristics (lines 139-162)
# REMOVED: should_wrap_in_mathrm() call (now always returns False)
#
# Wrapping in \mathrm{} is ONLY done when mathvariant="normal" attribute exists.
# That's handled in mi_visitor.py _handle_mathvariant() method.
# Default: return as-is (variables remain italic in LaTeX)
return clean_text
def format_operator(self, text: str, node: ET.Element) -> str:
"""
Format an operator (<mo> element) for mathematics.
Args:
text: The operator text content
node: The <mo> element node
Returns:
Formatted LaTeX string
Logic:
1. Delimiters → __DELIM__ markers for later processing
2. Comparison operators < and > → \\lt and \\gt (LaTeX convention)
3. Unicode symbols → LaTeX equivalents (via UNICODE_TO_LATEX)
4. Standard operators → unchanged (+, -, *, /, =)
Examples:
"(" → "__DELIM__(__DELIM__"
")" → "__DELIM__)__DELIM__"
"+" → "+"
"<" → "\\lt"
">" → "\\gt"
"×" → "\\times"
"≤" → "\\leqslant"
"∈" → "\\in"
"""
# CRITICAL: Mark delimiters for potential scaling
# These will be processed later by _process_delimiters()
# This matches the original converter's behavior (line 3558 in omml_to_tex.py)
# Include common paired delimiters: parentheses, brackets, braces, angle brackets, ceil/floor
# BUG-012 FIX: Added "|" to delimiters to allow proper absolute value detection
import sys
delimiter_chars = ("(", ")", "[", "]", "{", "}", "⟨", "⟩", "〈", "〉", "⌈", "⌉", "⌊", "⌋", "|")
if text in delimiter_chars:
# Convert Unicode delimiters to LaTeX BEFORE marking
# This ensures the delimiter pairing processor can find and pair them
delimiter_latex_map = {
"⟨": "\\langle",
"⟩": "\\rangle",
"〈": "\\langle", # CJK angle bracket
"〉": "\\rangle", # CJK angle bracket
"⌈": "\\lceil",
"⌉": "\\rceil",
"⌊": "\\lfloor",
"⌋": "\\rfloor",
"{": "\\{", # FIX: Escape braces so unpaired ones are valid LaTeX
"}": "\\}", # FIX: Escape braces so unpaired ones are valid LaTeX
}
converted_text = delimiter_latex_map.get(text, text)
# Check for stretchy attribute - if true, use a special marker to force scaling
stretchy = node.get('stretchy')
if stretchy == 'true':
result = f"__DELIM__STRETCHY::{converted_text}__DELIM__"
else:
result = f"__DELIM__{converted_text}__DELIM__"
# Debug: print(f"[DEBUG STRATEGY] Marking delimiter {text!r} → {result!r}", file=sys.stderr)
return result
# Handle < and > specifically to match expected output format (from original converter)
if text == "<":
return "\\lt"
if text == ">":
return "\\gt"
# BUG-012 FIX: Removed forced | -> \mid conversion
# Let DelimiterPairingProcessor decide whether it's \mid (operator) or \left|\right| (abs value)
# if text == "|":
# return "\\mid"
# UNICODE_PASSTHROUGH: Keep certain symbols as Unicode for MathJax compatibility
# These symbols have LaTeX commands that MathJax doesn't support or renders poorly.
# Check this BEFORE converting to LaTeX to preserve the original Unicode.
if text in UNICODE_PASSTHROUGH:
return text
# Check for Unicode symbol mapping
if text in UNICODE_TO_LATEX:
return UNICODE_TO_LATEX[text]
# Handle multi-character operators with Unicode
if len(text) > 1:
has_unicode = any(char in UNICODE_TO_LATEX for char in text)
has_passthrough = any(char in UNICODE_PASSTHROUGH for char in text)
if has_unicode or has_passthrough:
converted_parts = []
for char in text:
# Check passthrough first - keep these as Unicode
if char in UNICODE_PASSTHROUGH:
converted_parts.append(char)
elif char in UNICODE_TO_LATEX:
converted_parts.append(UNICODE_TO_LATEX[char])
else:
converted_parts.append(char)
return ' '.join(converted_parts)
# Default: return as-is
return text
def format_number(self, text: str, node: ET.Element) -> str:
"""
Format a number (<mn> element) for mathematics.
Args:
text: The number text content
node: The <mn> element node
Returns:
Formatted LaTeX string
Logic:
Numbers are returned as-is in mathematics mode.
Examples:
"123" → "123"
"3.14" → "3.14"
"1.5×10⁻³" → "1.5×10⁻³" (Unicode handled by operator conversion)
"""
# Numbers are typically returned as-is
# Unicode processing happens at operator level
return text
def set_equation_text(self, text: str) -> None:
"""
Set the full equation text for context detection.
Args:
text: The complete equation LaTeX text
"""
self._equation_text = text
def set_wrap_identifiers_flag(self, value: bool) -> None:
"""
Set whether to wrap identifiers in \\mathrm{}.
Args:
value: True to wrap multi-char identifiers, False to leave as-is
"""
self._wrap_identifiers_in_mathrm = value
def set_root_element(self, root: ET.Element) -> None:
"""
Set the root MathML element for context detection.
Args:
root: The root <math> element
"""
self._root_element = root
self._is_chemistry_cached = None # Clear cache
def is_chemistry_equation(self) -> bool:
"""
Detect if the current equation is chemistry-related.
DISABLED: Subject detection is obsolete. We rely on mathvariant="normal" only.
Returns:
False - always (chemistry detection disabled)
"""
# DISABLED: Always return False - no chemistry detection
# Chemistry formulas are handled via mathvariant="normal" in the MathML
return False
# OLD CODE (kept for reference):
# if self._is_chemistry_cached is not None:
# return self._is_chemistry_cached
# if self._root_element is None:
# return False
# self._is_chemistry_cached = detect_chemistry_equation(self._root_element)
# return self._is_chemistry_cached
def should_wrap_in_mathrm(self, content: str) -> bool:
"""
Determine if an identifier should be wrapped in \\mathrm{}.
CRITICAL USER REQUIREMENT: "use the mathvariant to determine whether it's mathrm"
This means NO HEURISTIC WRAPPING. Only wrap when there's an explicit
mathvariant="normal" attribute, which is handled in mi_visitor.py.
This method now serves as a safety check only - by default returns False.
All wrapping decisions are made based on explicit MathML attributes.
Args:
content: The identifier text
Returns:
False - no heuristic wrapping allowed
"""
# USER REQUIREMENT: Only mathvariant="normal" should trigger \mathrm wrapping
# That's handled in mi_visitor.py _handle_mathvariant() method
# This method should NOT apply any heuristics
return False
def _is_mathematical_variable(self, content: str) -> bool:
"""
Determine if multi-letter content should be treated as mathematical variables (not wrapped).
Args:
content: The identifier text
Returns:
True if should remain italic (mathematical variable), False otherwise
"""
# Pattern: all lowercase letters (like 'ab', 'xy', 'mn') - typical for mathematical variables
if content.islower() and content.isalpha():
# Check if we're in a mathematical context (inequalities, fractions, etc.)
if self._is_mathematical_context():
return True
# Pattern: mixed case with lowercase start (like 'ab', 'pq') - likely mathematical variables
if content[0].islower() and content.isalpha() and len(content) <= 3:
if self._is_mathematical_context():
return True
return False
def _is_mathematical_context(self) -> bool:
"""Check if we're in a mathematical context with inequalities, equations, fractions, etc."""
if not self._equation_text:
return False
mathematical_indicators = [
# Inequality operators
'≤', '≥', '≠', '<', '>', '\\leqslant', '\\geqslant', '\\leq', '\\geq', '\\ne', '\\neq',
# Mathematical operations and relations
'=', '+', '-', '×', '÷', '/', '\\cdot', '\\times', '\\div', '\\pm', '\\mp',
# Fraction indicators
'\\dfrac', '\\frac', '\\tfrac',
# Function operations
'\\sin', '\\cos', '\\tan', '\\log', '\\ln', '\\exp', '\\sqrt',
# Set operations (when not chemical)
'\\cap', '\\cup', '∩', '∪',
# Mathematical symbols
'∞', '\\infty', '∂', '\\partial', '∇', '\\nabla',
# Set membership
'\\in', '\\notin', '∈', '∉'
]
return any(indicator in self._equation_text for indicator in mathematical_indicators)
def _is_clear_chemical_context(self) -> bool:
"""Check if we're in a clear chemical context (more restrictive than set theory)."""
if not self._equation_text:
return False
chemical_indicators = ['\\xlongequal', 'Cu', 'Mn', 'Fe', 'SO', 'OH', '2+', '2-', '↑', '↓']
return any(indicator in self._equation_text for indicator in chemical_indicators)
def _has_mathematical_functions(self) -> bool:
"""Check if the current equation contains mathematical functions."""
# Common mathematical functions that indicate math context, not chemistry
math_functions = ['sin', 'cos', 'tan', 'log', 'ln', 'exp', 'sqrt', 'lim', 'max', 'min',
'arcsin', 'arccos', 'arctan', 'sinh', 'cosh', 'tanh']
# Check if we have stored equation text for context analysis
if self._equation_text:
return any(func in self._equation_text for func in math_functions)
return False
def detect_context(self, equation_text: str) -> bool:
"""
Detect if an equation is mathematical content.
Args:
equation_text: The full equation text
Returns:
True if likely mathematical, False otherwise
Note:
Math is the default context, so this returns True unless
other strategies provide stronger indicators.
"""
# Store equation text for context detection
self.set_equation_text(equation_text)
# Math is the default - return True
# In practice, this is overridden by more specific strategies
# (chemistry, physics, statistics) which check for their markers
return True
@property
def name(self) -> str:
"""Get the strategy name."""
return "math"
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MathStrategy(functions={len(self.FUNCTIONS)})"