将MathML公式转换为域代码公式
在上一篇文章中将Html导出为Docx介绍了如何包含 MathML 公式的网页转换为 Docx 文件,但我个人总是嫌 Word 公式不好看,在整理试卷的时候我用的都是 Word 自带的域代码公式,所以想在题库导出中添加生成域代码公式的功能。
比方说 \(\sqrt 2 \) 的域代码公式是 EQ \R(2),如何用编程的方式将这个公式插入到 Word 文件中去呢?可以用 Open XML SDK 2.5 Productivity Tool 打开包含这个公式的 Word 文件,找到对应的方法如下:
Paragraph paragraph1 = new Paragraph(){ RsidParagraphAddition = "002A4C03", RsidRunAdditionDefault = "003B4857" }; Run run1 = new Run(); FieldChar fieldChar1 = new FieldChar(){ FieldCharType = FieldCharValues.Begin }; run1.Append(fieldChar1); Run run2 = new Run(); FieldCode fieldCode1 = new FieldCode(){ Space = SpaceProcessingModeValues.Preserve }; fieldCode1.Text = " EQ \\R(2)"; run2.Append(fieldCode1); Run run3 = new Run(); FieldChar fieldChar2 = new FieldChar(){ FieldCharType = FieldCharValues.End }; run3.Append(fieldChar2); paragraph1.Append(run1); paragraph1.Append(run2); paragraph1.Append(run3);
非常简单!因此我采用了如下方法实现了域代码公式的插入。
初步的尝试
在前端的 Html 页面中输入域代码公式,由于不想在页面中显示这个信息,我将它放置在 <i> 标签的 data-eq 属性中,即:
<i data-eq=”\R(2)”></i>
然后在提交数据到后台的时候,将这个信息包装在一个自定义的标签 <eq> 中,即:
<eq>\R(2)</eq>
在后端提取出“\R(2)”,由上面的 C# 代码将域代码公式插入到 Word 中,可以正常显示。
期间费了好大劲解决了这么一个问题:前端输入题目所用的富文本编辑器是 CKEditor,它会自动将空的 <i></i> 标签过滤掉,在网上搜到了答案(具体网址忘了记录,好像是在 CKEditor 官网论坛上),即在配置文件 config.js 中加上如下代码:
CKEDITOR.editorConfig = function( config ) { …… // 允许输入所有元素 config.allowedContent = { $1: { elements: CKEDITOR.dtd, attributes: true, styles: true, classes: false } }; // 过滤掉不允许输入的元素 config.disallowedContent = 'script; *[on*];a;blockquote;body;dd;dl;div;dt;fieldset;form;frame;frameset;h1;h2;h3;h4; h5;h6;noframes;ol;ul;center;dir;hr;menu;pre;li;head'; // 只允许输入 <i data-eq=*></i>的形式 config.protectedSource.push(/<i data-eq=[\s\S]*?\><\/i>/g); };
但这种做法有两个很大的问题:
1.在题库中已经输入了几千个以 Latex 格式的保存的公式,用这个方法意味着还需要输入几千个域代码公式,工作量不小。
2.不能处理包含上,下标以及斜体的公式。
目前采用的前端方法
要解决上述第1个问题,思路很简单,因为 Html 网页中可以提取公式的 Latex 表达,只要将它转变为域代码公式即可。以 \(\sqrt 2 \) 为例,就是将“\ ( \sqrt 2 \ )”转换成“\R(2)”,但自身水平不够不知如何处理。
万幸在 https://stackoverflow.com/questions/26357109/parsing-mathml-to-plain-math-expression 找到了另一个方法,即将 MathML 公式在前端转换为普通的文本格式,最关键的一个方法为remove_tags,代码如下:
function remove_tags(node) { var result = ""; var nodes = node.childNodes; var tagName = node.tagName; if (!nodes.length) { if (node.nodeValue === " ") result = ""; else result = node.nodeValue; } else if (tagName === "mroot") { result = "\\R(" + remove_tags(nodes[1]) + "," + remove_tags(nodes[0]) + ")"; } else if (tagName === "mfrac") { result = "\\F("+remove_tags(nodes[0])+","+remove_tags(nodes[1])+")"; } else if (tagName === "msup") { result = remove_tags(nodes[0]) + "^(" + remove_tags(nodes[1])+")"; } else if (tagName === "msub") { result = remove_tags(nodes[0]) + "_(" + remove_tags(nodes[1])+")"; } else if (tagName === "msubsup") { result = remove_tags(nodes[0]) + "_(" + remove_tags(nodes[1]) + ")^(" + remove_tags(nodes[2])+")"; } else for (var i = 0; i < nodes.length; ++i) { result += remove_tags(nodes[i]); } if (tagName === "msqrt") result = "\\R(" + result + ")"; return result; }
上述方法利用递归将一段 MathML 转换为文本格式。不过没有支持所有 MathML 标签,不支持的原因主要是某些公式在高中物理题库中用不到(比方说矩阵),mover 标签主要用来表示平均值,例如 \(\bar v\),但域代码公式太难看,也不支持,遇到这种公式,会以 Word 公式的形式导出。下表是支持情况:
元素 |
支持情况 |
mrow |
√ |
mfrac |
分数 |
msqrt |
平方根 |
mroot |
高次平方根 |
mstyle |
× |
merror |
× |
mpadded |
× |
mphantom |
× |
mfenced |
× |
menclose |
× |
msub |
下标 |
msup |
上标 |
msubsup |
上下标 |
munder |
× |
mover |
× |
munderover |
× |
mmultiscripts |
× |
mtable |
× |
mlabeledtr |
× |
mtr |
× |
mtd |
× |
mstack |
× |
mlongdiv |
× |
msgroup |
× |
msrow |
× |
mscarries |
× |
mscarry |
× |
maction |
× |
math |
√ |
最后在 stringifyMathML 方法中调用 remove_tags 即可,完整代码如下:
function stringifyMathML(input) { // 只支持<msub><msup><mfrac><msqrt><mroot>等标签 var reg = /mover|mstyle|merror|mpadded|mphantom|mfenced|menclose|munder|mover |munderover|mmultiscripts|mtable|mlabeledtr|mtr|mtd|mstack|mlongdiv|msgroup|mscarries|mscarry|maction/i; if (!reg.test(input)) { // 删除空字符<mo><\/mo> var strMath = input.replace(/<mo><\/mo>/g, ""); parser = new DOMParser(); xmlDoc = parser.parseFromString(strMath, "text/xml"); var plainText = remove_tags(xmlDoc.documentElement); // 替换某些字符 plainText = plainText.replace(/\^\(∘\)/g, "°"); plainText = plainText.replace(/\^\(′\)/g, "′"); plainText = plainText.replace(/\^\(″\)/g, "″"); plainText = plainText.replace(/\^\(‴\)/g, "‴"); // 拆分字符串,并用#连接为一个字符串 var out = plainText.match(/\\R\(|\\F\(|\^\(.*?\)|_\(.*?\)|sin|cos|tan|tg|cot|ctg|ln|log|\S/gi); var finalOut = out.join("#"); // 将此字符串包含在<eq>标签中 $(this).replaceWith("<eq>" + finalOut + "<eq>"); } }
例如公式 \(\sqrt[3]{{\frac{{G'{M_1}T_1^2}}{{4{\pi ^2}}}}}\) 的 MathML 公式如下:
<math xmlns=”http://www.w3.org/1998/Math/MathML”> <mroot> <mrow> <mfrac> <mrow> <msup> <mi>G</mi> <mo>′</mo> </msup> <mrow> <msub> <mi>M</mi> <mn>1</mn> </msub> </mrow> <msubsup> <mi>T</mi> <mn>1</mn> <mn>2</mn> </msubsup> <mi>sin</mi> <mo>⁡</mo> <msup> <mn>30</mn> <mo>∘</mo> </msup> </mrow> <mrow> <mn>4</mn> <mrow> <msup> <mi>π</mi> <mn>2</mn> </msup> </mrow> </mrow> </mfrac> </mrow> <mn>3</mn> </mroot> </math>
经过上面的处理输出的代码为:
\R(#3#,#\F(#G#′#M#_(1)#T#_(1)#^(2)#sin#3#0#°#,#4#π#^(2)#)#)
其中 ^ 表示上标,_ 表示下标。
代码的解释
- 为什么要删除空字符 <mo>⁡<\/mo>?
var strMath = input.replace(/<mo>⁡<\/mo>/g, "");
因为在 sin、cos、cot、tan 之后会出现一个诡异的空字符 ⁡,原因不明,只能简单粗暴地将它删除。
- 为什么要替换某些字符?
plainText = plainText.replace(/\^\(∘\)/g, "°"); plainText = plainText.replace(/\^\(′\)/g, "′"); plainText = plainText.replace(/\^\(″\)/g, "″"); plainText = plainText.replace(/\^\(‴\)/g, "‴");
非常奇怪,度符号“°”、撇号“′”、“″”、“‴”在MathML中竟然是用上标表示的,所以只能手动将它们替换为正常的字符。
- 为什么要拆分字符串,并用#连接为一个字符串?
var out = plainText.match(/\\R\(|\\F\(|\^\(.*?\)|_\(.*?\)|sin|cos|tan|tg|cot|ctg|ln|log|\S/gi); var finalOut = out.join("#");
这是为了后台代码设置斜体用的。
按照科学论文的规范,通常这样规定:
- 在数学表达式中,按规定函数、数学符号应用正体表示。例如,D、sin、cos、ln、e、exp、等均为数学符号,因此使用正体。
- 数学式中的变量、物理量用斜体。其中一些普适物理常数,例如 Boltzmann 常数 k、通用气体常数 R、Planck 常数 h、Faraday 常数 F 等,均用斜体字母表示。
- 用英文词字头表示上下标应用正体字母,用字母表示条件或编号代号时用斜体字母,用阿拉伯数字表示编号时,一律为正体。
- 物理量单位用正体。
- ……
挺复杂的,我做了简化,规定如下:
- 英文字母和小写希腊字母用斜体;
- 大写希腊字母和上下标一律用正体。
依据以上原则,要用正则表达式将一个公式拆分成英文字母、数字、上下标等,由于我的正则表达式水平不够,还没达到最完美的拆分方式,但也够用了。
最后用“#”将拆分后的字符拼成一个字符串用于发送到后台,之所以不用常用的“,”作为连接符是因为在公式中“,”有特殊用途。
后台代码
后台接收到字符串之后的处理代码如下:
// 找到上传的html代码中的<eq></eq>标签中的内容 HtmlNodeCollection eqs = hdoc.DocumentNode.SelectNodes("//eq"); if (eqs != null) { // 将<eq><eq>替换为"<b>/////<b>" HtmlNode newNode4Eq = HtmlNode.CreateNode("<b>/////</b>"); foreach (HtmlNode eq in eqs) { eq.ParentNode.ReplaceChild(newNode4Eq, eq); } } using (MemoryStream memoryStream = new MemoryStream()) { doc.WriteByteArray(memoryStream); using (WordprocessingDocument wordDoc = WordprocessingDocument.Open(memoryStream, true)) { List<Text> replaceTexts = new List<Text>(); List<Text> replaceEqs = new List<Text>(); // 首先找到要插入公式的位置 foreach (var paragraph in wordDoc.MainDocumentPart.Document.Body.Descendants<Paragraph>()) { foreach (var run in paragraph.Descendants<Run>()) { Text text = run.Elements<Text>().Where(p => p.Text == "////").FirstOrDefault(); Text text4Eq = run.Elements<Text>().Where(p => p.Text == "/////").FirstOrDefault(); if (text != null) replaceTexts.Add(text); if (text4Eq != null) replaceEqs.Add(text4Eq); } }; // 插入普通公式 if (replaceTexts.Count() > 0) { int i = 0; foreach (var replaceText in replaceTexts) { foreach (var currentRun in ommls[i].Descendants<DocumentFormat.OpenXml.Math.Run>()) { // Add font information to every run. DocumentFormat.OpenXml.Wordprocessing.RunProperties runProperties2 = new DocumentFormat.OpenXml.Wordprocessing.RunProperties(); RunFonts runFonts2 = new RunFonts() { Ascii = "Cambria Math", HighAnsi = "Cambria Math" }; runProperties2.Append(runFonts2); currentRun.InsertAt(runProperties2, 0); } replaceText.Parent.ReplaceChild(ommls[i], replaceText); i += 1; } } // 插入域代码公式 if (replaceEqs.Count() > 0) { int j = 0; Regex reTriFun = new Regex("sin|cos|tan|cot|ctg|tg"); Regex reVar = new Regex("^[a-zA-Z]+|^[\u03B1-\u03C9]+"); // 判断是否英文或小写希腊 Regex reSup = new Regex("^\\^\\(", RegexOptions.IgnoreCase); // 判断开头是否是^((上标) Regex reSub = new Regex("^_\\(", RegexOptions.IgnoreCase); // 判断开头是否是_((下标) foreach (var replaceEq in replaceEqs) { // 添加域代码公式 // Begin Run run1 = new Run(); FieldChar fieldChar1 = new FieldChar() { FieldCharType = FieldCharValues.Begin }; run1.Append(fieldChar1); replaceEq.Parent.InsertAfterSelf(run1); Run run2 = new Run(); FieldCode fieldCode2 = new FieldCode() { Space = SpaceProcessingModeValues.Preserve }; fieldCode2.Text = "EQ "; run2.Append(fieldCode2); run1.InsertAfterSelf(run2); // End Run run3 = new Run(); FieldChar fieldChar3 = new FieldChar() { FieldCharType = FieldCharValues.End }; run3.Append(fieldChar3); run2.InsertAfterSelf(run3); string eq = eqs[j].InnerText; string[] eqElements = eq.Split('#'); // Begin和End之间的内容 for (int i = 0; i < eqElements.Length; i++) { Run run = new Run(); // 上标 if (reSup.IsMatch(eqElements[i])) { RunProperties runProperties = new RunProperties(); VerticalTextAlignment verticalTextAlignment = new VerticalTextAlignment() { Val = VerticalPositionValues.Superscript }; runProperties.Append(verticalTextAlignment); run.Append(runProperties); // 去掉前两个字符^(,最后一个字符) eqElements[i] = eqElements[i].Substring(2, eqElements[i].Length-3); } // 下标 else if (reSub.IsMatch(eqElements[i])) { RunProperties runProperties = new RunProperties(); VerticalTextAlignment verticalTextAlignment = new VerticalTextAlignment() { Val = VerticalPositionValues.Subscript }; runProperties.Append(verticalTextAlignment); run.Append(runProperties); // 去掉前两个字符_(,最后一个字符) eqElements[i] = eqElements[i].Substring(2, eqElements[i].Length - 3); } // 英文和小写希腊字母,但不包含(sin、cos、……) else if (!reTriFun.IsMatch(eqElements[i]) && reVar.IsMatch(eqElements[i])) { RunProperties runProperties = new RunProperties(); Italic italic = new Italic(); runProperties.Append(italic); run.Append(runProperties); } FieldCode fieldCode = new FieldCode() { Space = SpaceProcessingModeValues.Preserve }; fieldCode.Text = eqElements[i]; run.Append(fieldCode); run3.InsertBeforeSelf(run); } replaceEq.Parent.Remove(); j += 1; } } } File.WriteAllBytes("../../finalWord.docx", memoryStream.ToArray()); }
至此大功告成。还有一个bug无法解决:“<”“>”会被转义为“<”“>”,不知道在哪个环节出了问题,无法解决。
可供下载的源代码不包含前端代码。
文件下载(已下载 1690 次)发布时间:2017/6/28 下午10:41:23 阅读次数:5530