Files
DoLuEdu-WordFileParse/scripts/omml_mathml2latex/visitors/mfrac_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

609 lines
23 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.

"""
Visitor for <mfrac> (fraction) MathML elements.
The <mfrac> element represents fractions in MathML:
- Standard fractions: \\frac{numerator}{denominator}
- Display fractions: \\dfrac{numerator}{denominator}
- Bevelled fractions (line fractions): numerator/denominator
"""
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 MfracVisitor(Visitor):
"""
Handles <mfrac> (fraction) MathML elements.
This visitor converts fractions to LaTeX format using \\frac or \\dfrac
commands depending on context.
Examples:
Standard fraction:
<mfrac><mn>1</mn><mn>2</mn></mfrac> → \\dfrac{1}{2}
Algebraic fraction:
<mfrac><mi>x</mi><mi>y</mi></mfrac> → \\dfrac{x}{y}
Complex fraction:
<mfrac>
<mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow>
<mrow><mi>y</mi><mo>-</mo><mn>1</mn></mrow>
</mfrac>
→ \\dfrac{x + 1}{y - 1}
Bevelled fraction (bevelled="true"):
<mfrac bevelled="true"><mn>1</mn><mn>2</mn></mfrac> → 1/2
No-bar stacked fraction (linethickness="0"):
<mfrac linethickness="0"><mi>n</mi><mi>k</mi></mfrac> → \genfrac{}{}{0pt}{}{n}{k}
Note:
For full compatibility with the original converter, we need to:
1. Detect xlongequal pattern (special chemistry notation)
2. Handle script context (use \\frac in superscripts/subscripts)
3. Handle display style context (use \\dfrac in display mode)
Currently implements basic functionality. Advanced features TODO.
"""
def __init__(self, strategy: Strategy, context: ConversionContext = None):
"""
Initialize the Mfrac visitor.
Args:
strategy: The formatting strategy to use
context: Shared conversion context for state tracking
"""
super().__init__(strategy)
self.context = context
# We'll need a reference to the visitor registry to convert children
# This will be set by the conversion stage
self._visitor_registry = None
def set_visitor_registry(self, registry):
"""
Set the visitor registry for converting child elements.
Args:
registry: The VisitorRegistry instance
Note:
This is called by the conversion stage to enable child conversion.
"""
self._visitor_registry = registry
def visit(self, node: ET.Element) -> str:
"""
Convert <mfrac> element to LaTeX.
Args:
node: The <mfrac> element to convert
Returns:
LaTeX fraction string
Raises:
ValueError: If node is not an <mfrac> element
ValueError: If mfrac has fewer than 2 children
"""
if not self.can_handle(node):
raise ValueError(f"MfracVisitor cannot handle element '{node.tag}'")
# mfrac should have exactly 2 children: numerator and denominator
children = list(node)
if len(children) < 2:
# Malformed mfrac - handle gracefully for MathJax compatibility
# \frac{} or \frac{x}{} should work; \frac without braces won't
if len(children) == 0:
return "\\dfrac{}{}"
elif len(children) == 1:
# Only numerator provided
numerator = self._convert_child(children[0])
return f"\\dfrac{{{numerator}}}{{}}"
# Check for xlongequal pattern with === in denominator FIRST (BUG-009 fix)
# This MUST come before linethickness check because noBar fractions
# with === in denominator should produce \xlongequal, not bevelled fraction
# Pattern: <mfrac linethickness="0"><text>高温</text><mo>===</mo></mfrac>
if self._is_xlongequal_with_equals_denominator(children):
# Extract text from numerator (goes in braces)
numerator = self._convert_child(children[0])
return f"\\xlongequal[\\quad]{{{numerator}}}"
# Check for xlongequal pattern WITH both upper and lower text
# Pattern: <mfrac><munder><text1><mo>‾</mo></munder><text2></mfrac>
# This represents \xlongequal[text2]{text1} (text above and below equals)
if self._is_xlongequal_with_both_texts(children):
# Extract text from numerator (goes in braces) and denominator (goes in brackets)
upper_text = self._extract_reaction_condition_text(children[0])
lower_text = self._convert_child(children[1])
# Strip any \mathrm{} or \text{} wrappers from both texts
# Note: The post-processing will handle space removal
lower_clean = self._strip_latex_wrappers(lower_text)
upper_clean = self._strip_latex_wrappers(upper_text)
return f"\\xlongequal[{lower_clean}]{{{upper_clean}}}"
# Check for xlongequal pattern (chemistry reaction condition)
# Pattern: <mfrac><munder><text><mo>‾</mo></munder><empty/></mfrac>
# This is Word's way of representing reaction conditions like "点燃" above an arrow
if self._is_chemical_reaction_condition(children):
# Extract the text from the underlined numerator
condition_text = self._extract_reaction_condition_text(children[0])
return f"\\xlongequal[\\quad]{{{condition_text}}}"
# Check for bevelled fraction (bevelled="true" attribute triggers inline fraction)
# Note: bevelled="true" means inline fraction with slash (a/b)
bevelled = node.get('bevelled')
if bevelled == 'true':
return self._convert_bevelled_fraction(children)
# Check for no-bar stacking (linethickness="0")
# Note: linethickness="0" means no-bar stacking (like binomial), NOT bevelled
# This is for binomial-style stacking without a fraction bar
linethickness = node.get('linethickness')
if linethickness == '0':
return self._convert_nobar_stack(children)
# Convert numerator and denominator
numerator = self._convert_child(children[0])
denominator = self._convert_child(children[1])
# Context-aware fraction command selection
# Priority: Script context > display style context > default
if self.context and self.context.in_script_context():
# Use \frac in script contexts (superscript/subscript) for proper sizing
frac_command = "\\frac"
elif self.context and self.context.display_style_context:
# Use \dfrac if in display style context (from mstyle)
frac_command = "\\dfrac"
else:
# Default to \dfrac for main equations
frac_command = "\\dfrac"
# Wrap in braces and return
return f"{frac_command}{self._brace(numerator)}{self._brace(denominator)}"
def _convert_bevelled_fraction(self, children: list) -> str:
"""
Convert bevelled (inline) fraction.
Bevelled fractions display as numerator/denominator in a single line.
This is triggered by bevelled="true" attribute.
Note: This is different from linethickness="0" which produces stacking
without a fraction bar (like binomial coefficients).
Args:
children: List of child elements [numerator, denominator]
Returns:
LaTeX string with / separator
Examples:
1/2, x/y, (x+1)/(y-1)
"""
numerator = self._convert_child(children[0])
denominator = self._convert_child(children[1])
# For bevelled fractions, use simple / separator
# May need parentheses for complex expressions
# TODO: Add intelligent parenthesization
return f"{numerator}/{denominator}"
def _convert_nobar_stack(self, children: list) -> str:
"""
Convert no-bar stacked fraction (linethickness="0").
This is for stacking like binomial coefficients where numerator
is above denominator but without a fraction bar.
Args:
children: List of child elements [numerator, denominator]
Returns:
LaTeX string for stacked notation
Note:
We use \\begin{array}{c}...\\end{array} for proper stacking.
This is more user-friendly and widely understood than \\genfrac syntax.
Examples:
<mfrac linethickness="0"><mi>n</mi><mi>k</mi></mfrac>
→ \\begin{array}{c} n \\\\ k \\end{array}
"""
numerator = self._convert_child(children[0])
denominator = self._convert_child(children[1])
# Use array environment for proper no-bar stacking
# Format: \begin{array}{c} top \\ bottom \end{array}
# Center-aligned single column with line break between elements
# Note: No space after \\ to avoid it being converted to \~
return f"\\begin{{array}}{{c}} {numerator} \\\\\\\\{denominator} \\end{{array}}"
def _convert_child(self, child: ET.Element) -> str:
"""
Convert a child element to LaTeX.
Args:
child: The child element to convert
Returns:
LaTeX string
Note:
For now, we directly instantiate the appropriate visitor.
In the future, this should use the visitor registry.
"""
from ..utils import local_name as _local
tag = _local(child.tag)
if tag == 'mi':
from .mi_visitor import MiVisitor
visitor = MiVisitor(self.strategy)
return visitor.visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
visitor = MoVisitor(self.strategy)
return visitor.visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
visitor = MnVisitor(self.strategy)
return visitor.visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(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 == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(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 for unknown elements: return text content
import sys
print(f"[WARNING] Unknown element <{tag}> in mfrac, using text fallback", file=sys.stderr)
return _text_content(child)
def _brace(self, content: str) -> str:
"""
Wrap content in braces if needed.
Args:
content: The content to wrap
Returns:
Content wrapped in braces
Note:
Always wraps in braces for fractions to ensure proper LaTeX parsing.
"""
return f"{{{content}}}"
def _is_chemical_reaction_condition(self, children: list) -> bool:
"""
Check if this fraction represents a chemical reaction condition.
Pattern: numerator is munder with underline OR mover with overline,
denominator is empty/whitespace
Args:
children: [numerator, denominator] child elements
Returns:
True if this is a chemical reaction condition pattern
"""
if len(children) != 2:
return False
numerator = children[0]
denominator = children[1]
# Unwrap mrow if present (Word often wraps in mrow)
numerator_tag = _local(numerator.tag)
if numerator_tag == "mrow" and len(numerator) == 1:
numerator = numerator[0]
numerator_tag = _local(numerator.tag)
# Check if numerator is munder with underline OR mover with overline
if numerator_tag in ("munder", "mover") and len(numerator) >= 2:
line_node = numerator[1] # Second child is the line (under or over)
if _local(line_node.tag) == "mo":
text = line_node.text or ""
# Check for line character (same character used for both over and under)
if "‾" in text or text.strip() in ["¯", "―", "−", "_", "-"]:
# Check if denominator is empty
denom_tag = _local(denominator.tag)
# Empty <mo> tag
if denom_tag == "mo":
denom_text = denominator.text or ""
if denom_text.strip() == "":
return True
# Empty <mrow/> tag
elif denom_tag == "mrow":
# Check if mrow has no children or only whitespace
if len(denominator) == 0:
return True
# Check if mrow has only whitespace text nodes
if len(denominator) == 1 and _local(denominator[0].tag) == "mo":
mo_text = denominator[0].text or ""
if mo_text.strip() == "":
return True
return False
def _extract_reaction_condition_text(self, numerator_node: ET.Element) -> str:
"""
Extract text from underlined/overlined numerator for reaction condition.
Args:
numerator_node: The munder or mover element (or mrow wrapping it) containing the condition text
Returns:
LaTeX content with user-specified formatting preserved (e.g., \\mathbf{})
but subject-determined formatting removed (e.g., \\mathrm{})
"""
numerator_tag = _local(numerator_node.tag)
# Unwrap mrow if present
if numerator_tag == "mrow" and len(numerator_node) == 1:
numerator_node = numerator_node[0]
numerator_tag = _local(numerator_node.tag)
# Handle both munder and mover
if numerator_tag not in ("munder", "mover"):
return ""
# The first child contains the actual content (same for both munder and mover)
if len(numerator_node) > 0:
content_node = numerator_node[0]
# Convert to LaTeX, preserving user-specified formatting (like \mathbf{})
content = self._convert_child(content_node)
# Only strip subject-determined formatting (\mathrm{} for chemistry mode)
# Preserve user-specified formatting like \mathbf{}, \textbf{}, \textit{}, etc.
if content.startswith("\\mathrm{") and content.endswith("}"):
content = content[8:-1] # Remove \mathrm{ and }
# Strip \text{} wrapper if present
elif content.startswith("\\text{") and content.endswith("}"):
content = content[6:-1] # Remove \text{ and }
# Clean up extra spaces
return content.strip()
return ""
def _is_xlongequal_with_equals_denominator(self, children: list) -> bool:
"""
Check if this fraction represents xlongequal with === in denominator.
Pattern: numerator has content, denominator contains only consecutive equals signs
This pattern is used in chemistry for reaction arrows with conditions above.
Args:
children: [numerator, denominator] child elements
Returns:
True if denominator contains only === (or ==, ====, etc.)
Examples:
<mfrac>
<mi>高温</mi>
<mo>===</mo>
</mfrac>
→ \\xlongequal[\\quad]{高温}
<mfrac>
<mtext>heat</mtext>
<mo>==</mo>
</mfrac>
→ \\xlongequal[\\quad]{heat}
"""
if len(children) != 2:
return False
numerator = children[0]
denominator = children[1]
# Get text content from denominator
denom_text = _text_content(denominator).strip()
# Check if denominator contains only equals signs (2 or more)
# Pattern: ==, ===, ====, = = =, etc. (with or without spaces)
import re
# Remove all spaces to check if it's only equals signs
denom_text_no_spaces = denom_text.replace(' ', '')
if denom_text_no_spaces and re.match(r'^=+$', denom_text_no_spaces) and len(denom_text_no_spaces) >= 2:
# Check if numerator has actual content
num_text = _text_content(numerator).strip()
if num_text and num_text != "":
return True
return False
def _is_xlongequal_with_both_texts(self, children: list) -> bool:
"""
Check if this fraction represents xlongequal with both upper and lower text.
Pattern: numerator is munder with underline OR mover with overline,
denominator has actual content
Args:
children: [numerator, denominator] child elements
Returns:
True if this is xlongequal with both texts pattern
Examples:
<mfrac>
<munder><mi>abc</mi><mo>‾</mo></munder>
<mi>defg</mi>
</mfrac>
→ \\xlongequal[defg]{abc}
<mfrac>
<mover><mi>abc</mi><mo>‾</mo></mover>
<mi>defg</mi>
</mfrac>
→ \\xlongequal[defg]{abc}
<mfrac>
<mrow><mover><mrow><mi>abc</mi></mrow><mo>‾</mo></mover></mrow>
<mrow><mi>defg</mi></mrow>
</mfrac>
→ \\xlongequal[defg]{abc}
"""
if len(children) != 2:
return False
numerator = children[0]
denominator = children[1]
# Unwrap mrow if present (Word often wraps in mrow)
numerator_tag = _local(numerator.tag)
if numerator_tag == "mrow" and len(numerator) == 1:
numerator = numerator[0]
numerator_tag = _local(numerator.tag)
# Check if numerator is munder with underline OR mover with overline
if numerator_tag in ("munder", "mover") and len(numerator) >= 2:
line_node = numerator[1] # Second child is the line (under or over)
if _local(line_node.tag) == "mo":
text = line_node.text or ""
# Check for line character (same character used for both over and under)
if "‾" in text or text.strip() in ["¯", "―", "−", "_", "-"]:
# Check if denominator is NOT empty (has actual content)
denom_text = _text_content(denominator).strip()
if denom_text and denom_text != "":
return True
return False
def _strip_latex_wrappers(self, latex: str) -> str:
"""
Strip LaTeX wrapper commands like \\mathrm{}, \\text{}, etc.
Args:
latex: LaTeX string that might have wrappers
Returns:
Clean text without wrappers
Examples:
\\mathrm{abc} → abc
\\text{hello} → hello
\\mathrm{d e f g} → d e f g (spaces preserved)
"""
latex = latex.strip()
# Strip \mathrm{} wrapper
if latex.startswith("\\mathrm{") and latex.endswith("}"):
latex = latex[8:-1]
# Strip \text{} wrapper
if latex.startswith("\\text{") and latex.endswith("}"):
latex = latex[6:-1]
# Strip \mathbf{} wrapper
if latex.startswith("\\mathbf{") and latex.endswith("}"):
latex = latex[8:-1]
return latex.strip()
def can_handle(self, node: ET.Element) -> bool:
"""
Check if this visitor can handle the given node.
Args:
node: The MathML element to check
Returns:
True if node is an <mfrac> element, False otherwise
"""
return _local(node.tag) == 'mfrac'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mfrac'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MfracVisitor(strategy={self.strategy.name})"