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