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:
851
scripts/omml_mathml2latex/core/OMML2MML.xsl
Normal file
851
scripts/omml_mathml2latex/core/OMML2MML.xsl
Normal 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 . = '<' or . = '>' 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(.)) > 1 and (contains(., '(') or contains(., ')') or contains(., '[') or contains(., ']') or contains(., '{') or contains(., '}') or contains(., '+') or contains(., '-') or contains(., '=') or contains(., '<') or contains(., '>'))">
|
||||
<!-- 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) > 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) > 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 < 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) > 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) > 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 = '<' or $char = '>' 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) > 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>
|
||||
25
scripts/omml_mathml2latex/core/__init__.py
Normal file
25
scripts/omml_mathml2latex/core/__init__.py
Normal 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',
|
||||
]
|
||||
290
scripts/omml_mathml2latex/core/cache.py
Normal file
290
scripts/omml_mathml2latex/core/cache.py
Normal 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()
|
||||
163
scripts/omml_mathml2latex/core/context.py
Normal file
163
scripts/omml_mathml2latex/core/context.py
Normal 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")"
|
||||
)
|
||||
298
scripts/omml_mathml2latex/core/interfaces.py
Normal file
298
scripts/omml_mathml2latex/core/interfaces.py
Normal 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
|
||||
2353
scripts/omml_mathml2latex/core/omml_parser_core.py
Normal file
2353
scripts/omml_mathml2latex/core/omml_parser_core.py
Normal file
File diff suppressed because it is too large
Load Diff
171
scripts/omml_mathml2latex/core/pipeline.py
Normal file
171
scripts/omml_mathml2latex/core/pipeline.py
Normal 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}"
|
||||
138
scripts/omml_mathml2latex/core/xslt_transformer.py
Normal file
138
scripts/omml_mathml2latex/core/xslt_transformer.py
Normal 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)
|
||||
Reference in New Issue
Block a user