使用DocumentFormat.OpenXml替换[Replace] Word文档中指定的文字

框架:.Net Blazor Webassembly

由于需求,要从服务器下载word模板,将在word预定的文字替换为其他文本。

 

 

 

在以上word文档中需要将<Date>替换为当前日期,<Chief>替换为一个人名,【DD】替换为#博客园#,【DDDDD】替换为这个文章的名称。

可能有人要疑问了,不就是替换个文字,不是Replace以下,大不了Regex正则以下就完了。

确实一开始我也是这么想的。

但是因为开发框架问题,而且并不想在服务器通过offfice组件来实现这个功能,怕服务器吃不消。

通过查找资料,目前只知道通过DocumentFormat.OpenXml来实现。

也在网上查了很多教程,是可以替换文本,但是都不是完美的方法。

目前网上比较好找到的方式:

1、正则替换法:将文档转换为流,并通过正则进行转换。

如何:搜索和替换文档部件中的文本 (Open XML SDK)

2、通过OpenXml修改Text节点文本实现。本文的方式也是通过这个方法改良过来的。

这两种方法都会遇到一个那就是读取的XML文档,和平时看到的差距很多。

而且第一种方法还会遇到转义字符的问题

 

 比如这两种查找字中的"<"和“>”,使用StreamReader读取到并不是纯文本而是xml文本。

但是在XML中,我们要查询的关键字可能在一个Text节点中也可能在多个节点中,这导致了无论使用正则或者Replace都无法完整地匹配所有查找字。

  1 <w:p>
  2 <w:pPr>
  3 <w:keepNext w:val="0"/>
  4 <w:keepLines w:val="0"/>
  5 <w:pageBreakBefore w:val="0"/>
  6 <w:widowControl w:val="0"/>
  7 <w:kinsoku/>
  8 <w:wordWrap/>
  9 <w:overflowPunct/>
 10 <w:topLinePunct w:val="0"/>
 11 <w:autoSpaceDE/>
 12 <w:autoSpaceDN/>
 13 <w:bidi w:val="0"/>
 14 <w:adjustRightInd/>
 15 <w:snapToGrid/>
 16 <w:spacing w:line="560" w:lineRule="exact"/>
 17 <w:ind w:firstLine="560" w:firstLineChars="200"/>
 18 <w:jc w:val="left"/>
 19 <w:textAlignment w:val="auto"/>
 20 <w:rPr>
 21 <w:rFonts w:hint="eastAsia" w:ascii="宋体" w:hAnsi="宋体" w:cs="宋体"/>
 22 <w:bCs/>
 23 <w:color w:val="0000FF"/>
 24 <w:sz w:val="28"/>
 25 <w:szCs w:val="28"/>
 26 <w:highlight w:val="yellow"/>
 27 <w:lang w:val="en-US" w:eastAsia="zh-CN"/>
 28 </w:rPr>
 29 </w:pPr>
 30 <w:r>
 31 <w:rPr>
 32 <w:rFonts w:hint="eastAsia" w:ascii="宋体" w:hAnsi="宋体" w:cs="宋体"/>
 33 <w:bCs/>
 34 <w:sz w:val="28"/>
 35 <w:szCs w:val="28"/>
 36 <w:highlight w:val="green"/>
 37 <w:lang w:val="en-US" w:eastAsia="zh-CN"/>
 38 </w:rPr>
 39 <w:t>大家好,我在【DD】发布了【</w:t>
 40 </w:r>
 41 <w:r>
 42 <w:rPr>
 43 <w:rFonts w:hint="eastAsia" w:ascii="宋体" w:hAnsi="宋体" w:cs="宋体"/>
 44 <w:bCs/>
 45 <w:sz w:val="28"/>
 46 <w:szCs w:val="28"/>
 47 <w:highlight w:val="lightGray"/>
 48 <w:lang w:val="en-US" w:eastAsia="zh-CN"/>
 49 </w:rPr>
 50 <w:t>D</w:t>
 51 </w:r>
 52 <w:r>
 53 <w:rPr>
 54 <w:rFonts w:hint="eastAsia" w:ascii="宋体" w:hAnsi="宋体" w:cs="宋体"/>
 55 <w:bCs/>
 56 <w:sz w:val="28"/>
 57 <w:szCs w:val="28"/>
 58 <w:highlight w:val="cyan"/>
 59 <w:lang w:val="en-US" w:eastAsia="zh-CN"/>
 60 </w:rPr>
 61 <w:t>D</w:t>
 62 </w:r>
 63 <w:r>
 64 <w:rPr>
 65 <w:rFonts w:hint="eastAsia" w:ascii="宋体" w:hAnsi="宋体" w:cs="宋体"/>
 66 <w:b/>
 67 <w:bCs w:val="0"/>
 68 <w:sz w:val="28"/>
 69 <w:szCs w:val="28"/>
 70 <w:highlight w:val="cyan"/>
 71 <w:lang w:val="en-US" w:eastAsia="zh-CN"/>
 72 </w:rPr>
 73 <w:t>D</w:t>
 74 </w:r>
 75 <w:r>
 76 <w:rPr>
 77 <w:rFonts w:hint="eastAsia" w:ascii="宋体" w:hAnsi="宋体" w:cs="宋体"/>
 78 <w:bCs/>
 79 <w:i/>
 80 <w:iCs/>
 81 <w:sz w:val="28"/>
 82 <w:szCs w:val="28"/>
 83 <w:highlight w:val="cyan"/>
 84 <w:lang w:val="en-US" w:eastAsia="zh-CN"/>
 85 </w:rPr>
 86 <w:t>D</w:t>
 87 </w:r>
 88 <w:r>
 89 <w:rPr>
 90 <w:rFonts w:hint="eastAsia" w:ascii="宋体" w:hAnsi="宋体" w:cs="宋体"/>
 91 <w:bCs/>
 92 <w:sz w:val="28"/>
 93 <w:szCs w:val="28"/>
 94 <w:highlight w:val="cyan"/>
 95 <w:u w:val="single"/>
 96 <w:lang w:val="en-US" w:eastAsia="zh-CN"/>
 97 </w:rPr>
 98 <w:t>D</w:t>
 99 </w:r>
100 <w:r>
101 <w:rPr>
102 <w:rFonts w:hint="eastAsia" w:ascii="宋体" w:hAnsi="宋体" w:cs="宋体"/>
103 <w:bCs/>
104 <w:sz w:val="28"/>
105 <w:szCs w:val="28"/>
106 <w:highlight w:val="cyan"/>
107 <w:lang w:val="en-US" w:eastAsia="zh-CN"/>
108 </w:rPr>
109 <w:t>】这是</w:t>
110 </w:r>
111 <w:r>
112 <w:rPr>
113 <w:rFonts w:hint="eastAsia" w:ascii="宋体" w:hAnsi="宋体" w:cs="宋体"/>
114 <w:bCs/>
115 <w:color w:val="0000FF"/>
116 <w:sz w:val="28"/>
117 <w:szCs w:val="28"/>
118 <w:highlight w:val="yellow"/>
119 <w:lang w:val="en-US" w:eastAsia="zh-CN"/>
120 </w:rPr>
121 <w:t>我的第一篇文章。</w:t>
122 </w:r>
123 </w:p>
124 <w:p>
125 <w:pPr>
126 <w:keepNext w:val="0"/>
127 <w:keepLines w:val="0"/>
128 <w:pageBreakBefore w:val="0"/>
129 <w:widowControl w:val="0"/>
130 <w:kinsoku/>
131 <w:wordWrap/>
132 <w:overflowPunct/>
133 <w:topLinePunct w:val="0"/>
134 <w:autoSpaceDE/>
135 <w:autoSpaceDN/>
136 <w:bidi w:val="0"/>
137 <w:adjustRightInd/>
138 <w:snapToGrid/>
139 <w:spacing w:line="560" w:lineRule="exact"/>
140 <w:ind w:firstLine="560" w:firstLineChars="200"/>
141 <w:jc w:val="left"/>
142 <w:textAlignment w:val="auto"/>
143 <w:rPr>
144 <w:rFonts w:hint="default" w:ascii="宋体" w:hAnsi="宋体" w:cs="宋体"/>
145 <w:bCs/>
146 <w:color w:val="0000FF"/>
147 <w:sz w:val="28"/>
148 <w:szCs w:val="28"/>
149 <w:highlight w:val="yellow"/>
150 <w:lang w:val="en-US" w:eastAsia="zh-CN"/>
151 </w:rPr>
152 </w:pPr>
153 <w:r>
154 <w:rPr>
155 <w:rFonts w:hint="eastAsia" w:ascii="宋体" w:hAnsi="宋体" w:cs="宋体"/>
156 <w:bCs/>
157 <w:color w:val="0000FF"/>
158 <w:sz w:val="28"/>
159 <w:szCs w:val="28"/>
160 <w:highlight w:val="yellow"/>
161 <w:lang w:val="en-US" w:eastAsia="zh-CN"/>
162 </w:rPr>
163 <w:t>【DD】</w:t>
164 </w:r>
165 </w:p>
View Code

 可以看到,虽然在同一个段落(Para),但查找字分布在很多个连续区(Run,每个Run里的文本的格式是一致的,w:t即Text节点只含有字符串)里面。

虽然我们可以严格要求使用人,在上传模板的时候要将关键字的格式修改成完全一致,但这完全不在控制之中(强迫症)。

所以,必须解决这个问题。

现在实现的一个可行方法如下

(可能不是最高效的,但是已经优化好几个版本了,而且我的项目基本不会使用到太大的文档,所以暂时不考虑继续优化算法了):

  1 using System;
  2 using System.Linq;
  3 using DocumentFormat.OpenXml.Packaging;
  4 using DocumentFormat.OpenXml.Wordprocessing;
  5 using DocumentFormat.OpenXml;
  6 using System.Text.RegularExpressions;
  7 using System.Xml;
  8 
  9         /// <summary>
 10         /// word字符串替换,加强版
 11         /// </summary>
 12         /// <param name="doc"></param>
 13         /// <param name="oldStr"></param>
 14         /// <param name="newStr"></param>
 15         /// <returns></returns>
 16         /// <exception cref="ArgumentNullException"></exception>
 17         public static async Task ReplaceStringEx(WordprocessingDocument doc, string oldStr, string newStr)
 18         {
 19             if (doc == null) throw new ArgumentNullException(nameof(doc));
 20             if (string.IsNullOrEmpty(oldStr)) throw new ArgumentNullException(nameof(oldStr));
 21             //获取文档主体
 22             Body? body = doc.MainDocumentPart.Document.Body;
 23             if (body != null)
 24             {
 25                 //获取包含oldStr的段落
 26                 var paras = body.Descendants<Paragraph>().Where(d => d.InnerText.Contains(oldStr));
 27 
 28                 if (paras.Any())
 29                 {
 30                     //遍历各个含有oldStr的段落
 31                     foreach (var par in paras)
 32                     {
 33                         //获取段落中连续字符(Run)的集合
 34                         var rs = par.Descendants<Run>();
 35                         var rs_count = rs.Count();
 36 
 37                         //如果段落只有一个run,直接替换
 38                         if (rs_count == 1)
 39                         {
 40                             //获取text节点
 41                             var text = rs.First().Descendants<Text>().First();
 42                             text.Text = text.Text.Replace(oldStr, newStr);
 43 
 44                         }
 45                         //如果段落包含多个run,说明oldStr可能由多个run中的文本连接而成,要再进一步处理
 46                         else if (rs_count > 1)
 47                         {
 48                             //获取段落文本
 49                             var para_InnerText = par.InnerText;
 50                             //使用正则获取匹配项
 51                             var matches = Regex.Matches(para_InnerText, oldStr);
 52                             if (matches.Any())
 53                             {
 54                                 //构建匹配的字符串在段落文本中的索引范围
 55                                 List<TextRange> matchRanges = new List<TextRange>();
 56                                 for (int i = 0; i < matches.Count; i++)
 57                                 {
 58                                     var match = matches[i];
 59                                     matchRanges.Add(new TextRange(match.Index, match.Index + match.Length - 1));
 60                                 }
 61 
 62                                 //构建各run节点在段落文本中的索引范围
 63                                 Dictionary<Run, TextRange> runTextRangeDic = new Dictionary<Run, TextRange>();
 64                                 //构建索引的断点
 65                                 int bp = 0;
 66                                 for (int i = 0; i < rs_count; i++)
 67                                 {
 68                                     string run_innertext = rs.ElementAt(i).InnerText;
 69                                     if (run_innertext.Length > 0)
 70                                     {
 71                                         var run_range = new TextRange(bp, bp + run_innertext.Length - 1);
 72                                         runTextRangeDic.Add(rs.ElementAt(i), run_range);
 73                                         bp += run_innertext.Length;
 74                                     }
 75                                     //是否可能出现empty的情况?暂未发现!
 76                                 }
 77 
 78                                 //当前run区检索位置。避免重复匹配前面已经检索过的run区,在大量文本中会提高效率
 79                                 var ti = 0;
 80                                 //遍历各个匹配范围
 81                                 for (int mi = 0; mi < matchRanges.Count; mi++)
 82                                 {
 83                                     var matchRange = matchRanges[mi];
 84 
 85                                     while (ti < runTextRangeDic.Count)
 86                                     {
 87                                         var runKV = runTextRangeDic.ElementAt(ti);
 88                                         Run run = runKV.Key;
 89                                         TextRange runTextRange = runTextRangeDic.ElementAt(ti).Value;
 90                                         
 91                                         //如果run区包含匹配区,直接替换,并查询下一个匹配区
 92                                         if (runTextRange.Contains(matchRange))
 93                                         {
 94                                             var textNode = run.Descendants<Text>().First();
 95                                             textNode.Text = textNode.Text.Replace(oldStr, newStr);
 96                                             //ti++;因为可能还存在前交叉可能,这个还得再匹配一次
 97                                             break;
 98                                         }
 99                                         else
100                                         {
101                                             //如果匹配字与run区存在前交叉
102                                             if (matchRange.StartInterset(runTextRange))
103                                             {
104                                                 var length = runTextRange.End - matchRange.Start + 1;
105                                                 //将交叉区文本替换为newStr
106                                                 var textNode = run.Descendants<Text>().First();
107                                                 textNode.Text = textNode.Text.Remove( textNode.Text.Length - length , length);
108                                                 textNode.Text += newStr;
109                                             }
110                                             //如果匹配字与run区存在后交叉
111                                             else if (matchRange.EndIntersect(runTextRange))
112                                             {
113                                                 var length = matchRange.End - runTextRange.Start + 1;
114                                                 //删除交叉区文本
115                                                 var textNode = run.Descendants<Text>().First();
116                                                 textNode.Text = textNode.Text.Remove(0, length);
117                                                 //t++;当前run匹配后交叉之后可能还存在前交叉,还得再匹配一次
118                                                 break;
119                                             }
120                                             //如果匹配区包含run区则移除这个run区
121                                             else if (matchRange.Contains(runTextRange))
122                                             {
123                                                 run.Remove();
124                                             }
125                                             ti++;
126                                         }
127                                     }
128                                 }
129                             }
130                             
131                         }
132                     }
133                 }
134 
135             }
136             await Task.CompletedTask;
137         }

补充几个自定义的方法和类,可能有的没用到:

 1 /// <summary>
 2     /// Wordprocessing中Text在run文本中的索引范围
 3     /// </summary>
 4     public class TextRange
 5     {
 6         public TextRange(int start, int end)
 7         {
 8             Start = start;
 9             End = end;
10         }
11         /// <summary>
12         /// 起始位置
13         /// </summary>
14         public int Start { get; set; }
15         /// <summary>
16         /// 结束位置
17         /// </summary>
18         public int End { get; set; }
19         /// <summary>
20         /// 范围长度
21         /// </summary>
22         public int Length => End - Start + 1;
23     }
24 
25     public static class TextRangeExtensions
26     {
27         /// <summary>
28         /// 判断该索引范围是否与指定索引范围有后交叉
29         /// </summary>
30         /// <param name="tr1"></param>
31         /// <param name="tr2"></param>
32         /// <returns></returns>
33         public static bool EndIntersect(this TextRange tr1, TextRange tr2)
34         {
35             if(tr2==null) return false;
36             if (tr2.Start >= tr1.Start && tr2.Start <= tr1.End && tr2.End >= tr1.End) return true;
37             return false;
38         }
39         /// <summary>
40         /// 判断该索引范围是否与指定的索引有前交叉
41         /// </summary>
42         /// <param name="tr1"></param>
43         /// <param name="tr2"></param>
44         /// <returns></returns>
45         public static bool StartInterset(this TextRange tr1, TextRange tr2)
46         {
47             if (tr2 == null) return false;
48             if (tr2.Start <= tr1.Start && tr2.End >= tr1.Start && tr2.End <= tr1.End) return true;
49             return false;
50         }
51         
52         /// <summary>
53         /// 判断该索引范围是在指定的索引范围之内(不包含全等于)
54         /// </summary>
55         /// <param name="tr1"></param>
56         /// <param name="tr2"></param>
57         /// <returns></returns>
58         public static bool IsPartOf(this TextRange tr1,TextRange tr2)
59         {
60             if (tr2 == null) return false;
61             if (tr1.Start > tr2.Start && tr1.Start < tr2.End && tr1.End > tr2.Start && tr1.End < tr2.End) return true;
62             return false;
63         }
64         /// <summary>
65         /// 判断该范围是否包含指定的的范围(包括全等于)
66         /// </summary>
67         /// <param name="tr1"></param>
68         /// <param name="tr2"></param>
69         /// <returns></returns>
70         public static bool Contains(this TextRange tr1,TextRange tr2)
71         {
72             if (tr2 == null) return false;
73             if (tr1.Start <= tr2.Start && tr1.End>=tr2.End) return true;
74             return false;
75         }
76         /// <summary>
77         /// 判断两个范围是否没有交集
78         /// </summary>
79         /// <param name="tr1"></param>
80         /// <param name="tr2"></param>
81         /// <returns></returns>
82         public static bool Unintersect(this TextRange tr1,TextRange tr2)
83         {
84             if (tr2 == null) return true;
85             if (tr1.End < tr2.Start || tr1.Start > tr2.End) return true;
86             return false;
87         }
88         /// <summary>
89         /// 判断两个范围是否有交集
90         /// </summary>
91         /// <param name="tr1"></param>
92         /// <param name="tr2"></param>
93         /// <returns></returns>
94         public static bool HasIntersect(this TextRange tr1, TextRange tr2) => !Unintersect(tr1, tr2);
95     }

测试内容:

        [Inject]
        DownloadService? DLServiece { get; set; }
        public async Task test()
        {
            Http.DefaultRequestHeaders.CacheControl=new System.Net.Http.Headers.CacheControlHeaderValue() { NoCache = true };
            var response = await Http.GetAsync("/templates/test.docx");
            if (response.IsSuccessStatusCode)
            {
                long file_length = response.Content.Headers.ContentLength ?? 0;
                if (file_length == 0) return;
                MemoryStream sm = new MemoryStream(new byte[file_length], true);
                await response.Content.CopyToAsync(sm);
                Console.WriteLine(sm.Length);
                using (WordprocessingDocument doc = WordprocessingDocument.Open(sm, true))
                {
                    await ReplaceStringEx(doc, "<Date>", $"{DateTime.Now:yyyy.MM.dd}");
                    await ReplaceStringEx(doc, "<Chief>", $"Morcom");
                    await ReplaceStringEx(doc, "【DD】", $"#博客园#");
                    await ReplaceStringEx(doc, "【DDDDD】", $"【使用DocumentFormat.OpenXml替换[Replace] Word文档中指定的文字】");
                }
                Console.WriteLine(sm.Length);
                await DLServiece.Download(sm.ToArray(), $"[{Guid.NewGuid():N}].docx");
            }
        }

测试结果:

 

 

目前测试基本没啥问题,但是仅限于自己的业务,可能还有很多奇葩的情况存在(毕竟修改了很多次)。

posted @ 2022-06-12 20:39  墨攻Morcom  阅读(1254)  评论(1)    收藏  举报