Files
Quentin 93d070930a fix(omml2latex): fragmented-function 前缀匹配,修复 sinx 误输出
mrow_visitor 的 _check_fragmented_function_at_position 把所有连续单字母
拼成整串后只查整串是否在 MATH_FUNCTIONS,导致 <mi>s</mi><mi>i</mi><mi>n</mi>
<mi>x</mi> 输出 'sinx' 而非 '\sin x'。

改为前缀匹配(对齐 legacy_adapter._check_fragmented_function_at_level),
从最长到最短找已知函数名,只消耗函数名对应字母,其余留给调用方。

验证:sinx/cosx/tanx → \sin x/\cos x/\tan x;纯 sin/log、log_2(msub)、
BC 拼接、abc 非函数等场景零回归;test_mixed.docx 130 公式输出不变。
2026-10-15 01:27:13 +08:00

1707 lines
70 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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.
# Use prefix matching so that "sinx" resolves to "sin" (leaving the
# trailing "x" for the caller to convert), rather than requiring the
# whole run to be a function name. Mirrors the logic in
# legacy_adapter._check_fragmented_function_at_level.
from ..data import MATH_FUNCTIONS
potential_function = ""
matched_length = 0
for length in range(min(len(letters), 6), 1, -1):
candidate = ''.join(letters[:length])
if candidate in MATH_FUNCTIONS:
potential_function = candidate
matched_length = length
break
if not potential_function:
return ("", 0, None)
# Only the matched letters belong to the function; any trailing letters
# (e.g. the "x" in "sinx") remain for the caller to convert.
consumed_count = matched_length
# 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})"