Files
DoLuEdu-WordFileParse/scripts/omml_mathml2latex/visitors/mtable_visitor.py
Quentin 3271fee5cc init: WordFileParse docx→HTML 解析服务(含 Word EQ 域公式修复)
服务:Spring Boot 3.2 + docx4j 8.3.9,POST /api/v3/server_office_file/file/parse
      接受 .docx,返回 HTML(公式 → LaTeX,图片 → RustFS 外链)

公式支持三类来源:
- OMML(Word 2007+ 公式)      → Python omml_mathml2latex
- MathType OLE 对象            → Ruby mathtype2mathml → MathML → LaTeX
- Word EQ 域(老教辅主力)      → EqToLatexUtil(本次新增)

本次改动:
1. 新增 java-svc/.../core/EqToLatexUtil.java:
   移植自 jby backend-services office-core,EQ 语法 → LaTeX
   (\f→\dfrac、\r→\sqrt、\i→\int/\sum/\prod、\s→^{}/_{}、\b→\left\right、
     \a→array、\x→\boxed/\overline、\o\al 堆叠、圈码、化学符号、极限、连等式)
   品牌标记 JBYU* → WPU*,与采集端一致
2. CtoMathFinder:接上 EQ 分支(原为 not-ported 桩),域代码经
   EqToLatexUtil.convert 转 LaTeX 写入 eqLatexMap(WPEQNUM 占位符)
3. WordParseUtil.dealPlaceholder:EQ 的 LaTeX 同样加 \( \) 包裹
   (原先因 WPEQNUM 例外不加包裹,前端/MathJax 不渲染)

修前症状:教辅 Word 里分式/方程组/上下标整段丢失(如 {−1/3, 1} 变成 {-,1})
修后实测:46 份教辅 2908 处 EQ 域全部还原,公式总数 2954 处、零未包裹残留

安全:application.yml(含 RustFS 密钥)不入库,提供 application.yml.example;
      OssService 的 @Value 默认密钥已清空
2026-09-29 09:43:13 +08:00

1004 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

r"""
Visitor for <mtable> (table/matrix) MathML elements.
The <mtable> element represents tables and matrices in mathematical expressions:
- Matrix environments: pmatrix, bmatrix, vmatrix, Vmatrix
- Generic arrays with column specifications
- Cases environment (piecewise functions)
- Binomial coefficients
- Chemical reaction conditions
This visitor coordinates with MtrVisitor and MtdVisitor to handle table structure.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MtableVisitor(Visitor):
r"""
Handles <mtable> (table/matrix) MathML elements.
Converts tables to appropriate LaTeX environments based on context and attributes.
Examples:
Matrix (parentheses):
<mfenced open="(" close=")">
<mtable>
<mtr><mtd><mn>1</mn></mtd><mtd><mn>2</mn></mtd></mtr>
<mtr><mtd><mn>3</mn></mtd><mtd><mn>4</mn></mtd></mtr>
</mtable>
</mfenced>
→ \begin{pmatrix}1 & 2 \\ 3 & 4\end{pmatrix}
Matrix (brackets):
<mfenced open="[" close="]">
<mtable>...</mtable>
</mfenced>
→ \begin{bmatrix}...\end{bmatrix}
Matrix (vertical bars):
<mfenced open="|" close="|">
<mtable>...</mtable>
</mfenced>
→ \begin{vmatrix}...\end{vmatrix}
Piecewise functions (legacy data-cases attribute, now ignored):
<mfenced open="{" close="">
<mtable>
<mtr><mtd><mi>x</mi></mtd></mtr>
<mtr><mtd><mi>y</mi></mtd></mtr>
</mtable>
</mfenced>
→ \left\{\begin{array}{l}x \\ y\end{array}\right.
Generic array:
<mtable>
<mtr><mtd><mi>a</mi></mtd><mtd><mi>b</mi></mtd></mtr>
</mtable>
→ \begin{array}{cc}a & b \\\end{array}
"""
def __init__(self, strategy: Strategy):
"""Initialize the Mtable visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <mtable> element to LaTeX.
Args:
node: The <mtable> element to convert
Returns:
LaTeX string with appropriate matrix/table environment
Raises:
ValueError: If node is not an <mtable> element
"""
if not self.can_handle(node):
raise ValueError(f"MtableVisitor cannot handle element '{node.tag}'")
# Check for special table types
# 1. Cases-like structures (piecewise functions)
# NOTE: We NO LONGER use cases environment for better compatibility
# Instead, these are wrapped by parent mfenced with \left\{ and \right.
# If we see data-cases="true", we should ignore it and render as a regular array
# (the wrapping delimiters will be added by the parent mfenced element)
# 2. Binomial coefficients
if node.get("data-binomial") == "true":
return self._create_binomial(node)
# 3. Chemical reaction conditions (double underline pattern)
# CRITICAL: Check BEFORE rendering as array
# Pattern: 2-row mtable with nested munder elements
if self._is_chemical_reaction_condition(node):
upper_text = self._extract_reaction_condition_text(node)
lower_text = self._extract_reaction_condition_lower_text(node)
if not lower_text:
lower_text = "\\quad"
return f"\\xlongequal[{lower_text}]{{{upper_text}}}"
# 4. Large operator with limits (vertical stack pattern)
# Pattern: 3-row mtable with large operator in middle row
# Example: n / ∑ / i=1 → \sum^{n}_{i=1}
if self._is_large_operator_stack(node):
return self._convert_large_operator_stack(node)
# Get table attributes
columnalign = node.get("columnalign", "center")
align = node.get("align", "") # Vertical alignment (top, bottom, center, axis, baseline)
frame = node.get("frame", "none")
# Determine environment type based on parent mfenced delimiters
env = self._determine_environment(node, frame)
# Get all rows
rows = self._convert_rows(node)
if not rows:
return f"\\begin{{{env}}}\\end{{{env}}}"
# Remove trailing \\ from last row (not needed in LaTeX)
if rows and rows[-1].endswith(" \\\\"):
rows[-1] = rows[-1][:-3] # Remove " \\"
# Determine strict alignment requirements
# If the environment is a matrix type (pmatrix, etc.) but we have specific alignment
# (e.g., left aligned columns), we must switch to 'array' with manual delimiters
# because pmatrix/bmatrix etc. do not support column specs (always centered).
explicit_alignment = columnalign and columnalign != "center" and columnalign != "mean"
# Matrix to delimiter mapping
matrix_delims = {
"pmatrix": ("(", ")"),
"bmatrix": ("[", "]"),
"vmatrix": ("|", "|"),
"Vmatrix": (r"\|", r"\|"),
}
force_array_with_delims = False
open_delim, close_delim = "", ""
if env in matrix_delims and explicit_alignment:
# Switch to array but wrap in delimiters
force_array_with_delims = True
open_delim, close_delim = matrix_delims[env]
env = "array"
# Add column specification for array environment
if env == "array":
col_count = rows[0].count('&') + 1 if rows else 1
col_spec = self._get_column_spec(columnalign, col_count)
# Was: Forced left for cases-style.
# Removed to respect source alignment (e.g. Center) even for single-brace tables.
# Helper for position argument (vertical alignment)
# MathXML align: top, bottom, center, axis, baseline
# LaTeX array: t (top), b (bottom), c (center/default)
pos_arg = ""
if align == "top":
pos_arg = "[t]"
elif align == "bottom":
pos_arg = "[b]"
# center/axis/baseline -> default (center)
# Format as: \begin{array}[pos]{cc}...\end{array}
rows_str = '\n'.join(rows)
array_latex = f"\\begin{{{env}}}{pos_arg}{{{col_spec}}}\n{rows_str}\n\\end{{{env}}}"
if force_array_with_delims:
# Wrap in \left...\right
# Handle special case for braces/bars in LaTeX if needed (but map uses raw chars or commands)
# open_delim is raw char usually.
# If it's { or } it needs escaping, but Vmatrix uses \|.
# However, \left( is valid. \left\| is valid.
# Only need to be careful if delim is complex.
# Check if we need to escape { or } for built-in text
# But here we are generating LaTeX code.
left_cmd = f"\\left{open_delim}"
right_cmd = f"\\right{close_delim}"
# Specific fix for brace which might be passed as {
if open_delim == "{": left_cmd = r"\left\{"
if close_delim == "}": right_cmd = r"\right\}"
return f"{left_cmd}{array_latex}{right_cmd}"
else:
return array_latex
else:
# Format as: \begin{pmatrix}...\end{pmatrix}
# Note: Standard matrix envs don't support vertical alignment arg easily
rows_str = '\n'.join(rows)
return f"\\begin{{{env}}}\n{rows_str}\n\\end{{{env}}}"
def _is_cases_style(self, node: ET.Element) -> bool:
"""
Check if mtable is inside a cases-style structure (left brace only).
Cases-style includes:
- Piecewise functions: f(x) = { case1 \\ case2 }
- Equation systems: { eq1 \\ eq2 }
- Inequality systems
Per company convention, these should use left alignment ('ll').
Args:
node: The <mtable> element
Returns:
True if this is a cases-style table
"""
from ..utils import text_content as _text_content
parent = node.getparent()
# Pattern 1: Direct mfenced parent
if parent is not None and _local(parent.tag) == "mfenced":
open_delim = parent.get("open", "(")
close_delim = parent.get("close", ")")
# Cases-style: left brace with empty or "." closing
if open_delim == "{" and close_delim in ("", "."):
return True
# Pattern 2: mfenced -> mrow -> mtable
if parent is not None and _local(parent.tag) == "mrow":
grandparent = parent.getparent()
if grandparent is not None and _local(grandparent.tag) == "mfenced":
open_delim = grandparent.get("open", "(")
close_delim = grandparent.get("close", ")")
if open_delim == "{" and close_delim in ("", "."):
return True
# Pattern 3: <mo fence="true">{</mo> sibling pattern (MathML without mfenced)
# Structure: <mrow><mo fence="true">{</mo><mrow><mtable>...</mtable></mrow><mo fence="true"/></mrow>
# The mtable is in mrow -> mrow, and sibling mo has fence=true with {
if parent is not None and _local(parent.tag) == "mrow":
grandparent = parent.getparent()
if grandparent is not None and _local(grandparent.tag) == "mrow":
# Check siblings of parent for <mo fence="true">{</mo>
for sibling in grandparent:
if _local(sibling.tag) == "mo" and sibling.get("fence") == "true":
sibling_text = _text_content(sibling).strip()
if sibling_text == "{":
# Found left brace fence, check if closing is empty
# Look for an empty <mo fence="true"/> or <mo fence="true">.</mo>
for other in grandparent:
if other is sibling:
continue
if _local(other.tag) == "mo" and other.get("fence") == "true":
other_text = _text_content(other).strip()
if other_text in ("", "."):
return True
# If only one fence found with {, still consider it cases-style
# (closing might be implicit)
return True
return False
def _determine_environment(self, node: ET.Element, frame: str) -> str:
"""
Determine the LaTeX environment based on context.
Args:
node: The <mtable> element
frame: Frame attribute value
Returns:
Environment name (pmatrix, bmatrix, vmatrix, Vmatrix, array, matrix)
"""
# Check for delimiter attributes on parent mfenced
parent = node.getparent()
if parent is not None and _local(parent.tag) == "mfenced":
open_delim = parent.get("open", "(")
close_delim = parent.get("close", ")")
# Match delimiters to matrix types
if open_delim == "(" and close_delim == ")":
return "pmatrix"
elif open_delim == "[" and close_delim == "]":
return "bmatrix"
elif open_delim == "|" and close_delim == "|":
return "vmatrix"
elif open_delim == "||" and close_delim == "||":
return "Vmatrix"
# Default based on frame attribute
if frame == "solid":
return "matrix"
else:
return "array"
def _get_column_spec(self, columnalign: str, col_count: int) -> str:
"""
Generate column specification for array environment.
Args:
columnalign: Column alignment attribute
col_count: Number of columns
Returns:
Column specification string (e.g., "ccc" for 3 centered columns)
"""
# Parse columnalign attribute
# Can be: "left", "center", "right", or space-separated list
alignments = columnalign.split()
if len(alignments) == 1:
# Same alignment for all columns
align_char = self._align_to_char(alignments[0])
return align_char * col_count
else:
# Different alignments per column
# Repeat last alignment if not enough specified
spec = []
for i in range(col_count):
if i < len(alignments):
spec.append(self._align_to_char(alignments[i]))
else:
# Repeat last alignment
spec.append(self._align_to_char(alignments[-1]))
return ''.join(spec)
def _align_to_char(self, align: str) -> str:
"""
Convert alignment name to LaTeX column spec character.
Args:
align: Alignment name (left, center, right)
Returns:
Column spec character (l, c, r)
"""
align_map = {
'left': 'l',
'center': 'c',
'right': 'r',
}
return align_map.get(align, 'c') # Default to center
def _convert_rows(self, node: ET.Element) -> list:
"""
Convert all <mtr> child elements to LaTeX row strings.
Args:
node: The <mtable> element
Returns:
List of LaTeX row strings
"""
rows = []
for child in node:
if _local(child.tag) == "mtr":
row_latex = self._convert_row(child)
rows.append(row_latex)
return rows
def _convert_row(self, node: ET.Element) -> str:
"""
Convert a single <mtr> (table row) element.
Args:
node: The <mtr> element
Returns:
LaTeX row string with cells separated by &
"""
# Use MtrVisitor for proper row conversion
from .mtr_visitor import MtrVisitor
return MtrVisitor(self.strategy).visit(node)
# NOTE: _create_cases_environment is no longer used
# We now use explicit \left\{ and \right. notation via mfenced wrapper
# for better compatibility (cases environment is not universally supported)
# def _create_cases_environment(self, node: ET.Element) -> str:
# """
# Create a cases environment for piecewise functions.
#
# Args:
# node: The <mtable> element with data-cases="true"
#
# Returns:
# LaTeX cases environment
# """
# from .mtd_visitor import MtdVisitor
#
# rows = []
# for child in node:
# if _local(child.tag) == "mtr":
# # For cases, we only use the first cell of each row
# if len(child) > 0:
# first_cell = child[0]
# if _local(first_cell.tag) == "mtd":
# cell_content = MtdVisitor(self.strategy).visit(first_cell)
# rows.append(cell_content)
#
# if not rows:
# return "\\begin{cases}\\end{cases}"
#
# # Join rows with \\ and add space after each line break (except last)
# # This matches the original converter's output format
# cases_content = '\\\\ '.join(rows)
# return f"\\begin{{cases}}{cases_content}\\end{{cases}}"
def _create_binomial(self, node: ET.Element) -> str:
"""
Create binomial coefficient notation.
Args:
node: The <mtable> element with data-binomial="true"
Returns:
LaTeX array for binomial coefficient
"""
from .mtd_visitor import MtdVisitor
rows = [child for child in node if _local(child.tag) == "mtr"]
if len(rows) == 2:
# Get numerator from first row
num_cell = rows[0][0] if len(rows[0]) > 0 else None
num_content = MtdVisitor(self.strategy).visit(num_cell) if num_cell is not None else ""
# Get denominator from second row
den_cell = rows[1][0] if len(rows[1]) > 0 else None
den_content = MtdVisitor(self.strategy).visit(den_cell) if den_cell is not None else ""
return f"\\begin{{array}}{num_content}\\\\{den_content}\\end{{array}}"
# Fallback to regular array
return self.visit(node)
def _is_chemical_reaction_condition(self, mtable_node: ET.Element) -> bool:
"""
Check if an mtable represents chemical reaction conditions.
Pattern: 2-row mtable with:
- Row 1: Contains double munder pattern (nested munder with underscore operators)
- Row 2: Empty or whitespace only
This is how Word/OMML encodes reaction arrows like: A + B --[condition]--> C + D
Args:
mtable_node: The <mtable> element to check
Returns:
True if this is a chemical reaction condition table
"""
from ..utils import text_content as _text_content
rows = [child for child in mtable_node if _local(child.tag) == "mtr"]
# Must be exactly 2 rows (condition text + empty row)
if len(rows) != 2:
return False
# First row should have content, second row should be empty
first_row = rows[0]
second_row = rows[1]
# Check first row has exactly one cell
first_cells = [child for child in first_row if _local(child.tag) == "mtd"]
if len(first_cells) != 1:
return False
# CRITICAL: Check for malformed "double munder" pattern
# Pattern: mtd → munder → munder → content
first_cell = first_cells[0]
if self._has_double_munder_pattern(first_cell):
return True
# Alternative: Check if content looks like a reaction condition
# (For cases where double munder isn't present but it's clearly a condition)
first_cell_text = _text_content(first_cell).strip()
if not first_cell_text:
return False
# Check second row is empty or has only whitespace
second_cells = [child for child in second_row if _local(child.tag) == "mtd"]
if len(second_cells) != 1:
return False
second_cell_text = _text_content(second_cells[0]).strip()
# Allow nbsp (U+00A0) and other whitespace
if second_cell_text and second_cell_text != "\xa0" and second_cell_text != " ":
return False
# Check for typical reaction conditions
if self._contains_chinese_text(first_cell_text):
return True
# Check for standalone chemical condition symbols like Δ (delta/heat)
if first_cell_text in ["Δ", "δ", "\\Delta"]:
return True
return False
def _has_double_munder_pattern(self, cell_node: ET.Element) -> bool:
"""
Detect nested munder elements with underscore operators.
Pattern: mtd → [mrow →] munder → munder → content
Both munders have underscore (_) as the operator, creating double underline.
Args:
cell_node: The <mtd> cell element
Returns:
True if double munder pattern detected
"""
from ..utils import text_content as _text_content
# Check if cell contains munder as direct child
munder_children = [child for child in cell_node if _local(child.tag) == "munder"]
# If no direct munder, check inside mrow wrapper
if len(munder_children) == 0:
mrow_children = [child for child in cell_node if _local(child.tag) == "mrow"]
if len(mrow_children) == 1:
# Look for munder inside the mrow
munder_children = [child for child in mrow_children[0] if _local(child.tag) == "munder"]
if len(munder_children) != 1:
return False
outer_munder = munder_children[0]
# Check if outer munder has underaccent or accentunder attribute
if not (outer_munder.get("accentunder") or outer_munder.get("underaccent")):
return False
# Outer munder should have 2 children: content (nested munder) and operator (_)
if len(outer_munder) < 2:
return False
# Check if the operator is underscore
outer_operator = outer_munder[1]
if _local(outer_operator.tag) == "mo":
outer_op_text = _text_content(outer_operator).strip()
if outer_op_text != "_":
return False
else:
return False
# Check if first child is another munder (nested)
inner_content = outer_munder[0]
inner_munder = None
if _local(inner_content.tag) == "munder":
inner_munder = inner_content
elif _local(inner_content.tag) == "mrow":
# Inner munder might be inside mrow
for child in inner_content:
if _local(child.tag) == "munder":
inner_munder = child
break
if inner_munder is None:
return False
# Check if inner munder also has underaccent or accentunder attribute
if not (inner_munder.get("accentunder") or inner_munder.get("underaccent")):
return False
# Inner munder should also have underscore operator
if len(inner_munder) < 2:
return False
inner_operator = inner_munder[1]
if _local(inner_operator.tag) == "mo":
inner_op_text = _text_content(inner_operator).strip()
if inner_op_text != "_":
return False
else:
return False
# If we got here, we have the double munder pattern!
return True
def _extract_reaction_condition_text(self, mtable_node: ET.Element) -> str:
"""
Extract the reaction condition text from the mtable.
Args:
mtable_node: The <mtable> element
Returns:
Condition text (e.g., "电解", "\\Delta", etc.)
"""
from ..utils import text_content as _text_content
rows = [child for child in mtable_node if _local(child.tag) == "mtr"]
if len(rows) < 1:
return ""
first_row = rows[0]
cells = [child for child in first_row if _local(child.tag) == "mtd"]
if len(cells) < 1:
return ""
first_cell = cells[0]
# CRITICAL: Handle malformed double munder pattern
if self._has_double_munder_pattern(first_cell):
# Extract text from inside the nested munder structure
text = self._extract_text_from_double_munder(first_cell)
if text:
# Convert common symbols
if text == "Δ" or text == "\\Delta":
return "\\Delta"
# For Chinese text or other content, wrap in \mathrm{}
if self._contains_chinese_text(text):
return f"\\mathrm{{{text}}}"
return text
# If extraction failed or content is empty, return empty string
return ""
# Fallback: Convert the cell normally
from .mtd_visitor import MtdVisitor
content = MtdVisitor(self.strategy).visit(first_cell).strip()
# Clean up extra wrappers
import re
# Remove \text{} wrappers
content = re.sub(r'\\text\{([^}]*)\}', r'\1', content)
# Preserve \mathrm{} wrappers
return content
def _extract_reaction_condition_lower_text(self, mtable_node: ET.Element) -> str:
"""
Extract lower (below-arrow) reaction condition text from second row.
Args:
mtable_node: The <mtable> element
Returns:
Lower condition text for xlongequal bracket (empty if missing)
"""
from ..utils import text_content as _text_content
from .mtd_visitor import MtdVisitor
import re
rows = [child for child in mtable_node if _local(child.tag) == "mtr"]
if len(rows) < 2:
return ""
second_row = rows[1]
cells = [child for child in second_row if _local(child.tag) == "mtd"]
if len(cells) < 1:
return ""
second_cell = cells[0]
raw_text = _text_content(second_cell).strip()
# Treat empty/whitespace-only second row as no lower condition
if not raw_text or raw_text in ("\xa0", " "):
return ""
# Normalize common symbol cases
if raw_text in ("Δ", "\\Delta"):
return "\\Delta"
# Prefer direct text extraction for Chinese to avoid spacing artifacts
if self._contains_chinese_text(raw_text):
compact_text = re.sub(r'(?<=[\u4e00-\u9fff])\s+(?=[\u4e00-\u9fff])', '', raw_text)
return f"\\mathrm{{{compact_text}}}"
# Fallback to full conversion for non-textual/math-rich content
content = MtdVisitor(self.strategy).visit(second_cell).strip()
content = re.sub(r'\\text\{([^}]*)\}', r'\1', content)
return content
def _extract_text_from_double_munder(self, cell_node: ET.Element) -> str:
"""
Extract actual text content from double munder pattern.
Structure: mtd → [mrow →] munder → munder → actual_content
Args:
cell_node: The <mtd> cell element
Returns:
Extracted text content
"""
from ..utils import text_content as _text_content
# Navigate to outer munder
outer_munder = None
for child in cell_node:
if _local(child.tag) == "munder":
outer_munder = child
break
elif _local(child.tag) == "mrow":
# Check inside mrow
for grandchild in child:
if _local(grandchild.tag) == "munder":
outer_munder = grandchild
break
if outer_munder is None:
return ""
# Get first child of outer munder (should be inner munder or mrow containing it)
if len(outer_munder) < 1:
return ""
inner_content = outer_munder[0]
# Find inner munder
inner_munder = None
if _local(inner_content.tag) == "munder":
inner_munder = inner_content
elif _local(inner_content.tag) == "mrow":
for child in inner_content:
if _local(child.tag) == "munder":
inner_munder = child
break
if inner_munder is None:
return ""
# Get first child of inner munder (the actual content)
if len(inner_munder) < 1:
return ""
actual_content = inner_munder[0]
simple_label = self._extract_simple_chemical_label(actual_content)
if simple_label:
return simple_label
# Extract content while preserving MathML structure (e.g., subscripts)
# instead of flattening to plain text.
from .mrow_visitor import MrowVisitor
try:
rendered = MrowVisitor(self.strategy)._convert_child(actual_content)
return rendered.strip()
except Exception:
# Fallback for unexpected structures
text = _text_content(actual_content).strip()
return text
def _extract_simple_chemical_label(self, node: ET.Element) -> str:
"""
Extract simple chemical labels like FeCl₃ as \mathrm{F e C l}_{3}.
This is used for reaction-condition annotations in malformed double-munder
structures where preserving chemistry-style spacing is desirable.
"""
if _local(node.tag) != "mrow":
return ""
children = list(node)
if not children:
return ""
roman_parts: list[str] = []
trailing_subscript = ""
for index, child in enumerate(children):
tag = _local(child.tag)
if tag in {"mi", "mn"}:
token = self._extract_single_normal_variant_token(child)
if token:
roman_parts.append(token)
else:
return ""
continue
if tag == "msub":
if index != len(children) - 1 or len(child) < 2:
return ""
base_text = self._extract_single_normal_variant_token(child[0])
sub_text = self._extract_single_normal_variant_token(child[1])
if not base_text or not sub_text:
return ""
roman_parts.append(base_text)
trailing_subscript = sub_text
continue
return ""
if not roman_parts:
return ""
roman_text = " ".join(roman_parts)
if trailing_subscript:
return f"\\mathrm{{{roman_text}}}_{{{trailing_subscript}}}"
return f"\\mathrm{{{roman_text}}}"
def _extract_single_normal_variant_token(self, node: ET.Element) -> str:
"""
Extract a single token only when it explicitly carries mathvariant="normal".
Returns empty string if the node is not a simple single-token wrapper.
"""
from ..utils import text_content as _text_content
tag = _local(node.tag)
if tag in {"mi", "mn"}:
if node.get("mathvariant") != "normal":
return ""
return _text_content(node).strip()
if tag == "mrow" and len(node) == 1:
return self._extract_single_normal_variant_token(node[0])
return ""
def _contains_chinese_text(self, text: str) -> bool:
"""
Check if text contains Chinese characters.
Args:
text: Text to check
Returns:
True if text contains Chinese characters
"""
for char in text:
if '\u4e00' <= char <= '\u9fff': # CJK Unified Ideographs range
return True
return False
def _is_large_operator_stack(self, mtable_node: ET.Element) -> bool:
"""
Check if mtable represents a vertical stack with large operator.
Pattern: 3-row mtable with:
- Row 1: Upper limit (e.g., "n")
- Row 2: Large operator (e.g., "∑")
- Row 3: Lower limit (e.g., "i=1")
This is how Word/OMML encodes operators with limits when NOT using munderover.
Args:
mtable_node: The <mtable> element to check
Returns:
True if this is a large operator stack
"""
from ..utils import text_content as _text_content
rows = [child for child in mtable_node if _local(child.tag) == "mtr"]
# Must be exactly 3 rows (upper limit, operator, lower limit)
# or 2 rows (operator, lower limit) for simple cases
if len(rows) < 2 or len(rows) > 3:
return False
# Each row should have exactly 1 cell
for row in rows:
cells = [child for child in row if _local(child.tag) == "mtd"]
if len(cells) != 1:
return False
# Find which row contains the large operator
operator_row_idx = -1
for idx, row in enumerate(rows):
cell = [child for child in row if _local(child.tag) == "mtd"][0]
# Get first child of cell (might be wrapped in mstyle/mrow)
if self._cell_contains_large_operator(cell):
operator_row_idx = idx
break
if operator_row_idx == -1:
return False
# For 3-row stacks: operator should be in middle row (idx 1)
# For 2-row stacks: operator should be in first row (idx 0) - integral/sum with lower limit only
if len(rows) == 3 and operator_row_idx != 1:
return False
if len(rows) == 2 and operator_row_idx != 0 and operator_row_idx != 1:
return False
return True
def _cell_contains_large_operator(self, cell_node: ET.Element) -> bool:
"""
Check if a cell contains a large operator.
Recursively searches through wrapper elements to find operator.
Args:
cell_node: The <mtd> cell element
Returns:
True if cell contains a large operator
"""
from ..utils import text_content as _text_content
# Large operators
large_ops = [
"∑", "\\sum", # Summation
"∏", "\\prod", # Product
"∐", "\\coprod", # Coproduct
"∫", "\\int", # Integral
"∬", "\\iint", # Double integral
"∭", "\\iiint", # Triple integral
"∮", "\\oint", # Contour integral
"⋃", "\\bigcup", # Union
"⋂", "\\bigcap", # Intersection
"⋁", "\\bigvee", # Logical OR
"⋀", "\\bigwedge", # Logical AND
"⨁", "\\bigoplus", # Direct sum
"⨂", "\\bigotimes", # Tensor product
"⨄", "\\biguplus", # Multiset union
]
# Check cell content directly
cell_text = _text_content(cell_node).strip()
if cell_text in large_ops:
return True
# Recursively check children
for child in cell_node:
tag = _local(child.tag)
if tag in ('mi', 'mo'):
child_text = _text_content(child).strip()
if child_text in large_ops:
return True
elif tag in ('mstyle', 'mrow', 'mpadded', 'menclose'):
# Recursively check wrapper elements
if self._cell_contains_large_operator(child):
return True
return False
def _convert_large_operator_stack(self, mtable_node: ET.Element) -> str:
"""
Convert vertical stack mtable to proper large operator notation.
Args:
mtable_node: The <mtable> element
Returns:
LaTeX string with proper subscript/superscript notation
"""
from .mtd_visitor import MtdVisitor
rows = [child for child in mtable_node if _local(child.tag) == "mtr"]
# Get cells
cells = []
for row in rows:
cell = [child for child in row if _local(child.tag) == "mtd"][0]
cells.append(cell)
# Find operator cell
operator_idx = -1
for idx, cell in enumerate(cells):
if self._cell_contains_large_operator(cell):
operator_idx = idx
break
if operator_idx == -1:
# Fallback: shouldn't happen
return self.visit(mtable_node)
# Convert operator
operator_latex = MtdVisitor(self.strategy).visit(cells[operator_idx]).strip()
if len(cells) == 3:
# 3-row stack: upper / operator / lower
upper_latex = MtdVisitor(self.strategy).visit(cells[0]).strip()
lower_latex = MtdVisitor(self.strategy).visit(cells[2]).strip()
return f"{operator_latex}\\limits^{{{upper_latex}}}_{{{lower_latex}}}"
elif len(cells) == 2:
if operator_idx == 0:
# 2-row stack: operator / lower
lower_latex = MtdVisitor(self.strategy).visit(cells[1]).strip()
return f"{operator_latex}\\limits_{{{lower_latex}}}"
else:
# 2-row stack: upper / operator
upper_latex = MtdVisitor(self.strategy).visit(cells[0]).strip()
return f"{operator_latex}\\limits^{{{upper_latex}}}"
else:
# Fallback
return self.visit(mtable_node)
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mtable'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mtable'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MtableVisitor(strategy={self.strategy.name})"