服务: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 默认密钥已清空
380 lines
16 KiB
Python
380 lines
16 KiB
Python
#!/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())
|