OpenXml编程--修正Word目录页码错误

场景描述

image

图1

 

        图1是一个PDF文件生成的简单流程,事先做好的Word模板和数据源进行匹配以生成新的Word文档,然后再将Word文档转换为PDF文档。由Word文档和数据源产生新的Word文档我们采用的是FlexDoc组件(http://flexdoc.codeplex.com/)。生成的PDF文档要求有目录,如图2所示。目录是在Word模板中定义的,并没有采用在代码中自动生成目录的方式,这样是因为可以很方便的更改目录的样式,如图3所示。

image

图2

image

图3

     生成的Word的页码是不会自动更新的,但是会在转PDF的时候更新,这时候我们遇到了一个FlexDoc的Bug,转换后的目录产生了“未定义书签的错误”。如图4。

image

图4

        本文从Word目录的原理出发,探寻页码转换出错的原因,继而提出完整的解决方案。

Word目录绑定原理

      word目录有多种类型,类型是拿什么区别的呢?首先我们插入Word2007中的“自动目录2”,如图5。

image

图5  插入自动目录2

目录插入成功之后,我们选择目录,右键—>编辑域,切换到域编辑界面,如图6。

image 

图6  编辑域

       在域编辑页面在域名项选择TOC,然后单击选项,在选项界面中我们可以看到TOC域支持的开关,不同的开关组合就是不同Word目录,如图7所示。刚才我们选择的“自动目录2”的域代码为TOC \o "1-3" \h \z \u 。关于各个开关的含义,您自己看说明就可以 了,我就不啰嗦了。

image

图7   编辑域选项

下面我们从WordML的角度继续研究目录。打开word文档,找到Body节点,再找到W:sdt节点,如图8。

image

图8  找到w:sdt节点

w:sdt节点代表SdtBlock,SdtBlock又是什么呢?就是包在目录外面的那个框,SdtBlock并不是word目录必须的元素,插入自动目录 的时候word默认会将目录放在SdtBlock中,您也可以选择去除,由于SdtBlock可以帮助我们在程序中迅速找到目录项,所以我要去所有的目标中的目录必须带SdtBlock。SdtBlock节点下有一个w:sdtContent (对应的对象为SdtContentBlock)子节点,该子节点下包含了多个w:p(对应的对象为Paragraph)标签,这些w:p标签组成了Word目录。现在我们展开其中一个w:p,看看里面包含了什么秘密。

代码清单1   一个目录项

   1:  <w:p w:rsidRPr="00F34D5F" w:rsidR="00F34D5F" w:rsidRDefault="00F34D5F" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
   2:    <w:pPr>
   3:      <w:pStyle w:val="20" />
   4:      <w:rPr>
   5:        <w:rFonts w:asciiTheme="minorHAnsi" w:hAnsiTheme="minorHAnsi" w:eastAsiaTheme="minorEastAsia" />
   6:        <w:color w:val="auto" />
   7:      </w:rPr>
   8:    </w:pPr>
   9:    <w:hyperlink w:history="1" w:anchor="_Toc296003347">
  10:      <w:r w:rsidRPr="00F34D5F">
  11:        <w:rPr>
  12:          <w:rStyle w:val="ad" />
  13:          <w:rFonts w:hint="eastAsia" />
  14:          <w:color w:val="auto" />
  15:        </w:rPr>
  16:        <w:t>作答有效性分析</w:t>
  17:      </w:r>
  18:      <w:r w:rsidRPr="00F34D5F">
  19:        <w:rPr>
  20:          <w:webHidden />
  21:          <w:color w:val="auto" />
  22:        </w:rPr>
  23:        <w:tab />
  24:      </w:r>
  25:      <w:r w:rsidRPr="00F34D5F">
  26:        <w:rPr>
  27:          <w:webHidden />
  28:          <w:color w:val="auto" />
  29:        </w:rPr>
  30:        <w:fldChar w:fldCharType="begin" />
  31:      </w:r>
  32:      <w:r w:rsidRPr="00F34D5F">
  33:        <w:rPr>
  34:          <w:webHidden />
  35:          <w:color w:val="auto" />
  36:        </w:rPr>
  37:        <w:instrText xml:space="preserve"> PAGEREF _Toc296003347 \h </w:instrText>
  38:      </w:r>
  39:      <w:r w:rsidRPr="00F34D5F">
  40:        <w:rPr>
  41:          <w:webHidden />
  42:          <w:color w:val="auto" />
  43:        </w:rPr>
  44:      </w:r>
  45:      <w:r w:rsidRPr="00F34D5F">
  46:        <w:rPr>
  47:          <w:webHidden />
  48:          <w:color w:val="auto" />
  49:        </w:rPr>
  50:        <w:fldChar w:fldCharType="separate" />
  51:      </w:r>
  52:      <w:r w:rsidRPr="00F34D5F">
  53:        <w:rPr>
  54:          <w:webHidden />
  55:          <w:color w:val="auto" />
  56:        </w:rPr>
  57:        <w:t>1</w:t>
  58:      </w:r>
  59:      <w:r w:rsidRPr="00F34D5F">
  60:        <w:rPr>
  61:          <w:webHidden />
  62:          <w:color w:val="auto" />
  63:        </w:rPr>
  64:        <w:fldChar w:fldCharType="end" />
  65:      </w:r>
  66:    </w:hyperlink>
  67:  </w:p>

        代码清单1是w:sdtContent 中的一个w:p项内容。现在我们来看里面几个关键项。第9行代码“<w:hyperlink w:history="1" w:anchor="_Toc296003347">”是w:hyperlink(对应的对象为Hyperlink )标记的起始配置,w:hyperlink代表超链接,点击目录会自动跳转到文档中的正确位置,如果您的TOC域支持的开关没有“\h”选项的话是不会产生w:hyperlink标签的,那么您看到的目录项的代码是另一种样子,这里我就不演示了。这里我们重点关注w:anchor属性,该属性指定了超链接的位置。那么w:anchor的值"_Toc296003347"又是什么呢?先不做解释,我们再看另一个标记,第37行的“<w:instrText xml:space="preserve"> PAGEREF _Toc296003347 \h </w:instrText>”,w:instrText(对应的对象为FieldCode)标签的值 “PAGEREF _Toc296003347 \h ”是用来标识超链接的页码的,但是它本身并没有页码值,而是引用了一个位置,最后更新页码的时候会将那个位置所在页的页码赋值给第57行的<w:t>。第50行的<w:fldChar w:fldCharType="separate" />标签是目录项的标题和页码之间的分隔符样式。第16行的“<w:t>作答有效性分析</w:t>”就是当前目录项的标题,实现显示的是word文档正文中的1级 、二级或3级标题。

    现在我们基本了解了目录的组成,还有一个关键的定位属性没有解释,我们继续查看word文档,看下面这一段代码:

代码2   一个二级标题

   1:  <w:p w:rsidRPr="00F34D5F" w:rsidR="000535A9" w:rsidP="00F34D5F" w:rsidRDefault="00E24DF2" 
xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
   2:    <w:pPr>
   3:      <w:pStyle w:val="2" />
   4:      <w:ind w:firstLine="372" w:firstLineChars="133" />
   5:      <w:rPr>
   6:        <w:rFonts w:ascii="微软雅黑" w:hAnsi="微软雅黑" w:eastAsia="微软雅黑" w:cstheme="minorBidi" />
   7:        <w:bCs w:val="0" />
   8:        <w:color w:val="93550D" />
   9:        <w:sz w:val="28" />
  10:        <w:szCs w:val="24" />
  11:      </w:rPr>
  12:    </w:pPr>
  13:    <w:bookmarkStart w:name="_Toc295939763" w:id="3" />
  14:    <w:bookmarkStart w:name="_Toc296003347" w:id="4" />
  15:    <w:r w:rsidRPr="00F34D5F">
  16:      <w:rPr>
  17:        <w:rFonts w:hint="eastAsia" w:ascii="微软雅黑" w:hAnsi="微软雅黑" w:eastAsia="微软雅黑" w:cstheme="minorBidi" />
  18:        <w:bCs w:val="0" />
  19:        <w:color w:val="93550D" />
  20:        <w:sz w:val="28" />
  21:        <w:szCs w:val="24" />
  22:      </w:rPr>
  23:      <w:t>作答有效性分析</w:t>
  24:    </w:r>
  25:    <w:bookmarkEnd w:id="3" />
  26:    <w:bookmarkEnd w:id="4" />
  27:  </w:p>
       看代码2所示的内容,实际上是一个二级标题,该二级标题包含在一个单独的<w:p>标记内,从哪里能看出该内容的大纲级别是二级呢?看第3行代码---<w:pStyle w:val="2" />。
然后我们看第13、1
4、25和26四行代码,是两对w:bookmarkStart 和bookmarkEnd标签,第14行的w:name="_Toc296003347"是不是很眼熟呢?没错,就是目录项中的定位标记。
     到现在为止,我们已经明白了目录的原理,那么为什么会出错呢?我们看一个出错的Word文档,如图9。
image
图9  页码更新出错的Word文档
       看图9中,比较突出是几个w:bookmarkStart 标签,它们本应该是如代码2里那样,和bookmarkEnd标签一起成对的出现在P标签内然后上学包裹标题,但是现在它却单独跑到了P标签外
,如果bookmarkEnd标签单独的跑出来也会造成页码更新失败。代码3是标题的内容,我们可以看到只剩下两个孤零零的bookmarkEnd标签。这就是出错的原因。
<w:p w:rsidRPr="00115C2B" w:rsidR="009E7404" w:rsidP="009A7ED0" w:rsidRDefault="00BD76F7" 
xmlns:w
="http://schemas.openxmlformats.org/wordprocessingml/2006/main"> <w:pPr> <w:pStyle w:val="1" /> <w:jc w:val="center" /> <w:rPr> <w:rFonts w:ascii="微软雅黑" w:hAnsi="微软雅黑" w:cstheme="majorBidi" /> <w:color w:val="365F91" w:themeColor="accent1" w:themeShade="BF" /> <w:kern w:val="0" /> <w:lang w:val="zh-CN" /> </w:rPr> </w:pPr> <w:r w:rsidRPr="00115C2B"> <w:rPr> <w:rFonts w:hint="eastAsia" w:ascii="微软雅黑" w:hAnsi="微软雅黑" w:cstheme="majorBidi" /> <w:color w:val="365F91" w:themeColor="accent1" w:themeShade="BF" /> <w:kern w:val="0" /> <w:lang w:val="zh-CN" /> </w:rPr> <w:t>整体测评结果</w:t> </w:r> <w:bookmarkEnd w:id="2" /> <w:bookmarkEnd w:id="1" /> </w:p>

修正策略

     问题我们已经分析清楚了,其实这是FlexDoc的bug,当然我们可以通过修改FlexDoc的源代码来解决这个问题,但是我实在是懒得读源码,决定在FlexDoc匹配数据之后将word文档写在磁盘上之前来修正目录。流程如下:

 image

代码实现

代码很简单,全部代码如下所示:

   1:   public static void FixtDirectory(WordprocessingDocument wdDoc)
   2:          {
   3:              Body body = wdDoc.MainDocumentPart.Document.Body;
   4:              //获取所有包含一、二级标题的段落
   5:              var parHasStyle = body.Descendants<Paragraph>().Where(t => t.Descendants<ParagraphStyleId>().Count() > 0 && 
t.Descendants<ParagraphStyleId>().All(c => c.Val == "1" || c.Val == "2"));
   6:              string bookMarkName = "_Toc{0}";
   7:              int num = 988888888;
   8:              Dictionary<string, string> bookMarkAddedDic = new Dictionary<string, string>();
   9:   
  10:              if (parHasStyle.Count() > 0)
  11:              {
  12:                  foreach (Paragraph p in parHasStyle)
  13:                  {
  14:                      var bookmarkEnds = p.Descendants<BookmarkEnd>();//获取段落中所有BookmarkEnd标签
  15:                      var bookmarkStarts = p.Descendants<BookmarkStart>();//获取段落中所有BookmarkStart标签
  16:                      int bookmarkEndsCount = bookmarkEnds.Count();
  17:                      int bookmarkStartsCount = bookmarkStarts.Count();
  18:                      string name = string.Format(bookMarkName, ++num);
  19:                      string id = (num++).ToString();
  20:   
  21:                      //创建新书签用于添加到标题上下
  22:                      BookmarkStart bookmarkStart = new BookmarkStart() { Name = name, Id = id };
  23:                      BookmarkEnd bookmarkEnd = new BookmarkEnd() { Id = id };
  24:   
  25:                      if (bookmarkEndsCount == 0 && bookmarkStartsCount == 0)
  26:                      {
  27:                          if (p.Descendants<Text>().Count() > 0)
  28:                          {
  29:                              AddBookMarkToParagraph(p, bookmarkEnd, bookmarkStart);//添加书签
  30:                              bookMarkAddedDic.Add(p.Descendants<Text>().First().Text, name);//记录添加的书签
  31:                          }
  32:                      }
  33:                      else
  34:                          if (bookmarkEndsCount != bookmarkStartsCount)
  35:                          {
  36:                              DeleteBookMarkFromParagraph(body, p, bookmarkStarts, bookmarkEnds);//删除孤单书签
  37:                              AddBookMarkToParagraph(p, bookmarkEnd, bookmarkStart);//添加新书签
  38:                              string dicKey = GetKey(p);//获取被添加书签的标题
  39:                              bookMarkAddedDic.Add(dicKey, name);//记录添加的书签
  40:                          }
  41:                  }
  42:                  FixtDirectory(bookMarkAddedDic, body);//更新目录
  43:              }
  44:   
  45:          }
  46:   
  47:          /// <summary>
  48:          /// 将段落中文字拼起来得到标题内容
  49:          /// </summary>
  50:          /// <param name="p"></param>
  51:          /// <returns></returns>
  52:          private static string GetKey(Paragraph p)
  53:          {
  54:              return string.Join("", p.Descendants<Text>().Select(t => t.Text));
  55:          }
  56:   
  57:          /// <summary>
  58:          /// 修正书签
  59:          /// </summary>
  60:          /// <param name="bookMarkAddedDic"></param>
  61:          /// <param name="body"></param>
  62:          private static void FixtDirectory(Dictionary<string, string> bookMarkAddedDic, Body body)
  63:          {
  64:              if (bookMarkAddedDic.Count > 0)
  65:              {
  66:                  if (body.Descendants<SdtBlock>().Count() > 0)
  67:                  {
  68:                      //得到SdtContentBlock
  69:                      SdtContentBlock sdtContentBlock = body.Descendants<SdtBlock>().First().GetFirstChild<SdtContentBlock>();
  70:                      //遍历每一个超链接,修改里面的书签值
  71:                      foreach (Hyperlink hyperlink in sdtContentBlock.Descendants<Hyperlink>())
  72:                      {
  73:   
  74:                          Text text = hyperlink.Descendants<Text>().First();//得到目录项绑定的标题内容
  75:                          if (bookMarkAddedDic.Keys.Contains(text.Text))
  76:                          {
  77:                              hyperlink.Anchor = bookMarkAddedDic[text.Text];//超链接绑定到书签的name
  78:                              FieldCode pageRef = hyperlink.Descendants<FieldCode>().First(t => t.Text.Contains("PAGEREF"));//
  79:                              pageRef.Text = "PAGEREF " + hyperlink.Anchor + "\\h";//更新PAGEREF以更新页码
  80:                          }
  81:   
  82:                      }
  83:                  }
  84:   
  85:              }
  86:   
  87:          }
  88:   
  89:          /// <summary>
  90:          /// 删除孤单标签
  91:          /// </summary>
  92:          /// <param name="body"></param>
  93:          /// <param name="p"></param>
  94:          /// <param name="bookmarkStarts"></param>
  95:          /// <param name="bookmarkEnds"></param>
  96:          private static void DeleteBookMarkFromParagraph(Body body, Paragraph p, IEnumerable<BookmarkStart> bookmarkStarts, 
IEnumerable<BookmarkEnd> bookmarkEnds)
  97:          {
  98:              IEnumerable<BookmarkStart> singleStartElenmentsIn = null;
  99:              IEnumerable<BookmarkEnd> singleEndElenmentsIn = null;
 100:              IEnumerable<BookmarkStart> singleStartElenmentsOut = null;
 101:              IEnumerable<BookmarkEnd> singleEndElenmentsOut = null;
 102:   
 103:              singleStartElenmentsIn = bookmarkStarts.Where(t => 
!bookmarkEnds.Select(c => c.Id.Value).Contains(t.Id.Value));//获得段落内的孤单BookmarkStart标签
 104:              List<BookmarkStart> bookmarkStartsLst = singleStartElenmentsIn.ToList();
 105:              singleEndElenmentsIn = bookmarkEnds.Where(t => !bookmarkStartsLst.Select(c => c.Id.Value).
Contains(t.Id.Value));//获得段落内的孤单BookmarkEnd标签
 106:   
 107:              singleStartElenmentsOut = body.Descendants<BookmarkStart>().Where(t => singleEndElenmentsIn.
Select(c => c.Id.Value).Contains(t.Id.Value));//获得段落外的孤单BookmarkStart标签
 108:              singleEndElenmentsOut = body.Descendants<BookmarkEnd>().Where(t => singleStartElenmentsIn.
Select(c => c.Id.Value).Contains(t.Id.Value));//获得段落外的孤单BookmarkEnd标签
 109:   
 110:              //删除所有孤单标签
 111:              Remove(singleStartElenmentsOut);
 112:              Remove(singleEndElenmentsOut);
 113:              Remove(singleStartElenmentsIn);
 114:              Remove(singleEndElenmentsIn);
 115:   
 116:          }
 117:   
 118:          private static void Remove(IEnumerable<OpenXmlElement> singleElenments)
 119:          {
 120:              singleElenments.ToList().ForEach(t => t.Remove());//删除标签
 121:          }
 122:   
 123:   
 124:          /// <summary>
 125:          /// 添加新的标签到段落中标题上下
 126:          /// </summary>
 127:          /// <param name="p"></param>
 128:          /// <param name="bookmarkEnd"></param>
 129:          /// <param name="bookmarkStart"></param>
 130:          private static void AddBookMarkToParagraph(Paragraph p, BookmarkEnd bookmarkEnd, BookmarkStart bookmarkStart)
 131:          {
 132:              if (p.Descendants<Text>().Count() > 0)
 133:              {
 134:                  var wtBegin = p.Descendants<Text>().First();
 135:                  var wtEnd = p.Descendants<Text>().Last();
 136:                  Run rBegin = wtBegin.Parent as Run;//得到标题内容开始行
 137:                  Run rEnd = wtEnd.Parent as Run;//得到标题内容结束行
 138:   
 139:                  rBegin.InsertBeforeSelf(bookmarkStart);//在标题上面插入BookmarkStart
 140:                  rEnd.InsertAfterSelf(bookmarkEnd);//在标题下面插入bookmarkEnd
 141:              }
 142:          }
 代码很少,我将说明加在注释上,相信各位都能看的懂。最后还希望大家踊跃留言讨论。谢谢!
posted @ 2011-06-16 20:42  玄魂  阅读(13200)  评论(11编辑  收藏  举报