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