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,851 @@
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"
xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:mml="http://www.w3.org/1998/Math/MathML"
xmlns="http://www.w3.org/1998/Math/MathML"
exclude-result-prefixes="m w">
<xsl:output method="xml" indent="yes" encoding="UTF-8"/>
<!-- Root template -->
<xsl:template match="/">
<xsl:apply-templates select="//m:oMath"/>
</xsl:template>
<!-- Main math element -->
<xsl:template match="m:oMath">
<math>
<xsl:apply-templates select="*"/>
</math>
</xsl:template>
<!-- Text run -->
<xsl:template match="m:r">
<xsl:apply-templates select="m:t"/>
</xsl:template>
<!-- Text content -->
<xsl:template match="m:t">
<!-- Get style from parent <m:r><m:rPr><m:sty> -->
<xsl:variable name="sty" select="../m:rPr/m:sty/@m:val"/>
<!-- Get Word run properties (w:rPr) - check for italic (w:i) -->
<!-- w:rPr is a sibling of m:t (both are children of m:r) -->
<!-- CRITICAL: w:i with @w:val="0" or "false" means NOT italic! -->
<!-- Only treat as italic if w:i exists AND val is not "0" or "false" -->
<xsl:variable name="has_word_italic" select="boolean(../w:rPr/w:i[not(@w:val = '0') and not(@w:val = 'false')])"/>
<!-- Get math "normal" style (m:nor) - text mode button -->
<xsl:variable name="has_math_normal" select="boolean(../m:rPr/m:nor)"/>
<xsl:choose>
<xsl:when test="normalize-space(.) = ''">
<!-- Skip empty text -->
</xsl:when>
<xsl:when test="number(.) = number(.)">
<mn><xsl:value-of select="normalize-space(.)"/></mn>
</xsl:when>
<!-- Fence delimiters: parentheses, brackets, pipes (with fence="true") -->
<xsl:when test=". = '(' or . = ')' or . = '[' or . = ']' or . = '|'">
<mo fence="true"><xsl:value-of select="normalize-space(.)"/></mo>
</xsl:when>
<!-- Plain operators (no fence attribute) -->
<!-- NOTE: { and } are converted to markers to distinguish source braces from generated LaTeX braces -->
<xsl:when test=". = '{' or . = '}' or . = '+' or . = '-' or . = '=' or . = '&lt;' or . = '&gt;' or . = '±' or . = '≠' or . = '≤' or . = '≥' or . = '×' or . = '÷' or . = '∞'">
<mo>
<xsl:choose>
<xsl:when test=". = '{'">__LBRACE__</xsl:when>
<xsl:when test=". = '}'">__RBRACE__</xsl:when>
<xsl:otherwise><xsl:value-of select="normalize-space(.)"/></xsl:otherwise>
</xsl:choose>
</mo>
</xsl:when>
<!-- Mixed operators/delimiters (like ")+" or ")=" or "(A" or "={" or ",}") -->
<!-- Check if string contains multiple operator/delimiter characters -->
<xsl:when test="string-length(normalize-space(.)) &gt; 1 and (contains(., '(') or contains(., ')') or contains(., '[') or contains(., ']') or contains(., '{') or contains(., '}') or contains(., '+') or contains(., '-') or contains(., '=') or contains(., '&lt;') or contains(., '&gt;'))">
<!-- Split into individual characters and classify each one -->
<!-- Pass style info so letters get proper mathvariant (e.g., mathvariant="normal" for m:nor text mode) -->
<xsl:call-template name="split-operators">
<xsl:with-param name="text" select="normalize-space(.)"/>
<xsl:with-param name="math_variant">
<xsl:choose>
<xsl:when test="$has_word_italic">italic</xsl:when>
<xsl:when test="$sty = 'b' or $sty = 'bi'">bold</xsl:when>
<xsl:when test="$sty = 'p'">normal</xsl:when>
<xsl:when test="$has_math_normal">normal</xsl:when>
<xsl:otherwise></xsl:otherwise>
</xsl:choose>
</xsl:with-param>
</xsl:call-template>
</xsl:when>
<xsl:when test="contains(., '|')">
<!-- Special handling for text containing | (like "x|x", "A|B", or "||") -->
<!-- Split by | and create separate elements with fence="true" for absolute values -->
<xsl:variable name="before" select="substring-before(., '|')"/>
<xsl:variable name="after" select="substring-after(., '|')"/>
<!-- Process before part (plain text) -->
<xsl:if test="$before != ''">
<xsl:choose>
<!-- If before part contains operators or is multi-char, use split-operators -->
<xsl:when test="string-length($before) &gt; 1">
<xsl:call-template name="split-operators">
<xsl:with-param name="text" select="$before"/>
</xsl:call-template>
</xsl:when>
<!-- Single character - output as mi -->
<xsl:otherwise>
<mi><xsl:value-of select="$before"/></mi>
</xsl:otherwise>
</xsl:choose>
</xsl:if>
<!-- Output the | as fence for potential absolute value -->
<mo fence="true">|</mo>
<!-- Process after part recursively if it contains more | or operators -->
<xsl:if test="$after != ''">
<xsl:choose>
<!-- If after part is just |, output as fence -->
<xsl:when test="$after = '|'">
<mo fence="true">|</mo>
</xsl:when>
<!-- If after part contains more |, recursively process this m:t content -->
<xsl:when test="contains($after, '|')">
<xsl:call-template name="process-text-with-pipe">
<xsl:with-param name="text" select="$after"/>
</xsl:call-template>
</xsl:when>
<!-- If after part contains operators, use split-operators -->
<xsl:when test="string-length($after) &gt; 1 and (contains($after, '+') or contains($after, '-') or contains($after, '='))">
<xsl:call-template name="split-operators">
<xsl:with-param name="text" select="$after"/>
</xsl:call-template>
</xsl:when>
<!-- Single character or simple text - output as mi -->
<xsl:otherwise>
<mi><xsl:value-of select="$after"/></mi>
</xsl:otherwise>
</xsl:choose>
</xsl:if>
</xsl:when>
<xsl:when test="string-length(normalize-space(.)) = 1 and not(contains('0123456789', .))">
<mi>
<!-- Apply mathvariant based on style -->
<!-- Note: m:sty='i' (italic) is NOT handled here as italic is the default for variables -->
<!-- Priority: italic (w:i explicit) > bold > plain (m:sty='p') > normal (m:nor) -->
<xsl:choose>
<xsl:when test="$has_word_italic">
<xsl:attribute name="mathvariant">italic</xsl:attribute>
</xsl:when>
<xsl:when test="$sty = 'b' or $sty = 'bi'">
<xsl:attribute name="mathvariant">bold</xsl:attribute>
</xsl:when>
<xsl:when test="$sty = 'p'">
<xsl:attribute name="mathvariant">normal</xsl:attribute>
</xsl:when>
<xsl:when test="$has_math_normal">
<xsl:attribute name="mathvariant">normal</xsl:attribute>
</xsl:when>
</xsl:choose>
<xsl:value-of select="normalize-space(.)"/>
</mi>
</xsl:when>
<xsl:otherwise>
<mi>
<!-- Apply mathvariant for multi-character identifiers too -->
<!-- Note: m:sty='i' (italic) is NOT handled here as italic is the default for variables -->
<!-- Priority: italic (w:i explicit) > bold > plain (m:sty='p') > normal (m:nor) -->
<xsl:choose>
<xsl:when test="$has_word_italic">
<xsl:attribute name="mathvariant">italic</xsl:attribute>
</xsl:when>
<xsl:when test="$sty = 'b' or $sty = 'bi'">
<xsl:attribute name="mathvariant">bold</xsl:attribute>
</xsl:when>
<xsl:when test="$sty = 'p'">
<xsl:attribute name="mathvariant">normal</xsl:attribute>
</xsl:when>
<xsl:when test="$has_math_normal">
<xsl:attribute name="mathvariant">normal</xsl:attribute>
</xsl:when>
</xsl:choose>
<xsl:value-of select="normalize-space(.)"/>
</mi>
</xsl:otherwise>
</xsl:choose>
</xsl:template>
<!-- Superscript -->
<xsl:template match="m:sSup">
<msup>
<xsl:apply-templates select="m:e"/>
<xsl:apply-templates select="m:sup"/>
</msup>
</xsl:template>
<!-- Subscript -->
<xsl:template match="m:sSub">
<msub>
<xsl:apply-templates select="m:e"/>
<xsl:apply-templates select="m:sub"/>
</msub>
</xsl:template>
<!-- Sub-superscript -->
<xsl:template match="m:sSubSup">
<msubsup>
<xsl:apply-templates select="m:e"/>
<xsl:apply-templates select="m:sub"/>
<xsl:apply-templates select="m:sup"/>
</msubsup>
</xsl:template>
<!-- Pre-script (left-side subscript/superscript) -->
<!-- Used for nuclear notation like _{2}^{3}He -->
<xsl:template match="m:sPre">
<mmultiscripts>
<xsl:apply-templates select="m:e"/>
<mprescripts/>
<xsl:apply-templates select="m:sub"/>
<xsl:apply-templates select="m:sup"/>
</mmultiscripts>
</xsl:template>
<!-- Fraction -->
<!-- m:type values: bar (default), noBar, lin (linear), skw (skewed) -->
<xsl:template match="m:f">
<xsl:variable name="type" select="m:fPr/m:type/@m:val"/>
<mfrac>
<!-- Add linethickness="0" for noBar fractions (stacked without bar) -->
<xsl:if test="$type = 'noBar'">
<xsl:attribute name="linethickness">0</xsl:attribute>
</xsl:if>
<!-- Add bevelled="true" for linear fractions -->
<xsl:if test="$type = 'lin'">
<xsl:attribute name="bevelled">true</xsl:attribute>
</xsl:if>
<xsl:apply-templates select="m:num"/>
<xsl:apply-templates select="m:den"/>
</mfrac>
</xsl:template>
<!-- Radical -->
<xsl:template match="m:rad">
<xsl:choose>
<xsl:when test="m:deg">
<mroot>
<xsl:apply-templates select="m:e"/>
<xsl:apply-templates select="m:deg"/>
</mroot>
</xsl:when>
<xsl:otherwise>
<msqrt>
<xsl:apply-templates select="m:e"/>
</msqrt>
</xsl:otherwise>
</xsl:choose>
</xsl:template>
<!-- Parentheses/delimiters -->
<xsl:template match="m:d">
<mrow>
<!-- Opening delimiter: skip if begChr has empty or whitespace-only val -->
<xsl:choose>
<xsl:when test="not(m:dPr/m:begChr)">
<mo fence="true">(</mo>
</xsl:when>
<xsl:when test="normalize-space(m:dPr/m:begChr/@m:val) != ''">
<mo fence="true">
<xsl:value-of select="m:dPr/m:begChr/@m:val"/>
</mo>
</xsl:when>
<!-- begChr with empty/whitespace val → no opening delimiter -->
</xsl:choose>
<!-- Handle multiple m:e elements with separators -->
<xsl:for-each select="m:e">
<xsl:apply-templates select="."/>
<!-- Insert separator | between elements (except after the last one) -->
<xsl:if test="position() != last()">
<mo>|</mo>
</xsl:if>
</xsl:for-each>
<!-- Closing delimiter: skip if endChr has empty or whitespace-only val -->
<xsl:choose>
<xsl:when test="not(m:dPr/m:endChr)">
<mo fence="true">)</mo>
</xsl:when>
<xsl:when test="normalize-space(m:dPr/m:endChr/@m:val) != ''">
<mo fence="true">
<xsl:value-of select="m:dPr/m:endChr/@m:val"/>
</mo>
</xsl:when>
<!-- endChr with empty/whitespace val → no closing delimiter -->
</xsl:choose>
</mrow>
</xsl:template>
<!-- Function -->
<xsl:template match="m:func">
<mrow>
<xsl:apply-templates select="m:fName"/>
<xsl:apply-templates select="m:e"/>
</mrow>
</xsl:template>
<!-- Matrix -->
<xsl:template match="m:m">
<mtable>
<xsl:apply-templates select="m:mr"/>
</mtable>
</xsl:template>
<!-- Matrix row -->
<xsl:template match="m:mr">
<mtr>
<xsl:apply-templates select="m:e"/>
</mtr>
</xsl:template>
<!-- Equation array (for piecewise functions) -->
<xsl:template match="m:eqArr">
<mtable>
<!-- Each <m:e> child represents one row in the piecewise function -->
<xsl:for-each select="m:e">
<mtr>
<mtd>
<xsl:apply-templates select="node()"/>
</mtd>
</mtr>
</xsl:for-each>
</mtable>
</xsl:template>
<!-- Bar (overbar/underbar) -->
<!-- OMML spec: no pos attribute = underline (default), pos="top" = overline, pos="bot" = underline -->
<xsl:template match="m:bar">
<xsl:choose>
<xsl:when test="m:barPr/m:pos/@m:val = 'top'">
<mover>
<xsl:apply-templates select="m:e"/>
<mo>‾</mo>
</mover>
</xsl:when>
<xsl:otherwise>
<!-- Default: no pos attribute or pos="bot" both mean underline -->
<munder>
<xsl:apply-templates select="m:e"/>
<mo>‾</mo>
</munder>
</xsl:otherwise>
</xsl:choose>
</xsl:template>
<!-- Limits -->
<!-- limLow: Lower limit position (content BELOW the base, like underscript) -->
<xsl:template match="m:limLow">
<munder>
<xsl:apply-templates select="m:e"/>
<xsl:apply-templates select="m:lim"/>
</munder>
</xsl:template>
<!-- limUpp: Upper limit position (content ABOVE the base, like overscript) -->
<xsl:template match="m:limUpp">
<mover>
<xsl:apply-templates select="m:e"/>
<xsl:apply-templates select="m:lim"/>
</mover>
</xsl:template>
<xsl:template match="m:limUppLow">
<munderover>
<xsl:apply-templates select="m:e"/>
<xsl:apply-templates select="m:lim[1]"/>
<xsl:apply-templates select="m:lim[2]"/>
</munderover>
</xsl:template>
<!-- N-ary operators (integrals, sums, products) -->
<xsl:template match="m:nary">
<xsl:variable name="chr" select="m:naryPr/m:chr/@m:val"/>
<xsl:variable name="limLoc" select="m:naryPr/m:limLoc/@m:val"/>
<xsl:variable name="subHide" select="m:naryPr/m:subHide/@m:val"/>
<xsl:variable name="supHide" select="m:naryPr/m:supHide/@m:val"/>
<xsl:choose>
<!-- Both limits hidden: just operator and integrand -->
<xsl:when test="$subHide = '1' and $supHide = '1'">
<mrow>
<mo>
<xsl:choose>
<xsl:when test="$chr"><xsl:value-of select="$chr"/></xsl:when>
<xsl:otherwise>∫</xsl:otherwise>
</xsl:choose>
</mo>
<xsl:apply-templates select="m:e"/>
</mrow>
</xsl:when>
<!-- Limits as subscript/superscript (default for integrals) -->
<xsl:when test="not($limLoc) or $limLoc = 'subSup'">
<xsl:choose>
<!-- Both sub and sup present -->
<xsl:when test="m:sub and m:sup">
<msubsup>
<mo>
<xsl:choose>
<xsl:when test="$chr"><xsl:value-of select="$chr"/></xsl:when>
<xsl:otherwise>∫</xsl:otherwise>
</xsl:choose>
</mo>
<xsl:apply-templates select="m:sub"/>
<xsl:apply-templates select="m:sup"/>
</msubsup>
<xsl:apply-templates select="m:e"/>
</xsl:when>
<!-- Only subscript -->
<xsl:when test="m:sub">
<msub>
<mo>
<xsl:choose>
<xsl:when test="$chr"><xsl:value-of select="$chr"/></xsl:when>
<xsl:otherwise>∫</xsl:otherwise>
</xsl:choose>
</mo>
<xsl:apply-templates select="m:sub"/>
</msub>
<xsl:apply-templates select="m:e"/>
</xsl:when>
<!-- Only superscript -->
<xsl:when test="m:sup">
<msup>
<mo>
<xsl:choose>
<xsl:when test="$chr"><xsl:value-of select="$chr"/></xsl:when>
<xsl:otherwise>∫</xsl:otherwise>
</xsl:choose>
</mo>
<xsl:apply-templates select="m:sup"/>
</msup>
<xsl:apply-templates select="m:e"/>
</xsl:when>
<!-- No limits -->
<xsl:otherwise>
<mrow>
<mo>
<xsl:choose>
<xsl:when test="$chr"><xsl:value-of select="$chr"/></xsl:when>
<xsl:otherwise>∫</xsl:otherwise>
</xsl:choose>
</mo>
<xsl:apply-templates select="m:e"/>
</mrow>
</xsl:otherwise>
</xsl:choose>
</xsl:when>
<!-- Limits as under/over (for sums, products) -->
<xsl:when test="$limLoc = 'undOvr'">
<mrow>
<xsl:choose>
<!-- Both under and over -->
<xsl:when test="m:sub and m:sup and not($subHide = '1') and not($supHide = '1')">
<munderover>
<mo>
<xsl:choose>
<xsl:when test="$chr"><xsl:value-of select="$chr"/></xsl:when>
<xsl:otherwise>∫</xsl:otherwise>
</xsl:choose>
</mo>
<xsl:apply-templates select="m:sub"/>
<xsl:apply-templates select="m:sup"/>
</munderover>
</xsl:when>
<!-- Only under -->
<xsl:when test="m:sub and not($subHide = '1')">
<munder>
<mo>
<xsl:choose>
<xsl:when test="$chr"><xsl:value-of select="$chr"/></xsl:when>
<xsl:otherwise>∫</xsl:otherwise>
</xsl:choose>
</mo>
<xsl:apply-templates select="m:sub"/>
</munder>
</xsl:when>
<!-- Only over -->
<xsl:when test="m:sup and not($supHide = '1')">
<mover>
<mo>
<xsl:choose>
<xsl:when test="$chr"><xsl:value-of select="$chr"/></xsl:when>
<xsl:otherwise>∫</xsl:otherwise>
</xsl:choose>
</mo>
<xsl:apply-templates select="m:sup"/>
</mover>
</xsl:when>
<!-- No visible limits -->
<xsl:otherwise>
<mo>
<xsl:choose>
<xsl:when test="$chr"><xsl:value-of select="$chr"/></xsl:when>
<xsl:otherwise>∫</xsl:otherwise>
</xsl:choose>
</mo>
</xsl:otherwise>
</xsl:choose>
<xsl:apply-templates select="m:e"/>
</mrow>
</xsl:when>
</xsl:choose>
</xsl:template>
<!-- Accent (vector arrows, dots, etc.) -->
<xsl:template match="m:acc">
<xsl:variable name="chr" select="m:accPr/m:chr/@m:val"/>
<mover>
<!-- Add data attribute to distinguish default accents from explicit ones -->
<xsl:if test="not($chr)">
<xsl:attribute name="data-default-accent">true</xsl:attribute>
</xsl:if>
<xsl:apply-templates select="m:e"/>
<mo>
<xsl:choose>
<xsl:when test="$chr"><xsl:value-of select="$chr"/></xsl:when>
<xsl:otherwise>⃗</xsl:otherwise><!-- Default accent (should be hat in most cases) -->
</xsl:choose>
</mo>
</mover>
</xsl:template>
<!-- Group character (for over/under arrows with text) -->
<xsl:template match="m:groupChr">
<xsl:variable name="chr" select="m:groupChrPr/m:chr/@m:val"/>
<xsl:variable name="pos" select="m:groupChrPr/m:pos/@m:val"/>
<xsl:variable name="vertJc" select="m:groupChrPr/m:vertJc/@m:val"/>
<xsl:choose>
<!-- vertJc='bot': Character at bottom (base), content above → mover(char, content) -->
<!-- This is used for extensible arrows like \xrightarrow[]{text} or \overset{text}{arrow} -->
<xsl:when test="$vertJc = 'bot'">
<mover accent="false">
<mo stretchy="true">
<xsl:choose>
<xsl:when test="$chr"><xsl:value-of select="$chr"/></xsl:when>
<xsl:otherwise>→</xsl:otherwise>
</xsl:choose>
</mo>
<xsl:apply-templates select="m:e"/>
</mover>
</xsl:when>
<!-- pos='bot': Character position at bottom, content above → munder -->
<xsl:when test="$pos = 'bot'">
<munder>
<xsl:apply-templates select="m:e"/>
<mo>
<xsl:choose>
<xsl:when test="$chr"><xsl:value-of select="$chr"/></xsl:when>
<xsl:otherwise>⏟</xsl:otherwise><!-- Default underbrace -->
</xsl:choose>
</mo>
</munder>
</xsl:when>
<!-- pos='top' (or default): Character above content → mover(content, char) -->
<xsl:otherwise>
<mover>
<xsl:apply-templates select="m:e"/>
<mo>
<xsl:choose>
<xsl:when test="$chr"><xsl:value-of select="$chr"/></xsl:when>
<xsl:otherwise>⏞</xsl:otherwise><!-- Default overbrace -->
</xsl:choose>
</mo>
</mover>
</xsl:otherwise>
</xsl:choose>
</xsl:template>
<!-- Box (but detect if it contains groupChr for reaction arrows) -->
<xsl:template match="m:box">
<xsl:choose>
<!-- Skip boxes with opEmu="1" (operator emulation - should be transparent) -->
<!-- This means process all children in m:e transparently -->
<xsl:when test="m:boxPr/m:opEmu/@m:val='1'">
<xsl:apply-templates select="m:e/*"/>
</xsl:when>
<!-- If box contains ONLY a single direct groupChr, handle specially -->
<!-- This is for cases like reaction arrows: text above/below extensible arrow -->
<xsl:when test="count(m:e/*) = 1 and m:e/m:groupChr">
<xsl:variable name="groupChr" select="m:e/m:groupChr"/>
<xsl:variable name="arrow" select="$groupChr/m:groupChrPr/m:chr/@m:val"/>
<xsl:variable name="vertJc" select="$groupChr/m:groupChrPr/m:vertJc/@m:val"/>
<xsl:variable name="pos" select="$groupChr/m:groupChrPr/m:pos/@m:val"/>
<xsl:choose>
<xsl:when test="$vertJc = 'bot'">
<!-- Arrow at bottom with text above (like \xrightarrow[]{text}) -->
<!-- For extensible arrows: mover with arrow base, text above -->
<mover accent="false">
<mo stretchy="true">
<xsl:choose>
<xsl:when test="$arrow"><xsl:value-of select="$arrow"/></xsl:when>
<xsl:otherwise>→</xsl:otherwise>
</xsl:choose>
</mo>
<xsl:apply-templates select="$groupChr/m:e"/>
</mover>
</xsl:when>
<xsl:when test="$pos = 'top'">
<!-- Arrow at top: \overrightarrow pattern -->
<!-- mover with content base, arrow above -->
<mover>
<xsl:apply-templates select="$groupChr/m:e"/>
<mo>
<xsl:choose>
<xsl:when test="$arrow"><xsl:value-of select="$arrow"/></xsl:when>
<xsl:otherwise>→</xsl:otherwise>
</xsl:choose>
</mo>
</mover>
</xsl:when>
<xsl:otherwise>
<!-- Default: Vector arrow over content -->
<mover>
<xsl:apply-templates select="$groupChr/m:e"/>
<mo>
<xsl:choose>
<xsl:when test="$arrow"><xsl:value-of select="$arrow"/></xsl:when>
<xsl:otherwise>→</xsl:otherwise>
</xsl:choose>
</mo>
</mover>
</xsl:otherwise>
</xsl:choose>
</xsl:when>
<!-- Skip phantom/layout boxes (argSz with negative value) -->
<!-- These are used for sizing/spacing control, not actual visible boxes -->
<xsl:when test="m:e/m:argPr/m:argSz/@m:val &lt; 0">
<xsl:apply-templates select="m:e"/>
</xsl:when>
<!-- Regular box -->
<xsl:otherwise>
<menclose notation="box">
<xsl:apply-templates select="m:e"/>
</menclose>
</xsl:otherwise>
</xsl:choose>
</xsl:template>
<!-- Border box with strikethrough and border notation support -->
<xsl:template match="m:borderBox">
<xsl:variable name="props" select="m:borderBoxPr"/>
<!-- Check for strikethrough properties -->
<xsl:variable name="strikeBLTR" select="boolean($props/m:strikeBLTR)"/>
<xsl:variable name="strikeTLBR" select="boolean($props/m:strikeTLBR)"/>
<xsl:variable name="strikeH" select="boolean($props/m:strikeH)"/>
<xsl:variable name="strikeV" select="boolean($props/m:strikeV)"/>
<!-- Check for border hiding properties -->
<xsl:variable name="hideTop" select="boolean($props/m:hideTop)"/>
<xsl:variable name="hideBot" select="boolean($props/m:hideBot)"/>
<xsl:variable name="hideLeft" select="boolean($props/m:hideLeft)"/>
<xsl:variable name="hideRight" select="boolean($props/m:hideRight)"/>
<!-- Determine notation attribute -->
<xsl:variable name="notation">
<xsl:choose>
<!-- Combined diagonal strikes → xcancel -->
<xsl:when test="$strikeBLTR and $strikeTLBR">updiagonalstrike downdiagonalstrike</xsl:when>
<!-- Single diagonal strike BLTR → cancel (updiagonal) -->
<xsl:when test="$strikeBLTR">updiagonalstrike</xsl:when>
<!-- Single diagonal strike TLBR → bcancel (downdiagonal) -->
<xsl:when test="$strikeTLBR">downdiagonalstrike</xsl:when>
<!-- Combined horizontal and vertical → cancel -->
<xsl:when test="$strikeH and $strikeV">horizontalstrike verticalstrike</xsl:when>
<!-- Single horizontal strike → cancel -->
<xsl:when test="$strikeH">horizontalstrike</xsl:when>
<!-- Single vertical strike → bcancel -->
<xsl:when test="$strikeV">verticalstrike</xsl:when>
<!-- Single side visible -->
<xsl:when test="not($hideTop) and $hideBot and $hideLeft and $hideRight">top</xsl:when>
<xsl:when test="$hideTop and not($hideBot) and $hideLeft and $hideRight">bottom</xsl:when>
<xsl:when test="$hideTop and $hideBot and not($hideLeft) and $hideRight">left</xsl:when>
<xsl:when test="$hideTop and $hideBot and $hideLeft and not($hideRight)">right</xsl:when>
<!-- Two sides visible -->
<xsl:when test="not($hideTop) and not($hideBot) and $hideLeft and $hideRight">top bottom</xsl:when>
<xsl:when test="not($hideTop) and $hideBot and not($hideLeft) and $hideRight">top left</xsl:when>
<xsl:when test="not($hideTop) and $hideBot and $hideLeft and not($hideRight)">top right</xsl:when>
<xsl:when test="$hideTop and not($hideBot) and not($hideLeft) and $hideRight">bottom left</xsl:when>
<xsl:when test="$hideTop and not($hideBot) and $hideLeft and not($hideRight)">bottom right</xsl:when>
<xsl:when test="$hideTop and $hideBot and not($hideLeft) and not($hideRight)">left right</xsl:when>
<!-- Three sides visible -->
<xsl:when test="not($hideTop) and not($hideBot) and not($hideLeft) and $hideRight">top bottom left</xsl:when>
<xsl:when test="not($hideTop) and not($hideBot) and $hideLeft and not($hideRight)">top bottom right</xsl:when>
<xsl:when test="not($hideTop) and $hideBot and not($hideLeft) and not($hideRight)">top left right</xsl:when>
<xsl:when test="$hideTop and not($hideBot) and not($hideLeft) and not($hideRight)">bottom left right</xsl:when>
<!-- All four sides visible (or none hidden) -->
<xsl:when test="not($hideTop or $hideBot or $hideLeft or $hideRight)">box</xsl:when>
<!-- Default: roundedbox -->
<xsl:otherwise>roundedbox</xsl:otherwise>
</xsl:choose>
</xsl:variable>
<menclose notation="{$notation}">
<xsl:apply-templates select="m:e"/>
</menclose>
</xsl:template>
<!-- Math element base -->
<xsl:template match="m:e | m:num | m:den | m:sup | m:sub | m:deg | m:fName | m:lim">
<mrow>
<xsl:apply-templates select="*"/>
</mrow>
</xsl:template>
<!-- Template to split mixed operator/delimiter strings character by character -->
<!-- Recursive template to process text containing | characters -->
<xsl:template name="process-text-with-pipe">
<xsl:param name="text"/>
<xsl:if test="string-length($text) &gt; 0">
<xsl:choose>
<!-- If text contains |, split and recurse -->
<xsl:when test="contains($text, '|')">
<xsl:variable name="before" select="substring-before($text, '|')"/>
<xsl:variable name="after" select="substring-after($text, '|')"/>
<!-- Output before part if not empty -->
<xsl:if test="$before != ''">
<xsl:choose>
<!-- If before is single char, output as mi or mn -->
<xsl:when test="string-length($before) = 1">
<xsl:choose>
<xsl:when test="contains('0123456789', $before)">
<mn><xsl:value-of select="$before"/></mn>
</xsl:when>
<xsl:otherwise>
<mi><xsl:value-of select="$before"/></mi>
</xsl:otherwise>
</xsl:choose>
</xsl:when>
<!-- Multi-char - use split-operators -->
<xsl:otherwise>
<xsl:call-template name="split-operators">
<xsl:with-param name="text" select="$before"/>
</xsl:call-template>
</xsl:otherwise>
</xsl:choose>
</xsl:if>
<!-- Output | as fence -->
<mo fence="true">|</mo>
<!-- Recurse on after part -->
<xsl:if test="$after != ''">
<xsl:call-template name="process-text-with-pipe">
<xsl:with-param name="text" select="$after"/>
</xsl:call-template>
</xsl:if>
</xsl:when>
<!-- No more | in text - output as-is -->
<xsl:otherwise>
<xsl:choose>
<!-- Single char -->
<xsl:when test="string-length($text) = 1">
<xsl:choose>
<xsl:when test="contains('0123456789', $text)">
<mn><xsl:value-of select="$text"/></mn>
</xsl:when>
<xsl:otherwise>
<mi><xsl:value-of select="$text"/></mi>
</xsl:otherwise>
</xsl:choose>
</xsl:when>
<!-- Multi-char - use split-operators for proper handling -->
<xsl:otherwise>
<xsl:call-template name="split-operators">
<xsl:with-param name="text" select="$text"/>
</xsl:call-template>
</xsl:otherwise>
</xsl:choose>
</xsl:otherwise>
</xsl:choose>
</xsl:if>
</xsl:template>
<xsl:template name="split-operators">
<xsl:param name="text"/>
<!-- Style variant to apply to identifier (letter) elements: 'normal', 'bold', 'italic', or empty -->
<xsl:param name="math_variant" select="''"/>
<xsl:if test="string-length($text) &gt; 0">
<!-- Process first character -->
<xsl:variable name="char" select="substring($text, 1, 1)"/>
<xsl:choose>
<!-- Fence delimiters with fence="true" -->
<xsl:when test="$char = '(' or $char = ')' or $char = '[' or $char = ']' or $char = '|'">
<mo fence="true"><xsl:value-of select="$char"/></mo>
</xsl:when>
<!-- Plain operators (no fence attribute) -->
<!-- NOTE: { and } are converted to markers to distinguish source braces from generated LaTeX braces -->
<xsl:when test="$char = '+' or $char = '-' or $char = '=' or $char = '&lt;' or $char = '&gt;' or $char = '*' or $char = '/' or $char = '±' or $char = '≠' or $char = '≤' or $char = '≥' or $char = '×' or $char = '÷'">
<mo><xsl:value-of select="$char"/></mo>
</xsl:when>
<!-- Source braces - use markers -->
<xsl:when test="$char = '{'">
<mo>__LBRACE__</mo>
</xsl:when>
<xsl:when test="$char = '}'">
<mo>__RBRACE__</mo>
</xsl:when>
<!-- Numbers -->
<xsl:when test="contains('0123456789', $char)">
<mn><xsl:value-of select="$char"/></mn>
</xsl:when>
<!-- Identifiers (letters) -->
<xsl:otherwise>
<mi>
<!-- Apply mathvariant from parent run style if available -->
<xsl:if test="$math_variant != ''">
<xsl:attribute name="mathvariant"><xsl:value-of select="$math_variant"/></xsl:attribute>
</xsl:if>
<xsl:value-of select="$char"/>
</mi>
</xsl:otherwise>
</xsl:choose>
<!-- Recursively process remaining characters -->
<xsl:if test="string-length($text) &gt; 1">
<xsl:call-template name="split-operators">
<xsl:with-param name="text" select="substring($text, 2)"/>
<xsl:with-param name="math_variant" select="$math_variant"/>
</xsl:call-template>
</xsl:if>
</xsl:if>
</xsl:template>
<!-- Properties elements - ignore -->
<xsl:template match="m:rPr | m:ctrlPr | m:dPr | m:fPr | m:funcPr | m:limLowPr | m:limUppPr | m:naryPr | m:phantPr | m:radPr | m:sPrePr | m:sSubPr | m:sSubSupPr | m:sSupPr | m:accPr | m:barPr | m:boxPr | m:borderBoxPr | m:dPr | m:eqArrPr | m:groupChrPr | m:mPr | w:rPr">
<!-- Ignore formatting properties -->
</xsl:template>
<!-- Control properties - ignore -->
<xsl:template match="m:ctrlPr">
<!-- Ignore control properties -->
</xsl:template>
<!-- Default template -->
<xsl:template match="*">
<xsl:apply-templates select="*"/>
</xsl:template>
</xsl:stylesheet>

View File

@@ -0,0 +1,25 @@
"""
Core pipeline infrastructure.
This package contains the foundational components of the refactored
converter architecture: interfaces, context, and pipeline orchestrator.
"""
from .context import ConversionContext
from .pipeline import ConversionPipeline
from .interfaces import Stage, Visitor, Strategy, VisitorRegistry
from .xslt_transformer import omml_to_mathml, _local, _text_content
from .omml_parser_core import omml_to_mathml_fallback
__all__ = [
'ConversionContext',
'ConversionPipeline',
'Stage',
'Visitor',
'Strategy',
'VisitorRegistry',
'omml_to_mathml',
'omml_to_mathml_fallback',
'_local',
'_text_content',
]

View File

@@ -0,0 +1,290 @@
"""
Caching utilities for performance optimization.
Provides singleton pattern for expensive-to-create objects like
XSL transformers, and LRU caching for frequently-used operations.
"""
import os
import threading
from typing import Dict, Optional
from lxml import etree as ET
from functools import lru_cache
class XSLTransformerCache:
"""
Thread-safe singleton cache for XSL transformers.
The OMML2MML.xsl file is expensive to parse (lxml XSLT compilation).
This cache ensures it's only parsed once and reused across all conversions.
Example:
>>> cache = XSLTransformerCache.get_instance()
>>> transformer = cache.get_transformer('path/to/OMML2MML.xsl')
>>> # Subsequent calls return cached transformer
>>> transformer2 = cache.get_transformer('path/to/OMML2MML.xsl')
>>> assert transformer is transformer2
"""
_instance: Optional['XSLTransformerCache'] = None
_lock = threading.Lock()
def __init__(self):
"""Initialize cache storage."""
self._transformers: Dict[str, ET.XSLT] = {}
self._cache_lock = threading.Lock()
@classmethod
def get_instance(cls) -> 'XSLTransformerCache':
"""
Get singleton instance of the cache.
Thread-safe singleton pattern using double-checked locking.
Returns:
Singleton XSLTransformerCache instance
"""
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def get_transformer(self, xsl_path: str) -> ET.XSLT:
"""
Get cached XSL transformer or create and cache new one.
Args:
xsl_path: Path to XSL stylesheet file
Returns:
Compiled XSLT transformer
Raises:
FileNotFoundError: If XSL file doesn't exist
ET.XSLTParseError: If XSL file is malformed
Example:
>>> cache = XSLTransformerCache.get_instance()
>>> transformer = cache.get_transformer('OMML2MML.xsl')
>>> result = transformer(omml_doc)
"""
# Normalize path for consistent caching
xsl_path = os.path.abspath(xsl_path)
# Check cache (read lock)
if xsl_path in self._transformers:
return self._transformers[xsl_path]
# Not in cache - create with write lock
with self._cache_lock:
# Double-check after acquiring lock
if xsl_path in self._transformers:
return self._transformers[xsl_path]
# Validate file exists
if not os.path.exists(xsl_path):
raise FileNotFoundError(f"XSL file not found: {xsl_path}")
# Parse and compile XSL
try:
xsl_doc = ET.parse(xsl_path)
transformer = ET.XSLT(xsl_doc)
self._transformers[xsl_path] = transformer
return transformer
except (ET.XSLTParseError, ET.XMLSyntaxError) as e:
raise ET.XSLTParseError(f"Failed to parse XSL file {xsl_path}: {e}") from e
def clear_cache(self) -> None:
"""
Clear all cached transformers.
Useful for testing or when XSL files have been updated.
Example:
>>> cache = XSLTransformerCache.get_instance()
>>> cache.clear_cache()
"""
with self._cache_lock:
self._transformers.clear()
def get_cache_size(self) -> int:
"""
Get number of cached transformers.
Returns:
Number of cached XSL transformers
Example:
>>> cache = XSLTransformerCache.get_instance()
>>> size = cache.get_cache_size()
>>> print(f"Cached {size} transformers")
"""
return len(self._transformers)
class ConverterObjectPool:
"""
Object pool for visitor registries and strategies.
Maintains a pool of pre-initialized conversion objects to avoid
recreation overhead for each equation.
Thread-safe implementation using locks.
Example:
>>> pool = ConverterObjectPool(pool_size=4)
>>> obj = pool.acquire_converter('math', wrap_identifiers=False)
>>> # Use converter...
>>> pool.release_converter(obj)
"""
def __init__(self, pool_size: int = 4):
"""
Initialize object pool.
Args:
pool_size: Maximum number of pooled objects
"""
self.pool_size = pool_size
self._available = []
self._in_use = set()
self._lock = threading.Lock()
def acquire_converter(
self,
subject_mode: str = 'auto',
wrap_identifiers: bool = False,
):
"""
Acquire a converter from the pool.
If pool is exhausted, creates a new converter.
Args:
subject_mode: Subject formatting mode
wrap_identifiers: Wrap identifiers in \\mathrm{}
Returns:
Converter instance
Example:
>>> pool = ConverterObjectPool()
>>> converter = pool.acquire_converter('math')
"""
with self._lock:
# Try to reuse from pool
for i, (cached_mode, cached_wrap, obj) in enumerate(self._available):
if cached_mode == subject_mode and cached_wrap == wrap_identifiers:
self._available.pop(i)
self._in_use.add(id(obj))
return obj
# Pool miss - create new converter
from ..compat import LegacyMathMLToTeXConverter
converter = LegacyMathMLToTeXConverter(
wrap_identifiers_in_mathrm=wrap_identifiers,
subject_mode=subject_mode,
)
self._in_use.add(id(converter))
return converter
def release_converter(
self,
converter,
subject_mode: str = 'auto',
wrap_identifiers: bool = False,
) -> None:
"""
Release converter back to pool.
Args:
converter: Converter instance to release
subject_mode: Subject mode of the converter
wrap_identifiers: Wrap identifiers setting
Example:
>>> pool.release_converter(converter, 'math', False)
"""
with self._lock:
obj_id = id(converter)
if obj_id in self._in_use:
self._in_use.remove(obj_id)
# Only pool if under limit
if len(self._available) < self.pool_size:
self._available.append((subject_mode, wrap_identifiers, converter))
def clear_pool(self) -> None:
"""Clear the object pool."""
with self._lock:
self._available.clear()
self._in_use.clear()
# LRU cache for expensive pattern matching operations
@lru_cache(maxsize=256)
def cached_function_detection(text: str) -> Optional[str]:
"""
Cached function name detection for fragmented functions.
Args:
text: Text to check for function names
Returns:
Function name if detected, None otherwise
Example:
>>> cached_function_detection('sin')
'sin'
>>> cached_function_detection('xyz')
None
"""
from ..data import MATH_FUNCTIONS
text_lower = text.lower()
if text_lower in MATH_FUNCTIONS:
return text_lower
return None
@lru_cache(maxsize=128)
def cached_chemical_element_detection(symbol: str) -> bool:
"""
Cached chemical element symbol detection.
Args:
symbol: Symbol to check
Returns:
True if symbol is a chemical element
Example:
>>> cached_chemical_element_detection('Fe')
True
>>> cached_chemical_element_detection('Xy')
False
"""
from ..data import CHEMICAL_ELEMENTS
return symbol in CHEMICAL_ELEMENTS
def clear_all_caches() -> None:
"""
Clear all caches (XSL, LRU caches, object pools).
Useful for testing or when memory needs to be reclaimed.
Example:
>>> clear_all_caches()
"""
# Clear XSL cache
cache = XSLTransformerCache.get_instance()
cache.clear_cache()
# Clear LRU caches
cached_function_detection.cache_clear()
cached_chemical_element_detection.cache_clear()

View File

@@ -0,0 +1,163 @@
"""
Conversion context data structure.
This module defines the ConversionContext dataclass that holds all state
and intermediate results throughout the conversion pipeline.
"""
from dataclasses import dataclass, field
from typing import Optional, List, Dict, Any
@dataclass
class ConversionContext:
"""
Shared state for the conversion pipeline.
This context object flows through all pipeline stages, accumulating
intermediate results and maintaining conversion state.
Attributes:
input_type: Type of input ('docx', 'omml', 'mathml', 'stdin')
subject_mode: Subject area hint ('math', 'chemistry', 'physics', 'statistics', 'auto')
wrap_identifiers: Whether to wrap identifiers in \\mathrm{} (default: True)
prefer_xlongequal: Use \\xlongequal for overset equals (default: True)
# Intermediate results
omml: OMML XML string (if applicable)
mathml: MathML XML string
latex: Generated LaTeX string
# Validation state
validation_errors: List of validation error messages
validation_warnings: List of validation warning messages
is_valid: Whether MathML passed validation
# Color optimization state
uniform_equation_color: Hex color if entire equation uses same color, else empty
suppress_leaf_colors: Flag to suppress individual element colors when uniform color detected
# Metadata
equation_count: Number of equations processed
current_equation_index: Index of current equation being processed
context_hints: Additional context information
# Performance metrics
stage_timings: Dict mapping stage name to execution time (seconds)
total_time: Total processing time (seconds)
"""
# Configuration
input_type: str
subject_mode: str = "math"
wrap_identifiers: bool = True
prefer_xlongequal: bool = True
# Intermediate results
omml: Optional[str] = None
mathml: Optional[str] = None
latex: Optional[str] = None
# Validation results
validation_errors: List[str] = field(default_factory=list)
validation_warnings: List[str] = field(default_factory=list)
is_valid: bool = True
# Color optimization state
uniform_equation_color: str = ""
suppress_leaf_colors: bool = False
# Script context tracking (for fraction sizing)
script_context_stack: List[str] = field(default_factory=list)
display_style_context: bool = False
# Set theory mode (for unclosed set notation)
set_theory_mode: bool = False
# Chemistry equation detection (cached)
is_chemistry_equation: Optional[bool] = None
# Metadata
equation_count: int = 0
current_equation_index: int = 0
context_hints: Dict[str, Any] = field(default_factory=dict)
# Performance metrics
stage_timings: Dict[str, float] = field(default_factory=dict)
total_time: float = 0.0
def add_error(self, error: str) -> None:
"""Add a validation error."""
self.validation_errors.append(error)
self.is_valid = False
def add_warning(self, warning: str) -> None:
"""Add a validation warning."""
self.validation_warnings.append(warning)
def record_stage_time(self, stage_name: str, time_seconds: float) -> None:
"""Record execution time for a stage."""
self.stage_timings[stage_name] = time_seconds
def has_errors(self) -> bool:
"""Check if any errors were recorded."""
return len(self.validation_errors) > 0
def has_warnings(self) -> bool:
"""Check if any warnings were recorded."""
return len(self.validation_warnings) > 0
def clear_validation_state(self) -> None:
"""Clear validation errors and warnings."""
self.validation_errors.clear()
self.validation_warnings.clear()
self.is_valid = True
def push_script_context(self, element_type: str) -> None:
"""
Push a script context (msup/msub/msubsup) onto the stack.
Args:
element_type: The element type entering script context
"""
self.script_context_stack.append(element_type)
def pop_script_context(self) -> Optional[str]:
"""
Pop a script context from the stack.
Returns:
The popped element type, or None if stack was empty
"""
return self.script_context_stack.pop() if self.script_context_stack else None
def in_script_context(self) -> bool:
"""Check if currently inside a script context (superscript/subscript)."""
return len(self.script_context_stack) > 0
def __str__(self) -> str:
"""String representation for debugging."""
status = "valid" if self.is_valid else "invalid"
return (
f"ConversionContext("
f"input_type={self.input_type}, "
f"subject_mode={self.subject_mode}, "
f"status={status}, "
f"equations={self.equation_count})"
)
def __repr__(self) -> str:
"""Detailed representation for debugging."""
return (
f"ConversionContext(\n"
f" input_type={self.input_type!r},\n"
f" subject_mode={self.subject_mode!r},\n"
f" wrap_identifiers={self.wrap_identifiers},\n"
f" prefer_xlongequal={self.prefer_xlongequal},\n"
f" is_valid={self.is_valid},\n"
f" equation_count={self.equation_count},\n"
f" errors={len(self.validation_errors)},\n"
f" warnings={len(self.validation_warnings)},\n"
f" total_time={self.total_time:.4f}s\n"
f")"
)

View File

@@ -0,0 +1,298 @@
"""
Base interfaces for the refactored architecture.
This module defines the abstract base classes (interfaces) that establish
the contracts for all components in the refactored converter architecture.
"""
from abc import ABC, abstractmethod
from typing import Any, Optional, List
from lxml import etree as ET
class ConversionContext:
"""
Shared context object that flows through the conversion pipeline.
This context holds all state and intermediate results during the conversion
process, allowing stages to communicate and share data.
"""
pass # Will be implemented as dataclass in context.py
class Stage(ABC):
"""
Abstract base class for pipeline stages.
Each stage represents a distinct step in the conversion pipeline
(e.g., input parsing, validation, transformation, conversion, output).
"""
@abstractmethod
def process(self, context: ConversionContext) -> ConversionContext:
"""
Process the conversion context and return updated context.
Args:
context: The current conversion context with all state
Returns:
Updated conversion context with stage results
Raises:
ValueError: If stage preconditions are not met
RuntimeError: If stage processing fails
"""
pass
@abstractmethod
def validate_input(self, context: ConversionContext) -> bool:
"""
Validate that stage preconditions are met.
Args:
context: The current conversion context
Returns:
True if preconditions are met, False otherwise
"""
pass
@property
@abstractmethod
def name(self) -> str:
"""Get the human-readable name of this stage."""
pass
class Visitor(ABC):
"""
Abstract base class for MathML element visitors.
Each visitor handles conversion of a specific MathML element type
(e.g., <mi>, <mo>, <mn>, <mfrac>) to LaTeX format.
"""
def __init__(self, strategy: 'Strategy'):
"""
Initialize visitor with a conversion strategy.
Args:
strategy: The subject-specific formatting strategy to use
"""
self.strategy = strategy
self.context: Optional[ConversionContext] = None
@abstractmethod
def visit(self, node: ET.Element) -> str:
"""
Convert MathML element to LaTeX string.
Args:
node: The MathML element to convert
Returns:
LaTeX string representation of the element
Raises:
ValueError: If node is not the expected element type
"""
pass
@abstractmethod
def can_handle(self, node: ET.Element) -> bool:
"""
Check if this visitor can handle the given node.
Args:
node: The MathML element to check
Returns:
True if this visitor can convert the node, False otherwise
"""
pass
@property
@abstractmethod
def element_name(self) -> str:
"""Get the MathML element name this visitor handles (e.g., 'mi', 'mo')."""
pass
class Strategy(ABC):
"""
Abstract base class for subject-specific formatting strategies.
Strategies encapsulate the subject-specific logic for formatting
mathematical notation (e.g., math, chemistry, physics, statistics).
"""
@abstractmethod
def format_identifier(self, text: str, node: ET.Element) -> str:
"""
Format an identifier (<mi> element) according to subject conventions.
Args:
text: The identifier text content
node: The <mi> element node (for accessing attributes)
Returns:
Formatted LaTeX string for the identifier
Examples:
Math: "sin" → "\\sin" (function recognition)
Chemistry: "Fe" → "\\mathrm{Fe}" (element formatting)
"""
pass
@abstractmethod
def format_operator(self, text: str, node: ET.Element) -> str:
"""
Format an operator (<mo> element) according to subject conventions.
Args:
text: The operator text content
node: The <mo> element node
Returns:
Formatted LaTeX string for the operator
Examples:
Math: "+" → "+"
Chemistry: "→" → "\\rightarrow"
"""
pass
@abstractmethod
def format_number(self, text: str, node: ET.Element) -> str:
"""
Format a number (<mn> element) according to subject conventions.
Args:
text: The number text content
node: The <mn> element node
Returns:
Formatted LaTeX string for the number
"""
pass
@abstractmethod
def detect_context(self, equation_text: str) -> bool:
"""
Detect if an equation belongs to this subject area.
Args:
equation_text: The full equation text to analyze
Returns:
True if equation likely belongs to this subject, False otherwise
"""
pass
@property
@abstractmethod
def name(self) -> str:
"""Get the strategy name (e.g., 'math', 'chemistry', 'physics')."""
pass
class VisitorRegistry(ABC):
"""
Abstract base class for visitor registry/factory.
The registry maintains a mapping of MathML element names to their
corresponding visitor classes and provides visitor instances on demand.
"""
@abstractmethod
def register(self, element_name: str, visitor_class: type) -> None:
"""
Register a visitor class for a specific element type.
Args:
element_name: MathML element name (e.g., 'mi', 'mo')
visitor_class: The visitor class to handle this element
"""
pass
@abstractmethod
def get_visitor(self, element_name: str, strategy: Strategy) -> Visitor:
"""
Get a visitor instance for the given element type.
Args:
element_name: MathML element name
strategy: The conversion strategy to use
Returns:
Visitor instance configured with the strategy
Raises:
ValueError: If no visitor registered for element_name
"""
pass
@abstractmethod
def has_visitor(self, element_name: str) -> bool:
"""
Check if a visitor is registered for the element type.
Args:
element_name: MathML element name
Returns:
True if visitor is registered, False otherwise
"""
pass
class Parser(ABC):
"""
Abstract base class for input parsers.
Parsers handle different input formats (DOCX, OMML, MathML) and convert
them to a unified format (MathML strings) for the conversion pipeline.
"""
@abstractmethod
def parse(self, source: str) -> List[str]:
"""
Parse input and return list of MathML strings.
Args:
source: Input source (file path, XML string, etc.)
Returns:
List of MathML equation strings
Raises:
ValueError: If input format is invalid
FileNotFoundError: If source file doesn't exist (for file-based parsers)
RuntimeError: If parsing fails
"""
pass
@abstractmethod
def can_handle(self, source: str) -> bool:
"""
Check if this parser can handle the given input source.
Args:
source: Input source to check
Returns:
True if this parser can handle the source, False otherwise
"""
pass
@property
@abstractmethod
def parser_type(self) -> str:
"""
Get the parser type identifier.
Returns:
Parser type string (e.g., "docx", "omml", "mathml")
"""
pass

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,171 @@
"""
Pipeline orchestrator for the conversion process.
This module implements the pipeline pattern, coordinating the execution
of multiple stages in sequence to convert inputs to LaTeX.
"""
import time
import logging
from typing import List, Optional
from .interfaces import Stage, ConversionContext
logger = logging.getLogger(__name__)
class ConversionPipeline:
"""
Orchestrates conversion through multiple stages.
The pipeline executes stages in sequence, passing the context from
one stage to the next. Each stage can read from and write to the
shared context object.
Example:
>>> pipeline = (ConversionPipeline()
... .add_stage(InputStage())
... .add_stage(ValidationStage())
... .add_stage(ConversionStage())
... .add_stage(OutputStage()))
>>> context = ConversionContext(input_type='mathml', mathml='<math>...</math>')
>>> result = pipeline.process(context)
"""
def __init__(self, enable_validation: bool = True, enable_timing: bool = True):
"""
Initialize the pipeline.
Args:
enable_validation: Whether to validate stage preconditions before execution
enable_timing: Whether to record stage execution times
"""
self.stages: List[Stage] = []
self.enable_validation = enable_validation
self.enable_timing = enable_timing
def add_stage(self, stage: Stage) -> 'ConversionPipeline':
"""
Add a stage to the pipeline (fluent interface).
Args:
stage: The stage to add to the pipeline
Returns:
Self for method chaining
Raises:
TypeError: If stage is not an instance of Stage
"""
if not isinstance(stage, Stage):
raise TypeError(f"Stage must be instance of Stage, got {type(stage)}")
self.stages.append(stage)
logger.debug(f"Added stage: {stage.name}")
return self
def process(self, context: ConversionContext) -> ConversionContext:
"""
Execute all stages in order.
Args:
context: The initial conversion context
Returns:
Updated conversion context after all stages
Raises:
ValueError: If any stage validation fails
RuntimeError: If any stage processing fails
"""
if not self.stages:
raise RuntimeError("Pipeline has no stages configured")
logger.info(f"Starting pipeline with {len(self.stages)} stages")
pipeline_start_time = time.time()
try:
for stage in self.stages:
context = self._execute_stage(stage, context)
# Record total pipeline time
pipeline_end_time = time.time()
context.total_time = pipeline_end_time - pipeline_start_time
logger.info(
f"Pipeline completed successfully in {context.total_time:.4f}s "
f"({len(self.stages)} stages)"
)
return context
except Exception as e:
logger.error(f"Pipeline failed: {e}", exc_info=True)
raise
def _execute_stage(self, stage: Stage, context: ConversionContext) -> ConversionContext:
"""
Execute a single stage with validation and timing.
Args:
stage: The stage to execute
context: The current context
Returns:
Updated context after stage execution
Raises:
ValueError: If stage validation fails
RuntimeError: If stage execution fails
"""
logger.debug(f"Executing stage: {stage.name}")
# Validate preconditions if enabled
if self.enable_validation:
if not stage.validate_input(context):
error_msg = f"Stage '{stage.name}' validation failed"
logger.error(error_msg)
raise ValueError(error_msg)
# Execute stage with timing
if self.enable_timing:
stage_start_time = time.time()
context = stage.process(context)
stage_end_time = time.time()
stage_time = stage_end_time - stage_start_time
context.record_stage_time(stage.name, stage_time)
logger.debug(f"Stage '{stage.name}' completed in {stage_time:.4f}s")
else:
context = stage.process(context)
return context
def clear(self) -> 'ConversionPipeline':
"""
Remove all stages from the pipeline.
Returns:
Self for method chaining
"""
self.stages.clear()
logger.debug("Pipeline cleared")
return self
def get_stages(self) -> List[Stage]:
"""Get a copy of the stages list."""
return self.stages.copy()
def get_stage_count(self) -> int:
"""Get the number of stages in the pipeline."""
return len(self.stages)
def __repr__(self) -> str:
"""String representation for debugging."""
stage_names = [stage.name for stage in self.stages]
return f"ConversionPipeline(stages={stage_names})"
def __str__(self) -> str:
"""Human-readable representation."""
if not self.stages:
return "ConversionPipeline (empty)"
stage_list = "\n ".join([f"{i+1}. {stage.name}" for i, stage in enumerate(self.stages)])
return f"ConversionPipeline ({len(self.stages)} stages):\n {stage_list}"

View File

@@ -0,0 +1,138 @@
"""
OMML to MathML transformation using Microsoft's OMML2MML.XSL.
This module provides XSLT-based transformation of OMML (Office Math Markup Language)
to MathML (Mathematical Markup Language) using Microsoft's official OMML2MML.XSL stylesheet.
Includes fallback to Python-based parser for edge cases.
"""
import os
from typing import List, Optional
from lxml import etree as ET
def omml_to_mathml(omml_xml: str, xsl_path: str, timer: Optional['PerformanceTimer'] = None) -> str:
"""Transform OMML XML string to MathML XML string using OMML2MML.XSL.
Args:
omml_xml: OMML XML string (raw equation markup from Word)
xsl_path: Path to OMML2MML.XSL transformation stylesheet
timer: Optional performance timer for instrumentation
Returns:
MathML XML string
Raises:
ValueError: If OMML XML is invalid
FileNotFoundError: If XSL file not found
Example:
>>> omml = '<m:oMath xmlns:m="..."><m:r><m:t>x</m:t></m:r></m:oMath>'
>>> mathml = omml_to_mathml(omml, 'OMML2MML.xsl')
>>> '<math' in mathml
True
Note:
Falls back to omml_to_mathml_fallback() if XSLT transformation fails.
This ensures robust handling even when XSL file is missing or malformed.
Performance: Uses cached XSL transformer for significant speedup
(avoids reparsing XSL file for every equation).
"""
# Timing context helper
def _time(name):
if timer:
return timer.measure(name)
else:
from contextlib import contextmanager
@contextmanager
def nullcontext(enter_result=None):
yield enter_result
return nullcontext()
# Ensure required namespaces exist for fragments without declarations
with _time("xslt_namespace_preparation"):
xml_input = omml_xml.strip()
if "xmlns:m=" not in xml_input:
xml_input = (
"<root xmlns:m=\"http://schemas.openxmlformats.org/officeDocument/2006/math\" "
"xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">"
f"{xml_input}"
"</root>"
)
try:
with _time("xslt_xml_parsing"):
omml_doc = ET.fromstring(xml_input.encode("utf-8"))
except ET.XMLSyntaxError as e:
raise ValueError(f"Invalid OMML XML: {e}") from e
try:
# PERFORMANCE OPTIMIZATION: Use cached XSL transformer
# This avoids reparsing the XSL file for every equation (major speedup)
from .cache import XSLTransformerCache
with _time("xslt_cache_lookup"):
cache = XSLTransformerCache.get_instance()
transform = cache.get_transformer(xsl_path)
with _time("xslt_transformation"):
result_tree = transform(omml_doc)
result_bytes = ET.tostring(result_tree, encoding="utf-8", pretty_print=True)
return result_bytes.decode("utf-8")
except (ET.XSLTParseError, ET.XSLTApplyError, ET.XMLSyntaxError, OSError, ValueError, FileNotFoundError):
# Fallback to a minimal Python-based OMML→MathML converter for common cases
# Import here to avoid circular dependency
with _time("xslt_fallback_parser"):
from .omml_parser_core import omml_to_mathml_fallback
return omml_to_mathml_fallback(xml_input)
def _local(tag: str) -> str:
"""Extract local tag name without namespace.
Args:
tag: XML tag name, possibly with namespace prefix
Returns:
Local tag name without namespace
Example:
>>> _local('{http://www.w3.org/1998/Math/MathML}mi')
'mi'
>>> _local('mi')
'mi'
"""
if "}" in tag:
return tag.split("}", 1)[1]
return tag
def _text_content(node: ET._Element) -> str:
"""Flatten text under a node (best-effort).
NOTE: Does NOT strip() to preserve space-only content like NBSP and regular spaces.
Spaces are semantically meaningful in mathematical notation.
Args:
node: XML element node
Returns:
Combined text content from node and all children
Example:
>>> from lxml import etree as ET
>>> elem = ET.fromstring('<mi>x</mi>')
>>> _text_content(elem)
'x'
"""
texts: List[str] = []
if node.text:
texts.append(node.text)
for child in node:
texts.append(_text_content(child))
if child.tail:
texts.append(child.tail)
# IMPORTANT: Do not strip() here - spaces are meaningful!
# Space handling is done in the converter (e.g., _convert_spaces_to_latex)
return "".join(texts)