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:
2026-09-29 09:43:13 +08:00
commit 3271fee5cc
360 changed files with 86082 additions and 0 deletions

View 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',
]

View 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)})"

View 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)})"

View 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)})"

View 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)})"

View 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)"