Files
Quentin 3271fee5cc 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 默认密钥已清空
2026-09-29 09:43:13 +08:00

377 lines
11 KiB
Python

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