Files
DoLuEdu-WordFileParse/scripts/omml_mathml2latex/compat/legacy_adapter.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

1087 lines
44 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.

"""
Legacy adapter for backward compatibility.
This module provides a drop-in replacement for the original MathMLToTeXConverter
that internally uses the refactored architecture.
Examples:
Legacy API usage:
>>> from omml2tex.compat import LegacyMathMLToTeXConverter
>>> converter = LegacyMathMLToTeXConverter(subject_mode='chemistry')
>>> latex = converter.convert('<math><mi>H</mi><mn>2</mn><mi>O</mi></math>')
>>> latex
'\\mathrm{H}_2\\mathrm{O}'
"""
from typing import Optional, List, Tuple
from lxml import etree as ET
from ..core.interfaces import Strategy
from ..utils.debug import debug_print
from ..strategies import (
MathStrategy,
ChemistryStrategy,
PhysicsStrategy,
StatisticsStrategy,
StrategyFactory,
)
from ..visitors import (
VisitorRegistry,
MiVisitor,
MoVisitor,
MnVisitor,
MfracVisitor,
MsupVisitor,
MsubVisitor,
MrowVisitor,
MsqrtVisitor,
MrootVisitor,
MsubsupVisitor,
MoverVisitor,
MunderVisitor,
MunderoverVisitor,
MfencedVisitor,
MtextVisitor,
MtableVisitor,
MtrVisitor,
MtdVisitor,
MspaceVisitor,
MstyleVisitor,
MerrorVisitor,
MencloseVisitor,
MmultiscriptsVisitor,
MpaddedVisitor,
MphantomVisitor,
MactionVisitor,
MsVisitor,
MglyphVisitor,
MlabeledtrVisitor,
MaligngroupVisitor,
MalignmarkVisitor,
)
class LegacyMathMLToTeXConverter:
"""
Backward-compatible adapter for the original MathMLToTeXConverter.
This class maintains the same API as the original converter but internally
uses the refactored visitor/strategy architecture.
Args:
wrap_identifiers_in_mathrm: Whether to wrap multi-char identifiers in \\mathrm{}
prefer_xlongequal: Whether to use \\xlongequal for reaction arrows
subject_mode: Subject-specific formatting mode (auto, math, physics, chemistry, statistics)
Examples:
Basic usage:
>>> converter = LegacyMathMLToTeXConverter()
>>> latex = converter.convert('<math><mi>sin</mi><mi>x</mi></math>')
>>> latex
'\\sin x'
Chemistry mode:
>>> converter = LegacyMathMLToTeXConverter(subject_mode='chemistry')
>>> latex = converter.convert('<math><mi>Fe</mi></math>')
>>> latex
'\\mathrm{Fe}'
"""
def __init__(
self,
wrap_identifiers_in_mathrm: bool = False, # CRITICAL FIX: Default should be False like original
prefer_xlongequal: bool = True,
subject_mode: str = "auto",
universal_delimiter_scaling: bool = True, # NEW: Add \left\right to ALL delimiters
) -> None:
"""Initialize the legacy converter with original API parameters."""
self.wrap_identifiers_in_mathrm = wrap_identifiers_in_mathrm
self.prefer_xlongequal = prefer_xlongequal
self.subject_mode = subject_mode
self.universal_delimiter_scaling = universal_delimiter_scaling
# Internal state (mimics original converter)
self._script_context_stack = []
self._symbol_contexts = {}
self._fraction_symbols = set()
self._set_theory_mode = False
self._equation_symbols = set()
self._equation_text = ""
self._uniform_equation_color = ""
self._suppress_leaf_colors = False
self._uniform_background_color = "" # Background/highlight color from OMML
# Initialize strategy based on subject mode
self._strategy = self._create_strategy()
# Initialize visitor registry with all visitors
self._visitor_registry = self._create_visitor_registry()
def _create_strategy(self) -> Strategy:
"""
Create conversion strategy based on subject mode.
Returns:
Strategy instance for the configured subject mode
"""
if self.subject_mode == "auto":
# Auto-detection will be done per equation in convert()
return MathStrategy(wrap_identifiers_in_mathrm=self.wrap_identifiers_in_mathrm) # Default fallback
elif self.subject_mode == "chemistry":
return ChemistryStrategy()
elif self.subject_mode == "physics":
return PhysicsStrategy()
elif self.subject_mode == "statistics":
return StatisticsStrategy()
elif self.subject_mode == "math":
return MathStrategy(wrap_identifiers_in_mathrm=self.wrap_identifiers_in_mathrm)
else:
# Unknown mode - default to math
return MathStrategy(wrap_identifiers_in_mathrm=self.wrap_identifiers_in_mathrm)
def _create_visitor_registry(self, context: 'ConversionContext' = None) -> VisitorRegistry:
"""
Create and populate visitor registry with all known visitors.
Args:
context: Optional conversion context to pass to all visitors
Returns:
Configured VisitorRegistry instance
"""
registry = VisitorRegistry(context)
# Register all MathML element visitors
# Namespace handling: strip namespace prefix if present
registry.register('mi', MiVisitor)
registry.register('mo', MoVisitor)
registry.register('mn', MnVisitor)
registry.register('mfrac', MfracVisitor)
registry.register('msup', MsupVisitor)
registry.register('msub', MsubVisitor)
registry.register('mrow', MrowVisitor)
registry.register('msqrt', MsqrtVisitor)
registry.register('mroot', MrootVisitor)
registry.register('msubsup', MsubsupVisitor)
registry.register('mover', MoverVisitor)
registry.register('munder', MunderVisitor)
registry.register('munderover', MunderoverVisitor)
registry.register('mfenced', MfencedVisitor)
registry.register('mtext', MtextVisitor)
registry.register('mtable', MtableVisitor)
registry.register('mtr', MtrVisitor)
registry.register('mtd', MtdVisitor)
registry.register('mspace', MspaceVisitor)
registry.register('mstyle', MstyleVisitor)
registry.register('merror', MerrorVisitor)
registry.register('menclose', MencloseVisitor)
registry.register('mmultiscripts', MmultiscriptsVisitor)
registry.register('mpadded', MpaddedVisitor)
registry.register('mphantom', MphantomVisitor)
registry.register('maction', MactionVisitor)
registry.register('ms', MsVisitor)
registry.register('mglyph', MglyphVisitor)
registry.register('mlabeledtr', MlabeledtrVisitor)
registry.register('maligngroup', MaligngroupVisitor)
registry.register('malignmark', MalignmarkVisitor)
# Use mrow as default visitor (handles container elements like <math>)
registry.set_default_visitor(MrowVisitor)
return registry
def convert(self, mathml_xml: str) -> str:
"""
Convert MathML XML to LaTeX.
Args:
mathml_xml: MathML XML string
Returns:
LaTeX string
Raises:
ValueError: If MathML XML is invalid
Examples:
>>> converter = LegacyMathMLToTeXConverter()
>>> converter.convert('<math><mi>x</mi></math>')
'x'
"""
# Store original equation text for context analysis
self._equation_text = mathml_xml
try:
# IMPORTANT: Preserve NBSP characters before XML parsing
# lxml normalizes U+00A0 (NBSP) to regular space during parsing
mathml_bytes = mathml_xml.encode("utf-8")
mathml_bytes = mathml_bytes.replace(b'\xc2\xa0', b'&#160;')
doc = ET.fromstring(mathml_bytes)
except ET.XMLSyntaxError as e:
raise ValueError(f"Invalid MathML XML: {e}") from e
# CRITICAL FIX: Word embeds "&" as column separators in equation arrays
# (e.g., <m:t>-x, &amp;x&lt;0</m:t> for piecewise functions)
# The XSLT converts this to a single <mtd> containing <mo>&</mo> operators
# We need to split at these points to create separate table columns
self._fix_eqarr_column_separators(doc)
# Reset context tracking for new equation
self._symbol_contexts = {}
self._fraction_symbols = set()
self._set_theory_mode = False
self._equation_symbols = set()
self._uniform_equation_color = ""
self._suppress_leaf_colors = False
self._uniform_background_color = ""
# Detect background color from data-background attribute (set by styling_postprocessor)
background_attr = doc.get('data-background')
if background_attr:
self._uniform_background_color = background_attr
# Analyze equation context to detect set theory mode
self._analyze_equation_context(doc)
# Create conversion context for this equation
from ..core.context import ConversionContext
context = ConversionContext(
input_type='mathml',
subject_mode=self.subject_mode,
wrap_identifiers=self.wrap_identifiers_in_mathrm,
prefer_xlongequal=True
)
# Update visitor registry with new context
self._visitor_registry.context = context
# Step 0: Detect uniform color across entire equation (optimization)
from ..utils.color_helpers import detect_uniform_color
uniform_color = detect_uniform_color(doc)
if uniform_color:
# Entire equation uses same color - suppress individual leaf colors
# and wrap entire result at the end
self._uniform_equation_color = uniform_color
self._suppress_leaf_colors = True
# Also update context
context.uniform_equation_color = uniform_color
context.suppress_leaf_colors = True
# DISABLED: Auto-detect strategy (user wants full control via mathvariant only)
# Auto-detection was overriding user's explicit mathvariant settings
# if self.subject_mode == "auto":
# self._strategy = StrategyFactory.auto_detect_strategy(mathml_xml)
# CRITICAL: Pass wrap_identifiers flag to strategy (especially for auto-detected strategies)
if hasattr(self._strategy, 'set_wrap_identifiers_flag'):
self._strategy.set_wrap_identifiers_flag(self.wrap_identifiers_in_mathrm)
# CRITICAL: Give strategy access to equation text for context detection
if hasattr(self._strategy, 'set_equation_text'):
self._strategy.set_equation_text(mathml_xml)
# CRITICAL: Give strategy access to root element for chemistry detection
if hasattr(self._strategy, 'set_root_element'):
self._strategy.set_root_element(doc)
# CRITICAL: Store converter context in strategy for color suppression
# This allows all visitors (including child visitors) to access the suppression flag
# since the strategy is shared across all visitors in the conversion tree
self._strategy._converter_context = self
# Handle <math> root element - process its children
# The <math> element is a container element, similar to <mrow>
if self._get_local_name(doc.tag) == 'math':
children = list(doc)
if not children:
# Empty <math> element
latex = ""
elif len(children) == 1:
# Single child - convert directly without adding spaces
latex = self._convert_node(children[0])
elif len(children) == 3 and self._is_prescript_pattern(children):
# Prescript pattern: <math><mrow>sub</mrow><mrow>sup</mrow><mrow>base</mrow></math>
# This comes from OMML <sPre> element which places scripts on LEFT side
# Result: _{sub}^{sup}base (no leading {} needed for MathJax)
left_sub = self._convert_node(children[0])
left_sup = self._convert_node(children[1])
base = self._convert_node(children[2])
latex = f"_{{{left_sub}}}^{{{left_sup}}}{base}"
else:
# Multiple children - process with intelligent spacing
# IMPORTANT: Check for fragmented functions BEFORE converting each child
# This handles cases like: <mi>c</mi><mi>o</mi><mi>s</mi> → \cos
parts = []
i = 0
while i < len(children):
if not isinstance(children[i].tag, str):
i += 1
continue
# Check for fragmented function starting at position i
function_name, consumed = self._check_fragmented_function_at_level(children, i)
if function_name:
# Found fragmented function! Add it as \funcname
parts.append(f"\\{function_name}")
i += consumed # Skip the consumed elements
elif self._is_plain_single_letter_mi(children[i]):
# Consecutive single-letter identifiers are joined without spaces:
# Word often stores a multi-letter identifier (line segment "BC")
# as a single <m:t> that OMML2MML splits into adjacent <mi> runs.
# Re-merge them so "BC" stays "BC" instead of "B C" (B×C).
merged = self._convert_node(children[i])
j = i + 1
while j < len(children) and self._is_plain_single_letter_mi(children[j]):
merged += self._convert_node(children[j])
j += 1
if merged.strip() not in ['', '\\']:
parts.append(merged)
i = j
else:
# Normal element - convert and add
part = self._convert_node(children[i])
# Skip LaTeX space commands (backslash-space) since we join with spaces anyway
# This avoids double-spacing: `\sin ` + space + `\ ` = `\sin \ `
if part and part.strip() not in ['', '\\']:
# Keep the part unless it's just whitespace or lone backslash
parts.append(part)
i += 1
# Determine separator based on content pattern
# Check if this looks like a chemical formula (no spaces)
# vs regular math expression (with spaces)
if self._is_chemical_formula_pattern(parts):
# Chemical formula: H2O → no spaces
latex = ''.join(parts)
else:
# Regular math: sin x → with spaces
latex = ' '.join(parts)
else:
# Non-math root (shouldn't happen, but handle it)
latex = self._convert_node(doc)
# Step 2.5: Wrap entire equation with uniform color if detected
# BUT: Skip wrapping if color is black (default color)
if self._uniform_equation_color:
from ..utils.color_helpers import wrap_with_uniform_color
latex = wrap_with_uniform_color(latex, self._uniform_equation_color)
# Reset suppression flag for next equation
self._suppress_leaf_colors = False
self._uniform_equation_color = ""
# Step 2.6: Wrap entire equation with background color if detected
# This uses \bbox[color,padding]{content} from amsmath
if self._uniform_background_color:
from ..utils.color_helpers import wrap_with_background_color
latex = wrap_with_background_color(latex, self._uniform_background_color)
# Reset for next equation
self._uniform_background_color = ""
# Step 3: Apply full 7-step post-processing pipeline
# Import chemical elements and units for post-processor
from ..data import CHEMICAL_ELEMENTS, COMMON_UNITS
from ..converters.post_processor import PostProcessor
post_processor = PostProcessor(
chemical_elements=CHEMICAL_ELEMENTS,
common_units=COMMON_UNITS,
subject_mode=self._strategy.name # Pass actual strategy name (after auto-detection)
)
latex = post_processor.process(
latex,
set_theory_mode=self._set_theory_mode,
subject_mode=self.subject_mode,
skip_delimiter_processing=self.universal_delimiter_scaling # Skip if using new processor
)
# Step 4: Apply universal delimiter scaling if enabled
# Phase 3 Fix: Use new delimiter_pairing_processor which respects __DELIM__ markers
if self.universal_delimiter_scaling:
from ..converters.delimiter_pairing_processor import add_universal_left_right
latex = add_universal_left_right(latex, debug=False)
# Step 4.1: Resolve brace markers (__LBRACE__/__RBRACE__)
# NEW: Context-aware brace marker resolution
# This converts source braces (from DOCX) to proper LaTeX delimiters
# while preserving generated braces in LaTeX commands (e.g., \mathrm{...})
from ..converters.brace_marker_resolver import BraceMarkerResolver
brace_resolver = BraceMarkerResolver(debug=False)
latex = brace_resolver.resolve(latex)
# Step 4.5: Fix double backslashes created by delimiter pairing
# The delimiter pairing processor can create \\left\{ when processing escaped braces
# This cleanup removes double backslashes before LaTeX commands, preserving only
# line break double backslashes (\\ followed by space)
# FIX BUG-007: Also match \\ before { or } (escaped braces)
import re
latex = re.sub(
r'\\\\(?=[a-zA-Z{])', # Match \\ followed by a letter or { (lookahead)
r'\\', # Replace with single backslash
latex
)
# Step 4.6: [DEPRECATED - Replaced by brace_marker_resolver]
# Old regex-based brace handling - now handled by BraceMarkerResolver
# The new marker-based system properly distinguishes source braces from
# generated braces and handles pairing with context awareness
# escaped_delimiter_pairs = [
# (r'(?<!\\left)\\\{', r'\\\}(?!\\right)', r'\\left\\{', r'\\right\\}'), # Braces
# ]
# for open_pattern, close_pattern, left_cmd, right_cmd in escaped_delimiter_pairs:
# pattern = f'{open_pattern}\\s*(.+?)\\s*{close_pattern}'
# replacement = f'{left_cmd} \\1 {right_cmd}'
# latex = re.sub(pattern, replacement, latex)
# Step 4.7: Fix mismatched vertical bar delimiters
# This handles cases like \mid 25,26| where plain text |...| gets confused
# The opening | becomes \mid (operator) but closing stays | (delimiter)
# Pattern: \mid content| → \left| content \right|
# IMPORTANT: Don't change \mid inside parentheses (conditional probability P(A \mid B))
# or inside set notation (\left\{ x \mid x>0 \right\})
# Strategy: Only fix if \mid is at start of equation (standalone absolute value)
if latex.startswith('\\mid ') and latex.endswith('|'):
# Standalone \mid...| expression → convert to \left|...\right|
latex = re.sub(
r'^\\mid\s+(.+?)\|$', # ^match start, content, end| (no space before |)
r'\\left| \1 \\right|',
latex
)
# Step 4.8: Universal delimiter scaling (Phase 3)
# Add \left\right to LaTeX delimiter commands that don't have them
# This catches remaining delimiters like \langle, \rangle, \|
# that weren't created with proper OMML delimiter structures
# Handle \langle...\rangle (angle brackets)
if r'\langle' in latex and r'\left\langle' not in latex:
latex = re.sub(
r'\\langle\s*(.+?)\s*\\rangle',
r'\\left\\langle \1 \\right\\rangle',
latex
)
# Handle \|...\| (double vertical bars for norm notation)
if r'\|' in latex and r'\left\|' not in latex:
latex = re.sub(
r'\\\|\s*(.+?)\s*\\\|',
r'\\left\\| \1 \\right\\|',
latex
)
# Clean up and return
# Replace all newlines with spaces (important for inline equations)
latex = latex.replace('\n', ' ')
return latex.strip()
def _post_process_consecutive_equals(self, latex: str) -> str:
"""
Post-process the entire LaTeX output to fix consecutive equals patterns.
This handles cases where equals signs are split across multiple mtext elements,
like '+3C=' followed by '\\xlongequal{\\quad}', which should become
'+3C \\xlongequal{\\quad}' (remove the extra =).
Also ensures all xlongequal instances have proper [\quad] brackets.
Args:
latex: Input LaTeX string
Returns:
LaTeX string with consecutive equals fixed
"""
import re
# Pattern to match: equals sign followed by \xlongequal
# This handles: "=" + space + "\xlongequal" or "=\xlongequal"
pattern = r'=\s*\\xlongequal\{([^}]*)\}'
# Replace with just the xlongequal (remove the leading =)
result = re.sub(pattern, r'\\xlongequal{\1}', latex)
# Safety net: Also catch raw consecutive equals in final output
# Pattern for 2+ consecutive equals signs (with optional spaces)
pattern2 = r'=\s*=\s*=+'
result = re.sub(pattern2, r'\\xlongequal[\\quad]{}', result)
# Fix xlongequal instances that don't have brackets (but preserve existing brackets)
# Only match \xlongequal{content} where there are NO brackets at all
pattern3 = r'\\xlongequal(?!\[)(\{[^}]*\})'
result = re.sub(pattern3, r'\\xlongequal[\\quad]\1', result)
# Fix xlongequal instances that have empty brackets
pattern4 = r'\\xlongequal\[\]\{([^}]*)\}'
result = re.sub(pattern4, r'\\xlongequal[\\quad]{\1}', result)
return result
def _process_delimiters(self, latex: str) -> str:
"""
Post-process LaTeX to add \\left and \\right to delimiters for proper scaling.
This method processes __DELIM__ markers added during conversion and determines
which delimiters need \left/\right commands based on their content.
Based on original converter lines 5274-5509.
"""
import re
# Find all marked delimiters
delim_pattern = r'__DELIM__([()[\]{}|])__DELIM__'
delimiters = []
for match in re.finditer(delim_pattern, latex):
delimiters.append({
'char': match.group(1),
'start': match.start(),
'end': match.end(),
'full_match': match.group(0)
})
if not delimiters:
return latex
# Dictionary of delimiter pairs
delimiter_pairs = {
'(': ')',
'[': ']',
'{': '}',
'|': '|'
}
# Define opening and closing delimiters
opening_delims = set(['(', '[', '{', '|'])
closing_delims = set([')', ']', '}', '|'])
# Stack to track opening delimiters
stack = []
pairs = []
# First pass: identify delimiter pairs
for delim in delimiters:
char = delim['char']
# Special handling for pipe - it pairs with itself
if char == '|':
pipe_in_stack = False
for i, stacked_delim in enumerate(stack):
if stacked_delim['char'] == '|':
opening_delim = stack.pop(i)
pairs.append((opening_delim, delim))
pipe_in_stack = True
break
if not pipe_in_stack:
stack.append(delim)
elif char in opening_delims:
stack.append(delim)
elif char in closing_delims:
if stack:
opening_delim = stack.pop()
pairs.append((opening_delim, delim))
# Build replacement list
result = latex
all_replacements = []
for opening, closing in pairs:
# Extract content between delimiters
start_pos = opening['end']
end_pos = closing['start']
content = latex[start_pos:end_pos]
# Check if content needs scaling
if opening['char'] == '|' or closing['char'] == '|':
needs_scaling = True
else:
needs_scaling = self._needs_delimiter_scaling(content)
if needs_scaling:
all_replacements.append((closing['start'], closing['end'], closing['char'], True))
all_replacements.append((opening['start'], opening['end'], opening['char'], True))
else:
all_replacements.append((closing['start'], closing['end'], closing['char'], False))
all_replacements.append((opening['start'], opening['end'], opening['char'], False))
# Sort by position (descending) to process from end to beginning
all_replacements.sort(key=lambda x: x[0], reverse=True)
# Apply replacements
for start, end, char, scale in all_replacements:
if scale:
if char in ['(', '[', '{']:
latex_char = '\\{' if char == '{' else char
replacement = f"\\left{latex_char}"
elif char == '|':
# Determine if opening or closing by checking position
is_opening = any(opening['start'] == start for opening, closing in pairs)
replacement = "\\left|" if is_opening else "\\right|"
else:
latex_char = '\\}' if char == '}' else char
replacement = f"\\right{latex_char}"
else:
replacement = char
result = result[:start] + replacement + result[end:]
# Cleanup: Remove any remaining __DELIM__ markers
result = re.sub(r'__DELIM__(.+?)__DELIM__', r'\1', result)
result = re.sub(r'__DELIM__', '', result)
return result
def _needs_delimiter_scaling(self, content: str) -> bool:
"""
Determine if content between delimiters needs scaling (\left and \right).
Based on original converter lines 6482-6507.
"""
scaling_indicators = [
'\\frac', '\\dfrac', '\\tfrac', # Fractions
'\\sum', '\\prod', '\\int', # Large operators
'\\sqrt', # Square roots
'\\binom', # Binomial coefficients
'\\overset', '\\underset', # Over/under sets
'\\limits', # Limits
'\\Big', '\\big', # Already scaled content
]
# Check for scaling indicators
if any(indicator in content for indicator in scaling_indicators):
return True
# Check for nested delimiters
nested_delims = content.count('(') + content.count('[') + content.count('{')
if nested_delims > 0:
return True
# Check for multiple script levels
script_levels = content.count('_') + content.count('^')
if script_levels > 1:
return True
# Long expressions often benefit from scaling
if len(content) > 20:
return True
return False
def _post_process_xlongequal_spacing(self, latex: str) -> str:
r"""
Remove extra spaces in xlongequal bracket and brace content.
Converts patterns like:
- \xlongequal[d e f g]{abc} → \xlongequal[defg]{abc}
- \xlongequal[a b c]{x y z} → \xlongequal[abc]{xyz}
Args:
latex: Input LaTeX string
Returns:
LaTeX string with spaces removed from xlongequal content
"""
import re
def remove_spaces_in_xlongequal(match):
bracket_content = match.group(1)
brace_content = match.group(2)
# Remove all whitespace
bracket_clean = re.sub(r'\s+', '', bracket_content)
brace_clean = re.sub(r'\s+', '', brace_content)
return f'\\xlongequal[{bracket_clean}]{{{brace_clean}}}'
# Match \xlongequal[...]{...} and remove spaces
result = re.sub(r'\\xlongequal\[([^\]]*)\]\{([^}]*)\}', remove_spaces_in_xlongequal, latex)
return result
def _check_fragmented_function_at_level(self, children: List, start_idx: int) -> Tuple[str, int]:
"""
Check if elements starting at start_idx form a fragmented function.
Detects patterns like:
- <mi>c</mi><mi>o</mi><mi>s</mi> → "cos", 3
- <mi>s</mi><mi>i</mi><mi>n</mi> → "sin", 3
- <mi>t</mi><mi>a</mi><mi>n</mi> → "tan", 3
- <mrow><mi>s</mi></mrow><mrow><mi>i</mi></mrow><mrow><mi>n</mi></mrow> → "sin", 3
Args:
children: List of child elements
start_idx: Index to start checking from
Returns:
Tuple of (function_name, elements_consumed) or ("", 0) if no function found
"""
from ..utils import text_content as _text_content
from ..data import MATH_FUNCTIONS
if start_idx >= len(children):
return "", 0
letters = []
elements_checked = 0
for i in range(start_idx, len(children)):
child = children[i]
local_tag = self._get_local_name(child.tag)
# Case 1: Direct <mi> element with single letter
if local_tag == "mi":
content = _text_content(child).strip()
# Must be exactly one letter
if len(content) == 1 and content.isalpha():
letters.append(content)
elements_checked += 1
else:
# Not a single letter, stop checking
break
# Case 2: <mrow> with single <mi> child (MathJax pattern)
elif (local_tag == "mrow" and
len(list(child)) == 1 and
self._get_local_name(list(child)[0].tag) == "mi"):
mi_element = list(child)[0]
content = _text_content(mi_element).strip()
if len(content) == 1 and content.isalpha():
letters.append(content)
elements_checked += 1
else:
# Not a single letter, stop checking
break
else:
# Not an mi or mrow-mi element, stop checking
break
# Need at least 2 letters to form a function
if len(letters) < 2:
return "", 0
# Try different combinations, starting from the longest possible
for length in range(min(len(letters), 6), 1, -1):
potential_function = ''.join(letters[:length])
if potential_function in MATH_FUNCTIONS:
return potential_function, length
return "", 0
def _is_prescript_pattern(self, children: list) -> bool:
"""
Detect if children match the prescript pattern from OMML <sPre> element.
Prescript pattern: exactly 3 consecutive <mrow> elements
- First <mrow>: left subscript
- Second <mrow>: left superscript
- Third <mrow>: base element
This pattern is generated by Microsoft's OMML→MathML transformation
for <sPre> (prescript) elements.
Args:
children: List of child elements
Returns:
True if this matches prescript pattern
"""
if len(children) != 3:
return False
# All 3 children must be <mrow> elements
return all(
isinstance(child.tag, str) and self._get_local_name(child.tag) == 'mrow'
for child in children
)
def _is_chemical_formula_pattern(self, parts: List[str]) -> bool:
"""
Detect if parts represent a chemical formula pattern.
Chemical formulas typically have:
- Element symbols wrapped in \\mathrm{}
- Numbers (stoichiometric coefficients)
- Superscripts for charges (ions)
Args:
parts: List of LaTeX parts from converted children
Returns:
True if pattern looks like a chemical formula
"""
if not parts:
return False
# Heuristic: If most parts are \\mathrm{...} wrapped or plain numbers,
# it's likely a chemical formula
mathrm_count = sum(1 for p in parts if p.startswith('\\mathrm{'))
number_count = sum(1 for p in parts if p.isdigit())
# If more than half are chemical elements or numbers, treat as chemical formula
chemical_indicators = mathrm_count + number_count
return chemical_indicators > len(parts) / 2
def _is_plain_single_letter_mi(self, node: ET.Element) -> bool:
"""Return True if node is a single plain-letter <mi> identifier.
Used to re-merge multi-letter identifiers (e.g. line segment "BC") that
Word/OMML2MML split into adjacent <mi> runs. Only plain italic identifiers
are merged; elements carrying an explicit mathvariant (e.g. normal for
chemical formulas) are left untouched.
"""
if not isinstance(getattr(node, 'tag', None), str):
return False
if self._get_local_name(node.tag) != 'mi':
return False
if node.get('mathvariant') is not None:
return False
from ..utils import text_content
content = text_content(node).strip()
return len(content) == 1 and content.isalpha()
def _get_local_name(self, tag: str) -> str:
"""
Extract local element name from tag (strip namespace).
Args:
tag: Element tag (may include namespace)
Returns:
Local element name without namespace
"""
if isinstance(tag, str) and '}' in tag:
return tag.split('}')[1]
return tag
def _convert_node(self, node: ET.Element) -> str:
"""
Convert a MathML node to LaTeX using visitor pattern.
Args:
node: MathML element to convert
Returns:
LaTeX string
"""
# Strip namespace from tag if present
# e.g., "{http://www.w3.org/1998/Math/MathML}mi" → "mi"
tag_name = self._get_local_name(node.tag)
# Get visitor for this element type
try:
visitor = self._visitor_registry.get_visitor(tag_name, self._strategy)
except ValueError as e:
# DEBUG: Log failed lookups for critical elements
if tag_name in ['mmultiscripts', 'munderover']:
debug_print(f"[DEBUG] Failed to get visitor for <{tag_name}>: {e}")
debug_print(f"[DEBUG] Registered visitors: {self._visitor_registry.get_registered_elements()}")
visitor = None
if visitor is not None:
# Pass converter instance to visitor for context access (color suppression, etc.)
# Visitors can check self._suppress_leaf_colors through the converter
if hasattr(visitor, 'set_converter_context'):
visitor.set_converter_context(self)
# Use visitor to convert (visitors handle their own recursion)
return visitor.visit(node)
else:
# Fallback: process children
# DEBUG: Log when falling back for critical elements
if tag_name in ['mmultiscripts', 'munderover']:
from ..utils import text_content
content = text_content(node)
debug_print(f"[DEBUG] Falling back for <{tag_name}> content={content[:60]}")
result = []
for child in node:
if isinstance(child.tag, str): # Skip comments, processing instructions
result.append(self._convert_node(child))
return ''.join(result)
def _has_set_brace_context(self, pipe_node: ET.Element) -> bool:
"""
Check if a pipe symbol appears in a set brace context like {x | conditions}.
This method walks up the tree to find if there's an opening brace before the pipe.
Args:
pipe_node: The <mo>|</mo> element to check
Returns:
True if pipe appears after an opening brace, False otherwise
"""
from ..utils import text_content as _text_content, local_name as _local
current = pipe_node
found_opening_brace = False
# Look for opening brace before the pipe
while current is not None:
parent = current.getparent()
if parent is None:
break
# Check all siblings before this node for opening brace
for sibling in parent:
if sibling == current:
break
if _local(sibling.tag) == "mo" and _text_content(sibling).strip() == "{":
found_opening_brace = True
break
if found_opening_brace:
break
current = parent
return found_opening_brace
def _fix_eqarr_column_separators(self, doc: ET.Element) -> None:
"""
Fix equation array column separators by splitting <mtd> cells at & operators.
Word embeds "&" as column separators in equation arrays. The XSLT converter
creates a single <mtd> containing <mo>&</mo> operators. This method splits
those cells into separate columns.
Args:
doc: The MathML document root element
"""
from ..utils import local_name as _local
# Find all mtable elements
MATHML_NS = "http://www.w3.org/1998/Math/MathML"
# Try both with and without namespace
mtables = doc.findall(f".//{{{MATHML_NS}}}mtable")
if not mtables:
# Try without namespace prefix
mtables = [el for el in doc.iter() if _local(el.tag) == 'mtable']
for mtable in mtables:
# Process each row
mtrs = mtable.findall(f"{{{MATHML_NS}}}mtr")
# Try without namespace if not found
if not mtrs:
mtrs = [el for el in mtable if _local(el.tag) == 'mtr']
for mtr in mtrs:
mtds = mtr.findall(f"{{{MATHML_NS}}}mtd")
if not mtds:
mtds = [el for el in mtr if _local(el.tag) == 'mtd']
# Check if any mtd contains & in text content
needs_splitting = False
for mtd in mtds:
# Look for "&" in ANY child element's text
for child in mtd:
if child.text and '&' in child.text:
needs_splitting = True
break
if needs_splitting:
break
if needs_splitting:
# Remove all existing mtd elements from the row
for mtd in list(mtds):
mtr.remove(mtd)
# For each original mtd, split by & in text content
for mtd in mtds:
children = list(mtd)
# Find the element containing &
split_index = -1
split_element = None
for i, child in enumerate(children):
if child.text and '&' in child.text:
split_index = i
split_element = child
break
if split_index >= 0:
# Split the text at &
text_before, text_after = split_element.text.split('&', 1)
# IMPORTANT: Strip trailing/leading spaces around the & separator
# LaTeX array environment handles spacing automatically, and Word's
# spaces before & are just for visual alignment in the editor
text_before = text_before.rstrip()
text_after = text_after.lstrip()
# Create first column: elements before split + text_before
# Skip trailing space-only elements before the split
col1 = ET.Element(f"{{{MATHML_NS}}}mtd")
for child in children[:split_index]:
# Skip space-only mo/mi/mtext elements before the &
if (child.text and
child.text.strip() == '' and
_local(child.tag) in ('mo', 'mi', 'mtext')):
continue # Skip this space element
col1.append(child)
if text_before:
# Create element for text_before
elem = ET.Element(split_element.tag)
elem.text = text_before
# Copy attributes
for key, val in split_element.attrib.items():
elem.set(key, val)
col1.append(elem)
mtr.append(col1)
# Create second column: text_after + elements after split
col2 = ET.Element(f"{{{MATHML_NS}}}mtd")
if text_after:
# Create element for text_after
elem = ET.Element(split_element.tag)
elem.text = text_after
# Copy attributes
for key, val in split_element.attrib.items():
elem.set(key, val)
col2.append(elem)
for child in children[split_index + 1:]:
col2.append(child)
mtr.append(col2)
else:
# No & found, keep as single cell
new_mtd = ET.Element(f"{{{MATHML_NS}}}mtd")
for child in children:
new_mtd.append(child)
mtr.append(new_mtd)
def _analyze_equation_context(self, node: ET.Element) -> None:
"""
Analyze the equation to detect set theory mode and other context.
This recursively walks the MathML tree to detect:
- Set theory operators (∈, ∉, ⊆, ⊇, etc.)
- Set builder notation patterns (pipe after opening brace)
Args:
node: MathML element to analyze
"""
from ..utils import text_content as _text_content, local_name as _local
local_name_str = _local(node.tag)
# Check for set theory symbols in operators
if local_name_str == "mo":
op_text = _text_content(node)
set_theory_ops = ['∈', '∉', '⊆', '⊇', '⊂', '⊃', '∪', '∩', '∖', '∁', '∅']
if op_text in set_theory_ops:
self._set_theory_mode = True
# Also detect set builder notation patterns: look for { ... | ... } or { ... \mid ... }
if op_text == '|' and self._has_set_brace_context(node):
self._set_theory_mode = True
# Recursively analyze children
for child in node:
if isinstance(child.tag, str): # Skip comments, processing instructions
self._analyze_equation_context(child)
def __repr__(self) -> str:
"""String representation for debugging."""
return (
f"LegacyMathMLToTeXConverter("
f"wrap_identifiers_in_mathrm={self.wrap_identifiers_in_mathrm}, "
f"prefer_xlongequal={self.prefer_xlongequal}, "
f"subject_mode={self.subject_mode!r})"
)