将 Html 导出为 Docx
虽然在几年前就完成了题库系统,并使用 MathJax 实现了数学公式在网页上的显示,但题库的另一个重要功能——试卷的导出功能一直未能实现,主要的困难在于如何将 MathJax 公式转换为 word 的公式。
曾经试过 pandoc,先将Html转换为Markdown,再将 Markdown 转换为 docx 能够成功,但很多细节不是很完美,特别是没能解决图像和表格的问题。
也曾研究过 Aspose.Words,但未付费用户有功能限制,没有深入研究下去。
高考之后有了点空闲时间,花了大约三个星期实现了题库的导出功能。
第一个星期构造前端,使用 sessionStorage 处理购物篮,主要的参考文章是 使用jQuery和Session storage构建客户端购物车系统;使用 Sortable 插件实现了题目的拖动功能。
后两个星期构造后端,使用的开发工具为 Visual Studio Community 2017,框架为 Asp.Net MVC,核心组件为 Open XML SDK 2.7 和 Open-Xml-PowerTools 4.3。
要把 Html 转换为 docx,Open-Xml-PowerTools 已经做得足够完美,其中的一个类 HtmlToWmlConverterCore 可以将绝大多数Html标签——“a、b、body、caption、div、em、h1、h2、h3、h4、h5、h6、h7、h8、hr、html、i、blockquote、img、li、ol、p、s、span、strong、style、sub、sup、table、tbody、td、th、tr、u、ul、br、tt、code、kbd、samp、pre、article、hgroup、nav、section、dd、dl、dt、figure、main、abbr、bdi、bdo、cite、data、dfn、mark、q、rp、rt、ruby、small、time、var、wbr”转换为对应的 word 格式,而我主要要做的就是如何处理数学公式。流程图如下:
但在这之前,你最好去看一下 Open XML SDK 2.5 官网的一系列文章,对docx文件的构造、基本操作有所了解。由于 Word 文件本质上是 XML 文件,你需要能比较熟练的操作 XML,推荐使用 LINQ to XML(https://msdn.microsoft.com/zh-cn/library/bb387098.aspx)进行这种操作。Open-Xml-PowerTools 的作者Eric White的博客(http://www.ericwhite.com/)也很不错。
想要更深入地了解 docx 的结构和对应的 OpenXML SDK 指令,那么 Open XML SDK 2.5 Productivity Tool(https://www.microsoft.com/en-us/download/details.aspx?id=30425) 就是必不可少的。下面是这个工具的截图:

第1步 将 LaTex 转换为 MathML
题库的内容格式为 Html,公式保存为 LaTex 格式,例如\(\sqrt 2 \)的LaTex格式在题库中保存为\(\sqrt 2 \)\,我没找到.Net平台上实现此功能的类库,因此只能在前台借助于 MathJax 进行这个工作了。
我使用的 MathJax 的设置为“TeX-MML-AM_CHTML”,因此这个公式在网页中显示为:
<span class="MathJax_Preview" style="color: inherit;"></span>
<span id="MathJax-Element-1-Frame" class="mjx-chtml MathJax_CHTML" tabindex="0"
data-mathml="<math xmlns="http://www.w3.org/1998/Math/MathML"><msqrt><mn>2</mn></msqrt>
</math>" role="presentation" style="font-size: 101%; position: relative;">
<span id="MJXc-Node-4" class="mjx-math" role="math" aria-hidden="true">
<span id="MJXc-Node-5" class="mjx-mrow">
<span id="MJXc-Node-6" class="mjx-msqrt">
<span class="mjx-box" style="padding-top: 0.045em;">
<span class="mjx-surd">
<span class="mjx-char MJXc-TeX-main-R" style="padding-top: 0.518em; padding-bottom: 0.518em;">√</span>
</span>
<span class="mjx-box" style="padding-top: 0.13em; border-top: 1.2px solid;">
<span id="MJXc-Node-7" class="mjx-mrow">
<span id="MJXc-Node-8" class="mjx-mn">
<span class="mjx-char MJXc-TeX-main-R" style="padding-top: 0.389em; padding-bottom: 0.325em;">2</span>
</span>
</span>
</span>
</span>
</span>
</span>
</span><span class="MJX_Assistive_MathML" role="presentation">
<math xmlns="http://www.w3.org/1998/Math/MathML">
<msqrt>
<mn>2</mn>
</msqrt>
</math>
</span>
</span>
<script type="math/tex" id="MathJax-Element-2">\sqrt 2 </script>
竟然有这么多!仔细观察这个结构,发现要获取公式的MathML格式,只需提取 id 以“MathJax-Element-”开头的span元素中的 data-mathml 属性的内容即可,然后删除这一大堆 span 和 script 标签。这个任务可以使用 jquery 完成,具体代码略。
经过以上操作,就可以把包含<math>……</math>的内容提取出来。对应的 MathML 代码为:
<math xmlns="http://www.w3.org/1998/Math/MathML">
<msqrt>
<mn>2</mn>
</msqrt>
</math>
第2步 将 Html 转换为 XML 格式
传递后台用的是 post 形式,因此从 Request.Form 中就可以得到html的字符串,但在将这个 html 交给 Open-Xml-PowerTools 处理前需要将它转换为 XML 格式,这里用的是一个类库——html-agility-pack(https://github.com/zzzprojects/html-agility-pack),这个库还可以方便地解析和操作 XML 文件。但这个库不是必须的,用 XElement.Parse 方法和 LINQ To XML 可以完成同样的工作。代码如下:
string sourceHtml = Request.Form["html"];
// 使用HtmlAgilityPack将html转换为格式良好的xml
HtmlDocument hdoc = new HtmlDocument();
hdoc.LoadHtml(exers);
hdoc.OptionOutputAsXml = true;
StringBuilder sbXml=new StringBuilder();
using (StringWriter writer = new StringWriter(sbXml))
{
hdoc.Save(writer);
}
// 对一些特殊字符进行转义
sbXml.Replace("&", "&");
sbXml.Replace(" ", "\xA0");
sbXml.Replace(""", "\"");
sbXml.Replace("<", "~lt;");
sbXml.Replace(">", "~gt;");
sbXml.Replace("&#", "~#");
sbXml.Replace("&", "&");
sbXml.Replace("~lt;", "<");
sbXml.Replace("~gt;", ">");
sbXml.Replace("~#", "&#");
第3步 将 MathML 转换为 OMML
由于Open-Xml-PowerTools不会处理<math>标签,我也不想动它的源代码(实际上是因为源代码太复杂,我只能看懂个大概),因此需要在Open-Xml-PowerTools之前进行转换工作。
思路是:将xml中的所有<math>标签提取出来,然后将它们转换为Word中数学公式的格式——OMML(Office math markup language),在原来<math>的位置随便替换为另一个标签,其中的内容最好不会与其他内容发生冲突,我用的是“<b>////</b>”。
那么如何将MathML转换为OMML?答案是使用一个转换文件——MML2OMML.xsl,这个文件是office自带的,位于目录:%ProgramFiles%\Microsoft Office\Office12\之下(若你用的是office 2016,则在%ProgramFiles%\Microsoft Office\Office16\目录)。
不知你是否知道,将MathML公式以文本的形式粘贴到Word中时,它会自动变成Word公式,这个操作的背后就是MML2OMML.xsl在起作用。同样的目录下还有一个文件OMML2MML.xsl,它的作用是反过来转换,我们这里用不到。
\(\sqrt 2 \)的OMML代码为:
<m:oMath xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math" xmlns:mml="http://www.w3.org/1998/Math/MathML">
<m:rad>
<m:radPr>
<m:degHide m:val="on" />
</m:radPr>
<m:deg />
<m:e>
<m:r>
<m:t>2</m:t>
</m:r>
</m:e>
</m:rad>
</m:oMath>
具体的实现过程参考了这个网址:https://stackoverflow.com/questions/10993621/openxml-sdk-and-mathml。代码如下:
// 使用HtmlAgilityPack对xml进行进一步处理
hdoc.LoadHtml(sbXml.ToString());
// 提取xml中的所有<math></math>标签
HtmlNodeCollection mathMLs = hdoc.DocumentNode.SelectNodes("//math");
// 将结果保存在ommls集合中
List<DocumentFormat.OpenXml.Math.OfficeMath> ommls = new List<DocumentFormat.OpenXml.Math.OfficeMath>();
if (mathMLs != null)
{
// MML2OMML.xsl文件位于目录:%ProgramFiles%\Microsoft Office\Office12\
XslCompiledTransform xslTransform = new XslCompiledTransform();
xslTransform.Load("../../MML2OMML.xsl");
foreach (HtmlNode math in mathMLs)
{
// 将MathML转换为OMML(Office math markup language)
using (TextReader tr = new StringReader(math.OuterHtml))
{
using (XmlReader reader = XmlReader.Create(tr))
{
using (MemoryStream ms1 = new MemoryStream())
{
XmlWriterSettings settings = xslTransform.OutputSettings.Clone();
settings.ConformanceLevel = ConformanceLevel.Fragment;
settings.OmitXmlDeclaration = true;
XmlWriter xw = XmlWriter.Create(ms1, settings);
xslTransform.Transform(reader, xw);
ms1.Seek(0, SeekOrigin.Begin);
StreamReader sr = new StreamReader(ms1, Encoding.UTF8);
ommls.Add(new DocumentFormat.OpenXml.Math.OfficeMath(sr.ReadToEnd()));
}
}
}
}
// 将<math></math>中的内容替换为"<b>////<b>"
HtmlNode newNode = HtmlNode.CreateNode("<b>////</b>");
foreach (HtmlNode math in mathMLs)
{
math.ParentNode.ReplaceChild(newNode, math);
}
}
第4步 使用 Open-Xml-PowerTools 将 XML 转换为 Docx
Open-Xml-PowerTools中的HtmlToWmlConverter类负责转换工作。代码如下:
// HtmlToWmlConverter要求元素不能有命名空间
XElement html = (XElement)ConvertToNoNamespace(XElement.Parse(hdoc.DocumentNode.OuterHtml));
// ConvertHtmlToWml方法的第2个参数
string styleInHtml = (string)html.Descendants().FirstOrDefault(d => d.Name.LocalName.ToLower() == "style");
styleInHtml = styleInHtml.Replace("//<![CDATA[", "");
styleInHtml = styleInHtml.Replace("//]]>//", "");
string authorCss = HtmlToWmlConverter.CleanUpCss(styleInHtml);
// ConvertHtmlToWml方法的第3个参数
string userCss = @"h1 { background-color: #00ff00; }";
// 使用OpenXml PowerTools将html转换为word
HtmlToWmlConverterSettings c_settings = HtmlToWmlConverter.GetDefaultSettings();
// 设置包含图片文件的路径
c_settings.BaseUriForImages = "../../";
// 默认的字体大小为14pt,即小四号,而中文宋体五号字对应10.5pt
c_settings.DefaultFontSize = 10.5d;
// emptyDocument也可以不设置,即在ConvertHtmlToWml方法中的将这个参数设置为null,HtmlToWmlConverter会自动在内部创建一个默认的WmlDocument实例
WmlDocument emptyDocument = new WmlDocument("../../template.docx");
WmlDocument doc = HtmlToWmlConverter.ConvertHtmlToWml(defaultCss, authorCss, userCss, html, c_settings, emptyDocument, null);
其中调用的 ConvertToNoNamespace 方法的代码如下:
private static object ConvertToNoNamespace(XNode node)
{
XElement element = node as XElement;
if (element != null)
{
return new XElement(element.Name.LocalName,
element.Attributes().Where(a => !a.IsNamespaceDeclaration),
element.Nodes().Select(n => ConvertToNoNamespace(n)));
}
return node;
}
稍微解释一下 ConvertHtmlToWml 方法的前三个参数,它们都是包含 css 的字符串,其中 defaultCss 的代码如下:
static string defaultCss =
@"html, address,
blockquote,
body, dd, div,
dl, dt, fieldset, form,
frame, frameset,
h1, h2, h3, h4,
h5, h6, noframes,
ol, p, ul, center,
dir, hr, menu, pre { display: block; unicode-bidi: embed }
li { display: list-item }
head { display: none }
table { display: table; }
tr { display: table-row }
thead { display: table-header-group }
tbody { display: table-row-group }
tfoot { display: table-footer-group }
col { display: table-column }
colgroup { display: table-column-group }
td, th { display: table-cell; }
caption { display: table-caption }
th { font-weight: bolder; text-align: center }
caption { text-align: center }
body { margin: auto; }
h1 { font-size: 16pt; margin: auto; }
h2 { font-size: 14pt; margin: auto; }
h3 { font-size: 1.17em; margin: auto; }
h4, p,
blockquote, ul,
fieldset, form,
ol, dl, dir,
menu { margin: auto }
a { color: blue; }
h5 { font-size: .83em; margin: auto }
h6 { font-size: .75em; margin: auto }
h1, h2, h3, h4,
h5, h6, b,
strong { font-weight: bolder }
blockquote { margin-left: 40px; margin-right: 40px }
i, cite, em,
var, address { font-style: italic }
pre, tt, code,
kbd, samp { font-family: monospace }
pre { white-space: pre }
button, textarea,
input, select { display: inline-block }
big { font-size: 1.17em }
small { font-size: .83em }
sub { vertical-align: sub }
sup { vertical-align: super }
table { border-spacing: 2px; }
thead, tbody,
tfoot { vertical-align: middle }
td, th, tr { vertical-align: inherit }
s, strike, del { text-decoration: line-through }
hr { border: 1px inset }
ol, ul, dir,
menu, dd { margin-left: 40px }
ol { list-style-type: decimal }
ol ul, ul ol,
ul ul, ol ol { margin-top: 0; margin-bottom: 0 }
u, ins { text-decoration: underline }
br:before { content: ""\A""; white-space: pre-line }
center { text-align: center }
:link, :visited { text-decoration: underline }
:focus { outline: thin dotted invert }
/* Begin bidirectionality settings (do not change) */
BDO[DIR=""ltr""] { direction: ltr; unicode-bidi: bidi-override }
BDO[DIR=""rtl""] { direction: rtl; unicode-bidi: bidi-override }
*[DIR=""ltr""] { direction: ltr; unicode-bidi: embed }
*[DIR=""rtl""] { direction: rtl; unicode-bidi: embed }
";
我通常不会动这个 defaultCss,本示例只动了一处,将“small, sub, sup { font-size: .83em }”改成了“small { font-size: .83em }”,否则导出为docx文件后上、下标会偏小。
这三个参数的css会依次覆盖前面的设置,因此authorCss通常应设置为html中外部link的css文件,而写在Html文件<style>标签中的css代码优先级通常更高,因此把它设置为userCss。
本例中,提取了 Html 中<style>标签中的内容作为authorCss,这个 css 将标题1的背景颜色设置为红色,然后在userCss中将标题1的背景颜色设置为绿色,导出后你会发现最终的颜色为绿色。
第5步 将 OMML 公式插入到 docx 文件的相应位置
代码如下:
// 将OMML公式插入到转换好的文件中
using (MemoryStream memoryStream = new MemoryStream())
{
doc.WriteByteArray(memoryStream);
using (WordprocessingDocument wordDoc = WordprocessingDocument.Open(memoryStream, true))
{
List<Text> replaceTexts = new List<Text>();
// 找到<w:b>////</w:b>,这也是我们要插入公式的地方。
// 此处用的是OpenXML的扩展方法,你也可以使用LINQ To XML完成同样的任务
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();
if (text != null)
replaceTexts.Add(text);
}
}
if (replaceTexts.Count() > 0)
{
int i = 0;
foreach (var replaceText in replaceTexts)
{
foreach (var currentRun in ommls[i].Descendants<DocumentFormat.OpenXml.Math.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;
}
}
}
File.WriteAllBytes("../../finalWord.docx", memoryStream.ToArray());
//wordDoc.SaveAs("../../finalWord.docx");
}
至此就完成了全部工作。
可供下载的源代码是个控制台程序,将一个“Original.html”文件最终转换为“finalWord.docx”。
文件下载(已下载 460 次)发布时间:2017/6/10 下午10:53:28 阅读次数:6824
