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 个发生变化且全部为 正确改善(线段名/角度/三角形/隐式乘法拼接),零误伤。
1087 lines
44 KiB
Python
1087 lines
44 KiB
Python
"""
|
||
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' ')
|
||
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, &x<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})"
|
||
)
|