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 默认密钥已清空
This commit is contained in:
20
scripts/omml_mathml2latex/strategies/__init__.py
Normal file
20
scripts/omml_mathml2latex/strategies/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
Formatting strategies for different subject areas.
|
||||
|
||||
This package contains strategy implementations for subject-specific
|
||||
formatting (math, chemistry, physics, statistics).
|
||||
"""
|
||||
|
||||
from .math_strategy import MathStrategy
|
||||
from .chemistry_strategy import ChemistryStrategy
|
||||
from .physics_strategy import PhysicsStrategy
|
||||
from .statistics_strategy import StatisticsStrategy
|
||||
from .strategy_factory import StrategyFactory
|
||||
|
||||
__all__ = [
|
||||
'MathStrategy',
|
||||
'ChemistryStrategy',
|
||||
'PhysicsStrategy',
|
||||
'StatisticsStrategy',
|
||||
'StrategyFactory',
|
||||
]
|
||||
261
scripts/omml_mathml2latex/strategies/chemistry_strategy.py
Normal file
261
scripts/omml_mathml2latex/strategies/chemistry_strategy.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
Chemistry strategy for chemical equation notation.
|
||||
|
||||
This strategy handles formatting for chemical equations, including element
|
||||
symbols, reaction arrows, ion charges, and state symbols.
|
||||
"""
|
||||
|
||||
from lxml import etree as ET
|
||||
from typing import Set
|
||||
from ..core.interfaces import Strategy
|
||||
|
||||
# Import from existing data modules
|
||||
from ..data import CHEMICAL_ELEMENTS, UNICODE_TO_LATEX, MATH_FUNCTIONS, UNICODE_PASSTHROUGH
|
||||
|
||||
|
||||
class ChemistryStrategy(Strategy):
|
||||
"""
|
||||
Chemical equation notation strategy.
|
||||
|
||||
This strategy provides formatting for chemical content:
|
||||
- Chemical elements → \\mathrm{} (upright, not italic)
|
||||
- Reaction arrows → \\xlongequal{condition} or \\rightarrow
|
||||
- Ion charges → proper superscript formatting
|
||||
- State symbols → \\uparrow, \\downarrow
|
||||
- Chinese condition text → \\text{}
|
||||
"""
|
||||
|
||||
# Chemical elements (118 elements)
|
||||
ELEMENTS: Set[str] = CHEMICAL_ELEMENTS
|
||||
|
||||
# Mathematical functions still get special treatment
|
||||
FUNCTIONS: Set[str] = MATH_FUNCTIONS
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the chemistry strategy."""
|
||||
# Subject mode identifier for probability scoring
|
||||
self.subject_mode = 'chemistry'
|
||||
|
||||
def format_identifier(self, text: str, node: ET.Element) -> str:
|
||||
"""
|
||||
Format an identifier (<mi> element) for chemistry.
|
||||
|
||||
UPDATED: Now ONLY respects mathvariant attribute, no automatic element wrapping.
|
||||
User has full control through explicit mathvariant settings in Word.
|
||||
|
||||
Args:
|
||||
text: The identifier text content
|
||||
node: The <mi> element node
|
||||
|
||||
Returns:
|
||||
Formatted LaTeX string
|
||||
|
||||
Logic (SIMPLIFIED):
|
||||
1. Mathematical functions → \\function (e.g., "sin" → "\\sin")
|
||||
2. mathvariant="normal" → \\mathrm{text} (user explicitly set upright)
|
||||
3. Otherwise → return as-is (remain italic, default for math)
|
||||
|
||||
Examples:
|
||||
"Fe" with mathvariant="normal" → "\\mathrm{Fe}"
|
||||
"Fe" without mathvariant → "Fe" (italic)
|
||||
"H" with mathvariant="normal" → "\\mathrm{H}"
|
||||
"F" without mathvariant → "F" (italic, NOT wrapped)
|
||||
"sin" → "\\sin" (still a math function)
|
||||
"""
|
||||
# Check if it's a mathematical function (rare in chemistry, but possible)
|
||||
if text in self.FUNCTIONS:
|
||||
return f"\\{text}"
|
||||
|
||||
# DISABLED: Automatic element wrapping (user wants full control)
|
||||
# Old logic that auto-wrapped elements is now commented out
|
||||
# # Check if it's a chemical element
|
||||
# if text in self.ELEMENTS:
|
||||
# return f"\\mathrm{{{text}}}"
|
||||
|
||||
# DISABLED: Automatic capital letter wrapping
|
||||
# # Single CAPITAL letters → wrap in \mathrm{} (likely element symbols)
|
||||
# if len(text) == 1 and text.isupper():
|
||||
# return f"\\mathrm{{{text}}}"
|
||||
|
||||
# ONLY respect mathvariant attribute (user's explicit setting)
|
||||
mathvariant = node.get('mathvariant', '')
|
||||
if mathvariant == 'normal':
|
||||
# User explicitly set this to upright in Word (via m:sty='p' in OMML)
|
||||
return f"\\mathrm{{{text}}}"
|
||||
|
||||
# UNICODE_PASSTHROUGH: Keep certain symbols as Unicode for MathJax compatibility
|
||||
if text in UNICODE_PASSTHROUGH:
|
||||
return text
|
||||
|
||||
# Check for Unicode symbols
|
||||
if text in UNICODE_TO_LATEX:
|
||||
return UNICODE_TO_LATEX[text]
|
||||
|
||||
# Handle multi-character text with Unicode
|
||||
if len(text) > 1:
|
||||
# Check if it contains Unicode (passthrough or convertible)
|
||||
has_unicode = any(char in UNICODE_TO_LATEX for char in text)
|
||||
has_passthrough = any(char in UNICODE_PASSTHROUGH for char in text)
|
||||
if has_unicode or has_passthrough:
|
||||
converted_parts = []
|
||||
for char in text:
|
||||
if char in UNICODE_PASSTHROUGH:
|
||||
converted_parts.append(char)
|
||||
elif char in UNICODE_TO_LATEX:
|
||||
converted_parts.append(UNICODE_TO_LATEX[char])
|
||||
elif char.isspace():
|
||||
converted_parts.append(' ')
|
||||
else:
|
||||
converted_parts.append(char)
|
||||
return ' '.join(converted_parts)
|
||||
|
||||
# Default: return as-is (italic, standard for math variables)
|
||||
return text
|
||||
|
||||
def format_operator(self, text: str, node: ET.Element) -> str:
|
||||
"""
|
||||
Format an operator (<mo> element) for chemistry.
|
||||
|
||||
Args:
|
||||
text: The operator text content
|
||||
node: The <mo> element node
|
||||
|
||||
Returns:
|
||||
Formatted LaTeX string
|
||||
|
||||
Logic:
|
||||
1. Reaction arrows → \\xlongequal{condition} or \\rightarrow
|
||||
2. State symbols → \\uparrow, \\downarrow
|
||||
3. Unicode symbols → LaTeX equivalents (via UNICODE_TO_LATEX)
|
||||
4. Standard operators → unchanged (+, -, =)
|
||||
|
||||
Examples:
|
||||
"→" → "\\rightarrow"
|
||||
"↑" → "\\uparrow"
|
||||
"↓" → "\\downarrow"
|
||||
"=" → "="
|
||||
"+" → "+"
|
||||
"""
|
||||
# Handle < and > specifically (LaTeX convention)
|
||||
if text == "<":
|
||||
return "\\lt"
|
||||
if text == ">":
|
||||
return "\\gt"
|
||||
|
||||
# Reaction arrows
|
||||
if text == '→':
|
||||
return '\\rightarrow'
|
||||
elif text == '←':
|
||||
return '\\leftarrow'
|
||||
elif text == '⇌' or text == '⇄':
|
||||
return '\\rightleftharpoons'
|
||||
elif text == '↑':
|
||||
return '\\uparrow'
|
||||
elif text == '↓':
|
||||
return '\\downarrow'
|
||||
|
||||
# UNICODE_PASSTHROUGH: Keep certain symbols as Unicode for MathJax compatibility
|
||||
if text in UNICODE_PASSTHROUGH:
|
||||
return text
|
||||
|
||||
# Check for Unicode symbol mapping
|
||||
if text in UNICODE_TO_LATEX:
|
||||
return UNICODE_TO_LATEX[text]
|
||||
|
||||
# Handle multi-character operators with Unicode
|
||||
if len(text) > 1:
|
||||
has_unicode = any(char in UNICODE_TO_LATEX for char in text)
|
||||
has_passthrough = any(char in UNICODE_PASSTHROUGH for char in text)
|
||||
if has_unicode or has_passthrough:
|
||||
converted_parts = []
|
||||
for char in text:
|
||||
if char in UNICODE_PASSTHROUGH:
|
||||
converted_parts.append(char)
|
||||
elif char in UNICODE_TO_LATEX:
|
||||
converted_parts.append(UNICODE_TO_LATEX[char])
|
||||
else:
|
||||
converted_parts.append(char)
|
||||
return ' '.join(converted_parts)
|
||||
|
||||
# Default: return as-is
|
||||
return text
|
||||
|
||||
def format_number(self, text: str, node: ET.Element) -> str:
|
||||
"""
|
||||
Format a number (<mn> element) for chemistry.
|
||||
|
||||
Args:
|
||||
text: The number text content
|
||||
node: The <mn> element node
|
||||
|
||||
Returns:
|
||||
Formatted LaTeX string
|
||||
|
||||
Logic:
|
||||
Numbers are returned as-is in chemistry mode.
|
||||
|
||||
Examples:
|
||||
"2" → "2" (stoichiometric coefficient)
|
||||
"3.5" → "3.5" (pH value)
|
||||
"""
|
||||
# Numbers are returned as-is
|
||||
return text
|
||||
|
||||
def detect_context(self, equation_text: str) -> bool:
|
||||
"""
|
||||
Detect if an equation is chemical content.
|
||||
|
||||
Args:
|
||||
equation_text: The full equation MathML text
|
||||
|
||||
Returns:
|
||||
True if likely chemical, False otherwise
|
||||
|
||||
Detection indicators:
|
||||
- Chemical elements (Fe, Cu, H2O, etc.)
|
||||
- Reaction arrows (→, ⇌, ↑, ↓)
|
||||
- Ion charges (2+, 3-, etc.)
|
||||
- Multiple element symbols in sequence
|
||||
|
||||
Examples:
|
||||
"Fe + H2SO4 → FeSO4 + H2" → True
|
||||
"2H2O ⇌ H3O+ + OH-" → True
|
||||
"sin(x) + cos(x)" → False
|
||||
"P = C" → False (just math variables, not Phosphorus = Carbon)
|
||||
"""
|
||||
# CRITICAL FIX: Use proper XML parsing instead of naive substring search
|
||||
# The old method falsely detected "ln" in "xmlns" and "sup" in "<msup>" tags
|
||||
# as mathematical functions, causing chemistry equations to be rejected.
|
||||
|
||||
try:
|
||||
# Parse MathML XML to ElementTree
|
||||
root = ET.fromstring(equation_text.encode('utf-8'))
|
||||
|
||||
# Use the robust chemistry detection from utils module
|
||||
from ..utils.chemistry_detection import detect_chemistry_equation
|
||||
return detect_chemistry_equation(root)
|
||||
|
||||
except (ET.XMLSyntaxError, ValueError):
|
||||
# Fallback to string-based detection if XML parsing fails
|
||||
# This should rarely happen with valid MathML
|
||||
|
||||
# Check for UNAMBIGUOUS multi-character elements
|
||||
unambiguous_elements = {e for e in self.ELEMENTS if len(e) > 1}
|
||||
element_count = sum(1 for element in unambiguous_elements if element in equation_text)
|
||||
if element_count >= 1:
|
||||
return True
|
||||
|
||||
# Check for reaction arrows (strong indicator)
|
||||
if any(arrow in equation_text for arrow in ['→', '←', '⇌', '⇄', '↑', '↓']):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Get the strategy name."""
|
||||
return "chemistry"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation for debugging."""
|
||||
return f"ChemistryStrategy(elements={len(self.ELEMENTS)})"
|
||||
448
scripts/omml_mathml2latex/strategies/math_strategy.py
Normal file
448
scripts/omml_mathml2latex/strategies/math_strategy.py
Normal file
@@ -0,0 +1,448 @@
|
||||
"""
|
||||
Math strategy for standard mathematical notation.
|
||||
|
||||
This strategy handles formatting for general mathematical equations,
|
||||
including function recognition, operator mapping, and number formatting.
|
||||
"""
|
||||
|
||||
from lxml import etree as ET
|
||||
from typing import Set, Optional
|
||||
from ..core.interfaces import Strategy
|
||||
|
||||
# Import from existing data module
|
||||
from ..data import MATH_FUNCTIONS, UNICODE_TO_LATEX, UNICODE_PASSTHROUGH
|
||||
from ..data.chemistry_data import (
|
||||
ALL_CHEMICAL_ELEMENTS,
|
||||
CHEMISTRY_VARIABLES,
|
||||
)
|
||||
from ..utils.chemistry_detection import detect_chemistry_equation, is_chemistry_variable
|
||||
|
||||
|
||||
class MathStrategy(Strategy):
|
||||
"""
|
||||
Standard mathematical notation strategy.
|
||||
|
||||
This strategy provides formatting for general mathematical content:
|
||||
- Function recognition (sin, cos, log → \\sin, \\cos, \\log)
|
||||
- Operator symbol mapping (Unicode → LaTeX)
|
||||
- Standard number formatting
|
||||
- Variables remain italic (LaTeX default)
|
||||
- Sophisticated identifier wrapping based on context
|
||||
"""
|
||||
|
||||
# Mathematical functions that get special LaTeX commands
|
||||
FUNCTIONS: Set[str] = MATH_FUNCTIONS
|
||||
|
||||
# Mathematical sets that should use \mathbf{} (bold upright) - ONLY when mathvariant is set
|
||||
MATH_SETS = {'R', 'Z', 'N', 'Q', 'C', 'ℝ', 'ℤ', 'ℕ', 'ℚ', 'ℂ'}
|
||||
|
||||
# CRITICAL: In pure math mode, NO letters are always wrapped
|
||||
# Wrapping only happens with explicit mathvariant="normal" attribute
|
||||
# This set is kept for compatibility but should be empty for math mode
|
||||
ALWAYS_WRAPPED_LETTERS = set() # Empty - no automatic wrapping in math mode
|
||||
|
||||
def __init__(self, wrap_identifiers_in_mathrm: bool = False):
|
||||
"""
|
||||
Initialize the math strategy.
|
||||
|
||||
Args:
|
||||
wrap_identifiers_in_mathrm: If True, wrap multi-char identifiers in \\mathrm{}
|
||||
Default is False to match original converter behavior
|
||||
"""
|
||||
self._equation_text: Optional[str] = None
|
||||
self._chemical_elements = set() # Will be populated if needed
|
||||
self._wrap_identifiers_in_mathrm = wrap_identifiers_in_mathrm
|
||||
self._root_element: Optional[ET.Element] = None
|
||||
self._is_chemistry_cached: Optional[bool] = None
|
||||
|
||||
def format_identifier(self, text: str, node: ET.Element) -> str:
|
||||
"""
|
||||
Format an identifier (<mi> element) for mathematics.
|
||||
|
||||
Args:
|
||||
text: The identifier text content
|
||||
node: The <mi> element node
|
||||
|
||||
Returns:
|
||||
Formatted LaTeX string
|
||||
|
||||
Logic:
|
||||
1. Mathematical functions → \\function (e.g., "sin" → "\\sin")
|
||||
2. Single letters → check wrapping rules (most stay italic)
|
||||
3. Multi-letter → check wrapping rules (depends on context)
|
||||
|
||||
Examples:
|
||||
"sin" → "\\sin"
|
||||
"cos" → "\\cos"
|
||||
"x" → "x" (italic)
|
||||
"R" → "R" (italic in math context)
|
||||
"H" → "\\mathrm{H}" (always upright)
|
||||
"velocity" → "velocity" (italic if math context)
|
||||
"""
|
||||
# CRITICAL: Strip whitespace before checking (matches original converter behavior)
|
||||
# Original converter does: clean_text = text.strip()
|
||||
clean_text = text.strip()
|
||||
|
||||
# Check if it's a mathematical function (case-insensitive)
|
||||
# Original converter does: if clean_text.lower() in MATHEMATICAL_FUNCTIONS
|
||||
if clean_text.lower() in self.FUNCTIONS:
|
||||
# Special handling for logarithms with bases
|
||||
if clean_text == "log2":
|
||||
return "\\log_{2}"
|
||||
elif clean_text == "log10":
|
||||
return "\\log_{10}"
|
||||
# Use lowercase version for function name
|
||||
return f"\\{clean_text.lower()}"
|
||||
|
||||
# Check for function with subscript (e.g., "limn→∞")
|
||||
for func in ['lim', 'limsup', 'liminf', 'sup', 'inf', 'max', 'min']:
|
||||
if clean_text.startswith(func):
|
||||
remaining = clean_text[len(func):]
|
||||
if remaining:
|
||||
# Convert special symbols in subscript
|
||||
remaining = (remaining
|
||||
.replace('→', '\\to ')
|
||||
.replace('∞', '\\infty ')
|
||||
.replace('≤', '\\leqslant ')
|
||||
.replace('≥', '\\geqslant ')
|
||||
.replace('≠', '\\ne ')
|
||||
.replace('±', '\\pm '))
|
||||
import re
|
||||
remaining = re.sub(r'\s+', ' ', remaining).strip()
|
||||
return f"\\{func}\\limits_{{{remaining}}}"
|
||||
else:
|
||||
return f"\\{func}"
|
||||
|
||||
# CRITICAL: Unicode math set symbols (ℝ, ℤ, etc.) are already handled by UNICODE_TO_LATEX
|
||||
# These map to \mathbb{R}, \mathbb{Z}, etc. (not \mathbf{})
|
||||
# This is correct because these Unicode symbols ARE blackboard bold characters
|
||||
# So we don't need special handling here - just fall through to UNICODE_TO_LATEX check below
|
||||
|
||||
# UNICODE_PASSTHROUGH: Keep certain symbols as Unicode for MathJax compatibility
|
||||
if clean_text in UNICODE_PASSTHROUGH:
|
||||
return clean_text
|
||||
|
||||
# Check for Unicode symbols
|
||||
if clean_text in UNICODE_TO_LATEX:
|
||||
return UNICODE_TO_LATEX[clean_text]
|
||||
|
||||
# Handle multi-character strings with Unicode symbols
|
||||
if len(clean_text) > 1:
|
||||
has_unicode = any(char in UNICODE_TO_LATEX for char in clean_text)
|
||||
has_passthrough = any(char in UNICODE_PASSTHROUGH for char in clean_text)
|
||||
if has_unicode or has_passthrough:
|
||||
converted_parts = []
|
||||
for char in clean_text:
|
||||
if char in UNICODE_PASSTHROUGH:
|
||||
converted_parts.append(char)
|
||||
elif char in UNICODE_TO_LATEX:
|
||||
converted_parts.append(UNICODE_TO_LATEX[char])
|
||||
elif char.isspace():
|
||||
converted_parts.append(' ')
|
||||
else:
|
||||
converted_parts.append(char)
|
||||
return ' '.join(converted_parts)
|
||||
|
||||
# USER REQUIREMENT: "use the mathvariant to determine whether it's mathrm"
|
||||
# REMOVED: Chemistry detection heuristics (lines 139-162)
|
||||
# REMOVED: should_wrap_in_mathrm() call (now always returns False)
|
||||
#
|
||||
# Wrapping in \mathrm{} is ONLY done when mathvariant="normal" attribute exists.
|
||||
# That's handled in mi_visitor.py _handle_mathvariant() method.
|
||||
|
||||
# Default: return as-is (variables remain italic in LaTeX)
|
||||
return clean_text
|
||||
|
||||
def format_operator(self, text: str, node: ET.Element) -> str:
|
||||
"""
|
||||
Format an operator (<mo> element) for mathematics.
|
||||
|
||||
Args:
|
||||
text: The operator text content
|
||||
node: The <mo> element node
|
||||
|
||||
Returns:
|
||||
Formatted LaTeX string
|
||||
|
||||
Logic:
|
||||
1. Delimiters → __DELIM__ markers for later processing
|
||||
2. Comparison operators < and > → \\lt and \\gt (LaTeX convention)
|
||||
3. Unicode symbols → LaTeX equivalents (via UNICODE_TO_LATEX)
|
||||
4. Standard operators → unchanged (+, -, *, /, =)
|
||||
|
||||
Examples:
|
||||
"(" → "__DELIM__(__DELIM__"
|
||||
")" → "__DELIM__)__DELIM__"
|
||||
"+" → "+"
|
||||
"<" → "\\lt"
|
||||
">" → "\\gt"
|
||||
"×" → "\\times"
|
||||
"≤" → "\\leqslant"
|
||||
"∈" → "\\in"
|
||||
"""
|
||||
# CRITICAL: Mark delimiters for potential scaling
|
||||
# These will be processed later by _process_delimiters()
|
||||
# This matches the original converter's behavior (line 3558 in omml_to_tex.py)
|
||||
# Include common paired delimiters: parentheses, brackets, braces, angle brackets, ceil/floor
|
||||
# BUG-012 FIX: Added "|" to delimiters to allow proper absolute value detection
|
||||
import sys
|
||||
delimiter_chars = ("(", ")", "[", "]", "{", "}", "⟨", "⟩", "〈", "〉", "⌈", "⌉", "⌊", "⌋", "|")
|
||||
if text in delimiter_chars:
|
||||
# Convert Unicode delimiters to LaTeX BEFORE marking
|
||||
# This ensures the delimiter pairing processor can find and pair them
|
||||
delimiter_latex_map = {
|
||||
"⟨": "\\langle",
|
||||
"⟩": "\\rangle",
|
||||
"〈": "\\langle", # CJK angle bracket
|
||||
"〉": "\\rangle", # CJK angle bracket
|
||||
"⌈": "\\lceil",
|
||||
"⌉": "\\rceil",
|
||||
"⌊": "\\lfloor",
|
||||
"⌋": "\\rfloor",
|
||||
"{": "\\{", # FIX: Escape braces so unpaired ones are valid LaTeX
|
||||
"}": "\\}", # FIX: Escape braces so unpaired ones are valid LaTeX
|
||||
}
|
||||
converted_text = delimiter_latex_map.get(text, text)
|
||||
|
||||
# Check for stretchy attribute - if true, use a special marker to force scaling
|
||||
stretchy = node.get('stretchy')
|
||||
if stretchy == 'true':
|
||||
result = f"__DELIM__STRETCHY::{converted_text}__DELIM__"
|
||||
else:
|
||||
result = f"__DELIM__{converted_text}__DELIM__"
|
||||
|
||||
# Debug: print(f"[DEBUG STRATEGY] Marking delimiter {text!r} → {result!r}", file=sys.stderr)
|
||||
return result
|
||||
|
||||
# Handle < and > specifically to match expected output format (from original converter)
|
||||
if text == "<":
|
||||
return "\\lt"
|
||||
if text == ">":
|
||||
return "\\gt"
|
||||
|
||||
# BUG-012 FIX: Removed forced | -> \mid conversion
|
||||
# Let DelimiterPairingProcessor decide whether it's \mid (operator) or \left|\right| (abs value)
|
||||
# if text == "|":
|
||||
# return "\\mid"
|
||||
|
||||
# UNICODE_PASSTHROUGH: Keep certain symbols as Unicode for MathJax compatibility
|
||||
# These symbols have LaTeX commands that MathJax doesn't support or renders poorly.
|
||||
# Check this BEFORE converting to LaTeX to preserve the original Unicode.
|
||||
if text in UNICODE_PASSTHROUGH:
|
||||
return text
|
||||
|
||||
# Check for Unicode symbol mapping
|
||||
if text in UNICODE_TO_LATEX:
|
||||
return UNICODE_TO_LATEX[text]
|
||||
|
||||
# Handle multi-character operators with Unicode
|
||||
if len(text) > 1:
|
||||
has_unicode = any(char in UNICODE_TO_LATEX for char in text)
|
||||
has_passthrough = any(char in UNICODE_PASSTHROUGH for char in text)
|
||||
if has_unicode or has_passthrough:
|
||||
converted_parts = []
|
||||
for char in text:
|
||||
# Check passthrough first - keep these as Unicode
|
||||
if char in UNICODE_PASSTHROUGH:
|
||||
converted_parts.append(char)
|
||||
elif char in UNICODE_TO_LATEX:
|
||||
converted_parts.append(UNICODE_TO_LATEX[char])
|
||||
else:
|
||||
converted_parts.append(char)
|
||||
return ' '.join(converted_parts)
|
||||
|
||||
# Default: return as-is
|
||||
return text
|
||||
|
||||
def format_number(self, text: str, node: ET.Element) -> str:
|
||||
"""
|
||||
Format a number (<mn> element) for mathematics.
|
||||
|
||||
Args:
|
||||
text: The number text content
|
||||
node: The <mn> element node
|
||||
|
||||
Returns:
|
||||
Formatted LaTeX string
|
||||
|
||||
Logic:
|
||||
Numbers are returned as-is in mathematics mode.
|
||||
|
||||
Examples:
|
||||
"123" → "123"
|
||||
"3.14" → "3.14"
|
||||
"1.5×10⁻³" → "1.5×10⁻³" (Unicode handled by operator conversion)
|
||||
"""
|
||||
# Numbers are typically returned as-is
|
||||
# Unicode processing happens at operator level
|
||||
return text
|
||||
|
||||
def set_equation_text(self, text: str) -> None:
|
||||
"""
|
||||
Set the full equation text for context detection.
|
||||
|
||||
Args:
|
||||
text: The complete equation LaTeX text
|
||||
"""
|
||||
self._equation_text = text
|
||||
|
||||
def set_wrap_identifiers_flag(self, value: bool) -> None:
|
||||
"""
|
||||
Set whether to wrap identifiers in \\mathrm{}.
|
||||
|
||||
Args:
|
||||
value: True to wrap multi-char identifiers, False to leave as-is
|
||||
"""
|
||||
self._wrap_identifiers_in_mathrm = value
|
||||
|
||||
def set_root_element(self, root: ET.Element) -> None:
|
||||
"""
|
||||
Set the root MathML element for context detection.
|
||||
|
||||
Args:
|
||||
root: The root <math> element
|
||||
"""
|
||||
self._root_element = root
|
||||
self._is_chemistry_cached = None # Clear cache
|
||||
|
||||
def is_chemistry_equation(self) -> bool:
|
||||
"""
|
||||
Detect if the current equation is chemistry-related.
|
||||
|
||||
DISABLED: Subject detection is obsolete. We rely on mathvariant="normal" only.
|
||||
|
||||
Returns:
|
||||
False - always (chemistry detection disabled)
|
||||
"""
|
||||
# DISABLED: Always return False - no chemistry detection
|
||||
# Chemistry formulas are handled via mathvariant="normal" in the MathML
|
||||
return False
|
||||
|
||||
# OLD CODE (kept for reference):
|
||||
# if self._is_chemistry_cached is not None:
|
||||
# return self._is_chemistry_cached
|
||||
# if self._root_element is None:
|
||||
# return False
|
||||
# self._is_chemistry_cached = detect_chemistry_equation(self._root_element)
|
||||
# return self._is_chemistry_cached
|
||||
|
||||
def should_wrap_in_mathrm(self, content: str) -> bool:
|
||||
"""
|
||||
Determine if an identifier should be wrapped in \\mathrm{}.
|
||||
|
||||
CRITICAL USER REQUIREMENT: "use the mathvariant to determine whether it's mathrm"
|
||||
|
||||
This means NO HEURISTIC WRAPPING. Only wrap when there's an explicit
|
||||
mathvariant="normal" attribute, which is handled in mi_visitor.py.
|
||||
|
||||
This method now serves as a safety check only - by default returns False.
|
||||
All wrapping decisions are made based on explicit MathML attributes.
|
||||
|
||||
Args:
|
||||
content: The identifier text
|
||||
|
||||
Returns:
|
||||
False - no heuristic wrapping allowed
|
||||
"""
|
||||
# USER REQUIREMENT: Only mathvariant="normal" should trigger \mathrm wrapping
|
||||
# That's handled in mi_visitor.py _handle_mathvariant() method
|
||||
# This method should NOT apply any heuristics
|
||||
return False
|
||||
|
||||
def _is_mathematical_variable(self, content: str) -> bool:
|
||||
"""
|
||||
Determine if multi-letter content should be treated as mathematical variables (not wrapped).
|
||||
|
||||
Args:
|
||||
content: The identifier text
|
||||
|
||||
Returns:
|
||||
True if should remain italic (mathematical variable), False otherwise
|
||||
"""
|
||||
# Pattern: all lowercase letters (like 'ab', 'xy', 'mn') - typical for mathematical variables
|
||||
if content.islower() and content.isalpha():
|
||||
# Check if we're in a mathematical context (inequalities, fractions, etc.)
|
||||
if self._is_mathematical_context():
|
||||
return True
|
||||
|
||||
# Pattern: mixed case with lowercase start (like 'ab', 'pq') - likely mathematical variables
|
||||
if content[0].islower() and content.isalpha() and len(content) <= 3:
|
||||
if self._is_mathematical_context():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _is_mathematical_context(self) -> bool:
|
||||
"""Check if we're in a mathematical context with inequalities, equations, fractions, etc."""
|
||||
if not self._equation_text:
|
||||
return False
|
||||
|
||||
mathematical_indicators = [
|
||||
# Inequality operators
|
||||
'≤', '≥', '≠', '<', '>', '\\leqslant', '\\geqslant', '\\leq', '\\geq', '\\ne', '\\neq',
|
||||
# Mathematical operations and relations
|
||||
'=', '+', '-', '×', '÷', '/', '\\cdot', '\\times', '\\div', '\\pm', '\\mp',
|
||||
# Fraction indicators
|
||||
'\\dfrac', '\\frac', '\\tfrac',
|
||||
# Function operations
|
||||
'\\sin', '\\cos', '\\tan', '\\log', '\\ln', '\\exp', '\\sqrt',
|
||||
# Set operations (when not chemical)
|
||||
'\\cap', '\\cup', '∩', '∪',
|
||||
# Mathematical symbols
|
||||
'∞', '\\infty', '∂', '\\partial', '∇', '\\nabla',
|
||||
# Set membership
|
||||
'\\in', '\\notin', '∈', '∉'
|
||||
]
|
||||
|
||||
return any(indicator in self._equation_text for indicator in mathematical_indicators)
|
||||
|
||||
def _is_clear_chemical_context(self) -> bool:
|
||||
"""Check if we're in a clear chemical context (more restrictive than set theory)."""
|
||||
if not self._equation_text:
|
||||
return False
|
||||
|
||||
chemical_indicators = ['\\xlongequal', 'Cu', 'Mn', 'Fe', 'SO', 'OH', '2+', '2-', '↑', '↓']
|
||||
return any(indicator in self._equation_text for indicator in chemical_indicators)
|
||||
|
||||
def _has_mathematical_functions(self) -> bool:
|
||||
"""Check if the current equation contains mathematical functions."""
|
||||
# Common mathematical functions that indicate math context, not chemistry
|
||||
math_functions = ['sin', 'cos', 'tan', 'log', 'ln', 'exp', 'sqrt', 'lim', 'max', 'min',
|
||||
'arcsin', 'arccos', 'arctan', 'sinh', 'cosh', 'tanh']
|
||||
|
||||
# Check if we have stored equation text for context analysis
|
||||
if self._equation_text:
|
||||
return any(func in self._equation_text for func in math_functions)
|
||||
|
||||
return False
|
||||
|
||||
def detect_context(self, equation_text: str) -> bool:
|
||||
"""
|
||||
Detect if an equation is mathematical content.
|
||||
|
||||
Args:
|
||||
equation_text: The full equation text
|
||||
|
||||
Returns:
|
||||
True if likely mathematical, False otherwise
|
||||
|
||||
Note:
|
||||
Math is the default context, so this returns True unless
|
||||
other strategies provide stronger indicators.
|
||||
"""
|
||||
# Store equation text for context detection
|
||||
self.set_equation_text(equation_text)
|
||||
|
||||
# Math is the default - return True
|
||||
# In practice, this is overridden by more specific strategies
|
||||
# (chemistry, physics, statistics) which check for their markers
|
||||
return True
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Get the strategy name."""
|
||||
return "math"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation for debugging."""
|
||||
return f"MathStrategy(functions={len(self.FUNCTIONS)})"
|
||||
283
scripts/omml_mathml2latex/strategies/physics_strategy.py
Normal file
283
scripts/omml_mathml2latex/strategies/physics_strategy.py
Normal file
@@ -0,0 +1,283 @@
|
||||
"""
|
||||
Physics strategy for physical equations with units and constants.
|
||||
|
||||
This strategy handles formatting for physics equations, including physical
|
||||
constants, SI units, vector notation, and standard physics symbols.
|
||||
"""
|
||||
|
||||
from lxml import etree as ET
|
||||
from typing import Set, Dict
|
||||
from ..core.interfaces import Strategy
|
||||
|
||||
# Import from existing data modules
|
||||
from ..data import UNICODE_TO_LATEX, MATH_FUNCTIONS, COMMON_UNITS, UNICODE_PASSTHROUGH
|
||||
|
||||
|
||||
class PhysicsStrategy(Strategy):
|
||||
"""
|
||||
Physical equation notation strategy.
|
||||
|
||||
This strategy provides formatting for physics content:
|
||||
- Physical constants → proper formatting (c, h, ħ, kB)
|
||||
- SI units → \\mathrm{} (upright)
|
||||
- Mathematical functions → \\function
|
||||
- Variables remain italic (LaTeX default)
|
||||
- Vector notation support
|
||||
"""
|
||||
|
||||
# Mathematical functions that get special LaTeX commands
|
||||
FUNCTIONS: Set[str] = MATH_FUNCTIONS
|
||||
|
||||
# Common physical constants (typically italic in physics)
|
||||
PHYSICAL_CONSTANTS: Set[str] = {
|
||||
'c', # speed of light
|
||||
'h', # Planck's constant
|
||||
'ℏ', # reduced Planck's constant (hbar)
|
||||
'G', # gravitational constant
|
||||
'e', # elementary charge
|
||||
'k', # Boltzmann constant (also kB)
|
||||
'kB', # Boltzmann constant
|
||||
'NA', # Avogadro's number
|
||||
'R', # gas constant
|
||||
'ε0', # permittivity of free space
|
||||
'μ0', # permeability of free space
|
||||
'g', # gravitational acceleration
|
||||
}
|
||||
|
||||
# SI units (upright formatting)
|
||||
UNITS: Set[str] = COMMON_UNITS
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the physics strategy."""
|
||||
pass
|
||||
|
||||
def format_identifier(self, text: str, node: ET.Element) -> str:
|
||||
"""
|
||||
Format an identifier (<mi> element) for physics.
|
||||
|
||||
Args:
|
||||
text: The identifier text content
|
||||
node: The <mi> element node
|
||||
|
||||
Returns:
|
||||
Formatted LaTeX string
|
||||
|
||||
Logic:
|
||||
1. Mathematical functions → \\function (e.g., "sin" → "\\sin")
|
||||
2. SI units → \\mathrm{unit} (e.g., "kg" → "\\mathrm{kg}")
|
||||
3. Physical constants → unchanged (remain italic)
|
||||
4. Single letters → unchanged (variables remain italic)
|
||||
5. Multi-letter non-units → check context
|
||||
|
||||
Examples:
|
||||
"sin" → "\\sin"
|
||||
"kg" → "\\mathrm{kg}"
|
||||
"m" → "m" (could be mass or meter, context-dependent)
|
||||
"c" → "c" (speed of light, italic)
|
||||
"force" → "force" (variable name, italic)
|
||||
"""
|
||||
# Check if it's a mathematical function
|
||||
if text in self.FUNCTIONS:
|
||||
return f"\\{text}"
|
||||
|
||||
# Check if it's an SI unit (upright)
|
||||
if text in self.UNITS:
|
||||
return f"\\mathrm{{{text}}}"
|
||||
|
||||
# UNICODE_PASSTHROUGH: Keep certain symbols as Unicode for MathJax compatibility
|
||||
if text in UNICODE_PASSTHROUGH:
|
||||
return text
|
||||
|
||||
# Check for Unicode symbols
|
||||
if text in UNICODE_TO_LATEX:
|
||||
return UNICODE_TO_LATEX[text]
|
||||
|
||||
# Special physics symbols
|
||||
if text == 'ℏ': # hbar
|
||||
return '\\hbar'
|
||||
elif text == 'ħ': # alternative hbar
|
||||
return '\\hbar'
|
||||
|
||||
# Physical constants remain italic (default LaTeX)
|
||||
if text in self.PHYSICAL_CONSTANTS:
|
||||
return text
|
||||
|
||||
# Handle multi-character strings
|
||||
if len(text) > 1:
|
||||
# Check if it contains Unicode (passthrough or convertible)
|
||||
has_unicode = any(char in UNICODE_TO_LATEX for char in text)
|
||||
has_passthrough = any(char in UNICODE_PASSTHROUGH for char in text)
|
||||
if has_unicode or has_passthrough:
|
||||
converted_parts = []
|
||||
for char in text:
|
||||
if char in UNICODE_PASSTHROUGH:
|
||||
converted_parts.append(char)
|
||||
elif char in UNICODE_TO_LATEX:
|
||||
converted_parts.append(UNICODE_TO_LATEX[char])
|
||||
elif char.isspace():
|
||||
converted_parts.append(' ')
|
||||
else:
|
||||
converted_parts.append(char)
|
||||
return ' '.join(converted_parts)
|
||||
|
||||
# Check if it looks like a unit (multiple known units combined)
|
||||
# e.g., "kg·m/s²" → should be upright
|
||||
# For now, let units be upright if explicitly in UNITS set
|
||||
# Otherwise, remain italic (variable names)
|
||||
|
||||
# Default: return as-is (variables remain italic in LaTeX)
|
||||
return text
|
||||
|
||||
def format_operator(self, text: str, node: ET.Element) -> str:
|
||||
"""
|
||||
Format an operator (<mo> element) for physics.
|
||||
|
||||
Args:
|
||||
text: The operator text content
|
||||
node: The <mo> element node
|
||||
|
||||
Returns:
|
||||
Formatted LaTeX string
|
||||
|
||||
Logic:
|
||||
1. Unicode symbols → LaTeX equivalents (via UNICODE_TO_LATEX)
|
||||
2. Standard operators → unchanged (+, -, *, /, =)
|
||||
3. Vector operators → proper notation (⃗, ×, ·)
|
||||
|
||||
Examples:
|
||||
"+" → "+"
|
||||
"×" → "\\times"
|
||||
"·" → "\\cdot"
|
||||
"∇" → "\\nabla"
|
||||
"∂" → "\\partial"
|
||||
"""
|
||||
# Handle < and > specifically (LaTeX convention)
|
||||
if text == "<":
|
||||
return "\\lt"
|
||||
if text == ">":
|
||||
return "\\gt"
|
||||
|
||||
# Vector cross product
|
||||
if text == '×':
|
||||
return '\\times'
|
||||
elif text == '·':
|
||||
return '\\cdot'
|
||||
|
||||
# UNICODE_PASSTHROUGH: Keep certain symbols as Unicode for MathJax compatibility
|
||||
if text in UNICODE_PASSTHROUGH:
|
||||
return text
|
||||
|
||||
# Check for Unicode symbol mapping
|
||||
if text in UNICODE_TO_LATEX:
|
||||
return UNICODE_TO_LATEX[text]
|
||||
|
||||
# Handle multi-character operators with Unicode
|
||||
if len(text) > 1:
|
||||
has_unicode = any(char in UNICODE_TO_LATEX for char in text)
|
||||
has_passthrough = any(char in UNICODE_PASSTHROUGH for char in text)
|
||||
if has_unicode or has_passthrough:
|
||||
converted_parts = []
|
||||
for char in text:
|
||||
if char in UNICODE_PASSTHROUGH:
|
||||
converted_parts.append(char)
|
||||
elif char in UNICODE_TO_LATEX:
|
||||
converted_parts.append(UNICODE_TO_LATEX[char])
|
||||
else:
|
||||
converted_parts.append(char)
|
||||
return ' '.join(converted_parts)
|
||||
|
||||
# Default: return as-is
|
||||
return text
|
||||
|
||||
def format_number(self, text: str, node: ET.Element) -> str:
|
||||
"""
|
||||
Format a number (<mn> element) for physics.
|
||||
|
||||
Args:
|
||||
text: The number text content
|
||||
node: The <mn> element node
|
||||
|
||||
Returns:
|
||||
Formatted LaTeX string
|
||||
|
||||
Logic:
|
||||
Numbers are returned as-is in physics mode.
|
||||
|
||||
Examples:
|
||||
"9.8" → "9.8" (gravitational acceleration)
|
||||
"3.0×10⁸" → "3.0×10⁸" (speed of light)
|
||||
"""
|
||||
# Numbers are returned as-is
|
||||
return text
|
||||
|
||||
def detect_context(self, equation_text: str) -> bool:
|
||||
"""
|
||||
Detect if an equation is physics content.
|
||||
|
||||
Args:
|
||||
equation_text: The full equation text
|
||||
|
||||
Returns:
|
||||
True if likely physics, False otherwise
|
||||
|
||||
Detection indicators:
|
||||
- SI units (kg, m, s, N, J, etc.)
|
||||
- Physical constants (c, h, G, etc.)
|
||||
- Vector operators (∇, ∂, ×, ·)
|
||||
- Common physics notation
|
||||
|
||||
Examples:
|
||||
"F = ma" → True (if 'a' is acceleration)
|
||||
"E = mc²" → True (energy-mass equivalence)
|
||||
"∇×B = μ₀J" → True (Maxwell's equation)
|
||||
"sin(x) + cos(x)" → False
|
||||
"T = C" → False (just math variables, not Tesla/Coulomb)
|
||||
"""
|
||||
# CRITICAL: Check if we're in a mathematical context first
|
||||
# If equation contains math functions like sin, cos, tan, log, etc.,
|
||||
# it's likely a pure math equation, not physics
|
||||
# This prevents false positives like "C = 2πr" being detected as physics
|
||||
if any(func in equation_text for func in ['sin', 'cos', 'tan', 'cot', 'sec', 'csc',
|
||||
'arcsin', 'arccos', 'arctan',
|
||||
'sinh', 'cosh', 'tanh',
|
||||
'log', 'ln', 'lg', 'exp',
|
||||
'lim', 'max', 'min', 'sup', 'inf']):
|
||||
return False
|
||||
|
||||
# CRITICAL: Filter out single-letter units that are too ambiguous
|
||||
# 'C', 'T', 'P', 'A', 'K', 'S', 'H', 'N', 'V', 'F', 'W', 'J' can all be variables!
|
||||
# Only check for multi-character units or units in clear physics context
|
||||
ambiguous_single_letters = {'C', 'T', 'P', 'A', 'K', 'S', 'H', 'N', 'V', 'F', 'W', 'J', 'L', 'M', 'R', 'G'}
|
||||
|
||||
# Check for UNAMBIGUOUS multi-character units
|
||||
unambiguous_units = {u for u in self.UNITS if len(u) > 1 or u not in ambiguous_single_letters}
|
||||
unit_count = sum(1 for unit in unambiguous_units if unit in equation_text)
|
||||
if unit_count >= 1:
|
||||
return True
|
||||
|
||||
# Check for physical constants (these are less ambiguous)
|
||||
const_count = sum(1 for const in self.PHYSICAL_CONSTANTS if const in equation_text)
|
||||
if const_count >= 1:
|
||||
return True
|
||||
|
||||
# Check for vector operators
|
||||
if any(op in equation_text for op in ['∇', '∂', '×', '·', '⃗']):
|
||||
return True
|
||||
|
||||
# Check for common physics patterns (units with superscripts: m/s², kg·m, etc.)
|
||||
import re
|
||||
if re.search(r'(kg|m|s|N|J|W|Pa|V|A|K|mol|cd)[²³⁴]', equation_text):
|
||||
return True
|
||||
if re.search(r'(m|kg|s|N|J)/s', equation_text):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Get the strategy name."""
|
||||
return "physics"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation for debugging."""
|
||||
return f"PhysicsStrategy(units={len(self.UNITS)}, constants={len(self.PHYSICAL_CONSTANTS)})"
|
||||
304
scripts/omml_mathml2latex/strategies/statistics_strategy.py
Normal file
304
scripts/omml_mathml2latex/strategies/statistics_strategy.py
Normal file
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
Statistics strategy for statistical notation.
|
||||
|
||||
This strategy handles formatting for statistical equations, including
|
||||
probability operators, distribution notation, and statistical symbols.
|
||||
"""
|
||||
|
||||
from lxml import etree as ET
|
||||
from typing import Set
|
||||
from ..core.interfaces import Strategy
|
||||
|
||||
# Import from existing data modules
|
||||
from ..data import UNICODE_TO_LATEX, MATH_FUNCTIONS, UNICODE_PASSTHROUGH
|
||||
|
||||
|
||||
class StatisticsStrategy(Strategy):
|
||||
"""
|
||||
Statistical notation strategy.
|
||||
|
||||
This strategy provides formatting for statistical content:
|
||||
- Probability operators → \\Pr, \\operatorname{Var}, \\operatorname{Cov}
|
||||
- Expected value E remains italic (use mathvariant="double-struck" for \\mathbb{E})
|
||||
- Distribution notation → proper formatting
|
||||
- Statistical symbols → Greek letters, operators
|
||||
- Variables remain italic (LaTeX default)
|
||||
"""
|
||||
|
||||
# Mathematical functions that get special LaTeX commands
|
||||
FUNCTIONS: Set[str] = MATH_FUNCTIONS
|
||||
|
||||
# Statistical functions and operators (upright)
|
||||
# CRITICAL: 'E' is NOT in this list - it's just a regular identifier
|
||||
# Only use \mathbb{E} when there's explicit mathvariant="double-struck" attribute
|
||||
STATISTICAL_OPERATORS: Set[str] = {
|
||||
'Pr', # Probability
|
||||
# 'E', # Expected value - REMOVED: should remain italic unless mathvariant specified
|
||||
'Var', # Variance
|
||||
'Cov', # Covariance
|
||||
'Cor', # Correlation
|
||||
'SD', # Standard deviation
|
||||
'SE', # Standard error
|
||||
'MSE', # Mean squared error
|
||||
'RSS', # Residual sum of squares
|
||||
'SSE', # Sum of squared errors
|
||||
'Bias', # Bias
|
||||
'df', # degrees of freedom
|
||||
'logit', # Logit function
|
||||
'probit', # Probit function
|
||||
}
|
||||
|
||||
# Distribution names (upright)
|
||||
DISTRIBUTIONS: Set[str] = {
|
||||
'Normal',
|
||||
'Binomial',
|
||||
'Poisson',
|
||||
'Exponential',
|
||||
'Uniform',
|
||||
'Gamma',
|
||||
'Beta',
|
||||
'Bernoulli',
|
||||
'Geometric',
|
||||
'Hypergeometric',
|
||||
'Multinomial',
|
||||
'NegativeBinomial',
|
||||
'Chi',
|
||||
'ChiSquared',
|
||||
'Student',
|
||||
'F',
|
||||
'Weibull',
|
||||
'Cauchy',
|
||||
'Laplace',
|
||||
'Logistic',
|
||||
'Pareto',
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the statistics strategy."""
|
||||
pass
|
||||
|
||||
def format_identifier(self, text: str, node: ET.Element) -> str:
|
||||
"""
|
||||
Format an identifier (<mi> element) for statistics.
|
||||
|
||||
Args:
|
||||
text: The identifier text content
|
||||
node: The <mi> element node
|
||||
|
||||
Returns:
|
||||
Formatted LaTeX string
|
||||
|
||||
Logic:
|
||||
1. Mathematical functions → \\function (e.g., "sin" → "\\sin")
|
||||
2. Statistical operators → \\operatorname{op} (e.g., "Var" → "\\operatorname{Var}")
|
||||
3. Distribution names → \\mathrm{dist} (e.g., "Normal" → "\\mathrm{Normal}")
|
||||
4. Single letters → unchanged (variables remain italic)
|
||||
5. Greek letters → LaTeX symbols
|
||||
|
||||
Examples:
|
||||
"Pr" → "\\Pr"
|
||||
"E" → "E" (expected value - remains italic unless mathvariant="double-struck")
|
||||
"Var" → "\\operatorname{Var}"
|
||||
"Normal" → "\\mathrm{Normal}"
|
||||
"μ" → "\\mu"
|
||||
"σ" → "\\sigma"
|
||||
"x" → "x" (random variable)
|
||||
"""
|
||||
# Check if it's a mathematical function
|
||||
if text in self.FUNCTIONS:
|
||||
return f"\\{text}"
|
||||
|
||||
# Special statistical operators with LaTeX commands
|
||||
if text == 'Pr':
|
||||
return '\\Pr'
|
||||
elif text in self.STATISTICAL_OPERATORS:
|
||||
# CRITICAL: Do NOT automatically convert 'E' to \mathbb{E}!
|
||||
# Only apply \mathbb{} when there's explicit mathvariant="double-struck" attribute
|
||||
# The mi_visitor will handle mathvariant attributes properly.
|
||||
# In statistics mode, treat 'E' like other operators (upright but not blackboard bold)
|
||||
return f"\\operatorname{{{text}}}"
|
||||
|
||||
# Distribution names (upright)
|
||||
if text in self.DISTRIBUTIONS:
|
||||
return f"\\mathrm{{{text}}}"
|
||||
|
||||
# UNICODE_PASSTHROUGH: Keep certain symbols as Unicode for MathJax compatibility
|
||||
if text in UNICODE_PASSTHROUGH:
|
||||
return text
|
||||
|
||||
# Check for Unicode symbols (Greek letters)
|
||||
if text in UNICODE_TO_LATEX:
|
||||
return UNICODE_TO_LATEX[text]
|
||||
|
||||
# Handle multi-character strings
|
||||
if len(text) > 1:
|
||||
# Check if it contains Unicode (passthrough or convertible)
|
||||
has_unicode = any(char in UNICODE_TO_LATEX for char in text)
|
||||
has_passthrough = any(char in UNICODE_PASSTHROUGH for char in text)
|
||||
if has_unicode or has_passthrough:
|
||||
converted_parts = []
|
||||
for char in text:
|
||||
if char in UNICODE_PASSTHROUGH:
|
||||
converted_parts.append(char)
|
||||
elif char in UNICODE_TO_LATEX:
|
||||
converted_parts.append(UNICODE_TO_LATEX[char])
|
||||
elif char.isspace():
|
||||
converted_parts.append(' ')
|
||||
else:
|
||||
converted_parts.append(char)
|
||||
return ' '.join(converted_parts)
|
||||
|
||||
# Default: return as-is (variables remain italic in LaTeX)
|
||||
return text
|
||||
|
||||
def format_operator(self, text: str, node: ET.Element) -> str:
|
||||
"""
|
||||
Format an operator (<mo> element) for statistics.
|
||||
|
||||
Args:
|
||||
text: The operator text content
|
||||
node: The <mo> element node
|
||||
|
||||
Returns:
|
||||
Formatted LaTeX string
|
||||
|
||||
Logic:
|
||||
1. Delimiters → __DELIM__ markers for later processing
|
||||
2. Unicode symbols → LaTeX equivalents (via UNICODE_TO_LATEX)
|
||||
3. Statistical symbols → proper notation (~, ∼, ≈, etc.)
|
||||
4. Standard operators → unchanged (+, -, *, /, =)
|
||||
|
||||
Examples:
|
||||
"(" → "__DELIM__(__DELIM__"
|
||||
"~" → "\\sim" (distributed as)
|
||||
"∼" → "\\sim"
|
||||
"≈" → "\\approx"
|
||||
"∝" → "\\propto" (proportional to)
|
||||
"∈" → "\\in"
|
||||
"""
|
||||
# CRITICAL: Mark delimiters for potential scaling
|
||||
# This matches MathStrategy behavior
|
||||
if text in ("(", ")", "[", "]", "{", "}"):
|
||||
return f"__DELIM__{text}__DELIM__"
|
||||
|
||||
# Special case: pipe can be absolute value delimiter
|
||||
if text == "|":
|
||||
return f"__DELIM__|__DELIM__"
|
||||
|
||||
# Handle < and > specifically (LaTeX convention)
|
||||
if text == "<":
|
||||
return "\\lt"
|
||||
if text == ">":
|
||||
return "\\gt"
|
||||
|
||||
# Tilde (distributed as)
|
||||
if text == '~' or text == '∼':
|
||||
return '\\sim'
|
||||
|
||||
# UNICODE_PASSTHROUGH: Keep certain symbols as Unicode for MathJax compatibility
|
||||
if text in UNICODE_PASSTHROUGH:
|
||||
return text
|
||||
|
||||
# Check for Unicode symbol mapping
|
||||
if text in UNICODE_TO_LATEX:
|
||||
return UNICODE_TO_LATEX[text]
|
||||
|
||||
# Handle multi-character operators with Unicode
|
||||
if len(text) > 1:
|
||||
has_unicode = any(char in UNICODE_TO_LATEX for char in text)
|
||||
has_passthrough = any(char in UNICODE_PASSTHROUGH for char in text)
|
||||
if has_unicode or has_passthrough:
|
||||
converted_parts = []
|
||||
for char in text:
|
||||
if char in UNICODE_PASSTHROUGH:
|
||||
converted_parts.append(char)
|
||||
elif char in UNICODE_TO_LATEX:
|
||||
converted_parts.append(UNICODE_TO_LATEX[char])
|
||||
else:
|
||||
converted_parts.append(char)
|
||||
return ' '.join(converted_parts)
|
||||
|
||||
# Default: return as-is
|
||||
return text
|
||||
|
||||
def format_number(self, text: str, node: ET.Element) -> str:
|
||||
"""
|
||||
Format a number (<mn> element) for statistics.
|
||||
|
||||
Args:
|
||||
text: The number text content
|
||||
node: The <mn> element node
|
||||
|
||||
Returns:
|
||||
Formatted LaTeX string
|
||||
|
||||
Logic:
|
||||
Numbers are returned as-is in statistics mode.
|
||||
|
||||
Examples:
|
||||
"0.05" → "0.05" (p-value)
|
||||
"95" → "95" (confidence level)
|
||||
"""
|
||||
# Numbers are returned as-is
|
||||
return text
|
||||
|
||||
def detect_context(self, equation_text: str) -> bool:
|
||||
"""
|
||||
Detect if an equation is statistical content.
|
||||
|
||||
Args:
|
||||
equation_text: The full equation text
|
||||
|
||||
Returns:
|
||||
True if likely statistics, False otherwise
|
||||
|
||||
Detection indicators:
|
||||
- Statistical operators (Pr, E, Var, Cov)
|
||||
- Distribution notation (Normal, Binomial, etc.)
|
||||
- Greek letters commonly used in stats (μ, σ, β, α)
|
||||
- Tilde notation (X ~ Normal)
|
||||
- Statistical symbols (≈, ∝, ∼)
|
||||
|
||||
Examples:
|
||||
"X ~ Normal(μ, σ²)" → True
|
||||
"E[X] = μ" → True
|
||||
"Var(X) = σ²" → True
|
||||
"sin(x) + cos(x)" → False
|
||||
"""
|
||||
# Check for statistical operators
|
||||
op_count = sum(1 for op in self.STATISTICAL_OPERATORS if op in equation_text)
|
||||
if op_count >= 1:
|
||||
return True
|
||||
|
||||
# Check for distribution names
|
||||
dist_count = sum(1 for dist in self.DISTRIBUTIONS if dist in equation_text)
|
||||
if dist_count >= 1:
|
||||
return True
|
||||
|
||||
# Check for tilde notation (distributed as)
|
||||
if '~' in equation_text or '∼' in equation_text:
|
||||
return True
|
||||
|
||||
# Check for common Greek letters in statistics context
|
||||
stats_greek = ['μ', 'σ', 'β', 'α', 'θ', 'λ', 'ρ', 'τ', 'χ']
|
||||
greek_count = sum(1 for letter in stats_greek if letter in equation_text)
|
||||
if greek_count >= 2: # At least 2 Greek letters
|
||||
return True
|
||||
|
||||
# Check for statistical patterns (p-value, confidence interval)
|
||||
import re
|
||||
if re.search(r'p\s*[<>=]?\s*0\.\d+', equation_text): # p-value
|
||||
return True
|
||||
if re.search(r'\d+%\s*(CI|confidence)', equation_text, re.IGNORECASE):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Get the strategy name."""
|
||||
return "statistics"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation for debugging."""
|
||||
return f"StatisticsStrategy(operators={len(self.STATISTICAL_OPERATORS)}, distributions={len(self.DISTRIBUTIONS)})"
|
||||
171
scripts/omml_mathml2latex/strategies/strategy_factory.py
Normal file
171
scripts/omml_mathml2latex/strategies/strategy_factory.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Strategy factory for creating subject-specific formatting strategies.
|
||||
|
||||
This module provides centralized strategy creation with auto-detection
|
||||
support for determining the most appropriate strategy based on content.
|
||||
"""
|
||||
|
||||
from typing import Union, List
|
||||
from ..core.interfaces import Strategy
|
||||
from .math_strategy import MathStrategy
|
||||
from .chemistry_strategy import ChemistryStrategy
|
||||
from .physics_strategy import PhysicsStrategy
|
||||
from .statistics_strategy import StatisticsStrategy
|
||||
|
||||
|
||||
class StrategyFactory:
|
||||
"""
|
||||
Factory for creating subject-specific conversion strategies.
|
||||
|
||||
This factory provides centralized strategy creation with support for:
|
||||
- Explicit strategy selection (math, chemistry, physics, statistics)
|
||||
- Auto-detection based on equation content
|
||||
- Fallback to math strategy as default
|
||||
"""
|
||||
|
||||
# Singleton instances for strategy reuse
|
||||
_math_strategy: Union[MathStrategy, None] = None
|
||||
_chemistry_strategy: Union[ChemistryStrategy, None] = None
|
||||
_physics_strategy: Union[PhysicsStrategy, None] = None
|
||||
_statistics_strategy: Union[StatisticsStrategy, None] = None
|
||||
|
||||
@classmethod
|
||||
def create_strategy(cls, subject_mode: str = "math") -> Strategy:
|
||||
"""
|
||||
Create a formatting strategy for the specified subject mode.
|
||||
|
||||
Args:
|
||||
subject_mode: The subject area for formatting
|
||||
("math", "chemistry", "physics", "statistics", or "auto")
|
||||
|
||||
Returns:
|
||||
Strategy instance for the specified subject mode
|
||||
|
||||
Examples:
|
||||
>>> factory = StrategyFactory()
|
||||
>>> strategy = factory.create_strategy("chemistry")
|
||||
>>> isinstance(strategy, ChemistryStrategy)
|
||||
True
|
||||
|
||||
>>> strategy = factory.create_strategy("math")
|
||||
>>> isinstance(strategy, MathStrategy)
|
||||
True
|
||||
|
||||
>>> strategy = factory.create_strategy("invalid")
|
||||
>>> isinstance(strategy, MathStrategy) # Falls back to math
|
||||
True
|
||||
"""
|
||||
subject_mode = subject_mode.lower()
|
||||
|
||||
if subject_mode == "chemistry":
|
||||
if cls._chemistry_strategy is None:
|
||||
cls._chemistry_strategy = ChemistryStrategy()
|
||||
return cls._chemistry_strategy
|
||||
|
||||
elif subject_mode == "physics":
|
||||
if cls._physics_strategy is None:
|
||||
cls._physics_strategy = PhysicsStrategy()
|
||||
return cls._physics_strategy
|
||||
|
||||
elif subject_mode == "statistics":
|
||||
if cls._statistics_strategy is None:
|
||||
cls._statistics_strategy = StatisticsStrategy()
|
||||
return cls._statistics_strategy
|
||||
|
||||
elif subject_mode == "auto":
|
||||
# Auto-detection handled by auto_detect_strategy()
|
||||
# For now, return math as default (will be enhanced later)
|
||||
if cls._math_strategy is None:
|
||||
cls._math_strategy = MathStrategy()
|
||||
return cls._math_strategy
|
||||
|
||||
else: # Default: "math" or any unrecognized mode
|
||||
if cls._math_strategy is None:
|
||||
cls._math_strategy = MathStrategy()
|
||||
return cls._math_strategy
|
||||
|
||||
@classmethod
|
||||
def auto_detect_strategy(cls, equation_text: str) -> Strategy:
|
||||
"""
|
||||
Auto-detect the most appropriate strategy based on equation content.
|
||||
|
||||
Args:
|
||||
equation_text: The equation text to analyze
|
||||
|
||||
Returns:
|
||||
Strategy instance best suited for the content
|
||||
|
||||
Detection priority:
|
||||
1. Chemistry (chemical elements, reaction arrows)
|
||||
2. Physics (SI units, physical constants)
|
||||
3. Statistics (statistical operators, distributions)
|
||||
4. Math (default fallback)
|
||||
|
||||
Examples:
|
||||
>>> factory = StrategyFactory()
|
||||
>>> strategy = factory.auto_detect_strategy("Fe + H2SO4 → FeSO4 + H2")
|
||||
>>> isinstance(strategy, ChemistryStrategy)
|
||||
True
|
||||
|
||||
>>> strategy = factory.auto_detect_strategy("F = ma, where a = 9.8 m/s²")
|
||||
>>> isinstance(strategy, PhysicsStrategy)
|
||||
True
|
||||
|
||||
>>> strategy = factory.auto_detect_strategy("X ~ Normal(μ, σ²)")
|
||||
>>> isinstance(strategy, StatisticsStrategy)
|
||||
True
|
||||
|
||||
>>> strategy = factory.auto_detect_strategy("sin(x) + cos(x)")
|
||||
>>> isinstance(strategy, MathStrategy)
|
||||
True
|
||||
"""
|
||||
# Create strategy instances if not already created
|
||||
if cls._chemistry_strategy is None:
|
||||
cls._chemistry_strategy = ChemistryStrategy()
|
||||
if cls._physics_strategy is None:
|
||||
cls._physics_strategy = PhysicsStrategy()
|
||||
if cls._statistics_strategy is None:
|
||||
cls._statistics_strategy = StatisticsStrategy()
|
||||
if cls._math_strategy is None:
|
||||
cls._math_strategy = MathStrategy()
|
||||
|
||||
# Priority order: Chemistry > Physics > Statistics > Math
|
||||
# (Chemistry has most specific indicators)
|
||||
|
||||
if cls._chemistry_strategy.detect_context(equation_text):
|
||||
return cls._chemistry_strategy
|
||||
|
||||
if cls._physics_strategy.detect_context(equation_text):
|
||||
return cls._physics_strategy
|
||||
|
||||
if cls._statistics_strategy.detect_context(equation_text):
|
||||
return cls._statistics_strategy
|
||||
|
||||
# Default: math strategy
|
||||
return cls._math_strategy
|
||||
|
||||
@classmethod
|
||||
def reset_cache(cls):
|
||||
"""
|
||||
Reset the cached strategy instances.
|
||||
|
||||
This is useful for testing or when strategy implementations change.
|
||||
"""
|
||||
cls._math_strategy = None
|
||||
cls._chemistry_strategy = None
|
||||
cls._physics_strategy = None
|
||||
cls._statistics_strategy = None
|
||||
|
||||
@classmethod
|
||||
def get_available_strategies(cls) -> List[str]:
|
||||
"""
|
||||
Get list of available strategy names.
|
||||
|
||||
Returns:
|
||||
List of strategy names that can be used with create_strategy()
|
||||
"""
|
||||
return ["math", "chemistry", "physics", "statistics", "auto"]
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""String representation for debugging."""
|
||||
return "StrategyFactory(math, chemistry, physics, statistics, auto)"
|
||||
Reference in New Issue
Block a user