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:
379
scripts/omml_mathml2latex/__main__.py
Normal file
379
scripts/omml_mathml2latex/__main__.py
Normal 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())
|
||||
Reference in New Issue
Block a user