Files
DoLuEdu-WordFileParse/scripts/omml_mathml2latex/utils/styling_postprocessor.py
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

1010 lines
38 KiB
Python

"""
Styling post-processor for OMML to MathML conversion.
This module extracts styling information (bold, fonts, colors) from OMML XML
and applies it to MathML output as mathvariant and other attributes.
The Microsoft OMML2MML.xsl stylesheet does not preserve styling information,
so this post-processor fills that gap by:
1. Extracting m:rPr (run properties) from OMML
2. Building a styling map (position, text) -> StyleInfo
3. Enhancing MathML <mi> elements with mathvariant and other attributes
"""
from dataclasses import dataclass
from typing import Dict, Tuple, Optional, List
from lxml import etree as ET
# Namespace constants
OMML_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/math'
WORD_NS = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
MATHML_NS = 'http://www.w3.org/1998/Math/MathML'
# OMML namespaces for parsing
OMML_NAMESPACES = {
'm': OMML_NS,
'w': WORD_NS,
}
@dataclass
class StyleInfo:
"""Styling information extracted from OMML run properties."""
# Text content for matching
text: str
# Position in equation (for disambiguation)
position: int
# Style attributes from m:rPr
mathvariant: Optional[str] = None # bold, italic, bold-italic, normal, double-struck
# Font family from w:rPr
font_family: Optional[str] = None
# Text color from w:rPr
color: Optional[str] = None
# Background/highlight color from w:rPr (w:highlight element)
background_color: Optional[str] = None
# Font size from w:rPr (in points)
font_size: Optional[float] = None
# Combined flags for easier processing
is_bold: bool = False
is_italic: bool = False
is_plain: bool = False # m:sty val="p" - upright/roman
is_text_mode: bool = False # m:nor - Word "Text Mode" toggle (formatting deferred to w:rPr)
@dataclass
class StructuralStyleInfo:
"""Styling information for structural elements (fractions, radicals, etc.)."""
# Element type from OMML: 'f' (fraction), 'rad' (radical), 'func' (function), 'd' (delimiter)
element_type: str
# Position in equation (for matching with MathML elements)
position: int
# Color from structural properties (m:fPr/w:rPr/w:color or m:radPr/w:rPr/w:color)
color: Optional[str] = None
class StylingPostProcessor:
"""
Post-processes MathML to add styling attributes from OMML.
This bridges the gap between Microsoft's OMML2MML.xsl (which doesn't preserve
styling) and our LaTeX converter (which does handle styling).
Usage:
processor = StylingPostProcessor()
styling_map = processor.extract_styling_from_omml(omml_xml)
enhanced_mathml = processor.apply_styling_to_mathml(mathml_xml, styling_map)
"""
def __init__(self):
"""Initialize the post-processor."""
pass
def extract_styling_from_omml(self, omml_xml: str) -> Dict[Tuple[int, str], StyleInfo]:
"""
Extract styling information from OMML XML.
Parses OMML to find all <m:r> (run) elements with <m:rPr> (run properties)
and builds a map of (position, text) -> StyleInfo.
Args:
omml_xml: OMML XML string
Returns:
Dict mapping (position, text) to StyleInfo objects
Examples:
>>> processor = StylingPostProcessor()
>>> omml = '<m:oMath><m:r><m:rPr><m:sty m:val="b"/></m:rPr><m:t>Z</m:t></m:r></m:oMath>'
>>> styling = processor.extract_styling_from_omml(omml)
>>> styling[(0, 'Z')].is_bold
True
"""
try:
root = ET.fromstring(omml_xml.encode('utf-8'))
except ET.XMLSyntaxError:
# If parsing fails, return empty map (no styling)
return {}
styling_map = {}
position = 0
# Find all <m:r> (run) elements
for r_elem in root.iter(f'{{{OMML_NS}}}r'):
# Get text content
t_elem = r_elem.find(f'{{{OMML_NS}}}t', OMML_NAMESPACES)
if t_elem is None or not t_elem.text:
continue
text = t_elem.text.strip()
if not text:
continue
# Check for run properties
m_rpr = r_elem.find(f'{{{OMML_NS}}}rPr', OMML_NAMESPACES)
w_rpr = r_elem.find(f'{{{WORD_NS}}}rPr', OMML_NAMESPACES)
# Skip if no styling information
if m_rpr is None and w_rpr is None:
position += 1
continue
# Extract styling information
style_info = StyleInfo(text=text, position=position)
# Process OMML math run properties (m:rPr)
if m_rpr is not None:
self._extract_math_properties(m_rpr, style_info)
# Process Word run properties (w:rPr)
if w_rpr is not None:
self._extract_word_properties(w_rpr, style_info)
# Post-check: if m:nor (text mode) is active but no explicit style was set
# (neither m:sty nor w:i/w:b), default to normal (upright)
if style_info.is_text_mode and style_info.mathvariant is None:
style_info.mathvariant = 'normal'
# Only store if there's meaningful styling (not just default font)
# Skip if only font_family is set and it's Cambria Math (default)
has_meaningful_styling = (
style_info.mathvariant is not None or
style_info.color is not None or
style_info.background_color is not None or
style_info.font_size is not None or
(style_info.font_family and style_info.font_family != 'Cambria Math')
)
if has_meaningful_styling:
styling_map[(position, text)] = style_info
# CRITICAL FIX: When styled text contains mixed content (letters + operators/numbers),
# XSLT will split it into separate elements. We need to track character-level styling
# so each split element can still find its style.
#
# Example: "O=2MnO" with mathvariant="normal" gets split by XSLT into:
# <mi>O</mi><mo>=</mo><mn>2</mn><mi>M</mi><mi>n</mi><mi>O</mi>
# We need entries for 'O', '=', '2', 'MnO' (letters merge, operators/numbers split)
if len(text) > 1 and self._text_will_be_split(text):
self._add_character_level_styling(styling_map, text, style_info, position)
position += 1
return styling_map
def _text_will_be_split(self, text: str) -> bool:
"""
Check if text will be split by XSLT into multiple elements.
XSLT splits text when it contains:
- Operators: =, +, -, *, /, <, >, etc.
- Transitions between letters and digits
Args:
text: Text to check
Returns:
True if XSLT will split this text
"""
operators = set('=+-*/:<>|()[]{},;')
has_letters = any(c.isalpha() for c in text)
has_operators = any(c in operators for c in text)
has_digits = any(c.isdigit() for c in text)
# Split happens when we have letters + operators, or letters + digits + operators
return has_letters and (has_operators or (has_digits and has_letters))
def _add_character_level_styling(
self,
styling_map: Dict[Tuple[int, str], 'StyleInfo'],
text: str,
style_info: 'StyleInfo',
base_position: int
) -> None:
"""
Add character/token-level styling entries for text that will be split by XSLT.
XSLT tokenizes text into:
- Consecutive letters → single <mi> (but may be further split)
- Consecutive digits → single <mn>
- Operators → individual <mo>
We need to create styling entries for each potential token.
Args:
styling_map: Map to add entries to
text: The original styled text
style_info: The style to apply
base_position: Base position in the OMML
"""
operators = set('=+-*/:<>|()[]{},;')
# Tokenize like XSLT would
tokens = []
current_token = ''
current_type = None # 'letter', 'digit', 'op'
for char in text:
if char.isalpha():
char_type = 'letter'
elif char.isdigit():
char_type = 'digit'
elif char in operators:
char_type = 'op'
else:
char_type = 'other'
if current_type is None:
current_type = char_type
current_token = char
elif char_type == current_type and char_type != 'op':
# Same type and not operator - accumulate
current_token += char
else:
# Type changed or operator - flush and start new
if current_token:
tokens.append((current_token, current_type))
current_token = char
current_type = char_type
# Flush final token
if current_token:
tokens.append((current_token, current_type))
# Add styling entries for each token
# Use a sub-position to differentiate tokens from the same OMML run
for sub_pos, (token, token_type) in enumerate(tokens):
if token_type == 'letter':
# Create a copy of style_info with updated text
token_style = StyleInfo(
text=token,
position=base_position,
mathvariant=style_info.mathvariant,
font_family=style_info.font_family,
color=style_info.color,
background_color=style_info.background_color,
font_size=style_info.font_size,
is_bold=style_info.is_bold,
is_italic=style_info.is_italic,
is_plain=style_info.is_plain,
is_text_mode=style_info.is_text_mode,
)
# Use token text as key for text-only lookup
# Multiple entries for same position with different texts
styling_map[(base_position, token)] = token_style
# CRITICAL: XSLT may also split letter groups into individual letters
# (e.g., 'MnO' → <mi>M</mi><mi>n</mi><mi>O</mi>)
# Add entries for each individual letter as well
if len(token) > 1:
for char in token:
char_style = StyleInfo(
text=char,
position=base_position,
mathvariant=style_info.mathvariant,
font_family=style_info.font_family,
color=style_info.color,
background_color=style_info.background_color,
font_size=style_info.font_size,
is_bold=style_info.is_bold,
is_italic=style_info.is_italic,
is_plain=style_info.is_plain,
is_text_mode=style_info.is_text_mode,
)
styling_map[(base_position, char)] = char_style
# For operators and digits, we typically don't need mathvariant styling
# as they're already upright by default in LaTeX
def extract_structural_styling_from_omml(
self,
omml_xml: str
) -> Dict[Tuple[str, int], StructuralStyleInfo]:
"""
Extract styling from OMML structural elements (fractions, radicals, etc.).
This method identifies structural elements like <m:f> (fractions) and <m:rad> (radicals)
that have color properties in their properties elements (m:fPr, m:radPr).
Args:
omml_xml: The OMML XML string
Returns:
Dict mapping (element_type, position) -> StructuralStyleInfo
Example: ('f', 0) -> StructuralStyleInfo(element_type='f', position=0, color='FFFF00')
"""
try:
root = ET.fromstring(omml_xml.encode('utf-8'))
except ET.XMLSyntaxError:
return {}
structural_map = {}
# Track positions for each element type separately
positions = {'f': 0, 'rad': 0, 'func': 0, 'd': 0}
# Process fractions (m:f)
for f_elem in root.iter(f'{{{OMML_NS}}}f'):
fpr = f_elem.find(f'{{{OMML_NS}}}fPr', OMML_NAMESPACES)
if fpr is not None:
color = self._extract_color_from_props(fpr)
if color:
info = StructuralStyleInfo(
element_type='f',
position=positions['f'],
color=color
)
structural_map[('f', positions['f'])] = info
positions['f'] += 1
# Process radicals (m:rad) - square roots and nth roots
for rad_elem in root.iter(f'{{{OMML_NS}}}rad'):
radpr = rad_elem.find(f'{{{OMML_NS}}}radPr', OMML_NAMESPACES)
if radpr is not None:
color = self._extract_color_from_props(radpr)
if color:
info = StructuralStyleInfo(
element_type='rad',
position=positions['rad'],
color=color
)
structural_map[('rad', positions['rad'])] = info
positions['rad'] += 1
# Future: Add support for m:func (functions), m:d (delimiters), etc.
return structural_map
def _extract_color_from_props(self, props_elem: ET.Element) -> Optional[str]:
"""
Extract w:color from structural property element (m:fPr, m:radPr, etc.).
Args:
props_elem: The properties element (m:fPr or m:radPr)
Returns:
Color value (hex string without #) or None if no color found
Note:
Word may nest the color in two ways:
1. Direct: m:fPr/w:rPr/w:color
2. Via control properties: m:fPr/m:ctrlPr/w:rPr/w:color
"""
# Try direct path first: m:fPr/w:rPr/w:color
w_rpr = props_elem.find(f'{{{WORD_NS}}}rPr', OMML_NAMESPACES)
# If not found, try via m:ctrlPr (control properties)
if w_rpr is None:
ctrl_pr = props_elem.find(f'{{{OMML_NS}}}ctrlPr', OMML_NAMESPACES)
if ctrl_pr is not None:
w_rpr = ctrl_pr.find(f'{{{WORD_NS}}}rPr', OMML_NAMESPACES)
# Extract color from w:rPr if found
if w_rpr is not None:
color_elem = w_rpr.find(f'{{{WORD_NS}}}color', OMML_NAMESPACES)
if color_elem is not None:
# Try with namespace prefix first, then without
color_val = color_elem.get(f'{{{WORD_NS}}}val')
if color_val is None:
color_val = color_elem.get('val')
# Skip "auto" color (means default)
if color_val and color_val != 'auto':
return color_val
return None
def _extract_math_properties(self, m_rpr: ET.Element, style_info: StyleInfo):
"""
Extract styling from OMML math run properties (m:rPr).
Handles:
- m:sty (style): b=bold, p=plain, i=italic, bi=bold-italic
- m:scr (script): double-struck, script, fraktur, sans-serif, monospace
"""
# Check for m:nor - indicates "text mode" in Word equation editor
# m:nor switches to Word text formatting: w:i/w:b take effect.
# Without explicit m:sty, default is upright (normal) - but w:i can override to italic.
nor_elem = m_rpr.find(f'{{{OMML_NS}}}nor', OMML_NAMESPACES)
if nor_elem is not None:
# <m:nor/> activates text mode - defer mathvariant to w:rPr
# If w:i is present, text becomes italic; otherwise stays normal
style_info.is_plain = True
style_info.is_text_mode = True
# Do NOT set mathvariant here - let _extract_word_properties handle w:i
# Check for m:sty (style) element
sty_elem = m_rpr.find(f'{{{OMML_NS}}}sty', OMML_NAMESPACES)
if sty_elem is not None:
sty_val = sty_elem.get(f'{{{OMML_NS}}}val')
if sty_val is None:
sty_val = sty_elem.get('val') # Try without namespace
if sty_val == 'b':
style_info.is_bold = True
style_info.mathvariant = 'bold'
elif sty_val == 'p':
# m:sty val="p" indicates plain/upright text (not italic)
style_info.is_plain = True
if style_info.text and any(c.isalpha() for c in style_info.text):
style_info.mathvariant = 'normal'
elif sty_val == 'i':
style_info.is_italic = True
style_info.mathvariant = 'italic'
elif sty_val == 'bi':
style_info.is_bold = True
style_info.is_italic = True
style_info.mathvariant = 'bold-italic'
# Check for m:scr (script/font variant) element
scr_elem = m_rpr.find(f'{{{OMML_NS}}}scr', OMML_NAMESPACES)
if scr_elem is not None:
scr_val = scr_elem.get(f'{{{OMML_NS}}}val')
if scr_val is None:
scr_val = scr_elem.get('val')
# m:scr overrides m:sty for mathvariant
if scr_val == 'double-struck':
style_info.mathvariant = 'double-struck'
elif scr_val == 'script':
style_info.mathvariant = 'script'
elif scr_val == 'fraktur':
style_info.mathvariant = 'fraktur'
elif scr_val == 'sans-serif':
style_info.mathvariant = 'sans-serif'
elif scr_val == 'monospace':
style_info.mathvariant = 'monospace'
def _extract_word_properties(self, w_rpr: ET.Element, style_info: StyleInfo):
"""
Extract styling from Word run properties (w:rPr).
Handles:
- w:rFonts (font family)
- w:color (text color)
- w:sz (font size)
- w:b (bold flag)
- w:i (italic flag)
"""
# Font family
rfonts_elem = w_rpr.find(f'{{{WORD_NS}}}rFonts', OMML_NAMESPACES)
if rfonts_elem is not None:
# Try different attributes for font name
font = (rfonts_elem.get(f'{{{WORD_NS}}}ascii') or
rfonts_elem.get(f'{{{WORD_NS}}}hAnsi') or
rfonts_elem.get(f'{{{WORD_NS}}}cs'))
if font:
style_info.font_family = font
# Text color
color_elem = w_rpr.find(f'{{{WORD_NS}}}color', OMML_NAMESPACES)
if color_elem is not None:
color_val = color_elem.get(f'{{{WORD_NS}}}val')
if color_val is None:
color_val = color_elem.get('val')
if color_val and color_val != 'auto':
style_info.color = color_val
# Font size (in half-points)
sz_elem = w_rpr.find(f'{{{WORD_NS}}}sz', OMML_NAMESPACES)
if sz_elem is not None:
sz_val = sz_elem.get(f'{{{WORD_NS}}}val')
if sz_val is None:
sz_val = sz_elem.get('val')
if sz_val:
try:
# Convert half-points to points
style_info.font_size = float(sz_val) / 2
except ValueError:
pass
# Bold flag (from Word, if not already set by m:sty)
# IMPORTANT: <w:b/> (no val) means bold=true, but <w:b val="0"/> means bold=false
if not style_info.is_bold:
b_elem = w_rpr.find(f'{{{WORD_NS}}}b', OMML_NAMESPACES)
if b_elem is not None:
# Check the val attribute - "0" or "false" means NOT bold
b_val = b_elem.get(f'{{{WORD_NS}}}val')
if b_val is None:
b_val = b_elem.get('val') # Try without namespace
# Only set bold=True if val is not "0" or "false"
# Presence without val attribute means true
if b_val not in ('0', 'false'):
style_info.is_bold = True
if not style_info.mathvariant:
style_info.mathvariant = 'bold'
# Italic flag (from Word, if not already set by m:sty)
# IMPORTANT: <w:i/> (no val) means italic=true, but <w:i val="0"/> means italic=false
if not style_info.is_italic:
i_elem = w_rpr.find(f'{{{WORD_NS}}}i', OMML_NAMESPACES)
if i_elem is not None:
# Check the val attribute - "0" or "false" means NOT italic
i_val = i_elem.get(f'{{{WORD_NS}}}val')
if i_val is None:
i_val = i_elem.get('val') # Try without namespace
# Only set italic=True if val is not "0" or "false"
# Presence without val attribute means true
if i_val not in ('0', 'false'):
style_info.is_italic = True
if not style_info.mathvariant:
style_info.mathvariant = 'italic'
elif style_info.mathvariant == 'bold':
style_info.mathvariant = 'bold-italic'
# Background/highlight color (w:highlight element)
highlight_elem = w_rpr.find(f'{{{WORD_NS}}}highlight', OMML_NAMESPACES)
if highlight_elem is not None:
highlight_val = highlight_elem.get(f'{{{WORD_NS}}}val')
if highlight_val is None:
highlight_val = highlight_elem.get('val')
if highlight_val:
style_info.background_color = highlight_val
def _apply_structural_styling(
self,
mathml_root: ET.Element,
structural_styling: Dict[Tuple[str, int], StructuralStyleInfo]
) -> ET.Element:
"""
Wrap structural MathML elements in mstyle with mathcolor.
This method finds structural elements like mfrac, msqrt, mroot and wraps
them in <mstyle mathcolor="#color"> elements based on the styling extracted
from OMML.
Args:
mathml_root: The MathML root element
structural_styling: Dict mapping (element_type, position) -> StructuralStyleInfo
Returns:
Modified MathML root element
Note:
Maps OMML types to MathML tags:
- 'f' -> 'mfrac'
- 'rad' -> 'msqrt' or 'mroot'
"""
# Map OMML element types to MathML tag names
element_map = {
'f': ['mfrac'],
'rad': ['msqrt', 'mroot'],
# Future: 'func': [...], 'd': [...]
}
# Track positions for each MathML tag type
positions = {'mfrac': 0, 'msqrt': 0, 'mroot': 0}
# Collect elements to wrap (can't modify tree during iteration)
elements_to_wrap = []
for elem in mathml_root.iter():
local_tag = elem.tag.split('}')[-1] if '}' in elem.tag else elem.tag
# Check if this is a structural element we're tracking
for omml_type, mathml_tags in element_map.items():
if local_tag in mathml_tags:
# Check if we have styling for this position
key = (omml_type, positions.get(local_tag, 0))
if key in structural_styling:
style_info = structural_styling[key]
if style_info.color:
elements_to_wrap.append((elem, style_info.color))
# Increment position counter for this tag type
positions[local_tag] = positions.get(local_tag, 0) + 1
# Wrap elements in mstyle
for elem, color in elements_to_wrap:
self._wrap_element_in_mstyle(elem, color)
return mathml_root
def _wrap_element_in_mstyle(self, elem: ET.Element, color: str):
"""
Wrap element in mstyle with mathcolor attribute.
Args:
elem: The MathML element to wrap
color: Color value (hex string without #)
Note:
Modifies the tree in place by:
1. Creating <mstyle mathcolor="#color">
2. Replacing elem with mstyle in parent
3. Adding elem as child of mstyle
"""
parent = elem.getparent()
if parent is None:
# Can't wrap root element
return
# Get namespace from element
namespace = elem.tag.split('}')[0][1:] if '}' in elem.tag else None
# Create mstyle element with same namespace
if namespace:
mstyle_tag = f'{{{namespace}}}mstyle'
else:
mstyle_tag = 'mstyle'
mstyle = ET.Element(mstyle_tag)
mstyle.set('mathcolor', f'#{color}')
# Replace elem with mstyle in parent
index = list(parent).index(elem)
parent.remove(elem)
parent.insert(index, mstyle)
# Add elem as child of mstyle
mstyle.append(elem)
def apply_styling_to_mathml(
self,
mathml_xml: str,
styling_map: Dict[Tuple[int, str], StyleInfo],
structural_styling: Dict[Tuple[str, int], StructuralStyleInfo] = None
) -> str:
"""
Apply styling information to MathML elements.
Adds mathvariant, mathcolor, and other attributes to <mi>, <mo>, <mn>
elements based on the styling map extracted from OMML.
Args:
mathml_xml: MathML XML string (output from XSLT)
styling_map: Styling map from extract_styling_from_omml()
Returns:
Enhanced MathML XML string with styling attributes
Examples:
>>> processor = StylingPostProcessor()
>>> mathml = '<math><mi>Z</mi></math>'
>>> styling = {(0, 'Z'): StyleInfo(text='Z', position=0, mathvariant='bold')}
>>> enhanced = processor.apply_styling_to_mathml(mathml, styling)
>>> 'mathvariant="bold"' in enhanced
True
"""
if not styling_map and not structural_styling:
return mathml_xml
try:
root = ET.fromstring(mathml_xml.encode('utf-8'))
except ET.XMLSyntaxError:
# If parsing fails, return original
return mathml_xml
# FIRST: Apply structural styling (wraps elements in mstyle)
if structural_styling:
root = self._apply_structural_styling(root, structural_styling)
# THEN: Apply leaf styling (add attributes to mi, mo, mn)
# Track position for matching
position = 0
# Find all <mi>, <mo>, <mn> elements (identifier, operator, number)
for elem in root.iter():
local_tag = elem.tag.split('}')[-1] if '}' in elem.tag else elem.tag
if local_tag not in ('mi', 'mo', 'mn'):
continue
text = elem.text
if not text or not text.strip():
continue
text = text.strip()
# Look up styling
style_info = styling_map.get((position, text))
if style_info is None:
# Try without position (fallback for simple cases)
for key, info in styling_map.items():
if info.text == text:
style_info = info
break
if style_info is None:
position += 1
continue
# Apply mathvariant ONLY to identifiers (<mi>), not operators (<mo>) or numbers (<mn>)
# Operators should never have mathvariant as they're already upright by default
# This prevents wrapping operators in \mathrm{} which causes output pollution
if style_info.mathvariant and local_tag == 'mi':
elem.set('mathvariant', style_info.mathvariant)
# Apply mathcolor (if color present)
if style_info.color:
elem.set('mathcolor', f'#{style_info.color}')
# Apply font-family (custom attribute, will be handled by LaTeX converter)
# Skip Cambria Math as it's the default math font
if style_info.font_family and style_info.font_family != 'Cambria Math':
elem.set('fontfamily', style_info.font_family)
# Apply font-size (custom attribute)
if style_info.font_size:
elem.set('fontsize', str(style_info.font_size))
position += 1
# Convert back to string
return ET.tostring(root, encoding='unicode')
def has_styling(self, omml_xml: str) -> bool:
"""
Quick check if OMML contains any styling information.
Used for optimization: skip post-processing if no styling present.
Args:
omml_xml: OMML XML string
Returns:
True if styling (m:rPr, w:rPr, m:sty, m:scr, w:highlight) found
"""
# Quick string search (much faster than parsing)
return any(marker in omml_xml for marker in [
'm:rPr', 'm:sty', 'm:scr', # OMML math properties
'w:rPr', 'w:color', 'w:b', 'w:i', 'w:highlight', # Word properties
])
def detect_uniform_background(self, omml_xml: str) -> Optional[str]:
"""
Detect if entire equation has uniform background/highlight color.
Checks all <w:highlight> elements in the OMML to determine if they
all have the same color value, indicating the entire equation has
a uniform background.
Args:
omml_xml: OMML XML string
Returns:
Background color name (e.g., 'yellow') if uniform, None otherwise
Examples:
>>> processor = StylingPostProcessor()
>>> omml = '<m:oMath><m:r><w:rPr><w:highlight w:val="yellow"/></w:rPr><m:t>x</m:t></m:r></m:oMath>'
>>> processor.detect_uniform_background(omml)
'yellow'
"""
try:
root = ET.fromstring(omml_xml.encode('utf-8'))
except ET.XMLSyntaxError:
return None
background_colors = set()
has_elements_without_background = False
# Find all <m:r> (run) elements and check for w:highlight
for r_elem in root.iter(f'{{{OMML_NS}}}r'):
# Get text content - skip empty runs
t_elem = r_elem.find(f'{{{OMML_NS}}}t', OMML_NAMESPACES)
if t_elem is None or not t_elem.text or not t_elem.text.strip():
continue
# Check for Word run properties
w_rpr = r_elem.find(f'{{{WORD_NS}}}rPr', OMML_NAMESPACES)
if w_rpr is None:
has_elements_without_background = True
continue
# Check for highlight element
highlight_elem = w_rpr.find(f'{{{WORD_NS}}}highlight', OMML_NAMESPACES)
if highlight_elem is None:
has_elements_without_background = True
continue
# Get highlight value
highlight_val = highlight_elem.get(f'{{{WORD_NS}}}val')
if highlight_val is None:
highlight_val = highlight_elem.get('val')
if highlight_val:
background_colors.add(highlight_val.lower())
else:
has_elements_without_background = True
# Also check structural elements (fractions, radicals) for highlight in ctrlPr
for elem_type in ['f', 'rad', 'func', 'd', 'sSup', 'sSub', 'sSubSup']:
for struct_elem in root.iter(f'{{{OMML_NS}}}{elem_type}'):
# Find properties element (fPr, radPr, etc.)
pr_elem = struct_elem.find(f'{{{OMML_NS}}}{elem_type}Pr', OMML_NAMESPACES)
if pr_elem is None:
continue
# Check ctrlPr for w:rPr with highlight
ctrl_pr = pr_elem.find(f'{{{OMML_NS}}}ctrlPr', OMML_NAMESPACES)
if ctrl_pr is not None:
w_rpr = ctrl_pr.find(f'{{{WORD_NS}}}rPr', OMML_NAMESPACES)
if w_rpr is not None:
highlight_elem = w_rpr.find(f'{{{WORD_NS}}}highlight', OMML_NAMESPACES)
if highlight_elem is not None:
highlight_val = highlight_elem.get(f'{{{WORD_NS}}}val')
if highlight_val is None:
highlight_val = highlight_elem.get('val')
if highlight_val:
background_colors.add(highlight_val.lower())
# Uniform background only if:
# 1. We found exactly one unique background color
# 2. ALL content elements have that background (none are without)
if len(background_colors) == 1 and not has_elements_without_background:
return list(background_colors)[0]
# Not uniform
return None
def fix_mathml_table_structure(mathml_xml: str) -> str:
"""
Fix malformed table structure from OMML2MML.xsl transformation.
The Microsoft XSLT outputs <mtr><mrow>content</mrow></mtr> but MathML
requires <mtr><mtd>content</mtd></mtr> for proper table structure.
This function:
1. Finds all <mtr> elements with <mrow> children (should be <mtd>)
2. Wraps each <mrow> in <mtd> to create proper table cells
3. Preserves all content and attributes
Args:
mathml_xml: MathML XML string (possibly malformed)
Returns:
Fixed MathML with proper table structure
Examples:
>>> mathml = '<math><mtable><mtr><mrow><mn>1</mn></mrow></mtr></mtable></math>'
>>> fixed = fix_mathml_table_structure(mathml)
>>> '<mtd>' in fixed
True
"""
try:
root = ET.fromstring(mathml_xml.encode('utf-8'))
except ET.XMLSyntaxError:
# If parsing fails, return original
return mathml_xml
def _local_name(tag):
"""Extract local name without namespace."""
return tag.split('}')[-1] if '}' in tag else tag
def _make_tag(local_name, namespace=None):
"""Create tag with optional namespace."""
if namespace:
return f'{{{namespace}}}{local_name}'
return local_name
# Find all <mtr> elements (with or without namespace)
# Collect all mtr elements first to avoid modification during iteration
mtr_elements = [elem for elem in root.iter() if _local_name(elem.tag) == 'mtr']
for mtr_elem in mtr_elements:
# Check if mtr has direct <mrow> children (indicates malformed structure)
mrow_children = [child for child in mtr_elem if _local_name(child.tag) == 'mrow']
if mrow_children:
# Get namespace from mtr element (or its parent mtable)
mtr_ns = mtr_elem.tag.split('}')[0][1:] if '}' in mtr_elem.tag else None
# Process each mrow child: wrap in mtd
# IMPORTANT: We need to preserve order, so collect mtd elements first
new_mtd_elements = []
for mrow_child in mrow_children:
# Create new mtd element with same namespace as mtr
mtd_elem = ET.Element(_make_tag('mtd', mtr_ns))
# Move all children from mrow to mtd
for grandchild in list(mrow_child):
mtd_elem.append(grandchild)
# Preserve mrow text content if any
if mrow_child.text and mrow_child.text.strip():
if len(mtd_elem) == 0:
mtd_elem.text = mrow_child.text
else:
# Prepend text to first child
mtd_elem[0].text = (mrow_child.text or '') + (mtd_elem[0].text or '')
new_mtd_elements.append(mtd_elem)
# Now remove all mrow children and add mtd elements
for mrow_child in mrow_children:
mtr_elem.remove(mrow_child)
for mtd_elem in new_mtd_elements:
mtr_elem.append(mtd_elem)
# Convert back to string
return ET.tostring(root, encoding='unicode')
def apply_styling_to_mathml_from_omml(omml_xml: str, mathml_xml: str) -> str:
"""
Convenience function to extract styling from OMML and apply to MathML.
Also fixes table structure issues from OMML2MML.xsl transformation.
Detects and adds uniform background color as a custom attribute.
Args:
omml_xml: Original OMML XML string
mathml_xml: MathML XML string (from XSLT conversion)
Returns:
Enhanced MathML with styling attributes and fixed table structure
Examples:
>>> omml = '<m:oMath><m:r><m:rPr><m:sty m:val="b"/></m:rPr><m:t>Z</m:t></m:r></m:oMath>'
>>> mathml = '<math><mi>Z</mi></math>'
>>> enhanced = apply_styling_to_mathml_from_omml(omml, mathml)
>>> 'mathvariant="bold"' in enhanced
True
"""
processor = StylingPostProcessor()
# CRITICAL FIX: Fix table structure FIRST (before styling)
# The OMML2MML.xsl outputs <mtr><mrow> instead of <mtr><mtd>
mathml_xml = fix_mathml_table_structure(mathml_xml)
# Detect uniform background color from OMML
uniform_background = processor.detect_uniform_background(omml_xml)
# Quick check: skip if no styling and no background
if not processor.has_styling(omml_xml) and not uniform_background:
return mathml_xml
# Extract BOTH leaf and structural styling
styling_map = processor.extract_styling_from_omml(omml_xml)
structural_styling = processor.extract_structural_styling_from_omml(omml_xml)
# Apply both types of styling
result = processor.apply_styling_to_mathml(
mathml_xml,
styling_map,
structural_styling
)
# Add uniform background as custom attribute on <math> root element
# This will be detected by the LaTeX converter and applied as \bbox
if uniform_background:
result = _add_background_attribute(result, uniform_background)
return result
def _add_background_attribute(mathml_xml: str, background_color: str) -> str:
"""
Add data-background attribute to MathML root element.
Args:
mathml_xml: MathML XML string
background_color: Background color name (e.g., 'yellow')
Returns:
MathML with data-background attribute added to root element
"""
try:
root = ET.fromstring(mathml_xml.encode('utf-8'))
except ET.XMLSyntaxError:
return mathml_xml
# Add custom attribute to root element
root.set('data-background', background_color)
return ET.tostring(root, encoding='unicode')