服务: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 默认密钥已清空
1004 lines
36 KiB
Python
1004 lines
36 KiB
Python
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})"
|