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,204 @@
# Refactor - Standalone OMML/MathML to LaTeX Converter
**Version:** 1.0.0
**Status:** Production-ready standalone module
**Size:** 836KB | 75 Python files | ~19,000 lines of code
## Overview
This is a fully standalone, production-ready OMML/MathML to LaTeX converter module that has been refactored from the original `omml_to_tex` package. It uses modern design patterns and has **zero external dependencies** on the parent package.
### Key Features
- ✅ **Fully Standalone**: No dependencies on `omml_to_tex` package
- ✅ **Production Ready**: All necessary code copied and integrated
- ✅ **Clean Architecture**: Uses Visitor, Strategy, and Pipeline patterns
- ✅ **Comprehensive**: Includes all data, utils, processing, and core modules
- ✅ **Modular**: Well-organized package structure
## Architecture
```
refactor/
├── __init__.py # Package entry point
├── __main__.py # CLI entry point
│
├── data/ # Symbol mappings and constants (7 files)
│ ├── symbols.py # Unicode to LaTeX mappings (200+ symbols)
│ ├── functions.py # Mathematical functions (50+)
│ ├── operators.py # Operator definitions
│ ├── patterns.py # Regex patterns for processing
│ └── units.py # Common units
│
├── utils/ # Utility functions (6 files)
│ ├── xml_utils.py # XML/MathML processing
│ ├── helpers.py # Helper functions
│ ├── chemistry.py # Chemistry detection
│ ├── text_processing.py # Text I/O and LaTeX formatting
│ └── color_helpers.py # Color processing
│
├── core/ # Core infrastructure (6 files)
│ ├── interfaces.py # Abstract base classes
│ ├── context.py # Conversion context
│ ├── pipeline.py # Pipeline orchestrator
│ ├── xslt_transformer.py # OMML to MathML transformation
│ └── omml_parser_core.py # OMML parser (84K lines)
│
├── parsers/ # Input parsers (5 files)
│ ├── factory.py # Parser factory
│ ├── docx_parser.py # DOCX file parsing
│ ├── omml_parser.py # OMML parsing
│ └── mathml_parser.py # MathML parsing
│
├── processing/ # Processing modules (5 files)
│ ├── omml_extractor.py # Extract OMML from DOCX
│ ├── docx_processor.py # DOCX processing
│ ├── output_formatter.py # LaTeX formatting
│ └── result.py # Result data structures
│
├── visitors/ # Visitor pattern (35 files)
│ ├── mi_visitor.py # <mi> identifier handling
│ ├── mo_visitor.py # <mo> operator handling
│ ├── mn_visitor.py # <mn> number handling
│ ├── mfrac_visitor.py # <mfrac> fraction handling
│ └── ... # 30+ more element visitors
│
├── strategies/ # Strategy pattern (5 files)
│ ├── math_strategy.py # Mathematical notation
│ ├── chemistry_strategy.py # Chemical equations
│ ├── physics_strategy.py # Physics notation
│ └── statistics_strategy.py # Statistical notation
│
├── builders/ # Builder pattern (2 files)
│ └── latex_builder.py # LaTeX construction
│
├── compat/ # Compatibility layer (2 files)
│ └── legacy_adapter.py # Legacy converter adapter
│
└── converters/ # Converters (2 files)
└── post_processor.py # LaTeX post-processing
```
## Usage
### Command Line
```bash
# Convert DOCX file
uv run python -m refactor --docx document.docx --output equations.tex
# Convert MathML
uv run python -m refactor --mathml input.xml --output output.tex
# Convert with chemistry mode
uv run python -m refactor --subject chemistry --docx chemistry.docx
# Convert OMML
uv run python -m refactor --omml input.xml --output output.tex
```
### Python API
```python
from refactor.compat import LegacyMathMLToTeXConverter
from refactor.parsers import InputParserFactory
# Create converter
converter = LegacyMathMLToTeXConverter(subject_mode='chemistry')
# Parse input
parser = InputParserFactory.create_parser('mathml', 'input.xml')
mathml_equations = parser.parse()
# Convert to LaTeX
for mathml in mathml_equations:
latex = converter.convert(mathml)
print(latex)
```
## Dependencies
This module is **completely standalone** and only requires:
- Python 3.7+
- lxml (for XML processing)
No dependency on the parent `omml_to_tex` package!
## Design Patterns Used
1. **Visitor Pattern**: Each MathML element type has its own visitor class
2. **Strategy Pattern**: Subject-specific formatting (math, chemistry, physics)
3. **Pipeline Pattern**: Explicit conversion stages
4. **Builder Pattern**: Fluent LaTeX construction
5. **Factory Pattern**: Unified input parsing
## What Was Changed
### Copied Modules (from omml_to_tex/)
- ✅ `data/` - All symbol mappings and constants
- ✅ `utils/` - XML utilities, helpers, chemistry detection
- ✅ `processing/` - DOCX processing, OMML extraction, formatting
- ✅ `core/` - XSLT transformer, OMML parser
### Updated Imports
All 53 files that imported from `omml_to_tex` were updated to use relative imports:
- `from omml_to_tex.data import X` → `from ..data import X`
- `from omml_to_tex.utils import Y` → `from ..utils import Y`
- etc.
### Removed Files
- ❌ All test files (`tests/` directory, ~50 files)
- ❌ All documentation files (`*.md`, ~15 files)
- ❌ Demo and benchmark files
- ❌ Empty `stages/` directory
- ❌ Nested `refactor/refactor/` directory
## File Statistics
```
Total Python files: 75
Total lines of code: ~19,000
Package size: 836KB
Visitor files: 35
Strategy files: 4
Data modules: 5
Utility modules: 5
```
## Testing
The module has been verified to:
- ✅ Import successfully as a standalone module
- ✅ Have no dependencies on `omml_to_tex` package
- ✅ Contain all necessary code and data
- ✅ Use consistent relative imports throughout
## Production Deployment
To deploy this module to your system:
1. **Copy the entire `refactor/` directory** to your target location
2. **Install dependencies**: `pip install lxml`
3. **Verify installation**: `python -m refactor --help`
4. **Test conversion**: `python -m refactor --docx your_file.docx`
The module is completely self-contained and ready for production use!
## Version History
**v1.0.0** (2025-10-26)
- Initial standalone release
- Copied all necessary modules from omml_to_tex
- Updated all imports to use relative imports
- Removed test and documentation files
- Verified standalone functionality
## License
Same as parent project (omml_to_tex)
## Contact
For issues or questions, refer to the parent project documentation.

View File

@@ -0,0 +1,26 @@
"""
Refactored OMML/MathML to LaTeX converter using design patterns.
This package implements a refactored architecture of the converter using:
- Visitor pattern for element conversion
- Strategy pattern for subject-specific formatting
- Pipeline pattern for conversion stages
- Parallel processing for high-performance batch conversion
- XSL transformer caching for optimized performance
"""
from .compat import LegacyMathMLToTeXConverter
from .parallel import ParallelConverter, BatchProcessor
from .core.cache import XSLTransformerCache, ConverterObjectPool
from .batch import BatchJSONConverter
__version__ = '1.1.0' # Performance optimization release
__all__ = [
'LegacyMathMLToTeXConverter',
'ParallelConverter',
'BatchProcessor',
'BatchJSONConverter',
'XSLTransformerCache',
'ConverterObjectPool',
]

View File

@@ -0,0 +1,379 @@
#!/usr/bin/env python3
"""
CLI entry point for refactored MathML to LaTeX converter.
Usage:
python -m refactor --docx file.docx
uv run python -m refactor --docx file.docx --subject chemistry
"""
import argparse
import sys
import os
from pathlib import Path
import time
# Use relative imports for standalone module
from .compat import LegacyMathMLToTeXConverter
from .parsers import InputParserFactory
from .utils.timer import PerformanceTimer
def handle_json_batch(args, timer):
"""
Handle JSON batch conversion mode.
Args:
args: Parsed command-line arguments
timer: PerformanceTimer instance
Returns:
Exit code (0 for success, non-zero for error)
"""
import json
from .batch import BatchJSONConverter
try:
# Read input JSON
with timer.measure("json_read"):
if args.verbose and not args.quiet:
print(f"Reading JSON from {args.json}...", file=sys.stderr)
with open(args.json, 'r', encoding='utf-8') as f:
input_data = json.load(f)
# Validate input format
if not isinstance(input_data, dict):
print("Error: Input JSON must be a dictionary (key -> equation mapping)", file=sys.stderr)
return 1
# Support both flat dict and nested "mathml_dict" format
if "mathml_dict" in input_data:
equations_dict = input_data["mathml_dict"]
if args.verbose and not args.quiet:
print("Detected nested 'mathml_dict' format", file=sys.stderr)
else:
equations_dict = input_data
if not equations_dict:
print("Error: No equations found in input JSON", file=sys.stderr)
return 1
if not args.quiet:
print(f"INFO: Found {len(equations_dict)} equations in JSON file", file=sys.stderr)
# Create batch converter
with timer.measure("converter_init"):
converter = BatchJSONConverter(
subject_mode=args.subject,
wrap_identifiers=args.wrap_mathrm,
prefer_xlongequal=not args.no_xlongequal,
universal_delimiter_scaling=not args.no_universal_delimiters,
verbose=args.verbose and not args.quiet,
)
# Convert (parallel or sequential)
if args.parallel and len(equations_dict) >= 10:
if not args.quiet:
print(f"INFO: Using parallel processing with {args.workers or 'auto'} workers", file=sys.stderr)
with timer.measure("parallel_batch_conversion", {"equations": len(equations_dict)}):
results = converter.convert_batch_parallel(
equations_dict,
workers=args.workers,
quiet=args.quiet
)
else:
if args.parallel and not args.quiet:
print("INFO: Sequential mode (< 10 equations, parallel not beneficial)", file=sys.stderr)
with timer.measure("sequential_batch_conversion", {"equations": len(equations_dict)}):
results = converter.convert_batch(equations_dict, quiet=args.quiet)
# Format output JSON
with timer.measure("json_format"):
output_json = json.dumps(results, ensure_ascii=False, indent=2)
# Write output
with timer.measure("file_output"):
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
f.write(output_json)
if not args.quiet:
print(f"Wrote {len(results)} results to {args.output}", file=sys.stderr)
else:
print(output_json)
# Show timing report if requested
if args.show_timing and not args.quiet:
print("\n", file=sys.stderr)
print(timer.format_report(), file=sys.stderr)
return 0
except FileNotFoundError:
print(f"Error: Input file not found: {args.json}", file=sys.stderr)
return 1
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON in input file: {e}", file=sys.stderr)
return 1
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
if args.verbose:
import traceback
traceback.print_exc()
return 1
def main():
"""Main CLI entry point."""
parser = argparse.ArgumentParser(
description="Refactored OMML/MathML → LaTeX converter with design patterns",
epilog="Examples:\n"
" python -m omml2tex --docx document.docx --output output.tex\n"
" python -m omml2tex --mathml input.xml --subject chemistry\n"
" python -m omml2tex --json equations.json -o results.json\n"
" python -m omml2tex --json equations.json --parallel --workers 4\n"
" uv run python -m omml2tex --docx file.docx\n",
formatter_class=argparse.RawDescriptionHelpFormatter
)
# Input options (mutually exclusive)
input_group = parser.add_mutually_exclusive_group(required=True)
input_group.add_argument("--omml", help="Path to input OMML XML file")
input_group.add_argument("--docx", help="Path to input DOCX file")
input_group.add_argument("--mathml", help="Path to input MathML XML file (use '-' for stdin)")
input_group.add_argument("--json", help="Path to input JSON file (key -> equation mapping for batch conversion)")
# Output options
parser.add_argument("--output", "-o", help="Output file path (default: stdout)")
parser.add_argument("--output-format", choices=["latex", "json"], default="latex",
help="Output format (default: latex)")
# Conversion options
parser.add_argument("--wrap-mathrm", action="store_true",
help="Wrap multi-character identifiers in \\mathrm{...} (disabled by default)")
parser.add_argument("--no-xlongequal", action="store_true",
help="Do not prefer \\xlongequal for overset '='")
parser.add_argument("--subject", choices=["auto", "math", "physics", "chemistry", "statistics"],
default="math", help="Subject mode for specialized formatting (default: math for MathML, auto for DOCX)")
parser.add_argument("--no-universal-delimiters", action="store_true",
help="Disable universal \\left\\right scaling for all delimiters (enabled by default)")
# Advanced options
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
parser.add_argument("--quiet", "-q", action="store_true", help="Quiet mode (errors only)")
parser.add_argument("--numbered", action="store_true", help="Number equations in output")
# Performance options
parser.add_argument("--parallel", action="store_true",
help="Enable parallel processing for faster conversion (recommended for 5+ equations)")
parser.add_argument("--workers", type=int, default=None,
help="Number of worker processes (default: CPU count, max 8)")
parser.add_argument("--show-timing", action="store_true",
help="Show detailed performance timing report at the end")
args = parser.parse_args()
# Initialize performance timer
timer = PerformanceTimer(enabled=args.show_timing)
try:
# Start total timing
with timer.measure("total_execution"):
# JSON batch mode has different I/O semantics - handle separately
if args.json:
return handle_json_batch(args, timer)
# Determine input source and type (single-file modes)
with timer.measure("input_detection"):
if args.docx:
input_path = args.docx
input_type = "docx"
# Auto-detect subject mode for DOCX if not explicitly set
if args.subject == "math":
args.subject = "auto"
elif args.omml:
input_path = args.omml
input_type = "omml"
elif args.mathml:
input_path = args.mathml
input_type = "mathml"
else:
print("Error: No input file specified", file=sys.stderr)
return 1
# Handle stdin ("-")
if input_path == "-":
if args.verbose and not args.quiet:
print("Reading from stdin...", file=sys.stderr)
input_path = sys.stdin.read()
# Parse input file to get MathML equations
if args.verbose and not args.quiet:
print(f"Parsing input: {input_path if input_path != '-' else 'stdin'}", file=sys.stderr)
try:
with timer.measure("input_parsing", {"type": input_type}):
parser_obj = InputParserFactory.create_parser(input_path, parser_type=input_type)
mathml_equations = parser_obj.parse(input_path)
except Exception as e:
print(f"Error parsing input: {e}", file=sys.stderr)
return 1
if args.verbose and not args.quiet:
print(f"Found {len(mathml_equations)} equation(s)", file=sys.stderr)
# Print info messages to stderr (like original converter)
if not args.quiet:
print(f"INFO: Processing {'DOCX' if args.docx else 'MathML' if args.mathml else 'OMML'} file: {input_path}", file=sys.stderr)
print(f"INFO: Found {len(mathml_equations)} equations in {input_path}", file=sys.stderr)
print(f"INFO: Auto-detected subject mode: {args.subject}", file=sys.stderr)
# Convert equations - use parallel processing if enabled
if args.parallel and len(mathml_equations) >= 5:
# PARALLEL MODE: Use multiprocessing for faster conversion
if not args.quiet:
print(f"INFO: Using parallel processing with {args.workers or 'auto'} workers", file=sys.stderr)
from .parallel import ParallelConverter
with timer.measure("parallel_conversion", {"equations": len(mathml_equations)}):
parallel_converter = ParallelConverter(
workers=args.workers,
subject_mode=args.subject,
wrap_identifiers=args.wrap_mathrm,
prefer_xlongequal=not args.no_xlongequal,
universal_delimiter_scaling=not args.no_universal_delimiters,
show_progress=not args.quiet,
)
# Convert in parallel
conversion_results = parallel_converter.convert_batch(mathml_equations, quiet=args.quiet)
# Format results
with timer.measure("format_results"):
results = []
for i, result in enumerate(conversion_results, 1):
if result['success']:
latex = result['latex']
# Wrap in $ ... $ for inline math
if '\\begin{' in latex or latex.startswith('\\['):
wrapped = f"${latex}$"
else:
wrapped = f"${latex}$"
if args.numbered:
results.append(f"% Equation {i}\n{wrapped}")
else:
results.append(wrapped)
else:
error_msg = result.get('error', 'Unknown error')
if not args.quiet:
print(f"Warning: Failed to convert equation {i}: {error_msg}", file=sys.stderr)
results.append(f"% Error: {error_msg}")
else:
# SEQUENTIAL MODE: Original single-threaded conversion
if args.parallel and not args.quiet:
print("INFO: Sequential mode (< 5 equations, parallel not beneficial)", file=sys.stderr)
with timer.measure("converter_init"):
converter = LegacyMathMLToTeXConverter(
wrap_identifiers_in_mathrm=args.wrap_mathrm,
prefer_xlongequal=not args.no_xlongequal,
subject_mode=args.subject,
universal_delimiter_scaling=not args.no_universal_delimiters,
)
# Convert equations
results = []
for i, mathml in enumerate(mathml_equations, 1):
try:
with timer.measure("equation_conversion"):
latex = converter.convert(mathml)
# Wrap in $ ... $ for inline math (match original behavior)
# Use $$ ... $$ if equation contains \begin{...} or is display-style
if '\\begin{' in latex or latex.startswith('\\['):
wrapped = f"${latex}$"
else:
wrapped = f"${latex}$"
if args.numbered:
results.append(f"% Equation {i}\n{wrapped}")
else:
results.append(wrapped)
except Exception as e:
if not args.quiet:
print(f"Warning: Failed to convert equation {i}: {e}", file=sys.stderr)
results.append(f"% Error: {e}")
# Calculate success stats
with timer.measure("statistics_calculation"):
successful = sum(1 for r in results if not r.startswith("% Error"))
failed = len(results) - successful
success_rate = (successful / len(results) * 100) if results else 0
# Format output
with timer.measure("output_formatting"):
if args.output_format == "json":
import json
output = json.dumps({
"equations": results,
"count": len(results),
"subject_mode": args.subject,
"successful": successful,
"failed": failed,
}, indent=2)
else: # latex
# Add summary header (matching original format)
if not args.quiet:
summary = [
"",
"Conversion Results:",
f" Total equations: {len(results)}",
f" Successful: {successful}",
f" Failed: {failed}",
f" Success rate: {success_rate:.1f}%",
"",
"=" * 50,
"CONVERTED EQUATIONS:",
"=" * 50,
]
output = "\n".join(summary) + "\n" + "\n\n".join(results)
else:
output = "\n\n".join(results)
# Write output
with timer.measure("file_output"):
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
f.write(output)
if not args.quiet:
print(f"Wrote {len(results)} equation(s) to {args.output}", file=sys.stderr)
else:
print(output)
# Show timing report if requested
if args.show_timing and not args.quiet:
print("\n", file=sys.stderr)
print(timer.format_report(), file=sys.stderr)
return 0
except KeyboardInterrupt:
print("\nInterrupted", file=sys.stderr)
return 130
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
if args.verbose:
import traceback
traceback.print_exc()
return 1
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,261 @@
"""
Batch JSON converter for omml2tex with key preservation.
Handles conversion of multiple equations in JSON format while preserving
unique identifiers. Supports both sequential and parallel processing.
"""
from typing import Dict, Any
import sys
from .compat import LegacyMathMLToTeXConverter
from .parsers.omml_parser import OmmlParser
class BatchJSONConverter:
"""
Batch converter for JSON format with key preservation.
Converts a dictionary of equations (key -> XML) to LaTeX while preserving
the original keys. Supports both OMML and MathML input formats with
automatic detection.
Example:
>>> converter = BatchJSONConverter(subject_mode="chemistry")
>>> equations = {"eq1": "<math><mi>x</mi></math>", "eq2": "..."}
>>> results = converter.convert_batch(equations)
>>> print(results["eq1"]) # LaTeX output
"""
def __init__(
self,
subject_mode: str = "math",
wrap_identifiers: bool = True,
prefer_xlongequal: bool = True,
universal_delimiter_scaling: bool = True,
verbose: bool = False
):
"""
Initialize batch converter.
Args:
subject_mode: Subject mode for conversion (math, chemistry, physics, statistics)
wrap_identifiers: Whether to wrap identifiers in \\mathrm{}
prefer_xlongequal: Whether to prefer \\xlongequal for overset equations
universal_delimiter_scaling: Whether to add \\left\\right to all delimiters
verbose: Enable verbose output
"""
self.subject_mode = subject_mode
self.wrap_identifiers = wrap_identifiers
self.prefer_xlongequal = prefer_xlongequal
self.universal_delimiter_scaling = universal_delimiter_scaling
self.verbose = verbose
# Initialize converter
self.converter = LegacyMathMLToTeXConverter(
wrap_identifiers_in_mathrm=wrap_identifiers,
prefer_xlongequal=prefer_xlongequal,
subject_mode=subject_mode,
universal_delimiter_scaling=universal_delimiter_scaling,
)
# OMML parser for OMML input
self.omml_parser = OmmlParser()
def detect_input_type(self, content: str) -> str:
"""
Detect if input is OMML or MathML.
Args:
content: XML content string
Returns:
"omml" or "mathml"
"""
content_stripped = content.strip()
# Check for OMML namespace
if 'm:oMath' in content_stripped or 'm:oMathPara' in content_stripped:
return "omml"
# Check for MathML namespace
if '<math' in content_stripped or 'xmlns="http://www.w3.org/1998/Math/MathML"' in content_stripped:
return "mathml"
# Default to mathml
return "mathml"
def convert_single(self, key: str, equation_xml: str) -> Dict[str, Any]:
"""
Convert a single equation while preserving the key.
Args:
key: Unique identifier for the equation
equation_xml: OMML or MathML XML string
Returns:
Dict with 'key', 'latex', 'success', and optional 'error'
"""
try:
# Detect input type
input_type = self.detect_input_type(equation_xml)
if self.verbose:
print(f" {key}: {input_type} -> ", end="", file=sys.stderr)
# Convert OMML to MathML if needed
if input_type == "omml":
mathml_list = self.omml_parser.parse(equation_xml)
# OmmlParser.parse() returns a list - take the first element
if not mathml_list:
raise ValueError("OMML parser returned empty result")
mathml = mathml_list[0]
else:
mathml = equation_xml
# Convert MathML to LaTeX
latex = self.converter.convert(mathml)
if self.verbose:
print(f"LaTeX ({len(latex)} chars)", file=sys.stderr)
return {
"key": key,
"latex": latex,
"success": True,
}
except Exception as e:
if self.verbose:
print(f"FAILED: {e}", file=sys.stderr)
return {
"key": key,
"latex": "",
"success": False,
"error": str(e),
}
def convert_batch(self, input_dict: Dict[str, str], quiet: bool = False) -> Dict[str, str]:
"""
Convert a batch of equations from JSON dict (sequential).
Args:
input_dict: Dict mapping unique keys to equation XML strings
quiet: Suppress progress messages
Returns:
Dict mapping same keys to LaTeX strings (failed conversions get empty string)
"""
if self.verbose and not quiet:
print(f"Converting {len(input_dict)} equations...", file=sys.stderr)
results = {}
successful = 0
failed = 0
for key, equation_xml in input_dict.items():
equation_xml = equation_xml.replace("JBYSIMJBY", "~")
result = self.convert_single(key, equation_xml)
# Store LaTeX string (or empty string if failed)
results[key] = result["latex"]
if result["success"]:
successful += 1
else:
failed += 1
if not self.verbose and not quiet:
print(
f"Warning: Failed to convert {key}: {result.get('error', 'Unknown error')}",
file=sys.stderr
)
if (self.verbose or failed > 0) and not quiet:
print(f"\nConversion complete:", file=sys.stderr)
print(f" Total: {len(input_dict)}", file=sys.stderr)
print(f" Successful: {successful}", file=sys.stderr)
print(f" Failed: {failed}", file=sys.stderr)
print(f" Success rate: {successful/len(input_dict)*100:.1f}%", file=sys.stderr)
return results
def convert_batch_parallel(
self,
input_dict: Dict[str, str],
workers: int = None,
quiet: bool = False
) -> Dict[str, str]:
"""
Convert batch using parallel processing (for large batches).
Args:
input_dict: Dict mapping unique keys to equation XML strings
workers: Number of worker processes (default: CPU count, max 8)
quiet: Suppress progress messages
Returns:
Dict mapping same keys to LaTeX strings
"""
from .parallel import ParallelConverter
if self.verbose and not quiet:
print(f"Converting {len(input_dict)} equations in parallel...", file=sys.stderr)
# Create parallel converter
parallel_converter = ParallelConverter(
workers=workers,
subject_mode=self.subject_mode,
wrap_identifiers=self.wrap_identifiers,
prefer_xlongequal=self.prefer_xlongequal,
show_progress=self.verbose and not quiet,
)
# Prepare input list (keys and equations)
keys = list(input_dict.keys())
equations = list(input_dict.values())
# Detect input types and convert OMML to MathML if needed
mathml_equations = []
for equation_xml in equations:
equation_xml = equation_xml.replace("JBYSIMJBY", "~")
input_type = self.detect_input_type(equation_xml)
if input_type == "omml":
mathml_list = self.omml_parser.parse(equation_xml)
# OmmlParser.parse() returns a list - take the first element
if not mathml_list:
raise ValueError("OMML parser returned empty result")
mathml = mathml_list[0]
else:
mathml = equation_xml
mathml_equations.append(mathml)
# Convert in parallel
conversion_results = parallel_converter.convert_batch(mathml_equations, quiet=quiet)
# Build result dict
results = {}
successful = 0
failed = 0
for key, result in zip(keys, conversion_results):
if result['success']:
results[key] = result['latex']
successful += 1
else:
results[key] = ""
failed += 1
if not self.verbose and not quiet:
print(
f"Warning: Failed to convert {key}: {result.get('error', 'Unknown error')}",
file=sys.stderr
)
if (self.verbose or failed > 0) and not quiet:
print(f"\nConversion complete:", file=sys.stderr)
print(f" Total: {len(input_dict)}", file=sys.stderr)
print(f" Successful: {successful}", file=sys.stderr)
print(f" Failed: {failed}", file=sys.stderr)
print(f" Success rate: {successful/len(input_dict)*100:.1f}%", file=sys.stderr)
return results

View File

@@ -0,0 +1,12 @@
"""
Builder classes for fluent construction of LaTeX and MathML expressions.
This package contains builder classes that provide fluent interfaces for
constructing complex expressions.
"""
from .latex_builder import LaTeXBuilder
__all__ = [
'LaTeXBuilder',
]

View File

@@ -0,0 +1,320 @@
"""
LaTeX Builder for fluent construction of LaTeX expressions.
This builder provides a fluent interface for constructing complex LaTeX
expressions with proper brace balancing and formatting.
"""
from typing import List, Optional
class LaTeXBuilder:
"""
Fluent builder for constructing LaTeX expressions.
This builder provides method chaining for building complex LaTeX
expressions with automatic brace balancing and spacing optimization.
Examples:
Simple fraction:
>>> builder = LaTeXBuilder()
>>> result = builder.add_fraction("a", "b").build()
>>> result
'\\frac{a}{b}'
Superscript:
>>> builder = LaTeXBuilder()
>>> result = builder.add_superscript("x", "2").build()
>>> result
'x^{2}'
Complex expression:
>>> builder = LaTeXBuilder()
>>> result = (builder
... .add_command("sin", "x")
... .add_text(" + ")
... .add_fraction("1", "2")
... .build())
>>> result
'\\sin{x} + \\frac{1}{2}'
Nested fractions:
>>> builder = LaTeXBuilder()
>>> inner = LaTeXBuilder().add_fraction("a", "b").build()
>>> result = builder.add_fraction(inner, "c").build()
>>> result
'\\frac{\\frac{a}{b}}{c}'
"""
def __init__(self):
"""Initialize the LaTeX builder."""
self._parts: List[str] = []
def add_text(self, text: str) -> 'LaTeXBuilder':
"""
Add raw text to the LaTeX expression.
Args:
text: The text to add (no processing)
Returns:
Self for method chaining
Examples:
>>> builder = LaTeXBuilder()
>>> builder.add_text("x + y").build()
'x + y'
"""
if text:
self._parts.append(text)
return self
def add_command(self, cmd: str, arg: str) -> 'LaTeXBuilder':
"""
Add a LaTeX command with one argument: \\cmd{arg}
Args:
cmd: The command name (without backslash)
arg: The argument content
Returns:
Self for method chaining
Examples:
>>> builder = LaTeXBuilder()
>>> builder.add_command("sin", "x").build()
'\\sin{x}'
>>> builder = LaTeXBuilder()
>>> builder.add_command("text", "hello").build()
'\\text{hello}'
"""
self._parts.append(f"\\{cmd}{{{arg}}}")
return self
def add_fraction(self, numerator: str, denominator: str) -> 'LaTeXBuilder':
"""
Add a fraction: \\frac{numerator}{denominator}
Args:
numerator: The numerator expression
denominator: The denominator expression
Returns:
Self for method chaining
Examples:
>>> builder = LaTeXBuilder()
>>> builder.add_fraction("a", "b").build()
'\\frac{a}{b}'
>>> builder = LaTeXBuilder()
>>> builder.add_fraction("x + 1", "y - 2").build()
'\\frac{x + 1}{y - 2}'
"""
self._parts.append(f"\\frac{{{numerator}}}{{{denominator}}}")
return self
def add_superscript(self, base: str, superscript: str) -> 'LaTeXBuilder':
"""
Add a superscript: base^{superscript}
Args:
base: The base expression
superscript: The superscript expression
Returns:
Self for method chaining
Examples:
>>> builder = LaTeXBuilder()
>>> builder.add_superscript("x", "2").build()
'x^{2}'
>>> builder = LaTeXBuilder()
>>> builder.add_superscript("e", "i\\pi").build()
'e^{i\\pi}'
"""
self._parts.append(f"{base}^{{{superscript}}}")
return self
def add_subscript(self, base: str, subscript: str) -> 'LaTeXBuilder':
"""
Add a subscript: base_{subscript}
Args:
base: The base expression
subscript: The subscript expression
Returns:
Self for method chaining
Examples:
>>> builder = LaTeXBuilder()
>>> builder.add_subscript("x", "1").build()
'x_{1}'
>>> builder = LaTeXBuilder()
>>> builder.add_subscript("a", "i+j").build()
'a_{i+j}'
"""
self._parts.append(f"{base}_{{{subscript}}}")
return self
def add_subscript_superscript(self, base: str, subscript: str, superscript: str) -> 'LaTeXBuilder':
"""
Add both subscript and superscript: base_{subscript}^{superscript}
Args:
base: The base expression
subscript: The subscript expression
superscript: The superscript expression
Returns:
Self for method chaining
Examples:
>>> builder = LaTeXBuilder()
>>> builder.add_subscript_superscript("x", "1", "2").build()
'x_{1}^{2}'
>>> builder = LaTeXBuilder()
>>> builder.add_subscript_superscript("R", "i", "j").build()
'R_{i}^{j}'
"""
self._parts.append(f"{base}_{{{subscript}}}^{{{superscript}}}")
return self
def add_sqrt(self, content: str, root: Optional[str] = None) -> 'LaTeXBuilder':
"""
Add a square root or nth root.
Args:
content: The content under the root
root: Optional root index (None for square root)
Returns:
Self for method chaining
Examples:
>>> builder = LaTeXBuilder()
>>> builder.add_sqrt("x").build()
'\\sqrt{x}'
>>> builder = LaTeXBuilder()
>>> builder.add_sqrt("x", "3").build()
'\\sqrt[3]{x}'
"""
if root:
self._parts.append(f"\\sqrt[{root}]{{{content}}}")
else:
self._parts.append(f"\\sqrt{{{content}}}")
return self
def add_environment(self, env: str, content: str) -> 'LaTeXBuilder':
"""
Add a LaTeX environment: \\begin{env}content\\end{env}
Args:
env: The environment name
content: The environment content
Returns:
Self for method chaining
Examples:
>>> builder = LaTeXBuilder()
>>> builder.add_environment("cases", "x & x > 0 \\\\\\\\ 0 & x \\\\leq 0").build()
'\\begin{cases}x & x > 0 \\\\\\\\ 0 & x \\\\leq 0\\end{cases}'
"""
self._parts.append(f"\\begin{{{env}}}{content}\\end{{{env}}}")
return self
def add_delimiter_pair(self, left: str, content: str, right: str) -> 'LaTeXBuilder':
"""
Add delimiter pair: \\left{left}content\\right{right}
Args:
left: The left delimiter
content: The content between delimiters
right: The right delimiter
Returns:
Self for method chaining
Examples:
>>> builder = LaTeXBuilder()
>>> builder.add_delimiter_pair("(", "x + y", ")").build()
'\\left(x + y\\right)'
>>> builder = LaTeXBuilder()
>>> builder.add_delimiter_pair("\\\\{", "a, b, c", "\\\\}").build()
'\\left\\\\{a, b, c\\right\\\\}'
"""
self._parts.append(f"\\left{left}{content}\\right{right}")
return self
def add_matrix(self, rows: List[List[str]], environment: str = "matrix") -> 'LaTeXBuilder':
"""
Add a matrix environment.
Args:
rows: List of rows, each row is a list of cell contents
environment: The matrix environment (matrix, pmatrix, bmatrix, etc.)
Returns:
Self for method chaining
Examples:
>>> builder = LaTeXBuilder()
>>> rows = [["a", "b"], ["c", "d"]]
>>> builder.add_matrix(rows, "pmatrix").build()
'\\begin{pmatrix}a & b \\\\\\\\ c & d\\end{pmatrix}'
"""
matrix_content = " \\\\ ".join(
" & ".join(cell for cell in row)
for row in rows
)
self._parts.append(f"\\begin{{{environment}}}{matrix_content}\\end{{{environment}}}")
return self
def clear(self) -> 'LaTeXBuilder':
"""
Clear all parts (reset the builder).
Returns:
Self for method chaining
Examples:
>>> builder = LaTeXBuilder()
>>> builder.add_text("x").clear().add_text("y").build()
'y'
"""
self._parts.clear()
return self
def build(self) -> str:
"""
Build the final LaTeX string.
Returns:
The constructed LaTeX expression
Examples:
>>> builder = LaTeXBuilder()
>>> builder.add_fraction("a", "b").build()
'\\frac{a}{b}'
>>> builder = LaTeXBuilder()
>>> builder.add_text("x").add_text(" + ").add_text("y").build()
'x + y'
"""
return "".join(self._parts)
def __str__(self) -> str:
"""String representation (same as build())."""
return self.build()
def __repr__(self) -> str:
"""Debug representation."""
return f"LaTeXBuilder(parts={len(self._parts)})"

View File

@@ -0,0 +1,11 @@
"""
Compatibility layer for backward compatibility.
This package provides adapters and compatibility shims for the original API.
"""
from .legacy_adapter import LegacyMathMLToTeXConverter
__all__ = [
'LegacyMathMLToTeXConverter',
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,333 @@
"""
Brace marker resolver for context-aware delimiter pairing.
This module converts __LBRACE__ and __RBRACE__ markers to proper LaTeX
delimiters based on pairing context and existing \\left...\\right delimiters.
The markers distinguish source braces (from DOCX) from generated braces
(in LaTeX commands like \\mathrm{...}).
Author: omml2tex project
Date: 2026-01-14
"""
import re
from typing import List, Dict, Optional
from ..utils.debug import debug_print
class BraceMarkerResolver:
"""
Resolves __LBRACE__ and __RBRACE__ markers to LaTeX delimiters.
Pairing rules:
1. __LBRACE__...__RBRACE__ → \\left\\{...\\right\\}
2. __LBRACE__...\\right. → \\left\\{...\\right.
3. \\left....__RBRACE__ → \\left....\\right\\}
4. Standalone __LBRACE__ → \\{
5. Standalone __RBRACE__ → \\}
"""
def __init__(self, debug: bool = False):
"""
Initialize the brace marker resolver.
Args:
debug: Enable debug output to stderr
"""
self.debug = debug
def resolve(self, latex: str) -> str:
"""
Convert __LBRACE__ and __RBRACE__ markers to LaTeX delimiters.
Args:
latex: LaTeX string with markers
Returns:
LaTeX string with markers resolved to proper delimiters
Examples:
"__LBRACE__ x __RBRACE__" → "\\left\\{ x \\right\\}"
"__LBRACE__ x \\right." → "\\left\\{ x \\right."
"x __LBRACE__" → "x \\{"
"""
if not latex or '__LBRACE__' not in latex and '__RBRACE__' not in latex:
return latex
if self.debug:
debug_print(f"[BRACE RESOLVE] Input: {latex[:100]}...")
# Step 1: Find all markers and existing \\left...\\right delimiters
markers = self._find_markers(latex)
existing_delims = self._find_existing_delimiters(latex)
if not markers:
if self.debug:
debug_print("[BRACE RESOLVE] No markers found")
return latex
if self.debug:
debug_print(f"[BRACE RESOLVE] Found {len(markers)} markers")
# Step 2: Pair markers using stack-based algorithm
pairs = self._pair_markers(markers, existing_delims, latex)
if self.debug:
debug_print(f"[BRACE RESOLVE] Created {len(pairs)} pairs")
# Step 3: Build replacements
replacements = self._build_replacements(pairs)
# Step 4: Apply replacements in reverse order
result = self._apply_replacements(latex, replacements)
if self.debug:
debug_print(f"[BRACE RESOLVE] Output: {result[:100]}...")
return result
def _find_markers(self, latex: str) -> List[Dict]:
"""
Find all __LBRACE__ and __RBRACE__ markers.
Args:
latex: LaTeX string
Returns:
List of marker dicts with 'text', 'pos', 'end' keys
"""
markers = []
# Find __LBRACE__
for match in re.finditer(r'__LBRACE__', latex):
markers.append({
'kind': 'marker',
'text': '__LBRACE__',
'pos': match.start(),
'end': match.end(),
})
# Find __RBRACE__
for match in re.finditer(r'__RBRACE__', latex):
markers.append({
'kind': 'marker',
'text': '__RBRACE__',
'pos': match.start(),
'end': match.end(),
})
# Sort by position
markers.sort(key=lambda x: x['pos'])
return markers
def _find_existing_delimiters(self, latex: str) -> List[Dict]:
"""
Find existing \\left and \\right delimiters.
Args:
latex: LaTeX string
Returns:
List of delimiter dicts with 'text', 'pos', 'end', 'type' keys
"""
delimiters = []
# Pattern for \\left followed by delimiter
# \\left(, \\left[, \\left\\{, \\left\\langle, \\left., etc.
left_pattern = r'\\left(?:\\{|\\langle|\\lfloor|\\lceil|\\||[(\[|.])'
for match in re.finditer(left_pattern, latex):
delimiters.append({
'kind': 'existing',
'text': match.group(),
'pos': match.start(),
'end': match.end(),
'type': 'left',
})
# Pattern for \\right followed by delimiter
right_pattern = r'\\right(?:\\}|\\rangle|\\rfloor|\\rceil|\\||[)\]|.])'
for match in re.finditer(right_pattern, latex):
delimiters.append({
'kind': 'existing',
'text': match.group(),
'pos': match.start(),
'end': match.end(),
'type': 'right',
})
return delimiters
def _pair_markers(self, markers: List[Dict], existing_delims: List[Dict], latex: str) -> List[Dict]:
"""
Pair markers using stack-based algorithm.
Args:
markers: List of marker dicts
existing_delims: List of existing delimiter dicts
latex: Original LaTeX string (for context)
Returns:
List of pair dicts describing how to resolve each marker
"""
stack = []
pairs = []
# Combine and sort by position
all_items = sorted(markers + existing_delims, key=lambda x: x['pos'])
for item in all_items:
if item['kind'] == 'marker' and item['text'] == '__LBRACE__':
# Push opening marker
stack.append({'type': 'marker_open', 'item': item})
elif item['kind'] == 'marker' and item['text'] == '__RBRACE__':
# Try to pair with stack top
if stack:
top = stack[-1]
if top['type'] == 'marker_open':
# Pair: __LBRACE__...__RBRACE__
opener = stack.pop()
pairs.append({
'type': 'symmetric',
'left_pos': opener['item']['pos'],
'left_end': opener['item']['end'],
'right_pos': item['pos'],
'right_end': item['end'],
})
elif top['type'] == 'existing_left':
# Pair: \\left....__RBRACE__
stack.pop()
pairs.append({
'type': 'right_with_existing_left',
'right_pos': item['pos'],
'right_end': item['end'],
})
else:
# Standalone closing marker
pairs.append({
'type': 'standalone',
'marker': '__RBRACE__',
'pos': item['pos'],
'end': item['end'],
})
else:
# Standalone closing marker
pairs.append({
'type': 'standalone',
'marker': '__RBRACE__',
'pos': item['pos'],
'end': item['end'],
})
elif item['kind'] == 'existing' and item['type'] == 'left':
# Push existing \\left delimiter
stack.append({'type': 'existing_left', 'item': item})
elif item['kind'] == 'existing' and item['type'] == 'right':
# Try to pair with marker on stack
if stack:
top = stack[-1]
if top['type'] == 'marker_open':
# Pair: __LBRACE__...\\right
opener = stack.pop()
pairs.append({
'type': 'left_with_existing_right',
'left_pos': opener['item']['pos'],
'left_end': opener['item']['end'],
})
elif top['type'] == 'existing_left':
# Paired: \\left...\\right (no action needed)
stack.pop()
# Handle remaining items on stack (standalone opening markers)
for item in stack:
if item['type'] == 'marker_open':
pairs.append({
'type': 'standalone',
'marker': '__LBRACE__',
'pos': item['item']['pos'],
'end': item['item']['end'],
})
return pairs
def _build_replacements(self, pairs: List[Dict]) -> List[Dict]:
"""
Build replacement list from pairs.
Args:
pairs: List of pair dicts
Returns:
List of replacement dicts with 'pos', 'end', 'old', 'new' keys
"""
replacements = []
for pair in pairs:
if pair['type'] == 'symmetric':
# __LBRACE__...__RBRACE__ → \\left\\{...\\right\\}
replacements.append({
'pos': pair['left_pos'],
'end': pair['left_end'],
'old': '__LBRACE__',
'new': '\\left\\{',
})
replacements.append({
'pos': pair['right_pos'],
'end': pair['right_end'],
'old': '__RBRACE__',
'new': '\\right\\}',
})
elif pair['type'] == 'left_with_existing_right':
# __LBRACE__...\\right → \\left\\{...\\right
replacements.append({
'pos': pair['left_pos'],
'end': pair['left_end'],
'old': '__LBRACE__',
'new': '\\left\\{',
})
elif pair['type'] == 'right_with_existing_left':
# \\left....__RBRACE__ → \\left....\\right\\}
replacements.append({
'pos': pair['right_pos'],
'end': pair['right_end'],
'old': '__RBRACE__',
'new': '\\right\\}',
})
elif pair['type'] == 'standalone':
# Standalone marker → escaped brace
replacements.append({
'pos': pair['pos'],
'end': pair['end'],
'old': pair['marker'],
'new': '\\{' if pair['marker'] == '__LBRACE__' else '\\}',
})
# Sort by position in reverse order for safe replacement
replacements.sort(key=lambda x: x['pos'], reverse=True)
return replacements
def _apply_replacements(self, latex: str, replacements: List[Dict]) -> str:
"""
Apply replacements to LaTeX string.
Args:
latex: Original LaTeX string
replacements: List of replacement dicts (must be sorted by pos descending)
Returns:
LaTeX string with replacements applied
"""
result = latex
for rep in replacements:
# Replace from back to front to preserve positions
result = result[:rep['pos']] + rep['new'] + result[rep['end']:]
return result

View File

@@ -0,0 +1,846 @@
"""
Universal delimiter pairing post-processor.
This module provides functionality to add \\left and \\right commands to ALL
delimiter pairs in LaTeX output, ensuring uniform scaling regardless of content.
The processor uses a smart stack-based pairing algorithm that:
1. Handles symmetric pairs: (), [], {}, ||
2. Handles mismatched pairs for interval notation: [), (], etc.
3. Skips delimiters already in \\left...\\right or cases environment
Author: omml2tex project
Date: 2025-11-12
"""
import re
import sys
from typing import List, Tuple, Dict, Optional
from ..utils.debug import debug_print
class DelimiterPairingProcessor:
"""
Post-processor that adds \\left and \\right to all delimiter pairs.
This ensures uniform scaling of all parentheses, brackets, and braces,
including interval notation with mismatched delimiters.
Rules:
1. Add \\left\\right to symmetric pairs: (), [], {}, ||, ⟨⟩
2. Add \\left\\right to mismatched pairs (intervals): [), (], etc.
3. Add \\left\\right to quantum mechanics notation: ⟨φ|, |ψ⟩, ⟨φ|ψ⟩
4. Skip delimiters in: \\left...\\right, \\begin{array}...\\end{array}
"""
# Delimiter mappings
# BUG-010 FIX: Added '.' to OPENING_DELIMS for invisible left delimiter (\left.)
OPENING_DELIMS = {'(', '[', '{', '|', '\\langle', '\\{', '\\|', '\\lfloor', '\\lceil', '.'}
CLOSING_DELIMS = {')', ']', '}', '|', '\\rangle', '\\}', '\\|', '.', '\\rfloor', '\\rceil'} # '.' for invisible delimiter
ALL_DELIMS = OPENING_DELIMS | CLOSING_DELIMS
# Compatible delimiter pairings (for interval notation and quantum mechanics)
# Any opening can pair with any closing
COMPATIBLE_PAIRS = {
'(': {')', ']', '}', '\\rangle', '\\}', '.'}, # ( can close with ), ], }, ⟩, or . (invisible)
'[': {')', ']', '}', '\\rangle', '\\}', '.'}, # [ can close with ), ], }, ⟩, or . (invisible)
'{': {')', ']', '}', '\\rangle', '\\}', '.'}, # { can close with ), ], }, ⟩, or . (invisible)
'\\{': {')', ']', '}', '\\rangle', '\\}', '.'}, # \{ can close with ), ], }, \}, or . (invisible)
'|': {'|', '\\rangle', '.'}, # | can pair with |, ⟩, or . (invisible)
'\\|': {'\\|', '.'}, # \| pairs with \| or . (invisible)
'\\langle': {')', ']', '}', '\\rangle', '|', '\\}', '.'}, # ⟨ can pair with ⟩, |, or . (invisible)
'\\lfloor': {'\\rfloor', '.'}, # ⌊ pairs with ⌋ or . (invisible)
'\\lceil': {'\\rceil', '.'}, # ⌈ pairs with ⌉ or . (invisible)
# BUG-010 FIX: '.' (invisible delimiter) can pair with any closing delimiter
'.': {')', ']', '}', '\\rangle', '\\}', '\\|', '|', '\\rfloor', '\\rceil', '.'},
}
def __init__(self, debug: bool = False):
"""
Initialize the delimiter pairing processor.
Args:
debug: Enable debug output to stderr
"""
self.debug = debug
def process(self, latex: str) -> str:
"""
Add \\left and \\right to all delimiter pairs in LaTeX.
This is a post-processing step that runs AFTER the visitor pattern,
ensuring ALL delimiters get scaled, including interval notation.
Args:
latex: LaTeX string from conversion
Returns:
LaTeX string with \\left...\\right added to all delimiter pairs
Examples:
"(x + y)" → "\\left(x + y\\right)"
"[a, b]" → "\\left[a, b\\right]"
"[0, 1)" → "\\left[0, 1\\right)" (interval notation)
"(a, b]" → "\\left(a, b\\right]" (interval notation)
"\\langle \\phi | \\psi \\rangle" → "\\left\\langle \\phi | \\psi \\right\\rangle" (quantum mechanics)
"|\\psi\\rangle" → "\\left|\\psi\\right\\rangle" (ket notation)
"\\left\\{ \\begin{array}...\\right." → unchanged (skip)
"""
if not latex or not latex.strip():
return latex
# Step 1: Find all plain delimiters (skip those in protected contexts)
delimiters = self._find_plain_delimiters(latex)
if not delimiters:
if self.debug:
debug_print("[DELIM PAIR] No plain delimiters found")
return latex
if self.debug:
debug_print(f"[DELIM PAIR] Found {len(delimiters)} plain delimiters")
# Step 2: Pair delimiters using smart algorithm (handles intervals)
# Also identify pipes that should be \mid based on context
pairs, skip_indices = self._pair_delimiters(delimiters, latex)
if self.debug:
debug_print(f"[DELIM PAIR] Paired {len(pairs)} delimiter pairs")
for opening, closing in pairs:
debug_print(f" - {opening['char']} at {opening['pos']} ... {closing['char']} at {closing['pos']}")
if skip_indices:
debug_print(f"[DELIM PAIR] Skipped {len(skip_indices)} pipes (will convert to \\mid)")
# Step 3: Build replacement list (includes both paired delimiters and skipped pipes)
replacements = self._build_replacements(pairs, delimiters, skip_indices)
# Step 4: Apply replacements in reverse order
result = self._apply_replacements(latex, replacements)
# Step 5: Cleanup - Remove any remaining __DELIM__ markers
# These are delimiters that couldn't be paired (unknown types, unmatched, etc.)
# This is a fail-safe to ensure no markers leak into the final output
if '__DELIM__' in result:
if self.debug:
debug_print("[DELIM PAIR] Cleaning up remaining __DELIM__ markers")
result = result.replace('__DELIM__', '')
return result
def _find_plain_delimiters(self, latex: str) -> List[Dict]:
"""
Find all delimiters marked with __DELIM__ markers.
Phase 3 Fix: Only delimiters with __DELIM__ markers are paired.
This distinguishes fence delimiters (with fence="true" in MathML) from
plain operators that happen to use the same character (e.g., | in P(A|B)).
Args:
latex: LaTeX string with __DELIM__ markers
Returns:
List of delimiter dictionaries with 'char', 'pos', 'end', 'marker_start', 'marker_end' keys
Examples:
Input: "P __DELIM__(__DELIM__ A | B __DELIM__)__DELIM__"
Output: [
{'char': '(', 'pos': 11, 'end': 12, 'marker_start': 2, 'marker_end': 22},
{'char': ')', 'pos': 32, 'end': 33, 'marker_start': 23, 'marker_end': 43}
]
Note: Plain '|' at position 17 is NOT in the list (no __DELIM__ markers)
"""
delimiters = []
marker = '__DELIM__'
marker_len = len(marker)
# Scan for __DELIM__ markers
i = 0
while i < len(latex):
if latex[i:i+marker_len] == marker:
# Found opening marker
# FIX BUG-007: Check if there's a leading backslash before __DELIM__
# Pattern: \__DELIM__\{__DELIM__ should be treated as a unit
marker_start = i
if i > 0 and latex[i-1] == '\\':
# Include the leading backslash in the marker range
marker_start = i - 1
i += marker_len
# Extract delimiter character(s) between markers
delim_start = i
# Look for closing marker
closing_marker_pos = latex.find(marker, i)
if closing_marker_pos == -1:
# No closing marker - skip this opening marker
continue
# Extract delimiter between markers
delim_str = latex[delim_start:closing_marker_pos]
# Handle STRETCHY:: prefix (from MathStrategy)
if delim_str.startswith('STRETCHY::'):
delim_str = delim_str[10:] # Strip prefix
marker_end = closing_marker_pos + marker_len
# Skip empty delimiters
if not delim_str.strip():
i = marker_end
continue
# Add delimiter to list
delimiters.append({
'char': delim_str,
'pos': delim_start,
'end': closing_marker_pos,
'marker_start': marker_start,
'marker_end': marker_end,
'escaped': delim_str.startswith('\\'),
})
# Move past closing marker
i = marker_end
else:
i += 1
return delimiters
def _find_brace_argument_regions(self, latex: str) -> List[Tuple[int, int]]:
"""
Find regions where braces are used as command arguments.
This includes:
- Command arguments: \\frac{num}{denom}, \\sqrt{x}, etc.
- Subscripts: x_{sub}
- Superscripts: x^{sup}
Args:
latex: LaTeX string
Returns:
List of (start, end) tuples for brace regions
"""
regions = []
# Simple approach: Find all {...} pairs
# These are almost always command arguments, not delimiters
stack = []
for i, char in enumerate(latex):
if char == '{' and (i == 0 or latex[i-1] != '\\'):
stack.append(i)
elif char == '}' and (i == 0 or latex[i-1] != '\\'):
if stack:
start = stack.pop()
regions.append((start, i+1))
return regions
def _find_protected_regions(self, latex: str) -> List[Tuple[int, int]]:
"""
Find protected regions that should not have delimiters modified.
Protected regions:
1. \\left...\\right pairs
2. \\begin{array}...\\end{array} (cases environment)
Args:
latex: LaTeX string
Returns:
List of (start, end) position tuples for protected regions
"""
protected = []
# Pattern 1: Find \\left...\\right pairs
# Match \\left followed by delimiter, content, \\right followed by delimiter
# Include \langle and \rangle as possible delimiters
left_pattern = r'\\left(?:[(\[{\|]|\\langle)'
right_pattern = r'\\right(?:[)\]}\|.]|\\rangle)'
# Find all \\left positions
left_positions = []
for match in re.finditer(left_pattern, latex):
left_positions.append(match.start())
# Find all \\right positions
right_positions = []
for match in re.finditer(right_pattern, latex):
right_positions.append(match.end())
# Pair them (simple heuristic: match nearest \\right after each \\left)
for left_start in left_positions:
# Find the nearest \\right after this \\left
for right_end in right_positions:
if right_end > left_start:
protected.append((left_start, right_end))
break
# Pattern 2: Find \\begin{array}...\\end{array}
array_pattern = r'\\begin\{array\}.*?\\end\{array\}'
for match in re.finditer(array_pattern, latex, re.DOTALL):
protected.append((match.start(), match.end()))
return protected
def _is_in_protected_region(self, pos: int, protected_regions: List[Tuple[int, int]]) -> bool:
"""Check if position is within any protected region."""
for start, end in protected_regions:
if start <= pos < end:
return True
return False
def _pair_delimiters(self, delimiters: List[Dict], latex: str) -> Tuple[List[Tuple[Dict, Dict]], set]:
r"""
Pair opening and closing delimiters using smart algorithm.
Algorithm supports:
1. Symmetric pairs: (), [], {}, ||, ⟨⟩
2. Mismatched pairs for intervals: [), (], etc.
3. Quantum mechanics: |ψ⟩ (ket), ⟨φ| (bra), ⟨φ|ψ⟩ (inner product)
4. Conditional probability: P(A|B) - pipe inside parentheses becomes \mid
5. Set notation: {x|x>0} - pipe inside braces becomes \mid
Key insight: For pipes, use proximity-based pairing (pair closest pipes first)
to correctly handle cases like {x | x + |x-2| < 0} where the inner pipes
should be paired before the outer pipe.
Args:
delimiters: List of delimiter dictionaries
latex: Full LaTeX string for context checking
Returns:
Tuple of (pairs, skip_indices) where:
- pairs: List of (opening, closing) delimiter pairs
- skip_indices: Set of indices for pipes that should become \mid
"""
pairs = []
# Phase 1: Identify operator pipes (set separators, conditional probability) and pair remaining pipes
# Operator pipes should NOT be paired - they become \mid
# Examples:
# - Set notation: {x | condition} - first pipe is separator
# - Conditional probability: P(A|B) - pipe is operator
pipe_indices = [i for i, d in enumerate(delimiters) if d['char'] == '|']
operator_pipe_indices = set() # Pipes that should become \mid
paired_pipe_indices = set()
if pipe_indices:
# First, identify pipes that are operators (not delimiters)
for pipe_idx in pipe_indices:
pipe_delim = delimiters[pipe_idx]
pipe_start = pipe_delim['marker_start']
# Look backwards in LaTeX string
search_start = max(0, pipe_start - 50)
latex_before = latex[search_start:pipe_start]
# Check 1: Set notation separator {x | ...}
# Pattern: brace shortly before pipe, no other pipes between them
# NOTE: Check for both raw braces and __LBRACE__ markers
if '{' in latex_before or '\\{' in latex_before or '__LBRACE__' in latex_before:
last_brace_pos = max(
latex_before.rfind('{'),
latex_before.rfind('\\{'),
latex_before.rfind('__LBRACE__')
)
content_between = latex_before[last_brace_pos:]
if len(content_between) < 30:
# Check no other pipes between brace and this pipe
has_pipes_between = any(
delimiters[other_idx]['marker_start'] > search_start + last_brace_pos and
delimiters[other_idx]['marker_start'] < pipe_start
for other_idx in pipe_indices if other_idx != pipe_idx
)
if not has_pipes_between:
# Check if there's actual content between brace and pipe
# Pattern: {x | condition} → content exists → separator
# Pattern: {|x|} → no content → absolute value
# Strip the brace marker itself to get content after it
content_after_brace = content_between
for marker in ('__LBRACE__', '\\{', '{'):
if content_after_brace.startswith(marker):
content_after_brace = content_after_brace[len(marker):]
break
content_after_brace = content_after_brace.strip()
if content_after_brace:
# Has content between brace and pipe → set notation separator
operator_pipe_indices.add(pipe_idx)
continue # Skip other checks
# Check 2: Conditional probability operator P(A|B)
# Pattern: parenthesis shortly before pipe
if '(' in latex_before or '\\left(' in latex_before:
last_paren_pos = max(
latex_before.rfind('('),
latex_before.rfind('\\left(')
)
content_between = latex_before[last_paren_pos:]
if len(content_between) < 50:
# Check no other pipes between parenthesis and this pipe
has_pipes_between = any(
delimiters[other_idx]['marker_start'] > search_start + last_paren_pos and
delimiters[other_idx]['marker_start'] < pipe_start
for other_idx in pipe_indices if other_idx != pipe_idx
)
if not has_pipes_between:
# This is likely a conditional probability operator
operator_pipe_indices.add(pipe_idx)
continue # Skip other checks
# Remove operator pipes from pairing consideration
pipe_indices_for_pairing = [idx for idx in pipe_indices if idx not in operator_pipe_indices]
# Now pair the remaining pipes using proximity-based algorithm
# This pairs absolute values like |x-2| correctly
while len(pipe_indices_for_pairing) >= 2:
# Find the pair of pipes with minimum distance
min_distance = float('inf')
best_pair = None
for i in range(len(pipe_indices_for_pairing)):
for j in range(i + 1, len(pipe_indices_for_pairing)):
idx1, idx2 = pipe_indices_for_pairing[i], pipe_indices_for_pairing[j]
distance = idx2 - idx1
if distance < min_distance:
min_distance = distance
best_pair = (i, j, idx1, idx2)
if best_pair:
i, j, idx1, idx2 = best_pair
# Pair these pipes
pairs.append((delimiters[idx1], delimiters[idx2]))
paired_pipe_indices.add(idx1)
paired_pipe_indices.add(idx2)
# Remove from consideration
pipe_indices_for_pairing.pop(j) # Remove j first (higher index)
pipe_indices_for_pairing.pop(i) # Then remove i
else:
break
# Phase 2: Pair other delimiters using stack-based algorithm
stack = []
for idx, delim in enumerate(delimiters):
char = delim['char']
# Skip pipes that were already paired in Phase 1
if char == '|' and idx in paired_pipe_indices:
continue
# Skip pipes entirely - they'll be handled in Phase 3
if char == '|':
continue
# Special handling for ambiguous delimiters that can be both opening AND closing
# Examples: '.' (invisible delimiter), '|' (pipe)
# Strategy: If there's a compatible opening on the stack, treat as closing.
# Otherwise, treat as opening.
is_ambiguous = char in self.OPENING_DELIMS and char in self.CLOSING_DELIMS
if is_ambiguous:
# Check if there's a compatible opening on the stack
paired = False
if stack:
for i in range(len(stack) - 1, -1, -1):
opening = stack[i]
opening_char = opening['char']
# Check if this ambiguous delimiter can close this opening
if char in self.COMPATIBLE_PAIRS.get(opening_char, set()):
# Found a compatible match - treat as CLOSING
paired_opening = stack.pop(i)
pairs.append((paired_opening, delim))
paired = True
break
if not paired:
# No compatible opening found - treat as OPENING
stack.append(delim)
elif char in self.OPENING_DELIMS:
# Unambiguous opening delimiter - push to stack
stack.append(delim)
elif char in self.CLOSING_DELIMS:
# Unambiguous closing delimiter - find compatible opening
if stack:
# Strategy: Find the MOST RECENT opening that's compatible
# This handles both symmetric pairs and interval notation
paired = False
for i in range(len(stack) - 1, -1, -1):
opening = stack[i]
opening_char = opening['char']
# Check if this opening is compatible with closing
if char in self.COMPATIBLE_PAIRS.get(opening_char, set()):
# Found a compatible match!
paired_opening = stack.pop(i)
pairs.append((paired_opening, delim))
paired = True
break
if not paired and self.debug:
debug_print(f"[DELIM PAIR] Warning: Unpaired closing delimiter '{char}' at {delim['pos']}")
else:
# Stack is empty - unpaired closing delimiter
if self.debug:
debug_print(f"[DELIM PAIR] Warning: Unpaired closing delimiter '{char}' at {delim['pos']}")
# Phase 3: Post-process to identify UNPAIRED pipes that should be \mid operators
# Pattern 1: \langle ... | ... \rangle → quantum mechanics inner product (unpaired pipe)
# Pattern 2: ( ... | ... ) → conditional probability P(A|B) (unpaired pipe)
# Pattern 3: [ ... | ... ] → set builder notation [x|x>0] (unpaired pipe)
# Pattern 4: { ... | ... } → set notation {x|x>0} (unpaired pipe)
# We mark these UNPAIRED pipes to convert to \mid
skip_indices = set()
for i, delim in enumerate(delimiters):
if delim['char'] == '|' and i not in paired_pipe_indices:
# This pipe is unpaired - check if it's inside other delimiters
# Check if there's a \langle before and \rangle after this pipe
has_langle_before = False
has_rangle_after = False
# Check if there's a ( or [ before and ) or ] after this pipe
has_paren_or_bracket_before = False
has_paren_or_bracket_after = False
# Check if there's a { before and } after this pipe in the LaTeX string
# This handles set notation where braces might not have fence="true"
has_brace_before = False
has_brace_after = False
# Check in delimiters list first
for j in range(i-1, -1, -1):
if delimiters[j]['char'] == '\\langle':
has_langle_before = True
break
elif delimiters[j]['char'] in ('(', '['):
has_paren_or_bracket_before = True
break
elif delimiters[j]['char'] in ('{', '\\{'):
has_brace_before = True
break
elif delimiters[j]['char'] in (')', ']', '}', '\\}'): # Other closing delims
break # Stop searching
for j in range(i+1, len(delimiters)):
if delimiters[j]['char'] == '\\rangle':
has_rangle_after = True
break
elif delimiters[j]['char'] in (')', ']'):
has_paren_or_bracket_after = True
break
elif delimiters[j]['char'] in ('}', '\\}'):
has_brace_after = True
break
elif delimiters[j]['char'] in ('(', '[', '{', '\\{', '\\langle'): # Other opening delims
break # Stop searching
# Also check the LaTeX string for plain braces (not in delimiters list)
# Look backwards from the pipe position for a plain { or \{ or \left\{
if not has_brace_before:
pipe_start = delim['marker_start']
# Search backwards in LaTeX for { or \{ or \left\{
for k in range(pipe_start - 1, max(0, pipe_start - 200), -1):
# Check for \left\{
if k >= 6 and latex[k-6:k+1] == '\\left\\{':
has_brace_before = True
break
# Check for plain { (not \{)
elif latex[k] == '{' and (k == 0 or latex[k-1] != '\\'):
has_brace_before = True
break
# Check for \{ (escaped brace)
elif k >= 1 and latex[k-1:k+1] == '\\{':
has_brace_before = True
break
# Stop at other closing delimiters
elif latex[k] in (')', ']') and (k == 0 or latex[k-1] != '\\'):
break
# Stop at \right) or \right]
elif k >= 7 and latex[k-7:k+1] in ('\\right)', '\\right]'):
break
# Look forwards from the pipe position for a plain } or \} or \right\}
if not has_brace_after:
pipe_end = delim['marker_end']
# Search forwards in LaTeX for } or \} or \right\}
for k in range(pipe_end, min(len(latex), pipe_end + 200)):
# Check for \right\}
if k + 8 <= len(latex) and latex[k:k+8] == '\\right\\}':
has_brace_after = True
break
# Check for plain } (not \})
elif latex[k] == '}' and (k == 0 or latex[k-1] != '\\'):
has_brace_after = True
break
# Check for \} (escaped brace)
elif k + 2 <= len(latex) and latex[k:k+2] == '\\}':
has_brace_after = True
break
# Stop at other opening delimiters
elif latex[k] in ('(', '[') and (k == 0 or latex[k-1] != '\\'):
break
# Stop at \left( or \left[
elif k + 6 <= len(latex) and latex[k:k+6] in ('\\left(', '\\left['):
break
# If unpaired pipe is between \langle and \rangle, it's an inner product operator
if has_langle_before and has_rangle_after:
skip_indices.add(i)
# If unpaired pipe is between ( and ) or [ and ], it's a conditional probability or set builder operator
# This handles P(A|B), P(A|B,C), [x|x>0], etc.
elif has_paren_or_bracket_before and has_paren_or_bracket_after:
skip_indices.add(i)
# If unpaired pipe is between { and }, it's set notation {x|x>0}
elif has_brace_before and has_brace_after:
skip_indices.add(i)
# Check if there's a \langle before and \rangle after this pipe
has_langle_before = False
has_rangle_after = False
# Check if there's a ( or [ before and ) or ] after this pipe
has_paren_or_bracket_before = False
has_paren_or_bracket_after = False
# NEW: Check if there's a { before and } after this pipe in the LaTeX string
# This handles set notation where braces might not have fence="true"
has_brace_before = False
has_brace_after = False
# Check in delimiters list first
for j in range(i-1, -1, -1):
if delimiters[j]['char'] == '\\langle':
has_langle_before = True
break
elif delimiters[j]['char'] in ('(', '['):
has_paren_or_bracket_before = True
break
elif delimiters[j]['char'] in ('{', '\\{'):
has_brace_before = True
break
elif delimiters[j]['char'] in (')', ']', '}', '\\}'): # Other closing delims
break # Stop searching
for j in range(i+1, len(delimiters)):
if delimiters[j]['char'] == '\\rangle':
has_rangle_after = True
break
elif delimiters[j]['char'] in (')', ']'):
has_paren_or_bracket_after = True
break
elif delimiters[j]['char'] in ('}', '\\}'):
has_brace_after = True
break
elif delimiters[j]['char'] in ('(', '[', '{', '\\{', '\\langle'): # Other opening delims
break # Stop searching
# NEW: Also check the LaTeX string for plain braces (not in delimiters list)
# Look backwards from the pipe position for a plain { or \{ or \left\{
if not has_brace_before:
pipe_start = delim['marker_start']
# Search backwards in LaTeX for { or \{ or \left\{
for k in range(pipe_start - 1, max(0, pipe_start - 200), -1):
# Check for \left\{
if k >= 6 and latex[k-6:k+1] == '\\left\\{':
has_brace_before = True
break
# Check for plain { (not \{)
elif latex[k] == '{' and (k == 0 or latex[k-1] != '\\'):
has_brace_before = True
break
# Check for \{ (escaped brace)
elif k >= 1 and latex[k-1:k+1] == '\\{':
has_brace_before = True
break
# Stop at other closing delimiters
elif latex[k] in (')', ']') and (k == 0 or latex[k-1] != '\\'):
break
# Stop at \right) or \right]
elif k >= 7 and latex[k-7:k+1] in ('\\right)', '\\right]'):
break
# Look forwards from the pipe position for a plain } or \} or \right\}
if not has_brace_after:
pipe_end = delim['marker_end']
# Search forwards in LaTeX for } or \} or \right\}
for k in range(pipe_end, min(len(latex), pipe_end + 200)):
# Check for \right\}
if k + 8 <= len(latex) and latex[k:k+8] == '\\right\\}':
has_brace_after = True
break
# Check for plain } (not \})
elif latex[k] == '}' and (k == 0 or latex[k-1] != '\\'):
has_brace_after = True
break
# Check for \} (escaped brace)
elif k + 2 <= len(latex) and latex[k:k+2] == '\\}':
has_brace_after = True
break
# Stop at other opening delimiters
elif latex[k] in ('(', '[') and (k == 0 or latex[k-1] != '\\'):
break
# Stop at \left( or \left[
elif k + 6 <= len(latex) and latex[k:k+6] in ('\\left(', '\\left['):
break
# If pipe is between \langle and \rangle, it's an inner product operator
if has_langle_before and has_rangle_after:
skip_indices.add(i)
# If pipe is between ( and ) or [ and ], it's a conditional probability or set builder operator
# This handles P(A|B), P(A|B,C), [x|x>0], etc.
elif has_paren_or_bracket_before and has_paren_or_bracket_after:
skip_indices.add(i)
# If unpaired pipe is between { and }, it's set notation {x|x>0}
elif has_brace_before and has_brace_after:
skip_indices.add(i)
# Report unpaired opening delimiters
if stack and self.debug:
for unpaired in stack:
debug_print(f"[DELIM PAIR] Warning: Unpaired opening delimiter '{unpaired['char']}' at {unpaired['pos']}")
# Merge operator pipes into skip_indices so they get converted to \mid
skip_indices.update(operator_pipe_indices)
return pairs, skip_indices
def _build_replacements(self, pairs: List[Tuple[Dict, Dict]], delimiters: List[Dict], skip_indices: set) -> List[Tuple[int, int, str]]:
r"""
Build replacement list for paired delimiters and skipped pipes.
Phase 3 Fix: Replace __DELIM__char__DELIM__ with \left/\right commands.
Also converts skipped pipes (conditional probability) to \mid.
Args:
pairs: List of (opening, closing) delimiter pairs with marker positions
delimiters: List of all delimiter dictionaries
skip_indices: Set of indices for pipes that should become \mid
Returns:
List of (start_pos, end_pos, replacement_text) tuples
Examples:
Input pair: {'char': '(', 'marker_start': 2, 'marker_end': 22}
Output: (2, 22, '\\left(') # Replace __DELIM__(__DELIM__ with \left(
Input skipped pipe: {'char': '|', 'marker_start': 10, 'marker_end': 30}
Output: (10, 30, ' \\mid ') # Replace __DELIM__|__DELIM__ with \mid
"""
replacements = []
# Handle paired delimiters
for opening, closing in pairs:
opening_char = opening['char']
closing_char = closing['char']
# Build \\left command for opening delimiter
left_cmd = self._get_left_command(opening_char)
right_cmd = self._get_right_command(closing_char)
if left_cmd and right_cmd:
# Replace entire marker range: __DELIM__char__DELIM__ → \left/\right command
replacements.append((opening['marker_start'], opening['marker_end'], left_cmd))
replacements.append((closing['marker_start'], closing['marker_end'], right_cmd))
# Handle skipped pipes (convert to \mid)
for idx in skip_indices:
delim = delimiters[idx]
# Replace __DELIM__|__DELIM__ with \mid (with spaces for proper LaTeX spacing)
replacements.append((delim['marker_start'], delim['marker_end'], ' \\mid '))
return replacements
def _get_left_command(self, char: str) -> Optional[str]:
"""Get \\left command for opening delimiter."""
left_map = {
'(': '\\left(',
'[': '\\left[',
'{': '\\left\\{',
'\\{': '\\left\\{', # Escaped brace → \left\{
'|': '\\left|',
'\\|': '\\left\\|', # Double vertical line → \left\|
'\\langle': '\\left\\langle',
'\\lfloor': '\\left\\lfloor', # Floor function
'\\lceil': '\\left\\lceil', # Ceiling function
'.': '\\left.', # BUG-010 FIX: Invisible left delimiter
}
return left_map.get(char)
def _get_right_command(self, char: str) -> Optional[str]:
"""Get \\right command for closing delimiter."""
right_map = {
')': '\\right)',
']': '\\right]',
'}': '\\right\\}',
'\\}': '\\right\\}', # Escaped brace → \right\}
'|': '\\right|',
'\\|': '\\right\\|', # Double vertical line → \right\|
'\\rangle': '\\right\\rangle',
'.': '\\right.', # Invisible delimiter → \right.
'\\rfloor': '\\right\\rfloor', # Floor function
'\\rceil': '\\right\\rceil', # Ceiling function
}
return right_map.get(char)
def _apply_replacements(self, latex: str, replacements: List[Tuple[int, int, str]]) -> str:
"""
Apply replacements to LaTeX string.
Replacements are applied in reverse order to maintain position indices.
Args:
latex: Original LaTeX string
replacements: List of (start_pos, end_pos, replacement_text) tuples
Returns:
Modified LaTeX string
"""
# Sort by position (reverse order) to maintain indices
replacements.sort(key=lambda x: x[0], reverse=True)
result = latex
for start, end, replacement in replacements:
result = result[:start] + replacement + result[end:]
return result
# Convenience function for direct use
def add_universal_left_right(latex: str, debug: bool = False) -> str:
"""
Add \\left and \\right to all delimiter pairs in LaTeX.
Convenience function that creates a DelimiterPairingProcessor and
processes the LaTeX string.
This function handles:
- Symmetric pairs: (), [], {}, ||
- Interval notation: [), (], etc.
- Skips protected contexts (\\left...\\right, cases environment)
Args:
latex: LaTeX string from conversion
debug: Enable debug output to stderr
Returns:
LaTeX string with \\left...\\right added to all delimiter pairs
Examples:
>>> add_universal_left_right("(x + y)")
"\\\\left(x + y\\\\right)"
>>> add_universal_left_right("[0, 1)")
"\\\\left[0, 1\\\\right)"
>>> add_universal_left_right("\\\\left\\\\{ \\\\begin{array}...\\\\right.")
"\\\\left\\\\{ \\\\begin{array}...\\\\right." (unchanged)
"""
processor = DelimiterPairingProcessor(debug=debug)
return processor.process(latex)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,851 @@
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"
xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:mml="http://www.w3.org/1998/Math/MathML"
xmlns="http://www.w3.org/1998/Math/MathML"
exclude-result-prefixes="m w">
<xsl:output method="xml" indent="yes" encoding="UTF-8"/>
<!-- Root template -->
<xsl:template match="/">
<xsl:apply-templates select="//m:oMath"/>
</xsl:template>
<!-- Main math element -->
<xsl:template match="m:oMath">
<math>
<xsl:apply-templates select="*"/>
</math>
</xsl:template>
<!-- Text run -->
<xsl:template match="m:r">
<xsl:apply-templates select="m:t"/>
</xsl:template>
<!-- Text content -->
<xsl:template match="m:t">
<!-- Get style from parent <m:r><m:rPr><m:sty> -->
<xsl:variable name="sty" select="../m:rPr/m:sty/@m:val"/>
<!-- Get Word run properties (w:rPr) - check for italic (w:i) -->
<!-- w:rPr is a sibling of m:t (both are children of m:r) -->
<!-- CRITICAL: w:i with @w:val="0" or "false" means NOT italic! -->
<!-- Only treat as italic if w:i exists AND val is not "0" or "false" -->
<xsl:variable name="has_word_italic" select="boolean(../w:rPr/w:i[not(@w:val = '0') and not(@w:val = 'false')])"/>
<!-- Get math "normal" style (m:nor) - text mode button -->
<xsl:variable name="has_math_normal" select="boolean(../m:rPr/m:nor)"/>
<xsl:choose>
<xsl:when test="normalize-space(.) = ''">
<!-- Skip empty text -->
</xsl:when>
<xsl:when test="number(.) = number(.)">
<mn><xsl:value-of select="normalize-space(.)"/></mn>
</xsl:when>
<!-- Fence delimiters: parentheses, brackets, pipes (with fence="true") -->
<xsl:when test=". = '(' or . = ')' or . = '[' or . = ']' or . = '|'">
<mo fence="true"><xsl:value-of select="normalize-space(.)"/></mo>
</xsl:when>
<!-- Plain operators (no fence attribute) -->
<!-- NOTE: { and } are converted to markers to distinguish source braces from generated LaTeX braces -->
<xsl:when test=". = '{' or . = '}' or . = '+' or . = '-' or . = '=' or . = '&lt;' or . = '&gt;' or . = '±' or . = '≠' or . = '≤' or . = '≥' or . = '×' or . = '÷' or . = '∞'">
<mo>
<xsl:choose>
<xsl:when test=". = '{'">__LBRACE__</xsl:when>
<xsl:when test=". = '}'">__RBRACE__</xsl:when>
<xsl:otherwise><xsl:value-of select="normalize-space(.)"/></xsl:otherwise>
</xsl:choose>
</mo>
</xsl:when>
<!-- Mixed operators/delimiters (like ")+" or ")=" or "(A" or "={" or ",}") -->
<!-- Check if string contains multiple operator/delimiter characters -->
<xsl:when test="string-length(normalize-space(.)) &gt; 1 and (contains(., '(') or contains(., ')') or contains(., '[') or contains(., ']') or contains(., '{') or contains(., '}') or contains(., '+') or contains(., '-') or contains(., '=') or contains(., '&lt;') or contains(., '&gt;'))">
<!-- Split into individual characters and classify each one -->
<!-- Pass style info so letters get proper mathvariant (e.g., mathvariant="normal" for m:nor text mode) -->
<xsl:call-template name="split-operators">
<xsl:with-param name="text" select="normalize-space(.)"/>
<xsl:with-param name="math_variant">
<xsl:choose>
<xsl:when test="$has_word_italic">italic</xsl:when>
<xsl:when test="$sty = 'b' or $sty = 'bi'">bold</xsl:when>
<xsl:when test="$sty = 'p'">normal</xsl:when>
<xsl:when test="$has_math_normal">normal</xsl:when>
<xsl:otherwise></xsl:otherwise>
</xsl:choose>
</xsl:with-param>
</xsl:call-template>
</xsl:when>
<xsl:when test="contains(., '|')">
<!-- Special handling for text containing | (like "x|x", "A|B", or "||") -->
<!-- Split by | and create separate elements with fence="true" for absolute values -->
<xsl:variable name="before" select="substring-before(., '|')"/>
<xsl:variable name="after" select="substring-after(., '|')"/>
<!-- Process before part (plain text) -->
<xsl:if test="$before != ''">
<xsl:choose>
<!-- If before part contains operators or is multi-char, use split-operators -->
<xsl:when test="string-length($before) &gt; 1">
<xsl:call-template name="split-operators">
<xsl:with-param name="text" select="$before"/>
</xsl:call-template>
</xsl:when>
<!-- Single character - output as mi -->
<xsl:otherwise>
<mi><xsl:value-of select="$before"/></mi>
</xsl:otherwise>
</xsl:choose>
</xsl:if>
<!-- Output the | as fence for potential absolute value -->
<mo fence="true">|</mo>
<!-- Process after part recursively if it contains more | or operators -->
<xsl:if test="$after != ''">
<xsl:choose>
<!-- If after part is just |, output as fence -->
<xsl:when test="$after = '|'">
<mo fence="true">|</mo>
</xsl:when>
<!-- If after part contains more |, recursively process this m:t content -->
<xsl:when test="contains($after, '|')">
<xsl:call-template name="process-text-with-pipe">
<xsl:with-param name="text" select="$after"/>
</xsl:call-template>
</xsl:when>
<!-- If after part contains operators, use split-operators -->
<xsl:when test="string-length($after) &gt; 1 and (contains($after, '+') or contains($after, '-') or contains($after, '='))">
<xsl:call-template name="split-operators">
<xsl:with-param name="text" select="$after"/>
</xsl:call-template>
</xsl:when>
<!-- Single character or simple text - output as mi -->
<xsl:otherwise>
<mi><xsl:value-of select="$after"/></mi>
</xsl:otherwise>
</xsl:choose>
</xsl:if>
</xsl:when>
<xsl:when test="string-length(normalize-space(.)) = 1 and not(contains('0123456789', .))">
<mi>
<!-- Apply mathvariant based on style -->
<!-- Note: m:sty='i' (italic) is NOT handled here as italic is the default for variables -->
<!-- Priority: italic (w:i explicit) > bold > plain (m:sty='p') > normal (m:nor) -->
<xsl:choose>
<xsl:when test="$has_word_italic">
<xsl:attribute name="mathvariant">italic</xsl:attribute>
</xsl:when>
<xsl:when test="$sty = 'b' or $sty = 'bi'">
<xsl:attribute name="mathvariant">bold</xsl:attribute>
</xsl:when>
<xsl:when test="$sty = 'p'">
<xsl:attribute name="mathvariant">normal</xsl:attribute>
</xsl:when>
<xsl:when test="$has_math_normal">
<xsl:attribute name="mathvariant">normal</xsl:attribute>
</xsl:when>
</xsl:choose>
<xsl:value-of select="normalize-space(.)"/>
</mi>
</xsl:when>
<xsl:otherwise>
<mi>
<!-- Apply mathvariant for multi-character identifiers too -->
<!-- Note: m:sty='i' (italic) is NOT handled here as italic is the default for variables -->
<!-- Priority: italic (w:i explicit) > bold > plain (m:sty='p') > normal (m:nor) -->
<xsl:choose>
<xsl:when test="$has_word_italic">
<xsl:attribute name="mathvariant">italic</xsl:attribute>
</xsl:when>
<xsl:when test="$sty = 'b' or $sty = 'bi'">
<xsl:attribute name="mathvariant">bold</xsl:attribute>
</xsl:when>
<xsl:when test="$sty = 'p'">
<xsl:attribute name="mathvariant">normal</xsl:attribute>
</xsl:when>
<xsl:when test="$has_math_normal">
<xsl:attribute name="mathvariant">normal</xsl:attribute>
</xsl:when>
</xsl:choose>
<xsl:value-of select="normalize-space(.)"/>
</mi>
</xsl:otherwise>
</xsl:choose>
</xsl:template>
<!-- Superscript -->
<xsl:template match="m:sSup">
<msup>
<xsl:apply-templates select="m:e"/>
<xsl:apply-templates select="m:sup"/>
</msup>
</xsl:template>
<!-- Subscript -->
<xsl:template match="m:sSub">
<msub>
<xsl:apply-templates select="m:e"/>
<xsl:apply-templates select="m:sub"/>
</msub>
</xsl:template>
<!-- Sub-superscript -->
<xsl:template match="m:sSubSup">
<msubsup>
<xsl:apply-templates select="m:e"/>
<xsl:apply-templates select="m:sub"/>
<xsl:apply-templates select="m:sup"/>
</msubsup>
</xsl:template>
<!-- Pre-script (left-side subscript/superscript) -->
<!-- Used for nuclear notation like _{2}^{3}He -->
<xsl:template match="m:sPre">
<mmultiscripts>
<xsl:apply-templates select="m:e"/>
<mprescripts/>
<xsl:apply-templates select="m:sub"/>
<xsl:apply-templates select="m:sup"/>
</mmultiscripts>
</xsl:template>
<!-- Fraction -->
<!-- m:type values: bar (default), noBar, lin (linear), skw (skewed) -->
<xsl:template match="m:f">
<xsl:variable name="type" select="m:fPr/m:type/@m:val"/>
<mfrac>
<!-- Add linethickness="0" for noBar fractions (stacked without bar) -->
<xsl:if test="$type = 'noBar'">
<xsl:attribute name="linethickness">0</xsl:attribute>
</xsl:if>
<!-- Add bevelled="true" for linear fractions -->
<xsl:if test="$type = 'lin'">
<xsl:attribute name="bevelled">true</xsl:attribute>
</xsl:if>
<xsl:apply-templates select="m:num"/>
<xsl:apply-templates select="m:den"/>
</mfrac>
</xsl:template>
<!-- Radical -->
<xsl:template match="m:rad">
<xsl:choose>
<xsl:when test="m:deg">
<mroot>
<xsl:apply-templates select="m:e"/>
<xsl:apply-templates select="m:deg"/>
</mroot>
</xsl:when>
<xsl:otherwise>
<msqrt>
<xsl:apply-templates select="m:e"/>
</msqrt>
</xsl:otherwise>
</xsl:choose>
</xsl:template>
<!-- Parentheses/delimiters -->
<xsl:template match="m:d">
<mrow>
<!-- Opening delimiter: skip if begChr has empty or whitespace-only val -->
<xsl:choose>
<xsl:when test="not(m:dPr/m:begChr)">
<mo fence="true">(</mo>
</xsl:when>
<xsl:when test="normalize-space(m:dPr/m:begChr/@m:val) != ''">
<mo fence="true">
<xsl:value-of select="m:dPr/m:begChr/@m:val"/>
</mo>
</xsl:when>
<!-- begChr with empty/whitespace val → no opening delimiter -->
</xsl:choose>
<!-- Handle multiple m:e elements with separators -->
<xsl:for-each select="m:e">
<xsl:apply-templates select="."/>
<!-- Insert separator | between elements (except after the last one) -->
<xsl:if test="position() != last()">
<mo>|</mo>
</xsl:if>
</xsl:for-each>
<!-- Closing delimiter: skip if endChr has empty or whitespace-only val -->
<xsl:choose>
<xsl:when test="not(m:dPr/m:endChr)">
<mo fence="true">)</mo>
</xsl:when>
<xsl:when test="normalize-space(m:dPr/m:endChr/@m:val) != ''">
<mo fence="true">
<xsl:value-of select="m:dPr/m:endChr/@m:val"/>
</mo>
</xsl:when>
<!-- endChr with empty/whitespace val → no closing delimiter -->
</xsl:choose>
</mrow>
</xsl:template>
<!-- Function -->
<xsl:template match="m:func">
<mrow>
<xsl:apply-templates select="m:fName"/>
<xsl:apply-templates select="m:e"/>
</mrow>
</xsl:template>
<!-- Matrix -->
<xsl:template match="m:m">
<mtable>
<xsl:apply-templates select="m:mr"/>
</mtable>
</xsl:template>
<!-- Matrix row -->
<xsl:template match="m:mr">
<mtr>
<xsl:apply-templates select="m:e"/>
</mtr>
</xsl:template>
<!-- Equation array (for piecewise functions) -->
<xsl:template match="m:eqArr">
<mtable>
<!-- Each <m:e> child represents one row in the piecewise function -->
<xsl:for-each select="m:e">
<mtr>
<mtd>
<xsl:apply-templates select="node()"/>
</mtd>
</mtr>
</xsl:for-each>
</mtable>
</xsl:template>
<!-- Bar (overbar/underbar) -->
<!-- OMML spec: no pos attribute = underline (default), pos="top" = overline, pos="bot" = underline -->
<xsl:template match="m:bar">
<xsl:choose>
<xsl:when test="m:barPr/m:pos/@m:val = 'top'">
<mover>
<xsl:apply-templates select="m:e"/>
<mo>‾</mo>
</mover>
</xsl:when>
<xsl:otherwise>
<!-- Default: no pos attribute or pos="bot" both mean underline -->
<munder>
<xsl:apply-templates select="m:e"/>
<mo>‾</mo>
</munder>
</xsl:otherwise>
</xsl:choose>
</xsl:template>
<!-- Limits -->
<!-- limLow: Lower limit position (content BELOW the base, like underscript) -->
<xsl:template match="m:limLow">
<munder>
<xsl:apply-templates select="m:e"/>
<xsl:apply-templates select="m:lim"/>
</munder>
</xsl:template>
<!-- limUpp: Upper limit position (content ABOVE the base, like overscript) -->
<xsl:template match="m:limUpp">
<mover>
<xsl:apply-templates select="m:e"/>
<xsl:apply-templates select="m:lim"/>
</mover>
</xsl:template>
<xsl:template match="m:limUppLow">
<munderover>
<xsl:apply-templates select="m:e"/>
<xsl:apply-templates select="m:lim[1]"/>
<xsl:apply-templates select="m:lim[2]"/>
</munderover>
</xsl:template>
<!-- N-ary operators (integrals, sums, products) -->
<xsl:template match="m:nary">
<xsl:variable name="chr" select="m:naryPr/m:chr/@m:val"/>
<xsl:variable name="limLoc" select="m:naryPr/m:limLoc/@m:val"/>
<xsl:variable name="subHide" select="m:naryPr/m:subHide/@m:val"/>
<xsl:variable name="supHide" select="m:naryPr/m:supHide/@m:val"/>
<xsl:choose>
<!-- Both limits hidden: just operator and integrand -->
<xsl:when test="$subHide = '1' and $supHide = '1'">
<mrow>
<mo>
<xsl:choose>
<xsl:when test="$chr"><xsl:value-of select="$chr"/></xsl:when>
<xsl:otherwise>∫</xsl:otherwise>
</xsl:choose>
</mo>
<xsl:apply-templates select="m:e"/>
</mrow>
</xsl:when>
<!-- Limits as subscript/superscript (default for integrals) -->
<xsl:when test="not($limLoc) or $limLoc = 'subSup'">
<xsl:choose>
<!-- Both sub and sup present -->
<xsl:when test="m:sub and m:sup">
<msubsup>
<mo>
<xsl:choose>
<xsl:when test="$chr"><xsl:value-of select="$chr"/></xsl:when>
<xsl:otherwise>∫</xsl:otherwise>
</xsl:choose>
</mo>
<xsl:apply-templates select="m:sub"/>
<xsl:apply-templates select="m:sup"/>
</msubsup>
<xsl:apply-templates select="m:e"/>
</xsl:when>
<!-- Only subscript -->
<xsl:when test="m:sub">
<msub>
<mo>
<xsl:choose>
<xsl:when test="$chr"><xsl:value-of select="$chr"/></xsl:when>
<xsl:otherwise>∫</xsl:otherwise>
</xsl:choose>
</mo>
<xsl:apply-templates select="m:sub"/>
</msub>
<xsl:apply-templates select="m:e"/>
</xsl:when>
<!-- Only superscript -->
<xsl:when test="m:sup">
<msup>
<mo>
<xsl:choose>
<xsl:when test="$chr"><xsl:value-of select="$chr"/></xsl:when>
<xsl:otherwise>∫</xsl:otherwise>
</xsl:choose>
</mo>
<xsl:apply-templates select="m:sup"/>
</msup>
<xsl:apply-templates select="m:e"/>
</xsl:when>
<!-- No limits -->
<xsl:otherwise>
<mrow>
<mo>
<xsl:choose>
<xsl:when test="$chr"><xsl:value-of select="$chr"/></xsl:when>
<xsl:otherwise>∫</xsl:otherwise>
</xsl:choose>
</mo>
<xsl:apply-templates select="m:e"/>
</mrow>
</xsl:otherwise>
</xsl:choose>
</xsl:when>
<!-- Limits as under/over (for sums, products) -->
<xsl:when test="$limLoc = 'undOvr'">
<mrow>
<xsl:choose>
<!-- Both under and over -->
<xsl:when test="m:sub and m:sup and not($subHide = '1') and not($supHide = '1')">
<munderover>
<mo>
<xsl:choose>
<xsl:when test="$chr"><xsl:value-of select="$chr"/></xsl:when>
<xsl:otherwise>∫</xsl:otherwise>
</xsl:choose>
</mo>
<xsl:apply-templates select="m:sub"/>
<xsl:apply-templates select="m:sup"/>
</munderover>
</xsl:when>
<!-- Only under -->
<xsl:when test="m:sub and not($subHide = '1')">
<munder>
<mo>
<xsl:choose>
<xsl:when test="$chr"><xsl:value-of select="$chr"/></xsl:when>
<xsl:otherwise>∫</xsl:otherwise>
</xsl:choose>
</mo>
<xsl:apply-templates select="m:sub"/>
</munder>
</xsl:when>
<!-- Only over -->
<xsl:when test="m:sup and not($supHide = '1')">
<mover>
<mo>
<xsl:choose>
<xsl:when test="$chr"><xsl:value-of select="$chr"/></xsl:when>
<xsl:otherwise>∫</xsl:otherwise>
</xsl:choose>
</mo>
<xsl:apply-templates select="m:sup"/>
</mover>
</xsl:when>
<!-- No visible limits -->
<xsl:otherwise>
<mo>
<xsl:choose>
<xsl:when test="$chr"><xsl:value-of select="$chr"/></xsl:when>
<xsl:otherwise>∫</xsl:otherwise>
</xsl:choose>
</mo>
</xsl:otherwise>
</xsl:choose>
<xsl:apply-templates select="m:e"/>
</mrow>
</xsl:when>
</xsl:choose>
</xsl:template>
<!-- Accent (vector arrows, dots, etc.) -->
<xsl:template match="m:acc">
<xsl:variable name="chr" select="m:accPr/m:chr/@m:val"/>
<mover>
<!-- Add data attribute to distinguish default accents from explicit ones -->
<xsl:if test="not($chr)">
<xsl:attribute name="data-default-accent">true</xsl:attribute>
</xsl:if>
<xsl:apply-templates select="m:e"/>
<mo>
<xsl:choose>
<xsl:when test="$chr"><xsl:value-of select="$chr"/></xsl:when>
<xsl:otherwise>⃗</xsl:otherwise><!-- Default accent (should be hat in most cases) -->
</xsl:choose>
</mo>
</mover>
</xsl:template>
<!-- Group character (for over/under arrows with text) -->
<xsl:template match="m:groupChr">
<xsl:variable name="chr" select="m:groupChrPr/m:chr/@m:val"/>
<xsl:variable name="pos" select="m:groupChrPr/m:pos/@m:val"/>
<xsl:variable name="vertJc" select="m:groupChrPr/m:vertJc/@m:val"/>
<xsl:choose>
<!-- vertJc='bot': Character at bottom (base), content above → mover(char, content) -->
<!-- This is used for extensible arrows like \xrightarrow[]{text} or \overset{text}{arrow} -->
<xsl:when test="$vertJc = 'bot'">
<mover accent="false">
<mo stretchy="true">
<xsl:choose>
<xsl:when test="$chr"><xsl:value-of select="$chr"/></xsl:when>
<xsl:otherwise>→</xsl:otherwise>
</xsl:choose>
</mo>
<xsl:apply-templates select="m:e"/>
</mover>
</xsl:when>
<!-- pos='bot': Character position at bottom, content above → munder -->
<xsl:when test="$pos = 'bot'">
<munder>
<xsl:apply-templates select="m:e"/>
<mo>
<xsl:choose>
<xsl:when test="$chr"><xsl:value-of select="$chr"/></xsl:when>
<xsl:otherwise>⏟</xsl:otherwise><!-- Default underbrace -->
</xsl:choose>
</mo>
</munder>
</xsl:when>
<!-- pos='top' (or default): Character above content → mover(content, char) -->
<xsl:otherwise>
<mover>
<xsl:apply-templates select="m:e"/>
<mo>
<xsl:choose>
<xsl:when test="$chr"><xsl:value-of select="$chr"/></xsl:when>
<xsl:otherwise>⏞</xsl:otherwise><!-- Default overbrace -->
</xsl:choose>
</mo>
</mover>
</xsl:otherwise>
</xsl:choose>
</xsl:template>
<!-- Box (but detect if it contains groupChr for reaction arrows) -->
<xsl:template match="m:box">
<xsl:choose>
<!-- Skip boxes with opEmu="1" (operator emulation - should be transparent) -->
<!-- This means process all children in m:e transparently -->
<xsl:when test="m:boxPr/m:opEmu/@m:val='1'">
<xsl:apply-templates select="m:e/*"/>
</xsl:when>
<!-- If box contains ONLY a single direct groupChr, handle specially -->
<!-- This is for cases like reaction arrows: text above/below extensible arrow -->
<xsl:when test="count(m:e/*) = 1 and m:e/m:groupChr">
<xsl:variable name="groupChr" select="m:e/m:groupChr"/>
<xsl:variable name="arrow" select="$groupChr/m:groupChrPr/m:chr/@m:val"/>
<xsl:variable name="vertJc" select="$groupChr/m:groupChrPr/m:vertJc/@m:val"/>
<xsl:variable name="pos" select="$groupChr/m:groupChrPr/m:pos/@m:val"/>
<xsl:choose>
<xsl:when test="$vertJc = 'bot'">
<!-- Arrow at bottom with text above (like \xrightarrow[]{text}) -->
<!-- For extensible arrows: mover with arrow base, text above -->
<mover accent="false">
<mo stretchy="true">
<xsl:choose>
<xsl:when test="$arrow"><xsl:value-of select="$arrow"/></xsl:when>
<xsl:otherwise>→</xsl:otherwise>
</xsl:choose>
</mo>
<xsl:apply-templates select="$groupChr/m:e"/>
</mover>
</xsl:when>
<xsl:when test="$pos = 'top'">
<!-- Arrow at top: \overrightarrow pattern -->
<!-- mover with content base, arrow above -->
<mover>
<xsl:apply-templates select="$groupChr/m:e"/>
<mo>
<xsl:choose>
<xsl:when test="$arrow"><xsl:value-of select="$arrow"/></xsl:when>
<xsl:otherwise>→</xsl:otherwise>
</xsl:choose>
</mo>
</mover>
</xsl:when>
<xsl:otherwise>
<!-- Default: Vector arrow over content -->
<mover>
<xsl:apply-templates select="$groupChr/m:e"/>
<mo>
<xsl:choose>
<xsl:when test="$arrow"><xsl:value-of select="$arrow"/></xsl:when>
<xsl:otherwise>→</xsl:otherwise>
</xsl:choose>
</mo>
</mover>
</xsl:otherwise>
</xsl:choose>
</xsl:when>
<!-- Skip phantom/layout boxes (argSz with negative value) -->
<!-- These are used for sizing/spacing control, not actual visible boxes -->
<xsl:when test="m:e/m:argPr/m:argSz/@m:val &lt; 0">
<xsl:apply-templates select="m:e"/>
</xsl:when>
<!-- Regular box -->
<xsl:otherwise>
<menclose notation="box">
<xsl:apply-templates select="m:e"/>
</menclose>
</xsl:otherwise>
</xsl:choose>
</xsl:template>
<!-- Border box with strikethrough and border notation support -->
<xsl:template match="m:borderBox">
<xsl:variable name="props" select="m:borderBoxPr"/>
<!-- Check for strikethrough properties -->
<xsl:variable name="strikeBLTR" select="boolean($props/m:strikeBLTR)"/>
<xsl:variable name="strikeTLBR" select="boolean($props/m:strikeTLBR)"/>
<xsl:variable name="strikeH" select="boolean($props/m:strikeH)"/>
<xsl:variable name="strikeV" select="boolean($props/m:strikeV)"/>
<!-- Check for border hiding properties -->
<xsl:variable name="hideTop" select="boolean($props/m:hideTop)"/>
<xsl:variable name="hideBot" select="boolean($props/m:hideBot)"/>
<xsl:variable name="hideLeft" select="boolean($props/m:hideLeft)"/>
<xsl:variable name="hideRight" select="boolean($props/m:hideRight)"/>
<!-- Determine notation attribute -->
<xsl:variable name="notation">
<xsl:choose>
<!-- Combined diagonal strikes → xcancel -->
<xsl:when test="$strikeBLTR and $strikeTLBR">updiagonalstrike downdiagonalstrike</xsl:when>
<!-- Single diagonal strike BLTR → cancel (updiagonal) -->
<xsl:when test="$strikeBLTR">updiagonalstrike</xsl:when>
<!-- Single diagonal strike TLBR → bcancel (downdiagonal) -->
<xsl:when test="$strikeTLBR">downdiagonalstrike</xsl:when>
<!-- Combined horizontal and vertical → cancel -->
<xsl:when test="$strikeH and $strikeV">horizontalstrike verticalstrike</xsl:when>
<!-- Single horizontal strike → cancel -->
<xsl:when test="$strikeH">horizontalstrike</xsl:when>
<!-- Single vertical strike → bcancel -->
<xsl:when test="$strikeV">verticalstrike</xsl:when>
<!-- Single side visible -->
<xsl:when test="not($hideTop) and $hideBot and $hideLeft and $hideRight">top</xsl:when>
<xsl:when test="$hideTop and not($hideBot) and $hideLeft and $hideRight">bottom</xsl:when>
<xsl:when test="$hideTop and $hideBot and not($hideLeft) and $hideRight">left</xsl:when>
<xsl:when test="$hideTop and $hideBot and $hideLeft and not($hideRight)">right</xsl:when>
<!-- Two sides visible -->
<xsl:when test="not($hideTop) and not($hideBot) and $hideLeft and $hideRight">top bottom</xsl:when>
<xsl:when test="not($hideTop) and $hideBot and not($hideLeft) and $hideRight">top left</xsl:when>
<xsl:when test="not($hideTop) and $hideBot and $hideLeft and not($hideRight)">top right</xsl:when>
<xsl:when test="$hideTop and not($hideBot) and not($hideLeft) and $hideRight">bottom left</xsl:when>
<xsl:when test="$hideTop and not($hideBot) and $hideLeft and not($hideRight)">bottom right</xsl:when>
<xsl:when test="$hideTop and $hideBot and not($hideLeft) and not($hideRight)">left right</xsl:when>
<!-- Three sides visible -->
<xsl:when test="not($hideTop) and not($hideBot) and not($hideLeft) and $hideRight">top bottom left</xsl:when>
<xsl:when test="not($hideTop) and not($hideBot) and $hideLeft and not($hideRight)">top bottom right</xsl:when>
<xsl:when test="not($hideTop) and $hideBot and not($hideLeft) and not($hideRight)">top left right</xsl:when>
<xsl:when test="$hideTop and not($hideBot) and not($hideLeft) and not($hideRight)">bottom left right</xsl:when>
<!-- All four sides visible (or none hidden) -->
<xsl:when test="not($hideTop or $hideBot or $hideLeft or $hideRight)">box</xsl:when>
<!-- Default: roundedbox -->
<xsl:otherwise>roundedbox</xsl:otherwise>
</xsl:choose>
</xsl:variable>
<menclose notation="{$notation}">
<xsl:apply-templates select="m:e"/>
</menclose>
</xsl:template>
<!-- Math element base -->
<xsl:template match="m:e | m:num | m:den | m:sup | m:sub | m:deg | m:fName | m:lim">
<mrow>
<xsl:apply-templates select="*"/>
</mrow>
</xsl:template>
<!-- Template to split mixed operator/delimiter strings character by character -->
<!-- Recursive template to process text containing | characters -->
<xsl:template name="process-text-with-pipe">
<xsl:param name="text"/>
<xsl:if test="string-length($text) &gt; 0">
<xsl:choose>
<!-- If text contains |, split and recurse -->
<xsl:when test="contains($text, '|')">
<xsl:variable name="before" select="substring-before($text, '|')"/>
<xsl:variable name="after" select="substring-after($text, '|')"/>
<!-- Output before part if not empty -->
<xsl:if test="$before != ''">
<xsl:choose>
<!-- If before is single char, output as mi or mn -->
<xsl:when test="string-length($before) = 1">
<xsl:choose>
<xsl:when test="contains('0123456789', $before)">
<mn><xsl:value-of select="$before"/></mn>
</xsl:when>
<xsl:otherwise>
<mi><xsl:value-of select="$before"/></mi>
</xsl:otherwise>
</xsl:choose>
</xsl:when>
<!-- Multi-char - use split-operators -->
<xsl:otherwise>
<xsl:call-template name="split-operators">
<xsl:with-param name="text" select="$before"/>
</xsl:call-template>
</xsl:otherwise>
</xsl:choose>
</xsl:if>
<!-- Output | as fence -->
<mo fence="true">|</mo>
<!-- Recurse on after part -->
<xsl:if test="$after != ''">
<xsl:call-template name="process-text-with-pipe">
<xsl:with-param name="text" select="$after"/>
</xsl:call-template>
</xsl:if>
</xsl:when>
<!-- No more | in text - output as-is -->
<xsl:otherwise>
<xsl:choose>
<!-- Single char -->
<xsl:when test="string-length($text) = 1">
<xsl:choose>
<xsl:when test="contains('0123456789', $text)">
<mn><xsl:value-of select="$text"/></mn>
</xsl:when>
<xsl:otherwise>
<mi><xsl:value-of select="$text"/></mi>
</xsl:otherwise>
</xsl:choose>
</xsl:when>
<!-- Multi-char - use split-operators for proper handling -->
<xsl:otherwise>
<xsl:call-template name="split-operators">
<xsl:with-param name="text" select="$text"/>
</xsl:call-template>
</xsl:otherwise>
</xsl:choose>
</xsl:otherwise>
</xsl:choose>
</xsl:if>
</xsl:template>
<xsl:template name="split-operators">
<xsl:param name="text"/>
<!-- Style variant to apply to identifier (letter) elements: 'normal', 'bold', 'italic', or empty -->
<xsl:param name="math_variant" select="''"/>
<xsl:if test="string-length($text) &gt; 0">
<!-- Process first character -->
<xsl:variable name="char" select="substring($text, 1, 1)"/>
<xsl:choose>
<!-- Fence delimiters with fence="true" -->
<xsl:when test="$char = '(' or $char = ')' or $char = '[' or $char = ']' or $char = '|'">
<mo fence="true"><xsl:value-of select="$char"/></mo>
</xsl:when>
<!-- Plain operators (no fence attribute) -->
<!-- NOTE: { and } are converted to markers to distinguish source braces from generated LaTeX braces -->
<xsl:when test="$char = '+' or $char = '-' or $char = '=' or $char = '&lt;' or $char = '&gt;' or $char = '*' or $char = '/' or $char = '±' or $char = '≠' or $char = '≤' or $char = '≥' or $char = '×' or $char = '÷'">
<mo><xsl:value-of select="$char"/></mo>
</xsl:when>
<!-- Source braces - use markers -->
<xsl:when test="$char = '{'">
<mo>__LBRACE__</mo>
</xsl:when>
<xsl:when test="$char = '}'">
<mo>__RBRACE__</mo>
</xsl:when>
<!-- Numbers -->
<xsl:when test="contains('0123456789', $char)">
<mn><xsl:value-of select="$char"/></mn>
</xsl:when>
<!-- Identifiers (letters) -->
<xsl:otherwise>
<mi>
<!-- Apply mathvariant from parent run style if available -->
<xsl:if test="$math_variant != ''">
<xsl:attribute name="mathvariant"><xsl:value-of select="$math_variant"/></xsl:attribute>
</xsl:if>
<xsl:value-of select="$char"/>
</mi>
</xsl:otherwise>
</xsl:choose>
<!-- Recursively process remaining characters -->
<xsl:if test="string-length($text) &gt; 1">
<xsl:call-template name="split-operators">
<xsl:with-param name="text" select="substring($text, 2)"/>
<xsl:with-param name="math_variant" select="$math_variant"/>
</xsl:call-template>
</xsl:if>
</xsl:if>
</xsl:template>
<!-- Properties elements - ignore -->
<xsl:template match="m:rPr | m:ctrlPr | m:dPr | m:fPr | m:funcPr | m:limLowPr | m:limUppPr | m:naryPr | m:phantPr | m:radPr | m:sPrePr | m:sSubPr | m:sSubSupPr | m:sSupPr | m:accPr | m:barPr | m:boxPr | m:borderBoxPr | m:dPr | m:eqArrPr | m:groupChrPr | m:mPr | w:rPr">
<!-- Ignore formatting properties -->
</xsl:template>
<!-- Control properties - ignore -->
<xsl:template match="m:ctrlPr">
<!-- Ignore control properties -->
</xsl:template>
<!-- Default template -->
<xsl:template match="*">
<xsl:apply-templates select="*"/>
</xsl:template>
</xsl:stylesheet>

View File

@@ -0,0 +1,25 @@
"""
Core pipeline infrastructure.
This package contains the foundational components of the refactored
converter architecture: interfaces, context, and pipeline orchestrator.
"""
from .context import ConversionContext
from .pipeline import ConversionPipeline
from .interfaces import Stage, Visitor, Strategy, VisitorRegistry
from .xslt_transformer import omml_to_mathml, _local, _text_content
from .omml_parser_core import omml_to_mathml_fallback
__all__ = [
'ConversionContext',
'ConversionPipeline',
'Stage',
'Visitor',
'Strategy',
'VisitorRegistry',
'omml_to_mathml',
'omml_to_mathml_fallback',
'_local',
'_text_content',
]

View File

@@ -0,0 +1,290 @@
"""
Caching utilities for performance optimization.
Provides singleton pattern for expensive-to-create objects like
XSL transformers, and LRU caching for frequently-used operations.
"""
import os
import threading
from typing import Dict, Optional
from lxml import etree as ET
from functools import lru_cache
class XSLTransformerCache:
"""
Thread-safe singleton cache for XSL transformers.
The OMML2MML.xsl file is expensive to parse (lxml XSLT compilation).
This cache ensures it's only parsed once and reused across all conversions.
Example:
>>> cache = XSLTransformerCache.get_instance()
>>> transformer = cache.get_transformer('path/to/OMML2MML.xsl')
>>> # Subsequent calls return cached transformer
>>> transformer2 = cache.get_transformer('path/to/OMML2MML.xsl')
>>> assert transformer is transformer2
"""
_instance: Optional['XSLTransformerCache'] = None
_lock = threading.Lock()
def __init__(self):
"""Initialize cache storage."""
self._transformers: Dict[str, ET.XSLT] = {}
self._cache_lock = threading.Lock()
@classmethod
def get_instance(cls) -> 'XSLTransformerCache':
"""
Get singleton instance of the cache.
Thread-safe singleton pattern using double-checked locking.
Returns:
Singleton XSLTransformerCache instance
"""
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def get_transformer(self, xsl_path: str) -> ET.XSLT:
"""
Get cached XSL transformer or create and cache new one.
Args:
xsl_path: Path to XSL stylesheet file
Returns:
Compiled XSLT transformer
Raises:
FileNotFoundError: If XSL file doesn't exist
ET.XSLTParseError: If XSL file is malformed
Example:
>>> cache = XSLTransformerCache.get_instance()
>>> transformer = cache.get_transformer('OMML2MML.xsl')
>>> result = transformer(omml_doc)
"""
# Normalize path for consistent caching
xsl_path = os.path.abspath(xsl_path)
# Check cache (read lock)
if xsl_path in self._transformers:
return self._transformers[xsl_path]
# Not in cache - create with write lock
with self._cache_lock:
# Double-check after acquiring lock
if xsl_path in self._transformers:
return self._transformers[xsl_path]
# Validate file exists
if not os.path.exists(xsl_path):
raise FileNotFoundError(f"XSL file not found: {xsl_path}")
# Parse and compile XSL
try:
xsl_doc = ET.parse(xsl_path)
transformer = ET.XSLT(xsl_doc)
self._transformers[xsl_path] = transformer
return transformer
except (ET.XSLTParseError, ET.XMLSyntaxError) as e:
raise ET.XSLTParseError(f"Failed to parse XSL file {xsl_path}: {e}") from e
def clear_cache(self) -> None:
"""
Clear all cached transformers.
Useful for testing or when XSL files have been updated.
Example:
>>> cache = XSLTransformerCache.get_instance()
>>> cache.clear_cache()
"""
with self._cache_lock:
self._transformers.clear()
def get_cache_size(self) -> int:
"""
Get number of cached transformers.
Returns:
Number of cached XSL transformers
Example:
>>> cache = XSLTransformerCache.get_instance()
>>> size = cache.get_cache_size()
>>> print(f"Cached {size} transformers")
"""
return len(self._transformers)
class ConverterObjectPool:
"""
Object pool for visitor registries and strategies.
Maintains a pool of pre-initialized conversion objects to avoid
recreation overhead for each equation.
Thread-safe implementation using locks.
Example:
>>> pool = ConverterObjectPool(pool_size=4)
>>> obj = pool.acquire_converter('math', wrap_identifiers=False)
>>> # Use converter...
>>> pool.release_converter(obj)
"""
def __init__(self, pool_size: int = 4):
"""
Initialize object pool.
Args:
pool_size: Maximum number of pooled objects
"""
self.pool_size = pool_size
self._available = []
self._in_use = set()
self._lock = threading.Lock()
def acquire_converter(
self,
subject_mode: str = 'auto',
wrap_identifiers: bool = False,
):
"""
Acquire a converter from the pool.
If pool is exhausted, creates a new converter.
Args:
subject_mode: Subject formatting mode
wrap_identifiers: Wrap identifiers in \\mathrm{}
Returns:
Converter instance
Example:
>>> pool = ConverterObjectPool()
>>> converter = pool.acquire_converter('math')
"""
with self._lock:
# Try to reuse from pool
for i, (cached_mode, cached_wrap, obj) in enumerate(self._available):
if cached_mode == subject_mode and cached_wrap == wrap_identifiers:
self._available.pop(i)
self._in_use.add(id(obj))
return obj
# Pool miss - create new converter
from ..compat import LegacyMathMLToTeXConverter
converter = LegacyMathMLToTeXConverter(
wrap_identifiers_in_mathrm=wrap_identifiers,
subject_mode=subject_mode,
)
self._in_use.add(id(converter))
return converter
def release_converter(
self,
converter,
subject_mode: str = 'auto',
wrap_identifiers: bool = False,
) -> None:
"""
Release converter back to pool.
Args:
converter: Converter instance to release
subject_mode: Subject mode of the converter
wrap_identifiers: Wrap identifiers setting
Example:
>>> pool.release_converter(converter, 'math', False)
"""
with self._lock:
obj_id = id(converter)
if obj_id in self._in_use:
self._in_use.remove(obj_id)
# Only pool if under limit
if len(self._available) < self.pool_size:
self._available.append((subject_mode, wrap_identifiers, converter))
def clear_pool(self) -> None:
"""Clear the object pool."""
with self._lock:
self._available.clear()
self._in_use.clear()
# LRU cache for expensive pattern matching operations
@lru_cache(maxsize=256)
def cached_function_detection(text: str) -> Optional[str]:
"""
Cached function name detection for fragmented functions.
Args:
text: Text to check for function names
Returns:
Function name if detected, None otherwise
Example:
>>> cached_function_detection('sin')
'sin'
>>> cached_function_detection('xyz')
None
"""
from ..data import MATH_FUNCTIONS
text_lower = text.lower()
if text_lower in MATH_FUNCTIONS:
return text_lower
return None
@lru_cache(maxsize=128)
def cached_chemical_element_detection(symbol: str) -> bool:
"""
Cached chemical element symbol detection.
Args:
symbol: Symbol to check
Returns:
True if symbol is a chemical element
Example:
>>> cached_chemical_element_detection('Fe')
True
>>> cached_chemical_element_detection('Xy')
False
"""
from ..data import CHEMICAL_ELEMENTS
return symbol in CHEMICAL_ELEMENTS
def clear_all_caches() -> None:
"""
Clear all caches (XSL, LRU caches, object pools).
Useful for testing or when memory needs to be reclaimed.
Example:
>>> clear_all_caches()
"""
# Clear XSL cache
cache = XSLTransformerCache.get_instance()
cache.clear_cache()
# Clear LRU caches
cached_function_detection.cache_clear()
cached_chemical_element_detection.cache_clear()

View File

@@ -0,0 +1,163 @@
"""
Conversion context data structure.
This module defines the ConversionContext dataclass that holds all state
and intermediate results throughout the conversion pipeline.
"""
from dataclasses import dataclass, field
from typing import Optional, List, Dict, Any
@dataclass
class ConversionContext:
"""
Shared state for the conversion pipeline.
This context object flows through all pipeline stages, accumulating
intermediate results and maintaining conversion state.
Attributes:
input_type: Type of input ('docx', 'omml', 'mathml', 'stdin')
subject_mode: Subject area hint ('math', 'chemistry', 'physics', 'statistics', 'auto')
wrap_identifiers: Whether to wrap identifiers in \\mathrm{} (default: True)
prefer_xlongequal: Use \\xlongequal for overset equals (default: True)
# Intermediate results
omml: OMML XML string (if applicable)
mathml: MathML XML string
latex: Generated LaTeX string
# Validation state
validation_errors: List of validation error messages
validation_warnings: List of validation warning messages
is_valid: Whether MathML passed validation
# Color optimization state
uniform_equation_color: Hex color if entire equation uses same color, else empty
suppress_leaf_colors: Flag to suppress individual element colors when uniform color detected
# Metadata
equation_count: Number of equations processed
current_equation_index: Index of current equation being processed
context_hints: Additional context information
# Performance metrics
stage_timings: Dict mapping stage name to execution time (seconds)
total_time: Total processing time (seconds)
"""
# Configuration
input_type: str
subject_mode: str = "math"
wrap_identifiers: bool = True
prefer_xlongequal: bool = True
# Intermediate results
omml: Optional[str] = None
mathml: Optional[str] = None
latex: Optional[str] = None
# Validation results
validation_errors: List[str] = field(default_factory=list)
validation_warnings: List[str] = field(default_factory=list)
is_valid: bool = True
# Color optimization state
uniform_equation_color: str = ""
suppress_leaf_colors: bool = False
# Script context tracking (for fraction sizing)
script_context_stack: List[str] = field(default_factory=list)
display_style_context: bool = False
# Set theory mode (for unclosed set notation)
set_theory_mode: bool = False
# Chemistry equation detection (cached)
is_chemistry_equation: Optional[bool] = None
# Metadata
equation_count: int = 0
current_equation_index: int = 0
context_hints: Dict[str, Any] = field(default_factory=dict)
# Performance metrics
stage_timings: Dict[str, float] = field(default_factory=dict)
total_time: float = 0.0
def add_error(self, error: str) -> None:
"""Add a validation error."""
self.validation_errors.append(error)
self.is_valid = False
def add_warning(self, warning: str) -> None:
"""Add a validation warning."""
self.validation_warnings.append(warning)
def record_stage_time(self, stage_name: str, time_seconds: float) -> None:
"""Record execution time for a stage."""
self.stage_timings[stage_name] = time_seconds
def has_errors(self) -> bool:
"""Check if any errors were recorded."""
return len(self.validation_errors) > 0
def has_warnings(self) -> bool:
"""Check if any warnings were recorded."""
return len(self.validation_warnings) > 0
def clear_validation_state(self) -> None:
"""Clear validation errors and warnings."""
self.validation_errors.clear()
self.validation_warnings.clear()
self.is_valid = True
def push_script_context(self, element_type: str) -> None:
"""
Push a script context (msup/msub/msubsup) onto the stack.
Args:
element_type: The element type entering script context
"""
self.script_context_stack.append(element_type)
def pop_script_context(self) -> Optional[str]:
"""
Pop a script context from the stack.
Returns:
The popped element type, or None if stack was empty
"""
return self.script_context_stack.pop() if self.script_context_stack else None
def in_script_context(self) -> bool:
"""Check if currently inside a script context (superscript/subscript)."""
return len(self.script_context_stack) > 0
def __str__(self) -> str:
"""String representation for debugging."""
status = "valid" if self.is_valid else "invalid"
return (
f"ConversionContext("
f"input_type={self.input_type}, "
f"subject_mode={self.subject_mode}, "
f"status={status}, "
f"equations={self.equation_count})"
)
def __repr__(self) -> str:
"""Detailed representation for debugging."""
return (
f"ConversionContext(\n"
f" input_type={self.input_type!r},\n"
f" subject_mode={self.subject_mode!r},\n"
f" wrap_identifiers={self.wrap_identifiers},\n"
f" prefer_xlongequal={self.prefer_xlongequal},\n"
f" is_valid={self.is_valid},\n"
f" equation_count={self.equation_count},\n"
f" errors={len(self.validation_errors)},\n"
f" warnings={len(self.validation_warnings)},\n"
f" total_time={self.total_time:.4f}s\n"
f")"
)

View File

@@ -0,0 +1,298 @@
"""
Base interfaces for the refactored architecture.
This module defines the abstract base classes (interfaces) that establish
the contracts for all components in the refactored converter architecture.
"""
from abc import ABC, abstractmethod
from typing import Any, Optional, List
from lxml import etree as ET
class ConversionContext:
"""
Shared context object that flows through the conversion pipeline.
This context holds all state and intermediate results during the conversion
process, allowing stages to communicate and share data.
"""
pass # Will be implemented as dataclass in context.py
class Stage(ABC):
"""
Abstract base class for pipeline stages.
Each stage represents a distinct step in the conversion pipeline
(e.g., input parsing, validation, transformation, conversion, output).
"""
@abstractmethod
def process(self, context: ConversionContext) -> ConversionContext:
"""
Process the conversion context and return updated context.
Args:
context: The current conversion context with all state
Returns:
Updated conversion context with stage results
Raises:
ValueError: If stage preconditions are not met
RuntimeError: If stage processing fails
"""
pass
@abstractmethod
def validate_input(self, context: ConversionContext) -> bool:
"""
Validate that stage preconditions are met.
Args:
context: The current conversion context
Returns:
True if preconditions are met, False otherwise
"""
pass
@property
@abstractmethod
def name(self) -> str:
"""Get the human-readable name of this stage."""
pass
class Visitor(ABC):
"""
Abstract base class for MathML element visitors.
Each visitor handles conversion of a specific MathML element type
(e.g., <mi>, <mo>, <mn>, <mfrac>) to LaTeX format.
"""
def __init__(self, strategy: 'Strategy'):
"""
Initialize visitor with a conversion strategy.
Args:
strategy: The subject-specific formatting strategy to use
"""
self.strategy = strategy
self.context: Optional[ConversionContext] = None
@abstractmethod
def visit(self, node: ET.Element) -> str:
"""
Convert MathML element to LaTeX string.
Args:
node: The MathML element to convert
Returns:
LaTeX string representation of the element
Raises:
ValueError: If node is not the expected element type
"""
pass
@abstractmethod
def can_handle(self, node: ET.Element) -> bool:
"""
Check if this visitor can handle the given node.
Args:
node: The MathML element to check
Returns:
True if this visitor can convert the node, False otherwise
"""
pass
@property
@abstractmethod
def element_name(self) -> str:
"""Get the MathML element name this visitor handles (e.g., 'mi', 'mo')."""
pass
class Strategy(ABC):
"""
Abstract base class for subject-specific formatting strategies.
Strategies encapsulate the subject-specific logic for formatting
mathematical notation (e.g., math, chemistry, physics, statistics).
"""
@abstractmethod
def format_identifier(self, text: str, node: ET.Element) -> str:
"""
Format an identifier (<mi> element) according to subject conventions.
Args:
text: The identifier text content
node: The <mi> element node (for accessing attributes)
Returns:
Formatted LaTeX string for the identifier
Examples:
Math: "sin" → "\\sin" (function recognition)
Chemistry: "Fe" → "\\mathrm{Fe}" (element formatting)
"""
pass
@abstractmethod
def format_operator(self, text: str, node: ET.Element) -> str:
"""
Format an operator (<mo> element) according to subject conventions.
Args:
text: The operator text content
node: The <mo> element node
Returns:
Formatted LaTeX string for the operator
Examples:
Math: "+" → "+"
Chemistry: "→" → "\\rightarrow"
"""
pass
@abstractmethod
def format_number(self, text: str, node: ET.Element) -> str:
"""
Format a number (<mn> element) according to subject conventions.
Args:
text: The number text content
node: The <mn> element node
Returns:
Formatted LaTeX string for the number
"""
pass
@abstractmethod
def detect_context(self, equation_text: str) -> bool:
"""
Detect if an equation belongs to this subject area.
Args:
equation_text: The full equation text to analyze
Returns:
True if equation likely belongs to this subject, False otherwise
"""
pass
@property
@abstractmethod
def name(self) -> str:
"""Get the strategy name (e.g., 'math', 'chemistry', 'physics')."""
pass
class VisitorRegistry(ABC):
"""
Abstract base class for visitor registry/factory.
The registry maintains a mapping of MathML element names to their
corresponding visitor classes and provides visitor instances on demand.
"""
@abstractmethod
def register(self, element_name: str, visitor_class: type) -> None:
"""
Register a visitor class for a specific element type.
Args:
element_name: MathML element name (e.g., 'mi', 'mo')
visitor_class: The visitor class to handle this element
"""
pass
@abstractmethod
def get_visitor(self, element_name: str, strategy: Strategy) -> Visitor:
"""
Get a visitor instance for the given element type.
Args:
element_name: MathML element name
strategy: The conversion strategy to use
Returns:
Visitor instance configured with the strategy
Raises:
ValueError: If no visitor registered for element_name
"""
pass
@abstractmethod
def has_visitor(self, element_name: str) -> bool:
"""
Check if a visitor is registered for the element type.
Args:
element_name: MathML element name
Returns:
True if visitor is registered, False otherwise
"""
pass
class Parser(ABC):
"""
Abstract base class for input parsers.
Parsers handle different input formats (DOCX, OMML, MathML) and convert
them to a unified format (MathML strings) for the conversion pipeline.
"""
@abstractmethod
def parse(self, source: str) -> List[str]:
"""
Parse input and return list of MathML strings.
Args:
source: Input source (file path, XML string, etc.)
Returns:
List of MathML equation strings
Raises:
ValueError: If input format is invalid
FileNotFoundError: If source file doesn't exist (for file-based parsers)
RuntimeError: If parsing fails
"""
pass
@abstractmethod
def can_handle(self, source: str) -> bool:
"""
Check if this parser can handle the given input source.
Args:
source: Input source to check
Returns:
True if this parser can handle the source, False otherwise
"""
pass
@property
@abstractmethod
def parser_type(self) -> str:
"""
Get the parser type identifier.
Returns:
Parser type string (e.g., "docx", "omml", "mathml")
"""
pass

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,171 @@
"""
Pipeline orchestrator for the conversion process.
This module implements the pipeline pattern, coordinating the execution
of multiple stages in sequence to convert inputs to LaTeX.
"""
import time
import logging
from typing import List, Optional
from .interfaces import Stage, ConversionContext
logger = logging.getLogger(__name__)
class ConversionPipeline:
"""
Orchestrates conversion through multiple stages.
The pipeline executes stages in sequence, passing the context from
one stage to the next. Each stage can read from and write to the
shared context object.
Example:
>>> pipeline = (ConversionPipeline()
... .add_stage(InputStage())
... .add_stage(ValidationStage())
... .add_stage(ConversionStage())
... .add_stage(OutputStage()))
>>> context = ConversionContext(input_type='mathml', mathml='<math>...</math>')
>>> result = pipeline.process(context)
"""
def __init__(self, enable_validation: bool = True, enable_timing: bool = True):
"""
Initialize the pipeline.
Args:
enable_validation: Whether to validate stage preconditions before execution
enable_timing: Whether to record stage execution times
"""
self.stages: List[Stage] = []
self.enable_validation = enable_validation
self.enable_timing = enable_timing
def add_stage(self, stage: Stage) -> 'ConversionPipeline':
"""
Add a stage to the pipeline (fluent interface).
Args:
stage: The stage to add to the pipeline
Returns:
Self for method chaining
Raises:
TypeError: If stage is not an instance of Stage
"""
if not isinstance(stage, Stage):
raise TypeError(f"Stage must be instance of Stage, got {type(stage)}")
self.stages.append(stage)
logger.debug(f"Added stage: {stage.name}")
return self
def process(self, context: ConversionContext) -> ConversionContext:
"""
Execute all stages in order.
Args:
context: The initial conversion context
Returns:
Updated conversion context after all stages
Raises:
ValueError: If any stage validation fails
RuntimeError: If any stage processing fails
"""
if not self.stages:
raise RuntimeError("Pipeline has no stages configured")
logger.info(f"Starting pipeline with {len(self.stages)} stages")
pipeline_start_time = time.time()
try:
for stage in self.stages:
context = self._execute_stage(stage, context)
# Record total pipeline time
pipeline_end_time = time.time()
context.total_time = pipeline_end_time - pipeline_start_time
logger.info(
f"Pipeline completed successfully in {context.total_time:.4f}s "
f"({len(self.stages)} stages)"
)
return context
except Exception as e:
logger.error(f"Pipeline failed: {e}", exc_info=True)
raise
def _execute_stage(self, stage: Stage, context: ConversionContext) -> ConversionContext:
"""
Execute a single stage with validation and timing.
Args:
stage: The stage to execute
context: The current context
Returns:
Updated context after stage execution
Raises:
ValueError: If stage validation fails
RuntimeError: If stage execution fails
"""
logger.debug(f"Executing stage: {stage.name}")
# Validate preconditions if enabled
if self.enable_validation:
if not stage.validate_input(context):
error_msg = f"Stage '{stage.name}' validation failed"
logger.error(error_msg)
raise ValueError(error_msg)
# Execute stage with timing
if self.enable_timing:
stage_start_time = time.time()
context = stage.process(context)
stage_end_time = time.time()
stage_time = stage_end_time - stage_start_time
context.record_stage_time(stage.name, stage_time)
logger.debug(f"Stage '{stage.name}' completed in {stage_time:.4f}s")
else:
context = stage.process(context)
return context
def clear(self) -> 'ConversionPipeline':
"""
Remove all stages from the pipeline.
Returns:
Self for method chaining
"""
self.stages.clear()
logger.debug("Pipeline cleared")
return self
def get_stages(self) -> List[Stage]:
"""Get a copy of the stages list."""
return self.stages.copy()
def get_stage_count(self) -> int:
"""Get the number of stages in the pipeline."""
return len(self.stages)
def __repr__(self) -> str:
"""String representation for debugging."""
stage_names = [stage.name for stage in self.stages]
return f"ConversionPipeline(stages={stage_names})"
def __str__(self) -> str:
"""Human-readable representation."""
if not self.stages:
return "ConversionPipeline (empty)"
stage_list = "\n ".join([f"{i+1}. {stage.name}" for i, stage in enumerate(self.stages)])
return f"ConversionPipeline ({len(self.stages)} stages):\n {stage_list}"

View File

@@ -0,0 +1,138 @@
"""
OMML to MathML transformation using Microsoft's OMML2MML.XSL.
This module provides XSLT-based transformation of OMML (Office Math Markup Language)
to MathML (Mathematical Markup Language) using Microsoft's official OMML2MML.XSL stylesheet.
Includes fallback to Python-based parser for edge cases.
"""
import os
from typing import List, Optional
from lxml import etree as ET
def omml_to_mathml(omml_xml: str, xsl_path: str, timer: Optional['PerformanceTimer'] = None) -> str:
"""Transform OMML XML string to MathML XML string using OMML2MML.XSL.
Args:
omml_xml: OMML XML string (raw equation markup from Word)
xsl_path: Path to OMML2MML.XSL transformation stylesheet
timer: Optional performance timer for instrumentation
Returns:
MathML XML string
Raises:
ValueError: If OMML XML is invalid
FileNotFoundError: If XSL file not found
Example:
>>> omml = '<m:oMath xmlns:m="..."><m:r><m:t>x</m:t></m:r></m:oMath>'
>>> mathml = omml_to_mathml(omml, 'OMML2MML.xsl')
>>> '<math' in mathml
True
Note:
Falls back to omml_to_mathml_fallback() if XSLT transformation fails.
This ensures robust handling even when XSL file is missing or malformed.
Performance: Uses cached XSL transformer for significant speedup
(avoids reparsing XSL file for every equation).
"""
# Timing context helper
def _time(name):
if timer:
return timer.measure(name)
else:
from contextlib import contextmanager
@contextmanager
def nullcontext(enter_result=None):
yield enter_result
return nullcontext()
# Ensure required namespaces exist for fragments without declarations
with _time("xslt_namespace_preparation"):
xml_input = omml_xml.strip()
if "xmlns:m=" not in xml_input:
xml_input = (
"<root xmlns:m=\"http://schemas.openxmlformats.org/officeDocument/2006/math\" "
"xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">"
f"{xml_input}"
"</root>"
)
try:
with _time("xslt_xml_parsing"):
omml_doc = ET.fromstring(xml_input.encode("utf-8"))
except ET.XMLSyntaxError as e:
raise ValueError(f"Invalid OMML XML: {e}") from e
try:
# PERFORMANCE OPTIMIZATION: Use cached XSL transformer
# This avoids reparsing the XSL file for every equation (major speedup)
from .cache import XSLTransformerCache
with _time("xslt_cache_lookup"):
cache = XSLTransformerCache.get_instance()
transform = cache.get_transformer(xsl_path)
with _time("xslt_transformation"):
result_tree = transform(omml_doc)
result_bytes = ET.tostring(result_tree, encoding="utf-8", pretty_print=True)
return result_bytes.decode("utf-8")
except (ET.XSLTParseError, ET.XSLTApplyError, ET.XMLSyntaxError, OSError, ValueError, FileNotFoundError):
# Fallback to a minimal Python-based OMML→MathML converter for common cases
# Import here to avoid circular dependency
with _time("xslt_fallback_parser"):
from .omml_parser_core import omml_to_mathml_fallback
return omml_to_mathml_fallback(xml_input)
def _local(tag: str) -> str:
"""Extract local tag name without namespace.
Args:
tag: XML tag name, possibly with namespace prefix
Returns:
Local tag name without namespace
Example:
>>> _local('{http://www.w3.org/1998/Math/MathML}mi')
'mi'
>>> _local('mi')
'mi'
"""
if "}" in tag:
return tag.split("}", 1)[1]
return tag
def _text_content(node: ET._Element) -> str:
"""Flatten text under a node (best-effort).
NOTE: Does NOT strip() to preserve space-only content like NBSP and regular spaces.
Spaces are semantically meaningful in mathematical notation.
Args:
node: XML element node
Returns:
Combined text content from node and all children
Example:
>>> from lxml import etree as ET
>>> elem = ET.fromstring('<mi>x</mi>')
>>> _text_content(elem)
'x'
"""
texts: List[str] = []
if node.text:
texts.append(node.text)
for child in node:
texts.append(_text_content(child))
if child.tail:
texts.append(child.tail)
# IMPORTANT: Do not strip() here - spaces are meaningful!
# Space handling is done in the converter (e.g., _convert_spaces_to_latex)
return "".join(texts)

View File

@@ -0,0 +1,73 @@
"""
Data module containing symbol mappings, function definitions, and pattern definitions.
This module provides centralized data structures for:
- Unicode to LaTeX symbol mappings
- Mathematical function definitions
- Chemical element definitions
- Common unit definitions
- Regex patterns for text processing
- Operator and symbol set definitions
"""
from .symbols import (
UNICODE_TO_LATEX,
ADDITIONAL_SYMBOL_REPLACEMENTS,
LATEX_COMMAND_REPLACEMENTS,
MATHJAX_UNSUPPORTED_TO_UNICODE,
REGIONAL_SYMBOL_PREFERENCES,
UNICODE_PASSTHROUGH,
)
from .patterns import (
SPACING_CLEANUP_PATTERNS,
DELIMITER_CLEANUP_PATTERNS,
SIMPLE_STRUCTURE_FIX_PATTERNS,
ASSIGNMENT_OPERATOR_REPLACEMENTS,
)
from .functions import (
MATH_FUNCTIONS,
CHEMICAL_ELEMENTS,
)
from .units import COMMON_UNITS
from .operators import (
MATH_SYMBOLS_SET,
SUMMATION_SYMBOLS,
FONT_TO_LATEX,
LARGE_OPERATORS,
LARGE_OPERATORS_UNICODE,
)
from .chemistry_data import (
MULTI_CHAR_ELEMENTS,
SINGLE_CHAR_ELEMENTS,
ALL_CHEMICAL_ELEMENTS,
CHEMICAL_GROUPS,
CHEMISTRY_VARIABLES,
CHINESE_CHEMICAL_TERMS,
)
__all__ = [
"UNICODE_TO_LATEX",
"ADDITIONAL_SYMBOL_REPLACEMENTS",
"LATEX_COMMAND_REPLACEMENTS",
"MATHJAX_UNSUPPORTED_TO_UNICODE",
"REGIONAL_SYMBOL_PREFERENCES",
"UNICODE_PASSTHROUGH",
"SPACING_CLEANUP_PATTERNS",
"DELIMITER_CLEANUP_PATTERNS",
"SIMPLE_STRUCTURE_FIX_PATTERNS",
"ASSIGNMENT_OPERATOR_REPLACEMENTS",
"MATH_FUNCTIONS",
"CHEMICAL_ELEMENTS",
"COMMON_UNITS",
"MATH_SYMBOLS_SET",
"SUMMATION_SYMBOLS",
"FONT_TO_LATEX",
"LARGE_OPERATORS",
"LARGE_OPERATORS_UNICODE",
"MULTI_CHAR_ELEMENTS",
"SINGLE_CHAR_ELEMENTS",
"ALL_CHEMICAL_ELEMENTS",
"CHEMICAL_GROUPS",
"CHEMISTRY_VARIABLES",
"CHINESE_CHEMICAL_TERMS",
]

View File

@@ -0,0 +1,95 @@
"""
Chemistry-related data for equation formatting.
This module contains chemical elements, groups, and variables used for
detecting and formatting chemistry equations.
"""
# Multi-character chemical elements (2 chars)
# These are STRONG indicators - their presence alone triggers chemistry detection
MULTI_CHAR_ELEMENTS = {
# Period 2
'He', 'Li', 'Be', 'Ne',
# Period 3
'Na', 'Mg', 'Al', 'Si', 'Cl', 'Ar',
# Period 4
'Ca', 'Sc', 'Ti', 'Cr', 'Mn', 'Fe', 'Co', 'Ni', 'Cu', 'Zn',
'Ga', 'Ge', 'As', 'Se', 'Br', 'Kr',
# Period 5
'Rb', 'Sr', 'Zr', 'Nb', 'Mo', 'Tc', 'Ru', 'Rh', 'Pd', 'Ag', 'Cd',
'In', 'Sn', 'Sb', 'Te', 'Xe',
# Period 6
'Cs', 'Ba', 'La', 'Ce', 'Pr', 'Nd', 'Pm', 'Sm', 'Eu', 'Gd', 'Tb', 'Dy',
'Ho', 'Er', 'Tm', 'Yb', 'Lu', 'Hf', 'Ta', 'Re', 'Os', 'Ir', 'Pt',
'Au', 'Hg', 'Tl', 'Pb', 'Bi', 'Po', 'At', 'Rn',
# Period 7
'Fr', 'Ra', 'Ac', 'Th', 'Pa', 'Np', 'Pu', 'Am', 'Cm', 'Bk', 'Cf',
'Es', 'Fm', 'Md', 'No', 'Lr', 'Rf', 'Db', 'Sg', 'Bh', 'Hs', 'Mt', 'Ds',
'Rg', 'Cn', 'Nh', 'Fl', 'Mc', 'Lv', 'Ts', 'Og'
}
# Single-character chemical elements (1 char)
# These need CONTEXT - only formatted as chemistry when chemistry equation is detected
SINGLE_CHAR_ELEMENTS = {
'H', # Hydrogen
'B', # Boron
'C', # Carbon
'N', # Nitrogen
'O', # Oxygen
'F', # Fluorine
'P', # Phosphorus
'S', # Sulfur
'K', # Potassium (but also common variable)
'V', # Vanadium (but also common variable for volume)
'Y', # Yttrium
'I', # Iodine
'W', # Tungsten
'U', # Uranium
}
# All chemical elements (for formatting once chemistry is detected)
ALL_CHEMICAL_ELEMENTS = MULTI_CHAR_ELEMENTS | SINGLE_CHAR_ELEMENTS
# Chemical groups and radicals (strong indicators)
CHEMICAL_GROUPS = {
'OH', # Hydroxide
'SO', # Sulfite/Sulfate (partial)
'NO', # Nitrite/Nitrate (partial)
'CO', # Carbonyl/Carbonate (partial)
'NH', # Amine/Ammonium (partial)
'CH', # Methine/Hydrocarbon (partial)
'PO', # Phosphate (partial)
'ClO', # Chlorate (partial)
}
# Common chemistry variables (should stay italic even in chemistry equations)
# These are NOT chemical elements, they're variables used in chemistry
# NOTE: Keep this list minimal to avoid conflicts with fragmented elements
CHEMISTRY_VARIABLES = {
'c', # Concentration
# NOTE: 'n' removed - conflicts with fragmented Mn (Manganese)
# NOTE: 'M' removed - conflicts with fragmented Mn, Mg (Magnesium)
'p', # Pressure (but P is Phosphorus!)
'T', # Temperature
'm', # Mass
'R', # Gas constant
'E', # Energy (but E is also used in chemistry)
# NOTE: 'V' removed - conflicts with V (Vanadium)
# NOTE: 'K' removed - conflicts with K (Potassium)
# Generic math variables
'x', 'y', 'z',
'a', 'b', 'd', 'q',
}
# Chinese chemical conditions/terms
CHINESE_CHEMICAL_TERMS = {
'高温', # High temperature
'加热', # Heating
'催化剂', # Catalyst
'点燃', # Ignition
'电解', # Electrolysis
'控温', # Temperature control
'加压', # Pressurization
'光照', # Light
'通电', # Electrify
}

View File

@@ -0,0 +1,71 @@
"""
Mathematical functions and chemical elements definitions.
Contains:
- MATH_FUNCTIONS: Set of 60+ recognized mathematical functions
- CHEMICAL_ELEMENTS: Set of 118 chemical elements
"""
# Comprehensive function database
MATH_FUNCTIONS = {
# Trigonometric functions
'sin', 'cos', 'tan', 'sec', 'csc', 'cot',
'sinh', 'cosh', 'tanh', 'sech', 'csch', 'coth',
'arcsin', 'arccos', 'arctan', 'arcsec', 'arccsc', 'arccot',
'asin', 'acos', 'atan', 'asec', 'acsc', 'acot',
'arcsinh', 'arccosh', 'arctanh', 'arcsech', 'arccsch', 'arccoth',
'asinh', 'acosh', 'atanh', 'asech', 'acsch', 'acoth',
# Logarithmic and exponential
'log', 'ln', 'lg', 'exp', 'log2', 'log10',
# Limits and supremum/infimum
'lim', 'limsup', 'liminf', 'sup', 'inf', 'max', 'min',
# Linear algebra
'det', 'dim', 'ker', 'rank', 'trace', 'tr', 'span', 'null',
'col', 'row', 'diag', 'adj',
# Number theory
'gcd', 'lcm', 'mod', 'rem',
# Probability and statistics
'Pr', 'Var', 'Cov', 'Corr', 'Std', 'Med',
# Calculus and analysis
'grad', 'div', 'curl', 'rot', 'arg', 'Im', 'Re',
'sgn', 'sign', 'abs', 'floor', 'ceil', 'round',
# Special functions
'erf', 'erfc', 'gamma', 'Gamma', 'zeta', 'beta', 'Beta',
'sinc', 'jinc',
# Hyperbolic and inverse hyperbolic
'arsinh', 'arcosh', 'artanh', 'arsech', 'arcsch', 'arcoth',
# Other common functions
'deg', 'rad', 'hom', 'end', 'aut', 'id',
}
# Chemical elements for special handling
CHEMICAL_ELEMENTS = {
'H', 'He', 'Li', 'Be', 'B', 'C', 'N', 'O', 'F', 'Ne',
'Na', 'Mg', 'Al', 'Si', 'P', 'S', 'Cl', 'Ar', 'K', 'Ca',
'Sc', 'Ti', 'V', 'Cr', 'Mn', 'Fe', 'Co', 'Ni', 'Cu', 'Zn',
'Ga', 'Ge', 'As', 'Se', 'Br', 'Kr', 'Rb', 'Sr', 'Y', 'Zr',
'Nb', 'Mo', 'Tc', 'Ru', 'Rh', 'Pd', 'Ag', 'Cd', 'In', 'Sn',
'Sb', 'Te', 'I', 'Xe', 'Cs', 'Ba', 'La', 'Ce', 'Pr', 'Nd',
'Pm', 'Sm', 'Eu', 'Gd', 'Tb', 'Dy', 'Ho', 'Er', 'Tm', 'Yb',
'Lu', 'Hf', 'Ta', 'W', 'Re', 'Os', 'Ir', 'Pt', 'Au', 'Hg',
'Tl', 'Pb', 'Bi', 'Po', 'At', 'Rn', 'Fr', 'Ra', 'Ac', 'Th',
'Pa', 'U', 'Np', 'Pu', 'Am', 'Cm', 'Bk', 'Cf', 'Es', 'Fm',
'Md', 'No', 'Lr', 'Rf', 'Db', 'Sg', 'Bh', 'Hs', 'Mt', 'Ds',
'Rg', 'Cn', 'Nh', 'Fl', 'Mc', 'Lv', 'Ts', 'Og'
}
# Mathematical set notation symbols that should be wrapped in \mathrm{}
MATHEMATICAL_SETS = {
'N', 'Z', 'Q', 'R', 'C', # Number sets (Natural, Integer, Rational, Real, Complex)
'A', 'B', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
'O', 'P', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', # Generic sets
}

View File

@@ -0,0 +1,65 @@
"""
Operator and symbol set definitions for MathML to LaTeX conversion.
This module provides:
- Mathematical symbol sets for symbol detection
- Summation symbol mappings
- Font to LaTeX command mappings
- Large operator definitions (Unicode versions)
"""
# Mathematical symbol set for symbol detection
MATH_SYMBOLS_SET = {
"↑", "↓", "→", "←", "↔", "×", "⋅", "·", "÷", "∕", "−", # Added U+2212 MINUS SIGN
"∩", "∪", "⊂", "⊃", "⊆", "⊇", "∈", "∉", "∋", "∌",
"≤", "≥", "≠", "≈", "≅", "≡", "≢", "∼", "≃", "≄",
"≜", "≝", "≞", "≟", "≦", "≧", "≨", "≩", "≲", "≳", "≴", "≵", "≶", "≷", "≸", "≹",
"≺", "≻", "⪯", "⪰", "∝", "⊥",
"∥", "∦", "±", "∓", "∗", "∘", "∙", "√", "∞",
"∂", "∇", "∆", "∫", "∬", "∭", "∮", "∯", "∰",
"∑", "∏", "∐", "⋃", "⋂", "⋁", "⋀", "⨁", "⨂", "⨄",
"∀", "∃", "∄", "⊢", "⊣", "⊤", "⊥", "¬", "∧", "∨",
"⟨", "⟩", "⌊", "⌋", "⌈", "⌉", "⏞", "⏟", "∠", "∁"
}
# Summation symbols for specific processing
SUMMATION_SYMBOLS = {
'∑': 'sum',
'∏': 'prod',
'∫': 'integral',
'Σ': 'sum',
'Π': 'prod'
}
# Font family to LaTeX command mapping
FONT_TO_LATEX = {
'Times New Roman': 'mathrm', # Roman/upright text
'Times': 'mathrm', # Times font variant
'Cambria Math': None, # Default math font (no special formatting)
'Cambria': None, # Cambria variant
'Arial': 'mathsf', # Sans-serif
'Helvetica': 'mathsf', # Helvetica sans-serif
'Courier New': 'mathtt', # Typewriter/monospace
'Courier': 'mathtt', # Courier variant
'Computer Modern': None, # LaTeX default math font
'Latin Modern Math': None, # Modern LaTeX math font
}
# Large operators that should use \limits in display mode (LaTeX commands)
LARGE_OPERATORS = [
"\\sum", "\\prod", "\\int", "\\iint", "\\iiint", "\\oint",
"\\bigcup", "\\bigcap", "\\bigvee", "\\bigwedge",
"\\bigoplus", "\\bigotimes", "\\biguplus", "\\coprod",
"\\lim", "\\limsup", "\\liminf", "\\sup", "\\inf",
"\\max", "\\min"
]
# Unicode large operators for detection
LARGE_OPERATORS_UNICODE = [
"∑", "Σ", # Summation
"∏", "Π", # Product
"∫", # Integral
"⋃", "⋂", # Union, Intersection
"⋁", "⋀", # Logical OR, AND
"⨁", "⨂", "⨄", # Direct sum, tensor product, union
]

View File

@@ -0,0 +1,168 @@
"""
Pattern-based replacements and regex patterns for LaTeX processing.
This module provides regex patterns for:
- Spacing cleanup and normalization
- Delimiter marker cleanup
- LaTeX structure fixes
- Assignment operator normalization
"""
# Assignment operator normalization (line 5529-5530)
ASSIGNMENT_OPERATOR_REPLACEMENTS = [
('∶ =', ':='), # U+2236 RATIO with space and equals
(': =', ':='), # Colon with space and equals
]
# ========================================================================
# Pattern-Based Replacements (Regex Patterns)
# ========================================================================
# Spacing cleanup patterns (consolidated from lines 1289, 1292-1293, 1296, 5566-5567)
SPACING_CLEANUP_PATTERNS = [
# Reduce excessive spaces (line 1289)
(r' {3,}', ' ', 'Reduce 3+ spaces to double space'),
# Remove spaces before delimiters (lines 1292-1293)
(r'\\left\s+\\{', r'\\left\\{', 'Remove space before left brace'),
(r'\\right\s+\\}', r'\\right\\}', 'Remove space before right brace'),
# Remove space between command and braces (line 1296)
(r'\\([a-zA-Z]+)\s+\{', r'\\\1{', 'Remove space between command and opening brace'),
# Add space after set operations when not followed by braces (lines 5566-5567)
(r'\\cap(?![{])', r'\\cap ', 'Add space after \\cap'),
(r'\\cup(?![{])', r'\\cup ', 'Add space after \\cup'),
]
# Delimiter marker cleanup patterns (consolidated from lines 1491-1502, 5433-5447)
# These clean up __DELIM__ markers left from OMML processing
DELIMITER_CLEANUP_PATTERNS = [
# Paired delimiters with content (line 5433)
(r'__DELIM__(.+?)__DELIM__', r'\1', 'Remove paired __DELIM__ markers'),
# Corrupted paired delimiters (lines 5436-5438)
(r'__DELIM_(.+?)__DELIM_', r'\1', 'Remove corrupted paired markers (single underscore)'),
(r'__DELIM__(.+?)__DELIM_', r'\1', 'Remove mixed paired markers (full+corrupted)'),
(r'__DELIM_(.+?)__DELIM__', r'\1', 'Remove mixed paired markers (corrupted+full)'),
# Orphaned markers with single character (lines 5441-5444)
(r'__DELIM__(.)', r'\1', 'Remove orphaned opening marker with character'),
(r'(.)__DELIM__', r'\1', 'Remove orphaned closing marker with character'),
(r'__DELIM_(.)', r'\1', 'Remove corrupted opening marker with character'),
(r'(.)__DELIM_', r'\1', 'Remove corrupted closing marker with character'),
# Standalone markers (lines 5445-5447)
(r'__DELIM__', '', 'Remove any remaining full markers'),
(r'__DELIM_', '', 'Remove any remaining corrupted markers'),
(r'_DELIM__', '', 'Remove reverse corrupted markers'),
# Specific delimiter characters (lines 1491-1497)
(r'__DELIM[_]*\([_]*', '(', 'Clean opening paren markers'),
(r'[_]*\)__DELIM[_]*', ')', 'Clean closing paren markers'),
(r'__DELIM[_]*\[[_]*', '[', 'Clean opening bracket markers'),
(r'[_]*\]__DELIM[_]*', ']', 'Clean closing bracket markers'),
(r'__DELIM[_]*\{[_]*', '{', 'Clean opening brace markers'),
(r'[_]*\}__DELIM[_]*', '}', 'Clean closing brace markers'),
(r'__DELIM[_]*\|[_]*', '|', 'Clean pipe markers (absolute value)'),
# Fragments (lines 1500-1502)
(r'LIM__', '', 'Remove LIM__ fragment'),
(r'LIM_', '', 'Remove LIM_ fragment'),
]
# Simple structure fix patterns (these don't need complex logic)
SIMPLE_STRUCTURE_FIX_PATTERNS = [
# Convert \overset{\dot}{X} to \dot{X} (lines 5570-5571)
(r'\\overset\{\\dot\}\{([a-zA-Z0-9])\}', r'\\dot{\1}', 'Simplify dot overset to \\dot command'),
(r'\\overset\{\.\}\{([a-zA-Z0-9])\}', r'\\dot{\1}', 'Simplify literal dot overset to \\dot command'),
# Fix array column specification (line 5574)
(r'\\begin\{array\{([^}]+)\}\}', r'\\begin{array}{\1}', 'Fix array column specification braces'),
# Convert incorrect fraction to reaction arrow (line 5579)
(r'\\dfrac\{\\underline\{([^}]+)\}\}\{\}', r'\\xlongequal[\\quad]{\1}', 'Convert underline fraction to reaction arrow'),
# Fix nested underset with underbrace (lines 5744-5747)
(r'\\underset\{([^{}]+)\}\{\\underset\{\\underbrace\}\{([^{}]+)\}\}', r'\\underbrace{\2}_{\1}', 'Fix nested underset underbrace pattern'),
# Fix overset with \hat over numbers to \overline (lines 5753-5756)
(r'\\overset\{\\hat\}\{(\\mathrm\{)?(\d+)\}?\}', r'\\overline{\2}', 'Convert hat overset to overline for numbers'),
# Fix underset with textemdash patterns (lines 5802-5823)
(r'\\underset\{\\textemdash\}\{\\underset\{\\textemdash\}\{([^{}]+)\}\}', r'\\underline{\\underline{\1}}', 'Convert nested textemdash underset to double underline'),
(r'\\underset\{\\textemdash\}\{\\underline\{([^{}]+)\}\}', r'\\underline{\\underline{\1}}', 'Convert textemdash underset with underline to double underline'),
(r'\\underset\{\\textemdash\}\{([^{}]+)\}', r'\\underline{\1}', 'Convert textemdash underset to underline'),
]
# ========================================================================
# Additional Symbol Classification Data
# ========================================================================
# Mathematical symbols for classification
MATH_SYMBOLS_SET = {
"↑", "↓", "→", "←", "↔", "×", "⋅", "·", "÷", "∕", "−", # Added U+2212 MINUS SIGN
"∩", "∪", "⊂", "⊃", "⊆", "⊇", "∈", "∉", "∋", "∌",
"≤", "≥", "≠", "≈", "≅", "≡", "≢", "∼", "≃", "≄",
"≜", "≝", "≞", "≟", "≦", "≧", "≨", "≩", "≲", "≳", "≴", "≵", "≶", "≷", "≸", "≹",
"≺", "≻", "⪯", "⪰", "∝", "⊥",
"∥", "∦", "±", "∓", "∗", "∘", "∙", "√", "∞",
"∂", "∇", "∆", "∫", "∬", "∭", "∮", "∯", "∰",
"∑", "∏", "∐", "⋃", "⋂", "⋁", "⋀", "⨁", "⨂", "⨄",
"∀", "∃", "∄", "⊢", "⊣", "⊤", "⊥", "¬", "∧", "∨",
"⟨", "⟩", "⌊", "⌋", "⌈", "⌉", "⏞", "⏟", "∠", "∁"
}
# Summation symbols for specific processing
SUMMATION_SYMBOLS = {
'∑': 'sum',
'∏': 'prod',
'∫': 'integral',
'Σ': 'sum',
'Π': 'prod'
}
# Font family to LaTeX command mapping
FONT_TO_LATEX = {
'Times New Roman': 'mathrm', # Roman/upright text
'Times': 'mathrm', # Times font variant
'Cambria Math': None, # Default math font (no special formatting)
'Cambria': None, # Cambria variant
'Arial': 'mathsf', # Sans-serif
'Helvetica': 'mathsf', # Helvetica sans-serif
'Courier New': 'mathtt', # Typewriter/monospace
'Courier': 'mathtt', # Courier variant
'Computer Modern': None, # LaTeX default math font
'Latin Modern Math': None, # Modern LaTeX math font
}
# Large operators that should use \limits in display mode
LARGE_OPERATORS = [
"\\sum", "\\prod", "\\int", "\\iint", "\\iiint", "\\oint",
"\\bigcup", "\\bigcap", "\\bigvee", "\\bigwedge",
"\\bigoplus", "\\bigotimes", "\\biguplus", "\\coprod",
"\\lim", "\\limsup", "\\liminf", "\\sup", "\\inf",
"\\max", "\\min"
]
# Unicode large operators for detection
LARGE_OPERATORS_UNICODE = [
"∑", "Σ", # Summation
"∏", "Π", # Product
"∫", # Integral
"∮", # Contour integral
"∬", # Double integral
"∭", # Triple integral
"⋂", # Intersection
"⋃", # Union
"⋁", # Logical OR
"⋀", # Logical AND
"⊕", # Direct sum
"⊗", # Tensor product
"⊎", # Multiset sum
"⨁", # Large direct sum
"⨂", # Large tensor product
"⨃", # Large union with plus
"⨆", # Large square union
"⨄", # Large union with plus
]

View File

@@ -0,0 +1,689 @@
"""
Unicode to LaTeX symbol mappings for OMML/MathML to LaTeX conversion.
This module provides comprehensive Unicode symbol to LaTeX command mappings
for mathematical notation, chemical symbols, and special characters.
Architecture
============
This module defines three primary dictionaries that work together to handle
symbol conversion with MathJax compatibility:
1. UNICODE_TO_LATEX (dict[str, str])
- Maps Unicode symbols → LaTeX commands
- Used for symbols that SHOULD be converted to LaTeX
- Contains ~430 symbol mappings organized by category:
* Greek letters (lowercase and uppercase)
* Mathematical operators and relations
* Arrows and directional symbols
* Set theory and logic symbols
* Calculus and analysis symbols (basic integrals)
* Chemistry symbols and arrows
* Geometric shapes and special characters
2. UNICODE_PASSTHROUGH (set[str])
- Unicode symbols that should NEVER be converted to LaTeX
- These symbols are kept as-is for MathJax compatibility
- Contains ~64 advanced mathematical symbols whose LaTeX commands
are not supported or render poorly in MathJax
- Examples: advanced integrals (∯, ∰), special operators (⊛, ⊚)
- IMPORTANT: Symbols in this set are NOT in UNICODE_TO_LATEX
3. MATHJAX_UNSUPPORTED_TO_UNICODE (dict[str, str])
- Inverse of UNICODE_PASSTHROUGH: LaTeX command → Unicode
- Used in post-processing to convert unsupported LaTeX back to Unicode
- Should be exact inverse of symbols in UNICODE_PASSTHROUGH
Conversion Flow
===============
Input Unicode Symbol
↓
Is it in UNICODE_PASSTHROUGH?
├─ YES → Keep as Unicode (don't convert)
└─ NO → Look up in UNICODE_TO_LATEX
↓
Convert to LaTeX command
↓
Post-process: If command in MATHJAX_UNSUPPORTED_TO_UNICODE
↓
Convert back to Unicode for MathJax rendering
Design Principles
=================
1. **No Overlap**: Symbols should be in EITHER UNICODE_TO_LATEX OR
UNICODE_PASSTHROUGH, never both.
2. **Consistency**: MATHJAX_UNSUPPORTED_TO_UNICODE values must exactly
match UNICODE_PASSTHROUGH contents (they are inverses).
3. **Inverse Mapping**: For any symbol in UNICODE_PASSTHROUGH, if there
exists a LaTeX command in MATHJAX_UNSUPPORTED_TO_UNICODE, the command
names must be consistent.
4. **Special Patterns**:
- Empty braces ({}) only for radical symbols (√, ∛, ∜)
- Placeholders (__DDOT__, __DDDOT__) only for accent detection
- Regional preferences handled separately (see REGIONAL_SYMBOL_PREFERENCES)
Testing
=======
Symbol dictionary integrity is validated by:
- tests/test_symbol_dictionaries.py
This test suite ensures:
- No duplicate keys in any dictionary
- No overlap between UNICODE_TO_LATEX and UNICODE_PASSTHROUGH
- Consistency between MATHJAX_UNSUPPORTED and UNICODE_PASSTHROUGH
- Valid mappings and inverse consistency
"""
# Centralized symbol mappings to eliminate duplication
UNICODE_TO_LATEX = {
# Greek letters (lowercase)
"α": "\\alpha", "β": "\\beta", "γ": "\\gamma", "δ": "\\delta",
"ε": "\\varepsilon", "ζ": "\\zeta", "η": "\\eta", "θ": "\\theta",
"ι": "\\iota", "κ": "\\kappa", "λ": "\\lambda", "μ": "\\mu",
"ν": "\\nu", "ξ": "\\xi", "ο": "\\omicron", "π": "\\pi",
"ρ": "\\rho", "σ": "\\sigma", "τ": "\\tau", "υ": "\\upsilon",
"φ": "\\varphi", "χ": "\\chi", "ψ": "\\psi", "ω": "\\omega",
"ϵ": "\\epsilon", "ϑ": "\\vartheta", "ϕ": "\\phi", "ϱ": "\\varrho",
"ϖ": "\\varpi", "ς": "\\varsigma", "ϰ": "\\varkappa",
# Greek letters (uppercase)
"Α": "A", "Β": "B", "Γ": "\\Gamma", "Δ": "\\Delta",
"Ε": "E", "Ζ": "Z", "Η": "H", "Θ": "\\Theta",
"Ι": "I", "Κ": "K", "Λ": "\\Lambda", "Μ": "M",
"Ν": "N", "Ξ": "\\Xi", "Ο": "O", "Π": "\\Pi",
"Ρ": "P", "Σ": "\\Sigma", "Τ": "T", "Υ": "\\Upsilon",
"Φ": "\\Phi", "Χ": "X", "Ψ": "\\Psi", "Ω": "\\Omega",
# Mathematical constants and special symbols
"∞": "\\infty", "∅": "\\emptyset", "∇": "\\nabla", "∂": "\\partial",
"ℏ": "\\hbar", "ℓ": "\\ell", "∠": "\\angle", "∁": "\\complement",
"℘": "\\wp", "ℵ": "\\aleph", "℧": "\\mho",
# Geometric symbols
"△": "\\triangle", "▲": "\\blacktriangle", "▽": "\\triangledown",
"◁": "\\triangleleft", "▷": "\\triangleright", "◀": "\\blacktriangleleft",
"▶": "\\blacktriangleright",
# Square symbols (comprehensive coverage)
"□": "\\square", # U+25A1 WHITE SQUARE
"■": "\\blacksquare", # U+25A0 BLACK SQUARE
"▪": "\\blacksquare", # U+25AA BLACK SMALL SQUARE
"▫": "\\square", # U+25AB WHITE SMALL SQUARE
"◻": "\\square", # U+25FB WHITE MEDIUM SQUARE
"◼": "\\blacksquare", # U+25FC BLACK MEDIUM SQUARE
"◽": "\\square", # U+25FD WHITE MEDIUM SMALL SQUARE
"◾": "\\blacksquare", # U+25FE BLACK MEDIUM SMALL SQUARE
# Other geometric shapes
# NOTE: ▱ (parallelogram) is in UNICODE_PASSTHROUGH
"◇": "\\diamond", "◆": "\\blacklozenge", "○": "\\circ", "●": "\\bullet",
"☆": "\\star", "★": "\\bigstar", "◯": "\\bigcirc",
# Temperature symbols
"℃": "^\\circ\\mathrm{C}", "℉": "^\\circ\\mathrm{F}",
# Basic operators and punctuation
"∶": ":", # U+2236 RATIO to colon for assignment operators
"§": "\\S", "†": "\\dagger", "‵": "\\backprime",
"`": "\\backprime", # U+0060 GRAVE ACCENT (backtick) used as backprime in equations
"―": "\\textemdash", # U+2015 horizontal bar
# Multiplication and division
"×": "\\times", "⋅": "\\cdot", "·": "\\cdot", "÷": "\\div", "∕": "/", "⁄": "/",
"//": "/ /", # Double slash with spacing
"⧸": "/", # U+29F8 BIG SOLIDUS (used in \not patterns but also standalone)
# Mathematical script letters
"ℑ": "\\Im", # Imaginary part (Fraktur I)
"ℜ": "\\Re", # Real part (Fraktur R)
# Arrows (basic)
"→": "\\rightarrow", "←": "\\leftarrow", "↔": "\\leftrightarrow",
"⇒": "\\Longrightarrow", "⇐": "\\Longleftarrow", "⇔": "\\Longleftrightarrow",
"⟹": "\\Longrightarrow", "⟸": "\\Longleftarrow", "⟺": "\\Longleftrightarrow",
"⟷": "\\longleftrightarrow",
"↑": "\\uparrow", "↓": "\\downarrow", "⇑": "\\Uparrow", "⇓": "\\Downarrow",
"↕": "\\updownarrow", "⇕": "\\Updownarrow",
# Arrows (diagonal)
"↗": "\\nearrow", "↘": "\\searrow", "↙": "\\swarrow", "↖": "\\nwarrow",
"⤴": "\\nwarrow", "⤵": "\\searrow", "⤡": "\\swarrow", "⤢": "\\nearrow",
# Arrows (special forms)
# NOTE: Some special arrows (↤ mapsfrom, ⟻ longmapsfrom, ⇜ leftsquigarrow, ⇝ rightsquigarrow) are in UNICODE_PASSTHROUGH
"↦": "\\mapsto",
"⟼": "\\longmapsto",
"↪": "\\hookrightarrow", "↩": "\\hookleftarrow",
"⤷": "\\hookrightarrow", "⤶": "\\hookleftarrow",
"↬": "\\looparrowright", "↫": "\\looparrowleft",
"⇀": "\\rightharpoonup", "↼": "\\leftharpoonup",
"↢": "\\leftarrowtail", "↣": "\\rightarrowtail",
"↰": "\\Lsh", "↱": "\\Rsh",
# Arrows (negated)
"↛": "\\nrightarrow", "↚": "\\nleftarrow", "⇏": "\\nRightarrow", "⇍": "\\nLeftarrow",
"↮": "\\nleftrightarrow", "⇎": "\\nLeftrightarrow",
# Set theory and logic
# Set membership
"∈": "\\in", "∉": "\\notin", "∋": "\\ni", "∌": "\\notni",
"⋲": "\\in", "⋳": "\\ni", "⋴": "\\in", "⋵": "\\ni",
"⋶": "\\in", "⋷": "\\ni", "⋸": "\\pitchfork", "⋹": "\\ltimes",
# Subset and superset relations (basic)
"⊂": "\\subset", "⊃": "\\supset",
"⊆": "\\subseteq", "⊇": "\\supseteq",
"⊄": "\\not\\subset", "⊅": "\\not\\supset",
"⊈": "\\nsubseteq", "⊉": "\\nsupseteq",
"⊊": "\\subsetneq", "⊋": "\\supsetneq",
# Subset and superset relations (extended with double lines)
"⫅": "\\subseteqq", "⫆": "\\supseteqq", # U+2AC5, U+2AC6 subset/supset with double equals
"⫋": "\\subsetneqq", "⫌": "\\supsetneqq", # U+2ACB, U+2ACC not equal variant with double
"⫏": "\\sqsubset", "⫐": "\\sqsupset", # U+2ACF, U+2AD0 square subset/supset
"⫑": "\\sqsubseteq", "⫒": "\\sqsupseteq", # U+2AD1, U+2AD2 square subset/supset equal
"⊏": "\\sqsubset", "⊐": "\\sqsupset", # U+228F, U+2290 square variants
"⊑": "\\sqsubseteq", "⊒": "\\sqsupseteq", # U+2291, U+2292 square equal variants
# Set operations
# NOTE: Some symbols like ⊌ (dotcup), ⊍ (cupdot) are in UNICODE_PASSTHROUGH
"∪": "\\cup", "∩": "\\cap", "∖": "\\setminus",
"⊎": "\\uplus", "⊔": "\\sqcup", "⊓": "\\sqcap",
"⨃": "\\Cup", "⨄": "\\biguplus", "⨅": "\\bigsqcap", "⨆": "\\bigsqcup",
"⩁": "\\Cup", "⩂": "\\Cap", "⩃": "\\cupdot",
# Logical operators
"∧": "\\wedge", "∨": "\\vee", "¬": "\\neg", "⊕": "\\oplus",
"⊗": "\\otimes", "⊙": "\\odot", "⊖": "\\ominus", "⊘": "\\oslash",
"∀": "\\forall", "∃": "\\exists", "∄": "\\nexists",
"⊤": "\\top", "⊥": "\\perp", "⊢": "\\vdash", "⊣": "\\dashv",
"⊨": "\\models", "⊭": "\\not\\models",
"∴": "\\therefore", "∵": "\\because",
"⋏": "\\curlywedge", "⋎": "\\curlyvee",
# Additional logical symbols
"⊩": "\\Vdash", "⊪": "\\Vvdash", "⊫": "\\VDash", "⊬": "\\nvdash",
"⊯": "\\nVdash", "⊮": "\\nVDash", "∎": "\\qed", "◊": "\\lozenge",
"≐": "\\doteq", "≑": "\\doteqdot", "≒": "\\fallingdotseq", "≓": "\\risingdotseq",
# Relations
# Equality and equivalence
"≈": "\\approx", "≅": "\\cong", "≡": "\\equiv", "≢": "\\not\\equiv",
"≠": "\\neq", "≁": "\\not\\sim", "≄": "\\nsim", "≇": "\\ncong",
"~": "\\sim", # ASCII tilde (U+007E) - similarity operator
"∼": "\\sim", "≃": "\\simeq", "≂": "\\eqsim", "≊": "\\approxeq",
# Inequality relations (basic)
# NOTE: Triple relations ⋘ (lll), ⋙ (ggg) are in UNICODE_PASSTHROUGH
"≤": "\\leqslant", "≥": "\\geqslant", "≮": "\\nless", "≯": "\\ngtr",
"≰": "\\nleq", "≱": "\\ngeq", "≪": "\\ll", "≫": "\\gg",
# Inequality relations (extended)
"≦": "\\leqq", "≧": "\\geqq", "≨": "\\lneqq", "≩": "\\gneqq",
"⩽": "\\leqslant", "⩾": "\\geqslant", "⩿": "\\gneq",
"≶": "\\lessgtr", "≷": "\\gtrless", "≸": "\\not\\lessgtr", "≹": "\\not\\gtrless",
# NOTE: Advanced comparison symbols (⪋ lesseqgtr, ⪌ gtreqless, ⪅ lessapprox, etc.) are in UNICODE_PASSTHROUGH
"⪇": "\\lneq", "⪈": "\\gneq",
# Precedence and succession
# NOTE: Advanced precedence symbols (⋞ curlyeqprec, ⋟ curlyeqsucc, ⪷ precapprox, ⪸ succapprox) are in UNICODE_PASSTHROUGH
"≺": "\\prec", "≻": "\\succ", "⪯": "\\preceq", "⪰": "\\succeq",
"⪳": "\\preceqq", "⪴": "\\succeqq",
"⪵": "\\precneqq", "⪶": "\\succneqq",
# Similarity and comparison
"≲": "\\lesssim", "≳": "\\gtrsim", "≴": "\\not\\lesssim", "≵": "\\not\\gtrsim",
"∝": "\\propto", "∥": "\\parallel", "∦": "\\nparallel",
"≍": "\\asymp", "≎": "\\Bumpeq", "≏": "\\bumpeq",
"∽": "\\backsim", "⋍": "\\backsimeq", # U+22CD REVERSED TILDE EQUALS
"≌": "\\cong", # U+224C ALL EQUAL TO (commonly used for congruence)
"⊲": "\\lhd", "⊳": "\\rhd",
"⌢": "\\frown", "⌣": "\\smile",
# Special equality symbols
# NOTE: Some special equality symbols (≔ coloneqq, ≕ eqqcolon, ≘ arceq, ≙ wedgeq, ≚ veeeq) are in UNICODE_PASSTHROUGH
"≜": "\\triangleq", "≝": "\\overset{\\mathrm{def}}{=}", "≞": "\\overset{\\mathrm{m}}{=}",
"≟": "\\overset{?}{=}", "≗": "\\circeq", "≖": "\\eqcirc",
# Calculus and analysis
# Integrals
# NOTE: Many advanced integral symbols (∯, ∰, ∱, etc.) are in UNICODE_PASSTHROUGH
# for direct MathJax rendering, so they are NOT listed here
"∫": "\\int", "∬": "\\iint", "∭": "\\iiint", "⨌": "\\iiiint",
"∮": "\\oint",
# Differentials and operators
"∆": "\\Delta", "∂": "\\partial", "∇": "\\nabla",
"∝": "\\propto", "∞": "\\infty",
# Cardinality symbols
# NOTE: Hebrew letters ℶ (beth), ℷ (gimel), ℸ (daleth) are in UNICODE_PASSTHROUGH
"ℵ": "\\aleph",
# Number sets (use \mathbb for blackboard bold notation)
"ℕ": "\\mathbb{N}", "ℤ": "\\mathbb{Z}", "ℚ": "\\mathbb{Q}",
"ℝ": "\\mathbb{R}", "ℂ": "\\mathbb{C}", "ℍ": "\\mathbb{H}",
"ℙ": "\\mathbb{P}", "𝔽": "\\mathbb{F}", "𝔾": "\\mathbb{G}",
# Special operators
# Basic arithmetic operators
"±": "\\pm", "∓": "\\mp", "−": "-", "∔": "\\dotplus",
# Binary operators (algebraic)
"⋆": "\\star", "∗": "\\ast", "∘": "\\circ", "∙": "\\bullet",
"⋄": "\\diamond", "⋅": "\\cdot", "•": "\\bullet",
# Binary operators (lattice and semilattice)
# NOTE: ⊌ (dotcup), ⊍ (cupdot) are in UNICODE_PASSTHROUGH
"⊔": "\\sqcup", "⊓": "\\sqcap", "⊎": "\\uplus",
# Binary operators (group theory)
# NOTE: Some group theory operators (⋋ leftthreetimes, ⋌ rightthreetimes, ⋇ divideontimes) are in UNICODE_PASSTHROUGH
"⋉": "\\ltimes", "⋊": "\\rtimes",
"⨿": "\\amalg", "⋈": "\\bowtie",
# Binary operators (wreath and semidirect products)
"≀": "\\wr", "⊺": "\\intercal", "⊻": "\\veebar",
# Binary operators (extended)
# NOTE: Advanced box operators (⧄ boxbox, ⧅ boxast, ⧆ boxcirc, ⧇ boxbslash, ⧈ boxslash) are in UNICODE_PASSTHROUGH
"⊝": "\\ominus", "⊞": "\\boxplus", "⊟": "\\boxminus", "⊠": "\\boxtimes",
"⊡": "\\boxdot",
# Big operators
"∑": "\\sum", "∏": "\\prod", "∐": "\\coprod", "⋃": "\\bigcup",
"⋂": "\\bigcap", "⋁": "\\bigvee", "⋀": "\\bigwedge", "⨁": "\\bigoplus",
"⨂": "\\bigotimes", "⨄": "\\biguplus", "⨅": "\\bigsqcap", "⨆": "\\bigsqcup",
# Delimiters and brackets
# Angle brackets
"⟨": "\\langle", "⟩": "\\rangle", "⟪": "\\langle\\langle", "⟫": "\\rangle\\rangle",
"〈": "\\langle", "〉": "\\rangle", # CJK angle brackets
# Floor and ceiling
"⌊": "\\lfloor", "⌋": "\\rfloor", "⌈": "\\lceil", "⌉": "\\rceil",
# Special brackets
# NOTE: Some special brackets (⦅ llparenthesis, ⦆ rrparenthesis, 〚 llbracket, 〛 rrbracket, ⦉ Langle, ⦊ Rangle) are in UNICODE_PASSTHROUGH
"⦃": "\\{", "⦄": "\\}",
"⟦": "[\\![", "⟧": "]\\!]", "⟬": "\\llbracket", "⟭": "\\rrbracket",
"⦇": "\\llparenthesis", "⦈": "\\rrparenthesis",
# Absolute value and norm
"|": "|", "‖": "\\|", "∥": "\\parallel", # ‖ (U+2016) is double vertical line for norms → \|
# Miscellaneous symbols
# NOTE: Radical symbols must include {} braces for valid LaTeX
# Users may type these directly as text (malformed input), so we ensure valid output
"√": "\\sqrt{}", "∛": "\\sqrt[3]{}", "∜": "\\sqrt[4]{}",
"∡": "\\measuredangle", "∢": "\\sphericalangle", "°": "^\\circ",
# Prime notation - both Unicode primes and ASCII apostrophe (commonly used in Word)
"'": "\\prime", # ASCII apostrophe (U+0027) - commonly typed instead of Unicode prime
"′": "\\prime", "″": "\\prime\\prime", "‴": "\\prime\\prime\\prime", "⁗": "\\prime\\prime\\prime\\prime",
"℮": "e", "ℯ": "e", "ⅈ": "i", "ⅉ": "j",
# NOTE: ⋰ (iddots) is in UNICODE_PASSTHROUGH
"…": "\\ldots", "⋯": "\\cdots", "⋮": "\\vdots", "⋱": "\\ddots",
"♭": "\\flat", "♮": "\\natural", "♯": "\\sharp",
# Fullwidth punctuation (CJK)
",": ", ", # U+FF0C fullwidth comma (Chinese comma) → regular comma with space
# Accent marks (for detection in mover elements)
"¨": "__DDOT__", # U+00A8 diaeresis (standalone) - placeholder for ddot detection
"⃛": "__DDDOT__", # U+20DB combining three dots above - placeholder for dddot detection
"̈": "__DDOT__", # U+0308 combining diaeresis - placeholder for ddot detection
"⃛": "__DDDOT__", # U+20DB combining triple dot above - placeholder for dddot detection
# Chemistry arrows and symbols (harpoons)
"⇌": "\\rightleftharpoons", # U+21CC rightleftharpoons (equilibrium)
"⇋": "\\leftrightharpoons", # U+21CB leftrightharpoons (equilibrium reversed)
"⇀": "\\rightharpoonup", # U+21C0 rightharpoonup
"⇁": "\\rightharpoondown", # U+21C1 rightharpoondown
"↼": "\\leftharpoonup", # U+21BC leftharpoonup
"↽": "\\leftharpoondown", # U+21BD leftharpoondown
"⥂": "\\rightleftharpoons", # U+2942 alternate form
"⥃": "\\leftrightharpoons", # U+2943 alternate form
"⥄": "\\rightleftarrows", # U+2944 right over left arrows
"⥅": "\\leftrightarrows", # U+2945 left over right arrows
"⥆": "\\rightleftarrows", # U+2946 right over left arrows
"⥇": "\\leftrightarrows", # U+2947 left over right arrows
"⇄": "\\rightleftarrows", # U+21C4 right over left (already in extended arrows)
"⇆": "\\leftrightarrows", # U+21C6 left over right (already in extended arrows)
"⇉": "\\rightrightarrows", # U+21C9 double right (already in extended arrows)
"⇇": "\\leftleftarrows", # U+21C7 double left (already in extended arrows)
# Overbrace and underbrace characters
"⏞": "\\overbrace", "⏟": "\\underbrace",
# Extended mathematical operators (remaining unique ones)
# NOTE: ⊚ (ocirc), ⊛ (oast) are in UNICODE_PASSTHROUGH
# Extended relation symbols (remaining unique ones)
# NOTE: Several special equality symbols (≔ coloneqq, ≕ eqqcolon, ≘ arceq, ≙ wedgeq, ≚ veeeq) are in UNICODE_PASSTHROUGH
"≆": "\\approxeq", "≛": "\\triangleq",
# Extended arrows (remaining unique ones)
"⇈": "\\upuparrows", "⇊": "\\downdownarrows",
"⇅": "\\updownarrows", "⇵": "\\downuparrows", "↶": "\\curvearrowleft",
"↷": "\\curvearrowright", "↺": "\\circlearrowleft", "↻": "\\circlearrowright",
# Extended logical symbols
# NOTE: ⟀ (lozengeminus) is in UNICODE_PASSTHROUGH
"⟁": "\\lozengepls", "⟂": "\\perp", "⟃": "\\between",
"⟄": "\\pitchfork", "⟅": "\\varpropto", "⟆": "\\backprime", "⟇": "\\caretinsert",
# Extended geometric symbols
"⟐": "\\angle", "⟑": "\\measuredangle", "⟒": "\\sphericalangle", "⟓": "\\rightangle",
"⟔": "\\angle", "⟕": "\\measuredangle", "⟖": "\\sphericalangle", "⟗": "\\rightangle",
# Extended miscellaneous symbols
"⟘": "\\perp", "⟙": "\\diamondsuit", "⟚": "\\heartsuit", "⟛": "\\clubsuit",
"⟜": "\\spadesuit", "⟝": "\\varnothing", "⟞": "\\diameter", "⟟": "\\house",
}
# Additional Symbol Replacements (Consolidated from scattered code)
# ========================================================================
# These symbols are used in specific contexts (e.g., function subscripts)
# and should be applied consistently across the codebase
ADDITIONAL_SYMBOL_REPLACEMENTS = {
# Arrows used in function subscripts (line 1828)
'→': '\\to ',
# These are handled in UNICODE_TO_LATEX but kept for reference in subscript context
# '∞': '\\infty', # Already in UNICODE_TO_LATEX
# '≤': '\\leqslant ', # Already in UNICODE_TO_LATEX
# '≥': '\\geqslant ', # Already in UNICODE_TO_LATEX
# '≠': '\\ne ', # Already in UNICODE_TO_LATEX as \\neq
# '±': '\\pm ', # Already in UNICODE_TO_LATEX
}
# LaTeX command normalization (converting between command variants)
LATEX_COMMAND_REPLACEMENTS = {
# Logical equivalence (line 5545)
'\\Longleftrightarrow': '\\iff ',
# Arrow shorthand (line 5548)
'\\lra': '\\leftrightarrow',
}
# Summation symbols for OMML fallback parser
SUMMATION_SYMBOLS = {
'∑': 'sum',
'∏': 'prod',
'∫': 'integral',
'Σ': 'sum',
'Π': 'prod'
}
# LaTeX commands not supported by MathJax - use Unicode fallback
# Maps LaTeX command (without backslash) to Unicode character
# This comprehensive list ensures proper rendering in MathJax environments
MATHJAX_UNSUPPORTED_TO_UNICODE = {
# =========================================================================
# INTEGRAL OPERATORS - Many not in standard MathJax
# =========================================================================
# Surface and volume integrals
'oiint': '∯', # U+222F SURFACE INTEGRAL
'oiiint': '∰', # U+2230 VOLUME INTEGRAL
# Clockwise/counterclockwise integrals - NOT in MathJax
'intclockwise': '∱', # U+2231 CLOCKWISE INTEGRAL
'varointclockwise': '∲', # U+2232 CLOCKWISE CONTOUR INTEGRAL
'varointctrclockwise': '∳', # U+2233 ANTICLOCKWISE CONTOUR INTEGRAL
# Extended integrals - limited MathJax support
'fint': '⨍', # U+2A0D FINITE PART INTEGRAL
'pointint': '⨎', # U+2A0E PATH INTEGRAL (line integral)
'sqint': '⨏', # U+2A0F INTEGRAL WITH SQUARED OPERATOR
'intlarhk': '⨐', # U+2A10 CIRCULATION FUNCTION
'intbar': '⨑', # U+2A11 INTEGRAL WITH OVERBAR
'intBar': '⨒', # U+2A12 INTEGRAL WITH DOUBLE OVERBAR
'fintbar': '⨓', # U+2A13 FINITE PART INTEGRAL WITH OVERBAR
'ointbar': '⨔', # U+2A14 CONTOUR INTEGRAL WITH OVERBAR
'ointBar': '⨕', # U+2A15 CONTOUR INTEGRAL WITH DOUBLE OVERBAR
'oiintbar': '⨖', # U+2A16 SURFACE INTEGRAL WITH OVERBAR
'oiintBar': '⨗', # U+2A17 SURFACE INTEGRAL WITH DOUBLE OVERBAR
# =========================================================================
# BINARY OPERATORS - Non-standard or limited support
# =========================================================================
# Division operator
'divideontimes': '⋇', # U+22C7 DIVISION TIMES
# Circle operators - non-standard names
'oast': '⊛', # U+229B CIRCLED ASTERISK OPERATOR
'ocirc': '⊚', # U+229A CIRCLED RING OPERATOR
# Box operators - extended (standard ones like boxplus ARE supported)
'boxbox': '⧄', # U+29C4 SQUARED SQUARE
'boxast': '⧅', # U+29C5 SQUARED ASTERISK
'boxcirc': '⧆', # U+29C6 SQUARED SMALL CIRCLE
'boxbslash': '⧇', # U+29C7 SQUARED BACKSLASH
'boxslash': '⧈', # U+29C8 SQUARED SLASH
# Semidirect products
'leftthreetimes': '⋋', # U+22CB LEFT SEMIDIRECT PRODUCT
'rightthreetimes': '⋌', # U+22CC RIGHT SEMIDIRECT PRODUCT
# Set operations with dot
'dotcup': '⊌', # U+228C MULTISET
'cupdot': '⊍', # U+228D MULTISET MULTIPLICATION
# =========================================================================
# ARROWS - Extended arrows with limited MathJax support
# =========================================================================
'mapsfrom': '↤', # U+21A4 LEFTWARDS ARROW FROM BAR
'longmapsfrom': '⟻', # U+27FB LONG LEFTWARDS ARROW FROM BAR
'leftsquigarrow': '⇜', # U+21DC LEFTWARDS SQUIGGLE ARROW
'rightsquigarrow': '⇝', # U+21DD RIGHTWARDS SQUIGGLE ARROW
# 'looparrowleft': '↫', # U+21AB LEFTWARDS ARROW WITH LOOP
# 'looparrowright': '↬', # U+21AC RIGHTWARDS ARROW WITH LOOP
# 'leftarrowtail': '↢', # U+21A2 LEFTWARDS ARROW WITH TAIL
# 'rightarrowtail': '↣', # U+21A3 RIGHTWARDS ARROW WITH TAIL
# 'Lsh': '↰', # U+21B0 UPWARDS ARROW WITH TIP LEFTWARDS
# 'Rsh': '↱', # U+21B1 UPWARDS ARROW WITH TIP RIGHTWARDS
# 'curvearrowleft': '↶', # U+21B6 ANTICLOCKWISE TOP SEMICIRCLE ARROW
# 'curvearrowright': '↷', # U+21B7 CLOCKWISE TOP SEMICIRCLE ARROW
# 'circlearrowleft': '↺', # U+21BA ANTICLOCKWISE OPEN CIRCLE ARROW
# 'circlearrowright': '↻', # U+21BB CLOCKWISE OPEN CIRCLE ARROW
# 'upuparrows': '⇈', # U+21C8 UPWARDS PAIRED ARROWS
# 'downdownarrows': '⇊', # U+21CA DOWNWARDS PAIRED ARROWS
# 'updownarrows': '⇅', # U+21C5 UPWARDS ARROW LEFTWARDS OF DOWNWARDS
# 'downuparrows': '⇵', # U+21F5 DOWNWARDS ARROW LEFTWARDS OF UPWARDS
# =========================================================================
# RELATION SYMBOLS - Extended relations
# =========================================================================
# Triple comparisons
'lll': '⋘', # U+22D8 VERY MUCH LESS-THAN
'ggg': '⋙', # U+22D9 VERY MUCH GREATER-THAN
# Combined relations
'lesseqgtr': '⪋', # U+2A8B LESS-THAN EQUAL TO OR GREATER-THAN
'gtreqless': '⪌', # U+2A8C GREATER-THAN EQUAL TO OR LESS-THAN
'lessapprox': '⪅', # U+2A85 LESS-THAN OR APPROXIMATE
'gtrapprox': '⪆', # U+2A86 GREATER-THAN OR APPROXIMATE
'lnapprox': '⪉', # U+2A89 LESS-THAN AND NOT APPROXIMATE
'gnapprox': '⪊', # U+2A8A GREATER-THAN AND NOT APPROXIMATE
# Precedence relations
'precapprox': '⪷', # U+2AB7 PRECEDES ABOVE ALMOST EQUAL TO
'succapprox': '⪸', # U+2AB8 SUCCEEDS ABOVE ALMOST EQUAL TO
'curlyeqprec': '⋞', # U+22DE EQUAL TO OR PRECEDES
'curlyeqsucc': '⋟', # U+22DF EQUAL TO OR SUCCEEDS
# Similarity relations
# 'backsim': '∽', # U+223D REVERSED TILDE
# 'backsimeq': '⋍', # U+22CD REVERSED TILDE EQUALS
# Special equality symbols
'wedgeq': '≙', # U+2259 ESTIMATES (wedge equals)
'veeeq': '≚', # U+225A EQUIANGULAR TO
'arceq': '≘', # U+2258 CORRESPONDS TO
'coloneqq': '≔', # U+2254 COLON EQUALS
'eqqcolon': '≕', # U+2255 EQUALS COLON
'Coloneqq': '⩴', # U+2A74 DOUBLE COLON EQUAL
'eqcolon': '∹', # U+2239 EXCESS (-:)
# =========================================================================
# BRACKET/DELIMITER SYMBOLS
# =========================================================================
'llbracket': '〚', # U+301A LEFT WHITE SQUARE BRACKET
'rrbracket': '〛', # U+301B RIGHT WHITE SQUARE BRACKET
'llparenthesis': '⦅', # U+2985 LEFT WHITE PARENTHESIS
'rrparenthesis': '⦆', # U+2986 RIGHT WHITE PARENTHESIS
'Langle': '⦉', # U+2989 Z NOTATION LEFT BINDING BRACKET
'Rangle': '⦊', # U+298A Z NOTATION RIGHT BINDING BRACKET
# =========================================================================
# GEOMETRIC AND MISCELLANEOUS SYMBOLS
# =========================================================================
'parallelogram': '▱', # U+25B1 WHITE PARALLELOGRAM
'iddots': '⋰', # U+22F0 UP RIGHT DIAGONAL ELLIPSIS
# 'mho': '℧', # U+2127 INVERTED OHM SIGN
# 'complement': '∁', # U+2201 COMPLEMENT
# Hebrew letters (beyond aleph)
'beth': 'ℶ', # U+2136 BET SYMBOL
'gimel': 'ℷ', # U+2137 GIMEL SYMBOL
'daleth': 'ℸ', # U+2138 DALET SYMBOL
# =========================================================================
# LOGICAL AND SET OPERATORS
# =========================================================================
# Extended subset/superset relations
# 'subseteqq': '⫅', # U+2AC5 SUBSET OF ABOVE EQUALS SIGN
# 'supseteqq': '⫆', # U+2AC6 SUPERSET OF ABOVE EQUALS SIGN
# 'subsetneqq': '⫋', # U+2ACB SUBSET OF ABOVE NOT EQUAL TO
# 'supsetneqq': '⫌', # U+2ACC SUPERSET OF ABOVE NOT EQUAL TO
# Logic symbols
'lozengeminus': '⟀', # U+27C0 THREE DIMENSIONAL ANGLE
'between': '≬', # U+226C BETWEEN
}
# Regional symbol preferences (e.g., Chinese region prefers varnothing over emptyset)
# Maps standard LaTeX command to regional alternative
REGIONAL_SYMBOL_PREFERENCES = {
# Chinese region: prefer varnothing (⌀) over emptyset (∅)
# varnothing has a diagonal line which is clearer in Chinese typography
'emptyset': 'varnothing', # ∅ → ⌀
}
# Unicode symbols that should be kept as-is (NOT converted to LaTeX)
# These are symbols whose LaTeX commands are not supported or render poorly in MathJax.
# By keeping them as Unicode, they render correctly using system fonts.
# This set is the "inverse" of MATHJAX_UNSUPPORTED_TO_UNICODE - same symbols, different direction.
UNICODE_PASSTHROUGH = {
# =========================================================================
# INTEGRAL OPERATORS - Keep as Unicode for MathJax compatibility
# =========================================================================
'∯', # U+222F SURFACE INTEGRAL (oiint)
'∰', # U+2230 VOLUME INTEGRAL (oiiint)
'∱', # U+2231 CLOCKWISE INTEGRAL (intclockwise)
'∲', # U+2232 CLOCKWISE CONTOUR INTEGRAL (varointclockwise)
'∳', # U+2233 ANTICLOCKWISE CONTOUR INTEGRAL (varointctrclockwise)
'⨍', # U+2A0D FINITE PART INTEGRAL (fint)
'⨎', # U+2A0E PATH INTEGRAL (pointint)
'⨏', # U+2A0F INTEGRAL WITH SQUARED OPERATOR (sqint)
'⨐', # U+2A10 CIRCULATION FUNCTION (intlarhk)
'⨑', # U+2A11 INTEGRAL WITH OVERBAR (intbar)
'⨒', # U+2A12 INTEGRAL WITH DOUBLE OVERBAR (intBar)
'⨓', # U+2A13 FINITE PART INTEGRAL WITH OVERBAR (fintbar)
'⨔', # U+2A14 CONTOUR INTEGRAL WITH OVERBAR (ointbar)
'⨕', # U+2A15 CONTOUR INTEGRAL WITH DOUBLE OVERBAR (ointBar)
'⨖', # U+2A16 SURFACE INTEGRAL WITH OVERBAR (oiintbar)
'⨗', # U+2A17 SURFACE INTEGRAL WITH DOUBLE OVERBAR (oiintBar)
# =========================================================================
# BINARY OPERATORS - Keep as Unicode
# =========================================================================
'⋇', # U+22C7 DIVISION TIMES (divideontimes)
'⊛', # U+229B CIRCLED ASTERISK OPERATOR (oast)
'⊚', # U+229A CIRCLED RING OPERATOR (ocirc)
'⧄', # U+29C4 SQUARED SQUARE (boxbox)
'⧅', # U+29C5 SQUARED ASTERISK (boxast)
'⧆', # U+29C6 SQUARED SMALL CIRCLE (boxcirc)
'⧇', # U+29C7 SQUARED BACKSLASH (boxbslash)
'⧈', # U+29C8 SQUARED SLASH (boxslash)
'⋋', # U+22CB LEFT SEMIDIRECT PRODUCT (leftthreetimes)
'⋌', # U+22CC RIGHT SEMIDIRECT PRODUCT (rightthreetimes)
'⊌', # U+228C MULTISET (dotcup)
'⊍', # U+228D MULTISET MULTIPLICATION (cupdot)
# =========================================================================
# ARROWS - Keep as Unicode for consistency
# =========================================================================
'↤', # U+21A4 LEFTWARDS ARROW FROM BAR (mapsfrom)
'⟻', # U+27FB LONG LEFTWARDS ARROW FROM BAR (longmapsfrom)
'⇜', # U+21DC LEFTWARDS SQUIGGLE ARROW (leftsquigarrow)
'⇝', # U+21DD RIGHTWARDS SQUIGGLE ARROW (rightsquigarrow)
# '↫', # U+21AB LEFTWARDS ARROW WITH LOOP (looparrowleft)
# '↬', # U+21AC RIGHTWARDS ARROW WITH LOOP (looparrowright)
# '↢', # U+21A2 LEFTWARDS ARROW WITH TAIL (leftarrowtail)
# '↣', # U+21A3 RIGHTWARDS ARROW WITH TAIL (rightarrowtail)
# '↰', # U+21B0 UPWARDS ARROW WITH TIP LEFTWARDS (Lsh)
# '↱', # U+21B1 UPWARDS ARROW WITH TIP RIGHTWARDS (Rsh)
# '↶', # U+21B6 ANTICLOCKWISE TOP SEMICIRCLE ARROW (curvearrowleft)
# '↷', # U+21B7 CLOCKWISE TOP SEMICIRCLE ARROW (curvearrowright)
# '↺', # U+21BA ANTICLOCKWISE OPEN CIRCLE ARROW (circlearrowleft)
# '↻', # U+21BB CLOCKWISE OPEN CIRCLE ARROW (circlearrowright)
# '⇈', # U+21C8 UPWARDS PAIRED ARROWS (upuparrows)
# '⇊', # U+21CA DOWNWARDS PAIRED ARROWS (downdownarrows)
# '⇅', # U+21C5 UPWARDS ARROW LEFTWARDS OF DOWNWARDS (updownarrows)
# '⇵', # U+21F5 DOWNWARDS ARROW LEFTWARDS OF UPWARDS (downuparrows)
# =========================================================================
# RELATION SYMBOLS - Keep as Unicode
# =========================================================================
'⋘', # U+22D8 VERY MUCH LESS-THAN (lll)
'⋙', # U+22D9 VERY MUCH GREATER-THAN (ggg)
'⪋', # U+2A8B LESS-THAN EQUAL TO OR GREATER-THAN (lesseqgtr)
'⪌', # U+2A8C GREATER-THAN EQUAL TO OR LESS-THAN (gtreqless)
'⪅', # U+2A85 LESS-THAN OR APPROXIMATE (lessapprox)
'⪆', # U+2A86 GREATER-THAN OR APPROXIMATE (gtrapprox)
'⪉', # U+2A89 LESS-THAN AND NOT APPROXIMATE (lnapprox)
'⪊', # U+2A8A GREATER-THAN AND NOT APPROXIMATE (gnapprox)
'⪷', # U+2AB7 PRECEDES ABOVE ALMOST EQUAL TO (precapprox)
'⪸', # U+2AB8 SUCCEEDS ABOVE ALMOST EQUAL TO (succapprox)
'⋞', # U+22DE EQUAL TO OR PRECEDES (curlyeqprec)
'⋟', # U+22DF EQUAL TO OR SUCCEEDS (curlyeqsucc)
# '∽', # U+223D REVERSED TILDE (backsim)
# '⋍', # U+22CD REVERSED TILDE EQUALS (backsimeq)
'≙', # U+2259 ESTIMATES (wedgeq)
'≚', # U+225A EQUIANGULAR TO (veeeq)
'≘', # U+2258 CORRESPONDS TO (arceq)
'≔', # U+2254 COLON EQUALS (coloneqq)
'≕', # U+2255 EQUALS COLON (eqqcolon)
'⩴', # U+2A74 DOUBLE COLON EQUAL (Coloneqq)
'∹', # U+2239 EXCESS (eqcolon)
'≬', # U+226C BETWEEN (between)
# =========================================================================
# BRACKET/DELIMITER SYMBOLS - Keep as Unicode
# =========================================================================
'〚', # U+301A LEFT WHITE SQUARE BRACKET (llbracket)
'〛', # U+301B RIGHT WHITE SQUARE BRACKET (rrbracket)
'⦅', # U+2985 LEFT WHITE PARENTHESIS (llparenthesis)
'⦆', # U+2986 RIGHT WHITE PARENTHESIS (rrparenthesis)
'⦉', # U+2989 Z NOTATION LEFT BINDING BRACKET (Langle)
'⦊', # U+298A Z NOTATION RIGHT BINDING BRACKET (Rangle)
# =========================================================================
# GEOMETRIC AND MISCELLANEOUS SYMBOLS - Keep as Unicode
# =========================================================================
'▱', # U+25B1 WHITE PARALLELOGRAM (parallelogram)
'⋰', # U+22F0 UP RIGHT DIAGONAL ELLIPSIS (iddots)
# '℧', # U+2127 INVERTED OHM SIGN (mho)
# '∁', # U+2201 COMPLEMENT (complement)
'ℶ', # U+2136 BET SYMBOL (beth)
'ℷ', # U+2137 GIMEL SYMBOL (gimel)
'ℸ', # U+2138 DALET SYMBOL (daleth)
'⟀', # U+27C0 THREE DIMENSIONAL ANGLE (lozengeminus)
# =========================================================================
# LOGICAL AND SET OPERATORS - Keep as Unicode
# =========================================================================
# '⫅', # U+2AC5 SUBSET OF ABOVE EQUALS SIGN (subseteqq)
# '⫆', # U+2AC6 SUPERSET OF ABOVE EQUALS SIGN (supseteqq)
# '⫋', # U+2ACB SUBSET OF ABOVE NOT EQUAL TO (subsetneqq)
# '⫌', # U+2ACC SUPERSET OF ABOVE NOT EQUAL TO (supsetneqq)
}

View File

@@ -0,0 +1,63 @@
"""
Common unit definitions for chemistry, physics, and mathematics.
Contains:
- COMMON_UNITS: Set of common units that should be formatted as \\mathrm{unit}
Includes: chemistry units, SI base units, SI derived units, non-SI units,
angular units, time units, concentration units, biological units
"""
# Common units that should be formatted as ~\mathrm{unit}~ (with preceding and trailing space)
COMMON_UNITS = {
# Chemistry units
'mol', 'M', 'mM', 'µM', 'nM', 'pM', 'pH', 'pOH', 'atm', 'bar', 'Torr', 'eq',
'aq', 'sol', 'ppm', 'ppb', 'vol', 'wt',
# SI base units
'kg', 'g', 'mg', 'µg', 'ng', 'pg', 'ton', 'tonne',
'm', 'km', 'cm', 'mm', 'µm', 'nm', 'pm', 'Å',
's', 'ms', 'µs', 'ns', 'ps', 'min', 'h', 'hr', 'day',
'A', 'mA', 'µA', 'nA', 'pA',
'K', 'mK', 'µK', 'nK',
'cd', 'lm', 'lx',
# SI derived units
'Hz', 'kHz', 'MHz', 'GHz', 'THz',
'N', 'kN', 'MN', 'µN', 'nN', 'pN',
'Pa', 'kPa', 'MPa', 'GPa', 'hPa', 'mbar',
'J', 'kJ', 'MJ', 'µJ', 'nJ', 'pJ', 'mJ',
'W', 'kW', 'MW', 'GW', 'mW', 'µW', 'nW', 'pW',
'C', 'mC', 'µC', 'nC', 'pC',
'V', 'kV', 'MV', 'mV', 'µV', 'nV',
'F', 'mF', 'µF', 'nF', 'pF',
'Ω', 'kΩ', 'MΩ', 'GΩ', 'mΩ', 'µΩ',
'S', 'mS', 'µS', 'nS',
'Wb', 'mWb', 'µWb', 'nWb',
'T', 'mT', 'µT', 'nT', 'pT',
'H', 'mH', 'µH', 'nH', 'pH',
# Common non-SI units
'L', 'mL', 'µL', 'nL', 'pL', 'dL', 'cL',
'°C', '°F', '°K', 'celsius', 'fahrenheit', 'kelvin',
'eV', 'keV', 'MeV', 'GeV', 'TeV', 'meV', 'µeV',
'cal', 'kcal', 'Cal',
'Bq', 'kBq', 'MBq', 'GBq', 'mBq',
'Gy', 'mGy', 'µGy', 'kGy',
'Sv', 'mSv', 'µSv', 'kSv',
# Angular units
'rad', 'mrad', 'µrad', 'sr',
'deg', 'arcmin', 'arcsec',
# Time units
'ms', 'µs', 'ns', 'ps', 'fs', 'as',
'year', 'yr', 'month', 'week', 'day', 'hr', 'min',
# Concentration and biological units
'IU', 'U', 'unit', 'units', 'Da', 'kDa', 'MDa',
'bp', 'kbp', 'Mbp', 'kb', 'Mb', 'Gb',
'rpm', 'rps', 'Hz', 'cps',
# Physics constants (when used as units)
'c', 'h', 'ħ', 'kB', 'NA', 'R', 'σ', 'ε₀', 'μ₀',
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,26 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Standalone CLI runner for omml2tex converter.
execute: python /path/omml_mathml2latex.py --json input.json
"""
import sys
import os
# 添加项目根目录到系统路径(上两级目录)
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if project_root not in sys.path:
sys.path.insert(0, project_root)
# 直接导入主模块,避免通过 __init__.py 的相对导入
from omml_mathml2latex.__main__ import main
if __name__ == '__main__':
default_args = [
'--output-format', 'json',
'--parallel',
'--quiet'
]
sys.argv.extend(default_args)
sys.exit(main())

View File

@@ -0,0 +1,13 @@
"""
Parallel processing module for omml2tex.
Provides multiprocessing and multithreading capabilities for efficient
batch conversion of equations.
"""
from .batch_processor import ParallelConverter, BatchProcessor
__all__ = [
'ParallelConverter',
'BatchProcessor',
]

View File

@@ -0,0 +1,449 @@
"""
Batch processor with multiprocessing and multithreading support.
This module provides parallel processing capabilities for converting
large batches of equations efficiently.
"""
import os
import multiprocessing as mp
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
from typing import List, Dict, Any, Optional, Callable
from pathlib import Path
import logging
logger = logging.getLogger(__name__)
class ParallelConverter:
"""
Parallel equation converter using multiprocessing.
This class handles CPU-bound equation conversion across multiple processes
to bypass Python's GIL and maximize CPU utilization.
Example:
>>> converter = ParallelConverter(workers=4)
>>> results = converter.convert_batch(mathml_equations, subject_mode='math')
>>> successful = [r for r in results if r['success']]
"""
def __init__(
self,
workers: Optional[int] = None,
subject_mode: str = "auto",
wrap_identifiers: bool = False,
prefer_xlongequal: bool = True,
universal_delimiter_scaling: bool = True,
show_progress: bool = True,
):
"""
Initialize parallel converter.
Args:
workers: Number of worker processes (default: CPU count)
subject_mode: Subject-specific formatting mode
wrap_identifiers: Whether to wrap identifiers in \\mathrm{}
prefer_xlongequal: Whether to use \\xlongequal for arrows
universal_delimiter_scaling: Whether to add \\left\\right to all delimiters
show_progress: Whether to show progress bar
"""
self.workers = workers or min(mp.cpu_count(), 8) # Cap at 8 to avoid overhead
self.subject_mode = subject_mode
self.wrap_identifiers = wrap_identifiers
self.prefer_xlongequal = prefer_xlongequal
self.universal_delimiter_scaling = universal_delimiter_scaling
self.show_progress = show_progress
def convert_batch(
self,
mathml_equations: List[str],
quiet: bool = False,
) -> List[Dict[str, Any]]:
"""
Convert a batch of MathML equations to LaTeX in parallel.
Args:
mathml_equations: List of MathML equation strings
quiet: Suppress progress output
Returns:
List of result dictionaries with keys:
- 'index': Original equation index
- 'latex': Converted LaTeX (if successful)
- 'success': Boolean success flag
- 'error': Error message (if failed)
Example:
>>> equations = ['<math><mi>x</mi></math>', '<math><mi>y</mi></math>']
>>> results = converter.convert_batch(equations)
>>> all(r['success'] for r in results)
True
"""
if not mathml_equations:
return []
# For small batches, sequential processing is faster due to overhead
if len(mathml_equations) < 5:
return self._convert_sequential(mathml_equations, quiet)
# Prepare conversion tasks
tasks = [
{
'index': i,
'mathml': eq,
'subject_mode': self.subject_mode,
'wrap_identifiers': self.wrap_identifiers,
'prefer_xlongequal': self.prefer_xlongequal,
'universal_delimiter_scaling': self.universal_delimiter_scaling,
}
for i, eq in enumerate(mathml_equations)
]
results = []
try:
with ProcessPoolExecutor(max_workers=self.workers) as executor:
# Submit all tasks
future_to_task = {
executor.submit(_convert_single_equation, task): task
for task in tasks
}
# Collect results as they complete
completed = 0
total = len(tasks)
for future in as_completed(future_to_task):
task = future_to_task[future]
completed += 1
try:
result = future.result()
results.append(result)
if not quiet and self.show_progress:
print(f"\rProgress: {completed}/{total} equations processed", end='', flush=True)
except Exception as e:
logger.error(f"Failed to convert equation {task['index']}: {e}")
results.append({
'index': task['index'],
'success': False,
'error': str(e),
})
if not quiet and self.show_progress:
print() # New line after progress
except Exception as e:
logger.error(f"Parallel processing failed: {e}. Falling back to sequential.")
return self._convert_sequential(mathml_equations, quiet)
# Sort results by original index to maintain order
results.sort(key=lambda r: r['index'])
return results
def _convert_sequential(
self,
mathml_equations: List[str],
quiet: bool = False,
) -> List[Dict[str, Any]]:
"""
Fallback sequential conversion (single-threaded).
Args:
mathml_equations: List of MathML equations
quiet: Suppress output
Returns:
List of conversion results
"""
# Import here to avoid circular dependencies
from ..compat import LegacyMathMLToTeXConverter
converter = LegacyMathMLToTeXConverter(
wrap_identifiers_in_mathrm=self.wrap_identifiers,
prefer_xlongequal=self.prefer_xlongequal,
subject_mode=self.subject_mode,
universal_delimiter_scaling=self.universal_delimiter_scaling,
)
results = []
for i, mathml in enumerate(mathml_equations):
try:
latex = converter.convert(mathml)
results.append({
'index': i,
'latex': latex,
'success': True,
})
except Exception as e:
results.append({
'index': i,
'success': False,
'error': str(e),
})
if not quiet and self.show_progress:
print(f"\rProgress: {i+1}/{len(mathml_equations)} equations processed", end='', flush=True)
if not quiet and self.show_progress:
print() # New line
return results
class BatchProcessor:
"""
Batch file processor with parallel file and equation processing.
Processes multiple DOCX files in parallel, with parallel equation
conversion within each file.
Example:
>>> processor = BatchProcessor(file_workers=2, equation_workers=4)
>>> results = processor.process_files(['doc1.docx', 'doc2.docx'])
"""
def __init__(
self,
file_workers: Optional[int] = None,
equation_workers: Optional[int] = None,
subject_mode: str = "auto",
wrap_identifiers: bool = False,
prefer_xlongequal: bool = True,
):
"""
Initialize batch processor.
Args:
file_workers: Workers for parallel file I/O (default: 2)
equation_workers: Workers for parallel equation conversion (default: CPU count)
subject_mode: Subject formatting mode
wrap_identifiers: Wrap identifiers in \\mathrm{}
prefer_xlongequal: Use \\xlongequal for arrows
"""
self.file_workers = file_workers or 2
self.equation_workers = equation_workers
self.subject_mode = subject_mode
self.wrap_identifiers = wrap_identifiers
self.prefer_xlongequal = prefer_xlongequal
def process_files(
self,
docx_paths: List[str],
output_dir: Optional[str] = None,
quiet: bool = False,
) -> Dict[str, Any]:
"""
Process multiple DOCX files in parallel.
Args:
docx_paths: List of DOCX file paths
output_dir: Optional output directory for results
quiet: Suppress progress output
Returns:
Dictionary with processing statistics and results
Example:
>>> files = ['test1.docx', 'test2.docx']
>>> results = processor.process_files(files, output_dir='output/')
>>> print(f"Processed {results['total_equations']} equations")
"""
if not docx_paths:
return {'files_processed': 0, 'total_equations': 0, 'results': []}
# For small file counts, sequential is fine
if len(docx_paths) <= 2:
return self._process_files_sequential(docx_paths, output_dir, quiet)
results = []
total_equations = 0
try:
with ThreadPoolExecutor(max_workers=self.file_workers) as executor:
# Submit file processing tasks
future_to_path = {
executor.submit(
self._process_single_file,
path,
output_dir,
quiet,
): path
for path in docx_paths
}
# Collect results
for future in as_completed(future_to_path):
path = future_to_path[future]
try:
result = future.result()
results.append(result)
total_equations += result['equation_count']
if not quiet:
print(f"✓ Processed {path}: {result['equation_count']} equations")
except Exception as e:
logger.error(f"Failed to process {path}: {e}")
results.append({
'file': path,
'success': False,
'error': str(e),
})
except Exception as e:
logger.error(f"Batch file processing failed: {e}")
return self._process_files_sequential(docx_paths, output_dir, quiet)
return {
'files_processed': len([r for r in results if r.get('success', False)]),
'total_files': len(docx_paths),
'total_equations': total_equations,
'results': results,
}
def _process_single_file(
self,
docx_path: str,
output_dir: Optional[str],
quiet: bool,
) -> Dict[str, Any]:
"""
Process a single DOCX file.
Args:
docx_path: Path to DOCX file
output_dir: Output directory
quiet: Suppress output
Returns:
Processing result dictionary
"""
from ..parsers import InputParserFactory
try:
# Parse DOCX to get MathML equations
parser = InputParserFactory.create_parser(docx_path, parser_type='docx')
mathml_equations = parser.parse(docx_path)
# Convert equations in parallel
converter = ParallelConverter(
workers=self.equation_workers,
subject_mode=self.subject_mode,
wrap_identifiers=self.wrap_identifiers,
prefer_xlongequal=self.prefer_xlongequal,
show_progress=not quiet,
)
conversion_results = converter.convert_batch(mathml_equations, quiet=quiet)
# Optionally write output
if output_dir:
self._write_output(docx_path, conversion_results, output_dir)
return {
'file': docx_path,
'success': True,
'equation_count': len(mathml_equations),
'successful_conversions': sum(1 for r in conversion_results if r['success']),
'results': conversion_results,
}
except Exception as e:
logger.error(f"Error processing {docx_path}: {e}")
return {
'file': docx_path,
'success': False,
'error': str(e),
}
def _process_files_sequential(
self,
docx_paths: List[str],
output_dir: Optional[str],
quiet: bool,
) -> Dict[str, Any]:
"""Sequential fallback for file processing."""
results = []
total_equations = 0
for path in docx_paths:
result = self._process_single_file(path, output_dir, quiet)
results.append(result)
if result.get('success'):
total_equations += result['equation_count']
return {
'files_processed': len([r for r in results if r.get('success', False)]),
'total_files': len(docx_paths),
'total_equations': total_equations,
'results': results,
}
def _write_output(
self,
docx_path: str,
results: List[Dict[str, Any]],
output_dir: str,
) -> None:
"""Write conversion results to output file."""
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
# Generate output filename
input_name = Path(docx_path).stem
output_file = output_path / f"{input_name}_converted.tex"
# Write LaTeX equations
with open(output_file, 'w', encoding='utf-8') as f:
for i, result in enumerate(results, 1):
if result['success']:
f.write(f"% Equation {i}\n")
f.write(f"${result['latex']}$\n\n")
else:
f.write(f"% Equation {i} - Error: {result.get('error', 'Unknown')}\n\n")
def _convert_single_equation(task: Dict[str, Any]) -> Dict[str, Any]:
"""
Worker function for parallel equation conversion.
This function is called in separate processes by ProcessPoolExecutor.
Must be defined at module level for pickling.
Args:
task: Dictionary with conversion parameters
Returns:
Result dictionary with conversion outcome
"""
# Import here (in worker process) to avoid pickling issues
from ..compat import LegacyMathMLToTeXConverter
try:
# Create converter instance in worker process
converter = LegacyMathMLToTeXConverter(
wrap_identifiers_in_mathrm=task['wrap_identifiers'],
prefer_xlongequal=task['prefer_xlongequal'],
subject_mode=task['subject_mode'],
universal_delimiter_scaling=task['universal_delimiter_scaling'],
)
# Convert equation
latex = converter.convert(task['mathml'])
return {
'index': task['index'],
'latex': latex,
'success': True,
}
except Exception as e:
return {
'index': task['index'],
'success': False,
'error': str(e),
}

View File

@@ -0,0 +1,18 @@
"""
Input parser classes for handling different input formats.
This package contains parser implementations for DOCX, OMML, and MathML
input formats, along with a factory for auto-detection and creation.
"""
from .factory import InputParserFactory
from .docx_parser import DocxParser
from .omml_parser import OmmlParser
from .mathml_parser import MathMLParser
__all__ = [
'InputParserFactory',
'DocxParser',
'OmmlParser',
'MathMLParser',
]

View File

@@ -0,0 +1,143 @@
"""
DOCX parser for Microsoft Word documents.
This parser handles .docx files, extracting OMML equations and converting
them to MathML format using the existing DOCX processing pipeline.
"""
import os
from typing import List
from ..core.interfaces import Parser
class DocxParser(Parser):
"""
Parser for Microsoft Word .docx files.
This parser handles .docx files by extracting OMML equations and
converting them to MathML using the existing DOCX processing pipeline.
Examples:
Parse DOCX file:
>>> parser = DocxParser()
>>> results = parser.parse("equations.docx")
>>> isinstance(results, list)
True
"""
def __init__(self):
"""Initialize the DOCX parser."""
pass
def parse(self, source: str) -> List[str]:
"""
Parse DOCX file and return list of MathML equation strings.
Args:
source: Path to .docx file
Returns:
List of MathML equation strings (extracted from DOCX)
Raises:
FileNotFoundError: If DOCX file doesn't exist
ValueError: If file is not a valid .docx file
RuntimeError: If DOCX processing fails
Examples:
>>> parser = DocxParser()
>>> # Assuming test.docx exists with equations
>>> results = parser.parse("test.docx")
>>> all(isinstance(eq, str) for eq in results)
True
"""
# Validate file exists
if not os.path.exists(source):
raise FileNotFoundError(f"DOCX file not found: {source}")
# Validate file extension
if not source.lower().endswith('.docx'):
raise ValueError(f"File must have .docx extension: {source}")
# Import here to avoid circular dependencies
from ..processing import OMMLExtractor
from ..core import omml_to_mathml
from ..utils.styling_postprocessor import apply_styling_to_mathml_from_omml
# Get XSL path (in core module)
current_dir = os.path.dirname(os.path.abspath(__file__))
core_dir = os.path.join(os.path.dirname(current_dir), 'core')
xsl_path = os.path.join(core_dir, 'OMML2MML.xsl')
try:
# Extract OMML from DOCX using OMMLExtractor
extractor = OMMLExtractor()
equation_dicts = extractor.extract_from_docx(source)
if not equation_dicts:
# No equations found
return []
# Convert each OMML to MathML
results = []
for eq_dict in equation_dicts:
try:
# Extract the cleaned OMML string from the dictionary
omml_str = eq_dict.get('cleaned_omml', eq_dict.get('omml', ''))
if not omml_str:
continue
# Step 1: XSLT transformation
mathml_str = omml_to_mathml(omml_str, xsl_path)
# Step 2: Post-process to fix table structure and add styling
# CRITICAL: This fixes the OMML2MML.xsl bug where <mtr><mrow> should be <mtr><mtd>
mathml_str = apply_styling_to_mathml_from_omml(omml_str, mathml_str)
if mathml_str and mathml_str.strip():
results.append(mathml_str)
except Exception as e:
# Log error but continue with other equations
print(f"Warning: Failed to convert OMML equation: {e}")
continue
return results
except Exception as e:
raise RuntimeError(f"DOCX processing failed: {e}")
def can_handle(self, source: str) -> bool:
"""
Check if this parser can handle the given input source.
Args:
source: Input source to check
Returns:
True if source is a .docx file, False otherwise
Examples:
>>> parser = DocxParser()
>>> parser.can_handle("equations.docx")
True
>>> parser.can_handle("<math><mi>x</mi></math>")
False
"""
# Check file extension
if source.lower().endswith('.docx'):
return True
# Check if it's an existing file with .docx extension
if os.path.exists(source) and source.lower().endswith('.docx'):
return True
return False
@property
def parser_type(self) -> str:
"""Get the parser type identifier."""
return "docx"
def __repr__(self) -> str:
"""String representation for debugging."""
return "DocxParser()"

View File

@@ -0,0 +1,144 @@
"""
Input parser factory for auto-detecting and creating appropriate parsers.
This module provides centralized parser creation with auto-detection
support for determining the most appropriate parser based on input source.
"""
import os
from typing import Union, List
from ..core.interfaces import Parser
class InputParserFactory:
"""
Factory for creating input parsers based on source content.
This factory provides centralized parser creation with support for:
- Auto-detection based on file extension or content
- Explicit parser selection by type
- Clear error messages for unsupported formats
"""
@staticmethod
def create_parser(source: str, parser_type: Union[str, None] = None) -> Parser:
"""
Create an appropriate parser for the input source.
Args:
source: Input source (file path or content string)
parser_type: Optional explicit parser type
("docx", "omml", "mathml", or None for auto-detection)
Returns:
Parser instance appropriate for the source
Raises:
ValueError: If input format cannot be determined or is unsupported
Examples:
>>> factory = InputParserFactory()
>>> parser = factory.create_parser("equations.docx")
>>> isinstance(parser, DocxParser)
True
>>> parser = factory.create_parser("<math><mi>x</mi></math>")
>>> isinstance(parser, MathMLParser)
True
>>> parser = factory.create_parser(xml_content, parser_type="omml")
>>> isinstance(parser, OmmlParser)
True
"""
# Import here to avoid circular dependencies
from .docx_parser import DocxParser
from .omml_parser import OmmlParser
from .mathml_parser import MathMLParser
# Explicit parser type specified
if parser_type:
parser_type = parser_type.lower()
if parser_type == "docx":
return DocxParser()
elif parser_type == "omml":
return OmmlParser()
elif parser_type == "mathml":
return MathMLParser()
else:
raise ValueError(f"Unknown parser type: {parser_type}")
# Auto-detection based on source
# Check file extension first (most common case)
if source.endswith('.docx') or (os.path.exists(source) and source.lower().endswith('.docx')):
return DocxParser()
# Check content for XML markers
# Strip whitespace for reliable detection
content_start = source.strip()[:200] # Check first 200 chars
# Check for OMML namespace
if '<m:oMath' in content_start or '<m:oMathPara' in content_start:
return OmmlParser()
# Check for MathML namespace
if '<math' in content_start or '<mml:math' in content_start:
return MathMLParser()
# If it's a file path but not .docx, check file content
if os.path.exists(source):
try:
with open(source, 'r', encoding='utf-8') as f:
file_content = f.read(500) # Read first 500 chars
if '<m:oMath' in file_content:
return OmmlParser()
elif '<math' in file_content or '<mml:math' in file_content:
return MathMLParser()
except (UnicodeDecodeError, IOError):
# Binary file or read error - not a supported format
pass
# Unable to determine format
preview = source if len(source) <= 100 else source[:100] + "..."
raise ValueError(
f"Unable to determine input format for source: '{preview}'. "
f"Supported formats: .docx files, OMML XML (<m:oMath), MathML XML (<math)"
)
@staticmethod
def detect_parser_type(source: str) -> str:
"""
Detect the parser type needed for the input source.
Args:
source: Input source (file path or content string)
Returns:
Parser type string ("docx", "omml", or "mathml")
Raises:
ValueError: If input format cannot be determined
Examples:
>>> factory = InputParserFactory()
>>> factory.detect_parser_type("equations.docx")
'docx'
>>> factory.detect_parser_type("<math><mi>x</mi></math>")
'mathml'
"""
parser = InputParserFactory.create_parser(source)
return parser.parser_type
@staticmethod
def get_supported_formats() -> List[str]:
"""
Get list of supported input formats.
Returns:
List of format identifiers that can be parsed
"""
return ["docx", "omml", "mathml"]
def __repr__(self) -> str:
"""String representation for debugging."""
return "InputParserFactory(docx, omml, mathml)"

View File

@@ -0,0 +1,168 @@
"""
MathML parser for direct MathML input.
This parser handles MathML XML input, performing validation and extraction
of individual math equations.
"""
import sys
from typing import List
from pathlib import Path
from lxml import etree as ET
from ..core.interfaces import Parser
# Import utilities from omml_to_tex package for HTML entity decoding and equation extraction
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from ..utils import decode_html_entities, extract_math_equations
class MathMLParser(Parser):
"""
Parser for MathML XML input.
This parser handles direct MathML XML input, performing basic validation
and extracting individual <math> elements.
Examples:
Single equation:
>>> parser = MathMLParser()
>>> mathml = "<math><mi>x</mi></math>"
>>> results = parser.parse(mathml)
>>> len(results)
1
Multiple equations:
>>> parser = MathMLParser()
>>> mathml = "<root><math><mi>x</mi></math><math><mi>y</mi></math></root>"
>>> results = parser.parse(mathml)
>>> len(results)
2
"""
def __init__(self):
"""Initialize the MathML parser."""
pass
def parse(self, source: str) -> List[str]:
"""
Parse MathML input and return list of MathML equation strings.
Args:
source: MathML XML string or file path
Returns:
List of MathML equation strings (one per <math> element)
Raises:
ValueError: If input is not valid MathML XML
RuntimeError: If parsing fails
Examples:
>>> parser = MathMLParser()
>>> mathml = "<math><mi>x</mi><mo>+</mo><mi>y</mi></math>"
>>> results = parser.parse(mathml)
>>> len(results)
1
>>> '<mi>x</mi>' in results[0]
True
"""
import os
# Check if source is a file path
if os.path.exists(source):
try:
with open(source, 'r', encoding='utf-8') as f:
source = f.read()
except (IOError, UnicodeDecodeError) as e:
raise ValueError(f"Failed to read MathML file: {e}")
# STRATEGY 1: Try regex-based extraction first (handles multi-equation files + HTML entities)
# This is the same approach as the original omml_to_tex.py
try:
equations = extract_math_equations(source)
if equations:
return equations
except Exception:
# If regex extraction fails, fall through to XML parsing
pass
# STRATEGY 2: Try XML parsing as fallback (single well-formed document)
try:
root = ET.fromstring(source.encode('utf-8'))
except ET.XMLSyntaxError as e:
raise ValueError(f"Invalid XML syntax: {e}")
# Find all <math> elements
math_elements = []
# Check if root itself is a <math> element
if self._is_math_element(root):
math_elements.append(root)
else:
# Search for <math> elements (with or without namespace)
math_elements.extend(root.findall('.//{http://www.w3.org/1998/Math/MathML}math'))
math_elements.extend(root.findall('.//math'))
if not math_elements:
# No <math> elements found - treat entire document as one equation if it looks like MathML
if self._looks_like_mathml(root):
math_elements.append(root)
else:
raise ValueError("No <math> elements found in MathML input")
# Convert each <math> element to string
results = []
for math_elem in math_elements:
mathml_str = ET.tostring(math_elem, encoding='unicode')
results.append(mathml_str)
return results
def can_handle(self, source: str) -> bool:
"""
Check if this parser can handle the given input source.
Args:
source: Input source to check
Returns:
True if source appears to be MathML, False otherwise
Examples:
>>> parser = MathMLParser()
>>> parser.can_handle("<math><mi>x</mi></math>")
True
>>> parser.can_handle("<m:oMath>...</m:oMath>")
False
"""
# Check for MathML markers
content_start = source.strip()[:200]
return '<math' in content_start or '<mml:math' in content_start
@property
def parser_type(self) -> str:
"""Get the parser type identifier."""
return "mathml"
def _is_math_element(self, element: ET.Element) -> bool:
"""Check if element is a <math> element."""
local_name = element.tag.split('}')[-1] if '}' in element.tag else element.tag
return local_name == 'math'
def _looks_like_mathml(self, element: ET.Element) -> bool:
"""Check if element tree looks like MathML content."""
# Check for common MathML element tags
mathml_tags = {'mi', 'mo', 'mn', 'mrow', 'mfrac', 'msup', 'msub', 'mtext'}
# Get all element tags in tree
all_tags = set()
for elem in element.iter():
local_name = elem.tag.split('}')[-1] if '}' in elem.tag else elem.tag
all_tags.add(local_name)
# If at least 2 MathML tags present, consider it MathML
return len(all_tags & mathml_tags) >= 2
def __repr__(self) -> str:
"""String representation for debugging."""
return "MathMLParser()"

View File

@@ -0,0 +1,183 @@
"""
OMML parser for Office Math Markup Language input.
This parser handles OMML XML input from Microsoft Office, converting it
to MathML format using the existing OMML processing pipeline.
"""
from typing import List
from lxml import etree as ET
from ..core.interfaces import Parser
class OmmlParser(Parser):
"""
Parser for OMML (Office Math Markup Language) XML input.
This parser handles OMML XML from Microsoft Office documents, converting
it to MathML using the existing OMML transformation pipeline.
Examples:
Single OMML equation:
>>> parser = OmmlParser()
>>> omml = '<m:oMath xmlns:m="http://..."><m:r><m:t>x</m:t></m:r></m:oMath>'
>>> results = parser.parse(omml)
>>> len(results) >= 1
True
"""
def __init__(self):
"""Initialize the OMML parser."""
pass
def parse(self, source: str) -> List[str]:
"""
Parse OMML input and return list of MathML equation strings.
Args:
source: OMML XML string or file path
Returns:
List of MathML equation strings (converted from OMML)
Raises:
ValueError: If input is not valid OMML XML
RuntimeError: If OMML to MathML conversion fails
Examples:
>>> parser = OmmlParser()
>>> omml = '<m:oMath xmlns:m="http://...">...</m:oMath>'
>>> results = parser.parse(omml)
>>> isinstance(results, list)
True
"""
import os
# Check if source is a file path
if os.path.exists(source):
try:
with open(source, 'r', encoding='utf-8') as f:
source = f.read()
except (IOError, UnicodeDecodeError) as e:
raise ValueError(f"Failed to read OMML file: {e}")
# Parse XML
try:
root = ET.fromstring(source.encode('utf-8'))
except ET.XMLSyntaxError as e:
raise ValueError(f"Invalid XML syntax: {e}")
# Find all OMML math elements
# OMML uses <m:oMath> or <m:oMathPara> elements
omml_ns = {'m': 'http://schemas.openxmlformats.org/officeDocument/2006/math'}
# Find oMath elements (with or without namespace)
omath_elements = []
omath_elements.extend(root.findall('.//m:oMath', omml_ns))
omath_elements.extend(root.findall('.//m:oMathPara', omml_ns))
# Try without namespace (in case it's not declared)
if not omath_elements:
for elem in root.iter():
local_name = elem.tag.split('}')[-1] if '}' in elem.tag else elem.tag
if local_name in ('oMath', 'oMathPara'):
omath_elements.append(elem)
if not omath_elements:
# Check if root itself is an OMML element
root_local = root.tag.split('}')[-1] if '}' in root.tag else root.tag
if root_local in ('oMath', 'oMathPara'):
omath_elements.append(root)
else:
raise ValueError("No OMML math elements (<m:oMath> or <m:oMathPara>) found in input")
# Convert each OMML element to MathML
results = []
for omml_elem in omath_elements:
try:
mathml_str = self._omml_to_mathml(omml_elem)
if mathml_str and mathml_str.strip():
results.append(mathml_str)
except Exception as e:
# Log error but continue with other equations
print(f"Warning: Failed to convert OMML element: {e}")
continue
if not results:
raise RuntimeError("Failed to convert any OMML elements to MathML")
return results
def can_handle(self, source: str) -> bool:
"""
Check if this parser can handle the given input source.
Args:
source: Input source to check
Returns:
True if source appears to be OMML, False otherwise
Examples:
>>> parser = OmmlParser()
>>> parser.can_handle("<m:oMath>...</m:oMath>")
True
>>> parser.can_handle("<math><mi>x</mi></math>")
False
"""
# Check for OMML markers
content_start = source.strip()[:200]
return '<m:oMath' in content_start or '<m:oMathPara' in content_start
@property
def parser_type(self) -> str:
"""Get the parser type identifier."""
return "omml"
def _omml_to_mathml(self, omml_element: ET.Element) -> str:
"""
Convert OMML element to MathML string with styling preservation.
This wraps the existing OMML to MathML conversion from the core module,
and adds post-processing to preserve styling (bold, fonts, colors) that
the XSLT stylesheet doesn't preserve.
Args:
omml_element: OMML XML element
Returns:
MathML string with styling attributes
Raises:
RuntimeError: If conversion fails
"""
# Import here to avoid circular dependencies
import os
from ..core import omml_to_mathml
from ..utils.styling_postprocessor import apply_styling_to_mathml_from_omml
try:
# Convert OMML element to string
omml_str = ET.tostring(omml_element, encoding='unicode')
# Get XSL path (in core module)
current_dir = os.path.dirname(os.path.abspath(__file__))
core_dir = os.path.join(os.path.dirname(current_dir), 'core')
xsl_path = os.path.join(core_dir, 'OMML2MML.xsl')
# Step 1: Use XSLT to convert OMML to MathML
mathml_str = omml_to_mathml(omml_str, xsl_path)
# Step 2: Post-process to add styling (bold, fonts, colors)
# The XSLT doesn't preserve m:sty, m:scr, w:color, etc.
# This step extracts styling from OMML and applies to MathML
mathml_str = apply_styling_to_mathml_from_omml(omml_str, mathml_str)
return mathml_str
except Exception as e:
raise RuntimeError(f"OMML to MathML conversion failed: {e}")
def __repr__(self) -> str:
"""String representation for debugging."""
return "OmmlParser()"

View File

@@ -0,0 +1,27 @@
"""
Input/output processing module for OMML and DOCX files.
This module handles:
- DOCX file processing and equation extraction
- OMML extraction from Word documents
- LaTeX output formatting (inline, display, various delimiters)
- Result data structures
"""
from .result import ConversionResult
from .omml_extractor import OMMLExtractor
from .docx_processor import DocxProcessor
from .output_formatter import (
format_latex,
save_results,
generate_html_preview,
)
__all__ = [
"ConversionResult",
"OMMLExtractor",
"DocxProcessor",
"format_latex",
"save_results",
"generate_html_preview",
]

View File

@@ -0,0 +1,156 @@
"""
DOCX file processing and end-to-end conversion.
This module provides the DocxProcessor class which orchestrates the complete
conversion pipeline from DOCX to LaTeX, including OMML extraction, MathML
transformation, and LaTeX conversion.
"""
import logging
import time
from typing import List
from .result import ConversionResult
from .omml_extractor import OMMLExtractor
logger = logging.getLogger(__name__)
class DocxProcessor:
"""Process DOCX files and convert all OMML equations to LaTeX.
This class coordinates:
- OMML extraction from DOCX (via OMMLExtractor)
- OMML → MathML transformation (via omml_to_mathml function)
- MathML → LaTeX conversion (via MathMLToTeXConverter)
Example:
>>> from omml2tex.compat import LegacyMathMLToTeXConverter
>>> processor = DocxProcessor(
... xsl_path='OMML2MML.xsl',
... converter=MathMLToTeXConverter()
... )
>>> results = processor.process_docx('document.docx')
>>> for result in results:
... if result.success:
... print(result.latex)
"""
def __init__(self, xsl_path: str, converter, subject_mode: str = "auto"):
"""Initialize DOCX processor.
Args:
xsl_path: Path to OMML2MML.xsl transformation file
converter: MathMLToTeXConverter instance
subject_mode: Subject mode for conversion ('auto', 'math', 'chemistry', 'physics')
"""
self.extractor = OMMLExtractor()
self.xsl_path = xsl_path
self.converter = converter
self.subject_mode = subject_mode
def process_docx(self, docx_path: str, omml_to_mathml_func=None) -> List[ConversionResult]:
"""Process a DOCX file and convert all equations.
Args:
docx_path: Path to DOCX file
omml_to_mathml_func: Function to convert OMML to MathML (for dependency injection)
Returns:
List of ConversionResult objects
Example:
>>> results = processor.process_docx('test.docx')
>>> successful = [r for r in results if r.success]
>>> print(f"{len(successful)}/{len(results)} equations converted")
"""
results = []
try:
# Extract OMML equations
equations = self.extractor.extract_from_docx(docx_path)
logger.info(f"Found {len(equations)} equations in {docx_path}")
for eq_data in equations:
result = ConversionResult()
result.omml = eq_data['cleaned_omml']
result.context = eq_data['context']
result.position = eq_data['position']
result.source_file = eq_data['source_file']
result.display_mode = eq_data.get('display_mode', False)
try:
start_time = time.time()
# Convert OMML to MathML
if omml_to_mathml_func is None:
# Import here to avoid circular dependency
from ..core.xslt_transformer import omml_to_mathml
omml_to_mathml_func = omml_to_mathml
mathml_xml = omml_to_mathml_func(eq_data['cleaned_omml'], self.xsl_path)
result.mathml = mathml_xml
# Convert MathML to LaTeX
latex = self.converter.convert(mathml_xml)
result.latex = latex
result.processing_time = time.time() - start_time
result.success = True
except Exception as e:
result.error = str(e)
result.success = False
logger.error(f"Failed to convert equation {result.position}: {e}")
results.append(result)
except Exception as e:
logger.error(f"Failed to process DOCX file: {e}")
# Return empty results with error
result = ConversionResult()
result.error = f"DOCX processing failed: {e}"
results.append(result)
return results
def detect_subject_mode(self, results: List[ConversionResult]) -> str:
"""Auto-detect subject mode based on content.
Analyzes context text from equations to determine if document
is primarily chemistry, physics, or general math.
Args:
results: List of conversion results with context
Returns:
Detected subject mode: 'chemistry', 'physics', or 'math'
Example:
>>> results = processor.process_docx('chem_doc.docx')
>>> mode = processor.detect_subject_mode(results)
>>> print(f"Detected mode: {mode}")
"""
chemistry_indicators = 0
physics_indicators = 0
total_context = ""
for result in results:
if result.success and result.context:
total_context += " " + result.context.lower()
# Simple heuristic detection
chemistry_words = ['reaction', 'equation', 'chemical', 'compound', 'element', 'molecule', 'ion', 'acid', 'base']
physics_words = ['force', 'energy', 'velocity', 'acceleration', 'wave', 'field', 'quantum', 'particle']
for word in chemistry_words:
chemistry_indicators += total_context.count(word)
for word in physics_words:
physics_indicators += total_context.count(word)
if chemistry_indicators > physics_indicators and chemistry_indicators > 2:
return "chemistry"
elif physics_indicators > chemistry_indicators and physics_indicators > 2:
return "physics"
else:
return "math"

View File

@@ -0,0 +1,273 @@
"""
OMML equation extraction from DOCX files.
This module provides the OMMLExtractor class which extracts OMML (Office Math Markup Language)
equations from Microsoft Word DOCX files, including main document, headers, and footers.
"""
import zipfile
import logging
from pathlib import Path
from typing import List, Dict, Any
from lxml import etree as ET
logger = logging.getLogger(__name__)
class OMMLExtractor:
"""Extract OMML equations from DOCX files.
Handles extraction from:
- Main document body (word/document.xml)
- Headers (word/header*.xml)
- Footers (word/footer*.xml)
Preserves:
- Non-breaking spaces (NBSP, U+00A0)
- Display mode detection (inline vs display equations)
- Context from surrounding paragraphs
- Position tracking
Example:
>>> extractor = OMMLExtractor()
>>> equations = extractor.extract_from_docx('document.docx')
>>> print(f"Found {len(equations)} equations")
>>> print(extractor.stats)
"""
# OMML and Word namespace definitions
NAMESPACES = {
'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
'm': 'http://schemas.openxmlformats.org/officeDocument/2006/math',
'wp': 'http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing'
}
def __init__(self):
"""Initialize extractor with empty statistics."""
self.stats = {
'equations_found': 0,
'files_processed': 0,
'errors': 0
}
def extract_from_docx(self, docx_path: str) -> List[Dict[str, Any]]:
"""Extract all OMML equations from a DOCX file.
Args:
docx_path: Path to DOCX file
Returns:
List of equation dictionaries with keys:
- omml: Raw OMML XML string
- cleaned_omml: Cleaned OMML ready for transformation
- position: Position/index in document
- context: Surrounding text context
- source_file: XML file within DOCX (e.g., 'word/document.xml')
- display_mode: Boolean indicating display (True) vs inline (False)
Raises:
FileNotFoundError: If DOCX file doesn't exist
Exception: For other processing errors
Example:
>>> extractor = OMMLExtractor()
>>> equations = extractor.extract_from_docx('test.docx')
>>> for eq in equations:
... print(f"Position {eq['position']}: {eq['display_mode']}")
"""
equations = []
docx_path = Path(docx_path)
if not docx_path.exists():
raise FileNotFoundError(f"DOCX file not found: {docx_path}")
try:
with zipfile.ZipFile(docx_path, 'r') as docx:
# Extract main document
equations.extend(self._extract_from_document(docx, 'word/document.xml'))
# Check for equations in headers/footers
header_files = [f for f in docx.namelist() if f.startswith('word/header')]
footer_files = [f for f in docx.namelist() if f.startswith('word/footer')]
for header_file in header_files:
equations.extend(self._extract_from_document(docx, header_file))
for footer_file in footer_files:
equations.extend(self._extract_from_document(docx, footer_file))
self.stats['files_processed'] += 1
self.stats['equations_found'] += len(equations)
except Exception as e:
self.stats['errors'] += 1
logger.error(f"Error processing {docx_path}: {e}")
raise
return equations
def _extract_from_document(self, docx: zipfile.ZipFile, xml_path: str) -> List[Dict[str, Any]]:
"""Extract equations from a specific XML document within the DOCX.
Args:
docx: Open zipfile.ZipFile object
xml_path: Path to XML file within DOCX
Returns:
List of equation dictionaries
"""
equations = []
try:
if xml_path not in docx.namelist():
return equations
doc_xml = docx.read(xml_path)
# IMPORTANT: Preserve NBSP characters before XML parsing
# lxml normalizes U+00A0 (NBSP) to regular space, so we replace it
# with a placeholder that we'll restore later
# UTF-8 encoding of NBSP is \xc2\xa0
doc_xml = doc_xml.replace(b'\xc2\xa0', b'&#160;') # Convert NBSP to decimal entity
# Use lxml for parsing to enable getparent() method
root = ET.fromstring(doc_xml)
# Find all OMML equations
math_elements = root.findall('.//m:oMath', self.NAMESPACES)
for i, math_elem in enumerate(math_elements):
try:
# Extract raw OMML (use lxml for consistency)
raw_omml = ET.tostring(math_elem, encoding='unicode', method='xml')
# Clean and prepare OMML
cleaned_omml = self._clean_omml(raw_omml)
# Get surrounding context
context = self._get_context(math_elem)
# Detect if this is a display equation (wrapped in oMathPara)
display_mode = self._is_display_equation(math_elem)
equation_data = {
'omml': raw_omml,
'cleaned_omml': cleaned_omml,
'position': i,
'context': context,
'source_file': xml_path,
'display_mode': display_mode
}
equations.append(equation_data)
except Exception as e:
logger.warning(f"Error extracting equation {i} from {xml_path}: {e}")
continue
except Exception as e:
logger.error(f"Error reading {xml_path}: {e}")
return equations
def _clean_omml(self, omml: str) -> str:
"""Clean and prepare OMML for transformation.
Fixes:
- Missing namespace declarations
- Word formatting properties
- Whitespace normalization between tags
Args:
omml: Raw OMML XML string
Returns:
Cleaned OMML string
"""
import re
# Add proper namespace declarations if missing
if 'xmlns:m=' not in omml:
omml = omml.replace(
'<m:oMath',
'<m:oMath xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"'
)
if 'xmlns:w=' not in omml and 'w:' in omml:
omml = omml.replace(
'<m:oMath',
'<m:oMath xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"'
)
# Remove Word formatting properties that might interfere (but preserve font information)
omml = re.sub(r'<w:szCs[^>]*?/>', '', omml)
# Keep w:color elements for color preservation
# omml = re.sub(r'<w:color[^>]*?/>', '', omml)
# Normalize whitespace BETWEEN XML tags only (not inside text content)
# Collapse multiple whitespace chars between tags into single space
# Pattern: ">[ \t\n\r]+<" → "> <"
omml = re.sub(r'>([ \t\n\r]+)<', r'> <', omml)
omml = omml.strip()
return omml
def _get_context(self, math_elem: ET._Element) -> str:
"""Get surrounding text context for the equation.
Args:
math_elem: OMML math element
Returns:
Surrounding paragraph text (max 200 characters)
"""
try:
# Find the parent paragraph
current = math_elem
while current is not None:
if current.tag.endswith('p'): # Word paragraph
break
parent = current.find('..')
current = parent
if current is None:
return ""
# Extract all text from the paragraph
text_parts = []
for elem in current.iter():
if elem.tag.endswith('t') and elem.text: # Word text element
text_parts.append(elem.text)
context = ' '.join(text_parts).strip()
# Limit context length
if len(context) > 200:
context = context[:200] + "..."
return context
except Exception as e:
logger.debug(f"Error getting context: {e}")
return ""
def _is_display_equation(self, math_elem: ET._Element) -> bool:
"""Determine if an equation is display mode ($$) or inline mode ($).
Display equations are wrapped in <m:oMathPara>, inline equations are not.
Args:
math_elem: OMML math element
Returns:
True for display mode, False for inline mode
"""
try:
# Check if the parent is oMathPara
parent = math_elem.getparent()
if parent is not None and parent.tag.endswith('oMathPara'):
return True
return False
except Exception as e:
logger.debug(f"Error detecting display mode: {e}")
return False # Default to inline if unsure

View File

@@ -0,0 +1,221 @@
"""
Output formatting for conversion results.
This module provides functions to format conversion results in various output
formats including LaTeX, JSON, and HTML with MathJax rendering.
"""
import json
from typing import List
from .result import ConversionResult
from ..utils.text_processing import write_text
def format_latex(results: List[ConversionResult], format_type: str = "display",
delimiter: str = "dollar") -> str:
"""Format LaTeX results for output.
Args:
results: List of conversion results
format_type: 'inline', 'display', or 'both' (auto-detect)
delimiter: 'dollar', 'bracket', 'mathjax', or 'katex'
Returns:
Formatted LaTeX string with appropriate delimiters
Example:
>>> results = [ConversionResult()]
>>> results[0].success = True
>>> results[0].latex = 'x + y'
>>> results[0].display_mode = False
>>> output = format_latex(results, 'both', 'dollar')
>>> print(output)
'$x + y$'
"""
if delimiter == "dollar":
inline_start, inline_end = "$", "$"
display_start, display_end = "$$", "$$"
elif delimiter == "bracket":
inline_start, inline_end = "\\(", "\\)"
display_start, display_end = "\\[", "\\]"
elif delimiter == "mathjax":
inline_start, inline_end = "\\(", "\\)"
display_start, display_end = "$$", "$$"
elif delimiter == "katex":
inline_start, inline_end = "$", "$"
display_start, display_end = "$$", "$$"
else:
inline_start, inline_end = "$", "$"
display_start, display_end = "$$", "$$"
output_lines = []
for i, result in enumerate(results):
if not result.success:
output_lines.append(f"% Equation {i+1}: ERROR - {result.error}")
continue
# Determine if equation should be inline or display
if hasattr(result, 'display_mode') and format_type == "both":
# Use the detected display mode from OMML structure
if result.display_mode:
equation = f"{display_start}{result.latex}{display_end}"
else:
equation = f"{inline_start}{result.latex}{inline_end}"
elif format_type == "inline":
equation = f"{inline_start}{result.latex}{inline_end}"
elif format_type == "display":
equation = f"{display_start}{result.latex}{display_end}"
else: # format_type == "both" but no display_mode set
# Use heuristic: short equations inline, complex ones display
if len(result.latex) < 50 and not any(cmd in result.latex for cmd in ["\\frac", "\\sum", "\\int", "\\begin"]):
equation = f"{inline_start}{result.latex}{inline_end}"
else:
equation = f"{display_start}{result.latex}{display_end}"
output_lines.append(equation)
output_lines.append("") # Empty line between equations
return "\n".join(output_lines)
def save_results(results: List[ConversionResult], output_path: str,
format_type: str = "display", delimiter: str = "dollar",
include_context: bool = False, output_format: str = None):
"""Save conversion results in various formats.
Args:
results: List of conversion results
output_path: Output file path
format_type: For LaTeX output - 'inline', 'display', or 'both'
delimiter: For LaTeX output - delimiter style
include_context: Whether to include context in JSON output
output_format: 'latex', 'json', or 'html' (auto-detected from output_path if not provided)
Example:
>>> results = [ConversionResult()]
>>> save_results(results, 'output.json')
>>> save_results(results, 'output.tex', format_type='inline', delimiter='dollar')
"""
# Auto-detect output format from file extension if not provided
if output_format is None:
if output_path.endswith('.json'):
output_format = 'json'
elif output_path.endswith('.html'):
output_format = 'html'
else:
# Default to LaTeX for .tex, .md, and any other extension
output_format = 'latex'
if output_format == "latex":
latex_content = format_latex(results, format_type, delimiter)
write_text(output_path, latex_content)
elif output_format == "json":
json_data = {
"metadata": {
"total_equations": len(results),
"successful_conversions": sum(1 for r in results if r.success),
"failed_conversions": sum(1 for r in results if not r.success),
"format_type": format_type,
"delimiter": delimiter,
"include_context": include_context
},
"equations": [result.to_dict() for result in results]
}
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(json_data, f, indent=2, ensure_ascii=False)
elif output_format == "html":
html_content = generate_html_preview(results, format_type, delimiter)
write_text(output_path, html_content)
def generate_html_preview(results: List[ConversionResult], format_type: str, delimiter: str) -> str:
"""Generate HTML preview with MathJax rendering.
Args:
results: List of conversion results
format_type: 'inline', 'display', or 'both'
delimiter: Delimiter style
Returns:
HTML document string with embedded MathJax
Example:
>>> results = [ConversionResult()]
>>> html = generate_html_preview(results, 'display', 'dollar')
>>> '<!DOCTYPE html>' in html
True
"""
html_template = """<!DOCTYPE html>
<html>
<head>
<title>OMML to LaTeX Conversion Results</title>
<script src="https://polyfill.io/v3/polyfill.min.js?features=es6"></script>
<script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
<style>
body {{ font-family: Arial, sans-serif; margin: 40px; }}
.equation {{ margin: 20px 0; padding: 15px; border: 1px solid #ddd; background: #f9f9f9; }}
.context {{ font-style: italic; color: #666; margin-bottom: 10px; }}
.latex-code {{ font-family: monospace; background: #f0f0f0; padding: 10px; margin: 5px 0; }}
.error {{ color: red; background: #ffe6e6; }}
.success {{ border-left: 4px solid #4CAF50; }}
.failure {{ border-left: 4px solid #f44336; }}
.stats {{ background: #e3f2fd; padding: 15px; margin-bottom: 20px; }}
</style>
</head>
<body>
<h1>OMML to LaTeX Conversion Results</h1>
<div class="stats">
<h3>Conversion Statistics</h3>
<p>Total equations: {total}</p>
<p>Successful: {successful}</p>
<p>Failed: {failed}</p>
<p>Success rate: {rate:.1f}%</p>
</div>
{equations_html}
</body>
</html>"""
successful = sum(1 for r in results if r.success)
failed = len(results) - successful
success_rate = (successful / len(results)) * 100 if results else 0
equations_html = []
for i, result in enumerate(results):
equation_class = "success" if result.success else "failure"
if result.success:
latex_formatted = format_latex([result], format_type, delimiter).strip()
equation_html = f"""
<div class="equation {equation_class}">
<h4>Equation {i+1}</h4>
{f'<div class="context">Context: {result.context}</div>' if result.context else ''}
<div class="latex-code">LaTeX: {result.latex}</div>
<div>Rendered: {latex_formatted}</div>
</div>
"""
else:
equation_html = f"""
<div class="equation {equation_class}">
<h4>Equation {i+1} - ERROR</h4>
{f'<div class="context">Context: {result.context}</div>' if result.context else ''}
<div class="error">Error: {result.error}</div>
</div>
"""
equations_html.append(equation_html)
return html_template.format(
total=len(results),
successful=successful,
failed=failed,
rate=success_rate,
equations_html="\n".join(equations_html)
)

View File

@@ -0,0 +1,96 @@
"""
Conversion result data structure.
This module provides the ConversionResult class which represents the result
of an OMML to LaTeX conversion operation.
"""
from typing import Optional, List, Dict, Any
class ConversionResult:
"""Result of OMML conversion process.
Attributes:
success: Whether conversion was successful
omml: Original OMML XML string
mathml: Intermediate MathML XML string
latex: Final LaTeX output
context: Surrounding text context from source document
position: Position/index of equation in source document
source_file: Source file name or path
error: Error message if conversion failed
warnings: List of warning messages
processing_time: Time taken for conversion in seconds
display_mode: True for display equations ($$), False for inline ($)
Example:
>>> result = ConversionResult()
>>> result.success = True
>>> result.latex = '\\\\frac{1}{2}'
>>> result.display_mode = False
>>> print(result.to_dict())
{'success': True, 'latex': '\\\\frac{1}{2}', ...}
"""
def __init__(self):
"""Initialize conversion result with default values."""
self.success: bool = False
self.omml: str = ""
self.mathml: str = ""
self.latex: str = ""
self.context: str = ""
self.position: int = 0
self.source_file: str = ""
self.error: Optional[str] = None
self.warnings: List[str] = []
self.processing_time: float = 0.0
self.display_mode: bool = False # True for display equations ($$), False for inline ($)
def to_dict(self) -> Dict[str, Any]:
"""Convert result to dictionary for JSON serialization.
Returns:
Dictionary representation of conversion result
Example:
>>> result = ConversionResult()
>>> result.success = True
>>> result.latex = 'x + y'
>>> data = result.to_dict()
>>> print(data['latex'])
'x + y'
"""
return {
'success': self.success,
'omml': self.omml,
'mathml': self.mathml,
'latex': self.latex,
'context': self.context,
'position': self.position,
'source_file': self.source_file,
'error': self.error,
'warnings': self.warnings,
'processing_time': self.processing_time,
'display_mode': self.display_mode
}
def __repr__(self) -> str:
"""Return detailed string representation.
Returns:
String representation showing key attributes
"""
status = "Success" if self.success else f"Failed: {self.error}"
return f"ConversionResult(position={self.position}, status={status}, latex='{self.latex[:50]}...')"
def __str__(self) -> str:
"""Return human-readable string representation.
Returns:
User-friendly string representation
"""
if self.success:
return f"Equation {self.position}: {self.latex}"
else:
return f"Equation {self.position}: ERROR - {self.error}"

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

View File

@@ -0,0 +1,771 @@
# OMML to TeX Refactoring - Task List
**Project**: Transform 9,200-line monolithic `omml_to_tex.py` into modular package
**Strategy**: Extract modules incrementally, create new main file at end, keep original as backup
**Status**: ✅ PROJECT COMPLETE - All 11 Phases Done (100%)
---
## 📊 Progress Overview
- **Completed**: 11/11 phases (100%)
- **Status**: Project Complete ✅
- **Commits**: 11 clean commits (Phases 10 & 11 pending)
- **Lines Refactored**: ~5,900 / 9,200 (64%)
- **Original File**: Unchanged (backward compatible)
---
## ✅ Phase 1: Package Structure (COMPLETED)
**Status**: ✅ Done
**Commit**: `238c9a8` (part of data extraction)
**Time**: 30 minutes
### Tasks Completed:
- [x] Create `omml_to_tex/` package directory
- [x] Create 7 subdirectories with `__init__.py` files
- [x] `core/` - Conversion engine
- [x] `data/` - Symbol mappings and patterns
- [x] `converters/` - Element-specific converters
- [x] `processing/` - I/O processing
- [x] `validation/` - MathML validation
- [x] `utils/` - Utility functions
- [x] Add package-level `__init__.py` with version info
**Result**: Clean modular structure ready for code migration
---
## ✅ Phase 2: Data Module Extraction (COMPLETED)
**Status**: ✅ Done
**Commit**: `238c9a8` - feat: extract data modules
**Time**: 2 hours
**Lines**: 1,370 lines added
### Tasks Completed:
- [x] Create `data/symbols.py` (17 KB, 494 symbols)
- [x] Extract UNICODE_TO_LATEX dictionary
- [x] Add ADDITIONAL_SYMBOL_REPLACEMENTS
- [x] Add LATEX_COMMAND_REPLACEMENTS
- [x] Create `data/patterns.py` (7.9 KB, 35 patterns)
- [x] Extract SPACING_CLEANUP_PATTERNS (6 patterns)
- [x] Extract DELIMITER_CLEANUP_PATTERNS (20 patterns)
- [x] Extract SIMPLE_STRUCTURE_FIX_PATTERNS (9 patterns)
- [x] Add MATH_SYMBOLS_SET, SUMMATION_SYMBOLS, FONT_TO_LATEX, LARGE_OPERATORS
- [x] Create `data/functions.py` (2.6 KB)
- [x] Extract MATH_FUNCTIONS (105 functions)
- [x] Extract CHEMICAL_ELEMENTS (118 elements)
- [x] Extract MATHEMATICAL_SETS (26 sets)
- [x] Create `data/units.py` (2.2 KB, 199 units)
- [x] Extract COMMON_UNITS
- [x] Fix all syntax errors (4 issues fixed)
- [x] Test all imports
- [x] Update `data/__init__.py` with exports
**Result**: All data structures centralized and importable
---
## ✅ Phase 3: Utility Functions Extraction (COMPLETED)
**Status**: ✅ Done
**Commit**: `adde47a` - feat: extract utility functions to utils/ module
**Time**: 2 hours
**Lines**: 696 lines added (590 code)
### Tasks Completed:
- [x] Create `utils/helpers.py` (100 lines, 3 functions)
- [x] Extract `apply_symbol_replacements()`
- [x] Extract `apply_pattern_replacements()`
- [x] Extract `apply_unicode_symbol_mapping()`
- [x] Create `utils/xml_utils.py` (130 lines, 4 functions)
- [x] Extract `local_name()` (alias: `_local`)
- [x] Extract `text_content()` (alias: `_text_content`)
- [x] Extract `decode_html_entities()`
- [x] Extract `extract_math_equations()`
- [x] Create `utils/text_processing.py` (100 lines, 3 functions)
- [x] Extract `read_text()`
- [x] Extract `write_text()`
- [x] Extract `convert_spaces_to_latex()`
- [x] Create `utils/chemistry.py` (260 lines, 8 functions)
- [x] Extract `contains_chinese_characters()`
- [x] Extract `is_typical_reaction_condition_text()`
- [x] Extract `is_chemical_reaction_arrow()`
- [x] Extract `detect_chemistry_context()`
- [x] Extract `has_mathematical_functions()`
- [x] Extract `has_set_theory_symbols()`
- [x] Extract `detect_physics_context()`
- [x] Extract `is_physics_constant()`
- [x] Update `utils/__init__.py` with all exports
- [x] Test all 17 functions
- [x] Verify backward compatibility
**Result**: 17 utility functions extracted and tested
---
## ✅ Phase 4: Validation Modules (COMPLETED)
**Status**: ✅ Done
**Commit**: `b8ef96c` - feat: extract validation modules
**Time**: 3 hours
**Lines**: 760 lines added
### Tasks Completed:
- [x] Create `validation/sanitizer.py` (~300 lines)
- [x] Extract `MathMLSanitizer` class with 4-tier strategy
- [x] Extract `sanitize_mathml()` function
- [x] Extract namespace fixing logic
- [x] Extract tag repair logic
- [x] Extract BeautifulSoup fallback
- [x] Create `validation/validator.py` (~250 lines)
- [x] Extract `ValidationResult` dataclass
- [x] Extract `MathMLValidator` class
- [x] Extract structure validation logic
- [x] Extract error collection
- [x] Create `validation/recovery.py` (~210 lines)
- [x] Extract malformed MathML recovery functions
- [x] Extract regex-based content extraction
- [x] Extract error handling strategies
- [x] Update `validation/__init__.py` with exports
- [x] Test all validation functions
- [x] Commit milestone document
**Result**: Multi-tier validation and recovery system complete
---
## ✅ Phase 5: Processing Modules (COMPLETED)
**Status**: ✅ Done
**Commit**: `3735d70` - feat: extract processing modules
**Time**: 3 hours
**Lines**: 690 lines added
### Tasks Completed:
- [x] Create `processing/result.py` (~90 lines)
- [x] Extract `ConversionResult` dataclass
- [x] Add fields: success, latex, errors, warnings, display_mode
- [x] Create `processing/omml_extractor.py` (~230 lines)
- [x] Extract `OMMLExtractor` class
- [x] Extract DOCX zip handling with NBSP preservation
- [x] Extract equation extraction from Word XML
- [x] Extract display mode detection (oMathPara wrapper)
- [x] Create `processing/docx_processor.py` (~160 lines)
- [x] Extract `DocxProcessor` class
- [x] Extract end-to-end DOCX processing
- [x] Extract subject mode auto-detection
- [x] Create `processing/output_formatter.py` (~210 lines)
- [x] Extract output formatting functions
- [x] Extract LaTeX/JSON/HTML output
- [x] Extract MathJax preview generation
- [x] Update `processing/__init__.py` with exports
- [x] Test all processing functions
- [x] Commit milestone document
**Result**: Complete I/O processing pipeline with NBSP preservation
---
## ✅ Phase 6: Core Modules (COMPLETED)
**Status**: ✅ Done
**Commits**: `06dcb88`, `808703d` - feat: extract core transformation modules
**Time**: 4 hours
**Lines**: 2,309 lines added
### Tasks Completed:
- [x] Create `core/xslt_transformer.py` (~120 lines)
- [x] Extract `omml_to_mathml()` XSLT transformation
- [x] Extract OMML2MML.XSL loading
- [x] Extract namespace handling
- [x] Add deferred import pattern for fallback
- [x] Create `core/omml_parser.py` (~2,189 lines)
- [x] Extract `omml_to_mathml_fallback()` function
- [x] Extract manual OMML parsing logic (21 element types)
- [x] Extract function merging detection
- [x] Extract chemical reaction detection
- [x] Extract summation notation handling
- [x] Extract \\not pattern detection
- [x] Update `core/__init__.py` with exports
- [x] Test transformations
- [x] Commit milestone document
**Result**: XSLT + Python fallback OMML→MathML pipeline complete
---
## ✅ Phase 7: Converter Foundation (COMPLETED - PARTIAL)
**Status**: ✅ Foundation Complete (Full split deferred to future)
**Commit**: `14dd8a2` - feat: extract base converter with shared utilities
**Time**: 2 hours
**Lines**: 460 lines added
### Tasks Completed:
- [x] Analyze MathMLToTeXConverter class (5,559 lines)
- [x] Create `converters/base.py` (~460 lines)
- [x] Extract MATH_FUNCTIONS (105 functions)
- [x] Extract CHEMICAL_ELEMENTS (118 elements)
- [x] Extract COMMON_UNITS (150+ units)
- [x] Extract MATHEMATICAL_SETS and LARGE_OPERATORS
- [x] Create BaseConverter class with shared utilities
- [x] Add LaTeX generation methods (_brace, _wrap_if_needed)
- [x] Add color conversion methods
- [x] Add space conversion methods
- [x] Add context detection methods
- [x] Create `converters/__init__.py` with exports
- [x] Test all imports and utilities
- [x] Commit milestone document
**Result**: Reusable foundation for all converters (~460 lines)
### Tasks Deferred (Future Enhancement):
The full converter split (12 specialized converters, ~5,000 lines) is deferred as future work:
- ⏳ Create element-specific converters (identifiers, numbers, operators, text)
- ⏳ Create layout converters (fractions, scripts, radicals, tables)
- ⏳ Create accent/delimiter/special converters
- ⏳ Create orchestrator to coordinate all converters
- ⏳ Comprehensive testing of modular conversion
**Reason for Deferral**:
- Foundation (base.py) is complete and usable
- Full split is 12-16 hours of complex work
- Current monolithic converter is working and tested
- Can be done later when needed
**Dependencies**: Uses data, utils modules
---
## ✅ Phase 8: Create New Main Converter (COMPLETED)
**Status**: ✅ Done
**Commit**: `6d0bb62` - feat: create modular main converter and public API
**Time**: 2.5 hours
**Lines**: 332 lines added
### Tasks Completed:
- [x] Create `omml_to_tex/converter.py` (~70 lines)
- [x] Import `MathMLToTeXConverter` from original file using importlib
- [x] Re-export for backward compatibility
- [x] Document future enhancement path (full converter split)
- [x] Create `omml_to_tex/api.py` (~262 lines)
- [x] Create `convert_mathml()` - simple MathML to LaTeX conversion
- [x] Create `convert_mathml_with_validation()` - conversion with auto-fix
- [x] Create `convert_omml()` - OMML to LaTeX via MathML
- [x] Create `convert_docx()` - extract and convert DOCX equations
- [x] Create `save_latex()` - save results to file with formatting
- [x] Add convenience aliases (omml_to_latex, mathml_to_latex, docx_to_latex)
- [x] Update `omml_to_tex/__init__.py`
- [x] Export all public API functions
- [x] Export core classes (MathMLToTeXConverter, DocxProcessor, etc.)
- [x] Add comprehensive docstring with examples
- [x] Clean up imports and __all__ list
- [x] Test full conversion pipeline
- [x] Simple MathML conversion (✓ `\sin x`)
- [x] Chemistry mode conversion (✓ `\mathrm{H}2\mathrm{O}`)
- [x] Converter class usage (✓ `\dfrac{1}{2}`)
- [x] Validation with auto-fix (✓ working)
- [x] Compare outputs with original converter
- [x] All tests pass with identical output
- [x] Backward compatibility maintained
**Result**: Clean modular API wrapping existing converter. All 15 public functions/classes exported and tested.
**Implementation Strategy**:
- Used wrapper approach: import existing `MathMLToTeXConverter` via importlib
- Maintains 100% backward compatibility with original converter
- Provides modern, Pythonic API on top of extracted modules
- Sets foundation for future full converter refactoring
**Dependencies**: Uses all previous phases (data, utils, validation, processing, core)
---
## ✅ Phase 9: CLI Extraction (COMPLETED)
**Status**: ✅ Done
**Commit**: `3b05a79` - feat: extract CLI to dedicated module
**Time**: 2 hours
**Lines**: 405 lines added
### Tasks Completed:
- [x] Create `omml_to_tex/cli.py` (~390 lines)
- [x] Extract argument parsing into `create_parser()`
- [x] Extract CLI logic into mode handlers (OMML, DOCX, MathML)
- [x] Add statistics printing with `print_statistics()`
- [x] Handle all three input modes with proper error recovery
- [x] Support all CLI flags (--verbose, --quiet, --validate, --auto-fix, etc.)
- [x] Create `omml_to_tex/__main__.py` (~15 lines)
- [x] Add entry point: `python -m omml_to_tex`
- [x] Wire up CLI main() function
- [x] Test CLI commands
- [x] MathML from stdin (✓ `\sin x`)
- [x] Chemistry mode (✓ `\mathrm{H}2\mathrm{O}`)
- [x] Display format (✓ `$$\dfrac{1}{2}$$`)
- [x] Help text (✓ all arguments documented)
- [x] Compare behavior with original
- [x] MathML mode: identical output ✓
- [x] DOCX mode: known issue with dynamically loaded converter (see notes)
- [x] OMML mode: not tested (rare usage)
**Result**: Clean, modular CLI with proper separation of concerns. Entry point working via `python -m omml_to_tex`.
**Implementation Highlights**:
- Separated CLI into dedicated module (was inline in main())
- Created mode handlers for each input type (OMML, DOCX, MathML)
- Proper logging configuration and error handling
- Maintains backward compatibility with all CLI flags
- Clean entry point via __main__.py
**Known Issues** (to be addressed in Phase 10):
- DOCX mode has import issues with converter dependencies when loaded dynamically
- Simple workaround: load converter data modules into global scope
- Affects only DOCX mode; MathML and OMML modes work correctly
**Dependencies**: Uses Phase 8 (API) and all extracted modules
---
## ✅ Phase 10: Testing & Validation (COMPLETED)
**Status**: ✅ Done
**Commit**: `[pending]` - Combined with Phase 11
**Time**: 1 hour
**Lines**: Minimal (testing focus)
### Tasks Completed:
- [x] Test API functions
- [x] `convert_mathml()` - ✓ Simple (`\sin x`)
- [x] Chemistry mode - ✓ (`\mathrm{H}2\mathrm{O}`)
- [x] Fractions - ✓ (`\dfrac{1}{2}`)
- [x] `convert_mathml_with_validation()` - ✓ Working
- [x] Test CLI functionality
- [x] `python -m omml_to_tex --help` - ✓ All arguments
- [x] MathML from stdin - ✓ Working
- [x] Format options - ✓ inline/display
- [x] Subject modes - ✓ chemistry/physics/math
- [x] Verify backward compatibility
- [x] Original `omml_to_tex.py` - ✓ Still works
- [x] All imports - ✓ Working
- [x] Package API - ✓ Functional
**Result**: Core functionality fully tested and working. Known limitation with DOCX mode documented.
**Known Limitations**:
- DOCX processing has converter dependency injection issue
- Affects only complex DOCX files when using modular package
- **Workaround**: Use original `omml_to_tex.py` for DOCX
- MathML conversion works perfectly
- API and CLI both functional
**Testing Summary**:
- ✅ API: All conversion functions working
- ✅ CLI: All modes tested (MathML, OMML)
- ✅ Backward compatibility: Original script works
- ⚠️ DOCX mode: Known issue (documented workaround)
**Dependencies**: Uses all previous phases
---
## ✅ Phase 11: Documentation & Cleanup (COMPLETED)
**Status**: ✅ Done
**Commit**: `[pending]` - Combined with Phase 10
**Time**: 30 minutes
### Tasks Completed:
- [x] Update `CLAUDE.md`
- [x] Document new modular package structure
- [x] Add new usage examples (API and CLI)
- [x] Document known limitations
- [x] Update development workflow
- [x] Update `tasks.md`
- [x] Mark all phases complete
- [x] Document final status (82% → 100% functional)
- [x] Add known limitations section
**Result**: Documentation updated with modular package information and usage guides.
**Documentation Updates**:
- Package structure diagram added
- Modern API usage examples
- CLI usage with `python -m omml_to_tex`
- Known limitations clearly documented
- Development workflow updated for modular structure
**Not Done** (Out of scope for this phase):
- ⏳ `MIGRATION.md` - Not needed (backward compatible)
- ⏳ Formal API docs generation - Can be done later
- ⏳ Type hints throughout - Can be added incrementally
**Dependencies**: All previous phases complete
---
## ✅ Phase 12: Core Converter Extraction (COMPLETED)
**Status**: ✅ Done
**Commit**: `[pending]` - feat: extract MathMLToTeXConverter to modular structure
**Time**: 6 hours (actual vs 8-12 hours estimated)
**Lines**: ~5,574 lines (MathMLToTeXConverter class) → 4,622 lines (modular) + 450 (ContextAnalyzer) + 550 (PostProcessor)
### 🧠 Ultrathink Analysis (COMPLETED)
**Problem Identified:**
- ✅ Modular package structure exists (`omml_to_tex/` with 7 subdirectories)
- ✅ Data, utilities, validation, processing, and core modules extracted (~5,866 lines)
- ❌ **Main converter still imports from original monolithic file via importlib** (omml_to_tex/converter.py:26-41)
- ❌ MathMLToTeXConverter class (137 methods, 5,574 lines) NOT extracted
**Class Analysis:**
```
MathMLToTeXConverter class (lines 880-6453 in omml_to_tex.py)
├── 137 methods total
├── ~5,574 lines of code
├── 4 class-level data structures (MATH_FUNCTIONS, CHEMICAL_ELEMENTS, COMMON_UNITS, MATHEMATICAL_SETS)
└── Deep coupling with helper methods and context tracking
```
**Method Categorization (137 methods → 12 logical modules):**
| Module | Methods | Lines | Responsibility | Priority |
|--------|---------|-------|----------------|----------|
| **1. IdentifierConverter** | 12 | ~400 | mi, mn elements, variable/constant handling | HIGH |
| **2. OperatorConverter** | 16 | ~600 | mo elements, arrows, operators, chemical symbols | HIGH |
| **3. TextConverter** | 14 | ~550 | mtext elements, Chinese text, units, punctuation | HIGH |
| **4. AccentConverter** | 11 | ~400 | Accents, vectors, over/under notation | MEDIUM |
| **5. FractionConverter** | 3 | ~100 | mfrac elements, complex fractions | HIGH |
| **6. ScriptConverter** | 5 | ~150 | msub, msup, msubsup, scripts | HIGH |
| **7. TableConverter** | 6 | ~300 | mtable, mtr, mtd, arrays, matrices | MEDIUM |
| **8. DelimiterConverter** | 12 | ~500 | Delimiters, fences, absolute values | MEDIUM |
| **9. LayoutConverter** | 8 | ~350 | mrow, mfenced, mstyle, mspace, menclose | MEDIUM |
| **10. SpecialConverter** | 7 | ~300 | mmultiscripts, large operators, summations | LOW |
| **11. ContextAnalyzer** | 15 | ~400 | Chemistry/physics/math detection, context tracking | HIGH |
| **12. PostProcessor** | 11 | ~350 | Spacing, cleanup, final transformations | HIGH |
| **BaseConverter (existing)** | 17 | ~460 | Color, bracing, wrapping, shared utilities | ✅ DONE |
**Architectural Decision: Hybrid Approach (Phase 12)**
**Option A: Full Modular Split** (12 separate files, 20+ hours)
- Complete separation into specialized converters
- Maximum modularity and testability
- Higher complexity and risk
**Option B: Hybrid Approach** ✅ SELECTED (3 files, 8-12 hours)
- Extract high-value modules: ContextAnalyzer, PostProcessor
- Keep remaining 111 methods in refactored `mathml_converter.py`
- Lower risk, faster delivery
- Evolution path to full split in Phase 13 (optional)
### Implementation Plan
**Step 1: Create ContextAnalyzer Module** (~2 hours)
```python
# omml_to_tex/converters/context_analyzer.py (~400 lines, 15 methods)
class ContextAnalyzer:
"""Analyze equation context for smart symbol processing and subject detection."""
Methods to extract:
- analyze_equation_context(node, context)
- has_set_brace_context(pipe_node)
- detect_chemistry_context(equation_text)
- has_mathematical_functions(equation_text)
- has_set_theory_symbols()
- detect_physics_context(equation_text)
- is_physics_constant(symbol)
- is_clear_chemical_context()
- is_mathematical_context()
- is_mathematical_variable(content)
- should_wrap_in_mathrm(content)
- should_wrap_as_chemical_element(text)
- should_treat_as_variable_in_chemistry(text)
- should_format_as_set(text)
- is_symbol_in_fraction(text)
```
**Step 2: Create PostProcessor Module** (~2 hours)
```python
# omml_to_tex/converters/post_processor.py (~350 lines, 11 methods)
class PostProcessor:
"""Post-process LaTeX output for cleanup, spacing, and formatting."""
Methods to extract:
- add_intelligent_spacing(latex)
- fix_latex_command_spacing(latex)
- post_process_unclosed_sets(latex)
- post_process_consecutive_equals(latex)
- merge_consecutive_mathrm(latex)
- final_cleanup(latex)
- split_delimiter_number_pairs(latex)
- convert_consecutive_equals(text)
- has_consecutive_equals(text)
- convert_spaces_to_latex(text)
- convert_chinese_punctuation(text)
```
**Step 3: Create MathMLToTeXConverter** (~4 hours)
```python
# omml_to_tex/converters/mathml_converter.py (~4,800 lines, 111 methods)
from omml_to_tex.converters.base import BaseConverter
from omml_to_tex.converters.context_analyzer import ContextAnalyzer
from omml_to_tex.converters.post_processor import PostProcessor
class MathMLToTeXConverter(BaseConverter):
"""Main MathML to LaTeX converter - orchestrates element conversion."""
def __init__(self, wrap_identifiers_in_mathrm=True, ...):
super().__init__()
self.context = ContextAnalyzer()
self.post_processor = PostProcessor(self.context)
# ... configuration
def convert(self, mathml_xml: str) -> str:
# Parse, analyze context, convert, post-process
pass
# Element conversion methods (111 methods grouped by type)
# - Core conversion: _convert_node, _convert_children (20 methods)
# - Identifier/Number: _convert_mi, _convert_mn (12 methods)
# - Operators: _convert_mo, _convert_operator (16 methods)
# - Text: _convert_mtext, _process_mtext_content (14 methods)
# - Accents: over/under notation, vectors (11 methods)
# - Fractions: _convert_mfrac (3 methods)
# - Scripts: _convert_msub, _convert_msup (5 methods)
# - Tables: _convert_mtable, _convert_mtr (6 methods)
# - Delimiters: _convert_mfenced, _process_delimiters (12 methods)
# - Layout: _convert_mrow, _convert_mstyle (8 methods)
# - Special: _convert_mmultiscripts, summations (7 methods)
```
**Step 4: Update converter.py Imports** (~30 minutes)
```python
# omml_to_tex/converter.py
# BEFORE: importlib to load from original file
# AFTER: Direct import from modular structure
from omml_to_tex.converters.mathml_converter import MathMLToTeXConverter
__all__ = ["MathMLToTeXConverter"]
```
**Step 5: Testing & Validation** (~2 hours)
- Run all DOCX tests (42/42 equations must pass)
- Verify output matches original converter exactly
- Test chemistry mode, physics mode, math mode
- Benchmark performance (should be ±5% of original)
**Step 6: Documentation Updates** (~1 hour)
- Update CLAUDE.md with new modular structure
- Update tasks.md with Phase 12 completion
- Document architecture in converters/__init__.py
### Tasks Checklist
**Analysis & Planning:**
- [x] Ultrathink analysis of class structure (137 methods identified)
- [x] Method categorization into 12 logical modules
- [x] Architectural decision: Hybrid approach selected
- [x] Implementation plan created with time estimates
- [x] Update tasks.md with Phase 12 details
**Implementation:**
- [x] Create `converters/context_analyzer.py` (15 methods, ~450 lines) ✅
- [x] Extract context analysis methods
- [x] Extract chemistry/physics/math detection
- [x] Extract symbol classification logic
- [x] Proper imports and class structure
- [x] Test all methods via integration testing
- [x] Create `converters/post_processor.py` (11 methods, ~550 lines) ✅
- [x] Extract spacing and cleanup methods
- [x] Extract LaTeX command formatting
- [x] Extract consecutive equals handling
- [x] Extract delimiter number pair splitting
- [x] Extract Chinese punctuation conversion
- [x] Extract merge consecutive mathrm logic
- [x] Proper imports and class structure
- [x] Test all methods via integration testing
- [x] Create `converters/mathml_converter.py` (111 methods, ~4,622 lines) ✅
- [x] Set up class structure with modular components
- [x] Integrate ContextAnalyzer and PostProcessor
- [x] Extract all element conversion methods
- [x] Implement main convert() orchestration
- [x] Test basic conversions
- [x] Add missing data constants (MATH_SYMBOLS_SET, SUMMATION_SYMBOLS, FONT_TO_LATEX, LARGE_OPERATORS_UNICODE)
- [x] Update all method calls to use modular components
- [x] Update `omml_to_tex/converter.py` ✅
- [x] Remove importlib dependency
- [x] Import from modular structure
- [x] Verify exports
- [x] Update `omml_to_tex/__init__.py` - Not needed (exports already correct)
- [x] Update `omml_to_tex/data/__init__.py` - Added operators module exports
**Testing & Validation:**
- [x] Run DOCX test suite (tests/input.docx - 88 equations) ✅ **100% success rate (88/88)**
- [x] Run DOCX test suite (tests/1009.docx - 21 equations) ✅ **100% success rate (21/21)**
- [x] Verify 100% output match with original ✅ **Character-perfect match**
- [x] Test chemistry mode conversions ✅ **Working (`\mathrm{Cu^{2+}}`)**
- [x] Test physics mode conversions ✅ **Working (inherited from base)**
- [x] Test math mode conversions ✅ **Working (`\sin\left(x\right)`, `\dfrac{1}{2}`)**
- [x] Benchmark performance comparison ✅ **Comparable performance**
- [x] Verify all CLI commands work ✅ **All commands functional**
**Documentation:**
- [x] Update tasks.md with completion status ✅ **This update**
- [ ] Update CLAUDE.md with modular structure
- [ ] Add docstrings to all new classes
- [ ] Document architecture in converters/__init__.py
### Success Criteria ✅ ALL MET
**Must Have:**
- ✅ Zero imports from original `omml_to_tex.py` (remove importlib dependency) **ACHIEVED**
- ✅ All 109/109 equations pass (88 + 21 from both test files) **100% SUCCESS RATE**
- ✅ Output matches original converter exactly (character-by-character) **VERIFIED**
- ✅ Modular package fully independent **ACHIEVED**
- ✅ All CLI commands functional **VERIFIED**
**Performance:**
- ✅ Conversion time within ±5% of original **COMPARABLE**
- ✅ Memory usage comparable or better **COMPARABLE**
- ✅ No degradation in error recovery **VERIFIED**
### Risk Assessment
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| Breaking existing functionality | Medium | High | Comprehensive test suite, output comparison |
| Import circular dependencies | Medium | High | Careful dependency graph management |
| Data structure coupling | High | Medium | Use dependency injection for data modules |
| Performance degradation | Low | Medium | Benchmark before/after, optimize if needed |
| Method interdependencies | High | High | Extract in logical groups, test incrementally |
### Time Estimates
- **Step 1** (ContextAnalyzer): 2 hours ✅ **1.5 hours actual**
- **Step 2** (PostProcessor): 2 hours ✅ **1 hour actual**
- **Step 3** (MathMLToTeXConverter): 4 hours ✅ **2.5 hours actual**
- **Step 4** (Update imports): 30 minutes ✅ **30 minutes actual**
- **Step 5** (Testing): 2 hours ✅ **45 minutes actual**
- **Step 6** (Documentation): 1 hour ⏳ **15 minutes actual (in progress)**
**Total: 11.5 hours estimated → 6.25 hours actual (46% faster)**
**Dependencies**: All previous phases (1-11) complete
### 🎉 Phase 12 Completion Summary
**Achievements:**
- ✅ Successfully extracted 137-method converter class into modular architecture
- ✅ Created 3 new modules: ContextAnalyzer (450 lines), PostProcessor (550 lines), MathMLToTeXConverter (4,622 lines)
- ✅ Added missing data module: operators.py (MATH_SYMBOLS_SET, SUMMATION_SYMBOLS, FONT_TO_LATEX, LARGE_OPERATORS_UNICODE)
- ✅ Eliminated importlib dependency - package now fully independent
- ✅ **100% test success rate: 109/109 equations** (88 from input.docx + 21 from 1009.docx)
- ✅ Character-perfect output match with original converter
- ✅ All subject modes working (chemistry, physics, math)
- ✅ Comparable performance to original implementation
**Technical Highlights:**
- Hybrid architecture approach: extracted high-value modules, kept core methods together
- Dependency injection pattern for ContextAnalyzer and PostProcessor integration
- Clean separation of concerns: context analysis → conversion → post-processing
- Backward compatibility maintained throughout
- Zero breaking changes to public API
**Result**: **Modular package is now fully independent and production-ready!** 🚀
**Dependencies**: All previous phases (1-11) complete
---
## 🎯 Success Criteria
### Must Have:
- ✅ All data structures extracted to `data/` (DONE)
- ✅ All utilities extracted to `utils/` (DONE)
- ✅ All validation logic extracted to `validation/` (DONE)
- ✅ All processing logic extracted to `processing/` (DONE)
- ✅ All core logic extracted to `core/` (DONE)
- ✅ Converter foundation created in `converters/base.py` (DONE)
- ✅ New main converter created (wrapper around existing) (DONE)
- ✅ Public API with convenience functions (DONE)
- ✅ CLI extracted and working (DONE)
- ✅ Backward compatibility maintained (DONE)
- ✅ Core functionality tested and working (DONE)
### Nice to Have:
- [ ] Performance equal or better than original
- [ ] Comprehensive test coverage (>80%)
- [ ] Complete API documentation
- [ ] Migration guide for users
- [ ] Type hints throughout
- ⏳ Full converter split into 12 specialized modules (future enhancement)
---
## 📈 Estimated Timeline
- ✅ **Phases 1-3**: 6 hours (DONE) - Package, Data, Utils
- ✅ **Phase 4** (Validation): 3 hours (DONE)
- ✅ **Phase 5** (Processing): 3 hours (DONE)
- ✅ **Phase 6** (Core): 4 hours (DONE)
- ✅ **Phase 7** (Converter Foundation): 2 hours (DONE) - Base utilities only
- ✅ **Phase 8** (Main Converter): 2.5 hours (DONE) - Wrapper API + public functions
- ✅ **Phase 9** (CLI): 2 hours (DONE) - Extract CLI logic
- ✅ **Phase 10** (Testing): 1 hour (DONE) - API/CLI testing, backward compatibility
- ✅ **Phase 11** (Documentation): 0.5 hours (DONE) - Update docs
**Revised Estimated Time**: 26 hours (was 28-32 hours, originally 35-45)
**Completed**: 24 hours (100%)
**Time Saved**: 11 hours under original estimate
---
## 🚦 Current Status
**Active Phase**: 🔄 Phase 12 - Core Converter Extraction (IN PROGRESS)
**Next Commit**: Phase 12 - Extract MathMLToTeXConverter to modular structure
**Blockers**: None
**Status**: Ultrathink analysis complete, beginning converter extraction
---
## 📝 Notes
- Original `omml_to_tex.py` remains unchanged throughout (backward compatibility)
- Each phase ends with a clean, tested commit
- All 9 completed phases have milestone documentation
- Phase 7 foundation complete; full converter split deferred to future
- Phase 8 provides clean, modern Python API wrapping original converter
- Phase 9 extracts CLI to dedicated module with `python -m omml_to_tex` support
- Clean separation between modules enables future enhancements
- Total lines refactored: ~5,866 / 9,200 (64%)
---
## 🎯 Next Steps
**Recommended Path**: Complete remaining phases (8-11) to deliver working project
- Phase 8: Create main converter wrapper (3-4 hours)
- Phase 9: Extract CLI (2-3 hours)
- Phase 10: Testing & validation (3-4 hours)
- Phase 11: Documentation updates (2-3 hours)
**Future Enhancement**: Full converter split into 12 specialized modules (12-16 hours)
- Can be done later when needed
- Foundation (base.py) already in place
---
**Last Updated**: October 16, 2025
**Status**: ✅ 100% Complete (11/11 phases)
**11 Clean Commits**: All tested and documented
---
## 🎉 Project Complete!
The OMML to TeX refactoring is successfully complete with a clean, modular package structure. All core functionality is working with comprehensive testing and documentation.

View File

@@ -0,0 +1,68 @@
"""
Utility functions for the refactor package.
"""
from .color_helpers import (
detect_uniform_color,
hex_to_latex_color,
should_suppress_color,
wrap_with_uniform_color,
wrap_element_with_color,
)
from .xml_utils import (
text_content,
local_name,
decode_html_entities,
extract_math_equations,
)
from .helpers import (
is_default_color,
)
from .text_processing import (
write_text,
read_text,
convert_spaces_to_latex,
escape_latex_special_chars,
)
from .timer import (
PerformanceTimer,
TimingRecord,
TimingSummary,
get_global_timer,
enable_global_timer,
disable_global_timer,
)
from .debug import (
is_debug_enabled,
debug_print,
)
__all__ = [
'detect_uniform_color',
'hex_to_latex_color',
'should_suppress_color',
'wrap_with_uniform_color',
'wrap_element_with_color',
'text_content',
'local_name',
'decode_html_entities',
'extract_math_equations',
'is_default_color',
'write_text',
'read_text',
'convert_spaces_to_latex',
'escape_latex_special_chars',
'PerformanceTimer',
'TimingRecord',
'TimingSummary',
'get_global_timer',
'enable_global_timer',
'disable_global_timer',
'is_debug_enabled',
'debug_print',
]

View File

@@ -0,0 +1,296 @@
"""
Chemistry detection utilities for identifying chemical equations and contexts.
This module provides functions to detect chemical reactions, conditions, and
other chemistry-specific patterns in mathematical equations.
"""
from typing import Optional, Dict
from lxml import etree as ET
# OMML namespace constant
OMML_NS = "http://schemas.openxmlformats.org/officeDocument/2006/math"
def contains_chinese_characters(text: str) -> bool:
"""Check if text contains Chinese characters.
Args:
text: Text to check
Returns:
True if text contains at least one Chinese character
Example:
>>> contains_chinese_characters('加热')
True
>>> contains_chinese_characters('heat')
False
"""
if not text:
return False
for char in text:
if '\u4e00' <= char <= '\u9fff': # Chinese character range
return True
return False
def is_typical_reaction_condition_text(text: str) -> bool:
"""Check if text looks like a typical chemical reaction condition.
Args:
text: Text to check
Returns:
True if text contains typical chemical reaction condition terms
Example:
>>> is_typical_reaction_condition_text('加热')
True
>>> is_typical_reaction_condition_text('heat')
True
>>> is_typical_reaction_condition_text('abc')
False
"""
if not text:
return False
# Common chemical reaction conditions
typical_conditions = {
# Chinese conditions
"电解", "加热", "催化剂", "高温", "高压", "光照", "加压", "蒸馏", "熔融", "溶解", "酸化", "碱化",
"氧化", "还原", "分解", "合成", "中和", "干燥", "过滤", "沉淀", "结晶", "升华", "煅烧",
# English conditions
"heat", "catalyst", "pressure", "light", "oxidation", "reduction", "electrolysis",
"distillation", "crystallization", "precipitation", "filtration", "drying",
# Symbols and abbreviations
"Δ", "hv", "H+", "OH-", "cat", "pt", "ni", "pd"
}
text_lower = text.lower().strip()
return any(condition.lower() in text_lower for condition in typical_conditions)
def is_chemical_reaction_arrow(eqArr_node: ET._Element) -> Optional[Dict[str, str]]:
"""Detect if an eqArr represents a chemical reaction arrow with conditions.
Args:
eqArr_node: OMML eqArr element
Returns:
Dictionary with chemical reaction info if detected, None otherwise.
Structure: {
'above': str, # Text above the arrow (e.g., '电解')
'below': str, # Text below the arrow (e.g., '加热', can be empty)
'type': str # 'chemical_reaction'
}
Example:
>>> # Assuming eqArr_node is an OMML element with reaction conditions
>>> result = is_chemical_reaction_arrow(eqArr_node)
>>> result['above']
'电解'
"""
# Find all equation array elements
e_elements = eqArr_node.findall(f"{{{OMML_NS}}}e")
# Must have exactly 2 elements for chemical reaction conditions
if len(e_elements) != 2:
return None
# Extract text from first element (above the arrow)
above_text = ""
first_elem = e_elements[0]
# Extract text from all t elements in the first element, avoiding duplication
# Use a set to track unique text content
text_parts = set()
for t_elem in first_elem.findall(f".//{{{OMML_NS}}}t"):
if t_elem.text and t_elem.text.strip():
text_parts.add(t_elem.text.strip())
# Join unique text parts
above_text = "".join(sorted(text_parts))
# Extract text from second element (below the arrow)
below_text = ""
second_elem = e_elements[1]
# Extract text from all t elements in the second element, avoiding duplication
text_parts_below = set()
for t_elem in second_elem.findall(f".//{{{OMML_NS}}}t"):
if t_elem.text and t_elem.text.strip():
text_parts_below.add(t_elem.text.strip())
# Join unique text parts
below_text = "".join(sorted(text_parts_below))
# Validate that this looks like a chemical reaction pattern
if above_text.strip():
# Check if contains chemical condition patterns (Chinese text or typical reaction words)
if (contains_chinese_characters(above_text) or
is_typical_reaction_condition_text(above_text) or
contains_chinese_characters(below_text) or
is_typical_reaction_condition_text(below_text)):
return {
'above': above_text.strip(),
'below': below_text.strip(),
'type': 'chemical_reaction'
}
return None
def detect_chemistry_context(equation_text: str) -> bool:
"""Detect if equation suggests chemistry context - Conservative approach.
Args:
equation_text: The full equation text to analyze
Returns:
True if chemistry context is detected
Example:
>>> detect_chemistry_context('H2O + pH = 7')
True
>>> detect_chemistry_context('sin x + cos x')
False
"""
if not equation_text:
return False
# Check if we're in a set theory context - if so, don't treat letters as chemical elements
if has_set_theory_symbols(equation_text):
return False
# Check if we're in a mathematical context (trigonometry, calculus, etc.)
if has_mathematical_functions(equation_text):
return False
# Conservative chemistry detection: require multiple indicators, not just chemical elements
chemistry_indicators = ['pH', 'pOH', 'mol', 'atm', 'bar', 'Torr', 'eq', 'aq', 'sol',
'↑', '↓', '→', '⇋', '⇌', 'xlongequal', 'rightarrow']
# Only return True if we have actual chemistry indicators in the equation text
return any(indicator in equation_text for indicator in chemistry_indicators)
def has_mathematical_functions(equation_text: str) -> bool:
"""Check if the equation contains mathematical functions.
Args:
equation_text: The equation text to analyze
Returns:
True if mathematical functions are detected
Example:
>>> has_mathematical_functions('sin x + cos x')
True
>>> has_mathematical_functions('H2O')
False
"""
if not equation_text:
return False
# 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']
return any(func in equation_text for func in math_functions)
def has_set_theory_symbols(equation_text: str) -> bool:
"""Check if the equation contains set theory symbols.
Args:
equation_text: The equation text to analyze
Returns:
True if set theory symbols are detected
Example:
>>> has_set_theory_symbols('A ∩ B ⊂ C')
True
>>> has_set_theory_symbols('H2O')
False
"""
if not equation_text:
return False
set_symbols = ['⊂', '⊃', '⊆', '⊇', '⊄', '⊅', '⊈', '⊉', '⊊', '⊋',
'∈', '∉', '∋', '∌', '∪', '∩', '∖', '∁']
return any(symbol in equation_text for symbol in set_symbols)
def detect_physics_context(equation_text: str) -> bool:
"""Detect if equation suggests physics context.
Args:
equation_text: The equation text to analyze
Returns:
True if physics context is detected
Example:
>>> detect_physics_context('F = ma, where m is in kg')
True
>>> detect_physics_context('x + y = z')
False
"""
if not equation_text:
return False
# Check if we're in a mathematical context (trigonometry, calculus, etc.)
if has_mathematical_functions(equation_text):
return False
# Common physics constants and units - look for these in the equation context
physics_indicators = [
'kg', 'm', 's', 'A', 'K', 'mol', 'cd', # SI base units
'Hz', 'N', 'Pa', 'J', 'W', 'C', 'V', 'F', 'Ω', 'S', 'Wb', 'T', 'H', # Derived units
'eV', 'cal', 'Bar', 'L', # Common non-SI
# Note: removed single-letter constants like 'c', 'h', 'k', 'G', 'R', 'NA'
# These are too common in math contexts and cause false positives
]
# Only return True if we have actual physics indicators in the equation text
return any(indicator in equation_text for indicator in physics_indicators)
def is_physics_constant(content: str) -> bool:
"""Check if content is a known physics constant.
Args:
content: String to check
Returns:
True if content represents a known physics constant
Example:
>>> is_physics_constant('c')
True
>>> is_physics_constant('x')
False
"""
constants = {
'c', # speed of light
'h', # Planck constant
'hbar', # reduced Planck constant
'k', # Boltzmann constant
'kB', # Boltzmann constant
'G', # gravitational constant
'R', # gas constant
'NA', # Avogadro number
'e', # elementary charge
'me', # electron mass
'mp', # proton mass
'mn', # neutron mass
'mu', # atomic mass unit
'epsilon0', # vacuum permittivity
'mu0', # vacuum permeability
'alpha', # fine structure constant
'sigma', # Stefan-Boltzmann constant
}
return content in constants

View File

@@ -0,0 +1,259 @@
"""
Chemistry equation detection utilities.
This module provides functions to detect whether an equation is chemistry-related
based on strong indicators like multi-char elements, charges, and reaction arrows.
"""
from lxml import etree as ET
from typing import Optional, Tuple
from ..data.chemistry_data import (
MULTI_CHAR_ELEMENTS,
ALL_CHEMICAL_ELEMENTS,
CHEMICAL_GROUPS,
CHINESE_CHEMICAL_TERMS,
)
def detect_chemistry_equation(root: ET.Element) -> bool:
"""
Detect if equation is chemistry-related using STRONG indicators only.
DISABLED: Subject detection is obsolete. We rely on mathvariant="normal" only.
Chemistry formulas are properly formatted when MathML includes mathvariant="normal".
Args:
root: The root MathML element (<math>)
Returns:
False - always (chemistry detection disabled)
"""
# DISABLED: Always return False - no heuristic chemistry detection
return False
# OLD CODE (kept for reference):
# Strong indicators:
# 1. Multi-char chemical elements in single <mi> tag (Cu, Al, Fe, Mn, etc.)
# 2. Chemical groups (OH, SO, NO, CO, NH)
# 3. Element + numeric subscript (H₂O pattern)
# 4. Electric charges in superscript (^{2+}, ^{2-})
# 5. Chemical arrows (↑, ↓)
# 6. Chinese chemical terms (高温, 电解, etc.)
#
# for node in root.iter():
# # Remove namespace if present
# tag = node.tag.split('}')[-1] if '}' in node.tag else node.tag
# text = (node.text or '').strip()
#
# # Strong indicator 1: Multi-char element in single <mi>
# if tag == 'mi' and text in MULTI_CHAR_ELEMENTS:
# return True
#
# # Strong indicator 2: Chemical group
# if tag == 'mtext' and text in CHEMICAL_GROUPS:
# return True
#
# # Strong indicator 3: Element + numeric subscript (H₂O, SO₄ pattern)
# if tag == 'msub' and len(node) >= 2:
# base_node = node[0]
# sub_node = node[1]
# base_text = (base_node.text or '').strip()
# sub_text = (sub_node.text or '').strip()
#
# # Element with numeric subscript
# if base_text in ALL_CHEMICAL_ELEMENTS and sub_text.isdigit():
# return True
#
# # Strong indicator 4: Electric charge superscript (Cu^{2+}, Mn^{2-})
# if tag == 'msup' and len(node) >= 2:
# sup_node = node[1]
# sup_text = (sup_node.text or '').strip()
#
# # Check for charge notation (contains + or -)
# # Pattern: "2+", "+", "2-", "-", "3+", etc.
# if sup_text and ('+' in sup_text or '-' in sup_text):
# # Additional check: ensure it's likely a charge, not math
# # Charges are typically: digit* followed by +/-
# # Math would be expressions like "x+1", "y-2"
# # Simple heuristic: if it's short and has +/- at end, it's likely a charge
# if len(sup_text) <= 3 and (sup_text.endswith('+') or sup_text.endswith('-')):
# return True
# # Also check for format: "2 +" or "2 -" (with space)
# parts = sup_text.split()
# if len(parts) == 2 and parts[0].isdigit() and parts[1] in ('+', '-'):
# return True
#
# # Strong indicator 5: Chemical arrows (precipitate, gas)
# if tag == 'mo' and text in ('↑', '↓'):
# return True
#
# # Strong indicator 6: Chinese chemical terms
# if tag in ('mtext', 'mi') and text in CHINESE_CHEMICAL_TERMS:
# return True
#
# return False
def calculate_chemistry_probability(root: ET.Element, subject_mode: Optional[str] = None) -> float:
"""
Calculate chemistry probability score based on multiple indicators.
Scoring weights:
- Ion charge present (^{2+}, ^{3-}): +1.0
- Multi-char element in single <mi>: +0.8
- Nearby multi-char element: +0.8
- Reaction arrow (→, ⇌, ⇄): +0.6
- Chemical subscripts (H_2, O_2): +0.4
- Chinese chemical term: +0.3
- User set --subject chemistry: +0.5
Thresholds:
- Chemistry mode: ≥0.8 (merge fragmented elements)
- Auto-mode: ≥1.5 (only VERY strong indicators)
Args:
root: The root MathML element (<math>)
subject_mode: Optional subject mode ('chemistry', 'physics', etc.)
Returns:
Probability score (0.0-3.0+)
Examples:
>>> # M n^{2+} with MnO nearby: 1.0 + 0.8 + 0.8 = 2.6 (strong)
>>> calculate_chemistry_probability(math_root) # 2.6
>>> # Just "M n" with no context: 0.0 (weak)
>>> calculate_chemistry_probability(math_root) # 0.0
>>> # Chemistry mode boost: M n → 0.5 (mode) + context
>>> calculate_chemistry_probability(math_root, 'chemistry') # 0.5+
"""
# DISABLED: Always return 0.0 - no chemistry detection
return 0.0
# OLD CODE (kept for reference):
# score = 0.0
# # Mode boost: user explicitly set --subject chemistry
# if subject_mode == 'chemistry':
# score += 0.5
#
# # Counters for various indicators
# has_ion_charge = False
# multi_char_element_count = 0
# has_reaction_arrow = False
# chemical_subscript_count = 0
# has_chinese_term = False
#
# for node in root.iter():
# # Remove namespace if present
# tag = node.tag.split('}')[-1] if '}' in node.tag else node.tag
# text = (node.text or '').strip()
#
# # Indicator 1: Multi-char element in single <mi> (+0.8 each, max 2)
# if tag == 'mi' and text in MULTI_CHAR_ELEMENTS:
# multi_char_element_count += 1
#
# # Indicator 2: Chemical group (+0.8)
# if tag == 'mtext' and text in CHEMICAL_GROUPS:
# multi_char_element_count += 1
#
# # Indicator 3: Element + numeric subscript (H₂O pattern) (+0.4 each, max 3)
# if tag == 'msub' and len(node) >= 2:
# base_node = node[0]
# sub_node = node[1]
# base_text = (base_node.text or '').strip()
# sub_text = (sub_node.text or '').strip()
#
# if base_text in ALL_CHEMICAL_ELEMENTS and sub_text.isdigit():
# chemical_subscript_count += 1
#
# # Indicator 4: Ion charge superscript (+1.0, strongest indicator)
# if tag == 'msup' and len(node) >= 2:
# sup_node = node[1]
# sup_text = (sup_node.text or '').strip()
#
# # Check for charge notation
# if sup_text and ('+' in sup_text or '-' in sup_text):
# if len(sup_text) <= 3 and (sup_text.endswith('+') or sup_text.endswith('-')):
# has_ion_charge = True
# # Also check for format: "2 +" or "2 -"
# parts = sup_text.split()
# if len(parts) == 2 and parts[0].isdigit() and parts[1] in ('+', '-'):
# has_ion_charge = True
#
# # Indicator 5: Reaction arrows (+0.6)
# if tag == 'mo':
# # Chemical arrows (precipitate, gas)
# if text in ('↑', '↓'):
# has_reaction_arrow = True
# # Reaction arrows (→, ⇌, ⇄, etc.)
# if text in ('→', '←', '⇌', '⇄', '⟶', '⟵', '⇀', '↽'):
# has_reaction_arrow = True
#
# # Indicator 6: Chinese chemical terms (+0.3)
# if tag in ('mtext', 'mi') and text in CHINESE_CHEMICAL_TERMS:
# has_chinese_term = True
#
# # Calculate total score
# if has_ion_charge:
# score += 1.0
#
# # Cap multi-char elements at 2 (prevent over-scoring)
# score += min(multi_char_element_count, 2) * 0.8
#
# if has_reaction_arrow:
# score += 0.6
#
# # Cap chemical subscripts at 3
# score += min(chemical_subscript_count, 3) * 0.4
#
# if has_chinese_term:
# score += 0.3
#
# return score
def detect_chemistry_equation_with_probability(root: ET.Element) -> Tuple[bool, float]:
"""
Detect chemistry equation and return both boolean and probability score.
DISABLED: Subject detection is obsolete.
Args:
root: The root MathML element (<math>)
Returns:
Tuple of (False, 0.0) - always (chemistry detection disabled)
"""
# DISABLED: Always return (False, 0.0)
return False, 0.0
# OLD CODE (kept for reference):
# probability = calculate_chemistry_probability(root)
# is_chemistry = probability >= 1.0 # Strong indicator threshold
# return is_chemistry, probability
def is_chemistry_variable(text: str, in_chemistry_context: bool) -> bool:
"""
Determine if text is a chemistry variable (should stay italic).
DISABLED: Chemistry detection is obsolete.
Args:
text: The identifier text
in_chemistry_context: Whether we're in a chemistry equation (ignored)
Returns:
False - always (not used anymore)
"""
# DISABLED: Always return False - chemistry detection disabled
return False
# OLD CODE (kept for reference):
# from ..data.chemistry_data import CHEMISTRY_VARIABLES
# # In chemistry context, check if it's a known variable
# if in_chemistry_context:
# return text in CHEMISTRY_VARIABLES
# # Not in chemistry context, not a chemistry variable
# return False

View File

@@ -0,0 +1,380 @@
"""
Color handling utilities for MathML to LaTeX conversion.
This module provides color optimization utilities including:
- Uniform color detection across entire equations
- Hex to LaTeX color conversion (named colors and RGB mode)
- Color suppression for optimization
"""
from lxml import etree as ET
from typing import Tuple, Set
def detect_uniform_color(node: ET.Element) -> str:
"""
Detect if entire equation subtree uses a single uniform color.
Args:
node: Root MathML element (typically <math> or <mrow>)
Returns:
Hex color string (e.g., '#FF0000') if uniform color detected, empty string otherwise.
Algorithm:
1. Recursively check all content-bearing elements (mi, mn, mo, mtext, etc.)
2. For each element, determine its effective color (from element or ancestors)
3. Return color only if ALL content elements have the SAME color
4. If ANY element has no color (default), it's not uniform
Examples:
>>> from lxml import etree as ET
>>> # All elements have same color
>>> xml = '<math><mstyle mathcolor="#FF0000"><mi>x</mi><mo>+</mo><mi>y</mi></mstyle></math>'
>>> tree = ET.fromstring(xml.encode('utf-8'))
>>> detect_uniform_color(tree)
'#FF0000'
>>> # Partial coloring (NOT uniform)
>>> xml = '<math><mi>x</mi><mstyle mathcolor="#FF0000"><mi>y</mi></mstyle></math>'
>>> tree = ET.fromstring(xml.encode('utf-8'))
>>> detect_uniform_color(tree)
''
>>> # Mixed colors
>>> xml = '<math><mi mathcolor="#FF0000">x</mi><mo mathcolor="#0000FF">+</mo></math>'
>>> tree = ET.fromstring(xml.encode('utf-8'))
>>> detect_uniform_color(tree)
''
"""
colors_found: Set[str] = set()
has_elements_without_color = False
# Content-bearing elements that should have consistent color
# These are the "leaf" elements that actually render visible content
CONTENT_ELEMENTS = {'mi', 'mn', 'mo', 'mtext', 'ms', 'merror'}
def get_local_name(tag: str) -> str:
"""Extract local tag name from potentially namespaced tag."""
return tag.split('}')[-1] if '}' in tag else tag
def get_effective_color(elem: ET.Element) -> str:
"""
Get the effective color for an element (from element or ancestors).
Returns empty string if no color is set.
"""
# Check element itself first
color = elem.get('mathcolor')
if color:
return color.upper()
# Check ancestors (walk up the tree)
parent = elem.getparent()
while parent is not None:
color = parent.get('mathcolor')
if color:
return color.upper()
parent = parent.getparent()
# No color found in element or ancestors
return ""
def check_colors(elem: ET.Element) -> None:
"""Recursively check colors of all content elements."""
nonlocal has_elements_without_color
tag = get_local_name(elem.tag)
# If this is a content-bearing element, check its effective color
if tag in CONTENT_ELEMENTS:
effective_color = get_effective_color(elem)
if effective_color:
colors_found.add(effective_color)
else:
# Found a content element without any color
has_elements_without_color = True
# Recurse into children
for child in elem:
check_colors(child)
check_colors(node)
# Uniform color only if:
# 1. We found exactly one unique color
# 2. ALL content elements have that color (none are without color)
if len(colors_found) == 1 and not has_elements_without_color:
return list(colors_found)[0]
# Not uniform: either no colors, multiple colors, or partial coloring
return ""
def hex_to_latex_color(hex_color: str) -> Tuple[str, str]:
"""
Convert hex color to LaTeX color name or RGB definition.
Args:
hex_color: Hex color string like '#FF0000' or 'FF0000'
Returns:
Tuple of (color_model, color_spec) where:
- color_model is '' for named colors or '[RGB]' for RGB mode
- color_spec is 'red' for named colors or '255,0,0' for RGB
Examples:
>>> hex_to_latex_color('#FF0000')
('', 'red')
>>> hex_to_latex_color('#E54C5E') # Custom color
('[RGB]', '229,76,94')
>>> hex_to_latex_color('000000') # Black (no #)
('', 'black')
Common Color Mappings:
#FF0000 → red
#00FF00 → green
#0000FF → blue
#000000 → black
#FFFFFF → white
#FFFF00 → yellow
#FF00FF → magenta
#00FFFF → cyan
"""
# Remove '#' if present
hex_color = hex_color.lstrip('#')
# Common color mappings (full hex codes only)
common_colors = {
'ff0000': 'red',
'00ff00': 'green',
'0000ff': 'blue',
'000000': 'black',
'ffffff': 'white',
'ffff00': 'yellow',
'ff00ff': 'magenta',
'00ffff': 'cyan',
}
hex_lower = hex_color.lower()
# Check for exact match in common colors
if hex_lower in common_colors:
return ('', common_colors[hex_lower])
# Convert to RGB mode for non-standard colors
try:
# Parse hex digits (supports 6-digit format)
if len(hex_color) == 6:
r = int(hex_color[0:2], 16)
g = int(hex_color[2:4], 16)
b = int(hex_color[4:6], 16)
return ('[RGB]', f'{r},{g},{b}')
else:
# Invalid format - default to black
return ('', 'black')
except (ValueError, IndexError):
# Parsing error - default to black
return ('', 'black')
def should_suppress_color(context) -> bool:
"""
Check if leaf-level colors should be suppressed.
Args:
context: ConversionContext object or converter with color optimization state
Returns:
True if leaf colors should be suppressed (uniform color detected), False otherwise
Usage:
This is used by visitors to check whether they should skip adding
\\textcolor wrappers when the entire equation has a uniform color.
"""
# Check for both attribute names (with and without underscore)
# Legacy adapter uses _suppress_leaf_colors (private), context uses suppress_leaf_colors (public)
return getattr(context, '_suppress_leaf_colors', False) or getattr(context, 'suppress_leaf_colors', False)
def wrap_with_uniform_color(latex: str, uniform_color: str) -> str:
"""
Wrap entire LaTeX equation with uniform color.
Args:
latex: The LaTeX equation string
uniform_color: Hex color string (e.g., '#FF0000')
Returns:
LaTeX wrapped with \\textcolor[RGB]{r,g,b}{...} or original if black
Examples:
>>> wrap_with_uniform_color('x^2', '#FF0000')
'\\\\textcolor{red}{x^2}'
>>> wrap_with_uniform_color('x^2', '#E54C5E')
'\\\\textcolor[RGB]{229,76,94}{x^2}'
>>> wrap_with_uniform_color('x^2', '#000000') # Black - no wrapping
'x^2'
"""
from ..utils import is_default_color
if not uniform_color or is_default_color(uniform_color):
# Black is default - no wrapping needed
return latex
# Convert to LaTeX color
color_model, color_spec = hex_to_latex_color(uniform_color)
# Skip if it's black after conversion
if color_spec == 'black':
return latex
# Wrap with appropriate format
if color_model:
# RGB mode
return f"\\textcolor{color_model}{{{color_spec}}}{{{latex}}}"
else:
# Named color
return f"\\textcolor{{{color_spec}}}{{{latex}}}"
# Mapping from Word highlight color names to CSS-compatible color values
# Word uses some non-standard names (e.g., darkyellow) that need conversion
WORD_HIGHLIGHT_TO_CSS = {
'yellow': 'yellow',
'green': 'lime', # Word "green" is bright green (lime in CSS)
'cyan': 'cyan',
'magenta': 'magenta',
'blue': 'blue',
'red': 'red',
'darkblue': 'darkblue',
'darkcyan': 'darkcyan',
'darkgreen': 'darkgreen',
'darkmagenta': 'darkmagenta',
'darkred': 'darkred',
'darkyellow': '#cccc00', # CSS has no "darkyellow"
'darkgray': 'darkgray',
'lightgray': 'lightgray',
'black': 'black',
'white': 'white',
}
def _highlight_color_to_css(color: str) -> str:
"""Convert a Word highlight color name or hex code to a CSS-compatible value.
Args:
color: Color name (e.g., 'yellow') or hex string (e.g., '#FFFF00')
Returns:
CSS-compatible color string
"""
if not color:
return ''
color_lower = color.lower().strip()
# If it's a hex color, return as-is
if color_lower.startswith('#') or (
len(color_lower) == 6 and all(c in '0123456789abcdef' for c in color_lower)
):
return f'#{color_lower.lstrip("#")}'
# Look up in Word highlight mapping
if color_lower in WORD_HIGHLIGHT_TO_CSS:
return WORD_HIGHLIGHT_TO_CSS[color_lower]
# Unknown named color - return as-is (CSS may still recognize it)
return color_lower
def wrap_with_background_color(latex: str, background_color: str) -> str:
"""
Wrap entire LaTeX equation with background color using \\bbox.
Uses MathJax \\bbox command to apply background color to the equation.
The \\bbox[<options>]{<content>} command renders a colored box around
the equation content.
Args:
latex: The LaTeX equation string
background_color: Color name (e.g., 'yellow', 'green') or hex string
Returns:
LaTeX wrapped with \\bbox[color]{content}, or original if no color
Examples:
>>> wrap_with_background_color('x^2', 'yellow')
'\\\\bbox[yellow]{x^2}'
>>> wrap_with_background_color('x^2', '#FFFF00')
'\\\\bbox[#ffff00]{x^2}'
>>> wrap_with_background_color('x^2', '') # No color - no wrapping
'x^2'
"""
if not background_color:
return latex
# Convert Word highlight color to CSS-compatible value
css_color = _highlight_color_to_css(background_color)
if not css_color:
return latex
# Wrap with \bbox for MathJax background color rendering
return f"\\bbox[{css_color}]{{{latex}}}"
def wrap_element_with_color(latex: str, node: ET.Element, context=None) -> str:
"""
Wrap element with color if present and not suppressed.
Args:
latex: The LaTeX string to wrap
node: MathML element that may have mathcolor attribute
context: Optional ConversionContext for suppression check
Returns:
Original LaTeX or wrapped with \\textcolor{color}{...}
This function respects the suppress_leaf_colors flag in the context.
If suppression is active, it skips individual element coloring.
Examples:
>>> from lxml import etree as ET
>>> node = ET.fromstring(b'<mi mathcolor="#FF0000">x</mi>')
>>> wrap_element_with_color('x', node)
'\\\\textcolor{red}{x}'
>>> # With suppression
>>> class Context:
... suppress_leaf_colors = True
>>> wrap_element_with_color('x', node, Context())
'x'
"""
from ..utils import is_default_color
# Check if suppression is active
if context and should_suppress_color(context):
return latex
# Get mathcolor attribute
mathcolor = node.get('mathcolor')
if not mathcolor or is_default_color(mathcolor):
return latex
# Convert and wrap
color_model, color_spec = hex_to_latex_color(mathcolor)
# Skip black (default color)
if color_spec == 'black':
return latex
# Wrap with appropriate format
if color_model:
return f"\\textcolor{color_model}{{{color_spec}}}{{{latex}}}"
else:
return f"\\textcolor{{{color_spec}}}{{{latex}}}"

View File

@@ -0,0 +1,55 @@
"""
Debug output control utility.
This module provides a centralized way to control debug output across the project.
Debug output is disabled by default and can be enabled via the OMML2TEX_DEBUG
environment variable.
Usage:
from omml2tex.utils.debug import debug_print
debug_print("[DEBUG] Some debug info") # Only prints if OMML2TEX_DEBUG=1
Environment Variables:
OMML2TEX_DEBUG: Set to "1" or "true" to enable debug output (default: disabled)
"""
import os
import sys
def is_debug_enabled() -> bool:
"""
Check if debug mode is enabled via environment variable.
Returns:
True if OMML2TEX_DEBUG is set to "1" or "true", False otherwise
"""
debug_value = os.environ.get('OMML2TEX_DEBUG', '').lower()
return debug_value in ('1', 'true', 'yes', 'on')
def debug_print(*args, **kwargs):
"""
Print debug output only if debug mode is enabled.
This function has the same signature as the built-in print() function,
but only outputs to stderr when OMML2TEX_DEBUG environment variable is set.
Args:
*args: Arguments to print
**kwargs: Keyword arguments for print (e.g., file, sep, end)
Examples:
>>> debug_print("[DEBUG] Processing equation")
>>> debug_print("[DEBUG] Value:", value, "Result:", result)
>>> debug_print("[DEBUG] Error", file=sys.stderr) # file=sys.stderr is default
"""
if is_debug_enabled():
# Default to stderr if file not specified
if 'file' not in kwargs:
kwargs['file'] = sys.stderr
print(*args, **kwargs)
__all__ = ['is_debug_enabled', 'debug_print']

View File

@@ -0,0 +1,136 @@
"""
Pattern and symbol replacement helper functions.
This module provides utilities for applying symbol mappings and regex patterns
to text, used throughout the conversion pipeline.
"""
import re
import logging
logger = logging.getLogger(__name__)
def apply_symbol_replacements(text: str, mapping: dict) -> str:
"""Apply simple symbol replacements from a mapping dictionary.
Args:
text: Input text
mapping: Dictionary of {symbol: replacement}
Returns:
Text with all symbols replaced
Example:
>>> mapping = {'α': '\\\\alpha', 'β': '\\\\beta'}
>>> apply_symbol_replacements('α + β', mapping)
'\\\\alpha + \\\\beta'
"""
for symbol, replacement in mapping.items():
text = text.replace(symbol, replacement)
return text
def apply_pattern_replacements(text: str, patterns: list, verbose: bool = False) -> str:
"""Apply regex pattern replacements from a list of (pattern, replacement, description) tuples.
Args:
text: Input text
patterns: List of (pattern, replacement, description) tuples
verbose: If True, log each replacement
Returns:
Text with all patterns applied
Example:
>>> patterns = [(r' {3,}', ' ', 'Reduce multiple spaces')]
>>> apply_pattern_replacements('a b', patterns)
'a b'
"""
for pattern, replacement, description in patterns:
if verbose:
count = len(re.findall(pattern, text))
if count > 0:
logger.debug(f"Applying: {description} ({count} matches)")
text = re.sub(pattern, replacement, text)
return text
def apply_unicode_symbol_mapping(
text: str,
unicode_to_latex: dict,
additional_replacements: dict = None,
subscript_context: bool = False
) -> str:
"""Apply Unicode to LaTeX symbol mapping.
Args:
text: Input text containing Unicode symbols
unicode_to_latex: Main Unicode to LaTeX mapping dictionary
additional_replacements: Optional additional symbol replacements
subscript_context: If True, use subscript-specific mappings (with spaces)
Returns:
Text with Unicode symbols converted to LaTeX
Example:
>>> mapping = {'α': '\\\\alpha', '→': '\\\\to'}
>>> apply_unicode_symbol_mapping('α → β', mapping, {})
'\\\\alpha \\\\to β'
"""
# First apply standard UNICODE_TO_LATEX mapping
for symbol, latex in unicode_to_latex.items():
text = text.replace(symbol, latex)
# In subscript context, apply additional replacements with proper spacing
if subscript_context and additional_replacements:
for symbol, latex in additional_replacements.items():
text = text.replace(symbol, latex)
return text
def is_default_color(color: str) -> bool:
"""Check if a color value represents the default (black) color.
Args:
color: Color value in various formats (hex, name, rgb)
Returns:
True if the color is black/default, False otherwise
Examples:
>>> is_default_color('#000000')
True
>>> is_default_color('#000')
True
>>> is_default_color('black')
True
>>> is_default_color('rgb(0, 0, 0)')
True
>>> is_default_color('#FF0000')
False
>>> is_default_color('red')
False
"""
if not color:
return False
# Normalize the color string
color_lower = color.lower().strip()
# Check for common black color representations
black_colors = {
'#000000', # Full hex
'#000', # Short hex
'black', # Named color
'rgb(0,0,0)', # RGB without spaces
'rgb(0, 0, 0)', # RGB with spaces
'rgba(0,0,0,1)', # RGBA without spaces
'rgba(0, 0, 0, 1)', # RGBA with spaces
}
# Remove all whitespace for comparison
color_normalized = ''.join(color_lower.split())
return color_normalized in {c.replace(' ', '') for c in black_colors}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,185 @@
"""
Text processing utilities for file I/O and LaTeX formatting.
This module provides functions for reading/writing text files and converting
various space characters to appropriate LaTeX spacing commands.
"""
import os
def read_text(path: str, encoding: str = "utf-8") -> str:
"""Read text from a file.
Args:
path: Path to the file to read
encoding: Text encoding (default: utf-8)
Returns:
File contents as string
Example:
>>> content = read_text('input.tex')
"""
with open(path, "r", encoding=encoding) as f:
return f.read()
def write_text(path: str, content: str, encoding: str = "utf-8") -> None:
"""Write text to a file, creating parent directories if needed.
Args:
path: Path to the file to write
content: Text content to write
encoding: Text encoding (default: utf-8)
Example:
>>> write_text('output.tex', '\\\\sin x')
"""
dirpath = os.path.dirname(path)
if dirpath:
os.makedirs(dirpath, exist_ok=True)
with open(path, "w", encoding=encoding) as f:
f.write(content)
def convert_spaces_to_latex(text: str) -> str:
"""Convert space characters in text to appropriate LaTeX spacing commands.
Handles:
- U+00A0 (NBSP, \\xa0) → ~ (tilde, non-breaking space in LaTeX)
- U+0020 (regular space) → \\\\ (backslash-space) for each space
Args:
text: Input text possibly containing various space characters
Returns:
Text with spaces converted to LaTeX commands
Example:
>>> convert_spaces_to_latex('sin x')
'sin\\\\ x'
>>> convert_spaces_to_latex('sin\\xa0x') # NBSP
'sin~x'
"""
if not text:
return text
# Quick check: if no spaces present, return as-is
if '\xa0' not in text and ' ' not in text:
return text
result = []
i = 0
while i < len(text):
char = text[i]
if char == '\xa0': # U+00A0: Non-breaking space
# Always use tilde for NBSP in LaTeX
result.append('~')
i += 1
elif char == ' ': # U+0020: Regular space
# Count consecutive spaces
space_count = 1
while i + space_count < len(text) and text[i + space_count] == ' ':
space_count += 1
# Convert each space to backslash-space (backslash followed by space)
# For N spaces, produce "\\ " repeated N times: "\\ \\ \\ "
# Each '\\ ' is already a complete unit, so use empty join (not space join)
result.append('\\ ' * space_count)
i += space_count # Skip all processed spaces
else:
# Regular character
result.append(char)
i += 1
# Join results but preserve trailing spaces that are part of LaTeX commands
result_str = ''.join(result)
# Don't strip if the string ends with LaTeX space command '\\ '
# The trailing space in '\\ ' is semantically significant
if result_str.endswith('\\ '):
return result_str
else:
return result_str.rstrip() # Remove trailing space from last element
def escape_latex_special_chars(text: str, preserve_nbsp: bool = True) -> str:
"""Escape special characters that have meaning in LaTeX.
LaTeX special characters that need escaping:
- $ (math mode delimiter)
- ^ (superscript)
- _ (subscript)
- # (parameter token)
- @ (letter category code)
- ! (negative thin space)
- % (comment)
- & (alignment tab)
- * (discretionary multiplication)
- ~ (tilde) → \\sim in math mode
Args:
text: Input text possibly containing special characters
preserve_nbsp: If True, preserve NBSP (\\xa0) for later conversion to ~
If False, convert NBSP to ~ before escaping
Returns:
Text with special characters escaped
Example:
>>> escape_latex_special_chars('$100 ^ 2')
'\\$100 \\^ 2'
>>> escape_latex_special_chars('H_2O')
'H\\_2O'
>>> escape_latex_special_chars('a ~ b')
'a \\sim b'
"""
if not text:
return text
# CRITICAL: Preserve marker strings used for brace resolution
# These markers distinguish source braces from generated LaTeX braces
if text in ('__LBRACE__', '__RBRACE__', '__DELIM__'):
return text
# Use a temporary placeholder for NBSP to preserve it during escaping
# This allows NBSP to be converted to ~ later for LaTeX spacing
NBSP_PLACEHOLDER = '\x00NBSP\x00' # Using null bytes as unlikely to appear in text
if preserve_nbsp and '\xa0' in text:
# Temporarily replace NBSP with placeholder
text = text.replace('\xa0', NBSP_PLACEHOLDER)
# Mapping of special characters to their escaped versions
# NOTE: In math mode, * is a valid operator and doesn't need escaping
# NOTE: { and } are NOT escaped here - they're handled via __LBRACE__/__RBRACE__ markers
# to distinguish source braces from generated LaTeX braces (e.g., \mathrm{...})
ESCAPE_MAP = {
'$': r'\$',
'%': r'\%',
'#': r'\#',
'&': r'\&',
'_': r'\_',
'^': r'\^',
# NOTE: '~' is NOT escaped here - it's handled via UNICODE_TO_LATEX mapping to \sim
'@': r'\@',
'!': r'\!',
# NOTE: '*' is NOT escaped - it's a valid multiplication operator in math mode
'{': r'\{',
'}': r'\}',
}
result = text
for char, escaped in ESCAPE_MAP.items():
result = result.replace(char, escaped)
# Restore NBSP placeholder back to NBSP for later processing
if preserve_nbsp and NBSP_PLACEHOLDER in result:
result = result.replace(NBSP_PLACEHOLDER, '\xa0')
return result

View File

@@ -0,0 +1,376 @@
"""
Performance timing utilities for omml2tex.
Provides comprehensive timing instrumentation with minimal overhead:
- Execution time tracking for critical operations
- Memory usage monitoring
- Hierarchical timing for nested operations
- Statistical summaries and reports
"""
import time
import functools
from typing import Dict, List, Optional, Callable, Any
from dataclasses import dataclass, field
from contextlib import contextmanager
@dataclass
class TimingRecord:
"""Single timing measurement record."""
name: str
start_time: float
end_time: Optional[float] = None
duration: Optional[float] = None
metadata: Dict[str, Any] = field(default_factory=dict)
parent: Optional[str] = None
def finish(self) -> None:
"""Mark record as finished and calculate duration."""
if self.end_time is None:
self.end_time = time.perf_counter()
self.duration = self.end_time - self.start_time
def is_finished(self) -> bool:
"""Check if timing record is complete."""
return self.end_time is not None
@dataclass
class TimingSummary:
"""Statistical summary of timing measurements."""
name: str
count: int
total_time: float
avg_time: float
min_time: float
max_time: float
metadata: Dict[str, Any] = field(default_factory=dict)
class PerformanceTimer:
"""
High-precision performance timer with hierarchical timing support.
Features:
- Sub-millisecond precision using time.perf_counter()
- Hierarchical timing for nested operations
- Minimal overhead (~1-2 microseconds per timing)
- Thread-safe operation tracking
- Statistical summaries and reporting
Example:
timer = PerformanceTimer()
# Context manager usage
with timer.measure("conversion"):
result = convert_mathml(xml)
# Decorator usage
@timer.time_function
def process_equation(xml):
return convert(xml)
# Manual timing
timer.start("parsing")
data = parse_xml(xml)
timer.stop("parsing")
# Get results
print(timer.get_summary())
"""
def __init__(self, enabled: bool = True):
"""
Initialize performance timer.
Args:
enabled: If False, timing is disabled (zero overhead)
"""
self.enabled = enabled
self._records: Dict[str, List[TimingRecord]] = {}
self._active: Dict[str, TimingRecord] = {}
self._parent_stack: List[str] = []
def start(self, name: str, metadata: Optional[Dict[str, Any]] = None) -> None:
"""
Start timing an operation.
Args:
name: Operation name (used for grouping)
metadata: Optional metadata to attach to record
"""
if not self.enabled:
return
parent = self._parent_stack[-1] if self._parent_stack else None
record = TimingRecord(
name=name,
start_time=time.perf_counter(),
metadata=metadata or {},
parent=parent
)
self._active[name] = record
self._parent_stack.append(name)
def stop(self, name: str, metadata: Optional[Dict[str, Any]] = None) -> Optional[float]:
"""
Stop timing an operation.
Args:
name: Operation name (must match start() call)
metadata: Optional additional metadata
Returns:
Duration in seconds, or None if timing disabled/not started
"""
if not self.enabled:
return None
if name not in self._active:
return None
record = self._active.pop(name)
record.finish()
if metadata:
record.metadata.update(metadata)
if name not in self._records:
self._records[name] = []
self._records[name].append(record)
# Remove from parent stack
if self._parent_stack and self._parent_stack[-1] == name:
self._parent_stack.pop()
return record.duration
@contextmanager
def measure(self, name: str, metadata: Optional[Dict[str, Any]] = None):
"""
Context manager for timing a block of code.
Args:
name: Operation name
metadata: Optional metadata
Yields:
TimingRecord that will be populated with results
Example:
with timer.measure("parsing", {"file": "input.xml"}):
data = parse_file()
"""
if not self.enabled:
yield None
return
self.start(name, metadata)
try:
yield self._active.get(name)
finally:
self.stop(name)
def time_function(self, name: Optional[str] = None):
"""
Decorator for timing function execution.
Args:
name: Optional custom name (defaults to function name)
Example:
@timer.time_function
def convert_equation(xml):
return process(xml)
@timer.time_function("custom_name")
def process_data(data):
return transform(data)
"""
def decorator(func: Callable) -> Callable:
operation_name = name or func.__name__
@functools.wraps(func)
def wrapper(*args, **kwargs):
if not self.enabled:
return func(*args, **kwargs)
with self.measure(operation_name):
return func(*args, **kwargs)
return wrapper
return decorator
def get_records(self, name: Optional[str] = None) -> List[TimingRecord]:
"""
Get timing records.
Args:
name: Optional operation name filter
Returns:
List of timing records (all records if name=None)
"""
if name:
return self._records.get(name, [])
all_records = []
for records in self._records.values():
all_records.extend(records)
return all_records
def get_summary(self, name: Optional[str] = None) -> Dict[str, TimingSummary]:
"""
Get statistical summary of timing measurements.
Args:
name: Optional operation name filter
Returns:
Dictionary mapping operation names to summaries
"""
summaries = {}
records_dict = {name: self._records[name]} if name and name in self._records else self._records
for op_name, records in records_dict.items():
if not records:
continue
durations = [r.duration for r in records if r.duration is not None]
if not durations:
continue
summaries[op_name] = TimingSummary(
name=op_name,
count=len(durations),
total_time=sum(durations),
avg_time=sum(durations) / len(durations),
min_time=min(durations),
max_time=max(durations)
)
return summaries
def get_total_time(self) -> float:
"""Get total time across all operations."""
summaries = self.get_summary()
return sum(s.total_time for s in summaries.values())
def format_report(self, include_hierarchy: bool = True) -> str:
"""
Format timing results as human-readable report.
Args:
include_hierarchy: Include parent-child relationships
Returns:
Formatted report string
"""
summaries = self.get_summary()
if not summaries:
return "No timing data collected"
lines = [
"=" * 80,
"Performance Timing Report".center(80),
"=" * 80,
""
]
# Sort by total time (descending)
sorted_summaries = sorted(
summaries.values(),
key=lambda s: s.total_time,
reverse=True
)
# Table header
lines.append(f"{'Operation':<30} {'Count':>8} {'Total':>12} {'Avg':>12} {'Min':>12} {'Max':>12}")
lines.append("-" * 80)
# Table rows
for summary in sorted_summaries:
lines.append(
f"{summary.name:<30} "
f"{summary.count:>8} "
f"{self._format_time(summary.total_time):>12} "
f"{self._format_time(summary.avg_time):>12} "
f"{self._format_time(summary.min_time):>12} "
f"{self._format_time(summary.max_time):>12}"
)
lines.append("-" * 80)
lines.append(f"{'TOTAL':<30} {'':<8} {self._format_time(self.get_total_time()):>12}")
lines.append("=" * 80)
# Hierarchy view
if include_hierarchy:
lines.append("")
lines.append("Hierarchical View:")
lines.append("-" * 80)
self._add_hierarchy_lines(lines)
return "\n".join(lines)
def _add_hierarchy_lines(self, lines: List[str], parent: Optional[str] = None, indent: int = 0):
"""Add hierarchical timing view to report."""
for name, records in self._records.items():
# Find records with matching parent
matching_records = [r for r in records if r.parent == parent]
if not matching_records:
continue
avg_time = sum(r.duration for r in matching_records if r.duration) / len(matching_records)
prefix = " " * indent + "└─ " if indent > 0 else ""
lines.append(f"{prefix}{name}: {self._format_time(avg_time)} avg ({len(matching_records)} calls)")
# Recursively add children
self._add_hierarchy_lines(lines, name, indent + 1)
@staticmethod
def _format_time(seconds: float) -> str:
"""Format time duration in human-readable form."""
if seconds < 1e-6:
return f"{seconds * 1e9:.2f} ns"
elif seconds < 1e-3:
return f"{seconds * 1e6:.2f} µs"
elif seconds < 1:
return f"{seconds * 1e3:.2f} ms"
else:
return f"{seconds:.2f} s"
def reset(self) -> None:
"""Clear all timing data."""
self._records.clear()
self._active.clear()
self._parent_stack.clear()
def enable(self) -> None:
"""Enable timing."""
self.enabled = True
def disable(self) -> None:
"""Disable timing."""
self.enabled = False
# Global timer instance for convenience
_global_timer = PerformanceTimer(enabled=False)
def get_global_timer() -> PerformanceTimer:
"""Get global timer instance."""
return _global_timer
def enable_global_timer() -> None:
"""Enable global timer."""
_global_timer.enable()
def disable_global_timer() -> None:
"""Disable global timer."""
_global_timer.disable()

View File

@@ -0,0 +1,205 @@
"""
XML processing utilities for MathML and OMML handling.
This module provides helper functions for working with XML/MathML/OMML documents,
including namespace handling, text extraction, and HTML entity decoding.
"""
import re
import html
from typing import List
from lxml import etree as ET
def local_name(tag: str) -> str:
"""Extract local tag name without namespace.
Args:
tag: XML tag name, possibly with namespace prefix like "{http://...}tagname"
Returns:
Local tag name without namespace
Example:
>>> local_name("{http://www.w3.org/1998/Math/MathML}mi")
'mi'
>>> local_name("mi")
'mi'
"""
if "}" in tag:
return tag.split("}", 1)[1]
return tag
def text_content(node: ET._Element) -> str:
"""Flatten text under a node (best-effort).
NOTE: Does NOT strip() to preserve space-only content like NBSP and regular spaces.
Spaces are semantically meaningful in mathematical notation.
However, DOES remove invisible formatting characters like function application (\u2061)
and normalizes whitespace (converts newlines/tabs to single spaces).
Args:
node: XML element to extract text from
Returns:
Concatenated text content from node and all its children
Example:
>>> from lxml import etree as ET
>>> node = ET.fromstring('<mi>sin</mi>')
>>> text_content(node)
'sin'
"""
texts: List[str] = []
if node.text:
texts.append(node.text)
for child in node:
texts.append(text_content(child))
if child.tail:
texts.append(child.tail)
result = "".join(texts)
# Remove invisible formatting characters that interfere with conversion
# but preserve meaningful spaces (regular space, nbsp)
invisible_chars = [
'\u2061', # FUNCTION APPLICATION (causes sin⁡ → \mathrm{sin})
'\u2062', # INVISIBLE TIMES
'\u2063', # INVISIBLE SEPARATOR
'\u2064', # INVISIBLE PLUS
'\uFEFF', # ZERO WIDTH NO-BREAK SPACE
'\u200B', # ZERO WIDTH SPACE
'\u200C', # ZERO WIDTH NON-JOINER
'\u200D', # ZERO WIDTH JOINER
]
for char in invisible_chars:
result = result.replace(char, '')
# Normalize whitespace: Remove newlines and tabs completely
# This fixes both multiline fractions like \dfrac{\n 2\n}
# AND prevents formatting whitespace from JSON/XML from being converted to LaTeX spacing
# Preserve NBSP (\xa0) as it's semantically meaningful
result = re.sub(r'[\n\r\t]+', '', result)
# Collapse multiple consecutive regular spaces into one
# This prevents " 2 " → should be " 2 " or just "2"
# But don't collapse NBSP (\xa0) as it's semantically meaningful
result = re.sub(r' +', ' ', result)
# Strip leading/trailing spaces that come from XML formatting
# These are not semantically meaningful - they're just from pretty-printed XML
# True semantic spaces (like in operators) are preserved within the content
result = result.strip()
return result
def decode_html_entities(text: str) -> str:
"""Decode HTML entities in text, particularly &quot; from web page copies.
IMPORTANT: This function is designed for HTML content, NOT for XML/MathML!
DO NOT use this on XML/MathML strings before XML parsing, as it will break
valid XML numeric character references like &#x003C; which XML parsers
handle natively.
Args:
text: Text possibly containing HTML entities
Returns:
Text with HTML named entities decoded (numeric entities preserved for XML)
Example:
>>> decode_html_entities('&lt;math&gt; &amp; &quot;test&quot;')
'<math> & "test"'
>>> decode_html_entities('<mo>&#x003C;</mo>') # Preserves numeric entities
'<mo>&#x003C;</mo>'
"""
# CRITICAL FIX: Do NOT use html.unescape() on XML/MathML content!
# html.unescape() decodes numeric character references like &#x003C; to <
# which breaks XML parsing when < is inside element content.
#
# Instead, only decode common HTML named entities that might appear
# in web page copies, and leave numeric entities for XML parser to handle.
# Handle specific named entities that might appear in HTML copies
# but preserve numeric entities (&#x...;, &#...;) for XML parsing
entity_map = {
'&quot;': '"',
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&apos;': "'",
'&nbsp;': '\xa0', # Non-breaking space
}
for entity, char in entity_map.items():
text = text.replace(entity, char)
return text
def extract_math_equations(text: str) -> List[str]:
"""Extract multiple MathML equations from text starting with <math tag.
Args:
text: Text possibly containing one or more MathML equations
Returns:
List of MathML equation strings (empty list if none found)
Example:
>>> text = '<math><mi>x</mi></math> and <math><mi>y</mi></math>'
>>> equations = extract_math_equations(text)
>>> len(equations)
2
"""
# CRITICAL FIX: Do NOT decode HTML entities before regex extraction!
# The input is XML/MathML which contains numeric character references
# like &#x003C; that are valid XML and must be preserved.
# XML parsers (lxml) will handle entity decoding automatically.
# Decoding them here would break valid XML like <mo>&#x003C;</mo> → <mo><</mo>
#
# Original line (REMOVED): text = decode_html_entities(text)
equations = []
# Find all <math> tags with their content
# Use a more robust regex that handles nested tags and various formatting
pattern = r'<math[^>]*>.*?</math>'
matches = re.findall(pattern, text, re.DOTALL | re.IGNORECASE)
for match in matches:
# Clean up the equation
equation = match.strip()
if equation:
# Decode HTML entities in the extracted equation
# This handles cases like &quot; → " which are needed for valid XML
equation = decode_html_entities(equation)
equations.append(equation)
# If no <math> tags found, check if the entire input is a single equation
if not equations and text.strip().startswith('<math'):
# Try to find the end of the math tag
text = text.strip()
# Simple case: if it looks like a complete math equation
if '</math>' in text.lower():
# Decode HTML entities before appending
text = decode_html_entities(text)
equations.append(text)
else:
# Try to fix incomplete equation by adding closing tag
if not text.lower().endswith('</math>'):
text += '</math>'
# Decode HTML entities before appending
text = decode_html_entities(text)
equations.append(text)
return equations
# Alias for backward compatibility (some code uses _local, some uses local_name)
_local = local_name
_text_content = text_content

View File

@@ -0,0 +1,74 @@
"""
Visitor implementations for MathML elements.
This package contains visitor classes that handle conversion of specific
MathML element types to LaTeX format.
"""
from .visitor_registry import VisitorRegistry
from .mi_visitor import MiVisitor
from .mo_visitor import MoVisitor
from .mn_visitor import MnVisitor
from .mfrac_visitor import MfracVisitor
from .msup_visitor import MsupVisitor
from .msub_visitor import MsubVisitor
from .mrow_visitor import MrowVisitor
from .msqrt_visitor import MsqrtVisitor
from .mroot_visitor import MrootVisitor
from .msubsup_visitor import MsubsupVisitor
from .mover_visitor import MoverVisitor
from .munder_visitor import MunderVisitor
from .munderover_visitor import MunderoverVisitor
from .mfenced_visitor import MfencedVisitor
from .mtext_visitor import MtextVisitor
from .mtable_visitor import MtableVisitor
from .mtr_visitor import MtrVisitor
from .mtd_visitor import MtdVisitor
from .mspace_visitor import MspaceVisitor
from .mstyle_visitor import MstyleVisitor
from .merror_visitor import MerrorVisitor
from .menclose_visitor import MencloseVisitor
from .mmultiscripts_visitor import MmultiscriptsVisitor
from .mpadded_visitor import MpaddedVisitor
from .mphantom_visitor import MphantomVisitor
from .maction_visitor import MactionVisitor
from .ms_visitor import MsVisitor
from .mglyph_visitor import MglyphVisitor
from .mlabeledtr_visitor import MlabeledtrVisitor
from .maligngroup_visitor import MaligngroupVisitor
from .malignmark_visitor import MalignmarkVisitor
__all__ = [
'VisitorRegistry',
'MiVisitor',
'MoVisitor',
'MnVisitor',
'MfracVisitor',
'MsupVisitor',
'MsubVisitor',
'MrowVisitor',
'MsqrtVisitor',
'MrootVisitor',
'MsubsupVisitor',
'MoverVisitor',
'MunderVisitor',
'MunderoverVisitor',
'MfencedVisitor',
'MtextVisitor',
'MtableVisitor',
'MtrVisitor',
'MtdVisitor',
'MspaceVisitor',
'MstyleVisitor',
'MerrorVisitor',
'MencloseVisitor',
'MmultiscriptsVisitor',
'MpaddedVisitor',
'MphantomVisitor',
'MactionVisitor',
'MsVisitor',
'MglyphVisitor',
'MlabeledtrVisitor',
'MaligngroupVisitor',
'MalignmarkVisitor',
]

View File

@@ -0,0 +1,137 @@
"""
Template for standardized _convert_child() method.
This provides a complete, comprehensive _convert_child implementation that all visitors can use.
It handles all MathML element types and provides proper warnings for unknown elements.
"""
from lxml import etree as ET
from ..utils import text_content as _text_content, local_name as _local
def convert_child_standard(child: ET.Element, strategy, visitor_name: str = "unknown") -> str:
"""
Standard _convert_child implementation for all visitors.
Args:
child: The child element to convert
strategy: The conversion strategy to use
visitor_name: Name of the calling visitor (for debug messages)
Returns:
LaTeX string representation of the child element
"""
tag = _local(child.tag)
# Token elements (leaf nodes)
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(strategy).visit(child)
# Layout elements (structural)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(strategy).visit(child)
# Script and limit elements
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(strategy).visit(child)
# Table elements
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(strategy).visit(child)
elif tag == 'mtr':
from .mtr_visitor import MtrVisitor
return MtrVisitor(strategy).visit(child)
elif tag == 'mtd':
from .mtd_visitor import MtdVisitor
return MtdVisitor(strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(strategy).visit(child)
# Style and presentation elements
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(strategy).visit(child)
# Action elements
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(strategy).visit(child)
# Glyph elements
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(strategy).visit(child)
else:
# Fallback for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in {visitor_name}, using text fallback", file=sys.stderr)
return _text_content(child)

View File

@@ -0,0 +1,215 @@
r"""
Visitor for <maction> (action/interactive) MathML elements.
The <maction> element binds actions (like tooltips, highlighting, or toggling)
to mathematical expressions. It typically has an actiontype attribute that
specifies the kind of action.
In LaTeX conversion, maction is treated as a transparent wrapper since LaTeX
is a static typesetting system without interactive capabilities. Only the
selected child (typically the first) is converted.
Common action types:
- toggle: Toggle between alternative expressions
- statusline: Display expression in status line
- tooltip: Show tooltip on hover
- highlight: Highlight on mouseover
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MactionVisitor(Visitor):
r"""
Handles <maction> (action/interactive) MathML elements.
Treats maction as a transparent wrapper, converting only the selected
child (typically the first one) since LaTeX doesn't support interactivity.
Examples:
Toggle action (shows first alternative):
<maction actiontype="toggle">
<mi>x</mi>
<mi>y</mi>
</maction>
→ x
Tooltip action (shows content, ignores tooltip):
<maction actiontype="tooltip">
<mi>a</mi>
<mtext>variable a</mtext>
</maction>
→ a
Highlight action (shows content without highlight):
<maction actiontype="highlight">
<mfrac><mi>a</mi><mi>b</mi></mfrac>
</maction>
→ \frac{a}{b}
Selection attribute (selects specific child):
<maction actiontype="toggle" selection="2">
<mi>x</mi>
<mi>y</mi>
<mi>z</mi>
</maction>
→ y (second child, 1-indexed)
Note:
LaTeX has no interactive capabilities, so:
- Only one child is rendered (the selected one)
- Action types are ignored
- Default: first child is selected
"""
def __init__(self, strategy: Strategy):
"""Initialize the Maction visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <maction> element to LaTeX (renders selected child only).
Args:
node: The <maction> element to convert
Returns:
LaTeX string with selected child converted
Raises:
ValueError: If node is not a <maction> element
"""
if not self.can_handle(node):
raise ValueError(f"MactionVisitor cannot handle element '{node.tag}'")
# Get selection attribute (1-indexed, defaults to 1)
selection = node.get("selection", "1")
try:
selection_idx = int(selection) - 1 # Convert to 0-indexed
except ValueError:
selection_idx = 0 # Default to first child if invalid
# Get children
children = list(node)
if not children:
return ""
# Ensure selection is within bounds
if selection_idx < 0 or selection_idx >= len(children):
selection_idx = 0
# Convert only the selected child
return self._convert_child(children[selection_idx])
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in maction, using text fallback", file=sys.stderr)
return _text_content(child)
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'maction'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'maction'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MactionVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,88 @@
r"""
Visitor for <maligngroup> (alignment group marker) MathML elements.
The <maligngroup/> element is an empty element used within table cells (<mtd>)
to mark alignment groups. It's used to control horizontal alignment across
table columns for complex multi-line expressions.
In LaTeX conversion, alignment groups are handled implicitly by the array/matrix
environment's column specifications (e.g., {lll} for left-aligned columns).
Therefore, maligngroup markers are treated as transparent (no output).
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MaligngroupVisitor(Visitor):
r"""
Handles <maligngroup/> (alignment group marker) MathML elements.
Treats maligngroup as an invisible marker since LaTeX handles alignment
through column specifications in array/matrix environments. The element
is completely ignored in conversion.
Examples:
Alignment group in cell:
<mtd>
<maligngroup/>
<mi>x</mi>
</mtd>
→ x (maligngroup ignored)
Multiple alignment groups:
<mtd>
<maligngroup groupalign="left"/>
<mi>a</mi>
<maligngroup groupalign="right"/>
<mi>b</mi>
</mtd>
→ a b (both maligngroup markers ignored)
Group with alignment attribute:
<maligngroup groupalign="center"/>
→ (empty - no output)
Note:
LaTeX doesn't support MathML's alignment group concept. Alignment
is controlled by column specifications in the array/matrix preamble
(e.g., {ccc} for center-aligned, {lll} for left-aligned).
Therefore, maligngroup elements produce no output.
"""
def __init__(self, strategy: Strategy):
"""Initialize the Maligngroup visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <maligngroup/> element (returns empty string).
Args:
node: The <maligngroup/> element to convert
Returns:
Empty string (maligngroup is invisible in LaTeX)
Raises:
ValueError: If node is not a <maligngroup/> element
"""
if not self.can_handle(node):
raise ValueError(f"MaligngroupVisitor cannot handle element '{node.tag}'")
# Maligngroup is an invisible alignment marker - no output
return ""
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'maligngroup'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'maligngroup'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MaligngroupVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,92 @@
r"""
Visitor for <malignmark> (alignment point marker) MathML elements.
The <malignmark/> element is an empty element used within table cells (<mtd>)
to mark specific alignment points for fine-grained horizontal alignment control.
It differs from <maligngroup/> in that it marks a specific point within content
rather than grouping alignment zones.
In LaTeX conversion, alignment points are handled implicitly by the array/matrix
environment's column specifications and & separators. Therefore, malignmark
markers are treated as transparent (no output).
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MalignmarkVisitor(Visitor):
r"""
Handles <malignmark/> (alignment point marker) MathML elements.
Treats malignmark as an invisible marker since LaTeX handles alignment
through column specifications and & separators in array/matrix environments.
The element is completely ignored in conversion.
Examples:
Alignment point in cell:
<mtd>
<mi>x</mi>
<malignmark/>
<mo>=</mo>
<mn>5</mn>
</mtd>
→ x = 5 (malignmark ignored)
Multiple alignment marks:
<mtd>
<malignmark edge="left"/>
<mi>a</mi>
<malignmark edge="right"/>
<mi>b</mi>
</mtd>
→ a b (both malignmark markers ignored)
Mark with edge attribute:
<malignmark edge="center"/>
→ (empty - no output)
Note:
LaTeX doesn't support MathML's alignment mark concept. Alignment
is controlled by column specifications in the array/matrix preamble
(e.g., {ccc} for center-aligned, {lll} for left-aligned) and by
& separators between cells. Therefore, malignmark elements produce
no output.
"""
def __init__(self, strategy: Strategy):
"""Initialize the Malignmark visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <malignmark/> element (returns empty string).
Args:
node: The <malignmark/> element to convert
Returns:
Empty string (malignmark is invisible in LaTeX)
Raises:
ValueError: If node is not a <malignmark/> element
"""
if not self.can_handle(node):
raise ValueError(f"MalignmarkVisitor cannot handle element '{node.tag}'")
# Malignmark is an invisible alignment point marker - no output
return ""
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'malignmark'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'malignmark'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MalignmarkVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,426 @@
r"""
Visitor for <menclose> (box/cancel notation) MathML elements.
The <menclose> element is used to enclose mathematical content with
various notations including boxes, circles, strike-throughs, and partial frames.
This visitor handles notation attribute values like:
- box: Full box around content
- circle: Circle around content
- horizontalstrike, verticalstrike: Strike-through lines
- updiagonalstrike, downdiagonalstrike: Diagonal strike-through
- top, bottom, left, right: Partial box sides
- actuarial, longdiv, radical: Specialized notations
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MencloseVisitor(Visitor):
r"""
Handles <menclose> (box/cancel notation) MathML elements.
Converts various notation attributes to appropriate LaTeX commands.
Examples:
Box notation:
<menclose notation="box"><mi>x</mi></menclose>
→ \boxed{x}
Horizontal strike:
<menclose notation="horizontalstrike"><mi>x</mi></menclose>
→ \cancel{x}
Diagonal strike (up):
<menclose notation="updiagonalstrike"><mi>x</mi></menclose>
→ \cancel{x}
Diagonal strike (down):
<menclose notation="downdiagonalstrike"><mi>x</mi></menclose>
→ \bcancel{x}
Combined diagonal strikes:
<menclose notation="updiagonalstrike downdiagonalstrike"><mi>x</mi></menclose>
→ \xcancel{x}
Top line only:
<menclose notation="top"><mi>x</mi></menclose>
→ \overline{x}
Bottom line only:
<menclose notation="bottom"><mi>x</mi></menclose>
→ \underline{x}
Left delimiter:
<menclose notation="left"><mi>x</mi></menclose>
→ \left| x \right.
Right delimiter:
<menclose notation="right"><mi>x</mi></menclose>
→ \left. x \right|
Circle notation:
<menclose notation="circle"><mi>x</mi></menclose>
→ \circled{x}
Actuarial notation:
<menclose notation="actuarial"><mi>x</mi></menclose>
→ \overline{\underline{x}}
Long division:
<menclose notation="longdiv"><mi>x</mi></menclose>
→ \enclose{longdiv}{x}
Radical notation:
<menclose notation="radical"><mi>x</mi></menclose>
→ \sqrt{x}
"""
def __init__(self, strategy: Strategy):
"""Initialize the Menclose visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <menclose> element to LaTeX with appropriate notation.
Args:
node: The <menclose> element to convert
Returns:
LaTeX string with notation applied to content
Raises:
ValueError: If node is not a <menclose> element
"""
if not self.can_handle(node):
raise ValueError(f"MencloseVisitor cannot handle element '{node.tag}'")
notation = node.get("notation", "box")
# Special case: box containing mfrac with underlined/overlined numerator (like chemical reaction conditions)
# Pattern: <menclose notation="box"><mfrac><munder>text<mo>‾</mo></munder><empty/></mfrac></menclose>
# Remove the box but preserve the fraction structure: \dfrac{\underline{text}}{}
# Also handles: <menclose notation="box"><mrow><mfrac>...</mfrac></mrow></menclose>
mfrac_node = None
if notation == "box" and len(node) == 1:
child = node[0]
child_tag = _local(child.tag)
# Direct mfrac child
if child_tag == "mfrac":
mfrac_node = child
# mfrac wrapped in mrow (Word often does this)
elif child_tag == "mrow" and len(child) == 1 and _local(child[0].tag) == "mfrac":
mfrac_node = child[0]
if mfrac_node is not None and len(mfrac_node) >= 2:
numerator_node = mfrac_node[0]
# Check if numerator contains underlined text (munder with line character)
if self._contains_underlined_text(numerator_node):
# Convert the fraction directly without the box
# This preserves the \dfrac structure with underlined numerator
return self._convert_child(mfrac_node)
content = self._convert_children(node)
# Skip boxing for assignment expressions
if notation == "box" and self._contains_assignment(content):
return content
# Parse notation (could be space-separated multi-value)
notations = notation.split()
# === COMBINED STRIKE NOTATIONS (check these FIRST) ===
# Combined diagonal strikes → \xcancel
if "updiagonalstrike" in notations and "downdiagonalstrike" in notations:
return f"\\xcancel{{{content}}}"
# Combined horizontal and vertical strikes → \cancel
if "horizontalstrike" in notations and "verticalstrike" in notations:
return f"\\cancel{{{content}}}"
# === SIDE-SPECIFIC NOTATIONS FOR PARTIAL BOXES ===
sides = [n for n in notations if n in ["top", "bottom", "left", "right"]]
side_count = len(sides)
# Single side visible
if side_count == 1:
side = sides[0]
if side == "top":
return f"\\overline{{{content}}}"
elif side == "bottom":
return f"\\underline{{{content}}}"
elif side == "left":
return f"\\left| {content} \\right."
elif side == "right":
return f"\\left. {content} \\right|"
# Full box (4 sides or explicit "box" notation)
elif "box" in notations or side_count == 4:
return f"\\boxed{{{content}}}"
# Partial box (2-3 sides)
elif side_count >= 2:
notation_str = " ".join(sides)
return f"\\enclose{{{notation_str}}}{{{content}}}"
# === INDIVIDUAL STRIKE NOTATIONS ===
if notation == "horizontalstrike":
return f"\\cancel{{{content}}}"
if notation == "verticalstrike":
return f"\\bcancel{{{content}}}"
if notation == "updiagonalstrike":
return f"\\cancel{{{content}}}"
if notation == "downdiagonalstrike":
return f"\\bcancel{{{content}}}"
# === OTHER NOTATIONS ===
if notation == "circle":
return f"\\circled{{{content}}}"
if notation == "roundedbox":
return f"\\boxed{{{content}}}"
if notation == "actuarial":
return f"\\overline{{\\underline{{{content}}}}}"
if notation == "longdiv":
return f"\\enclose{{longdiv}}{{{content}}}"
if notation == "radical":
return f"\\sqrt{{{content}}}"
# Default: just return content without notation
return content
def _convert_children(self, node: ET.Element) -> str:
"""
Convert all child elements to LaTeX.
Args:
node: The <menclose> element
Returns:
LaTeX string with all children converted
"""
parts = []
for child in node:
parts.append(self._convert_child(child))
return ''.join(parts).strip()
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in menclose, using text fallback", file=sys.stderr)
return _text_content(child)
def _is_empty_denominator(self, node: ET.Element) -> bool:
"""
Check if denominator node is empty (no content or only whitespace).
Args:
node: MathML denominator element to check
Returns:
True if denominator is empty
"""
if node is None:
return True
tag = _local(node.tag)
# Empty <mo> tag
if tag == "mo":
text = node.text or ""
return text.strip() == ""
# Empty <mrow/> tag
if tag == "mrow":
# No children
if len(node) == 0:
return True
# Only whitespace <mo> child
if len(node) == 1 and _local(node[0].tag) == "mo":
mo_text = node[0].text or ""
return mo_text.strip() == ""
return False
def _contains_assignment(self, content: str) -> bool:
"""
Check if content contains assignment operators.
Assignment expressions should not be boxed.
Note: We only skip boxing for explicit assignment operators like :=
NOT for regular equality = which is often used in boxed theorems.
Args:
content: LaTeX content to check
Returns:
True if content contains assignment operators
"""
# Only specific assignment operators - NOT plain = which appears in boxed theorems
assignment_ops = [":=", ": =", "∶=", "∶ =", "≔", "\\coloneqq"]
return any(op in content for op in assignment_ops)
def _contains_underlined_text(self, node: ET.Element) -> bool:
"""
Check if node contains underlined text (munder with line character)
OR overlined text (mover with line character).
This is used to detect chemical reaction conditions and malformed equals.
Args:
node: MathML element to check
Returns:
True if node contains underlined or overlined text
"""
if node is None:
return False
tag = _local(node.tag)
# Check if this node itself is an underlined element (munder with line)
if tag == "munder" and len(node) > 1:
under_node = node[1]
if _local(under_node.tag) == "mo":
# Get text content
text = under_node.text or ""
if "‾" in text: # Line character
return True
# Check if this node is an overlined element (mover with line)
# This handles the "malformed equal" pattern where users put overline in fraction
if tag == "mover" and len(node) > 1:
over_node = node[1]
if _local(over_node.tag) == "mo":
# Get text content
text = over_node.text or ""
# Check for overline/bar characters
if text.strip() in ["¯", "‾", "―", "−", "⎺", "⎻", "-", "_"]:
return True
# Recursively check children
for child in node:
if self._contains_underlined_text(child):
return True
return False
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'menclose'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'menclose'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MencloseVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,194 @@
r"""
Visitor for <merror> (error markup) MathML elements.
The <merror> element is used to display error messages or invalid markup
within mathematical expressions. It typically indicates a parsing error,
invalid content, or unsupported features.
This visitor wraps error content with visual indication using color or boxes.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MerrorVisitor(Visitor):
r"""
Handles <merror> (error markup) MathML elements.
Wraps error content with visual indication to highlight issues.
Examples:
Simple error:
<merror><mtext>Undefined</mtext></merror>
→ \textcolor{red}{\text{Undefined}}
Error with expression:
<merror><mi>x</mi><mo>/</mo><mn>0</mn></merror>
→ \textcolor{red}{x/0}
Empty error:
<merror></merror>
→ \textcolor{red}{\text{Error}}
Nested content:
<merror><mfrac><mi>a</mi><mn>0</mn></mfrac></merror>
→ \textcolor{red}{\frac{a}{0}}
"""
def __init__(self, strategy: Strategy):
"""Initialize the Merror visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <merror> element to LaTeX with error indication.
Args:
node: The <merror> element to convert
Returns:
LaTeX string with error content wrapped in red color
Raises:
ValueError: If node is not an <merror> element
"""
if not self.can_handle(node):
raise ValueError(f"MerrorVisitor cannot handle element '{node.tag}'")
# Convert all children
content = self._convert_children(node)
# If empty, use default error message
if not content or not content.strip():
content = "\\text{Error}"
# Wrap in red color to indicate error
return f"\\textcolor{{red}}{{{content}}}"
def _convert_children(self, node: ET.Element) -> str:
"""
Convert all child elements to LaTeX.
Args:
node: The <merror> element
Returns:
LaTeX string with all children converted
"""
parts = []
for child in node:
parts.append(self._convert_child(child))
return ''.join(parts).strip()
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in merror, using text fallback", file=sys.stderr)
return _text_content(child)
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'merror'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'merror'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MerrorVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,532 @@
r"""
Visitor for <mfenced> (fenced/delimited expressions) MathML elements.
The <mfenced> element represents expressions enclosed by delimiters:
- Auto-scaling parentheses: \left( ... \right)
- Custom brackets: \left[ ... \right]
- Custom braces: \left\{ ... \right\}
- Cases environment: single left brace
- Separators: commas, semicolons between elements
This visitor handles delimiter scaling and separator insertion.
"""
from typing import Optional, List
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import text_content as _text_content, local_name as _local
class MfencedVisitor(Visitor):
r"""
Handles <mfenced> (fenced/delimited) MathML elements.
Converts fenced expressions to LaTeX with auto-scaling delimiters.
Examples:
Default parentheses:
<mfenced><mi>x</mi><mi>y</mi></mfenced> → \left(x,y\right)
Custom brackets:
<mfenced open="[" close="]"><mi>a</mi></mfenced> → \left[a\right]
Custom braces:
<mfenced open="{" close="}"><mi>x</mi></mfenced> → \left\{x\right\}
Angle brackets:
<mfenced open="⟨" close="⟩"><mi>v</mi></mfenced> → \left\langle v\right\rangle
Single left brace (piecewise/cases):
<mfenced open="{" close="">
<mtable>...</mtable>
</mfenced> → \left\{\begin{array}{...}...\end{array}\right.
Custom separator (semicolon):
<mfenced separators=";"><mi>a</mi><mi>b</mi></mfenced> → \left(a;b\right)
Multiple separators:
<mfenced separators="; "><mi>a</mi><mi>b</mi><mi>c</mi></mfenced>
→ \left(a;b c\right)
"""
def __init__(self, strategy: Strategy):
"""Initialize the Mfenced visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <mfenced> element to LaTeX.
Args:
node: The <mfenced> element to convert
Returns:
LaTeX string with auto-scaling delimiters
Raises:
ValueError: If node is not an <mfenced> element
"""
if not self.can_handle(node):
raise ValueError(f"MfencedVisitor cannot handle element '{node.tag}'")
# Get opening and closing delimiters (default: parentheses)
open_delim = node.get('open', '(')
close_delim = node.get('close', ')')
# Get separator (default: comma)
separators = node.get('separators', ',')
# Convert children
children = list(node)
if not children:
# Empty mfenced
left = self._get_left_delimiter(open_delim)
right = self._get_right_delimiter(close_delim)
return f"{left}{right}"
# NOTE: We NO LONGER use cases environment for better compatibility
# Instead, we use explicit \left\{ and \right. notation
# (Cases environment is not universally supported)
# Check if this is set notation: { pattern | condition }
if open_delim == '{' and close_delim == '}':
set_notation_result = self._try_convert_as_set_notation(node, children)
if set_notation_result:
return set_notation_result
# Check if this is an interval with nested mfenced for the closing delimiter
# Pattern: <mfenced open="(" close=""> ... <mfenced open="" close="]">
# This should merge into a single interval: \left( ... \right]
if self._is_split_interval(node, children, open_delim, close_delim):
return self._merge_split_interval(node, children, open_delim)
# Convert each child, tracking which are operators
converted_children = []
child_types = []
for child in children:
converted = self._convert_child(child)
converted_children.append(converted)
child_types.append(_local(child.tag))
# Determine if this is a continuous expression or a list
# If there are explicit separator operators (mo elements with separator chars),
# treat as continuous expression (join with spaces)
# Otherwise, use the separators attribute for list formatting
content = self._smart_join(converted_children, child_types, separators)
# Get LaTeX delimiters
left = self._get_left_delimiter(open_delim)
right = self._get_right_delimiter(close_delim)
return f"{left}{content}{right}"
def _try_convert_as_set_notation(self, node: ET.Element, children: List) -> Optional[str]:
r"""
Try to convert as set notation: { pattern | condition }.
Set notation has the pattern:
- { (x, y) | x+y < 2 } → \left\{ (x, y) \mid x+y < 2 \right\}
- { x | x > 0 } → \left\{ x \mid x > 0 \right\}
Detection heuristics:
1. First child (or first few children) form the "pattern" (variable/tuple)
2. There's a nested <mfenced open="|" close="|"> that's really a separator
OR there are exactly 2 distinct groups of children
3. Last children form the "condition"
Args:
node: The outer mfenced element with open="{" close="}"
children: List of child elements
Returns:
LaTeX string if this is set notation, None otherwise
"""
if len(children) < 2:
return None
# Strategy 1: Look for nested mfenced with | delimiters
# If the second or third child is <mfenced open="|" close="|">,
# and there's content after it OR before it, treat as set notation
# BUT keep the |...| as absolute value brackets in the output
for i, child in enumerate(children):
if _local(child.tag) == 'mfenced':
child_open = child.get('open', '(')
child_close = child.get('close', ')')
if child_open == '|' and child_close == '|':
# Found nested | delimiters
# Check if there's content before this (the pattern) or after (condition)
has_content_before = i >= 1
has_content_after = i < len(children) - 1
if has_content_before or has_content_after:
# This looks like set notation!
# Split into: pattern (before), |...|, and rest
pattern_parts = children[:i]
pipe_element = child # The entire <mfenced open="|" close="|"> element
condition_parts = children[i+1:]
# Convert pattern
pattern_latex = ' '.join(self._convert_child(c) for c in pattern_parts if c is not None)
# Convert the |...| element as absolute value (keep the delimiters!)
pipe_latex = self._convert_child(pipe_element)
# Convert remaining condition
condition_latex = ' '.join(self._convert_child(c) for c in condition_parts if c is not None)
# Assemble: { pattern \mid |pipe_content| condition }
parts = []
if pattern_latex:
parts.append(pattern_latex)
parts.append('\\mid')
if pipe_latex:
parts.append(pipe_latex)
if condition_latex:
parts.append(condition_latex)
content = ' '.join(parts)
return f"\\left\\{{{content}\\right\\}}"
# Strategy 2: Check if there are exactly 2 "groups" of elements
# where the first group is a single identifier/tuple, suggesting { x | condition }
# This handles cases like { x x+1<2 } where x and x+1<2 should be separated by \mid
if len(children) >= 2:
first_child_type = _local(children[0].tag)
# If first child is a single identifier (mi) or a tuple (mfenced with parens)
if first_child_type in ['mi', 'mfenced']:
# Check if this looks like a pattern followed by a condition
# Heuristic: if first child is simple (mi or mfenced), and there are more elements,
# and those elements form an expression/condition, insert \mid
# For now, check if we have exactly these patterns:
# 1. First child is <mfenced open="(" close=")"> (tuple)
# 2. OR first child is <mi> (variable)
# 3. Rest form a condition
if first_child_type == 'mi':
# Check if second element is also mi (likely the start of condition)
if len(children) >= 2 and _local(children[1].tag) == 'mi':
# Likely: { x x+1<2 } should be { x | x+1<2 }
pattern_latex = self._convert_child(children[0])
condition_latex = ' '.join(self._convert_child(c) for c in children[1:])
return f"\\left\\{{{pattern_latex} \\mid {condition_latex}\\right\\}}"
elif first_child_type == 'mfenced':
first_child_open = children[0].get('open', '(')
first_child_close = children[0].get('close', ')')
# Check if it's a tuple (parentheses)
if first_child_open == '(' and first_child_close == ')':
# Pattern: { (x,y) ... } - check if ... is a condition
pattern_latex = self._convert_child(children[0])
# Check if remaining children don't start with a comma
# (which would indicate list notation, not set notation)
if len(children) >= 2:
second_type = _local(children[1].tag)
if second_type != 'mo' or (second_type == 'mo' and _text_content(children[1]).strip() != ','):
# Not a list, likely set notation
condition_latex = ' '.join(self._convert_child(c) for c in children[1:])
return f"\\left\\{{{pattern_latex} \\mid {condition_latex}\\right\\}}"
return None
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in mfenced, using text fallback", file=sys.stderr)
return _text_content(child)
def _smart_join(self, items: list, types: list, separators: str) -> str:
"""
Intelligently join items based on whether they form a continuous expression.
Args:
items: Converted LaTeX strings
types: Element types (mi, mo, mn, etc.)
separators: Separator string from mfenced attribute
Returns:
Joined LaTeX string
"""
if len(items) == 0:
return ''
if len(items) == 1:
return items[0]
# Check if this looks like a continuous expression:
# - Contains 'mo' (operator) elements
# - Separators not explicitly set (or default comma)
has_operators = 'mo' in types
if has_operators and separators == ',':
# Continuous expression: join with spaces, no extra separators
# Filter out empty items
return ' '.join(item for item in items if item)
else:
# List of items: use traditional separator insertion
return self._insert_separators(items, separators)
def _insert_separators(self, items: list, separators: str) -> str:
"""Insert separators between items for list-style formatting."""
if len(items) == 0:
return ''
if len(items) == 1:
return items[0]
# Parse separator string
# If multiple characters, each is used for successive separators
# If fewer separators than gaps, last separator is reused
sep_list = list(separators) if separators else [',']
result = [items[0]]
for i, item in enumerate(items[1:], start=0):
# Get appropriate separator (reuse last if not enough)
sep_idx = min(i, len(sep_list) - 1)
separator = sep_list[sep_idx]
result.append(separator)
result.append(item)
return ''.join(result)
def _get_left_delimiter(self, delim: str) -> str:
"""Map opening delimiter to LaTeX \\left command."""
delimiter_map = {
'(': '\\left(',
'[': '\\left[',
'{': '\\left\\{',
'⟨': '\\left\\langle',
'⌈': '\\left\\lceil',
'⌊': '\\left\\lfloor',
'|': '\\left|',
'‖': '\\left\\|',
'': '\\left.', # Invisible left delimiter
}
return delimiter_map.get(delim, f'\\left{delim}')
def _get_right_delimiter(self, delim: str) -> str:
"""Map closing delimiter to LaTeX \\right command."""
delimiter_map = {
')': '\\right)',
']': '\\right]',
'}': '\\right\\}',
'⟩': '\\right\\rangle',
'⌉': '\\right\\rceil',
'⌋': '\\right\\rfloor',
'|': '\\right|',
'‖': '\\right\\|',
'': '\\right.', # Invisible right delimiter
}
return delimiter_map.get(delim, f'\\right{delim}')
def _is_split_interval(self, node: ET.Element, children: list, open_delim: str, close_delim: str) -> bool:
"""
Detect if this is a split interval notation pattern.
Pattern: <mfenced open="(" close=""> ... <mfenced open="" close="]">
This represents an interval like (0, 1/4] split across two mfenced elements.
Also handles case where content is wrapped in mrow:
<mfenced open="(" close=""><mrow> ... <mfenced open="" close="]">...</mfenced></mrow></mfenced>
Returns True if:
1. Outer has opening delimiter but NO closing delimiter (close="")
2. Last child is <mfenced> with NO opening delimiter (open="") but HAS closing delimiter
3. There are other children before the last mfenced (the interval content)
Args:
node: The outer mfenced element
children: List of child elements
open_delim: Opening delimiter of outer mfenced
close_delim: Closing delimiter of outer mfenced
Returns:
True if this is a split interval pattern
"""
# Check pattern: outer has open delim but no close delim
if not (open_delim in '([{' and close_delim == ''):
return False
# Handle case where content is wrapped in a single mrow
actual_children = children
if len(children) == 1 and _local(children[0].tag) == 'mrow':
actual_children = list(children[0])
# Need at least 2 children (content + nested mfenced)
if len(actual_children) < 2:
return False
# Check if last child is mfenced with complementary delimiters
last_child = actual_children[-1]
if _local(last_child.tag) != 'mfenced':
return False
# Last mfenced should have no open delimiter but have close delimiter
last_open = last_child.get('open', '(')
last_close = last_child.get('close', ')')
if last_open == '' and last_close in ')]}\u232A\u2309\u230B': # Closing delimiters
return True
return False
def _merge_split_interval(self, node: ET.Element, children: list, open_delim: str) -> str:
"""
Merge split interval into a single LaTeX interval.
Pattern: <mfenced open="(" close=""> <content> <mfenced open="" close="]">
Output: \\left( <content> \\right]
Also handles case where content is wrapped in mrow:
<mfenced open="(" close=""><mrow> ... <mfenced open="" close="]">...</mfenced></mrow></mfenced>
Args:
node: The outer mfenced element
children: List of child elements
open_delim: Opening delimiter (e.g., '(', '[')
Returns:
LaTeX string for merged interval
"""
# Handle case where content is wrapped in a single mrow
actual_children = children
if len(children) == 1 and _local(children[0].tag) == 'mrow':
actual_children = list(children[0])
# Get the closing delimiter from the last child (which is the nested mfenced)
last_child = actual_children[-1]
close_delim = last_child.get('close', ')')
# Convert all children except the last (which is the nested mfenced)
content_children = actual_children[:-1]
# Also convert the CONTENT of the last mfenced (not the mfenced itself)
last_mfenced_children = list(last_child)
# Combine content from both outer and inner mfenced
all_content_children = content_children + last_mfenced_children
# Convert all content
converted_parts = []
for child in all_content_children:
part = self._convert_child(child)
if part:
converted_parts.append(part)
# Join with spaces (standard interval notation)
content = ' '.join(converted_parts)
# Build result with merged delimiters
left = self._get_left_delimiter(open_delim)
right = self._get_right_delimiter(close_delim)
return f"{left}{content}{right}"
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mfenced'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mfenced'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MfencedVisitor(strategy={self.strategy.name})"

View File

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

View File

@@ -0,0 +1,105 @@
r"""
Visitor for <mglyph> (custom glyph) MathML elements.
The <mglyph> element is used to display non-standard symbols or images
within mathematical expressions. It references external images via the
'src' attribute or font glyphs via 'fontfamily' and 'index' attributes.
In LaTeX conversion, mglyph is converted to text representations or
fallback Unicode characters when available. For image-based glyphs,
alt text is used if provided.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local, text_content as _text_content
class MglyphVisitor(Visitor):
r"""
Handles <mglyph> (custom glyph) MathML elements.
Converts custom glyphs to LaTeX text or fallback Unicode. Since LaTeX
doesn't support arbitrary image references, we use alt text or text
content as fallback.
Examples:
Glyph with alt text:
<mglyph src="myGlyph.png" alt="custom symbol"/>
→ \text{custom symbol}
Glyph with font reference:
<mglyph fontfamily="MyFont" index="42" alt="α"/>
→ \text{α}
Glyph without alt (uses src filename):
<mglyph src="alpha.png"/>
→ \text{alpha}
Glyph with width/height (ignored in LaTeX):
<mglyph src="beta.png" width="2em" height="2em" alt="β"/>
→ \text{β}
Note:
LaTeX doesn't support external image references in math mode,
so we convert to text representations using alt attribute or
derive from src filename.
"""
def __init__(self, strategy: Strategy):
"""Initialize the Mglyph visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <mglyph> element to LaTeX text.
Args:
node: The <mglyph> element to convert
Returns:
LaTeX string with \text{...} fallback
Raises:
ValueError: If node is not a <mglyph> element
"""
if not self.can_handle(node):
raise ValueError(f"MglyphVisitor cannot handle element '{node.tag}'")
# Try to get alt text (primary fallback)
alt_text = node.get("alt", "")
if alt_text:
# Use alt text if provided
return f"\\text{{{alt_text}}}"
# Fallback: try to derive from src attribute
src = node.get("src", "")
if src:
# Extract filename without extension as fallback
import os
filename = os.path.splitext(os.path.basename(src))[0]
if filename:
return f"\\text{{{filename}}}"
# Last resort: fontfamily reference or empty
fontfamily = node.get("fontfamily", "")
if fontfamily:
# Can't do much with font references in LaTeX
return f"\\text{{{fontfamily}}}"
# Completely empty - render as empty
return ""
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mglyph'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mglyph'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MglyphVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,772 @@
"""
Visitor for <mi> (identifier) MathML elements.
The <mi> element represents identifiers in MathML, including:
- Mathematical functions (sin, cos, log)
- Variables (x, y, z)
- Constants (e, π)
- Multi-letter identifiers (velocity, pressure)
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import text_content as _text_content, convert_spaces_to_latex, escape_latex_special_chars
from ..data import CHEMICAL_ELEMENTS, COMMON_UNITS, UNICODE_TO_LATEX, UNICODE_PASSTHROUGH
from ..converters.post_processor import PostProcessor
class MiVisitor(Visitor):
"""
Handles <mi> (identifier) MathML elements.
This is the most common MathML element type (~40% of all elements).
It converts identifiers to LaTeX format using subject-specific strategies.
Examples:
Math mode:
<mi>sin</mi> → \\sin
<mi>x</mi> → x
<mi>α</mi> → \\alpha
Chemistry mode:
<mi>Fe</mi> → \\mathrm{Fe}
<mi>H</mi> → \\mathrm{H}
"""
def __init__(self, strategy: Strategy, context=None):
"""
Initialize the Mi visitor.
Args:
strategy: The formatting strategy to use
context: Optional ConversionContext for script context tracking
"""
super().__init__(strategy)
self._converter_context = None
self.context = context
def set_converter_context(self, converter):
"""Set converter context for color suppression checking."""
self._converter_context = converter
def visit(self, node: ET.Element) -> str:
"""
Convert <mi> element to LaTeX.
Args:
node: The <mi> element to convert
Returns:
LaTeX string representation
Raises:
ValueError: If node is not an <mi> element
"""
if not self.can_handle(node):
raise ValueError(f"MiVisitor cannot handle element '{node.tag}'")
# Get text content
content = _text_content(node)
# Handle empty content
if not content:
return ""
# Escape LaTeX special characters before any other processing
content = escape_latex_special_chars(content)
# CRITICAL: Check for mathvariant BEFORE reclassifying as operator
# This preserves explicit mathvariant="normal" for units like "N/m"
mathvariant = node.get('mathvariant')
# PHASE 1 FIX: Detect delimiters and operators misclassified as identifiers
# This handles malformed MathML from OMML2MML.xsl where operators are tagged as <mi>
# instead of <mo>. Common issue with Word documents.
# EXCEPTION: If mathvariant="normal" is present, handle it specially to preserve mathrm wrapping
if self._contains_delimiter_or_operator(content):
if mathvariant == 'normal':
# Special case: mathvariant="normal" with operators (e.g., "N/m" units)
# Split by operators but wrap identifier parts in \mathrm{}
result = self._reclassify_with_mathrm(content, node)
return result
else:
# Normal reclassification without mathrm wrapping
result = self._reclassify_as_operator(content, node)
return result
# Handle spaces (NBSP → ~, space → \ )
# Must be done before function recognition
if '\xa0' in content or ' ' in content:
converted = convert_spaces_to_latex(content)
# If content was ONLY spaces, return directly
if not content.strip():
return self._wrap_with_color(converted, node)
# Update content with converted spaces
content = converted
# Check for explicit mathvariant (overrides strategy logic)
# Note: mathvariant already extracted earlier (line 78) for operator reclassification check
if mathvariant:
return self._handle_mathvariant(content, mathvariant, node)
# Check for explicit LaTeX font command
latex_font_cmd = node.get('latex-font-cmd')
if latex_font_cmd:
return self._wrap_with_color(f"\\{latex_font_cmd}{{{content}}}", node)
# Delegate to strategy for subject-specific formatting
result = self.strategy.format_identifier(content, node)
return self._wrap_with_color(result, node)
def _handle_mathvariant(self, content: str, mathvariant: str, node: ET.Element) -> str:
"""
Handle explicit mathvariant attributes.
Args:
content: The text content
mathvariant: The mathvariant attribute value
node: The element node
Returns:
LaTeX string with appropriate font command
"""
# CRITICAL: Strip whitespace and check for mathematical functions FIRST
# Original converter checks functions before applying mathvariant wrapping
from ..data import MATH_FUNCTIONS
clean_content = content.strip()
# CRITICAL FIX: When mathvariant="normal" is present with mixed-case content,
# do NOT treat it as a simple math function. Examples:
# - "Asin" → should be split to "\mathrm{A}\sin" (not "\Asin")
# - "sin" → should still be "\sin" (lowercase function names are always functions)
# - "max" → should still be "\max" (lowercase function names are always functions)
# Check if content is mixed case (has both uppercase and lowercase)
has_upper = any(c.isupper() for c in clean_content)
has_lower = any(c.islower() for c in clean_content)
is_mixed_case = has_upper and has_lower
# Skip function recognition ONLY for mixed-case content with mathvariant="normal"
# This allows "Asin" to be handled later by _split_uppercase_and_function
if mathvariant == 'normal' and len(clean_content) > 1 and is_mixed_case:
# Mixed-case content like "Asin" should NOT be treated as function
# Fall through to the mathvariant handling logic below
pass
else:
# Enhanced function recognition: detect functions with inline subscripts
# Pattern to match function + subscript (like "limn→∞" or "max0≤x≤1")
import re
for func in ['lim', 'limsup', 'liminf', 'sup', 'inf', 'max', 'min']:
if clean_content.startswith(func):
remaining = clean_content[len(func):]
if remaining:
# We found a function with trailing content - this should be a subscript
# Use _convert_unicode_in_content to handle all Unicode symbols
remaining_converted = self._convert_unicode_in_content(remaining)
return f"\\{func}\\limits_{{{remaining_converted}}}"
else:
return f"\\{func}"
# If it's a mathematical function, ignore mathvariant and use function command
# This handles lowercase function names like "sin", "cos", "max", "lim"
if clean_content.lower() in MATH_FUNCTIONS:
return f"\\{clean_content.lower()}"
# For other functions with trailing content
for func in MATH_FUNCTIONS:
if clean_content.startswith(func) and len(clean_content) > len(func):
# Make sure it's not just a substring (e.g., 'limits' shouldn't match 'lim')
if not clean_content[len(func)].isalpha():
remaining = clean_content[len(func):]
# CRITICAL FIX: Convert Unicode symbols in the remaining content
# Example: "sin∠" → "sin" + "∠" → "\sin" + "\angle" → "\sin \angle"
# Without this fix, "sin∠" becomes "\sin∠" (unconverted ∠)
remaining_converted = self._convert_unicode_in_content(remaining)
return f"\\{func} {remaining_converted}".strip()
# Special case: Im, Re with mathvariant="normal" → \Im, \Re
if mathvariant == 'normal' and content in ['Im', 'Re']:
return f"\\{content}"
# CRITICAL FIX: For multi-character content with mathvariant, apply same logic as original:
# 1. Convert each character individually (Unicode → LaTeX)
# 2. Apply mathvariant ONLY to non-LaTeX-command characters
# 3. Join with spaces
#
# This handles cases like:
# <mi mathvariant="double-struck">ℵRZCQN</mi> → \aleph \mathbb{R} \mathbb{Z} \mathbb{C} \mathbb{Q} \mathbb{N}
# <mi mathvariant="script">ℏl</mi> → \hbar \ell
if len(content) > 1 and any(char in UNICODE_TO_LATEX or char in UNICODE_PASSTHROUGH or char.isalpha() for char in content):
# Step 1: Convert each character individually (UNICODE_PASSTHROUGH first)
converted_parts = []
for char in content:
if char in UNICODE_PASSTHROUGH:
# Keep passthrough symbols as Unicode
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)
# Step 2: Apply mathvariant wrapping selectively
if mathvariant == 'double-struck':
result_parts = []
for part in converted_parts:
if part.startswith('\\'):
# Already a LaTeX command (like \aleph) - use as-is
result_parts.append(part)
elif part.strip(): # Skip empty strings and spaces
# Regular letter - wrap in \mathbb{}
result_parts.append(f"\\mathbb{{{part}}}")
# Note: spaces are intentionally skipped - we join with spaces anyway
return self._wrap_with_color(' '.join(result_parts), node)
elif mathvariant == 'script':
result_parts = []
for part in converted_parts:
if part.startswith('\\'):
# Already a LaTeX command (like \hbar) - use as-is
result_parts.append(part)
elif part == 'l':
# Special case: script 'l' is \ell, not \mathscr{l}
result_parts.append('\\ell')
elif part.strip(): # Skip empty strings and spaces
# Regular letter - wrap in \mathscr{}
result_parts.append(f"\\mathscr{{{part}}}")
# Note: spaces are intentionally skipped - we join with spaces anyway
return self._wrap_with_color(' '.join(result_parts), node)
elif mathvariant in ['fraktur', 'sans-serif', 'monospace', 'bold', 'bold-italic']:
# For other mathvariants, apply to all non-LaTeX-command parts
result_parts = []
font_cmd_map = {
'fraktur': 'mathfrak',
'sans-serif': 'mathsf',
'monospace': 'mathtt',
'bold': 'mathbf',
'bold-italic': 'boldsymbol'
}
font_cmd = font_cmd_map[mathvariant]
for part in converted_parts:
if part.startswith('\\'):
result_parts.append(part)
elif part.strip(): # Skip empty strings and spaces
result_parts.append(f"\\{font_cmd}{{{part}}}")
# Note: spaces are intentionally skipped - we join with spaces anyway
return self._wrap_with_color(' '.join(result_parts), node)
elif mathvariant == 'normal':
# Multi-character content with mathvariant="normal" - always wrap in \mathrm{}
# Combine the converted parts with intelligent spacing
# When a LaTeX command (like \Delta) is followed by a letter, add a space
# Otherwise, LaTeX interprets \DeltaH as a single undefined command
combined = self._join_with_command_spacing(converted_parts)
# DISABLED: "Smart" logic that ignored mathvariant="normal" for simple variables
# This was causing issues where explicit mathvariant="normal" was not respected
# Keeping _is_simple_math_variable for potential future use
# if self.strategy.name == 'math':
# is_simple = self._is_simple_math_variable(combined)
# if is_simple:
# return self._wrap_with_color(combined, node)
# CRITICAL FIX: Check if content starts with uppercase letter(s) followed by a known function
# Example: "Asin" → "\mathrm{A}\sin", "ABcos" → "\mathrm{AB}\cos"
# This handles the case where Word's "Text Mode" is used for mixed content
result = self._split_uppercase_and_function(content)
if result:
return self._wrap_with_color(result, node)
# Always respect mathvariant="normal" - wrap in \mathrm{}
return self._wrap_with_color(f"\\mathrm{{{combined}}}", node)
else:
# No mathvariant or unknown - just join the converted parts
return self._wrap_with_color(' '.join(converted_parts), node)
# Fallback: single character or simple cases
# IMPORTANT: Convert Unicode symbols BEFORE wrapping in font commands
# e.g., ℵ → \aleph, ℏ → \hbar
content_converted = self._convert_unicode_in_content(content)
# Map mathvariant values to LaTeX font commands
if mathvariant == 'double-struck':
# If already converted to LaTeX command, use as-is
if content in UNICODE_TO_LATEX:
return content_converted
return f"\\mathbb{{{content_converted}}}"
elif mathvariant == 'script':
# Special case: script lowercase 'l' → \ell
if content == 'l':
return "\\ell"
# If already converted to LaTeX command, use as-is
if content in UNICODE_TO_LATEX:
return content_converted
return f"\\mathscr{{{content_converted}}}"
elif mathvariant == 'fraktur':
return f"\\mathfrak{{{content_converted}}}"
elif mathvariant == 'sans-serif':
return f"\\mathsf{{{content_converted}}}"
elif mathvariant == 'monospace':
return f"\\mathtt{{{content_converted}}}"
elif mathvariant == 'bold':
return f"\\mathbf{{{content_converted}}}"
elif mathvariant == 'bold-italic':
# Bold italic - use \boldsymbol for math-mode bold italic
# Alternative: \bm from bm package, but \boldsymbol is standard
return f"\\boldsymbol{{{content_converted}}}"
elif mathvariant == 'italic':
# Explicitly italic - return as-is (LaTeX default)
return content_converted
elif mathvariant == 'normal':
# INTELLIGENT HANDLING of mathvariant="normal" based on subject mode and content
# This handles the <m:nor/> tag from Word's "Text Mode" button
# Special cases first: Im, Re → \Im, \Re
if content in ['Im', 'Re']:
return f"\\{content}"
# CRITICAL: If content was a Unicode symbol (like π → \pi), don't wrap in \mathrm{}
# The _convert_unicode_in_content() already converted it to LaTeX command
if content.strip() in UNICODE_TO_LATEX:
return content_converted # Already converted to LaTeX (e.g., \pi)
# DISABLED: "Smart" logic that ignored mathvariant="normal" for simple variables
# This was causing issues where explicit mathvariant="normal" was not respected
# Keeping _is_simple_math_variable for potential future use
# if self.strategy.name == 'math':
# if self._is_simple_math_variable(content_converted):
# return content_converted
# Always respect mathvariant="normal" - wrap in \mathrm{}
return f"\\mathrm{{{content_converted}}}"
else:
# Unknown mathvariant - return as-is
return content_converted
def _is_simple_math_variable(self, content: str) -> bool:
"""
Check if content is a simple mathematical variable that should be italic.
Simple math variables include:
- Single lowercase letters: x, y, z, a, b, etc.
- Single uppercase letters: X, Y, Z, A, B, etc. (but NOT chemical elements)
- Greek letters (already converted to LaTeX commands like \\alpha)
- Common mathematical notations like single letters with primes: x', y''
This is used to intelligently handle mathvariant="normal" in math mode:
- Simple variables should remain italic (ignore mathvariant="normal")
- Complex identifiers should be upright (respect mathvariant="normal")
Args:
content: The identifier content to check
Returns:
True if content is a simple math variable, False otherwise
"""
if not content:
return False
# Strip whitespace and LaTeX commands for checking
clean_content = content.strip()
# If it starts with a LaTeX command (like \alpha, \beta), it's a simple variable
if clean_content.startswith('\\'):
return True
# CRITICAL: If content contains mathematical operators, it's an expression (not an identifier)
# Examples: "x+2", "a-b", "n*m" should NOT be wrapped in \mathrm{}
# These are parsed as single <mi> elements by XSL but should remain italic
math_operators = {'+', '-', '*', '/', '=', '<', '>', '≤', '≥', '≠', '±'}
if any(op in clean_content for op in math_operators):
return True # Treat expressions as "simple" (don't wrap in \mathrm{})
# Single letter (a-z, A-Z) - simple variable
if len(clean_content) == 1 and clean_content.isalpha():
# BUT: exclude known chemical elements in non-math contexts
if self.strategy.name == 'chemistry' and clean_content in CHEMICAL_ELEMENTS:
return False
return True
# Single letter with primes (x', y'', etc.)
if len(clean_content) >= 2:
base = clean_content.rstrip("'")
primes = clean_content[len(base):]
if len(base) == 1 and base.isalpha() and all(c == "'" for c in primes):
return True
# Multi-character content that's NOT a math function is considered complex
# Examples: "velocity", "pressure", "Mn", "Fe" - should be upright if mathvariant="normal"
return False
def _convert_unicode_in_content(self, content: str) -> str:
"""
Convert Unicode symbols within identifier content to LaTeX equivalents.
For multi-character identifiers with Unicode symbols mixed in,
we need to convert each Unicode character individually.
Args:
content: Identifier text that may contain Unicode symbols
Returns:
Content with Unicode symbols converted to LaTeX, preserving spaces
"""
if not content:
return content
# Check if any Unicode symbols are present (passthrough or convertible)
has_unicode = any(char in UNICODE_TO_LATEX for char in content)
has_passthrough = any(char in UNICODE_PASSTHROUGH for char in content)
if not has_unicode and not has_passthrough:
return content
# Convert character by character, preserving spaces (PASSTHROUGH first)
result = []
for char in content:
if char in UNICODE_PASSTHROUGH:
# Keep passthrough symbols as Unicode
result.append(char)
elif char in UNICODE_TO_LATEX:
# Add space before/after LaTeX command if needed
latex_cmd = UNICODE_TO_LATEX[char]
result.append(latex_cmd)
else:
result.append(char)
# Join parts with space separator for multi-symbol content
# e.g., "ℵR" → "\aleph R" not "\alephR"
joined = ' '.join(result)
# Clean up multiple spaces
import re
joined = re.sub(r'\s+', ' ', joined)
return joined
def _join_with_command_spacing(self, parts: list) -> str:
"""
Join converted parts with intelligent spacing for LaTeX commands.
When a LaTeX command (like \\Delta) is followed by a letter, a space must
be inserted. Otherwise, LaTeX interprets \\DeltaH as a single undefined
command instead of \\Delta followed by H.
Args:
parts: List of converted parts (may contain LaTeX commands and letters)
Returns:
Joined string with appropriate spacing between commands and letters
Examples:
['\\\\Delta', 'H'] → '\\\\Delta H' (space needed)
['a', 'b', 'c'] → 'abc' (no space needed)
['\\\\alpha', '\\\\beta'] → '\\\\alpha\\\\beta' (commands can be adjacent)
['\\\\Delta', '1'] → '\\\\Delta 1' (space before number)
"""
if not parts:
return ''
result = []
for i, part in enumerate(parts):
result.append(part)
# Check if we need to add a space after this part
if i < len(parts) - 1:
next_part = parts[i + 1]
# If current part is a LaTeX command (starts with \)
# and next part starts with a letter or digit, add space
if part.startswith('\\') and next_part and (next_part[0].isalpha() or next_part[0].isdigit()):
result.append(' ')
return ''.join(result)
def _wrap_with_color(self, latex: str, node: ET.Element) -> str:
"""
Wrap LaTeX with color command if node has color attribute.
Args:
latex: The LaTeX string to wrap
node: The element node
Returns:
LaTeX string, optionally wrapped with color command
"""
# Use the centralized color wrapping utility with suppression support
from ..utils.color_helpers import wrap_element_with_color
# Get converter context from multiple sources (for backward compatibility):
# 1. Direct visitor context (set by legacy_adapter)
# 2. Strategy context (shared across all visitors, including child visitors)
context = getattr(self, '_converter_context', None)
if context is None and hasattr(self.strategy, '_converter_context'):
context = self.strategy._converter_context
return wrap_element_with_color(latex, node, context)
def _contains_delimiter_or_operator(self, content: str) -> bool:
"""
Check if content contains delimiter or operator characters that should be <mo> not <mi>.
This detects malformed MathML from OMML2MML.xsl where operators are misclassified
as identifiers (e.g., <mi>(</mi> instead of <mo fence="true">(</mo>).
Args:
content: Text content of the <mi> element
Returns:
True if content contains delimiters or operators
"""
# Delimiters that should be fences
delimiters = set('()[]{}<>')
# Common operators (excluding - which might be minus sign or hyphen)
# Note: We include = and < > which are often misclassified
operators = set('+=*/|≤≥≠≈∈∉∪∩⊂⊃∧∨¬→↔×÷±')
# Check if ANY character is a delimiter or operator
return any(char in delimiters or char in operators for char in content)
def _reclassify_with_mathrm(self, content: str, node: ET.Element) -> str:
"""
Reclassify content with operators while preserving mathvariant="normal" (mathrm wrapping).
This handles cases like:
- <mi mathvariant="normal">N/m</mi> → \mathrm{N}/\mathrm{m}
- <mi mathvariant="normal">kg·m/s</mi> → \mathrm{kg}·\mathrm{m}/\mathrm{s}
Args:
content: Text content with mathvariant="normal" that contains operators
node: Original <mi> element node
Returns:
LaTeX string with mathrm-wrapped identifiers and properly classified operators
"""
# Delimiters that should have fence="true"
fence_chars = set('()[]{}')
# Operators that should be <mo> but NOT fences
operator_chars = set('+=*/|<>≤≥≠≈∈∉∪∩⊂⊃∧∨¬→↔×÷±·')
# Multi-character content: split and classify each character
# Identifier parts get wrapped in \mathrm{}
parts = []
for char in content:
if char in fence_chars:
parts.append(self._convert_as_fence(char, node))
elif char in operator_chars:
parts.append(self._convert_as_operator(char, node))
elif char.isspace():
# Preserve spaces
from ..utils import convert_spaces_to_latex
parts.append(convert_spaces_to_latex(char))
else:
# Regular identifier character - wrap in \mathrm{} for mathvariant="normal"
parts.append(f"\\mathrm{{{char}}}")
return ''.join(parts)
def _reclassify_as_operator(self, content: str, node: ET.Element) -> str:
"""
Reclassify misclassified operators/delimiters and convert them properly.
Handles cases like:
- <mi>(</mi> → treat as <mo fence="true">(</mo>
- <mi>)+</mi> → treat as <mo fence="true">)</mo><mo>+</mo>
- <mi>=</mi> → treat as <mo>=</mo>
Args:
content: Text content that contains operators/delimiters
node: Original <mi> element node
Returns:
LaTeX string with properly classified operators
"""
# Import MoVisitor for operator handling
from .mo_visitor import MoVisitor
# Delimiters that should have fence="true"
fence_chars = set('()[]{}')
# Operators that should be <mo> but NOT fences
operator_chars = set('+=*/|<>≤≥≠≈∈∉∪∩⊂⊃∧∨¬→↔×÷±')
# If content is a single character, handle directly
if len(content) == 1:
if content in fence_chars:
return self._convert_as_fence(content, node)
elif content in operator_chars:
return self._convert_as_operator(content, node)
else:
# Shouldn't happen, but fallback to identifier
return self.strategy.format_identifier(content, node)
# Multi-character content: split and classify each character
parts = []
for char in content:
if char in fence_chars:
parts.append(self._convert_as_fence(char, node))
elif char in operator_chars:
parts.append(self._convert_as_operator(char, node))
elif char.isspace():
# Preserve spaces
from ..utils import convert_spaces_to_latex
parts.append(convert_spaces_to_latex(char))
else:
# Regular identifier character
parts.append(self.strategy.format_identifier(char, node))
return ''.join(parts)
def _convert_as_fence(self, char: str, node: ET.Element) -> str:
"""
Convert a character as a fence delimiter (with fence="true").
Fence delimiters are marked with __DELIM__ to indicate they should be paired
by the post-processor (e.g., ( and ) become \left( and \right)).
Args:
char: Single delimiter character
node: Original element node (for color preservation)
Returns:
Delimiter wrapped in __DELIM__ markers for post-processing
"""
# Map fence characters to LaTeX (handle escaped braces)
fence_map = {
'(': '(',
')': ')',
'[': '[',
']': ']',
'{': '\\{', # Must be escaped in LaTeX
'}': '\\}', # Must be escaped in LaTeX
}
latex_char = fence_map.get(char, char)
# Wrap in __DELIM__ markers so post-processor knows to add \left and \right
return f"__DELIM__{latex_char}__DELIM__"
def _convert_as_operator(self, char: str, node: ET.Element) -> str:
"""
Convert a character as an operator (without fence="true").
Operators like +, =, |, < are NOT marked with __DELIM__ because they should NOT
be paired as delimiters. For example, | in P(A|B) is an operator, not a delimiter.
Args:
char: Single operator character
node: Original element node (for color preservation)
Returns:
LaTeX operator string (plain, no markers)
"""
# Map operators to LaTeX equivalents
operator_map = {
'+': '+',
'-': '-',
'*': '*',
'/': '/',
'=': '=',
'<': '<',
'>': '>',
'|': '|', # IMPORTANT: | as operator (conditional probability), NOT delimiter
'×': '\\times ',
'÷': '\\div ',
'±': '\\pm ',
'≤': '\\leqslant ',
'≥': '\\geqslant ',
'≠': '\\ne ',
'≈': '\\approx ',
'∈': '\\in ',
'∉': '\\notin ',
'∪': '\\cup ',
'∩': '\\cap ',
'⊂': '\\subset ',
'⊃': '\\supset ',
'∧': '\\wedge ',
'∨': '\\vee ',
'¬': '\\neg ',
'→': '\\to ',
'↔': '\\leftrightarrow ',
}
latex_char = operator_map.get(char, char)
# Return as-is (NO __DELIM__ markers - should not be paired)
return latex_char
def _split_uppercase_and_function(self, content: str) -> str:
"""
Split content that starts with uppercase letter(s) followed by a known function.
This handles cases like:
- "Asin" → "\\mathrm{A}\\sin"
- "ABcos" → "\\mathrm{AB}\\cos"
- "Xlog" → "\\mathrm{X}\\log"
Args:
content: The identifier content to check
Returns:
LaTeX string with split content, or None if no split is needed
"""
from ..data import MATH_FUNCTIONS
if not content or len(content) < 2:
return None
# Check if content starts with uppercase letter(s)
if not content[0].isupper():
return None
# Find where the uppercase letters end
uppercase_end = 0
for i, char in enumerate(content):
if char.isupper():
uppercase_end = i + 1
else:
break
if uppercase_end == 0 or uppercase_end >= len(content):
return None
# Split into uppercase part and remaining part
uppercase_part = content[:uppercase_end]
remaining_part = content[uppercase_end:]
# Check if remaining part is a known math function
if remaining_part in MATH_FUNCTIONS:
# Split: uppercase in \mathrm{} + function as \function
return f"\\mathrm{{{uppercase_part}}}\\{remaining_part}"
return None
def can_handle(self, node: ET.Element) -> bool:
"""
Check if this visitor can handle the given node.
Args:
node: The MathML element to check
Returns:
True if node is an <mi> element, False otherwise
"""
from ..utils import local_name as _local
return _local(node.tag) == 'mi'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mi'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MiVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,200 @@
r"""
Visitor for <mlabeledtr> (labeled table row) MathML elements.
The <mlabeledtr> element is used in tables (<mtable>) to create a row with
a label. The first child is the label, and remaining children are table cells.
Labels are typically used for equation numbering or row identifiers.
In LaTeX conversion, the label is typically rendered separately or as part
of the row depending on context. For simplicity, we render label followed
by cells, separated by &.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MlabeledtrVisitor(Visitor):
r"""
Handles <mlabeledtr> (labeled table row) MathML elements.
Converts labeled table rows to LaTeX array/matrix rows. The first child
is treated as the label, remaining children are table cells.
Examples:
Labeled equation row:
<mlabeledtr>
<mtd><mtext>(1)</mtext></mtd>
<mtd><mi>x</mi></mtd>
<mtd><mi>y</mi></mtd>
</mlabeledtr>
→ (1) & x & y
Simple labeled row:
<mlabeledtr>
<mtd><mn>1</mn></mtd>
<mtd><mi>a</mi></mtd>
</mlabeledtr>
→ 1 & a
Three-column with label:
<mlabeledtr>
<mtd><mtext>Label</mtext></mtd>
<mtd><mi>x</mi></mtd>
<mtd><mo>=</mo></mtd>
<mtd><mn>5</mn></mtd>
</mlabeledtr>
→ \text{Label} & x & = & 5
Note:
The label (first child) is rendered as a regular cell. LaTeX doesn't
have a direct equivalent for MathML labeled rows, so we treat it as
a standard row with the label as the first column.
"""
def __init__(self, strategy: Strategy):
"""Initialize the Mlabeledtr visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <mlabeledtr> element to LaTeX array/matrix row.
Args:
node: The <mlabeledtr> element to convert
Returns:
LaTeX string with cells separated by &
Raises:
ValueError: If node is not a <mlabeledtr> element
"""
if not self.can_handle(node):
raise ValueError(f"MlabeledtrVisitor cannot handle element '{node.tag}'")
# Get all children (first is label, rest are cells)
children = list(node)
if not children:
return ""
# Convert all children (label + cells) as mtd elements
cells = []
for child in children:
cell_content = self._convert_child(child)
cells.append(cell_content)
# Join with &
return " & ".join(cells)
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in mlabeledtr, using text fallback", file=sys.stderr)
return _text_content(child)
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mlabeledtr'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mlabeledtr'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MlabeledtrVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,288 @@
r"""
Visitor for <mmultiscripts> (tensor notation) MathML elements.
The <mmultiscripts> element is used to attach prescripts and postscripts to a base element.
It's commonly used in physics and chemistry for tensor notation, nuclear notation, etc.
Structure:
<mmultiscripts>
<base/> <!-- Base element -->
<sub1/> <sup1/> <!-- First postscript pair -->
<sub2/> <sup2/> <!-- Second postscript pair -->
<mprescripts/> <!-- Separator -->
<presub1/> <presup1/> <!-- First prescript pair -->
<presub2/> <presup2/> <!-- Second prescript pair -->
</mmultiscripts>
This visitor handles both prescripts (left side) and postscripts (right side) with \sideset.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MmultiscriptsVisitor(Visitor):
r"""
Handles <mmultiscripts> (tensor notation) MathML elements.
Converts prescripts and postscripts to LaTeX using \sideset for bidirectional scripts.
Examples:
Only postscripts:
<mmultiscripts>
<mi>R</mi>
<mn>1</mn><mn>2</mn>
</mmultiscripts>
→ R_{1}^{2}
Only prescripts:
<mmultiscripts>
<mi>R</mi>
<mprescripts/>
<mn>1</mn><mn>2</mn>
</mmultiscripts>
→ _{1}^{2}R
Both prescripts and postscripts:
<mmultiscripts>
<mi>R</mi>
<mn>3</mn><mn>4</mn>
<mprescripts/>
<mn>1</mn><mn>2</mn>
</mmultiscripts>
→ \sideset{_{1}^{2}}{_{3}^{4}}{R}
Multiple postscripts:
<mmultiscripts>
<mi>R</mi>
<mi>i</mi><mi>j</mi>
<mi>k</mi><mi>l</mi>
</mmultiscripts>
→ R_{i}^{j}_{k}^{l}
None elements (empty script positions):
<mmultiscripts>
<mi>R</mi>
<none/><mn>2</mn>
<mprescripts/>
<mn>1</mn><none/>
</mmultiscripts>
→ \sideset{_{1}}{^{2}}{R}
"""
def __init__(self, strategy: Strategy):
"""Initialize the Mmultiscripts visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <mmultiscripts> element to LaTeX with tensor notation.
Args:
node: The <mmultiscripts> element to convert
Returns:
LaTeX string with prescripts and postscripts
Raises:
ValueError: If node is not a <mmultiscripts> element
"""
if not self.can_handle(node):
raise ValueError(f"MmultiscriptsVisitor cannot handle element '{node.tag}'")
if len(node) == 0:
return ""
# First child is the base element
base = self._convert_child(node[0])
scripts = list(node)[1:] # Skip base element
# Find mprescripts separator
prescript_idx = None
for i, child in enumerate(scripts):
if _local(child.tag) == "mprescripts":
prescript_idx = i
break
if prescript_idx is None:
# Only postscripts
postscripts = scripts
prescripts = []
else:
postscripts = scripts[:prescript_idx]
prescripts = scripts[prescript_idx + 1:]
# Process postscripts (pairs of sub/sup)
postscript_parts = self._process_script_pairs(postscripts)
# Process prescripts (pairs of sub/sup)
prescript_parts = self._process_script_pairs(prescripts)
# Use \sideset when there are scripts on both sides
if prescript_parts and postscript_parts:
left_scripts = "".join(prescript_parts)
right_scripts = "".join(postscript_parts)
return f"\\sideset{{{left_scripts}}}{{{right_scripts}}}{{{base}}}"
elif prescript_parts:
# Only left scripts - direct positioning
return f"{''.join(prescript_parts)}{base}"
elif postscript_parts:
# Only right scripts
return f"{base}{''.join(postscript_parts)}"
else:
return base
def _process_script_pairs(self, scripts: list) -> list:
"""
Process script pairs (sub/sup) from the scripts list.
Scripts come in pairs: subscript, superscript, subscript, superscript, ...
<none/> elements represent empty positions.
Args:
scripts: List of script elements (pairs of sub/sup)
Returns:
List of LaTeX script strings
"""
parts = []
for i in range(0, len(scripts), 2):
sub = self._convert_script(scripts[i]) if i < len(scripts) else ""
sup = self._convert_script(scripts[i + 1]) if i + 1 < len(scripts) else ""
if sub and sup:
parts.append(f"_{{{sub}}}^{{{sup}}}")
elif sub:
parts.append(f"_{{{sub}}}")
elif sup:
parts.append(f"^{{{sup}}}")
return parts
def _convert_script(self, child: ET.Element) -> str:
"""
Convert a script element to LaTeX.
Handles <none/> elements (empty script positions).
Args:
child: Script element to convert
Returns:
LaTeX string for the script (empty for <none/>)
"""
if _local(child.tag) == "none":
return ""
return self._convert_child(child)
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in mmultiscripts, using text fallback", file=sys.stderr)
return _text_content(child)
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mmultiscripts'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mmultiscripts'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MmultiscriptsVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,136 @@
"""
Visitor for <mn> (number) MathML elements.
The <mn> element represents numeric literals in MathML, including:
- Integers (1, 2, 3, ...)
- Decimals (3.14, 2.718, ...)
- Scientific notation (1.5×10⁻³)
- Fractions in decimal form (0.5)
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import text_content as _text_content, convert_spaces_to_latex, escape_latex_special_chars
from ..data import CHEMICAL_ELEMENTS, COMMON_UNITS
from ..converters.post_processor import PostProcessor
class MnVisitor(Visitor):
"""
Handles <mn> (number) MathML elements.
This visitor converts numbers to LaTeX format. Most numbers
are returned as-is, but the strategy can customize formatting
for specific subjects (e.g., chemistry, physics).
Examples:
Standard numbers:
<mn>123</mn> → 123
<mn>3.14</mn> → 3.14
<mn>0.5</mn> → 0.5
Scientific notation (handled at operator level):
<mn>1.5</mn><mo>×</mo><mn>10</mn><msup><mn>-3</mn></msup>
→ 1.5 \\times 10^{-3}
"""
def __init__(self, strategy: Strategy):
"""
Initialize the Mn visitor.
Args:
strategy: The formatting strategy to use
"""
super().__init__(strategy)
self._converter_context = None
def set_converter_context(self, converter):
"""Set converter context for color suppression checking."""
self._converter_context = converter
def visit(self, node: ET.Element) -> str:
"""
Convert <mn> element to LaTeX.
Args:
node: The <mn> element to convert
Returns:
LaTeX string representation
Raises:
ValueError: If node is not an <mn> element
"""
if not self.can_handle(node):
raise ValueError(f"MnVisitor cannot handle element '{node.tag}'")
# Get text content
content = _text_content(node)
# Handle empty content
if not content:
return ""
# Escape LaTeX special characters before any other processing
content = escape_latex_special_chars(content)
# Handle spaces (NBSP → ~, space → \ )
if '\xa0' in content or ' ' in content:
converted = convert_spaces_to_latex(content)
# If content was ONLY spaces, return directly
if not content.strip():
return self._wrap_with_color(converted, node)
# Update content with converted spaces
content = converted
# Delegate to strategy for subject-specific number formatting
result = self.strategy.format_number(content, node)
return self._wrap_with_color(result, node)
def _wrap_with_color(self, latex: str, node: ET.Element) -> str:
"""
Wrap LaTeX with color command if node has color attribute.
Args:
latex: The LaTeX string to wrap
node: The element node
Returns:
LaTeX string, optionally wrapped with color command
"""
# Use the centralized color wrapping utility with suppression support
from ..utils.color_helpers import wrap_element_with_color
# Get converter context from multiple sources (for backward compatibility):
# 1. Direct visitor context (set by legacy_adapter)
# 2. Strategy context (shared across all visitors, including child visitors)
context = getattr(self, '_converter_context', None)
if context is None and hasattr(self.strategy, '_converter_context'):
context = self.strategy._converter_context
return wrap_element_with_color(latex, node, context)
def can_handle(self, node: ET.Element) -> bool:
"""
Check if this visitor can handle the given node.
Args:
node: The MathML element to check
Returns:
True if node is an <mn> element, False otherwise
"""
from ..utils import local_name as _local
return _local(node.tag) == 'mn'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mn'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MnVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,388 @@
"""
Visitor for <mo> (operator) MathML elements.
The <mo> element represents operators in MathML, including:
- Binary operators (+, -, ×, ÷)
- Relational operators (=, <, >, ≤, ≥)
- Delimiters (parentheses, brackets, braces)
- Special operators (∫, ∑, ∏, ∇)
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import text_content as _text_content, local_name as _local, convert_spaces_to_latex, escape_latex_special_chars
from ..utils.debug import debug_print
from ..data import CHEMICAL_ELEMENTS, COMMON_UNITS
from ..converters.post_processor import PostProcessor
# Operators with built-in LaTeX spacing - skip manual spaces after these
SKIP_SPACE_AFTER = {
# Relation operators (thick spacing in LaTeX)
'\\rightarrow', '\\leftarrow', '\\to', '→', '←',
'\\Longrightarrow', '\\Longleftarrow', '⇒', '⇐',
'\\leftrightarrow', '\\Longleftrightarrow', '↔', '⇔', '⟷',
'=', '\\equiv', '\\approx', '≈', '\\cong', '≅',
'<', '>', '\\leq', '\\geq', '≤', '≥', '\\neq', '≠',
'\\subseteq', '⊆', '\\supseteq', '⊇',
'\\in', '∈', '\\notin', '∉',
'\\perp', '⊥', '\\parallel', '∥',
'\\sim', '∼', '\\simeq', '≃', '\\propto', '∝',
'\\prec', '≺', '\\succ', '≻', '\\preceq', '⪯', '\\succeq', '⪰',
'\\subset', '⊂', '\\supset', '⊃', '\\sqsubseteq', '⊑', '\\sqsupseteq', '⊒',
# Binary operators (medium spacing in LaTeX)
'+', '-', '\\times', '×', '\\div', '÷', '\\cdot', '·',
'\\pm', '±', '\\mp', '∓',
'\\lor', '∨', '\\land', '∧', '\\wedge', '∧', '\\vee', '∨', '\\neg', '¬',
'\\cap', '∩', '\\cup', '∪',
'\\sqcap', '⊓', '\\sqcup', '⊔', '\\uplus', '⊎',
'\\oplus', '⊕', '\\otimes', '⊗', '\\odot', '⊙', '\\ominus', '⊖', '\\oslash', '⊘',
'\\curlywedge', '⋏', '\\curlyvee', '⋎',
'\\setminus', '∖', '\\ast', '∗', '\\star', '⋆', '\\circ', '∘',
'\\bullet', '•', '\\diamond', '⋄', '\\bigtriangleup', '△', '\\bigtriangledown', '▽',
'\\triangleleft', '◃', '\\triangleright', '▹',
'\\lhd', '⊲', '\\rhd', '⊳', '\\unlhd', '⊴', '\\unrhd', '⊵',
# Punctuation with spacing
',', ';', ':',
}
# Preserve spaces before these (differentials need explicit spacing)
PRESERVE_SPACE_BEFORE = {
'dx', 'dy', 'dz', 'dt', 'du', 'dv', 'dw',
'dr', 'ds', 'da', 'db', 'dc',
'd\\theta', 'd\\phi', 'd\\psi', 'd\\alpha', 'd\\beta',
'dxdy', 'dydz', 'dxdz', # Multi-variable differentials
}
class MoVisitor(Visitor):
"""
Handles <mo> (operator) MathML elements.
This visitor converts operators to LaTeX format, handling:
- Unicode symbol mapping (× → \\times, ≤ → \\leqslant)
- Delimiter recognition (fence operators)
- Spacing control
- Subject-specific operator formatting
Examples:
Standard operators:
<mo>+</mo> → +
<mo>×</mo> → \\times
<mo>≤</mo> → \\leqslant
Delimiters:
<mo fence="true">(</mo> → \\left(
<mo fence="true">)</mo> → \\right)
Chemistry:
<mo>→</mo> → \\rightarrow (or \\xlongequal{condition})
"""
def __init__(self, strategy: Strategy):
"""
Initialize the Mo visitor.
Args:
strategy: The formatting strategy to use
"""
super().__init__(strategy)
self._converter_context = None
def set_converter_context(self, converter):
"""Set converter context for color suppression checking."""
self._converter_context = converter
def visit(self, node: ET.Element) -> str:
"""
Convert <mo> element to LaTeX.
Args:
node: The <mo> element to convert
Returns:
LaTeX string representation
Raises:
ValueError: If node is not an <mo> element
"""
if not self.can_handle(node):
raise ValueError(f"MoVisitor cannot handle element '{node.tag}'")
# Get text content
content = _text_content(node)
# Check for fence attribute FIRST (delimiter handling)
# Empty fence delimiters (like closing brace in cases) need special handling
fence = node.get('fence')
if fence == 'true':
# For fence delimiters, allow empty content (will be handled by _handle_fence)
if not content:
return self._handle_fence("", node)
# Escape LaTeX special characters before fence handling
content = escape_latex_special_chars(content)
return self._handle_fence(content, node)
# Handle empty content (non-fence operators)
if not content:
return ""
# Escape LaTeX special characters before any other processing
content = escape_latex_special_chars(content)
# Handle spaces (NBSP → ~, space → \ )
if '\xa0' in content or ' ' in content:
# If content is ONLY spaces, use context-aware filtering
if not content.strip():
# Check if this space should be preserved based on context
if self._should_preserve_space(node):
converted = convert_spaces_to_latex(content)
return self._wrap_with_color(converted, node)
else:
# Skip this space - LaTeX will handle spacing
return ""
# Content has spaces mixed with other characters
# CRITICAL FIX: Strip surrounding whitespace from operators!
# MathType sometimes outputs "<mo> < </mo>" which fails to match "<" in strategy
# and gets converted to "\ < \ " by convert_spaces_to_latex.
content = content.strip()
# If strip() resulted in empty string (should catch above, but safety)
if not content:
return ""
# Use convert_spaces_to_latex ONLY if there are still spaces INSIDE the operator
# (e.g. "mod " -> "mod" (stripped), but "a b" -> "a~b"?)
if '\xa0' in content or ' ' in content:
content = convert_spaces_to_latex(content)
# Check for stretchy attribute (affects some operators like arrows)
stretchy = node.get('stretchy')
# Delegate to strategy for subject-specific operator formatting
result = self.strategy.format_operator(content, node)
# DEBUG
if content in '()':
debug_print(f"[DEBUG MO] Operator {content!r} → {result!r}")
return self._wrap_with_color(result, node)
def _handle_fence(self, content: str, node: ET.Element) -> str:
"""
Handle fence (delimiter) operators with fence="true" attribute.
Args:
content: The delimiter character (can be empty for invisible delimiters)
node: The element node
Returns:
LaTeX delimiter wrapped in __DELIM__ markers for post-processing
Note:
Fences are marked with __DELIM__ markers to indicate they should be
paired by the delimiter pairing processor. This distinguishes them from
plain operators that happen to use the same character (e.g., | in P(A|B)
is an operator, not a fence delimiter).
Empty content is converted to "." (invisible delimiter) for cases like
piecewise functions: \left\{...\right.
"""
# Handle empty or whitespace-only delimiter
# Empty: invisible delimiter in LaTeX (e.g., piecewise functions: \left\{...\right.)
# Whitespace-only: OMML endChr/begChr with space value (e.g., m:val=" ")
# → return empty string (no delimiter), NOT a dot, to prevent extra \right.
if not content or content.strip() == "":
if not content:
# Truly empty → invisible delimiter (for cases environment etc.)
result = "__DELIM__.__DELIM__"
debug_print(f"[DEBUG MO FENCE] Empty delimiter → {result!r}")
return result
else:
# Whitespace-only → skip entirely (no delimiter needed)
debug_print(f"[DEBUG MO FENCE] Whitespace-only fence content → skip (no delimiter)")
return ""
# Map common fences to LaTeX
fence_map = {
'(': '(',
')': ')',
'[': '[',
']': ']',
'{': '\\{',
'}': '\\}',
'|': '|',
'‖': '\\|', # Double vertical bar
'⟨': '\\langle',
'⟩': '\\rangle',
'⌈': '\\lceil',
'⌉': '\\rceil',
'⌊': '\\lfloor',
'⌋': '\\rfloor',
}
latex_char = fence_map.get(content, content)
# Wrap in __DELIM__ markers so post-processor knows to add \left and \right
# ONLY delimiters with fence="true" get these markers
stretchy = node.get('stretchy')
if stretchy == 'true':
result = f"__DELIM__STRETCHY::{latex_char}__DELIM__"
else:
result = f"__DELIM__{latex_char}__DELIM__"
# Debug output
debug_print(f"[DEBUG MO FENCE] {content!r} → {result!r}")
return result
def _wrap_with_color(self, latex: str, node: ET.Element) -> str:
"""
Wrap LaTeX with color command if node has color attribute.
Args:
latex: The LaTeX string to wrap
node: The element node
Returns:
LaTeX string, optionally wrapped with color command
"""
# Use the centralized color wrapping utility with suppression support
from ..utils.color_helpers import wrap_element_with_color
# Get converter context from multiple sources (for backward compatibility):
# 1. Direct visitor context (set by legacy_adapter)
# 2. Strategy context (shared across all visitors, including child visitors)
context = getattr(self, '_converter_context', None)
if context is None and hasattr(self.strategy, '_converter_context'):
context = self.strategy._converter_context
return wrap_element_with_color(latex, node, context)
def can_handle(self, node: ET.Element) -> bool:
"""
Check if this visitor can handle the given node.
Args:
node: The MathML element to check
Returns:
True if node is an <mo> element, False otherwise
"""
from ..utils import local_name as _local
return _local(node.tag) == 'mo'
def _should_preserve_space(self, node: ET.Element) -> bool:
"""
Context-aware decision: should this space operator be preserved?
Analyzes the previous and next sibling elements to determine if the space
is redundant (LaTeX will add it automatically) or necessary.
Args:
node: The <mo> space element
Returns:
True if space should be preserved, False if it should be skipped
"""
parent = node.getparent()
if parent is None:
return False # No context, skip
siblings = list(parent)
try:
idx = siblings.index(node)
except ValueError:
return False # Can't find in parent, skip
# Rule 1: Skip trailing spaces (no next element)
if idx >= len(siblings) - 1:
return False
# Get previous and next elements
prev_elem = siblings[idx - 1] if idx > 0 else None
next_elem = siblings[idx + 1]
# Rule 2: Get LaTeX representation of previous element
if prev_elem is not None:
prev_latex = self._peek_sibling_latex(prev_elem)
# Skip after operators with built-in spacing
if prev_latex.strip() in SKIP_SPACE_AFTER:
return False
# Skip after LaTeX functions (\sin, \cos, etc.)
# Check if converted from plain text to LaTeX command
if prev_latex.startswith('\\'):
func_name = prev_latex.lstrip('\\').split()[0] if prev_latex else ''
if func_name in {'sin', 'cos', 'tan', 'log', 'ln', 'exp',
'lim', 'max', 'min', 'sup', 'inf',
'arcsin', 'arccos', 'arctan', 'sinh', 'cosh', 'tanh'}:
return False
# Rule 3: Preserve before differentials
next_latex = self._peek_sibling_latex(next_elem)
if next_latex.strip() in PRESERVE_SPACE_BEFORE:
return True
# Rule 4: Default - skip (let LaTeX handle spacing)
return False
def _peek_sibling_latex(self, elem: ET.Element) -> str:
"""
Convert a sibling element to LaTeX without full recursion.
This is a lightweight peek to check what LaTeX representation
the element would produce, used for context decisions.
Args:
elem: The sibling element to peek at
Returns:
LaTeX representation (simplified)
"""
if elem is None:
return ""
tag = _local(elem.tag)
# For simple elements, just get text content
if tag == 'mi':
text = _text_content(elem).strip()
# Check if it's a function name
if text in {'sin', 'cos', 'tan', 'log', 'ln', 'exp',
'lim', 'max', 'min', 'sup', 'inf'}:
return f'\\{text}'
return text
elif tag == 'mo':
text = _text_content(elem).strip()
# Common operator conversions
op_map = {
'×': '\\times', '÷': '\\div', '·': '\\cdot',
'≤': '\\leq', '≥': '\\geq', '≠': '\\neq',
'→': '\\rightarrow', '←': '\\leftarrow', '↔': '\\leftrightarrow',
'∨': '\\lor', '∧': '\\land', '¬': '\\neg',
'∩': '\\cap', '∪': '\\cup',
'⊥': '\\perp', '∥': '\\parallel',
'∈': '\\in', '⊆': '\\subseteq', '⊇': '\\supseteq',
}
return op_map.get(text, text)
elif tag == 'mn':
return _text_content(elem).strip()
else:
# For complex elements, return empty (conservative)
return ""
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mo'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MoVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,911 @@
r"""
Visitor for <mover> (overscript/accent) MathML elements.
The <mover> element represents content with something above it:
- Accents: hat, tilde, bar, dot
- Vector notation: arrow over variables
- Chemical reactions: text over equals/arrow
- General overscript: any content over base
This implementation handles the most common cases with TODOs for advanced features.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import text_content as _text_content, local_name as _local
class MoverVisitor(Visitor):
r"""
Handles <mover> (overscript/accent) MathML elements.
Converts overscript and accent notation to appropriate LaTeX commands.
Examples:
Vector notation (arrow over variable):
<mover><mi>v</mi><mo>→</mo></mover> → \overrightarrow{v}
Bar/overline:
<mover><mi>x</mi><mo>¯</mo></mover> → \overline{x}
Tilde accent:
<mover><mi>x</mi><mo>~</mo></mover> → \tilde{x}
Hat/circumflex:
<mover><mi>x</mi><mo>^</mo></mover> → \hat{x}
Dot accent:
<mover><mi>x</mi><mo>˙</mo></mover> → \dot{x}
Double dot (ddot) accent:
<mover><mi>x</mi><mo>¨</mo></mover> → \ddot{x}
Triple dot (dddot) accent:
<mover><mi>x</mi><mo>⃛</mo></mover> → \dddot{x}
Overbrace with label:
<mover><mover><mn>3333</mn><mo>⏞</mo></mover><mn>222</mn></mover>
→ \overbrace{3333}^{222}
Chemical reaction (xlongequal):
<mover accent="false"><mo>=</mo><mtext>heat</mtext></mover>
→ \xlongequal{\text{heat}}
General overscript:
<mover accent="false"><mi>x</mi><mi>n</mi></mover> → \overset{n}{x}
"""
def __init__(self, strategy: Strategy):
"""Initialize the Mover visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <mover> element to LaTeX.
Args:
node: The <mover> element to convert
Returns:
LaTeX string with appropriate overscript/accent notation
Raises:
ValueError: If node is not an <mover> element
ValueError: If mover has fewer than 2 children
"""
if not self.can_handle(node):
raise ValueError(f"MoverVisitor cannot handle element '{node.tag}'")
children = list(node)
# mover must have exactly 2 children: base and overscript
if len(children) < 2:
raise ValueError(
f"mover element must have 2 children (base, overscript), "
f"got {len(children)}"
)
# IMPORTANT: Check raw text content BEFORE converting children
# to properly detect bar/accent characters (e.g., "―" before it becomes "\textemdash")
over_raw_text = _text_content(children[1]).strip()
# Check for special data attributes (from OMML conversion)
if node.get("data-not-slash") == "true":
# \not command
base = self._convert_child(children[0])
return f"\\not{self._brace(base)}"
if node.get("data-chemical-reaction") == "true":
# Chemical reaction with text over arrow/equals
base = self._convert_child(children[0])
over = self._convert_child(children[1])
over_clean = self._strip_text_braces(over)
return f"\\xlongequal{self._brace(over_clean)}"
# Check for nested extensible arrow pattern: <mover><munder>arrow/equals<...>...</munder><...></mover>
# OR: <mover><mrow><munder>arrow/equals<...>...</munder></mrow><...></mover> (Word often adds mrow wrapper)
# This is Word's way of creating arrows with text above and below
munder_node = None
if _local(children[0].tag) == 'munder':
# Direct munder child
munder_node = children[0]
elif _local(children[0].tag) == 'mrow':
# Check if mrow contains munder as only significant child
mrow_children = list(children[0])
if len(mrow_children) == 1 and _local(mrow_children[0].tag) == 'munder':
munder_node = mrow_children[0]
else:
# Check if any child is munder (Word sometimes adds multiple mrows)
for child in mrow_children:
if _local(child.tag) == 'munder':
munder_node = child
break
if munder_node is not None:
munder_children = list(munder_node)
if len(munder_children) >= 2:
# The arrow/equals might be in an mrow wrapper
base_node = munder_children[0]
if _local(base_node.tag) == 'mrow':
# Unwrap mrow to find the actual operator
mrow_children = list(base_node)
if mrow_children and _local(mrow_children[0].tag) in ('mo', 'mi', 'mtext'):
base_node = mrow_children[0]
# Check if base is an arrow or equals
xarrow_cmd = self._get_xarrow_command(base_node)
if xarrow_cmd:
# Extract text from over and under (may be wrapped in <mphantom> or <mrow>)
under = self._convert_child(munder_children[1])
over = self._convert_child(children[1])
# Clean up the text (remove \phantom{}, \mathrm{}, \text{}, extra spaces)
under_clean = self._clean_arrow_text(under)
over_clean = self._clean_arrow_text(over)
# Re-wrap Chinese text and non-command text in \mathrm{} for chemistry mode
# (since _clean_arrow_text removes it)
# This is crucial for chemical reaction conditions like "加热", "控温", "电解"
if hasattr(self.strategy, 'name') and self.strategy.name == "chemistry":
# Check if text needs \mathrm{} wrapping
# Skip if it's already a LaTeX command (starts with \) or empty
# CRITICAL: Also skip pure numbers (like "1", "222", "2121")
# Numbers should remain as plain numbers, not wrapped in \mathrm{}
if over_clean and not over_clean.startswith("\\") and over_clean.strip():
# Check if it's a pure number (digits only, possibly with decimal point)
if not over_clean.replace('.', '', 1).isdigit():
# Not a pure number - wrap it in \mathrm{}
over_clean = f"\\mathrm{{{over_clean}}}"
if under_clean and not under_clean.startswith("\\") and under_clean.strip():
# Check if it's a pure number (digits only, possibly with decimal point)
if not under_clean.replace('.', '', 1).isdigit():
# Not a pure number - wrap it in \mathrm{}
under_clean = f"\\mathrm{{{under_clean}}}"
# Use \quad for empty positions (from <mphantom> elements)
# This creates proper spacing in chemical equations
if not under_clean or under_clean.strip() == "":
under_clean = "\\quad"
if not over_clean or over_clean.strip() == "":
over_clean = "\\quad"
return f"\\{xarrow_cmd}[{under_clean}]{{{over_clean}}}"
# Check for xlongequal pattern (equals sign with text above)
if self._is_equals_base(children[0]):
base = self._convert_child(children[0])
over = self._convert_child(children[1])
label = self._strip_text_braces(over)
return f"\\xlongequal{self._brace(label)}"
# Check for arc notation: arc/frown over multi-character content (line segments)
if self._is_arc_notation(node):
# Convert to arc notation with size modifiers
base_text = _text_content(children[0]).strip()
base = self._convert_child(children[0])
# Remove \mathrm{} wrapper - accent base should be italic
base_clean = self._strip_mathrm_for_accents(base)
# Use different sizes based on base length: \huge for 2 chars, \Huge for 3+ chars
if len(base_text) == 2:
return f"\\overset{{\\huge\\frown}}{self._brace(base_clean)}"
else: # 3+ characters
return f"\\overset{{\\Huge\\frown}}{self._brace(base_clean)}"
# Check for vector notation (arrow over variables)
# CRITICAL: Need to unwrap mrow wrappers around arrows
# Pattern: <mover><base><mrow><mi>→</mi></mrow></mover>
over_to_check = children[1]
if _local(over_to_check.tag) == 'mrow':
# Unwrap mrow if it contains a single element
mrow_children = list(over_to_check)
if len(mrow_children) == 1:
over_to_check = mrow_children[0]
arrow_cmd = self._get_vector_arrow_cmd(children[0], over_to_check)
if arrow_cmd:
# Remove any \mathrm{} wrapping (vector variables should be italic)
base = self._convert_child(children[0])
base_clean = self._strip_mathrm(base)
return f"\\{arrow_cmd}{self._brace(base_clean)}"
# Check for extensible arrow pattern (arrow as base)
# Pattern: <mover><mo>→</mo><content></mover>
# This produces \xrightarrow[\quad]{content} for chemical reaction arrows
#
# IMPORTANT: When arrow is the BASE of mover (not the overscript), it should use
# \xrightarrow format for extensible arrows with labels, NOT \overrightarrow.
# \overrightarrow is for vector notation where arrow is OVER variables.
base_to_check = children[0]
if _local(base_to_check.tag) == 'mrow':
# Unwrap mrow if it contains a single element
mrow_children = list(base_to_check)
if len(mrow_children) == 1:
base_to_check = mrow_children[0]
# Check if base is an arrow operator (extensible arrow for reaction notation)
# When arrow is the base element with content above, always use xrightarrow format
xarrow_cmd = self._get_xarrow_command(base_to_check)
if xarrow_cmd:
# Extensible arrow with content above
over = self._convert_child(children[1])
over_clean = self._clean_arrow_text(over)
# Use \quad for empty below position
return f"\\{xarrow_cmd}[\\quad]{self._brace(over_clean)}"
# Check for inverted vector notation (variables over arrow)
# CRITICAL: Need to unwrap mrow wrappers around arrows
# Pattern: <mover><mrow><mi>→</mi></mrow><content></mover>
inv_arrow_cmd = self._get_inverted_vector_arrow_cmd(base_to_check, children[1])
if inv_arrow_cmd:
# Variables are in over position
over = self._convert_child(children[1])
over_clean = self._strip_mathrm(over)
return f"\\{inv_arrow_cmd}{self._brace(over_clean)}"
# Check for bar/overline notation (using RAW text)
if self._is_bar_char(over_raw_text):
base = self._convert_child(children[0])
return f"\\overline{self._brace(base)}"
# Check for tilde accent (using RAW text)
if self._is_tilde_char(over_raw_text):
base = self._convert_child(children[0])
base_clean = self._strip_mathrm(base)
# Use \tilde for single char, \widetilde for multi-char
base_text = _text_content(children[0]).strip()
if len(base_text) == 1:
return f"\\tilde{self._brace(base_clean)}"
else:
return f"\\widetilde{self._brace(base_clean)}"
# Check for combining arrows from XSLT transformer
# U+20D7 = combining right arrow above → \overrightarrow OR \hat (depends on context)
# U+20D6 = combining left arrow above → \overleftarrow
if over_raw_text == "⃗": # U+20D7 combining right arrow above
base = self._convert_child(children[0])
base_clean = self._strip_mathrm(base)
# Check if this is a default accent (hat) or explicit vector arrow
# XSLT adds data-default-accent="true" when chr is not specified in OMML
# Default (no chr) → \hat, Explicit chr="⃗" → \overrightarrow
if node.get('data-default-accent') == 'true':
return f"\\hat{self._brace(base_clean)}"
else:
return f"\\overrightarrow{self._brace(base_clean)}"
if over_raw_text == "⃖": # U+20D6 combining left arrow above
base = self._convert_child(children[0])
base_clean = self._strip_mathrm(base)
# ALWAYS use overleftarrow for combining left arrow
return f"\\overleftarrow{self._brace(base_clean)}"
# Check for hat/circumflex accent (using RAW text)
if self._is_hat_char(over_raw_text):
base = self._convert_child(children[0])
base_clean = self._strip_mathrm(base)
# Use \hat for single char, \widehat for multi-char
base_text = _text_content(children[0]).strip()
if len(base_text) == 1:
return f"\\hat{self._brace(base_clean)}"
else:
return f"\\widehat{self._brace(base_clean)}"
# Check for dot accent (using RAW text)
if self._is_dot_char(over_raw_text):
base = self._convert_child(children[0])
base_clean = self._strip_mathrm(base)
return f"\\dot{self._brace(base_clean)}"
# Check for breve accent (using RAW text)
if self._is_breve_char(over_raw_text):
base = self._convert_child(children[0])
base_clean = self._strip_mathrm(base)
return f"\\breve{self._brace(base_clean)}"
# Check for harpoon accents (using RAW text)
harpoon_cmd = self._get_harpoon_cmd(over_raw_text)
if harpoon_cmd:
base = self._convert_child(children[0])
base_clean = self._strip_mathrm(base)
return f"\\overset{{\\{harpoon_cmd}}}{self._brace(base_clean)}"
# Check for ddot (double dot) accent (using RAW text)
if self._is_ddot_char(over_raw_text):
base = self._convert_child(children[0])
base_clean = self._strip_mathrm(base)
return f"\\ddot{self._brace(base_clean)}"
# Check for dddot (triple dot) accent (using RAW text)
if self._is_dddot_char(over_raw_text):
base = self._convert_child(children[0])
base_clean = self._strip_mathrm(base)
return f"\\dddot{self._brace(base_clean)}"
# Check for overbrace notation: nested mover with brace character
# Pattern 1: mover(mover(base, ⏞), label) → \overbrace{base}^{label}
# Pattern 2: mover(mover(⏞, base), label) → \overbrace{base}^{label} [NEW - vertJc="bot" case]
# OR: mover(mrow(mover(...)), label) with mrow wrapper (Word often wraps inner mover)
# This is how Word encodes overbrace with labels
inner_mover = None
if len(children) >= 2:
# Check if first child is mover directly
if _local(children[0].tag) == 'mover':
inner_mover = children[0]
# Check if first child is mrow containing mover
elif _local(children[0].tag) == 'mrow':
mrow_children = list(children[0])
# Look for mover inside mrow (may be the only child or one of several)
for child in mrow_children:
if _local(child.tag) == 'mover':
inner_mover = child
break
# Process the inner mover if found
if inner_mover is not None:
inner_children = list(inner_mover)
if len(inner_children) >= 2:
# Pattern 1: Check if inner mover's second child is the overbrace character
inner_over_text = _text_content(inner_children[1]).strip()
if inner_over_text == "⏞": # U+23DE Overbrace character
# Extract base from inner mover (first child)
inner_base = self._convert_child(inner_children[0])
# Extract label from outer mover (second child)
label = self._convert_child(children[1])
# Return proper overbrace with superscript label
return f"\\overbrace{self._brace(inner_base)}^{self._brace(label)}"
# Pattern 2: Check if inner mover's first child is the overbrace character
# This happens when vertJc="bot" in OMML groupChr
inner_base_text = _text_content(inner_children[0]).strip()
if inner_base_text == "⏞": # U+23DE Overbrace character as base
# Extract base from inner mover (second child - content above the brace)
inner_base = self._convert_child(inner_children[1])
# Extract label from outer mover (second child)
label = self._convert_child(children[1])
# Return proper overbrace with superscript label
return f"\\overbrace{self._brace(inner_base)}^{self._brace(label)}"
# Check for ALTERNATIVE overbrace pattern (Word sometimes uses this)
# Pattern: mover(munder(base, ⏞), label) → \overbrace{base}^{label}
# OR: mover(mrow(munder(base, ⏞)), label) with mrow wrapper
# The brace character ⏞ is in the UNDER position but still represents overbrace
if len(children) >= 2:
# Check if first child is munder (directly or inside mrow)
inner_munder = None
if _local(children[0].tag) == 'munder':
inner_munder = children[0]
elif _local(children[0].tag) == 'mrow':
# Check inside mrow wrapper
for child in children[0]:
if _local(child.tag) == 'munder':
inner_munder = child
break
if inner_munder is not None:
inner_children = list(inner_munder)
if len(inner_children) >= 2:
# Check if inner munder's second child is the overbrace character
inner_under_text = _text_content(inner_children[1]).strip()
if inner_under_text == "⏞": # U+23DE Overbrace character in under position
# Extract base from inner munder (first child)
inner_base = self._convert_child(inner_children[0])
# Extract label from outer mover (second child)
label = self._convert_child(children[1])
# Return proper overbrace with superscript label
return f"\\overbrace{self._brace(inner_base)}^{self._brace(label)}"
# TODO: Additional accent types (rarely used in mathematical notation)
# - Check (caron): ˇ character → \check{x}
# - Acute: ´ character → \acute{x}
# - Grave: ` character → \grave{x}
# CRITICAL FIX: Check if base is a large operator (max, min, lim, etc.)
# Word incorrectly produces <mover> for operators that should use subscripts
# Example: <mover><mi>max</mi><mrow>condition</mrow></mover>
# Should become: \max_{condition}, NOT \overset{condition}{max}
if self._is_large_operator(children[0]):
# Use subscript for operator limits, treating the "over" content as "under"
base = self._convert_child(children[0])
limit = self._convert_child(children[1])
return f"{base}\\limits_{self._brace(limit)}"
# Default: general overscript using \overset
base = self._convert_child(children[0])
over = self._convert_child(children[1])
return f"\\overset{self._brace(over)}{self._brace(base)}"
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in mover, using text fallback", file=sys.stderr)
return _text_content(child)
def _is_equals_base(self, base_node: ET.Element) -> bool:
"""Check if base is an equals sign."""
if _local(base_node.tag) == 'mo':
return _text_content(base_node).strip() == '='
return False
def _get_vector_arrow_cmd(self, base_node: ET.Element, over_node: ET.Element) -> str:
"""Get vector arrow command if pattern matches (arrow over variables/numbers)."""
# Check if base is a variable (mi), number (mn), expression (mrow), or complex expression
# Include fractions, superscripts, subscripts, roots, etc. as valid bases for vector arrows
if _local(base_node.tag) not in ['mi', 'mn', 'mrow', 'mfrac', 'msup', 'msub', 'msubsup', 'msqrt', 'mroot']:
return ""
# Check if over is an arrow
# CRITICAL FIX: OMML→MathML conversion often puts arrows in <mi> tags instead of <mo>
# Must check BOTH <mo> AND <mi> tags for arrow symbols
if _local(over_node.tag) not in ['mo', 'mi']:
return ""
over_text = _text_content(over_node).strip()
# NOTE: Only single arrows (→, ←, ↔) are used for vector notation
# Double arrows (⇒, ⇐, ⇔) should use overset with \Longrightarrow, not overrightarrow
arrow_map = {
"→": "overrightarrow",
"\\rightarrow": "overrightarrow",
"←": "overleftarrow",
"\\leftarrow": "overleftarrow",
"↔": "overleftrightarrow",
"\\leftrightarrow": "overleftrightarrow",
}
return arrow_map.get(over_text, "")
def _get_inverted_vector_arrow_cmd(self, base_node: ET.Element, over_node: ET.Element) -> str:
"""Get vector arrow command if pattern matches (variables over arrow)."""
# Check if base is an arrow
# CRITICAL FIX: OMML→MathML conversion often puts arrows in <mi> tags instead of <mo>
# Must check BOTH <mo> AND <mi> tags for arrow symbols
if _local(base_node.tag) not in ['mo', 'mi']:
return ""
base_text = _text_content(base_node).strip()
# NOTE: Only single arrows (→, ←, ↔) are used for vector notation
# Double arrows (⇒, ⇐, ⇔) should use overset with \Longrightarrow, not overrightarrow
arrow_map = {
"→": "overrightarrow",
"\\rightarrow": "overrightarrow",
"←": "overleftarrow",
"\\leftarrow": "overleftarrow",
"↔": "overleftrightarrow",
"\\leftrightarrow": "overleftrightarrow",
}
# Check if over is variable-like content or complex expression
if _local(over_node.tag) in ['mi', 'mn', 'mrow', 'mfrac', 'msup', 'msub', 'msubsup', 'msqrt', 'mroot']:
return arrow_map.get(base_text, "")
return ""
def _is_bar_char(self, latex_str: str) -> bool:
"""Check if string represents a bar/overline character."""
bar_chars = [
"¯", "‾", "―", "−", "⎺", "⎻", "-", "_",
"̅", # U+0305 combining overline (used in OMML m:acc)
]
return latex_str.strip() in bar_chars
def _is_tilde_char(self, latex_str: str) -> bool:
"""Check if string represents a tilde character."""
tilde_chars = [
"~", "˜", "∼", "\\tilde",
"̃", # U+0303 combining tilde (used in OMML m:acc)
]
return latex_str.strip() in tilde_chars
def _is_hat_char(self, latex_str: str) -> bool:
"""Check if string represents a hat/circumflex character."""
hat_chars = [
"^", "ˆ", "︶", "∧", "\\hat",
"̂", # U+0302 combining circumflex (used in OMML m:acc)
]
return latex_str.strip() in hat_chars
def _is_dot_char(self, latex_str: str) -> bool:
"""Check if string represents a dot character."""
dot_chars = [
"˙", ".", "·", "∙", "\\dot",
"̇", # U+0307 combining dot above (used in OMML m:acc)
]
return latex_str.strip() in dot_chars
def _is_ddot_char(self, latex_str: str) -> bool:
"""Check if string represents a ddot (double dot/diaeresis) character."""
# Double dot characters (diaeresis/umlaut)
ddot_chars = [
"¨", # U+00A8 diaeresis (standalone)
"̈", # U+0308 combining diaeresis
"‥", # U+2025 two dot leader
"__DDOT__", # Placeholder from symbol mapping
"\\ddot",
]
return latex_str.strip() in ddot_chars
def _is_dddot_char(self, latex_str: str) -> bool:
"""Check if string represents a dddot (triple dot) character."""
# Triple dot characters
dddot_chars = [
"⃛", # U+20DB combining three dots above
"⋮", # U+22EE vertical ellipsis (sometimes used)
"︙", # U+FE19 presentation form vertical ellipsis
"__DDDOT__", # Placeholder from symbol mapping
"\\dddot",
]
return latex_str.strip() in dddot_chars
def _is_breve_char(self, latex_str: str) -> bool:
"""Check if string represents a breve character."""
breve_chars = ["̆", "˘", "\\breve"] # U+0306 combining breve, U+02D8 breve
return latex_str.strip() in breve_chars
def _get_harpoon_cmd(self, latex_str: str) -> str:
"""Get harpoon LaTeX command if string represents a harpoon character.
Returns:
The LaTeX command name (without backslash) if harpoon, empty string otherwise.
"""
harpoon_map = {
"⃐": "leftharpoonup", # U+20D0 Combining left harpoon above
"⃑": "rightharpoonup", # U+20D1 Combining right harpoon above
"⇀": "rightharpoonup", # U+21C0 Right harpoon up
"↼": "leftharpoonup", # U+21BC Left harpoon up
"⇁": "rightharpoondown", # U+21C1 Right harpoon down
"↽": "leftharpoondown", # U+21BD Left harpoon down
}
return harpoon_map.get(latex_str.strip(), "")
def _strip_text_braces(self, latex_str: str) -> str:
r"""Remove \text{...} wrapper if present."""
import re
match = re.match(r'\\text\{(.+)\}', latex_str)
if match:
return match.group(1)
return latex_str
def _strip_mathrm(self, latex_str: str) -> str:
r"""Remove \mathrm{...} wrapper if present."""
import re
# Remove \mathrm{} wrappers
latex_str = re.sub(r'\\mathrm\{([a-zA-Z0-9]+)\}', r'\1', latex_str)
# Remove spaces in vector notation (a b c -> abc)
if re.match(r'^[a-zA-Z0-9](\s+[a-zA-Z0-9])+$', latex_str):
latex_str = re.sub(r'\s+', '', latex_str)
return latex_str
def _get_xarrow_command(self, base_node: ET.Element) -> str:
"""
Get extensible arrow command for the base element.
Returns command name (without backslash) if base is an arrow/equals,
otherwise returns empty string.
Supported arrows:
- = → xlongequal
- → → xrightarrow
- ← → xleftarrow
- ↔ → xleftrightarrow
- ⇌ → xrightleftharpoons
"""
# Accept <mo> (operator), <mi> (identifier), and <mtext> (text) elements for arrows
# Word's XSLT sometimes produces <mi> for arrows instead of <mo>
if _local(base_node.tag) not in ('mo', 'mi', 'mtext'):
return ""
base_text = _text_content(base_node).strip()
# Map arrow symbols to xarrow commands
arrow_map = {
"=": "xlongequal",
"→": "xrightarrow",
"\\rightarrow": "xrightarrow",
"←": "xleftarrow",
"\\leftarrow": "xleftarrow",
"↔": "xleftrightarrow",
"\\leftrightarrow": "xleftrightarrow",
"⟷": "xleftrightarrow", # U+27F7 long left-right arrow
"\\longleftrightarrow": "xleftrightarrow",
"⇌": "xrightleftharpoons",
"\\rightleftharpoons": "xrightleftharpoons",
}
return arrow_map.get(base_text, "")
def _clean_arrow_text(self, text: str) -> str:
"""
Clean up text for use in arrow labels.
Removes:
- \\phantom{} wrappers (Word uses these for arrow labels)
- \\text{} wrappers (content preserved)
- Extra spaces between characters
NOTE: \\mathrm{} wrappers are PRESERVED because they represent explicit
styling from the source document (mathvariant="normal" in MathML).
"""
import re
# Helper function to remove LaTeX commands with balanced braces
def remove_command(text: str, command: str) -> str:
"""Remove LaTeX command with proper brace balancing.
Args:
text: Input text with LaTeX commands
command: Command to remove (e.g., "\\mathrm" or "mathrm")
"""
# Normalize command: ensure it starts with backslash
if not command.startswith('\\'):
command = '\\' + command
while command in text:
# Find the command
idx = text.find(command)
if idx == -1:
break
# Find the opening brace (should be right after command)
brace_start = idx + len(command)
if brace_start >= len(text) or text[brace_start] != '{':
break # Malformed - no opening brace
# Match balanced braces
brace_count = 1
pos = brace_start + 1
while pos < len(text) and brace_count > 0:
if text[pos] == '{' and (pos == 0 or text[pos-1] != '\\'):
brace_count += 1
elif text[pos] == '}' and (pos == 0 or text[pos-1] != '\\'):
brace_count -= 1
pos += 1
if brace_count == 0:
# Successfully matched braces
content = text[brace_start+1:pos-1]
text = text[:idx] + content + text[pos:]
else:
break # Malformed braces
return text
# Remove \phantom{} wrappers (Word's equation editor uses these for arrow labels)
text = remove_command(text, '\\phantom')
# NOTE: Do NOT remove \mathrm{} wrappers - they represent explicit styling
# from mathvariant="normal" in the source MathML, indicating the author
# wanted upright text (important for chemistry formulas like NH3·H2O)
# Remove \text{} wrappers (but keep the content)
# For Chinese text, we want to preserve it as-is
text = remove_command(text, '\\text')
# Remove extra spaces between individual characters
# "高 压" → "高压", but preserve intentional spaces in phrases
text = re.sub(r'(?<=[\u4e00-\u9fff])\s+(?=[\u4e00-\u9fff])', '', text)
# For non-Chinese text with spaces between each char, remove spaces
# "a b c" → "abc", but "word1 word2" stays as-is
if len(text.replace(' ', '')) > 0:
# Check if it's all single chars separated by spaces
parts = text.split()
if all(len(p) == 1 for p in parts):
text = ''.join(parts)
return text.strip()
def _brace(self, content: str) -> str:
"""Wrap content in braces."""
return f"{{{content}}}"
def _is_arc_notation(self, mover_node: ET.Element) -> bool:
"""
Check if mover represents arc notation (frown/arc over multi-char content).
Arc notation is used for line segments like AB̂.
Args:
mover_node: The mover element to check
Returns:
True if this represents arc notation, False otherwise
"""
if len(mover_node) < 2:
return False
base_node = mover_node[0]
over_node = mover_node[1]
# Check if the over content is an arc/frown character
if _local(over_node.tag) in ["mtext", "mo"]:
over_text = _text_content(over_node).strip()
# Arc characters (frown/smile used as arc over multiple characters)
arc_characters = [
"⌢", # Frown (upward arc) - used for arc over segments
"︵", # Top parenthesis
"⁀", # Character tie
]
if over_text in arc_characters:
base_text = _text_content(base_node).strip()
# Arc notation is used for multi-character bases (line segments)
# For 2+ characters, use \overset{\frown} or widehat
if len(base_text) >= 2:
return True
return False
def _strip_mathrm_for_accents(self, latex: str) -> str:
"""
Remove \\mathrm{} wrapper from content that will be used in accent notations.
Accent base content (like AB in \\widetilde{AB}) should remain italic, not upright.
Args:
latex: LaTeX string possibly wrapped in \\mathrm{}
Returns:
LaTeX string with \\mathrm{} removed if present
"""
import re
# Match \mathrm{content} and extract just the content
pattern = r'^\\mathrm\{(.+)\}$'
match = re.match(pattern, latex)
if match:
return match.group(1)
return latex
def _is_large_operator(self, base_node: ET.Element) -> bool:
"""
Check if base is a large operator (sum, integral, product, max, min, lim, etc.).
Recursively searches through wrapper elements like <mstyle>, <mrow>, <mpadded>
to find the actual operator, since Word often wraps large operators.
This is used to detect when Word incorrectly uses <mover> for operators
that should use subscripts (e.g., max, min, lim).
"""
if base_node is None:
return False
tag = _local(base_node.tag)
# Define large operators list (same as in MunderVisitor)
large_ops = [
"∑", "\\sum", # Summation
"∏", "\\prod", # Product
"∐", "\\coprod", # Coproduct
"∫", "\\int", # Integral
"∬", "\\iint", # Double integral
"∭", "\\iiint", # Triple integral
"∮", "\\oint", # Contour integral
"⋃", "\\bigcup", # Union
"⋂", "\\bigcap", # Intersection
"⋁", "\\bigvee", # Logical OR
"⋀", "\\bigwedge", # Logical AND
"⨁", "\\bigoplus", # Direct sum
"⨂", "\\bigotimes", # Tensor product
"⨄", "\\biguplus", # Multiset union
"lim", "\\lim", # Limit (often in <mi> from Word)
"sup", "\\sup", # Supremum
"inf", "\\inf", # Infimum
"max", "\\max", # Maximum
"min", "\\min", # Minimum
]
# Check direct <mo> or <mi> elements
if tag in ('mo', 'mi'):
base_text = _text_content(base_node).strip()
return base_text in large_ops
# Recursively search through wrapper elements (mstyle, mrow, mpadded, menclose)
if tag in ('mstyle', 'mrow', 'mpadded', 'menclose'):
for child in base_node:
if self._is_large_operator(child):
return True
return False
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mover'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mover'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MoverVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,198 @@
r"""
Visitor for <mpadded> (padded content) MathML elements.
The <mpadded> element is used to adjust the spacing and positioning of its content
by adding padding. It supports attributes like width, height, depth, lwidth, and voffset.
In LaTeX conversion, mpadded is typically treated as a transparent wrapper since
LaTeX doesn't have a direct equivalent for fine-grained spacing control. The padding
attributes are generally ignored, and only the content is converted.
This visitor acts as a transparent container, similar to <mrow>.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MpaddedVisitor(Visitor):
r"""
Handles <mpadded> (padded content) MathML elements.
Acts as a transparent wrapper, converting only the content without
applying padding attributes (LaTeX doesn't have direct equivalents).
Examples:
Simple padding (transparent):
<mpadded width="+1em"><mi>x</mi></mpadded>
→ x
Padded expression:
<mpadded lspace="0.5em"><mrow><mi>a</mi><mo>+</mo><mi>b</mi></mrow></mpadded>
→ a+b
Padded fraction:
<mpadded height="+1em"><mfrac><mi>a</mi><mi>b</mi></mfrac></mpadded>
→ \frac{a}{b}
Empty padding:
<mpadded width="0em"><mi>x</mi></mpadded>
→ x
Note:
MathML padding attributes (width, height, depth, lwidth, voffset) are
intentionally ignored in LaTeX conversion as there's no direct equivalent.
For precise spacing control in LaTeX, use \hspace{}, \vspace{}, or \phantom{}.
"""
def __init__(self, strategy: Strategy):
"""Initialize the Mpadded visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <mpadded> element to LaTeX (transparent wrapper).
Padding attributes are ignored in conversion.
Args:
node: The <mpadded> element to convert
Returns:
LaTeX string with content only (no padding applied)
Raises:
ValueError: If node is not a <mpadded> element
"""
if not self.can_handle(node):
raise ValueError(f"MpaddedVisitor cannot handle element '{node.tag}'")
# Simply convert children, ignoring padding attributes
return self._convert_children(node)
def _convert_children(self, node: ET.Element) -> str:
"""
Convert all child elements to LaTeX.
Args:
node: The <mpadded> element
Returns:
LaTeX string with all children converted
"""
parts = []
for child in node:
parts.append(self._convert_child(child))
return ''.join(parts)
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in mpadded, using text fallback", file=sys.stderr)
return _text_content(child)
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mpadded'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mpadded'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MpaddedVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,204 @@
r"""
Visitor for <mphantom> (phantom/invisible content) MathML elements.
The <mphantom> element renders its content invisibly but reserves the space
it would normally occupy. This is useful for alignment and spacing purposes.
In LaTeX, this is converted using the \phantom{} command.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MphantomVisitor(Visitor):
r"""
Handles <mphantom> (phantom/invisible content) MathML elements.
Converts phantom content to LaTeX \phantom{} command, which creates
an invisible placeholder that reserves space.
Examples:
Simple phantom:
<mphantom><mi>x</mi></mphantom>
→ \phantom{x}
Phantom expression:
<mphantom><mrow><mi>a</mi><mo>+</mo><mi>b</mi></mrow></mphantom>
→ \phantom{a+b}
Phantom for alignment:
<mrow>
<mi>x</mi>
<mphantom><mo>+</mo></mphantom>
<mi>y</mi>
</mrow>
→ x\phantom{+}y
Phantom fraction:
<mphantom><mfrac><mi>a</mi><mi>b</mi></mfrac></mphantom>
→ \phantom{\frac{a}{b}}
Note:
The phantom command is useful for:
- Vertical alignment across equations
- Horizontal spacing that matches specific content
- Creating symmetric layouts
"""
def __init__(self, strategy: Strategy):
"""Initialize the Mphantom visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <mphantom> element to LaTeX \phantom{} command.
Args:
node: The <mphantom> element to convert
Returns:
LaTeX string with \phantom{content}
Raises:
ValueError: If node is not a <mphantom> element
"""
if not self.can_handle(node):
raise ValueError(f"MphantomVisitor cannot handle element '{node.tag}'")
# Convert children to get the content
content = self._convert_children(node)
# If empty, return empty phantom
if not content or not content.strip():
return "\\phantom{}"
# Wrap content in \phantom{}
return f"\\phantom{{{content}}}"
def _convert_children(self, node: ET.Element) -> str:
"""
Convert all child elements to LaTeX.
Args:
node: The <mphantom> element
Returns:
LaTeX string with all children converted
"""
parts = []
for child in node:
parts.append(self._convert_child(child))
return ''.join(parts)
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in mphantom, using text fallback", file=sys.stderr)
return _text_content(child)
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mphantom'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mphantom'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MphantomVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,201 @@
r"""
Visitor for <mroot> (nth root) MathML elements.
The <mroot> element represents nth roots with explicit index.
The LaTeX conversion uses \sqrt[n]{...} notation.
Note: When index is 2, optimizes to \sqrt{...} (standard square root).
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import text_content as _text_content, local_name as _local
class MrootVisitor(Visitor):
r"""
Handles <mroot> (nth root) MathML elements.
Converts nth root expressions to LaTeX \sqrt[n]{...} notation.
Optimizes square roots (index=2) to \sqrt{...}.
Examples:
Cube root:
<mroot><mi>x</mi><mn>3</mn></mroot> → \sqrt[3]{x}
Fourth root:
<mroot><mn>16</mn><mn>4</mn></mroot> → \sqrt[4]{16}
Square root (optimized):
<mroot><mi>x</mi><mn>2</mn></mroot> → \sqrt{x}
Variable index:
<mroot><mi>x</mi><mi>n</mi></mroot> → \sqrt[n]{x}
Complex base:
<mroot>
<mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow>
<mn>3</mn>
</mroot>
→ \sqrt[3]{x + 1}
"""
def __init__(self, strategy: Strategy):
"""Initialize the Mroot visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <mroot> element to LaTeX.
Args:
node: The <mroot> element to convert
Returns:
LaTeX nth root string (\sqrt[n]{content} or \sqrt{content})
Raises:
ValueError: If node is not an <mroot> element
ValueError: If mroot has fewer than 2 children
"""
if not self.can_handle(node):
raise ValueError(f"MrootVisitor cannot handle element '{node.tag}'")
children = list(node)
# mroot must have exactly 2 children: base and index
# Handle malformed input gracefully for MathJax compatibility
if len(children) < 2:
if len(children) == 0:
return "\\sqrt{}"
elif len(children) == 1:
# Only base provided, assume square root
base = self._convert_child(children[0])
return f"\\sqrt{self._brace(base)}"
# Convert base (radicand) and index (root degree)
base = self._convert_child(children[0])
index = self._convert_child(children[1])
# Optimize: omit [2] for square roots (standard mathematical notation)
# Also omit empty brackets [] when index is empty
index_stripped = index.strip()
if index_stripped == "2" or not index_stripped:
return f"\\sqrt{self._brace(base)}"
else:
return f"\\sqrt[{index}]{self._brace(base)}"
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in mroot, using text fallback", file=sys.stderr)
return _text_content(child)
def _brace(self, content: str) -> str:
"""Wrap content in braces."""
return f"{{{content}}}"
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mroot'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mroot'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MrootVisitor(strategy={self.strategy.name})"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,85 @@
r"""
Visitor for <ms> (string literal) MathML elements.
The <ms> element represents a string literal, typically displayed with
surrounding quotes. It's used for text that should be treated as a literal
string rather than mathematical notation.
In LaTeX conversion, ms is converted to \text{} with quotes, similar to mtext.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local, text_content as _text_content
class MsVisitor(Visitor):
r"""
Handles <ms> (string literal) MathML elements.
Converts string literals to LaTeX \text{} with optional quotes.
Examples:
Simple string:
<ms>hello</ms>
→ \text{"hello"}
String without lquote/rquote attributes:
<ms lquote="" rquote="">world</ms>
→ \text{world}
Custom quotes:
<ms lquote="[" rquote="]">text</ms>
→ \text{[text]}
Empty string:
<ms></ms>
→ \text{""}
Note:
Default quotes are double quotes (") unless overridden by
lquote and rquote attributes.
"""
def __init__(self, strategy: Strategy):
"""Initialize the Ms visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <ms> element to LaTeX \text{} with quotes.
Args:
node: The <ms> element to convert
Returns:
LaTeX string with \text{...} and quotes
Raises:
ValueError: If node is not a <ms> element
"""
if not self.can_handle(node):
raise ValueError(f"MsVisitor cannot handle element '{node.tag}'")
# Get quote attributes (default to double quotes)
lquote = node.get("lquote", '"')
rquote = node.get("rquote", '"')
# Get text content
content = _text_content(node)
# Wrap in \text{} with quotes
return f"\\text{{{lquote}{content}{rquote}}}"
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'ms'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'ms'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MsVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,194 @@
r"""
Visitor for <mspace> (spacing) MathML elements.
The <mspace> element represents horizontal spacing in mathematical expressions.
It uses the width attribute to specify the amount of space.
Common spacing types:
- Named spaces: thinmathspace, mediummathspace, thickmathspace
- Negative spaces: negativethinmathspace, etc.
- Explicit sizes: 0.1em, 1em, 2em
This visitor maps MathML spacing to LaTeX spacing commands.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MspaceVisitor(Visitor):
r"""
Handles <mspace> (spacing) MathML elements.
Converts spacing specifications to LaTeX spacing commands.
Examples:
Thin space:
<mspace width="thinmathspace"/>
→ \,
Medium space:
<mspace width="mediummathspace"/>
→ \:
Thick space:
<mspace width="thickmathspace"/>
→ \;
Quad space:
<mspace width="1em"/>
→ \quad
Qquad space:
<mspace width="2em"/>
→ \qquad
Negative space:
<mspace width="negativethinmathspace"/>
→ \!
Default (no width):
<mspace/>
→ \, (thin space)
"""
# Mapping from MathML spacing names to LaTeX commands
SPACING_MAP = {
# Named mathematical spaces
"thinmathspace": "\\,",
"mediummathspace": "\\:",
"thickmathspace": "\\;",
"verythinmathspace": "\\!",
"veryverythinmathspace": "\\!",
# Negative spaces (for fine-tuning)
"negativethinmathspace": "\\!",
"negativemediummathspace": "\\!",
"negativethickmathspace": "\\!",
# Explicit em-based sizes
"0em": "", # No space
"0.1em": "\\,",
"0.2em": "\\:",
"0.3em": "\\;",
"0.5em": "\\;",
"1em": "\\quad",
"2em": "\\qquad",
# Alternative common values
"thin": "\\,",
"medium": "\\:",
"thick": "\\;",
}
def __init__(self, strategy: Strategy):
"""Initialize the Mspace visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <mspace> element to LaTeX spacing.
Args:
node: The <mspace> element to convert
Returns:
LaTeX spacing command (e.g., \,, \:, \;, \quad, \qquad)
Raises:
ValueError: If node is not an <mspace> element
"""
if not self.can_handle(node):
raise ValueError(f"MspaceVisitor cannot handle element '{node.tag}'")
# Get width attribute
width = node.get("width", "")
# Handle empty width (default to thin space)
if not width:
return "\\,"
# Check if width is in our mapping
if width in self.SPACING_MAP:
return self.SPACING_MAP[width]
# Try to parse numeric width (e.g., "0.5em", "1.5em")
spacing_cmd = self._parse_numeric_width(width)
if spacing_cmd:
return spacing_cmd
# Default: thin space
return "\\,"
def _parse_numeric_width(self, width: str) -> str:
"""
Parse numeric width values and convert to LaTeX spacing.
Args:
width: Width string (e.g., "0.5em", "1.5em")
Returns:
LaTeX spacing command, or empty string if cannot parse
"""
# Remove whitespace
width = width.strip()
# Try to extract numeric value from em-based widths
if width.endswith("em"):
try:
value = float(width[:-2])
# Map numeric values to spacing commands
if value <= 0:
return ""
elif value < 0.15:
return "\\," # thin
elif value < 0.25:
return "\\:" # medium
elif value < 0.6:
return "\\;" # thick
elif value < 1.5:
return "\\quad"
else:
return "\\qquad"
except ValueError:
pass
# Try to extract numeric value from pt-based widths
if width.endswith("pt"):
try:
value = float(width[:-2])
# Map pt values to spacing (rough approximation)
# 1em ≈ 10pt in typical math fonts
if value <= 0:
return ""
elif value < 2:
return "\\,"
elif value < 4:
return "\\:"
elif value < 6:
return "\\;"
elif value < 12:
return "\\quad"
else:
return "\\qquad"
except ValueError:
pass
# Could not parse
return ""
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mspace'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mspace'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MspaceVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,194 @@
r"""
Visitor for <msqrt> (square root) MathML elements.
The <msqrt> element represents square roots (√).
The LaTeX conversion uses \sqrt{...} notation.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import text_content as _text_content, local_name as _local
class MsqrtVisitor(Visitor):
r"""
Handles <msqrt> (square root) MathML elements.
Converts square root expressions to LaTeX \sqrt notation.
Examples:
Simple number:
<msqrt><mn>2</mn></msqrt> → \sqrt{2}
Variable:
<msqrt><mi>x</mi></msqrt> → \sqrt{x}
Complex expression:
<msqrt>
<mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow>
</msqrt>
→ \sqrt{x + 1}
Nested root:
<msqrt><msqrt><mi>x</mi></msqrt></msqrt> → \sqrt{\sqrt{x}}
Fraction under root:
<msqrt><mfrac><mn>1</mn><mn>2</mn></mfrac></msqrt> → \sqrt{\dfrac{1}{2}}
"""
def __init__(self, strategy: Strategy):
"""Initialize the Msqrt visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <msqrt> element to LaTeX.
Args:
node: The <msqrt> element to convert
Returns:
LaTeX square root string (\sqrt{content})
Raises:
ValueError: If node is not an <msqrt> element
"""
if not self.can_handle(node):
raise ValueError(f"MsqrtVisitor cannot handle element '{node.tag}'")
children = list(node)
# Empty msqrt (edge case)
if not children:
return "\\sqrt{}"
# Convert all children and join with spaces
parts = []
for child in children:
part = self._convert_child(child)
if part: # Only add non-empty parts
parts.append(part)
radicand = ' '.join(parts).strip()
# Return \sqrt{radicand}
return f"\\sqrt{self._brace(radicand)}"
def _convert_child(self, child: ET.Element) -> str:
"""
Convert a child element to LaTeX.
This method instantiates the appropriate visitor for each child type.
"""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
# Recursive: msqrt can contain other msqrt elements
return self.visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in msqrt, using text fallback", file=sys.stderr)
return _text_content(child)
def _brace(self, content: str) -> str:
"""Wrap content in braces."""
return f"{{{content}}}"
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'msqrt'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'msqrt'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MsqrtVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,301 @@
r"""
Visitor for <mstyle> (style attributes) MathML elements.
The <mstyle> element is a wrapper that applies style attributes to its children:
- mathvariant: Font variants (bold, italic, script, fraktur, etc.)
- mathcolor: Text color
- displaystyle: Display vs inline math mode
This visitor is a transparent container that wraps converted children with
appropriate LaTeX style commands.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MstyleVisitor(Visitor):
r"""
Handles <mstyle> (style attributes) MathML elements.
Applies style attributes to child content through LaTeX wrappers.
Examples:
Bold text:
<mstyle mathvariant="bold"><mi>x</mi></mstyle>
→ \mathbf{x}
Colored text:
<mstyle mathcolor="red"><mi>f</mi></mstyle>
→ \textcolor{red}{f}
Script font:
<mstyle mathvariant="script"><mi>L</mi></mstyle>
→ \mathscr{L}
Fraktur font:
<mstyle mathvariant="fraktur"><mi>g</mi></mstyle>
→ \mathfrak{g}
Double-struck (blackboard bold):
<mstyle mathvariant="double-struck"><mi>R</mi></mstyle>
→ \mathbb{R}
Normal (upright) text:
<mstyle mathvariant="normal"><mi>x</mi></mstyle>
→ \mathrm{x}
Display style (affects fractions):
<mstyle displaystyle="true"><mfrac>...</mfrac></mstyle>
→ (context passed to children)
"""
def __init__(self, strategy: Strategy):
"""Initialize the Mstyle visitor."""
super().__init__(strategy)
self._converter_context = None
def set_converter_context(self, converter):
"""Set converter context for color suppression checking."""
self._converter_context = converter
def visit(self, node: ET.Element) -> str:
r"""
Convert <mstyle> element to LaTeX.
Args:
node: The <mstyle> element to convert
Returns:
LaTeX string with style wrappers applied to children
Raises:
ValueError: If node is not an <mstyle> element
"""
if not self.can_handle(node):
raise ValueError(f"MstyleVisitor cannot handle element '{node.tag}'")
# Get style attributes
mathvariant = node.get("mathvariant", "")
displaystyle = node.get("displaystyle", "")
mathcolor = node.get("mathcolor", "")
# Convert all children
content = self._convert_children(node)
# Apply font variants
content = self._apply_mathvariant(content, mathvariant)
# Apply color (if not black, which is default)
content = self._apply_color(content, mathcolor)
# Display style is contextual and doesn't produce direct LaTeX output
# It would be handled by context/strategy if needed
# (In the original implementation, it affects fraction rendering)
return content
def _convert_children(self, node: ET.Element) -> str:
"""
Convert all child elements to LaTeX.
Args:
node: The <mstyle> element
Returns:
LaTeX string with all children converted
"""
parts = []
for child in node:
parts.append(self._convert_child(child))
return ' '.join(parts).strip()
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in mstyle, using text fallback", file=sys.stderr)
return _text_content(child)
def _apply_mathvariant(self, content: str, mathvariant: str) -> str:
"""
Apply mathvariant font styling to content.
Args:
content: The LaTeX content to wrap
mathvariant: The mathvariant attribute value
Returns:
LaTeX content with font variant wrapper
"""
if not mathvariant or not content:
return content
# Map mathvariant values to LaTeX commands
variant_map = {
"bold": "\\mathbf",
"bold-italic": "\\boldsymbol", # Bold italic
"italic": None, # Italic is default in math mode
"normal": "\\mathrm", # Upright/roman text
"script": "\\mathscr",
"fraktur": "\\mathfrak",
"double-struck": "\\mathbb", # Blackboard bold
"monospace": "\\mathtt",
"sans-serif": "\\mathsf",
}
latex_cmd = variant_map.get(mathvariant)
if latex_cmd:
return f"{latex_cmd}{{{content}}}"
else:
# Italic (default) or unknown variant - no wrapper needed
return content
def _apply_color(self, content: str, mathcolor: str) -> str:
"""
Apply color styling to content.
Args:
content: The LaTeX content to wrap
mathcolor: The mathcolor attribute value
Returns:
LaTeX content with color wrapper
"""
# Use centralized color handling with RGB support and suppression
from ..utils.color_helpers import hex_to_latex_color
from ..utils import is_default_color
if not mathcolor or not content:
return content
# Check if suppression is active (uniform color optimization)
# Get context from visitor or strategy (strategy is shared across all visitors)
context = getattr(self, '_converter_context', None)
if context is None and hasattr(self.strategy, '_converter_context'):
context = self.strategy._converter_context
if context and getattr(context, '_suppress_leaf_colors', False):
return content
# Skip default black color
if is_default_color(mathcolor):
return content
# Convert hex color to LaTeX format (with RGB mode support)
color_model, color_spec = hex_to_latex_color(mathcolor)
# Skip if conversion resulted in black
if color_spec == 'black':
return content
# Wrap with appropriate format
if color_model:
return f"\\textcolor{color_model}{{{color_spec}}}{{{content}}}"
else:
return f"\\textcolor{{{color_spec}}}{{{content}}}"
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mstyle'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mstyle'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MstyleVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,244 @@
"""Visitor for <msub> (subscript) MathML elements."""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..core.context import ConversionContext
from ..utils import text_content as _text_content, local_name as _local
class MsubVisitor(Visitor):
"""Handles <msub> (subscript) MathML elements.
Examples:
Simple: <msub><mi>x</mi><mn>1</mn></msub> → x_{1}
Index: <msub><mi>a</mi><mi>i</mi></msub> → a_{i}
"""
def __init__(self, strategy: Strategy, context: ConversionContext = None):
"""
Initialize the Msub visitor.
Args:
strategy: The formatting strategy to use
context: Shared conversion context for state tracking
"""
super().__init__(strategy)
self.context = context
def visit(self, node: ET.Element) -> str:
if not self.can_handle(node):
raise ValueError(f"MsubVisitor cannot handle element '{node.tag}'")
children = list(node)
if len(children) < 2:
raise ValueError(f"msub element must have 2 children, got {len(children)}")
# Convert base (not in script context)
base = self._convert_child(children[0])
# Push script context before converting subscript
if self.context:
self.context.push_script_context('msub')
try:
# Convert subscript (in script context)
subscript = self._convert_child(children[1])
finally:
# Always pop context, even if conversion fails
if self.context:
self.context.pop_script_context()
# Special case: Empty subscript - just return the base
if not subscript or not subscript.strip():
base = self._wrap_if_needed(base)
return base
base = self._wrap_if_needed(base)
# Special case: If base contains a superscript (like f^{\prime}),
# wrap it in braces to prevent syntax errors
# Example: f^{\prime} should become {f^{\prime}}_{x}, not f^{\prime}_{x}
if self._contains_superscript(base):
base = f"{{{base}}}"
return f"{base}_{self._brace(subscript)}"
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy, self.context).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy, self.context).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
# Recursive: msub can contain other msub elements
return self.visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy, self.context).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in msub, using text fallback", file=sys.stderr)
return _text_content(child)
def _wrap_if_needed(self, base: str) -> str:
"""Wrap base in parentheses if needed, avoiding double-wrapping."""
# Check if base already has delimiters (starts and ends with matching delimiters)
base_stripped = base.strip()
if base_stripped:
# Check for matching delimiters
delimiter_pairs = {
'(': ')',
'[': ']',
'{': '}',
'\\left(': '\\right)',
'\\left[': '\\right]',
'\\left\\{': '\\right\\}',
}
for open_delim, close_delim in delimiter_pairs.items():
if base_stripped.startswith(open_delim) and base_stripped.endswith(close_delim):
# Already has delimiters, no need to wrap
return base
# Check if base needs grouping for proper subscript application
# In LaTeX, x_2 applies subscript to x, but "a + b_2" applies only to b
# We need to brace multi-element bases: {a + b}_2
#
# Use braces {} for grouping (invisible) not parentheses () (visible)
# Only wrap if base contains operators that indicate a true multi-element expression
# Don't wrap for spaces alone (e.g., "\Delta H" has space but is semantically single)
math_operators = {'=', '+', '-', '/', '*', '\\times', '\\cdot', '\\pm', '\\to'}
if ' ' in base and any(op in base for op in math_operators):
return f"{{{base}}}"
return base
def _brace(self, content: str) -> str:
return f"{{{content}}}"
def _contains_superscript(self, base: str) -> bool:
"""
Check if base contains a LaTeX superscript (^{...} or ^X).
This is important for proper grouping when adding subscripts.
For example: f^{\\prime} must be wrapped as {f^{\\prime}} before
adding subscripts to avoid invalid syntax.
Args:
base: The base LaTeX string
Returns:
True if base contains an unbraced superscript
"""
if not base or '^' not in base:
return False
# If the entire base is already wrapped in braces, it's safe
base_stripped = base.strip()
if base_stripped.startswith('{') and base_stripped.endswith('}'):
# Check if these braces wrap the entire expression
depth = 0
for i, char in enumerate(base_stripped):
if char == '{':
depth += 1
elif char == '}':
depth -= 1
# If depth hits 0 before the end, the braces don't wrap everything
if depth == 0 and i < len(base_stripped) - 1:
break
# If we maintained depth > 0 until the last character, it's wrapped
if i == len(base_stripped) - 1:
return False
# Base contains ^ and is not fully wrapped
return True
def can_handle(self, node: ET.Element) -> bool:
return _local(node.tag) == 'msub'
@property
def element_name(self) -> str:
return 'msub'
def __repr__(self) -> str:
return f"MsubVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,449 @@
r"""
Visitor for <msubsup> (subscript + superscript) MathML elements.
The <msubsup> element represents simultaneous subscripts and superscripts.
The LaTeX conversion uses base_{subscript}^{superscript} notation.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..core.context import ConversionContext
from ..utils import text_content as _text_content, local_name as _local
class MsubsupVisitor(Visitor):
r"""
Handles <msubsup> (subscript + superscript) MathML elements.
Converts elements with both subscript and superscript to LaTeX notation.
Examples:
Tensor with indices:
<msubsup><mi>T</mi><mi>i</mi><mi>j</mi></msubsup> → T_{i}^{j}
Integral bounds:
<msubsup><mo>∫</mo><mn>0</mn><mn>1</mn></msubsup> → \int_{0}^{1}
Summation bounds:
<msubsup><mo>∑</mo><mrow><mi>i</mi><mo>=</mo><mn>1</mn></mrow><mi>n</mi></msubsup>
→ \sum_{i = 1}^{n}
Variable with both scripts:
<msubsup><mi>x</mi><mn>0</mn><mn>2</mn></msubsup> → x_{0}^{2}
Complex base:
<msubsup>
<mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow>
<mn>0</mn>
<mn>2</mn>
</msubsup>
→ (x + 1)_{0}^{2}
"""
def __init__(self, strategy: Strategy, context: ConversionContext = None):
"""
Initialize the Msubsup visitor.
Args:
strategy: The formatting strategy to use
context: Shared conversion context for state tracking
"""
super().__init__(strategy)
self.context = context
def visit(self, node: ET.Element) -> str:
r"""
Convert <msubsup> element to LaTeX.
Args:
node: The <msubsup> element to convert
Returns:
LaTeX string with subscript and superscript (base_{sub}^{sup})
Raises:
ValueError: If node is not an <msubsup> element
ValueError: If msubsup has fewer than 3 children
"""
if not self.can_handle(node):
raise ValueError(f"MsubsupVisitor cannot handle element '{node.tag}'")
children = list(node)
# msubsup must have exactly 3 children: base, subscript, superscript
if len(children) < 3:
raise ValueError(
f"msubsup element must have 3 children (base, subscript, superscript), "
f"got {len(children)}"
)
# Check for sideset pattern: base is <mrow> with 3 <mrow> children
# Pattern: <msubsup><mrow><mrow>left_sub</mrow><mrow>left_sup</mrow><mrow>base</mrow></mrow><mrow>right_sub</mrow><mrow>right_sup</mrow></msubsup>
# Result: \sideset{_{left_sub}^{left_sup}}{_{right_sub}^{right_sup}}{base}
if self._is_sideset_pattern(children[0]):
return self._handle_sideset(children)
# Check if base is a large operator BEFORE converting
is_large_op = self._is_large_operator(children[0])
# Convert base (not in script context)
base = self._convert_child(children[0])
# Push script context before converting subscript and superscript
if self.context:
self.context.push_script_context('msubsup')
try:
# Convert subscript and superscript (both in script context)
subscript = self._convert_child(children[1])
superscript = self._convert_child(children[2])
finally:
# Always pop context, even if conversion fails
if self.context:
self.context.pop_script_context()
# Check for large operators that need \limits
if is_large_op:
return f"{base}\\limits_{self._brace(subscript)}^{self._brace(superscript)}"
# If base is empty (from empty <mrow/>), output just the sub/superscripts
# This allows them to attach to the previous element in LaTeX naturally
if not base or not base.strip():
return f"_{self._brace(subscript)}^{self._brace(superscript)}"
# Wrap base if needed (complex expressions or if it contains superscripts)
base = self._wrap_if_needed(base)
# Special case: If base contains a superscript (like v^{\prime}),
# wrap it in braces to prevent syntax error with additional sub/superscripts
# Example: v^{\prime} should become {v^{\prime}}_{1}^{2}, not v^{\prime}_{1}^{2}
if self._contains_superscript(base):
base = f"{{{base}}}"
# Return base_{subscript}^{superscript}
return f"{base}_{self._brace(subscript)}^{self._brace(superscript)}"
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy, self.context).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy, self.context).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy, self.context).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in msubsup, using text fallback", file=sys.stderr)
return _text_content(child)
def _wrap_if_needed(self, base: str) -> str:
"""
Wrap base in parentheses if needed.
Args:
base: The base content
Returns:
Base, optionally wrapped in parentheses
Note:
Complex expressions with operators should be wrapped.
Simple identifiers and numbers don't need wrapping.
"""
# Check if base already has delimiters (starts and ends with matching delimiters)
base_stripped = base.strip()
if base_stripped:
# Check for matching delimiters
delimiter_pairs = {
'(': ')',
'[': ']',
'{': '}',
'\\left(': '\\right)',
'\\left[': '\\right]',
'\\left\\{': '\\right\\}',
}
for open_delim, close_delim in delimiter_pairs.items():
if base_stripped.startswith(open_delim) and base_stripped.endswith(close_delim):
# Already has delimiters, no need to wrap
return base
# Check if base needs grouping for proper subscript/superscript application
# In LaTeX, x_2 applies subscript to x, but "a + b_2" applies only to b
# We need to brace multi-element bases: {a + b}_2
#
# Use braces {} for grouping (invisible) not parentheses () (visible)
# Only wrap if base contains operators that indicate a true multi-element expression
math_operators = {'=', '+', '-', '/', '*', '\\times', '\\cdot', '\\pm', '\\to'}
if ' ' in base and any(op in base for op in math_operators):
return f"{{{base}}}"
return base
def _brace(self, content: str) -> str:
"""Wrap content in braces."""
return f"{{{content}}}"
def _contains_superscript(self, base: str) -> bool:
"""
Check if base contains a LaTeX superscript (^{...} or ^X).
This is important for proper grouping when adding additional scripts.
For example: v^{\\prime} must be wrapped as {v^{\\prime}} before
adding subscripts/superscripts to avoid invalid syntax.
Args:
base: The base LaTeX string
Returns:
True if base contains an unbraced superscript
"""
# Look for ^ character in the base
# We need to be careful to not match ^ that's already inside braces at the start
# For example: {v^{\prime}} should return False (already wrapped)
# but v^{\prime} should return True (needs wrapping)
if not base or '^' not in base:
return False
# If the entire base is already wrapped in braces, it's safe
base_stripped = base.strip()
if base_stripped.startswith('{') and base_stripped.endswith('}'):
# Check if these braces wrap the entire expression
# Simple heuristic: count brace depth
depth = 0
for i, char in enumerate(base_stripped):
if char == '{':
depth += 1
elif char == '}':
depth -= 1
# If depth hits 0 before the end, the braces don't wrap everything
if depth == 0 and i < len(base_stripped) - 1:
break
# If we maintained depth > 0 until the last character, it's wrapped
if i == len(base_stripped) - 1:
return False
# Base contains ^ and is not fully wrapped
return True
def _is_large_operator(self, base_node: ET.Element) -> bool:
"""Check if base is a large operator that uses limits by default."""
if _local(base_node.tag) != 'mo':
return False
base_text = _text_content(base_node).strip()
# Large operators that display limits above/below in display mode
large_ops = [
"∑", "\\sum", # Summation
"∏", "\\prod", # Product
"∐", "\\coprod", # Coproduct
"∫", "\\int", # Integral
"∬", "\\iint", # Double integral
"∭", "\\iiint", # Triple integral
"∮", "\\oint", # Contour integral
"⋃", "\\bigcup", # Union
"⋂", "\\bigcap", # Intersection
"⋁", "\\bigvee", # Logical OR
"⋀", "\\bigwedge", # Logical AND
"⨁", "\\bigoplus", # Direct sum
"⨂", "\\bigotimes", # Tensor product
"⨄", "\\biguplus", # Multiset union
]
return base_text in large_ops
def _is_sideset_pattern(self, base_node: ET.Element) -> bool:
"""
Check if base node matches the sideset pattern.
Sideset pattern: base is <mrow> with at least 3 children where first 3 are <mrow>
- First <mrow>: left subscript
- Second <mrow>: left superscript
- Third <mrow>: base symbol (first part)
- Additional children: merged with base (e.g., FeMn has Fe in mrow, Mn as mi)
Args:
base_node: The base element to check
Returns:
True if this is a sideset pattern, False otherwise
"""
if _local(base_node.tag) != 'mrow':
return False
# Get direct children of the base <mrow>
children = list(base_node)
# Must have at least 3 children
if len(children) < 3:
return False
# First 3 children must all be <mrow> elements
return all(_local(children[i].tag) == 'mrow' for i in range(3))
def _handle_sideset(self, msubsup_children: list) -> str:
"""
Handle sideset pattern conversion.
Pattern:
<msubsup>
<mrow>
<mrow>left_sub</mrow>
<mrow>left_sup</mrow>
<mrow>base_part1</mrow>
[optional: additional base elements...]
</mrow>
<mrow>right_sub</mrow>
<mrow>right_sup</mrow>
</msubsup>
Converts to: \\sideset{_{left_sub}^{left_sup}}{_{right_sub}^{right_sup}}{base}
Args:
msubsup_children: The 3 children of <msubsup> element
Returns:
LaTeX sideset command
"""
base_mrow = msubsup_children[0]
right_sub_mrow = msubsup_children[1]
right_sup_mrow = msubsup_children[2]
# Extract children from base <mrow>
base_children = list(base_mrow)
left_sub_mrow = base_children[0]
left_sup_mrow = base_children[1]
actual_base_mrow = base_children[2]
# Additional children after the 3rd element should be merged with base
# Example: <mrow>Fe</mrow><mi>Mn</mi> → FeMn
extra_base_elements = base_children[3:] if len(base_children) > 3 else []
# Push script context for converting subscripts and superscripts
if self.context:
self.context.push_script_context('sideset')
try:
# Convert all parts
left_sub = self._convert_child(left_sub_mrow)
left_sup = self._convert_child(left_sup_mrow)
right_sub = self._convert_child(right_sub_mrow)
right_sup = self._convert_child(right_sup_mrow)
finally:
if self.context:
self.context.pop_script_context()
# Convert base (not in script context)
base_parts = [self._convert_child(actual_base_mrow)]
# Add any extra base elements (e.g., Mn after Fe in FeMn)
for extra_elem in extra_base_elements:
base_parts.append(self._convert_child(extra_elem))
# Merge base parts (no spaces for chemical formulas)
base = ''.join(base_parts)
# Build sideset command
left_scripts = f"_{{{left_sub}}}^{{{left_sup}}}"
right_scripts = f"_{{{right_sub}}}^{{{right_sup}}}"
return f"\\sideset{{{left_scripts}}}{{{right_scripts}}}{{{base}}}"
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'msubsup'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'msubsup'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MsubsupVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,469 @@
"""
Visitor for <msup> (superscript) MathML elements.
The <msup> element represents superscripts in MathML:
- Powers: x^2, x^n
- Exponents: e^x, 10^{-3}
- Indices: a^i, T^{ij}
- Prime notation: f', f'', f''' (derivatives)
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..core.context import ConversionContext
from ..utils import text_content as _text_content, local_name as _local
class MsupVisitor(Visitor):
"""
Handles <msup> (superscript) MathML elements.
This visitor converts superscripts to LaTeX format using ^ notation.
Special handling for prime notation (derivatives).
Examples:
Simple power:
<msup><mi>x</mi><mn>2</mn></msup> → x^{2}
Exponent:
<msup><mi>e</mi><mi>x</mi></msup> → e^{x}
Complex base:
<msup>
<mrow><mi>x</mi><mo>+</mo><mn>1</mn></mrow>
<mn>2</mn>
</msup>
→ (x + 1)^{2}
Complex exponent:
<msup><mi>x</mi><mrow><mn>2</mn><mi>n</mi></mrow></msup>
→ x^{2 n}
Prime notation (derivatives):
<msup><mi>f</mi><mo>&#x2032;</mo></msup> → f'
<msup><mi>f</mi><mo>&#x2033;</mo></msup> → f''
<msup><mi>y</mi><mo>&#x2034;</mo></msup> → y'''
"""
def __init__(self, strategy: Strategy, context: ConversionContext = None):
"""
Initialize the Msup visitor.
Args:
strategy: The formatting strategy to use
context: Shared conversion context for state tracking
"""
super().__init__(strategy)
self.context = context
def visit(self, node: ET.Element) -> str:
"""
Convert <msup> element to LaTeX.
Args:
node: The <msup> element to convert
Returns:
LaTeX superscript string
Raises:
ValueError: If node is not an <msup> element
ValueError: If msup has fewer than 2 children
"""
if not self.can_handle(node):
raise ValueError(f"MsupVisitor cannot handle element '{node.tag}'")
children = list(node)
if len(children) < 2:
raise ValueError(
f"msup element must have 2 children (base, superscript), "
f"got {len(children)}"
)
# NEW: Check for nested msup and merge if needed
# Pattern: <msup><mrow><msup>base<sup1></msup></mrow><sup2></msup>
# Should become: base^{sup1sup2} instead of base^{sup1}^{sup2}
nested_msup = self._detect_nested_msup(children[0])
if nested_msup is not None:
result = self._merge_nested_superscripts(nested_msup, children[1])
if result:
base_latex, merged_sup_latex = result
base_latex = self._wrap_if_needed(base_latex)
return f"{base_latex}^{self._brace(merged_sup_latex)}"
# Convert base (not in script context)
base = self._convert_child(children[0])
# Push script context before converting superscript
if self.context:
self.context.push_script_context('msup')
try:
# Convert superscript (in script context)
superscript = self._convert_child(children[1])
finally:
# Always pop context, even if conversion fails
if self.context:
self.context.pop_script_context()
# Special case: Empty superscript - just return the base
# This can happen when OMML has <m:sSup> with an empty <m:sup>
if not superscript or not superscript.strip():
base = self._wrap_if_needed(base)
return base
# Special case: Prime notation (\prime → ^{\prime}, \prime\prime → ^{\prime\prime}, etc.)
# If superscript contains only \prime symbols, convert to proper LaTeX prime syntax
if self._is_prime_notation(superscript):
# Wrap base if needed (complex expressions)
base = self._wrap_if_needed(base)
# Remove spaces from prime notation
clean_primes = superscript.replace(' ', '').replace('\xa0', '')
# Convert to proper superscript form: ^{\prime}, ^{\prime\prime}, etc.
return f"{base}^{{{clean_primes}}}"
# Special case: cancelto pattern (menclose with updiagonalstrike + superscript)
# Pattern: <msup><menclose notation="updiagonalstrike">content</menclose><sup>target</sup></msup>
# Should become: \cancelto{target}{content} instead of \cancel{content}^{target}
if self._is_cancel_with_target(children[0], base):
# Extract content from \cancel{content}
content = self._extract_cancel_content(base)
if content:
return f"\\cancelto{self._brace(superscript)}{self._brace(content)}"
# TODO: More special cases
# - Empty base (just return ^{sup})
# - Omit ^{1} for simple variables
# - Large operators with \limits
# Wrap base if needed (complex expressions)
base = self._wrap_if_needed(base)
# Return base^{superscript}
return f"{base}^{self._brace(superscript)}"
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy, self.context).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy, self.context).visit(child)
elif tag == 'msup':
# Recursive: msup can contain other msup elements
return self.visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy, self.context).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in msup, using text fallback", file=sys.stderr)
return _text_content(child)
def _wrap_if_needed(self, base: str) -> str:
"""
Wrap base in parentheses if needed.
Args:
base: The base content
Returns:
Base, optionally wrapped in parentheses
Note:
Complex expressions with operators should be wrapped.
Simple identifiers and numbers don't need wrapping.
"""
# Check if base already has delimiters (starts and ends with matching delimiters)
base_stripped = base.strip()
if base_stripped:
# Check for __DELIM__ markers (used by delimiter pairing processor)
# Pattern: __DELIM__X__DELIM__ ... __DELIM__Y__DELIM__
# These will be converted to \left...\right by post-processor
if base_stripped.startswith('__DELIM__') and '__DELIM__' in base_stripped[9:]:
# Already has delimiter markers, no need to wrap
return base
# Check for matching delimiters
delimiter_pairs = {
'(': ')',
'[': ']',
'{': '}',
'\\left(': '\\right)',
'\\left[': '\\right]',
'\\left\\{': '\\right\\}',
}
for open_delim, close_delim in delimiter_pairs.items():
if base_stripped.startswith(open_delim) and base_stripped.endswith(close_delim):
# Already has delimiters, no need to wrap
return base
# Check if base needs grouping for proper superscript application
# In LaTeX, x^2 applies superscript to x, but "a + b^2" applies only to b
# We need to brace multi-element bases: {a + b}^2
#
# Use braces {} for grouping (invisible) not parentheses () (visible)
# Only wrap if base contains operators that indicate a true multi-element expression
math_operators = {'=', '+', '-', '/', '*', '\\times', '\\cdot', '\\pm', '\\to'}
if ' ' in base and any(op in base for op in math_operators):
return f"{{{base}}}"
return base
def _brace(self, content: str) -> str:
"""Wrap content in braces."""
return f"{{{content}}}"
def _is_prime_notation(self, superscript: str) -> bool:
"""
Check if superscript is prime notation (only \\prime symbols).
Prime notation in LaTeX:
- x^{\\prime} (single prime)
- x^{\\prime\\prime} (double prime)
- x^{\\prime\\prime\\prime} (triple prime)
- x^{\\prime\\prime\\prime\\prime} (quadruple prime)
Args:
superscript: The converted superscript LaTeX string
Returns:
True if superscript contains only \\prime symbols
Note:
Handles spaced primes from mrow conversion (e.g., "\\prime \\prime")
which should become "\\prime\\prime"
"""
# Remove ALL whitespace
no_spaces = superscript.replace(' ', '').replace('\xa0', '')
if not no_spaces:
return False
# Check if content is only \prime symbols (possibly repeated)
# Valid patterns: \prime, \prime\prime, \prime\prime\prime, etc.
# The string should be exactly \prime repeated N times
if not no_spaces.startswith('\\prime'):
return False
# Remove all \prime occurrences and check if anything remains
test = no_spaces.replace('\\prime', '')
return len(test) == 0
def _is_cancel_with_target(self, base_node: ET.Element, base_latex: str) -> bool:
"""
Check if base is a cancel/strike-through that should use cancelto.
Args:
base_node: The base element (first child of msup)
base_latex: The converted LaTeX for the base
Returns:
True if this is a cancel pattern that should use \\cancelto
"""
# Check if base_node is menclose with updiagonalstrike notation
if _local(base_node.tag) == 'menclose':
notation = base_node.get('notation', '')
return 'updiagonalstrike' in notation
# Check if base_node is mrow containing a single menclose
if _local(base_node.tag) == 'mrow':
children = list(base_node)
# If mrow contains exactly one child and it's a menclose
if len(children) == 1 and _local(children[0].tag) == 'menclose':
notation = children[0].get('notation', '')
return 'updiagonalstrike' in notation
return False
def _extract_cancel_content(self, base_latex: str) -> str:
"""
Extract content from \\cancel{content}, \\xcancel{content}, etc.
Args:
base_latex: LaTeX string containing cancel command
Returns:
The content inside the cancel braces, or empty string if not a cancel pattern
Examples:
\\cancel{x} → x
\\xcancel{abc} → abc
\\bcancel{123} → 123
"""
import re
# Pattern: \cancel{content}, \xcancel{content}, \bcancel{content}
match = re.match(r'\\(?:x|b)?cancel\{(.+?)\}$', base_latex)
if match:
return match.group(1)
return ''
def _detect_nested_msup(self, base_node: ET.Element):
"""
Detect if base contains a nested msup element.
Handles two patterns:
1. Direct nested: <msup><msup>...</msup>...</msup>
2. Wrapped in mrow: <msup><mrow><msup>...</msup></mrow>...</msup>
Args:
base_node: The base element (first child of outer msup)
Returns:
The nested msup element if found, None otherwise
"""
# Check direct nested msup
if _local(base_node.tag) == 'msup':
return base_node
# Check msup wrapped in mrow (most common pattern)
if _local(base_node.tag) == 'mrow':
children = list(base_node)
# If mrow contains exactly one child and it's an msup
if len(children) == 1 and _local(children[0].tag) == 'msup':
return children[0]
return None
def _merge_nested_superscripts(self, inner_msup: ET.Element, outer_sup_node: ET.Element):
"""
Merge nested msup with outer superscript.
This handles the pattern:
<msup>
<msup><base><sup1></msup>
<sup2>
</msup>
Which should become: base^{sup1sup2} instead of base^{sup1}^{sup2}
Args:
inner_msup: The nested <msup> element
outer_sup_node: The outer superscript element
Returns:
Tuple of (base_latex, merged_superscript_latex) or None if invalid
Examples:
<msup><msup><mi>n</mi><mn>2</mn></msup><mo>+</mo></msup>
→ ("\\mathrm{n}", "2+")
"""
# Extract inner msup children
inner_children = list(inner_msup)
if len(inner_children) < 2:
return None # Invalid structure
inner_base = inner_children[0]
inner_sup = inner_children[1]
# Convert base (not in script context)
base_latex = self._convert_child(inner_base)
# Convert both superscripts in script context
if self.context:
self.context.push_script_context('msup')
try:
inner_sup_latex = self._convert_child(inner_sup)
outer_sup_latex = self._convert_child(outer_sup_node)
finally:
if self.context:
self.context.pop_script_context()
# Merge superscripts: {inner}{outer}
# Example: inner="2", outer="+" → "2+"
merged_sup = f"{inner_sup_latex}{outer_sup_latex}"
return (base_latex, merged_sup)
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'msup'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'msup'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MsupVisitor(strategy={self.strategy.name})"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,182 @@
r"""
Visitor for <mtd> (table cell) MathML elements.
The <mtd> element represents a cell in a table/matrix row. It is a helper
element that contains the actual mathematical content.
This visitor is primarily used by MtrVisitor to convert table cells.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local, text_content as _text_content
class MtdVisitor(Visitor):
r"""
Handles <mtd> (table cell) MathML elements.
Converts table cells to LaTeX format by rendering all child content.
Examples:
Simple number:
<mtd><mn>5</mn></mtd>
→ 5
Variable:
<mtd><mi>x</mi></mtd>
→ x
Expression:
<mtd><mi>x</mi><mo>+</mo><mn>1</mn></mtd>
→ x+1
Fraction:
<mtd><mfrac><mn>1</mn><mn>2</mn></mfrac></mtd>
→ \frac{1}{2}
Empty cell:
<mtd></mtd>
→ (empty string)
"""
def __init__(self, strategy: Strategy):
"""Initialize the Mtd visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <mtd> element to LaTeX.
Args:
node: The <mtd> element to convert
Returns:
LaTeX string for cell content
Raises:
ValueError: If node is not an <mtd> element
"""
if not self.can_handle(node):
raise ValueError(f"MtdVisitor cannot handle element '{node.tag}'")
# Convert all children of the cell
# Cells are transparent containers, so just render children
# Join with spaces (like mrow) to ensure proper spacing between elements
parts = []
for child in node:
part = self._convert_child(child)
if part: # Only add non-empty parts
parts.append(part)
return ' '.join(parts).strip()
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in mtd, using text fallback", file=sys.stderr)
return _text_content(child)
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mtd'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mtd'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MtdVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,327 @@
r"""
Visitor for <mtext> (text content) MathML elements.
The <mtext> element represents text content within mathematical expressions:
- Descriptive text: "and", "or", "if", "then"
- Unit labels: "kg", "mol", "Hz", "Pa", "m/s"
- Chinese/Unicode text: "电解", "加热", "催化剂"
- Words in equations: "min", "max", "for all"
This visitor wraps text in \text{} or \mathrm{} depending on context.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import text_content as _text_content, local_name as _local, escape_latex_special_chars
from ..data import UNICODE_TO_LATEX, UNICODE_PASSTHROUGH
class MtextVisitor(Visitor):
r"""
Handles <mtext> (text content) MathML elements.
Converts text content to LaTeX using appropriate text commands.
Examples:
Simple text:
<mtext>and</mtext> → \text{and}
Units:
<mtext>kg</mtext> → \text{kg}
Chinese text:
<mtext>电解</mtext> → \text{电解}
Multi-word text:
<mtext>for all</mtext> → \text{for all}
Empty text:
<mtext></mtext> → (empty string)
Text with spaces:
<mtext> spaced </mtext> → \text{ spaced }
"""
# Common SI units (should use \mathrm for units in some contexts)
SI_UNITS = {
# Base units
'm', 'kg', 'g', 's', 'A', 'K', 'mol', 'cd',
# Derived units
'Hz', 'N', 'Pa', 'J', 'W', 'C', 'V', 'F', 'Ω',
'S', 'Wb', 'T', 'H', 'lm', 'lx', 'Bq', 'Gy', 'Sv',
# Common combinations
'km', 'cm', 'mm', 'nm', 'pm',
'km/h', 'm/s', 'm/s²', 'cm³', 'm³',
'kJ', 'MJ', 'kW', 'MW',
'mA', 'kA', 'mV', 'kV',
# Other common units
'L', 'mL', 'atm', 'bar', 'mmHg',
'eV', 'kcal', 'cal',
'°C', '°F', 'K', # Temperature
}
# Common mathematical/logical text words
TEXT_WORDS = {
'and', 'or', 'not', 'if', 'then', 'else',
'for', 'all', 'such', 'that', 'where',
'with', 'when', 'otherwise',
'let', 'be', 'is', 'are',
}
def __init__(self, strategy: Strategy):
"""Initialize the Mtext visitor."""
super().__init__(strategy)
self._converter_context = None
def set_converter_context(self, converter):
"""Set converter context for color suppression checking."""
self._converter_context = converter
def visit(self, node: ET.Element) -> str:
r"""
Convert <mtext> element to LaTeX.
Args:
node: The <mtext> element to convert
Returns:
LaTeX string with text wrapped in \text{} or \mathrm{}
Raises:
ValueError: If node is not an <mtext> element
"""
if not self.can_handle(node):
raise ValueError(f"MtextVisitor cannot handle element '{node.tag}'")
# Get text content
text = _text_content(node)
# Handle empty text
if not text:
return ''
# Escape LaTeX special characters before any other processing
text = escape_latex_special_chars(text)
# Handle invisible function application operator (U+2061)
# This is used to indicate function application but should not be rendered
if text == "⁡": # U+2061 FUNCTION APPLICATION
return "" # Return empty string (invisible)
# CRITICAL: Check if text is a mathematical function BEFORE other processing
# This handles cases like <mtext>sin</mtext> → \sin (not \mathrm{sin})
from ..data import MATH_FUNCTIONS
text_stripped = text.strip()
if text_stripped.lower() in MATH_FUNCTIONS:
return f"\\{text_stripped.lower()}"
# IMPORTANT: Convert Unicode symbols to LaTeX BEFORE wrapping in \text{}
# But first check UNICODE_PASSTHROUGH - keep these as-is for MathJax compatibility
text_stripped = text.strip()
if len(text_stripped) == 1 and text_stripped in UNICODE_PASSTHROUGH:
# Single symbol that should be kept as Unicode
return text_stripped
if len(text_stripped) == 1 and text_stripped in UNICODE_TO_LATEX:
# Single symbol with direct LaTeX mapping - return LaTeX command directly
# (don't wrap in \text{})
return UNICODE_TO_LATEX[text_stripped]
# CRITICAL FIX: For multi-symbol Unicode strings, check if result after conversion
# is ALL LaTeX commands or passthrough symbols. If so, don't wrap in \text{}
#
# This handles cases like:
# <mtext>℧◊</mtext> → \mho\lozenge (not \text{\mho\lozenge})
# <mtext>∠∢⦞</mtext> → \measuredangle\sphericalangle (not \text{...})
# <mtext>∯∰</mtext> → ∯∰ (kept as Unicode for MathJax)
if len(text_stripped) > 1 and all(char in UNICODE_TO_LATEX or char in UNICODE_PASSTHROUGH for char in text_stripped):
# All characters are Unicode symbols
# Convert to LaTeX or keep as Unicode based on PASSTHROUGH set
result_parts = []
for char in text_stripped:
if char in UNICODE_PASSTHROUGH:
result_parts.append(char)
else:
result_parts.append(UNICODE_TO_LATEX[char])
return ''.join(result_parts) # Join without spaces for symbols
# Check for color attributes
from ..utils import is_default_color
color = node.get('mathcolor')
if color and not is_default_color(color):
# Apply color to text (after Unicode conversion if needed)
# Only add textcolor if it's not the default black color
text_converted = self._convert_unicode_in_text(text)
return f"\\textcolor{{{color}}}{{\\text{{{text_converted}}}}}"
# Determine whether to use \text{} or \mathrm{}
# CRITICAL: The default should be \mathrm{} to match wrap_identifiers_in_mathrm=True
# This ensures chemical formulas, units, and identifiers render upright (not italic)
# CRITICAL FIX: Check for operators in text (like "2NaCl+2H")
# If text contains operators, split and process separately
# This prevents wrapping operators in \mathrm{} and avoids nested \mathrm{}
if any(op in text for op in ['+', '-', '=', '<', '>', '×', '·', '/']):
return self._process_complex_mtext(text)
# Check if we should use \mathrm{} (default for most math/chemistry content)
# Use \text{} only for actual prose text (multi-word descriptions)
# If it contains spaces and looks like prose, use \text{}
if ' ' in text and any(word in text_stripped.lower() for word in self.TEXT_WORDS):
# Multi-word prose text
text_converted = self._convert_unicode_in_text(text)
return f"\\text{{{text_converted}}}"
# If it's Chinese text (common in chemistry for reaction conditions), use \mathrm{}
# Chinese text needs upright rendering for chemical equations
if self._contains_chinese(text):
text_converted = self._convert_unicode_in_text(text)
return f"\\mathrm{{{text_converted}}}"
# Default: use \mathrm{} for chemical formulas, units, and identifiers
# This matches the original converter's wrap_identifiers_in_mathrm=True behavior
text_converted = self._convert_unicode_in_text(text)
# CRITICAL FIX: If text_converted is ONLY LaTeX space commands (like ~, \ , \quad, etc.),
# return it directly WITHOUT wrapping in \mathrm{}
# This fixes the issue where <mtext>&#xA0;</mtext> was becoming \mathrm{~} instead of ~
stripped = text_converted.strip()
if stripped in ('~', '\\ ', '\\quad', '\\qquad', '\\!', '\\,', '\\:', '\\;'):
return text_converted # Return space command directly
elif stripped.replace('~', '').replace('\\ ', '').strip() == '':
# Pure spaces (multiple ~ or \ )
return text_converted
return f"\\mathrm{{{text_converted}}}"
def _process_complex_mtext(self, text: str) -> str:
"""
Process complex mtext that contains operators.
Splits text at operators and wraps chemical parts in \\mathrm{}
while keeping operators unwrapped.
Example:
"2NaCl+2H" → "\\mathrm{2NaCl}+\\mathrm{2H}"
Args:
text: Text containing operators
Returns:
Processed LaTeX with operators unwrapped
"""
import re
# Pattern for mathematical operators (including Unicode)
operator_pattern = r'([+\-=<>±∓×·/∩∪∈∉⊆⊇∁∅∧∨¬→←↔⇒⇐⇔≡≠≤≥∞])'
# Split text at operators (operators are preserved in the result)
parts = re.split(operator_pattern, text)
result_parts = []
for part in parts:
part_stripped = part.strip()
if not part_stripped:
continue
# Check if this part is an operator
if re.match(operator_pattern, part_stripped):
# Operator - keep as-is, DON'T wrap in \mathrm{}
result_parts.append(part_stripped)
else:
# Not an operator - wrap in \mathrm{}
# First convert any Unicode symbols
part_converted = self._convert_unicode_in_text(part)
result_parts.append(f"\\mathrm{{{part_converted}}}")
# Join parts without spaces (chemical formulas should be compact)
return ''.join(result_parts)
def _contains_chinese(self, text: str) -> bool:
"""
Check if text contains Chinese (CJK) characters.
Args:
text: Text to check
Returns:
True if text contains Chinese characters, False otherwise
"""
for char in text:
# CJK Unified Ideographs range
if '\u4e00' <= char <= '\u9fff':
return True
return False
def _convert_unicode_in_text(self, text: str) -> str:
"""
Convert Unicode symbols within text to LaTeX equivalents.
For multi-character text with Unicode symbols mixed in,
we need to convert each Unicode character individually.
IMPORTANT: Also converts spaces to LaTeX space commands:
- \\xa0 (NBSP) → ~ (non-breaking space in LaTeX)
- Regular space (when standalone) → \\ (backslash-space)
Args:
text: Text that may contain Unicode symbols
Returns:
Text with Unicode symbols converted to LaTeX
"""
if not text:
return text
# CRITICAL FIX: Convert non-breaking spaces (\xa0) to ~ BEFORE other processing
# This fixes the issue where <mtext>&#xA0;</mtext> was becoming \mathrm{ } instead of ~
result = text
# Step 1: Convert NBSP to LaTeX non-breaking space
if '\xa0' in result:
result = result.replace('\xa0', '~')
# Step 2: Convert regular spaces to LaTeX space (but NOT inside prose text)
# Only convert standalone spaces, not spaces within multi-word text
# Check if this is a single space or contains only spaces
if result.strip() == '' and result:
# Pure space(s) → convert to ~
result = '~' * len(result)
elif result in [' ']:
# Single space → ~
result = '~'
# Step 3: Convert Unicode symbols (except PASSTHROUGH symbols)
# Check if any Unicode symbols are present
has_unicode = any(char in UNICODE_TO_LATEX for char in result)
has_passthrough = any(char in UNICODE_PASSTHROUGH for char in result)
if not has_unicode and not has_passthrough:
return result
# Convert character by character (PASSTHROUGH symbols kept as-is)
converted = []
for char in result:
if char in UNICODE_PASSTHROUGH:
# Keep passthrough symbols as Unicode for MathJax
converted.append(char)
elif char in UNICODE_TO_LATEX:
converted.append(UNICODE_TO_LATEX[char])
else:
converted.append(char)
return ''.join(converted)
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mtext'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mtext'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MtextVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,98 @@
r"""
Visitor for <mtr> (table row) MathML elements.
The <mtr> element represents a row in a table/matrix. It is a helper element
that contains <mtd> (table cell) children.
This visitor is primarily used by MtableVisitor to convert table rows.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import local_name as _local
class MtrVisitor(Visitor):
r"""
Handles <mtr> (table row) MathML elements.
Converts table rows to LaTeX format with cells separated by & and
terminated with \\.
Examples:
Simple row:
<mtr><mtd><mn>1</mn></mtd><mtd><mn>2</mn></mtd></mtr>
→ 1 & 2 \\
Row with expressions:
<mtr><mtd><mi>x</mi></mtd><mtd><mi>y</mi></mtd></mtr>
→ x & y \\
Single cell row:
<mtr><mtd><mi>a</mi></mtd></mtr>
→ a \\
Empty row:
<mtr></mtr>
→ \\
"""
def __init__(self, strategy: Strategy):
"""Initialize the Mtr visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <mtr> element to LaTeX row.
Args:
node: The <mtr> element to convert
Returns:
LaTeX string with cells separated by & and terminated with \\
Raises:
ValueError: If node is not an <mtr> element
"""
if not self.can_handle(node):
raise ValueError(f"MtrVisitor cannot handle element '{node.tag}'")
# Convert all <mtd> children to cells
cells = []
for child in node:
if _local(child.tag) == "mtd":
cell_latex = self._convert_cell(child)
cells.append(cell_latex)
# Join cells with & and add row terminator
if not cells:
return "\\\\"
return " & ".join(cells) + " \\\\"
def _convert_cell(self, node: ET.Element) -> str:
"""
Convert a single <mtd> (table cell) element.
Args:
node: The <mtd> element
Returns:
LaTeX string for cell content
"""
# Use MtdVisitor for proper cell conversion
from .mtd_visitor import MtdVisitor
return MtdVisitor(self.strategy).visit(node)
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'mtr'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'mtr'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MtrVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,568 @@
r"""
Visitor for <munder> (underscript/underline) MathML elements.
The <munder> element represents content with something below it:
- Underline: line under text
- Operator limits: summation/integral with lower bound only
- Underbrace: brace under expression
- General underscript: any content under base
This implementation handles the most common cases with TODOs for advanced features.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import text_content as _text_content, local_name as _local
class MunderVisitor(Visitor):
r"""
Handles <munder> (underscript/underline) MathML elements.
Converts underscript and underline notation to appropriate LaTeX commands.
Examples:
Underline:
<munder><mi>x</mi><mo>_</mo></munder> → \underline{x}
Summation with lower limit only:
<munder><mo>∑</mo><mrow><mi>i</mi><mo>=</mo><mn>1</mn></mrow></munder>
→ \sum_{i=1}
Integral with lower bound:
<munder><mo>∫</mo><mi>a</mi></munder> → \int_{a}
Product with lower limit:
<munder><mo>∏</mo><mi>i</mi></munder> → \prod_{i}
Underbrace:
<munder><mrow><mi>x</mi><mo>+</mo><mi>y</mi></mrow><mo>⏟</mo></munder>
→ \underbrace{x+y}
General underscript:
<munder accent="false"><mi>x</mi><mi>n</mi></munder> → \underset{n}{x}
"""
def __init__(self, strategy: Strategy):
"""Initialize the Munder visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <munder> element to LaTeX.
Args:
node: The <munder> element to convert
Returns:
LaTeX string with appropriate underscript/underline notation
Raises:
ValueError: If node is not an <munder> element
ValueError: If munder has fewer than 2 children
"""
if not self.can_handle(node):
raise ValueError(f"MunderVisitor cannot handle element '{node.tag}'")
children = list(node)
# munder must have exactly 2 children: base and underscript
if len(children) < 2:
raise ValueError(
f"munder element must have 2 children (base, underscript), "
f"got {len(children)}"
)
# IMPORTANT: Check raw text content BEFORE converting children
# to properly detect underline characters (e.g., "―" before it becomes "\textemdash")
under_raw_text = _text_content(children[1]).strip()
# Check for underline notation (underbar character) using RAW text
if self._is_underline_char(under_raw_text):
base = self._convert_child(children[0])
# Handle empty base (fill-in blank pattern): use \quad for visible spacing
if not base or not base.strip():
base = "\\qquad"
return f"\\underline{self._brace(base)}"
# Check for underbrace notation using RAW text (simple case: no label)
if self._is_underbrace_char(under_raw_text):
base = self._convert_child(children[0])
return f"\\underbrace{self._brace(base)}"
# Check for underbrace with label: nested munder with brace character
# Pattern: munder(munder(base, ⏟), label) → \underbrace{base}_{label}
# OR: munder(mrow(munder(base, ⏟)), label) with mrow wrapper (Word often wraps inner munder)
# This is how Word encodes underbrace with labels
inner_munder = None
if len(children) >= 2:
# Check if first child is munder directly
if _local(children[0].tag) == 'munder':
inner_munder = children[0]
# Check if first child is mrow containing munder
elif _local(children[0].tag) == 'mrow':
mrow_children = list(children[0])
# Look for munder inside mrow (may be the only child or one of several)
for child in mrow_children:
if _local(child.tag) == 'munder':
inner_munder = child
break
# Process the inner munder if found
if inner_munder is not None:
inner_children = list(inner_munder)
if len(inner_children) >= 2:
# Check if inner munder's second child is the underbrace character
inner_under_text = _text_content(inner_children[1]).strip()
if inner_under_text == "⏟": # U+23DF Underbrace character
# Extract base from inner munder (first child)
inner_base = self._convert_child(inner_children[0])
# Extract label from outer munder (second child)
label = self._convert_child(children[1])
# Return proper underbrace with subscript label
return f"\\underbrace{self._brace(inner_base)}_{self._brace(label)}"
# Check for ALTERNATIVE underbrace pattern (Word sometimes uses this)
# Pattern: munder(mover(base, ⏞), label) → \underbrace{base}_{label}
# OR: munder(mrow(mover(base, ⏞)), label) with mrow wrapper
# Word uses the overbrace character ⏞ but places it in OVER position to create underbrace
if len(children) >= 2:
# Check if first child is mover (directly or inside mrow)
inner_mover = None
if _local(children[0].tag) == 'mover':
inner_mover = children[0]
elif _local(children[0].tag) == 'mrow':
# Check inside mrow wrapper
for child in children[0]:
if _local(child.tag) == 'mover':
inner_mover = child
break
if inner_mover is not None:
inner_children = list(inner_mover)
if len(inner_children) >= 2:
# Check if inner mover's second child is the overbrace character
inner_over_text = _text_content(inner_children[1]).strip()
if inner_over_text == "⏞": # U+23DE Overbrace char in over position = underbrace
# Extract base from inner mover (first child)
inner_base = self._convert_child(inner_children[0])
# Extract label from outer munder (second child)
label = self._convert_child(children[1])
# Return proper underbrace with subscript label
return f"\\underbrace{self._brace(inner_base)}_{self._brace(label)}"
# Check if base is a large operator (sum, integral, product, etc.)
if self._is_large_operator(children[0]):
# Use subscript for operator limits with \limits
base = self._convert_child(children[0])
under = self._convert_child(children[1])
return f"{base}\\limits_{self._brace(under)}"
# Check for arrow UNDER content pattern (arrow in under position)
# Pattern: <munder><content><mrow><mi>→</mi></mrow></munder>
# This should be converted to \underrightarrow{content}
under_to_check = children[1]
if _local(under_to_check.tag) == 'mrow':
# Unwrap mrow if it contains a single element
mrow_children = list(under_to_check)
if len(mrow_children) == 1:
under_to_check = mrow_children[0]
if _local(under_to_check.tag) in ['mo', 'mi']:
under_text = _text_content(under_to_check).strip()
# Map of arrows that can appear under content
# NOTE: Only single arrows (→, ←, ↔) are used for under-arrow notation
# Double arrows (⇒, ⇐, ⇔) should use underset with \Longrightarrow, not underrightarrow
arrow_commands = {
"→": "underrightarrow",
"←": "underleftarrow",
"↔": "underleftrightarrow",
}
if under_text in arrow_commands:
# Check if base is suitable for arrow notation (numbers, variables, etc.)
if _local(children[0].tag) in ['mi', 'mn', 'mrow', 'mfrac', 'msup', 'msub', 'msubsup']:
content = self._convert_child(children[0])
# Remove mathrm wrapper for variables
content_clean = self._strip_mathrm(content)
arrow_cmd = arrow_commands[under_text]
return f"\\{arrow_cmd}{self._brace(content_clean)}"
# Check for INVERTED arrow pattern from XSLT transformer
# Pattern: <munder><mo>→</mo><mrow>content</mrow></munder>
# This should be converted to \underrightarrow{content} not \underset
# CRITICAL FIX: OMML→MathML conversion often puts arrows in <mi> tags instead of <mo>
# Must check BOTH <mo> AND <mi> tags for arrow symbols
# Also need to unwrap mrow wrappers: <munder><mrow><mi>→</mi></mrow><content></munder>
base_to_check = children[0]
if _local(base_to_check.tag) == 'mrow':
# Unwrap mrow if it contains a single element
mrow_children = list(base_to_check)
if len(mrow_children) == 1:
base_to_check = mrow_children[0]
if _local(base_to_check.tag) in ['mo', 'mi']:
base_text = _text_content(base_to_check).strip()
# Map of arrows that can appear in inverted pattern
# NOTE: Only single arrows (→, ←, ↔) are used for under-arrow notation
# Double arrows (⇒, ⇐, ⇔) should use underset with \Longrightarrow, not underrightarrow
arrow_commands = {
"→": "underrightarrow",
"←": "underleftarrow",
"↔": "underleftrightarrow",
}
if base_text in arrow_commands:
# The content is in the under position
content = self._convert_child(children[1])
# Remove mathrm wrapper for variables
content_clean = self._strip_mathrm(content)
arrow_cmd = arrow_commands[base_text]
return f"\\{arrow_cmd}{self._brace(content_clean)}"
# Check for nested extensible arrow pattern (reversed): <munder><mover>arrow/equals<...>...</mover><...></munder>
# OR: <munder><mrow><mover>arrow/equals<...>...</mover></mrow><...></munder> (Word often adds mrow wrapper)
# This is Word's way of creating arrows with text above and below (reversed nesting)
mover_node = None
if _local(children[0].tag) == 'mover':
# Direct mover child
mover_node = children[0]
elif _local(children[0].tag) == 'mrow':
# Check if mrow contains mover as only significant child
mrow_children = list(children[0])
if len(mrow_children) == 1 and _local(mrow_children[0].tag) == 'mover':
mover_node = mrow_children[0]
else:
# Check if any child is mover (Word sometimes adds multiple mrows)
for child in mrow_children:
if _local(child.tag) == 'mover':
mover_node = child
break
if mover_node is not None:
mover_children = list(mover_node)
if len(mover_children) >= 2:
# The arrow/equals might be in an mrow wrapper
base_node = mover_children[0]
if _local(base_node.tag) == 'mrow':
# Unwrap mrow to find the actual operator
mrow_children = list(base_node)
if mrow_children and _local(mrow_children[0].tag) in ('mo', 'mi', 'mtext'):
base_node = mrow_children[0]
# Check if base is an arrow or equals
xarrow_cmd = self._get_xarrow_command(base_node)
if xarrow_cmd:
# Extract text from over and under (may be wrapped in <mphantom> or <mrow>)
over = self._convert_child(mover_children[1])
under = self._convert_child(children[1])
# Clean up the text (remove \phantom{}, \mathrm{}, etc.)
over_clean = self._clean_arrow_text(over)
under_clean = self._clean_arrow_text(under)
# Re-wrap Chinese text and non-command text in \mathrm{} for chemistry mode
# (since _clean_arrow_text removes it)
if hasattr(self.strategy, 'name') and self.strategy.name == "chemistry":
# Check if text needs \mathrm{} wrapping
# Skip if it's already a LaTeX command (starts with \) or empty
# CRITICAL: Also skip pure numbers (like "1", "222", "2121")
# Numbers should remain as plain numbers, not wrapped in \mathrm{}
if over_clean and not over_clean.startswith("\\") and over_clean.strip():
# Check if it's a pure number (digits only, possibly with decimal point)
if not over_clean.replace('.', '', 1).isdigit():
# Not a pure number - wrap it in \mathrm{}
over_clean = f"\\mathrm{{{over_clean}}}"
if under_clean and not under_clean.startswith("\\") and under_clean.strip():
# Check if it's a pure number (digits only, possibly with decimal point)
if not under_clean.replace('.', '', 1).isdigit():
# Not a pure number - wrap it in \mathrm{}
under_clean = f"\\mathrm{{{under_clean}}}"
# Use \quad for empty positions (from <mphantom> elements)
if not under_clean or under_clean.strip() == "":
under_clean = "\\quad"
if not over_clean or over_clean.strip() == "":
over_clean = "\\quad"
return f"\\{xarrow_cmd}[{under_clean}]{{{over_clean}}}"
# TODO: Additional underscript types
# - Underbar variants
# - Custom decorations
# Default: general underscript using \underset
base = self._convert_child(children[0])
under = self._convert_child(children[1])
return f"\\underset{self._brace(under)}{self._brace(base)}"
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in munder, using text fallback", file=sys.stderr)
return _text_content(child)
def _is_underline_char(self, latex_str: str) -> bool:
"""Check if string represents an underline character."""
# Common underline/bar characters used in mathematical notation
underline_chars = [
"―", # Em dash (U+2015) - commonly used for underline in Word
"_", # Underscore
"‾", # Overline character (also used for underline)
"¯", # Macron
"⎽", # Horizontal line extension
"﹍", # Wavy low line
"﹎", # Centreline low line
"﹏", # Wavy overline
"-", # Regular hyphen-minus
"−", # Minus sign (U+2212)
"‗", # Double low line
"⎺", # Horizontal line
"⎻", # Another horizontal line
]
return latex_str.strip() in underline_chars
def _is_underbrace_char(self, latex_str: str) -> bool:
"""Check if string represents an underbrace character."""
underbrace_chars = ["⏟", "︸", "\\underbrace"]
return latex_str.strip() in underbrace_chars
def _is_large_operator(self, base_node: ET.Element) -> bool:
"""
Check if base is a large operator (sum, integral, product, etc.).
Recursively searches through wrapper elements like <mstyle>, <mrow>, <mpadded>
to find the actual operator, since Word often wraps large operators in
<mstyle mathsize="140%"><mrow><mo>∑</mo></mrow></mstyle>.
"""
if base_node is None:
return False
tag = _local(base_node.tag)
# Define large operators list once
large_ops = [
"∑", "\\sum", # Summation
"∏", "\\prod", # Product
"∐", "\\coprod", # Coproduct
"∫", "\\int", # Integral
"∬", "\\iint", # Double integral
"∭", "\\iiint", # Triple integral
"∮", "\\oint", # Contour integral
"⋃", "\\bigcup", # Union
"⋂", "\\bigcap", # Intersection
"⋁", "\\bigvee", # Logical OR
"⋀", "\\bigwedge", # Logical AND
"⨁", "\\bigoplus", # Direct sum
"⨂", "\\bigotimes", # Tensor product
"⨄", "\\biguplus", # Multiset union
"lim", "\\lim", # Limit (often in <mi> from Word)
"sup", "\\sup", # Supremum
"inf", "\\inf", # Infimum
"max", "\\max", # Maximum
"min", "\\min", # Minimum
]
# Check direct <mo> or <mi> elements
if tag in ('mo', 'mi'):
base_text = _text_content(base_node).strip()
return base_text in large_ops
# Recursively search through wrapper elements (mstyle, mrow, mpadded, menclose)
# This handles Word's pattern: <munder><mstyle><mrow><mo>∑</mo></mrow></mstyle>...
if tag in ('mstyle', 'mrow', 'mpadded', 'menclose'):
for child in base_node:
if self._is_large_operator(child):
return True
return False
def _get_xarrow_command(self, base_node: ET.Element) -> str:
"""
Get extensible arrow command for the base element.
Returns command name (without backslash) if base is an arrow/equals,
otherwise returns empty string.
Supported arrows:
- = → xlongequal
- → → xrightarrow
- ← → xleftarrow
- ↔ → xleftrightarrow
- ⇌ → xrightleftharpoons
"""
# Accept <mo> (operator), <mi> (identifier), and <mtext> (text) elements for arrows
# Word's XSLT sometimes produces <mi> for arrows instead of <mo>
if _local(base_node.tag) not in ('mo', 'mi', 'mtext'):
return ""
base_text = _text_content(base_node).strip()
# Map arrow symbols to xarrow commands
arrow_map = {
"=": "xlongequal",
"→": "xrightarrow",
"\\rightarrow": "xrightarrow",
"←": "xleftarrow",
"\\leftarrow": "xleftarrow",
"↔": "xleftrightarrow",
"\\leftrightarrow": "xleftrightarrow",
"⟷": "xleftrightarrow", # U+27F7 long left-right arrow
"\\longleftrightarrow": "xleftrightarrow",
"⇌": "xrightleftharpoons",
"\\rightleftharpoons": "xrightleftharpoons",
}
return arrow_map.get(base_text, "")
def _clean_arrow_text(self, text: str) -> str:
"""
Clean up text for use in arrow labels.
Removes:
- \\phantom{} wrappers (Word uses these for arrow labels)
- \\mathrm{} wrappers
- \\text{} wrappers
- Extra spaces between characters
"""
import re
# Remove \phantom{} wrappers (Word's equation editor uses these for arrow labels)
text = re.sub(r'\\phantom\{([^}]*)\}', r'\1', text)
# Remove \mathrm{} wrappers
text = re.sub(r'\\mathrm\{([^}]*)\}', r'\1', text)
# Remove \text{} wrappers (but keep the content)
# For Chinese text, we want to preserve it as-is
text = re.sub(r'\\text\{([^}]*)\}', r'\1', text)
# Remove extra spaces between individual characters
# "高 压" → "高压", but preserve intentional spaces in phrases
text = re.sub(r'(?<=[\u4e00-\u9fff])\s+(?=[\u4e00-\u9fff])', '', text)
# For non-Chinese text with spaces between each char, remove spaces
# "a b c" → "abc", but "word1 word2" stays as-is
if len(text.replace(' ', '')) > 0:
# Check if it's all single chars separated by spaces
parts = text.split()
if all(len(p) == 1 for p in parts):
text = ''.join(parts)
return text.strip()
def _strip_mathrm(self, latex_str: str) -> str:
r"""Remove \mathrm{...} wrapper if present."""
import re
# Remove \mathrm{} wrappers
latex_str = re.sub(r'\\mathrm\{([a-zA-Z0-9]+)\}', r'\1', latex_str)
# Remove spaces in vector notation (a b c -> abc)
if re.match(r'^[a-zA-Z0-9](\s+[a-zA-Z0-9])+$', latex_str):
latex_str = re.sub(r'\s+', '', latex_str)
return latex_str
def _brace(self, content: str) -> str:
"""Wrap content in braces."""
return f"{{{content}}}"
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'munder'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'munder'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MunderVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,463 @@
r"""
Visitor for <munderover> (under+over script) MathML elements.
The <munderover> element represents content with both something above and below it:
- Large operators with limits: summation, integral, product with both bounds
- \sum_{i=1}^{n}, \int_{a}^{b}, \prod_{i=1}^{n}
- Union/intersection with limits
- General underover scripts
This visitor is CRITICAL because it handles \limits properly, unlike msubsup.
"""
from lxml import etree as ET
from ..core.interfaces import Visitor, Strategy
from ..utils import text_content as _text_content, local_name as _local
class MunderoverVisitor(Visitor):
r"""
Handles <munderover> (under+over script) MathML elements.
Converts under+over notation to appropriate LaTeX commands, especially
for large operators with both upper and lower limits.
Examples:
Summation with limits:
<munderover>
<mo>∑</mo>
<mrow><mi>i</mi><mo>=</mo><mn>1</mn></mrow>
<mi>n</mi>
</munderover> → \sum_{i=1}^{n}
Integral with bounds:
<munderover>
<mo>∫</mo>
<mi>a</mi>
<mi>b</mi>
</munderover> → \int_{a}^{b}
Product with limits:
<munderover>
<mo>∏</mo>
<mrow><mi>i</mi><mo>=</mo><mn>1</mn></mrow>
<mi>n</mi>
</munderover> → \prod_{i=1}^{n}
Union with limits:
<munderover>
<mo>⋃</mo>
<mi>i</mi>
<mi>n</mi>
</munderover> → \bigcup_{i}^{n}
General underover:
<munderover>
<mi>X</mi>
<mi>a</mi>
<mi>b</mi>
</munderover> → \overset{b}{\underset{a}{X}}
"""
def __init__(self, strategy: Strategy):
"""Initialize the Munderover visitor."""
super().__init__(strategy)
def visit(self, node: ET.Element) -> str:
r"""
Convert <munderover> element to LaTeX.
Args:
node: The <munderover> element to convert
Returns:
LaTeX string with appropriate under+over notation
Raises:
ValueError: If node is not an <munderover> element
ValueError: If munderover has fewer than 3 children
"""
if not self.can_handle(node):
raise ValueError(f"MunderoverVisitor cannot handle element '{node.tag}'")
children = list(node)
# munderover must have exactly 3 children: base, underscript, overscript
if len(children) < 3:
raise ValueError(
f"munderover element must have 3 children (base, under, over), "
f"got {len(children)}"
)
base = self._convert_child(children[0])
under = self._convert_child(children[1])
over = self._convert_child(children[2])
# Check if base is a large operator (sum, integral, product, etc.)
if self._is_large_operator(children[0]):
# Use subscript/superscript notation for operator limits with \limits
# \sum\limits_{i=1}^{n}
return f"{base}\\limits_{self._brace(under)}^{self._brace(over)}"
# Check if this should use \limits explicitly
# (for operators that don't default to limit placement)
if self._should_use_limits(children[0]):
return f"{base}\\limits_{self._brace(under)}^{self._brace(over)}"
# Check for extensible arrow patterns (arrows/equals with text above and below)
# This is commonly used for chemical reactions and annotated arrows
# First, unwrap mrow if the base is wrapped (Word often does this)
base_node = children[0]
if _local(base_node.tag) == 'mrow':
# Check if mrow contains a single operator/arrow
mrow_children = list(base_node)
if len(mrow_children) == 1 and _local(mrow_children[0].tag) in ('mo', 'mi', 'mtext'):
base_node = mrow_children[0]
xarrow_cmd = self._get_xarrow_command(base_node)
if xarrow_cmd:
# Check if original content had \mathrm{} (indicates mathvariant="normal")
# We need to preserve this semantic after cleaning
over_had_mathrm = '\\mathrm{' in over
under_had_mathrm = '\\mathrm{' in under
# Clean up the text (remove \phantom{}, \mathrm{}, \text{}, extra spaces)
over_clean = self._clean_arrow_text(over)
under_clean = self._clean_arrow_text(under)
# Re-wrap text in \mathrm{} if:
# 1. Original content had \mathrm{} (from mathvariant="normal")
# 2. Or in chemistry mode for non-command text
# This preserves the mathvariant="normal" semantic from the input MathML
# Skip if it's already a LaTeX command (starts with \) or empty
# Also skip pure numbers (like "1", "222", "2121") - they should remain plain
def should_wrap_mathrm(text: str, had_mathrm: bool) -> bool:
if not text or not text.strip():
return False
if text.startswith("\\"):
return False # Already a LaTeX command
if text.replace('.', '', 1).isdigit():
return False # Pure number
# Wrap if original had \mathrm{} OR in chemistry mode
if had_mathrm:
return True
if hasattr(self.strategy, 'name') and self.strategy.name == "chemistry":
return True
return False
if should_wrap_mathrm(over_clean, over_had_mathrm):
over_clean = f"\\mathrm{{{over_clean}}}"
if should_wrap_mathrm(under_clean, under_had_mathrm):
under_clean = f"\\mathrm{{{under_clean}}}"
# Use \quad for empty positions (from <mphantom> elements)
# This creates proper spacing in chemical equations
if not under_clean or under_clean.strip() == "":
under_clean = "\\quad"
if not over_clean or over_clean.strip() == "":
over_clean = "\\quad"
return f"\\{xarrow_cmd}[{under_clean}]{{{over_clean}}}"
# Default: general under+over using nested \overset and \underset
# \overset{b}{\underset{a}{X}}
return f"\\overset{self._brace(over)}{{\\underset{self._brace(under)}{self._brace(base)}}}"
def _convert_child(self, child: ET.Element) -> str:
"""Convert a child element to LaTeX."""
tag = _local(child.tag)
# Import visitors as needed to avoid circular imports
if tag == 'mi':
from .mi_visitor import MiVisitor
return MiVisitor(self.strategy).visit(child)
elif tag == 'mo':
from .mo_visitor import MoVisitor
return MoVisitor(self.strategy).visit(child)
elif tag == 'mn':
from .mn_visitor import MnVisitor
return MnVisitor(self.strategy).visit(child)
elif tag == 'mfrac':
from .mfrac_visitor import MfracVisitor
return MfracVisitor(self.strategy).visit(child)
elif tag == 'msup':
from .msup_visitor import MsupVisitor
return MsupVisitor(self.strategy).visit(child)
elif tag == 'msub':
from .msub_visitor import MsubVisitor
return MsubVisitor(self.strategy).visit(child)
elif tag == 'mrow':
from .mrow_visitor import MrowVisitor
return MrowVisitor(self.strategy).visit(child)
elif tag == 'msqrt':
from .msqrt_visitor import MsqrtVisitor
return MsqrtVisitor(self.strategy).visit(child)
elif tag == 'mroot':
from .mroot_visitor import MrootVisitor
return MrootVisitor(self.strategy).visit(child)
elif tag == 'msubsup':
from .msubsup_visitor import MsubsupVisitor
return MsubsupVisitor(self.strategy).visit(child)
elif tag == 'munder':
from .munder_visitor import MunderVisitor
return MunderVisitor(self.strategy).visit(child)
elif tag == 'mover':
from .mover_visitor import MoverVisitor
return MoverVisitor(self.strategy).visit(child)
elif tag == 'munderover':
from .munderover_visitor import MunderoverVisitor
return MunderoverVisitor(self.strategy).visit(child)
elif tag == 'mfenced':
from .mfenced_visitor import MfencedVisitor
return MfencedVisitor(self.strategy).visit(child)
elif tag == 'mtable':
from .mtable_visitor import MtableVisitor
return MtableVisitor(self.strategy).visit(child)
elif tag == 'mtext':
from .mtext_visitor import MtextVisitor
return MtextVisitor(self.strategy).visit(child)
elif tag == 'mstyle':
from .mstyle_visitor import MstyleVisitor
return MstyleVisitor(self.strategy).visit(child)
elif tag == 'mspace':
from .mspace_visitor import MspaceVisitor
return MspaceVisitor(self.strategy).visit(child)
elif tag == 'menclose':
from .menclose_visitor import MencloseVisitor
return MencloseVisitor(self.strategy).visit(child)
elif tag == 'mmultiscripts':
from .mmultiscripts_visitor import MmultiscriptsVisitor
return MmultiscriptsVisitor(self.strategy).visit(child)
elif tag == 'maction':
from .maction_visitor import MactionVisitor
return MactionVisitor(self.strategy).visit(child)
elif tag == 'merror':
from .merror_visitor import MerrorVisitor
return MerrorVisitor(self.strategy).visit(child)
elif tag == 'mpadded':
from .mpadded_visitor import MpaddedVisitor
return MpaddedVisitor(self.strategy).visit(child)
elif tag == 'mphantom':
from .mphantom_visitor import MphantomVisitor
return MphantomVisitor(self.strategy).visit(child)
elif tag == 'ms':
from .ms_visitor import MsVisitor
return MsVisitor(self.strategy).visit(child)
elif tag == 'mglyph':
from .mglyph_visitor import MglyphVisitor
return MglyphVisitor(self.strategy).visit(child)
elif tag == 'maligngroup':
from .maligngroup_visitor import MaligngroupVisitor
return MaligngroupVisitor(self.strategy).visit(child)
elif tag == 'malignmark':
from .malignmark_visitor import MalignmarkVisitor
return MalignmarkVisitor(self.strategy).visit(child)
elif tag == 'mlabeledtr':
from .mlabeledtr_visitor import MlabeledtrVisitor
return MlabeledtrVisitor(self.strategy).visit(child)
else:
# Fallback: return text content for unknown elements
import sys
print(f"[WARNING] Unknown element <{tag}> in munderover, using text fallback", file=sys.stderr)
return _text_content(child)
def _is_large_operator(self, base_node: ET.Element) -> bool:
"""
Check if base is a large operator that uses limits by default.
Recursively searches through wrapper elements like <mstyle>, <mrow>, <mpadded>
to find the actual operator, since Word often wraps large operators in
<mstyle mathsize="140%"><mrow><mo>∑</mo></mrow></mstyle>.
"""
if base_node is None:
return False
tag = _local(base_node.tag)
# Large operators that display limits above/below in display mode
large_ops = [
"∑", "\\sum", # Summation
"∏", "\\prod", # Product
"∐", "\\coprod", # Coproduct
"∫", "\\int", # Integral
"∬", "\\iint", # Double integral
"∭", "\\iiint", # Triple integral
"∮", "\\oint", # Contour integral
"⋃", "\\bigcup", # Union
"⋂", "\\bigcap", # Intersection
"⋁", "\\bigvee", # Logical OR
"⋀", "\\bigwedge", # Logical AND
"⨁", "\\bigoplus", # Direct sum
"⨂", "\\bigotimes", # Tensor product
"⨄", "\\biguplus", # Multiset union
]
# Check direct <mo> or <mi> elements
if tag in ('mo', 'mi'):
base_text = _text_content(base_node).strip()
return base_text in large_ops
# Recursively search through wrapper elements (mstyle, mrow, mpadded, menclose)
# This handles Word's pattern: <munderover><mstyle><mrow><mo>∑</mo></mrow></mstyle>...
if tag in ('mstyle', 'mrow', 'mpadded', 'menclose'):
for child in base_node:
if self._is_large_operator(child):
return True
return False
def _should_use_limits(self, base_node: ET.Element) -> bool:
"""Check if operator should explicitly use \\limits."""
if _local(base_node.tag) != 'mo':
return False
base_text = _text_content(base_node).strip()
# Operators that need explicit \limits to display limits above/below
limits_ops = [
"lim", "\\lim", # Limit
"sup", "\\sup", # Supremum
"inf", "\\inf", # Infimum
"max", "\\max", # Maximum
"min", "\\min", # Minimum
"limsup", "\\limsup", # Limit superior
"liminf", "\\liminf", # Limit inferior
"det", "\\det", # Determinant
"gcd", "\\gcd", # Greatest common divisor
]
return base_text in limits_ops
def _brace(self, content: str) -> str:
"""Wrap content in braces."""
return f"{{{content}}}"
def _get_xarrow_command(self, base_node: ET.Element) -> str:
"""
Get extensible arrow command for the base element.
Returns command name (without backslash) if base is an arrow/equals,
otherwise returns empty string.
Supported arrows:
- = → xlongequal
- → → xrightarrow
- ← → xleftarrow
- ↔ → xleftrightarrow
- ⇌ → xrightleftharpoons
"""
# Accept <mo> (operator), <mi> (identifier), and <mtext> (text) elements for arrows
# Word's XSLT sometimes produces <mi> for arrows instead of <mo>
if _local(base_node.tag) not in ('mo', 'mi', 'mtext'):
return ""
base_text = _text_content(base_node).strip()
# Map arrow symbols to xarrow commands
arrow_map = {
"=": "xlongequal",
"→": "xrightarrow",
"\\rightarrow": "xrightarrow",
"←": "xleftarrow",
"\\leftarrow": "xleftarrow",
"↔": "xleftrightarrow",
"\\leftrightarrow": "xleftrightarrow",
"⟷": "xleftrightarrow", # U+27F7 long left-right arrow
"\\longleftrightarrow": "xleftrightarrow",
"⇌": "xrightleftharpoons",
"\\rightleftharpoons": "xrightleftharpoons",
}
return arrow_map.get(base_text, "")
def _clean_arrow_text(self, text: str) -> str:
"""
Clean up text for use in arrow labels.
Removes:
- \\phantom{} wrappers (Word uses these for arrow labels)
- \\mathrm{} wrappers (with proper nested brace handling)
- \\text{} wrappers
- Extra spaces between characters
"""
import re
# Helper function to remove LaTeX commands with balanced braces
def remove_command(text: str, command: str) -> str:
"""Remove LaTeX command with proper brace balancing.
Args:
text: Input text with LaTeX commands
command: Command to remove (e.g., "\\mathrm" or "mathrm")
"""
# Normalize command: ensure it starts with backslash
if not command.startswith('\\'):
command = '\\' + command
while command in text:
# Find the command
idx = text.find(command)
if idx == -1:
break
# Find the opening brace (should be right after command)
brace_start = idx + len(command)
if brace_start >= len(text) or text[brace_start] != '{':
break # Malformed - no opening brace
# Match balanced braces
brace_count = 1
pos = brace_start + 1
while pos < len(text) and brace_count > 0:
if text[pos] == '{' and (pos == 0 or text[pos-1] != '\\'):
brace_count += 1
elif text[pos] == '}' and (pos == 0 or text[pos-1] != '\\'):
brace_count -= 1
pos += 1
if brace_count == 0:
# Successfully matched braces
content = text[brace_start+1:pos-1]
text = text[:idx] + content + text[pos:]
else:
break # Malformed braces
return text
# Remove \phantom{} wrappers (Word's equation editor uses these for arrow labels)
text = remove_command(text, '\\phantom')
# Remove \mathrm{} wrappers (with nested brace handling)
text = remove_command(text, '\\mathrm')
# Remove \text{} wrappers (but keep the content)
# For Chinese text, we want to preserve it as-is
text = remove_command(text, '\\text')
# Remove extra spaces between individual characters
# "高 压" → "高压", but preserve intentional spaces in phrases
text = re.sub(r'(?<=[\u4e00-\u9fff])\s+(?=[\u4e00-\u9fff])', '', text)
# For non-Chinese text with spaces between each char, remove spaces
# "a b c" → "abc", but "word1 word2" stays as-is
if len(text.replace(' ', '')) > 0:
# Check if it's all single chars separated by spaces
parts = text.split()
if all(len(p) == 1 for p in parts):
text = ''.join(parts)
return text.strip()
def can_handle(self, node: ET.Element) -> bool:
"""Check if this visitor can handle the given node."""
return _local(node.tag) == 'munderover'
@property
def element_name(self) -> str:
"""Get the element name this visitor handles."""
return 'munderover'
def __repr__(self) -> str:
"""String representation for debugging."""
return f"MunderoverVisitor(strategy={self.strategy.name})"

View File

@@ -0,0 +1,148 @@
"""
Visitor registry/factory for MathML element visitors.
This module provides a centralized registry for mapping MathML element
names to their corresponding visitor classes.
"""
from typing import Dict, Type, Optional, List
from ..core.interfaces import Visitor, Strategy, VisitorRegistry as IVisitorRegistry
from ..core.context import ConversionContext
class VisitorRegistry(IVisitorRegistry):
"""
Registry for MathML element visitors.
This class maintains a mapping of element names (e.g., 'mi', 'mo', 'mn')
to their corresponding visitor classes. It provides visitor instances
on demand with the appropriate strategy and context injected.
Example:
>>> registry = VisitorRegistry()
>>> registry.register('mi', MiVisitor)
>>> registry.register('mo', MoVisitor)
>>> strategy = MathStrategy()
>>> context = ConversionContext(input_type='mathml')
>>> mi_visitor = registry.get_visitor('mi', strategy, context)
"""
def __init__(self, context: Optional[ConversionContext] = None):
"""
Initialize the registry with empty mappings.
Args:
context: Optional shared conversion context for all visitors
"""
self._visitors: Dict[str, Type[Visitor]] = {}
self._default_visitor: Type[Visitor] | None = None
self.context = context
def register(self, element_name: str, visitor_class: type) -> None:
"""
Register a visitor class for a specific element type.
Args:
element_name: MathML element name (e.g., 'mi', 'mo', 'mn')
visitor_class: The visitor class to handle this element
Raises:
TypeError: If visitor_class is not a subclass of Visitor
"""
if not issubclass(visitor_class, Visitor):
raise TypeError(
f"visitor_class must be a subclass of Visitor, "
f"got {type(visitor_class)}"
)
self._visitors[element_name] = visitor_class
def get_visitor(self, element_name: str, strategy: Strategy, context: Optional[ConversionContext] = None) -> Visitor:
"""
Get a visitor instance for the given element type.
Args:
element_name: MathML element name (e.g., 'mi', 'mo')
strategy: The conversion strategy to inject into the visitor
context: Optional conversion context (uses registry's context if not provided)
Returns:
Visitor instance configured with the strategy and context
Raises:
ValueError: If no visitor registered for element_name and no default set
"""
visitor_class = self._visitors.get(element_name)
if visitor_class is None:
if self._default_visitor is not None:
visitor_class = self._default_visitor
else:
raise ValueError(
f"No visitor registered for element '{element_name}' "
f"and no default visitor configured"
)
# Use provided context or fall back to registry's context
ctx = context if context is not None else self.context
# Try to instantiate with context, fall back to strategy-only if needed
try:
return visitor_class(strategy, ctx)
except TypeError:
# Visitor doesn't accept context parameter yet
return visitor_class(strategy)
def has_visitor(self, element_name: str) -> bool:
"""
Check if a visitor is registered for the element type.
Args:
element_name: MathML element name
Returns:
True if visitor is registered or default exists, False otherwise
"""
return element_name in self._visitors or self._default_visitor is not None
def set_default_visitor(self, visitor_class: Type[Visitor]) -> None:
"""
Set a default visitor for unregistered element types.
Args:
visitor_class: The visitor class to use as default
Raises:
TypeError: If visitor_class is not a subclass of Visitor
"""
if not issubclass(visitor_class, Visitor):
raise TypeError(
f"visitor_class must be a subclass of Visitor, "
f"got {type(visitor_class)}"
)
self._default_visitor = visitor_class
def get_registered_elements(self) -> List[str]:
"""
Get list of all registered element names.
Returns:
List of element names with registered visitors
"""
return list(self._visitors.keys())
def clear(self) -> None:
"""Clear all registered visitors."""
self._visitors.clear()
self._default_visitor = None
def __repr__(self) -> str:
"""String representation for debugging."""
elements = ', '.join(self._visitors.keys()) if self._visitors else 'none'
default = f", default={self._default_visitor.__name__}" if self._default_visitor else ""
return f"VisitorRegistry(registered=[{elements}]{default})"
def __len__(self) -> int:
"""Get number of registered visitors."""
return len(self._visitors)