使用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>
可以看到,虽然在同一个段落(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"); } }
测试结果:

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

浙公网安备 33010602011771号