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,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())