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 个发生变化且全部为 正确改善(线段名/角度/三角形/隐式乘法拼接),零误伤。
1693 lines
69 KiB
Python
1693 lines
69 KiB
Python
"""
|
||
Visitor for <mrow> (row/container) MathML elements.
|
||
|
||
The <mrow> element is a container that groups mathematical expressions.
|
||
It's the most fundamental structural element in MathML, used to:
|
||
- Group operands and operators
|
||
- Delimit expressions with parentheses/brackets
|
||
- Contain complex expressions (numerators, exponents, etc.)
|
||
|
||
Basic behavior: Convert all children and join with spaces.
|
||
"""
|
||
|
||
from typing import Tuple, Optional
|
||
from lxml import etree as ET
|
||
from ..core.interfaces import Visitor, Strategy
|
||
from ..core.context import ConversionContext
|
||
from ..utils import text_content as _text_content, local_name as _local
|
||
|
||
|
||
class MrowVisitor(Visitor):
|
||
r"""
|
||
Handles <mrow> (row/container) MathML elements.
|
||
|
||
The mrow element is a container that groups mathematical content.
|
||
Basic behavior is to convert all children and join them with spaces.
|
||
|
||
In script contexts (subscripts/superscripts), multiple consecutive
|
||
identifiers/numbers are joined without spaces, since they usually form
|
||
a single composite index like \(x_{ij}\) or \(K_{a1}\).
|
||
|
||
Examples:
|
||
Simple grouping:
|
||
<mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow> → x + 1
|
||
|
||
In fractions:
|
||
<mfrac>
|
||
<mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow>
|
||
<mn>2</mn>
|
||
</mfrac>
|
||
→ \dfrac{x + 1}{2}
|
||
|
||
With parentheses:
|
||
<mrow>
|
||
<mo>(</mo>
|
||
<mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow>
|
||
<mo>)</mo>
|
||
</mrow>
|
||
→ ( x + 1 )
|
||
|
||
TODO (Future Enhancements):
|
||
- Fragmented function detection (sin split as s-i-n)
|
||
- Chemical formula pattern detection
|
||
- Absolute value notation (|x|)
|
||
- Mixed delimiter handling
|
||
- Delimiter-only rows (preserve spacing)
|
||
- Cross-mrow fragmented units
|
||
- Numeric mtext merging
|
||
"""
|
||
|
||
def __init__(self, strategy: Strategy, context: ConversionContext = None):
|
||
"""Initialize the Mrow visitor."""
|
||
super().__init__(strategy)
|
||
self.context = context
|
||
|
||
def visit(self, node: ET.Element) -> str:
|
||
"""
|
||
Convert <mrow> element to LaTeX.
|
||
|
||
Args:
|
||
node: The <mrow> element to convert
|
||
|
||
Returns:
|
||
LaTeX string with children joined by spaces
|
||
|
||
Raises:
|
||
ValueError: If node is not an <mrow> element
|
||
"""
|
||
if not self.can_handle(node):
|
||
raise ValueError(f"MrowVisitor cannot handle element '{node.tag}'")
|
||
|
||
children = list(node)
|
||
|
||
# Empty mrow returns empty string
|
||
if not children:
|
||
return ""
|
||
|
||
# Pattern 1: Fragmented function detection (sin split as s-i-n)
|
||
fragmented_function = self._is_fragmented_function_row(node)
|
||
if fragmented_function:
|
||
return self._handle_fragmented_function(node, fragmented_function)
|
||
|
||
# Pattern 2: Chemical formula pattern - DISABLED
|
||
# REASON: Always adding spaces is safer. Even H2O with mathvariant="normal"
|
||
# will render correctly as \mathrm{H} 2 \mathrm{O}. No need for heuristic merging.
|
||
# if self._detect_chemical_formula_pattern(node):
|
||
# return self._merge_chemical_formula(node)
|
||
|
||
# Pattern 2.5: Fragmented chemical elements with charge - DISABLED
|
||
# REASON: Same as Pattern 2. Spaces are safe; heuristics cause false positives.
|
||
# if self._detect_fragmented_element_with_charge(node):
|
||
# return self._merge_fragmented_element_with_charge(node)
|
||
|
||
# Pattern 3: Cross-mrow fragmented units
|
||
if self._has_cross_mrow_fragmented_units(node):
|
||
return self._merge_cross_mrow_fragmented_units(node)
|
||
|
||
# Pattern 4: Set notation {x | condition}
|
||
if self._is_set_notation_row(node):
|
||
return self._handle_set_notation_row(node)
|
||
|
||
# Pattern 5: Absolute value notation |x|
|
||
if self._is_absolute_value_row(node):
|
||
return self._handle_absolute_value_row(node)
|
||
|
||
# Pattern 6: Mixed delimiter row
|
||
if self._is_mixed_delimiter_row(node):
|
||
return self._handle_mixed_delimiter_row(node)
|
||
|
||
# Pattern 7: Delimiter-only row (preserve spacing)
|
||
if self._is_delimiter_only_row(node):
|
||
return self._convert_children_no_space(node)
|
||
|
||
# Pattern 8: Chemical formula row - DISABLED
|
||
# REASON: Subject detection is obsolete. We now rely solely on mathvariant="normal"
|
||
# to determine when to use \mathrm{}. Chemical formulas with mathvariant="normal"
|
||
# will be properly formatted. Always adding spaces is safer than heuristic removal.
|
||
# if self._is_chemical_formula_row(node):
|
||
# return self._convert_children_no_space(node)
|
||
|
||
# Pattern 9: Numeric mtext merging ("1" + "0 mol" → "10 mol")
|
||
if self._should_merge_numeric_mtext(node):
|
||
return self._merge_numeric_mtext_elements(node)
|
||
|
||
# Pattern 10: Fragmented units (m-o-l → mol)
|
||
if self._should_merge_fragmented_units(node):
|
||
return self._merge_fragmented_units(node)
|
||
|
||
# Check if this mrow has delimiters that need scaling
|
||
# This matches the original converter's behavior of adding \left and \right
|
||
# when parentheses contain fractions
|
||
delimiter_info = self._detect_delimiters(children)
|
||
if delimiter_info:
|
||
return self._handle_delimited_content(children, delimiter_info)
|
||
|
||
# Default: Convert all children and join with spaces
|
||
# IMPORTANT: Use _convert_children_with_spaces() which checks for
|
||
# fragmented functions at each position (not just at the start of mrow)
|
||
#
|
||
# EXCEPTION: Inside subscripts/superscripts, consecutive identifiers
|
||
# normally form a single composite index (e.g., x_{ij}, K_{a1}).
|
||
# Join them without spaces to preserve that semantic grouping.
|
||
if self.context and self.context.in_script_context():
|
||
result = self._convert_children_no_space(node)
|
||
else:
|
||
result = self._convert_children_with_spaces(node)
|
||
|
||
# Don't strip if result ends with LaTeX space command '\ '
|
||
# The trailing space in '\ ' is semantically significant
|
||
if result.endswith('\\ '):
|
||
return result
|
||
else:
|
||
return result.strip()
|
||
|
||
def _convert_child(self, child: ET.Element) -> str:
|
||
"""
|
||
Convert a child element to LaTeX.
|
||
|
||
This method instantiates the appropriate visitor for each child type.
|
||
"""
|
||
tag = _local(child.tag)
|
||
|
||
# Import visitors as needed to avoid circular imports
|
||
if tag == 'mi':
|
||
from .mi_visitor import MiVisitor
|
||
return MiVisitor(self.strategy).visit(child)
|
||
elif tag == 'mo':
|
||
from .mo_visitor import MoVisitor
|
||
return MoVisitor(self.strategy).visit(child)
|
||
elif tag == 'mn':
|
||
from .mn_visitor import MnVisitor
|
||
return MnVisitor(self.strategy).visit(child)
|
||
elif tag == 'mfrac':
|
||
from .mfrac_visitor import MfracVisitor
|
||
return MfracVisitor(self.strategy).visit(child)
|
||
elif tag == 'msup':
|
||
from .msup_visitor import MsupVisitor
|
||
return MsupVisitor(self.strategy).visit(child)
|
||
elif tag == 'msub':
|
||
from .msub_visitor import MsubVisitor
|
||
return MsubVisitor(self.strategy).visit(child)
|
||
elif tag == 'mrow':
|
||
# Recursive: mrow can contain other mrow elements
|
||
# Pass context so nested mrows also respect script context
|
||
return MrowVisitor(self.strategy, self.context).visit(child)
|
||
elif tag == 'msqrt':
|
||
from .msqrt_visitor import MsqrtVisitor
|
||
return MsqrtVisitor(self.strategy).visit(child)
|
||
elif tag == 'mover':
|
||
from .mover_visitor import MoverVisitor
|
||
return MoverVisitor(self.strategy).visit(child)
|
||
elif tag == 'munder':
|
||
from .munder_visitor import MunderVisitor
|
||
return MunderVisitor(self.strategy).visit(child)
|
||
elif tag == 'munderover':
|
||
from .munderover_visitor import MunderoverVisitor
|
||
return MunderoverVisitor(self.strategy).visit(child)
|
||
elif tag == 'msubsup':
|
||
from .msubsup_visitor import MsubsupVisitor
|
||
return MsubsupVisitor(self.strategy).visit(child)
|
||
elif tag == 'mroot':
|
||
from .mroot_visitor import MrootVisitor
|
||
return MrootVisitor(self.strategy).visit(child)
|
||
elif tag == 'mfenced':
|
||
from .mfenced_visitor import MfencedVisitor
|
||
return MfencedVisitor(self.strategy).visit(child)
|
||
elif tag == 'mtable':
|
||
from .mtable_visitor import MtableVisitor
|
||
return MtableVisitor(self.strategy).visit(child)
|
||
elif tag == 'mtext':
|
||
from .mtext_visitor import MtextVisitor
|
||
return MtextVisitor(self.strategy).visit(child)
|
||
elif tag == 'mstyle':
|
||
from .mstyle_visitor import MstyleVisitor
|
||
return MstyleVisitor(self.strategy).visit(child)
|
||
elif tag == 'mspace':
|
||
from .mspace_visitor import MspaceVisitor
|
||
return MspaceVisitor(self.strategy).visit(child)
|
||
elif tag == 'menclose':
|
||
from .menclose_visitor import MencloseVisitor
|
||
return MencloseVisitor(self.strategy).visit(child)
|
||
elif tag == 'mmultiscripts':
|
||
from .mmultiscripts_visitor import MmultiscriptsVisitor
|
||
return MmultiscriptsVisitor(self.strategy).visit(child)
|
||
elif tag == 'maction':
|
||
from .maction_visitor import MactionVisitor
|
||
return MactionVisitor(self.strategy).visit(child)
|
||
elif tag == 'merror':
|
||
from .merror_visitor import MerrorVisitor
|
||
return MerrorVisitor(self.strategy).visit(child)
|
||
elif tag == 'mpadded':
|
||
from .mpadded_visitor import MpaddedVisitor
|
||
return MpaddedVisitor(self.strategy).visit(child)
|
||
elif tag == 'mphantom':
|
||
from .mphantom_visitor import MphantomVisitor
|
||
return MphantomVisitor(self.strategy).visit(child)
|
||
elif tag == 'ms':
|
||
from .ms_visitor import MsVisitor
|
||
return MsVisitor(self.strategy).visit(child)
|
||
elif tag == 'mglyph':
|
||
from .mglyph_visitor import MglyphVisitor
|
||
return MglyphVisitor(self.strategy).visit(child)
|
||
elif tag == 'maligngroup':
|
||
from .maligngroup_visitor import MaligngroupVisitor
|
||
return MaligngroupVisitor(self.strategy).visit(child)
|
||
elif tag == 'malignmark':
|
||
from .malignmark_visitor import MalignmarkVisitor
|
||
return MalignmarkVisitor(self.strategy).visit(child)
|
||
elif tag == 'mlabeledtr':
|
||
from .mlabeledtr_visitor import MlabeledtrVisitor
|
||
return MlabeledtrVisitor(self.strategy).visit(child)
|
||
else:
|
||
# Fallback: return text content for unknown elements
|
||
# This should rarely happen now that most MathML elements are covered
|
||
import sys
|
||
print(f"[WARNING] Unknown element <{tag}> in mrow, using text fallback", file=sys.stderr)
|
||
return _text_content(child)
|
||
|
||
def _detect_delimiters(self, children: list) -> dict:
|
||
"""
|
||
Detect if this mrow contains delimiters (parentheses, brackets, etc.).
|
||
|
||
Returns dict with delimiter info if found, None otherwise.
|
||
Pattern: <mo>(</mo> ... content ... <mo>)</mo>
|
||
|
||
NOTE: Skips <mo fence="true"> elements to let them be handled by mo_visitor,
|
||
which will add __DELIM__ markers for the delimiter pairing processor.
|
||
"""
|
||
if len(children) < 3:
|
||
return None
|
||
|
||
# Check if first child is an opening delimiter
|
||
first_child = children[0]
|
||
if _local(first_child.tag) != 'mo':
|
||
return None
|
||
|
||
# CRITICAL FIX (Phase 2): Skip fence delimiters - let mo_visitor handle them
|
||
# This ensures XSLT-generated <mo fence="true"> elements get __DELIM__ markers
|
||
# and are processed by the universal delimiter pairing processor
|
||
if first_child.get('fence') == 'true':
|
||
return None
|
||
|
||
# CRITICAL FIX (Phase 3): Skip sticky delimiters - let mo_visitor handle them
|
||
# This supports <mo stretchy="true"> which should force scaling via PostProcessor
|
||
if first_child.get('stretchy') == 'true':
|
||
return None
|
||
|
||
first_text = _text_content(first_child).strip()
|
||
if first_text not in '([{':
|
||
return None
|
||
|
||
# Check if last child is a closing delimiter
|
||
last_child = children[-1]
|
||
if _local(last_child.tag) != 'mo':
|
||
return None
|
||
|
||
# Also check last child for fence attribute
|
||
if last_child.get('fence') == 'true':
|
||
return None
|
||
|
||
# Also check last child for stretchy attribute
|
||
if last_child.get('stretchy') == 'true':
|
||
return None
|
||
|
||
last_text = _text_content(last_child).strip()
|
||
if last_text not in ')]}':
|
||
return None
|
||
|
||
# Verify delimiter pairing
|
||
pairs = {'(': ')', '[': ']', '{': '}'}
|
||
if pairs.get(first_text) != last_text:
|
||
# Mismatched delimiters - treat as regular mrow
|
||
return None
|
||
|
||
return {
|
||
'open': first_text,
|
||
'close': last_text,
|
||
'content_children': children[1:-1]
|
||
}
|
||
|
||
def _handle_delimited_content(self, children: list, delimiter_info: dict) -> str:
|
||
r"""
|
||
Handle mrow with delimiters, adding \left and \right if needed.
|
||
|
||
This matches the original converter's behavior:
|
||
- If content contains fractions, use \left( and \right)
|
||
- Otherwise, use plain ( and )
|
||
"""
|
||
# Convert content between delimiters
|
||
content_parts = []
|
||
has_fraction = False
|
||
|
||
for child in delimiter_info['content_children']:
|
||
part = self._convert_child(child)
|
||
if part:
|
||
content_parts.append(part)
|
||
# Check if this child is a fraction
|
||
if _local(child.tag) == 'mfrac':
|
||
has_fraction = True
|
||
# Check if converted content contains fraction commands
|
||
if '\\frac' in part or '\\dfrac' in part or '\\tfrac' in part:
|
||
has_fraction = True
|
||
|
||
content = ' '.join(content_parts)
|
||
|
||
# Check if content needs delimiter scaling
|
||
needs_scaling = has_fraction or self._needs_delimiter_scaling(content)
|
||
|
||
# Build result with appropriate delimiters
|
||
open_delim = delimiter_info['open']
|
||
close_delim = delimiter_info['close']
|
||
|
||
if needs_scaling:
|
||
# Map delimiters for LaTeX
|
||
if open_delim == '{':
|
||
left = '\\left\\{'
|
||
else:
|
||
left = f'\\left{open_delim}'
|
||
|
||
if close_delim == '}':
|
||
right = '\\right\\}'
|
||
else:
|
||
right = f'\\right{close_delim}'
|
||
|
||
return f'{left} {content} {right}'
|
||
else:
|
||
# Plain delimiters (must escape braces)
|
||
left = '\\{' if open_delim == '{' else open_delim
|
||
right = '\\}' if close_delim == '}' else close_delim
|
||
return f'{left} {content} {right}'
|
||
|
||
def _needs_delimiter_scaling(self, content: str) -> bool:
|
||
r"""
|
||
Determine if content needs delimiter scaling (\left and \right).
|
||
|
||
Based on the original converter's logic at line 6482-6507.
|
||
Content needs scaling if it contains:
|
||
- Fractions (\frac, \dfrac, \tfrac)
|
||
- Large operators (\sum, \prod, \int)
|
||
- Square roots (\sqrt)
|
||
- Nested delimiters
|
||
- Multiple scripts
|
||
"""
|
||
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
|
||
]
|
||
|
||
# CRITICAL FIX: Check universal_delimiter_scaling flag from LegacyAdapter
|
||
# This allows forcing \left and \right on all delimiters regardless of content size
|
||
if hasattr(self.strategy, '_converter_context'):
|
||
converter = self.strategy._converter_context
|
||
if getattr(converter, 'universal_delimiter_scaling', False):
|
||
return True
|
||
|
||
# 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
|
||
|
||
# ===================================================================
|
||
# Pattern Detection Methods (10 patterns)
|
||
# ===================================================================
|
||
|
||
def _check_fragmented_function_at_position(
|
||
self, children: list, start_idx: int
|
||
) -> Tuple[str, int, Optional[ET.Element]]:
|
||
|
||
"""
|
||
Check if elements starting at start_idx form a fragmented function.
|
||
|
||
Returns:
|
||
(function_latex, consumed_count, last_sub_or_sup_element)
|
||
- function_latex: LaTeX for the function (e.g., "\\log_{2}") or empty string
|
||
- consumed_count: Number of children consumed
|
||
- last_sub_or_sup_element: The last msub/msup element if any, None otherwise
|
||
|
||
Examples:
|
||
<mi>s</mi><mi>i</mi><mi>n</mi> → ("\\sin", 3, None)
|
||
<mi>l</mi><mi>o</mi><msub><mi>g</mi><mn>2</mn></msub> → ("\\log_{2}", 3, msub_element)
|
||
"""
|
||
if start_idx >= len(children):
|
||
return ("", 0, None)
|
||
|
||
letters = []
|
||
consumed_count = 0
|
||
last_sub_or_sup = None
|
||
|
||
|
||
for i in range(start_idx, len(children)):
|
||
child = children[i]
|
||
tag = _local(child.tag)
|
||
|
||
# Case 1: Direct <mi> element
|
||
if tag == "mi":
|
||
content = _text_content(child).strip()
|
||
if len(content) == 1 and content.isalpha():
|
||
letters.append(content)
|
||
consumed_count += 1
|
||
else:
|
||
break # Stop at first non-single-letter mi
|
||
|
||
# Case 2: <mrow> containing a single <mi> element (MathJax pattern)
|
||
elif (tag == "mrow" and
|
||
len(list(child)) == 1 and
|
||
_local(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)
|
||
consumed_count += 1
|
||
else:
|
||
break
|
||
|
||
# Case 3: <msub> or <msup> where the base is a single-letter <mi>
|
||
# Also handles <msup><mrow><mi>s</mi></mrow><mrow><mn>2</mn></mrow></msup>
|
||
elif tag in ("msub", "msup"):
|
||
base_children = list(child)
|
||
if len(base_children) >= 1:
|
||
base = base_children[0]
|
||
base_tag = _local(base.tag)
|
||
|
||
if base_tag == "mi":
|
||
content = _text_content(base).strip()
|
||
if len(content) == 1 and content.isalpha():
|
||
letters.append(content)
|
||
consumed_count += 1
|
||
last_sub_or_sup = child
|
||
break # msup/msub should be the last element
|
||
else:
|
||
break
|
||
elif base_tag == "mrow":
|
||
# Handle both single <mi> and nested <mrow> structures
|
||
# Pattern 1: <mrow><mi>s</mi></mrow> (single mi)
|
||
# Pattern 2: <mrow><mrow><mi>s</mi></mrow></mrow> (nested mrow)
|
||
base_children_list = list(base)
|
||
if len(base_children_list) == 1:
|
||
inner_child = base_children_list[0]
|
||
inner_tag = _local(inner_child.tag)
|
||
|
||
if inner_tag == "mi":
|
||
# Pattern 1: direct mi
|
||
content = _text_content(inner_child).strip()
|
||
if len(content) == 1 and content.isalpha():
|
||
letters.append(content)
|
||
consumed_count += 1
|
||
last_sub_or_sup = child
|
||
break # msup/msub should be the last element in fragmented function
|
||
else:
|
||
break
|
||
elif inner_tag == "mrow" and len(list(inner_child)) == 1:
|
||
# Pattern 2: nested mrow with mi
|
||
deepest_mi = list(inner_child)[0]
|
||
if _local(deepest_mi.tag) == "mi":
|
||
content = _text_content(deepest_mi).strip()
|
||
if len(content) == 1 and content.isalpha():
|
||
letters.append(content)
|
||
consumed_count += 1
|
||
last_sub_or_sup = child
|
||
break # msup/msub should be the last element
|
||
else:
|
||
break
|
||
else:
|
||
break
|
||
else:
|
||
break
|
||
else:
|
||
break
|
||
else:
|
||
break
|
||
else:
|
||
break
|
||
else:
|
||
break # Stop at first non-matching element
|
||
|
||
|
||
if len(letters) < 2:
|
||
return ("", 0, None)
|
||
|
||
# Check if the sequence forms a known function
|
||
from ..data import MATH_FUNCTIONS
|
||
potential_function = ''.join(letters)
|
||
|
||
|
||
if potential_function not in MATH_FUNCTIONS:
|
||
return ("", 0, None)
|
||
|
||
# Build the function LaTeX
|
||
function_latex = f"\\{potential_function}"
|
||
|
||
# If the last consumed element was msub/msup, add the subscript/superscript
|
||
if last_sub_or_sup is not None:
|
||
tag = _local(last_sub_or_sup.tag)
|
||
children_of_sub = list(last_sub_or_sup)
|
||
|
||
if len(children_of_sub) >= 2:
|
||
# Get the subscript/superscript (second child)
|
||
script = children_of_sub[1]
|
||
script_latex = self._convert_child(script)
|
||
|
||
if tag == "msub":
|
||
function_latex += f"_{{{script_latex}}}"
|
||
elif tag == "msup":
|
||
function_latex += f"^{{{script_latex}}}"
|
||
|
||
return (function_latex, consumed_count, last_sub_or_sup)
|
||
|
||
def _is_fragmented_function_row(self, node: ET.Element) -> str:
|
||
"""
|
||
Check if this mrow contains a fragmented mathematical function.
|
||
|
||
Returns the function name if found, empty string otherwise.
|
||
|
||
Examples:
|
||
<mi>s</mi><mi>i</mi><mi>n</mi> → "sin"
|
||
<mi>c</mi><mi>o</mi><mi>s</mi> → "cos"
|
||
<mrow><mi>m</mi></mrow><mrow><mi>a</mi></mrow><mrow><mi>x</mi></mrow> → "max"
|
||
<mi>l</mi><mi>o</mi><msub><mi>g</mi><mn>2</mn></msub> → "log"
|
||
"""
|
||
children = list(node)
|
||
if len(children) < 2:
|
||
return ""
|
||
|
||
# Extract consecutive mi elements with single characters
|
||
# Also handle nested <mrow><mi>X</mi></mrow> patterns (MathJax style)
|
||
# AND handle <msub>/<msup> where the base is a single-letter <mi> (e.g., log_2)
|
||
letters = []
|
||
for child in children:
|
||
tag = _local(child.tag)
|
||
|
||
# Case 1: Direct <mi> element
|
||
if tag == "mi":
|
||
content = _text_content(child).strip()
|
||
if len(content) == 1 and content.isalpha():
|
||
letters.append(content)
|
||
else:
|
||
break # Stop at first non-single-letter mi
|
||
# Case 2: <mrow> containing a single <mi> element (MathJax pattern)
|
||
elif (tag == "mrow" and
|
||
len(list(child)) == 1 and
|
||
_local(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)
|
||
else:
|
||
break # Stop at first non-single-letter mi
|
||
# Case 3: <msub> or <msup> where the base is a single-letter <mi>
|
||
# This handles patterns like <mi>l</mi><mi>o</mi><msub><mi>g</mi><mn>2</mn></msub> → "log"
|
||
# Also handles <msup><mrow><mi>s</mi></mrow><mrow><mn>2</mn></mrow></msup>
|
||
elif tag in ("msub", "msup"):
|
||
# Get the base element (first child of msub/msup)
|
||
base_children = list(child)
|
||
if len(base_children) >= 1:
|
||
base = base_children[0]
|
||
# Check if base is <mi> or <mrow><mi>X</mi></mrow>
|
||
base_tag = _local(base.tag)
|
||
if base_tag == "mi":
|
||
content = _text_content(base).strip()
|
||
if len(content) == 1 and content.isalpha():
|
||
letters.append(content)
|
||
else:
|
||
break # Stop at first non-single-letter base
|
||
elif base_tag == "mrow":
|
||
# Handle both single <mi> and nested <mrow> structures
|
||
base_children_list = list(base)
|
||
if len(base_children_list) == 1:
|
||
inner_child = base_children_list[0]
|
||
inner_tag = _local(inner_child.tag)
|
||
|
||
if inner_tag == "mi":
|
||
# Pattern 1: <mrow><mi>s</mi></mrow>
|
||
content = _text_content(inner_child).strip()
|
||
if len(content) == 1 and content.isalpha():
|
||
letters.append(content)
|
||
else:
|
||
break
|
||
elif inner_tag == "mrow" and len(list(inner_child)) == 1:
|
||
# Pattern 2: <mrow><mrow><mi>s</mi></mrow></mrow>
|
||
deepest_mi = list(inner_child)[0]
|
||
if _local(deepest_mi.tag) == "mi":
|
||
content = _text_content(deepest_mi).strip()
|
||
if len(content) == 1 and content.isalpha():
|
||
letters.append(content)
|
||
else:
|
||
break
|
||
else:
|
||
break
|
||
else:
|
||
break
|
||
else:
|
||
break
|
||
else:
|
||
break # Base is not a simple <mi>
|
||
else:
|
||
break # msub/msup has no children
|
||
else:
|
||
break # Stop at first non-matching element
|
||
|
||
if len(letters) < 2:
|
||
return ""
|
||
|
||
# Check if the sequence forms a known function
|
||
from ..data import MATH_FUNCTIONS
|
||
potential_function = ''.join(letters)
|
||
if potential_function in MATH_FUNCTIONS:
|
||
return potential_function
|
||
|
||
return ""
|
||
|
||
def _handle_fragmented_function(self, node: ET.Element, function_name: str) -> str:
|
||
"""
|
||
Handle fragmented function by merging letters and processing remaining content.
|
||
|
||
Args:
|
||
node: The mrow element
|
||
function_name: The detected function name (e.g., "sin")
|
||
|
||
Returns:
|
||
LaTeX with function and remaining content
|
||
|
||
Examples:
|
||
<mi>s</mi><mi>i</mi><mi>n</mi><mi>x</mi> → "\\sin x"
|
||
<mi>l</mi><mi>o</mi><msub><mi>g</mi><mn>2</mn></msub><mi>x</mi> → "\\log_{2} x"
|
||
"""
|
||
children = list(node)
|
||
function_length = len(function_name)
|
||
|
||
# Track which children were consumed for the function
|
||
# This is trickier when the last letter is in an msub/msup
|
||
consumed_count = 0
|
||
letters_found = 0
|
||
last_was_sub_or_sup = False
|
||
last_sub_or_sup_element = None
|
||
|
||
for child in children:
|
||
if letters_found >= function_length:
|
||
break
|
||
|
||
tag = _local(child.tag)
|
||
|
||
# Count letters consumed (same logic as _is_fragmented_function_row)
|
||
if tag == "mi":
|
||
letters_found += 1
|
||
consumed_count += 1
|
||
elif (tag == "mrow" and
|
||
len(list(child)) == 1 and
|
||
_local(list(child)[0].tag) == "mi"):
|
||
letters_found += 1
|
||
consumed_count += 1
|
||
elif tag in ("msub", "msup"):
|
||
letters_found += 1
|
||
consumed_count += 1
|
||
last_was_sub_or_sup = True
|
||
last_sub_or_sup_element = child
|
||
else:
|
||
break
|
||
|
||
# Build the function LaTeX
|
||
function_latex = f"\\{function_name}"
|
||
|
||
# If the last consumed element was msub/msup, we need to add the subscript/superscript
|
||
if last_was_sub_or_sup and last_sub_or_sup_element is not None:
|
||
tag = _local(last_sub_or_sup_element.tag)
|
||
children_of_sub = list(last_sub_or_sup_element)
|
||
|
||
if len(children_of_sub) >= 2:
|
||
# Get the subscript/superscript (second child)
|
||
script = children_of_sub[1]
|
||
script_latex = self._convert_child(script)
|
||
|
||
if tag == "msub":
|
||
function_latex += f"_{{{script_latex}}}"
|
||
elif tag == "msup":
|
||
function_latex += f"^{{{script_latex}}}"
|
||
|
||
# Convert remaining elements after the function
|
||
remaining_parts = []
|
||
for child in children[consumed_count:]:
|
||
part = self._convert_child(child)
|
||
if part:
|
||
remaining_parts.append(part)
|
||
|
||
remaining_content = " ".join(remaining_parts).strip()
|
||
if remaining_content:
|
||
return f"{function_latex} {remaining_content}"
|
||
else:
|
||
return function_latex
|
||
|
||
def _detect_chemical_formula_pattern(self, node: ET.Element) -> bool:
|
||
"""
|
||
Detect if consecutive mi elements form a chemical formula like HCl, KClO3, etc.
|
||
|
||
Pattern: consecutive mi elements with mathvariant="normal"
|
||
that together form a chemical formula starting with known element.
|
||
"""
|
||
children = list(node)
|
||
if len(children) < 2:
|
||
return False
|
||
|
||
# Check for pattern: consecutive mi elements with mathvariant="normal"
|
||
consecutive_mi = []
|
||
for child in children:
|
||
if (_local(child.tag) == "mi" and
|
||
child.get("mathvariant") == "normal"):
|
||
text = _text_content(child)
|
||
if text and (text.isalpha() or text.isdigit()):
|
||
consecutive_mi.append(text)
|
||
else:
|
||
break
|
||
else:
|
||
break
|
||
|
||
# Check if combined text forms a chemical formula
|
||
if len(consecutive_mi) >= 2:
|
||
combined = "".join(consecutive_mi)
|
||
# Pattern: Starts with capital letter, contains mix of letters/numbers
|
||
import re
|
||
if re.match(r'^[A-Z][A-Za-z0-9]+$', combined):
|
||
# Additional check: starts with known element symbols
|
||
from ..data import CHEMICAL_ELEMENTS
|
||
for elem in CHEMICAL_ELEMENTS:
|
||
if combined.startswith(elem):
|
||
return True
|
||
return False
|
||
|
||
def _merge_chemical_formula(self, node: ET.Element) -> str:
|
||
"""
|
||
Merge consecutive mi elements that form a chemical formula.
|
||
|
||
Example: <mi>H</mi><mi>C</mi><mi>l</mi> → \\mathrm{HCl}
|
||
"""
|
||
children = list(node)
|
||
formula_parts = []
|
||
formula_consumed = 0
|
||
|
||
# Collect consecutive mi elements that form the formula
|
||
for child in children:
|
||
if (_local(child.tag) == "mi" and
|
||
child.get("mathvariant") == "normal"):
|
||
text = _text_content(child)
|
||
if text and (text.isalpha() or text.isdigit()):
|
||
formula_parts.append(text)
|
||
formula_consumed += 1
|
||
else:
|
||
break
|
||
else:
|
||
break
|
||
|
||
# Process remaining children after the formula
|
||
remaining_children = children[formula_consumed:]
|
||
|
||
# Combine formula parts and wrap in \mathrm{}
|
||
if formula_parts:
|
||
formula = "".join(formula_parts)
|
||
result = f"\\mathrm{{{formula}}}"
|
||
|
||
# Process remaining elements
|
||
if remaining_children:
|
||
remaining_parts = []
|
||
for child in remaining_children:
|
||
remaining_parts.append(self._convert_child(child))
|
||
remaining_content = " ".join([p for p in remaining_parts if p])
|
||
if remaining_content:
|
||
result += " " + remaining_content
|
||
|
||
return result
|
||
|
||
# Fallback to normal processing
|
||
return self._convert_children_with_spaces(node)
|
||
|
||
def _detect_fragmented_element_with_charge(self, node: ET.Element) -> bool:
|
||
"""
|
||
Detect fragmented chemical elements with charge indicators.
|
||
|
||
Pattern: <mi>M</mi> <mi>n</mi> <msup>...<charge></msup>
|
||
This handles cases like "M n^{2+}" which should be "\mathrm{Mn}^{2+}"
|
||
|
||
The key difference from _detect_chemical_formula_pattern is that this
|
||
allows interruptions by <msup> or <msub> elements when they contain charges.
|
||
|
||
Returns True if:
|
||
1. Found 2+ consecutive <mi> elements (may be interrupted by msup/msub)
|
||
2. Combined text forms known multi-char element (Mn, Cu, Fe, etc.)
|
||
3. Chemistry probability score meets threshold for current mode
|
||
"""
|
||
children = list(node)
|
||
if len(children) < 2:
|
||
return False
|
||
|
||
# Import here to avoid circular dependency
|
||
from ..data.chemistry_data import MULTI_CHAR_ELEMENTS
|
||
from ..utils.chemistry_detection import calculate_chemistry_probability
|
||
|
||
# Look for pattern: mi mi [msup/msub with charge]
|
||
i = 0
|
||
while i < len(children) - 1:
|
||
# Check if we have consecutive mi elements
|
||
if _local(children[i].tag) != "mi":
|
||
i += 1
|
||
continue
|
||
|
||
# Collect consecutive mi elements (allowing msup/msub interruptions)
|
||
mi_elements = []
|
||
mi_texts = []
|
||
j = i
|
||
|
||
while j < len(children):
|
||
child = children[j]
|
||
tag = _local(child.tag)
|
||
|
||
# Accept ANY <mi> element (not just mathvariant="normal")
|
||
# Chemistry probability scoring will decide whether to merge
|
||
if tag == "mi":
|
||
text = _text_content(child).strip()
|
||
# Only consider single letters (M, n) or 2-letter elements (Mn)
|
||
if text and len(text) <= 2 and text.isalpha():
|
||
mi_elements.append((j, text))
|
||
mi_texts.append(text)
|
||
j += 1
|
||
else:
|
||
break
|
||
elif tag in ("msup", "msub") and len(mi_elements) >= 1:
|
||
# Check if this interruption contains a charge indicator
|
||
if tag == "msup" and len(child) >= 2:
|
||
sup_node = child[1]
|
||
sup_text = _text_content(sup_node).strip()
|
||
|
||
# Check for charge pattern
|
||
has_charge = False
|
||
if sup_text and ('+' in sup_text or '-' in sup_text):
|
||
if len(sup_text) <= 3 and (sup_text.endswith('+') or sup_text.endswith('-')):
|
||
has_charge = True
|
||
# Format: "2 +" or "2 -"
|
||
parts = sup_text.split()
|
||
if len(parts) == 2 and parts[0].isdigit() and parts[1] in ('+', '-'):
|
||
has_charge = True
|
||
|
||
if has_charge:
|
||
# Found charge! Check if collected mi elements form known element
|
||
if len(mi_texts) >= 2:
|
||
combined = "".join(mi_texts)
|
||
if combined in MULTI_CHAR_ELEMENTS:
|
||
# Calculate chemistry probability to decide if we should merge
|
||
# Get the root <math> element for full context
|
||
math_root = node
|
||
while math_root is not None and _local(math_root.tag) != 'math':
|
||
math_root = math_root.getparent()
|
||
|
||
if math_root is not None:
|
||
subject_mode = self.strategy.subject_mode if hasattr(self.strategy, 'subject_mode') else None
|
||
chem_prob = calculate_chemistry_probability(math_root, subject_mode)
|
||
|
||
# Decision thresholds:
|
||
# - Chemistry mode: ≥0.8
|
||
# - Auto-mode: ≥1.5 (very strong indicators)
|
||
threshold = 0.8 if subject_mode == 'chemistry' else 1.5
|
||
if chem_prob >= threshold:
|
||
return True
|
||
break
|
||
else:
|
||
break
|
||
|
||
i += 1
|
||
|
||
return False
|
||
|
||
def _merge_fragmented_element_with_charge(self, node: ET.Element) -> str:
|
||
"""
|
||
Merge fragmented chemical elements that are separated with charge indicators.
|
||
|
||
Example: <mi>M</mi><mi>n</mi><msup>...<mn>2</mn><mo>+</mo></msup>
|
||
→ \mathrm{Mn}^{2 +}
|
||
"""
|
||
from ..data.chemistry_data import MULTI_CHAR_ELEMENTS
|
||
from ..utils.chemistry_detection import calculate_chemistry_probability
|
||
|
||
children = list(node)
|
||
result_parts = []
|
||
i = 0
|
||
|
||
while i < len(children):
|
||
# Try to find and merge fragmented element pattern
|
||
# Accept ANY <mi> element (not just mathvariant="normal")
|
||
if _local(children[i].tag) == "mi":
|
||
# Collect consecutive mi elements
|
||
mi_elements = []
|
||
mi_texts = []
|
||
j = i
|
||
|
||
while j < len(children):
|
||
child = children[j]
|
||
tag = _local(child.tag)
|
||
|
||
# Accept ANY <mi> element
|
||
if tag == "mi":
|
||
text = _text_content(child).strip()
|
||
# Only consider single letters or 2-letter elements
|
||
if text and len(text) <= 2 and text.isalpha():
|
||
mi_elements.append((j, child))
|
||
mi_texts.append(text)
|
||
j += 1
|
||
else:
|
||
break
|
||
elif tag == "msup" and len(mi_elements) >= 2:
|
||
# Check if this is a charge superscript
|
||
if len(child) >= 2:
|
||
sup_node = child[1]
|
||
sup_text = _text_content(sup_node).strip()
|
||
|
||
has_charge = False
|
||
if sup_text and ('+' in sup_text or '-' in sup_text):
|
||
if len(sup_text) <= 3 and (sup_text.endswith('+') or sup_text.endswith('-')):
|
||
has_charge = True
|
||
parts = sup_text.split()
|
||
if len(parts) == 2 and parts[0].isdigit() and parts[1] in ('+', '-'):
|
||
has_charge = True
|
||
|
||
if has_charge:
|
||
# Check if combined forms known element
|
||
combined = "".join(mi_texts)
|
||
if combined in MULTI_CHAR_ELEMENTS:
|
||
# Merge the mi elements
|
||
merged_latex = f"\\mathrm{{{combined}}}"
|
||
|
||
# Add the superscript
|
||
sup_latex = self._convert_child(child)
|
||
result_parts.append(merged_latex + sup_latex)
|
||
|
||
# Skip past all consumed elements
|
||
i = j + 1
|
||
break
|
||
|
||
# If we get here, didn't merge, fall through to normal processing
|
||
break
|
||
else:
|
||
break
|
||
|
||
# If we didn't merge anything, process normally
|
||
if j == i + len(mi_elements):
|
||
# Didn't find charge pattern, process first element normally
|
||
result_parts.append(self._convert_child(children[i]))
|
||
i += 1
|
||
else:
|
||
# Not an mi element, process normally
|
||
result_parts.append(self._convert_child(children[i]))
|
||
i += 1
|
||
|
||
return " ".join([p for p in result_parts if p])
|
||
|
||
def _has_cross_mrow_fragmented_units(self, node: ET.Element) -> bool:
|
||
"""
|
||
Check if this element has fragmented units across multiple mrow children.
|
||
|
||
Example: <mrow><mi>m</mi></mrow><mrow><mi>o</mi></mrow><mrow><mi>l</mi></mrow> → mol
|
||
"""
|
||
children = list(node)
|
||
if len(children) < 2:
|
||
return False
|
||
|
||
# Collect all letters from consecutive mrow elements
|
||
letters = []
|
||
for child in children:
|
||
if _local(child.tag) == "mrow":
|
||
mrow_children = list(child)
|
||
mrow_letters = []
|
||
for grandchild in mrow_children:
|
||
if (_local(grandchild.tag) == "mi" and
|
||
grandchild.get('mathvariant') == 'normal'):
|
||
text = _text_content(grandchild).strip()
|
||
if text and len(text) == 1 and text.isalpha():
|
||
mrow_letters.append(text)
|
||
else:
|
||
mrow_letters = []
|
||
break
|
||
elif _local(grandchild.tag) not in ['mrow']:
|
||
mrow_letters = []
|
||
break
|
||
|
||
if mrow_letters:
|
||
letters.extend(mrow_letters)
|
||
else:
|
||
break
|
||
|
||
# Check if collected letters form a unit
|
||
if len(letters) >= 2:
|
||
concatenated = "".join(letters)
|
||
from ..data import COMMON_UNITS
|
||
if concatenated in COMMON_UNITS:
|
||
return True
|
||
|
||
return False
|
||
|
||
def _merge_cross_mrow_fragmented_units(self, node: ET.Element) -> str:
|
||
"""
|
||
Merge fragmented units that are split across multiple mrow elements.
|
||
|
||
This is complex - needs to be imported from original converter.
|
||
For now, fallback to normal processing.
|
||
"""
|
||
# TODO: Implement full cross-mrow merging logic
|
||
# This is a complex method that requires careful handling
|
||
# For now, return normal processing to avoid breaking tests
|
||
return self._convert_children_with_spaces(node)
|
||
|
||
def _is_set_notation_row(self, node: ET.Element) -> bool:
|
||
r"""
|
||
Check if this mrow represents set notation {x | condition}.
|
||
|
||
Pattern: <mo>{</mo> ... <mo>|</mo> ... <mo>}</mo>
|
||
Also handles nested: <mo>{</mo> <mrow>... <mo>|</mo> ...</mrow> <mo>}</mo>
|
||
|
||
The vertical bar | should be converted to \mid (set separator),
|
||
not \left| \right| (absolute value).
|
||
|
||
Examples:
|
||
{x | x > 0} - set builder notation
|
||
{x | x ∈ R} - set membership
|
||
{(x,y) | x+y < 2} - set with condition
|
||
|
||
Returns:
|
||
True if this is set notation, False otherwise
|
||
"""
|
||
children = list(node)
|
||
if len(children) < 3: # Need at least {, content, }
|
||
return False
|
||
|
||
# Check if first child is { and last child is }
|
||
first_child = children[0]
|
||
last_child = children[-1]
|
||
|
||
if not (_local(first_child.tag) == "mo" and _text_content(first_child).strip() == "{" and
|
||
_local(last_child.tag) == "mo" and _text_content(last_child).strip() == "}"):
|
||
return False
|
||
|
||
# Check if there's a | somewhere in the middle (recursively search nested mrows)
|
||
# Support both <mo>|</mo> (correct) and <mn>|</mn> (malformed but common)
|
||
def has_pipe_recursive(elem):
|
||
"""Recursively search for | in element and its children"""
|
||
elem_tag = _local(elem.tag)
|
||
elem_text = _text_content(elem).strip()
|
||
|
||
# Check this element
|
||
if elem_tag in ("mo", "mn") and elem_text == "|":
|
||
return True
|
||
|
||
# Recursively check children (only for mrow, not for other containers)
|
||
if elem_tag == "mrow":
|
||
for child in elem:
|
||
if has_pipe_recursive(child):
|
||
return True
|
||
|
||
return False
|
||
|
||
# Search in children between { and }
|
||
for child in children[1:-1]: # Skip first { and last }
|
||
if has_pipe_recursive(child):
|
||
return True
|
||
|
||
return False
|
||
|
||
def _handle_set_notation_row(self, node: ET.Element) -> str:
|
||
r"""
|
||
Convert set notation to LaTeX with \mid separator.
|
||
|
||
Converts: {x | condition} → \left\{ x \mid condition \right\}
|
||
|
||
The key difference from absolute value:
|
||
- Set notation: | becomes \mid (relational operator)
|
||
- Absolute value: | becomes \left| \right| (delimiters)
|
||
|
||
Special case: {(x,y) | |x+y| < 2}
|
||
- First | after (x,y) → \mid (set separator)
|
||
- Paired || around x+y → \left| \right| (absolute value)
|
||
|
||
Args:
|
||
node: The mrow element containing set notation
|
||
|
||
Returns:
|
||
LaTeX string with proper set notation
|
||
"""
|
||
children = list(node)
|
||
|
||
# Track if we've converted the first pipe
|
||
self._first_pipe_converted = False
|
||
|
||
def convert_with_pipe_replacement(elem):
|
||
"""Recursively convert element, replacing first | with \mid"""
|
||
elem_tag = _local(elem.tag)
|
||
elem_text = _text_content(elem).strip()
|
||
|
||
# Check if this is a pipe that should be converted to \mid
|
||
if elem_tag in ("mo", "mn") and elem_text == "|":
|
||
if not self._first_pipe_converted:
|
||
# Convert first pipe to \mid
|
||
self._first_pipe_converted = True
|
||
return "\\mid"
|
||
else:
|
||
# Keep subsequent pipes - let normal conversion handle them
|
||
return self._convert_child(elem)
|
||
|
||
# For mrow elements, we need to handle them specially
|
||
if elem_tag == "mrow":
|
||
# CRITICAL FIX: After the set separator | has been converted to \mid,
|
||
# any nested mrow that looks like |x| should be treated as absolute value,
|
||
# not as more set separators.
|
||
# Example: {x | |y| > 0} → \left\{ x \mid \left|y\right| > 0 \right\}
|
||
if self._first_pipe_converted and self._is_absolute_value_row(elem):
|
||
# This nested mrow is an absolute value, handle it properly
|
||
return self._handle_absolute_value_row(elem)
|
||
|
||
# Otherwise, process mrow children and convert nested pipes
|
||
inner_parts = []
|
||
for child in elem:
|
||
part = convert_with_pipe_replacement(child)
|
||
if part:
|
||
inner_parts.append(part)
|
||
return " ".join(inner_parts).strip()
|
||
|
||
# For all other elements, use normal conversion
|
||
return self._convert_child(elem)
|
||
|
||
# Process all children between { and }
|
||
parts = []
|
||
for child in children[1:-1]: # Skip first { and last }
|
||
part = convert_with_pipe_replacement(child)
|
||
if part:
|
||
parts.append(part)
|
||
|
||
content = " ".join(parts).strip()
|
||
return f"\\left\\{{{content}\\right\\}}"
|
||
|
||
def _is_absolute_value_row(self, node: ET.Element) -> bool:
|
||
"""
|
||
Check if this mrow represents absolute value notation |...|.
|
||
|
||
Pattern: <mo>|</mo> ... content ... <mo>|</mo>
|
||
|
||
IMPORTANT: Only match if there are exactly 2 pipe operators.
|
||
For multiple absolute values like |AB||n|, there are 4 pipes,
|
||
which should NOT be treated as a single absolute value.
|
||
"""
|
||
children = list(node)
|
||
if len(children) < 3: # Need at least |, content, |
|
||
return False
|
||
|
||
# Check if first and last children are | operators
|
||
first_child = children[0]
|
||
last_child = children[-1]
|
||
|
||
if not (_local(first_child.tag) == "mo" and _text_content(first_child).strip() == "|" and
|
||
_local(last_child.tag) == "mo" and _text_content(last_child).strip() == "|"):
|
||
return False
|
||
|
||
# Count total | operators in the row
|
||
# Only match if there are exactly 2 (one opening, one closing)
|
||
pipe_count = 0
|
||
for child in children:
|
||
if _local(child.tag) == "mo" and _text_content(child).strip() == "|":
|
||
pipe_count += 1
|
||
|
||
# If more than 2 pipes, this is multiple absolute values, not a single one
|
||
# e.g., |AB||n| has 4 pipes and should NOT be treated as \left|...\right|
|
||
return pipe_count == 2
|
||
|
||
def _handle_absolute_value_row(self, node: ET.Element) -> str:
|
||
"""
|
||
Convert absolute value notation to \\left|...\\right| for dynamic scaling.
|
||
|
||
Example: <mo>|</mo><mi>x</mi><mo>|</mo> → \\left|x\\right|
|
||
"""
|
||
children = list(node)
|
||
|
||
# Convert the content between the first and last | operators
|
||
content_parts = []
|
||
for child in children[1:-1]: # Skip first and last |
|
||
part = self._convert_child(child)
|
||
if part:
|
||
content_parts.append(part)
|
||
|
||
content = " ".join(content_parts).strip()
|
||
return f"\\left|{content}\\right|"
|
||
|
||
def _is_mixed_delimiter_row(self, node: ET.Element) -> bool:
|
||
"""
|
||
Check if this mrow contains delimiters mixed with other content.
|
||
|
||
Returns True if row has both delimiters and non-delimiter content.
|
||
|
||
Note: This should NOT match the case where delimiters are at the
|
||
beginning and end as matching pairs (e.g., ( ... )), as that should
|
||
be handled by the proper delimiter detection logic later.
|
||
"""
|
||
children = list(node)
|
||
if len(children) <= 1:
|
||
return False
|
||
|
||
# Check if this is a properly delimited expression (first and last are matching delimiters)
|
||
# If so, return False to let the delimiter handling logic deal with it
|
||
delimiter_info = self._detect_delimiters(children)
|
||
if delimiter_info:
|
||
# This is a properly delimited expression, not a mixed delimiter row
|
||
return False
|
||
|
||
has_delimiters = False
|
||
has_other_content = False
|
||
delimiter_chars = set("()[]{}")
|
||
|
||
for child in children:
|
||
if _local(child.tag) == "mo":
|
||
content = _text_content(child).strip()
|
||
if content in delimiter_chars:
|
||
has_delimiters = True
|
||
else:
|
||
has_other_content = True
|
||
else:
|
||
has_other_content = True
|
||
|
||
return has_delimiters and has_other_content
|
||
|
||
def _handle_mixed_delimiter_row(self, node: ET.Element) -> str:
|
||
"""
|
||
Handle mrow with mixed delimiters and content.
|
||
|
||
For now, use normal processing with spaces.
|
||
"""
|
||
# TODO: Implement specialized mixed delimiter handling if needed
|
||
return self._convert_children_with_spaces(node)
|
||
|
||
def _is_delimiter_only_row(self, node: ET.Element) -> bool:
|
||
"""
|
||
Check if this mrow contains only delimiter operators.
|
||
|
||
Returns True if all children are mo elements with delimiter characters.
|
||
"""
|
||
children = list(node)
|
||
if len(children) == 0:
|
||
return False
|
||
|
||
delimiter_chars = set("()[]{}")
|
||
for child in children:
|
||
if _local(child.tag) == "mo":
|
||
content = _text_content(child).strip()
|
||
if content not in delimiter_chars:
|
||
return False
|
||
else:
|
||
return False
|
||
|
||
return True
|
||
|
||
def _is_chemical_formula_row(self, node: ET.Element) -> bool:
|
||
"""
|
||
Check if mrow contains chemical formula elements that should be joined without spaces.
|
||
|
||
Returns True if more than half the elements are chemical.
|
||
|
||
IMPORTANT: Subscripted elements (msub) with single-letter bases are treated as
|
||
geometry/math notation (e.g., F₁, F₂ for points), NOT chemical formulas.
|
||
Only count as chemical if they are part of a clear chemical formula pattern.
|
||
"""
|
||
children = list(node)
|
||
if len(children) < 2:
|
||
return False
|
||
|
||
from ..data import CHEMICAL_ELEMENTS
|
||
|
||
# CRITICAL: Check for mathematical binary operators that indicate this is NOT a chemical formula
|
||
# Binary operators like ⊙, ⊕, ⊗, ⊖, ⊘ clearly indicate mathematical context
|
||
# This set should include all operators from SKIP_SPACE_AFTER in mo_visitor.py
|
||
mathematical_binary_operators = {
|
||
# Relation operators
|
||
'→', '←', '⇒', '⇐', '↔', '⇔', '⟷',
|
||
'\\rightarrow', '\\leftarrow', '\\to', '\\Longrightarrow', '\\Longleftarrow',
|
||
'\\leftrightarrow', '\\Longleftrightarrow',
|
||
'=', '≈', '≅', '<', '>', '≤', '≥', '≠',
|
||
'\\equiv', '\\approx', '\\cong', '\\leq', '\\geq', '\\neq',
|
||
'⊆', '⊇', '∈', '∉', '⊥', '∥', '∼', '≃', '∝', '≺', '≻', '⪯', '⪰',
|
||
'\\subseteq', '\\supseteq', '\\in', '\\notin', '\\perp', '\\parallel',
|
||
'\\sim', '\\simeq', '\\propto', '\\prec', '\\succ', '\\preceq', '\\succeq',
|
||
'⊂', '⊃', '⊑', '⊒',
|
||
'\\subset', '\\supset', '\\sqsubseteq', '\\sqsupseteq',
|
||
|
||
# Binary operators
|
||
'+', '-', '×', '÷', '·', '±', '∓', '*', '/',
|
||
'\\times', '\\div', '\\cdot', '\\pm', '\\mp',
|
||
'∨', '∧', '¬', '∩', '∪', '⊓', '⊔', '⊎',
|
||
'\\lor', '\\land', '\\wedge', '\\vee', '\\neg', '\\cap', '\\cup',
|
||
'\\sqcap', '\\sqcup', '\\uplus',
|
||
'⊕', '⊗', '⊙', '⊖', '⊘', '⋏', '⋎',
|
||
'\\oplus', '\\otimes', '\\odot', '\\ominus', '\\oslash',
|
||
'\\curlywedge', '\\curlyvee',
|
||
'∖', '∗', '⋆', '∘', '•', '⋄', '△', '▽', '◃', '▹', '⊲', '⊳', '⊴', '⊵',
|
||
'\\setminus', '\\ast', '\\star', '\\circ', '\\bullet', '\\diamond',
|
||
'\\bigtriangleup', '\\bigtriangledown', '\\triangleleft', '\\triangleright',
|
||
'\\lhd', '\\rhd', '\\unlhd', '\\unrhd',
|
||
}
|
||
|
||
for child in children:
|
||
if _local(child.tag) == "mo":
|
||
content = _text_content(child).strip()
|
||
if content in mathematical_binary_operators:
|
||
# Found a mathematical binary operator - this is NOT a chemical formula
|
||
return False
|
||
|
||
chemical_indicators = 0
|
||
total_relevant = 0
|
||
|
||
# CRITICAL FIX: If ALL children are msub with single-letter bases,
|
||
# this is likely geometry notation (F₁, F₂, P₁, P₂), not chemistry.
|
||
# Don't treat it as a chemical formula.
|
||
all_are_single_letter_msub = True
|
||
for child in children:
|
||
child_name = _local(child.tag)
|
||
if child_name != "msub":
|
||
all_are_single_letter_msub = False
|
||
break
|
||
# Check if base is a single letter
|
||
if len(child) > 0:
|
||
base_elem = child[0]
|
||
# Handle both direct <mi> and wrapped <mrow><mi></mrow>
|
||
if _local(base_elem.tag) == "mi":
|
||
base_content = _text_content(base_elem).strip()
|
||
if len(base_content) != 1:
|
||
all_are_single_letter_msub = False
|
||
break
|
||
elif _local(base_elem.tag) == "mrow":
|
||
mrow_children = list(base_elem)
|
||
if (len(mrow_children) == 1 and
|
||
_local(mrow_children[0].tag) == "mi"):
|
||
base_content = _text_content(mrow_children[0]).strip()
|
||
if len(base_content) != 1:
|
||
all_are_single_letter_msub = False
|
||
break
|
||
else:
|
||
all_are_single_letter_msub = False
|
||
break
|
||
else:
|
||
all_are_single_letter_msub = False
|
||
break
|
||
|
||
# If all children are single-letter msub elements, NOT a chemical formula
|
||
if all_are_single_letter_msub:
|
||
return False
|
||
|
||
for child in children:
|
||
child_name = _local(child.tag)
|
||
|
||
if child_name == "mi":
|
||
total_relevant += 1
|
||
content = _text_content(child)
|
||
if content in CHEMICAL_ELEMENTS:
|
||
chemical_indicators += 1
|
||
|
||
elif child_name == "msub":
|
||
total_relevant += 1
|
||
# Check if base element is chemical
|
||
if len(child) > 0:
|
||
base_elem = child[0]
|
||
if _local(base_elem.tag) == "mi":
|
||
base_content = _text_content(base_elem)
|
||
if base_content in CHEMICAL_ELEMENTS:
|
||
chemical_indicators += 1
|
||
elif _local(base_elem.tag) == "mrow":
|
||
# Handle wrapped <mrow><mi>X</mi></mrow> pattern
|
||
mrow_children = list(base_elem)
|
||
if (len(mrow_children) == 1 and
|
||
_local(mrow_children[0].tag) == "mi"):
|
||
base_content = _text_content(mrow_children[0])
|
||
if base_content in CHEMICAL_ELEMENTS:
|
||
chemical_indicators += 1
|
||
|
||
# If more than half the elements are chemical, treat as chemical formula
|
||
return total_relevant > 0 and chemical_indicators / total_relevant > 0.5
|
||
|
||
def _should_merge_numeric_mtext(self, node: ET.Element) -> bool:
|
||
"""
|
||
Check if this mrow contains consecutive mtext elements that should be merged.
|
||
|
||
Pattern: mtext with single digit + mtext starting with digit and containing units
|
||
Example: "1" + "0 mol" → "10 mol"
|
||
"""
|
||
children = list(node)
|
||
if len(children) < 2:
|
||
return False
|
||
|
||
# Look for pattern: mtext with single digit + mtext starting with digit
|
||
for i in range(len(children) - 1):
|
||
current = children[i]
|
||
next_elem = children[i + 1]
|
||
|
||
if (_local(current.tag) == "mtext" and _local(next_elem.tag) == "mtext"):
|
||
current_text = _text_content(current).strip()
|
||
next_text = _text_content(next_elem).strip()
|
||
|
||
# Check if current is single digit and next starts with digit
|
||
if (current_text.isdigit() and len(current_text) == 1 and
|
||
next_text and next_text[0].isdigit()):
|
||
return True
|
||
|
||
return False
|
||
|
||
def _merge_numeric_mtext_elements(self, node: ET.Element) -> str:
|
||
"""
|
||
Merge consecutive numeric mtext elements intelligently.
|
||
|
||
Example: <mtext>1</mtext><mtext>0 mol</mtext> → 10 mol
|
||
"""
|
||
children = list(node)
|
||
result_parts = []
|
||
i = 0
|
||
|
||
while i < len(children):
|
||
current = children[i]
|
||
|
||
if _local(current.tag) == "mtext":
|
||
current_text = _text_content(current).strip()
|
||
|
||
# Check if we can merge with next element
|
||
if (i + 1 < len(children) and
|
||
_local(children[i + 1].tag) == "mtext" and
|
||
current_text.isdigit() and len(current_text) == 1):
|
||
|
||
next_elem = children[i + 1]
|
||
next_text = _text_content(next_elem).strip()
|
||
|
||
if next_text and next_text[0].isdigit():
|
||
# Merge: remove space between digits
|
||
merged_text = current_text + next_text
|
||
# Import mtext visitor to process merged content
|
||
from .mtext_visitor import MtextVisitor
|
||
visitor = MtextVisitor(self.strategy)
|
||
# Create a temporary element with merged text
|
||
temp_elem = ET.Element('mtext')
|
||
temp_elem.text = merged_text
|
||
processed = visitor.visit(temp_elem)
|
||
result_parts.append(processed)
|
||
i += 2 # Skip next element as it's been merged
|
||
continue
|
||
|
||
# No merge possible, process normally
|
||
from .mtext_visitor import MtextVisitor
|
||
visitor = MtextVisitor(self.strategy)
|
||
processed = visitor.visit(current)
|
||
result_parts.append(processed)
|
||
else:
|
||
# Non-mtext element, convert normally
|
||
converted = self._convert_child(current)
|
||
result_parts.append(converted)
|
||
|
||
i += 1
|
||
|
||
return " ".join([p for p in result_parts if p]).strip()
|
||
|
||
def _should_merge_fragmented_units(self, node: ET.Element) -> bool:
|
||
"""
|
||
Check if this mrow contains fragmented units (mi elements that spell out unit names).
|
||
|
||
Example: <mi>m</mi><mi>o</mi><mi>l</mi> → mol
|
||
"""
|
||
children = list(node)
|
||
if len(children) < 2:
|
||
return False
|
||
|
||
# Collect all mi elements with mathvariant="normal"
|
||
mi_elements = []
|
||
for child in children:
|
||
if _local(child.tag) == "mi":
|
||
mathvariant = child.get('mathvariant')
|
||
if mathvariant == 'normal':
|
||
text = _text_content(child).strip()
|
||
if text and len(text) == 1 and text.isalpha():
|
||
mi_elements.append(text)
|
||
|
||
# Check if concatenated text forms a unit
|
||
if len(mi_elements) >= 2:
|
||
concatenated = "".join(mi_elements)
|
||
from ..data import COMMON_UNITS
|
||
if concatenated in COMMON_UNITS:
|
||
return True
|
||
|
||
return False
|
||
|
||
def _merge_fragmented_units(self, node: ET.Element) -> str:
|
||
"""
|
||
Merge fragmented unit elements into proper unit formatting.
|
||
|
||
Example: <mi>m</mi><mi>o</mi><mi>l</mi> → \\mathrm{mol}
|
||
"""
|
||
children = list(node)
|
||
result_parts = []
|
||
i = 0
|
||
|
||
from ..data import COMMON_UNITS
|
||
|
||
while i < len(children):
|
||
current = children[i]
|
||
|
||
if _local(current.tag) == "mi":
|
||
mathvariant = current.get('mathvariant')
|
||
if mathvariant == 'normal':
|
||
# Start collecting consecutive mi elements
|
||
unit_letters = [_text_content(current).strip()]
|
||
j = i + 1
|
||
|
||
# Collect consecutive mi elements with mathvariant="normal"
|
||
while j < len(children):
|
||
next_elem = children[j]
|
||
if (_local(next_elem.tag) == "mi" and
|
||
next_elem.get('mathvariant') == 'normal'):
|
||
text = _text_content(next_elem).strip()
|
||
if text and len(text) == 1 and text.isalpha():
|
||
unit_letters.append(text)
|
||
j += 1
|
||
else:
|
||
break
|
||
else:
|
||
break
|
||
|
||
# Check if collected letters form a unit
|
||
if len(unit_letters) >= 2:
|
||
concatenated = "".join(unit_letters)
|
||
if concatenated in COMMON_UNITS:
|
||
# Format as unit with \mathrm{}
|
||
formatted_unit = f"\\mathrm{{{concatenated}}}"
|
||
result_parts.append(formatted_unit)
|
||
i = j # Skip all consumed elements
|
||
continue
|
||
|
||
# If not part of a unit, process normally
|
||
converted = self._convert_child(current)
|
||
result_parts.append(converted)
|
||
else:
|
||
# Non-mi element, convert normally
|
||
converted = self._convert_child(current)
|
||
result_parts.append(converted)
|
||
|
||
i += 1
|
||
|
||
return " ".join([p for p in result_parts if p]).strip()
|
||
|
||
# ===================================================================
|
||
# Helper Methods
|
||
# ===================================================================
|
||
|
||
def _convert_children_with_spaces(self, node: ET.Element) -> str:
|
||
"""
|
||
Convert all children and join with spaces.
|
||
|
||
IMPORTANT: Also checks for fragmented functions at each position.
|
||
This allows detection of fragmented functions in the middle of an mrow,
|
||
not just at the beginning.
|
||
"""
|
||
children = list(node)
|
||
parts = []
|
||
i = 0
|
||
|
||
while i < len(children):
|
||
# Check for fragmented function starting at position i
|
||
function_name, consumed_count, last_sub_or_sup = self._check_fragmented_function_at_position(
|
||
children, i
|
||
)
|
||
|
||
if function_name:
|
||
# Found a fragmented function - add it to parts
|
||
parts.append(function_name)
|
||
i += consumed_count
|
||
elif self._is_plain_single_letter_mi(children[i]):
|
||
# Consecutive single-letter identifiers are joined without spaces:
|
||
# Word often splits a multi-letter identifier (e.g. line segment "BC")
|
||
# into several runs, which OMML2MML turns into adjacent <mi> elements.
|
||
# Re-merge them so "BC" stays "BC" instead of "B C" (which renders as B×C).
|
||
merged = self._convert_child(children[i])
|
||
j = i + 1
|
||
while j < len(children) and self._is_plain_single_letter_mi(children[j]):
|
||
merged += self._convert_child(children[j])
|
||
j += 1
|
||
parts.append(merged)
|
||
i = j
|
||
else:
|
||
# No fragmented function - convert child normally
|
||
part = self._convert_child(children[i])
|
||
if part:
|
||
parts.append(part)
|
||
i += 1
|
||
|
||
return ' '.join(parts).strip()
|
||
|
||
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 split into adjacent <mi> runs. Only plain italic identifiers are
|
||
merged; elements carrying an explicit mathvariant (e.g. normal for
|
||
chemical formulas, double-struck, etc.) are left untouched.
|
||
"""
|
||
if _local(node.tag) != 'mi':
|
||
return False
|
||
if node.get('mathvariant') is not None:
|
||
return False
|
||
content = _text_content(node).strip()
|
||
return len(content) == 1 and content.isalpha()
|
||
|
||
def _convert_children_no_space(self, node: ET.Element) -> str:
|
||
"""Convert all children and join without spaces."""
|
||
parts = []
|
||
for child in list(node):
|
||
part = self._convert_child(child)
|
||
if part:
|
||
parts.append(part)
|
||
return ''.join(parts).strip()
|
||
|
||
def can_handle(self, node: ET.Element) -> bool:
|
||
"""Check if this visitor can handle the given node."""
|
||
return _local(node.tag) == 'mrow'
|
||
|
||
@property
|
||
def element_name(self) -> str:
|
||
"""Get the element name this visitor handles."""
|
||
return 'mrow'
|
||
|
||
def __repr__(self) -> str:
|
||
"""String representation for debugging."""
|
||
return f"MrowVisitor(strategy={self.strategy.name})"
|