将 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.7Open-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) 就是必不可少的。下面是这个工具的截图:

Open XML SDK Productivity Tool

第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”。

文件下载(已下载 451 次)

发布时间:2017/6/10 下午10:53:28  阅读次数:5853

2006 - 2024,推荐分辨率 1024*768 以上,推荐浏览器 Chrome、Edge 等现代浏览器,截止 2021 年 12 月 5 日的访问次数:1872 万 9823 站长邮箱

沪 ICP 备 18037240 号-1

沪公网安备 31011002002865 号