diff --git a/README.zh-CN.md b/README.zh-CN.md index 22f68ec..f7f74d9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -273,6 +273,39 @@ MiniWord.SaveAsByTemplate(path, templatePath, value); ![after_if](https://user-images.githubusercontent.com/38832863/220125435-72ea24b4-2412-45de-961a-ad4b2134417b.PNG) +### 循环 + + `@foreach` 和 `@endforeach` tags . + +##### Example + +```csharp +var value = new +{ + LoopData = new List() + { + new { + Type="类型A", + Items = new List() {new {Name = "A-1"}, new {Name = "A-2"},} + }, + new + { + Type="类型B", + Items = new List() {new {Name = "B-1"}, new {Name = "B-2"}, new {Name = "B-3"},} + }, + } +}; +MiniWord.SaveAsByTemplate(path, templatePath, value); +``` + +##### Template + +![1](https://github.com/user-attachments/assets/5d32241d-3977-46e7-b3de-cae130e5a653) + +##### Result + +![2](https://github.com/user-attachments/assets/69daa15e-4864-483e-b132-d8e867b6d1d1) + ### 多彩字体 ##### 代码例子 diff --git a/src/MiniWord/MiniWord.Implment.cs b/src/MiniWord/MiniWord.Implment.cs index cec059d..a1085ab 100644 --- a/src/MiniWord/MiniWord.Implment.cs +++ b/src/MiniWord/MiniWord.Implment.cs @@ -1,4 +1,4 @@ -namespace MiniSoftware +namespace MiniSoftware { using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; @@ -15,6 +15,9 @@ namespace MiniSoftware using A = DocumentFormat.OpenXml.Drawing; using DW = DocumentFormat.OpenXml.Drawing.Wordprocessing; using PIC = DocumentFormat.OpenXml.Drawing.Pictures; + using System.Xml; + using System.Xml.Linq; + using DocumentFormat.OpenXml.Drawing.Charts; public static partial class MiniWord { @@ -48,67 +51,180 @@ private static void Generate(this OpenXmlElement xmlElement, WordprocessingDocum // avoid {{tag}} like aa{{ test in... AvoidSplitTagText(xmlElement); + // @foreach循环体 + ReplaceForeachStatements(xmlElement,docx,tags); + //Tables - var tables = xmlElement.Descendants().ToArray(); + // 忽略table中没有占位符“{{}}”的表格 + var tables = xmlElement.Descendants
().Where(t => t.InnerText.Contains("{{")).ToArray(); { foreach (var table in tables) { - var trs = table.Descendants().ToArray(); // remember toarray or system will loop OOM; + GenerateTable(table,docx,tags); + } + } + + ReplaceIfStatements(xmlElement, tags); + + ReplaceText(xmlElement, docx, tags); + } + + /// + /// 渲染Table + /// + /// + /// + /// + /// + private static void GenerateTable(Table table, WordprocessingDocument docx, Dictionary tags) + { + var trs = table.Descendants().ToArray(); // remember toarray or system will loop OOM; + + foreach (var tr in trs) + { + var innerText = tr.InnerText.Replace("{{foreach", "").Replace("endforeach}}", "") + .Replace("{{if(", "").Replace(")if", "").Replace("endif}}", ""); + + // 匹配list数据,格式“Items.PropName” + var matchs = (Regex.Matches(innerText, "(?<={{).*?\\..*?(?=}})") + .Cast().GroupBy(x => x.Value).Select(varGroup => varGroup.First().Value)).ToArray(); + if (matchs.Length > 0) + { + //var listKeys = matchs.Select(s => s.Split('.')[0]).Distinct().ToArray(); + //// TODO: + //// not support > 2 list in same tr + //if (listKeys.Length > 2) + // throw new NotSupportedException("MiniWord doesn't support more than 2 list in same row"); + //var listKey = listKeys[0]; + + var listLevelKeys = matchs.Select(s => s.Substring(0, s.LastIndexOf('.'))).Distinct().ToArray(); + // TODO: + // not support > 2 list in same tr + if (listLevelKeys.Length > 2) + throw new NotSupportedException("MiniWord doesn't support more than 2 list in same row"); + + var tagObj = GetObjVal(tags, listLevelKeys[0]); - foreach (var tr in trs) + if(tagObj == null) continue; + + if (tagObj is IEnumerable) { - var innerText = tr.InnerText.Replace("{{foreach", "").Replace("endforeach}}", "") - .Replace("{{if(", "").Replace(")if", "").Replace("endif}}", ""); - var matchs = (Regex.Matches(innerText, "(?<={{).*?\\..*?(?=}})") - .Cast().GroupBy(x => x.Value).Select(varGroup => varGroup.First().Value)).ToArray(); - if (matchs.Length > 0) + var attributeKey = matchs[0].Split('.')[0]; + var list = tagObj as IEnumerable; + + foreach (var item in list) { - var listKeys = matchs.Select(s => s.Split('.')[0]).Distinct().ToArray(); - // TODO: - // not support > 2 list in same tr - if (listKeys.Length > 2) - throw new NotSupportedException("MiniWord doesn't support more than 2 list in same row"); - var listKey = listKeys[0]; - if (tags.ContainsKey(listKey) && tags[listKey] is IEnumerable) - { - var attributeKey = matchs[0].Split('.')[0]; - var list = tags[listKey] as IEnumerable; + var dic = new Dictionary(); //TODO: optimize - foreach (Dictionary es in list) - { - var dic = new Dictionary(); //TODO: optimize - var newTr = tr.CloneNode(true); - foreach (var e in es) - { - var dicKey = $"{listKey}.{e.Key}"; - dic.Add(dicKey, e.Value); - } + var newTr = tr.CloneNode(true); + if (item is IDictionary) + { + var es = (Dictionary)item; + foreach (var e in es) + { + var dicKey = $"{listLevelKeys[0]}.{e.Key}"; + dic[dicKey] = e.Value; + } + } + // 支持Obj.A.B.C... + else + { + var props = item.GetType().GetProperties(); + foreach (var p in props) + { + var dicKey = $"{listLevelKeys[0]}.{p.Name}"; + dic[dicKey] = p.GetValue(item); + } + } - ReplaceStatements(newTr, tags: dic); + ReplaceIfStatements(newTr, tags: dic); - ReplaceText(newTr, docx, tags: dic); - //Fix #47 The table should be inserted at the template tag position instead of the last row - if (table.Contains(tr)) - { - table.InsertBefore(newTr, tr); - } - else - { - // If it is a nested table, temporarily append it to the end according to the original plan. - table.Append(newTr); - } - } - tr.Remove(); + ReplaceText(newTr, docx, tags: dic); + //Fix #47 The table should be inserted at the template tag position instead of the last row + if (table.Contains(tr)) + { + table.InsertBefore(newTr, tr); } + else + { + // If it is a nested table, temporarily append it to the end according to the original plan. + table.Append(newTr); + } + } + tr.Remove(); + } + else + { + var dic = new Dictionary(); //TODO: optimize + + var props = tagObj.GetType().GetProperties(); + foreach (var p in props) + { + var dicKey = $"{listLevelKeys[0]}.{p.Name}"; + dic[dicKey] = p.GetValue(tagObj); } + + ReplaceIfStatements(tr, tags: tagObj.ToDictionary()); + + ReplaceText(tr, docx, tags: dic); } } + else + { + var matchTxtProp = new Regex(@"(?<={{).*?\.?.*?(?=}})").Match(innerText); + if(!matchTxtProp.Success) continue; + + ReplaceText(tr, docx, tags); + } } + } - ReplaceStatements(xmlElement, tags); - ReplaceText(xmlElement, docx, tags); + /// + /// 获取Obj对象指定的值 + /// + /// 数据源 + /// 属性名,如“A.B” + /// + /// + private static object GetObjVal(object objSource, string propNames) + { + return GetObjVal(objSource, propNames.Split('.')); + } + + /// + /// 获取Obj对象指定的值 + /// + /// 数据源 + /// 属性名,如“A.B”即[0]A,[1]B + /// + /// + private static object GetObjVal(object objSource, string[] propNames) + { + var nextPropNames = propNames.Skip(1).ToArray(); + if (objSource is IDictionary) + { + var dict = (IDictionary)objSource; + if (dict.Contains(propNames[0])) + { + var val = dict[propNames[0]]; + if(propNames.Length >1) + return GetObjVal(dict[propNames[0]], nextPropNames); + else return val; + } + return null; + } + // todo objSource = list + var prop1 = objSource.GetType().GetProperty(propNames[0]); + if (prop1 == null) + return null; + + var prop1Val = prop1.GetValue(objSource); + // 如果propNames只有一级,则直接返回对应的值 + if (propNames.Length == 1) + return prop1Val; + return GetObjVal(prop1Val, nextPropNames); } private static void AvoidSplitTagText(OpenXmlElement xmlElement) @@ -334,213 +450,355 @@ private static object EvaluateValue(string value) return value; } - private static void ReplaceText(OpenXmlElement xmlElement, WordprocessingDocument docx, Dictionary tags) + /// + /// 替换单个paragraph属性值 + /// + /// + /// + /// + private static void ReplaceText(Paragraph p, WordprocessingDocument docx, Dictionary tags) { - var paragraphs = xmlElement.Descendants().ToArray(); - foreach (var p in paragraphs) - { - var runs = p.Descendants().ToArray(); + var runs = p.Descendants().ToArray(); - foreach (var run in runs) + foreach (var run in runs) + { + var texts = run.Descendants().ToArray(); + if (texts.Length == 0) + continue; + foreach (Text t in texts) { - var texts = run.Descendants().ToArray(); - if (texts.Length == 0) - continue; - foreach (Text t in texts) + foreach (var tag in tags) { - foreach (var tag in tags) - { - var isMatch = t.Text.Contains($"{{{{{tag.Key}}}}}"); + // 完全匹配 + var isFullMatch = t.Text.Contains($"{{{{{tag.Key}}}}}"); + // 层级匹配,如{{A.B.C.D}} + var partMatch = new Regex($".*{{{{({tag.Key}(\\.\\w+)+)}}}}.*").Match(t.Text); - if (!isMatch && tag.Value is List forTags) + if (!isFullMatch && tag.Value is List forTags) + { + if (forTags.Any(forTag => forTag.Value.Keys.Any(dictKey => { - if (forTags.Any(forTag => forTag.Value.Keys.Any(dictKey => - { - var innerTag = "{{" + tag.Key + "." + dictKey + "}}"; - return t.Text.Contains(innerTag); - }))) - { - isMatch = true; - } + var innerTag = "{{" + tag.Key + "." + dictKey + "}}"; + return t.Text.Contains(innerTag); + }))) + { + isFullMatch = true; } + } - if (isMatch) + if (isFullMatch || partMatch.Success) + { + var key = isFullMatch ? tag.Key : partMatch.Groups[1].Value; + var value = isFullMatch ? tag.Value : GetObjVal(tags, key); + + if (value is string[] || value is IList || value is List) { - if (tag.Value is string[] || tag.Value is IList || tag.Value is List) + var vs = value as IEnumerable; + var currentT = t; + var isFirst = true; + foreach (var v in vs) { - var vs = tag.Value as IEnumerable; - var currentT = t; - var isFirst = true; - foreach (var v in vs) - { - var newT = t.CloneNode(true) as Text; - newT.Text = t.Text.Replace($"{{{{{tag.Key}}}}}", v?.ToString()); - if (isFirst) - isFirst = false; - else - run.Append(new Break()); - newT.Text = EvaluateIfStatement(newT.Text); - run.Append(newT); - currentT = newT; - } - t.Remove(); + var newT = t.CloneNode(true) as Text; + // todo + newT.Text = t.Text.Replace($"{{{{{key}}}}}", v?.ToString()); + if (isFirst) + isFirst = false; + else + run.Append(new Break()); + newT.Text = EvaluateIfStatement(newT.Text); + run.Append(newT); + currentT = newT; } - else if (tag.Value is List vs) + t.Remove(); + } + // todo 未验证嵌套对象的渲染 + else if (value is List vs) + { + var currentT = t; + var generatedText = new Text(); + currentT.Text = currentT.Text.Replace(@"{{foreach", "").Replace(@"endforeach}}", ""); + + var newTexts = new Dictionary(); + for (var i = 0; i < vs.Count; i++) { - var currentT = t; - var generatedText = new Text(); - currentT.Text = currentT.Text.Replace(@"{{foreach", "").Replace(@"endforeach}}", ""); + var newT = t.CloneNode(true) as Text; - var newTexts = new Dictionary(); - for (var i = 0; i < vs.Count; i++) + foreach (var vv in vs[i].Value) { - var newT = t.CloneNode(true) as Text; + // todo tag,Key + newT.Text = newT.Text.Replace("{{" + tag.Key + "." + vv.Key + "}}", vv.Value.ToString()); + } - foreach (var vv in vs[i].Value) - { - newT.Text = newT.Text.Replace("{{" + tag.Key + "." + vv.Key + "}}", vv.Value.ToString()); - } + newT.Text = EvaluateIfStatement(newT.Text); - newT.Text = EvaluateIfStatement(newT.Text); + if (!string.IsNullOrEmpty(newT.Text)) + newTexts.Add(i, newT.Text); + } - if (!string.IsNullOrEmpty(newT.Text)) - newTexts.Add(i, newT.Text); - } + for (var i = 0; i < newTexts.Count; i++) + { + var dict = newTexts.ElementAt(i); + generatedText.Text += dict.Value; - for (var i = 0; i < newTexts.Count; i++) + if (i != newTexts.Count - 1) { - var dict = newTexts.ElementAt(i); - generatedText.Text += dict.Value; - - if (i != newTexts.Count - 1) - { - generatedText.Text += vs[dict.Key].Separator; - } + generatedText.Text += vs[dict.Key].Separator; } + } - run.Append(generatedText); - t.Remove(); + run.Append(generatedText); + t.Remove(); + } + else if (IsHyperLink(value)) + { + AddHyperLink(docx, run, value); + t.Remove(); + } + else if (value is MiniWordColorText || value is MiniWordColorText[]) + { + if (value is MiniWordColorText) + { + AddColorText(run, new[] { (MiniWordColorText)value }); } - else if (tag.Value is MiniWordPicture) + else { - var pic = (MiniWordPicture)tag.Value; - byte[] l_Data = null; - if (pic.Path != null) - { - l_Data = File.ReadAllBytes(pic.Path); - } - if (pic.Bytes != null) - { - l_Data = pic.Bytes; - } - - var mainPart = docx.MainDocumentPart; - - var imagePart = mainPart.AddImagePart(pic.GetImagePartType); - using (var stream = new MemoryStream(l_Data)) - { - imagePart.FeedData(stream); - AddPicture(run, mainPart.GetIdOfPart(imagePart), pic); - - } - t.Remove(); + AddColorText(run, (MiniWordColorText[])value); } - else if (IsHyperLink(tag.Value)) + t.Remove(); + } + else if (value is MiniWordPicture) + { + var pic = (MiniWordPicture)value; + byte[] l_Data = null; + if (pic.Path != null) { - AddHyperLink(docx, run, tag.Value); - t.Remove(); + l_Data = File.ReadAllBytes(pic.Path); } - else if (tag.Value is MiniWordColorText || tag.Value is MiniWordColorText[]) + if (pic.Bytes != null) { - if (tag.Value is MiniWordColorText) - { - AddColorText(run, new[] { (MiniWordColorText)tag.Value }); - } - else - { - AddColorText(run, (MiniWordColorText[])tag.Value); - } - t.Remove(); + l_Data = pic.Bytes; } - else + + var mainPart = docx.MainDocumentPart; + + var imagePart = mainPart.AddImagePart(pic.GetImagePartType); + using (var stream = new MemoryStream(l_Data)) { - var newText = string.Empty; - if (tag.Value is DateTime) - { - newText = ((DateTime)tag.Value).ToString("yyyy-MM-dd HH:mm:ss"); - } - else - { - newText = tag.Value?.ToString(); - } + imagePart.FeedData(stream); + AddPicture(run, mainPart.GetIdOfPart(imagePart), pic); - t.Text = t.Text.Replace($"{{{{{tag.Key}}}}}", newText); } + t.Remove(); + } + else + { + var newText = value is DateTime ? ((DateTime)value).ToString("yyyy-MM-dd HH:mm:ss") : value?.ToString(); + t.Text = t.Text.Replace($"{{{{{key}}}}}", newText); } } - t.Text = EvaluateIfStatement(t.Text); + } + + t.Text = EvaluateIfStatement(t.Text); - // add breakline + // add breakline + { + var newText = t.Text; + var splits = Regex.Split(newText, "(<[a-zA-Z/].*?>|\n|\r\n)").Where(o => o != "\n" && o != "\r\n"); + var currentT = t; + var isFirst = true; + if (splits.Count() > 1) { - var newText = t.Text; - var splits = Regex.Split(newText, "(<[a-zA-Z/].*?>|\n|\r\n)").Where(o => o != "\n" && o != "\r\n"); - var currentT = t; - var isFirst = true; - if (splits.Count() > 1) + foreach (var v in splits) { - foreach (var v in splits) - { - var newT = t.CloneNode(true) as Text; - newT.Text = v?.ToString(); - if (isFirst) - isFirst = false; - else - run.Append(new Break()); - run.Append(newT); - currentT = newT; - } - t.Remove(); + var newT = t.CloneNode(true) as Text; + newT.Text = v?.ToString(); + if (isFirst) + isFirst = false; + else + run.Append(new Break()); + run.Append(newT); + currentT = newT; } + t.Remove(); } } } } } - private static void ReplaceStatements(OpenXmlElement xmlElement, Dictionary tags) + private static void ReplaceText(OpenXmlElement xmlElement, WordprocessingDocument docx, Dictionary tags) { - var descendants = xmlElement.Descendants().ToList(); - var paragraphs = xmlElement.Descendants().ToList(); + var paragraphs = xmlElement.Descendants().ToArray(); + foreach (var p in paragraphs) + { + ReplaceText(p,docx,tags); + } + } + + /// + /// @foreach元素复制及填充 + /// + /// + private static void ReplaceForeachStatements(OpenXmlElement xmlElement,WordprocessingDocument docx,Dictionary data) + { + // 1. 先获取Foreach的元素 + var beginKey = "@foreach"; + var endKey = "@endforeach"; + + var betweenEles = GetBetweenElements(xmlElement, beginKey, endKey, false); + while (betweenEles?.Any() == true) + { + var beginParagraph = + xmlElement.Descendants().FirstOrDefault(p => p.InnerText.Contains(beginKey)); + var endParagraph = + xmlElement.Descendants().FirstOrDefault(p => p.InnerText.Contains(endKey)); + // 获取需循环的数据key + var match = new Regex(@".*{{(\w+(\.\w+)*)}}.*").Match(beginParagraph.InnerText); + if (!match.Success) throw new Exception($"@Foreach循环未找到对应数据"); + var foreachDataKey = match.Groups[1].Value; + + // 删除关键字文本行 + beginParagraph?.Remove(); + endParagraph?.Remove(); + // 循环体最后一个元素,用于新元素插入定位 + var lastEleInLoop = betweenEles.LastOrDefault(); + var copyLoopEles = betweenEles.Select(e => e.CloneNode(true)).ToList(); + // 需要循环的数据 + var foreachList = GetObjVal(data, foreachDataKey); + + if (foreachList is IList list && list.Count > 0) + { + var loopEles = new List(); + for (var i = 0; i < list.Count; i++) + { + var item = list[i]; + var foreachDataDict = item.ToDictionary(); + // 2. 渲染替换属性值{{}},插入循环元素,再替换…… + // 2.1 替换属性值 + if (i == 0) + loopEles = new List(betweenEles); + foreach (var ele in loopEles) + { + if (ele is Table table) + GenerateTable(table, docx, foreachDataDict); + else if (ele is Paragraph p) + { + ReplaceText(p, docx, foreachDataDict); + } + } + // @if代码块替换 + ReplaceIfStatements(xmlElement, loopEles, foreachDataDict); + // 2.2 新增一个循环体元素 + if (list.Count - 1 > i) + { + loopEles.Clear(); + foreach (var ele in copyLoopEles) + { + var newEle = ele.CloneNode(true); + xmlElement.InsertAfter(newEle, lastEleInLoop); + lastEleInLoop = newEle; + loopEles.Add(newEle); + } + } + } + } + else + { + // 如果没有数据,删除循环元素 + foreach (var ele in betweenEles) + { + ele.Remove(); + } + } + + betweenEles = GetBetweenElements(xmlElement, beginKey, endKey, false); + } + + + } + + /// + /// 获取关键词之间的元素 + /// + /// + /// + /// + /// 是否克隆元素对象。true:返回克隆元素;false:返回原始元素 + /// + private static List GetBetweenElements(OpenXmlElement sourceElement,string beginKey,string endKey,bool isCopyEle = true) + { + var beginParagraph = + sourceElement.Descendants().FirstOrDefault(p => p.InnerText.Contains(beginKey)); + var beginIndex = sourceElement.Elements().ToList().IndexOf(beginParagraph); + if (beginIndex < 0) return null; + + var result = new List(); + foreach (var element in sourceElement.Elements().Skip(beginIndex + 1)) + { + result.Add(isCopyEle ? element.CloneNode(true) : element); + if (element is Paragraph p && p.InnerText.Contains(endKey)) + { + // 移除endKey的paragraph + result.RemoveAt(result.Count - 1); + return result; + } + } + return result; + } + + /// + /// @if处理逻辑 + /// + /// 根元素 + /// 包含@if-@end的元素集合 + /// + private static void ReplaceIfStatements(OpenXmlElement rootXmlElement, List elementList, Dictionary tags) + { + var paragraphs = elementList.Where(e=>e is Paragraph).ToList(); while (paragraphs.Any(s => s.InnerText.Contains("@if"))) { - var ifIndex = paragraphs.FindIndex(0, s => s.InnerText.Contains("@if")); - var endIfFinalIndex = paragraphs.FindIndex(ifIndex, s => s.InnerText.Contains("@endif")); + var ifP = paragraphs.First( s => s.InnerText.Contains("@if")); + var endIfP = paragraphs.First( s => s.InnerText.Contains("@endif")); - var statement = paragraphs[ifIndex].InnerText.Split(' '); + var statement = ifP.InnerText.Split(' '); - var tagValue = tags[statement[1]] ?? "NULL"; + //var tagValue = tags[statement[1]] ?? "NULL"; + var tagValue1 = GetObjVal(tags, statement[1]) ?? "NULL"; + var tagValue2 = GetObjVal(tags, statement[3]) ?? statement[3]; - var checkStatement = statement.Length == 4 ? EvaluateStatement(tagValue.ToString(), statement[2], statement[3]) : !bool.Parse(tagValue.ToString()); + var checkStatement = statement.Length == 4 ? EvaluateStatement(tagValue1.ToString(), statement[2], tagValue2.ToString()) : !bool.Parse(tagValue1.ToString()); if (!checkStatement) { - var paragraphIfIndex = descendants.FindIndex(a => a == paragraphs[ifIndex]); - var paragraphEndIfIndex = descendants.FindIndex(a => a == paragraphs[endIfFinalIndex]); + var paragraphIfIndex = elementList.FindIndex(a => a == ifP); + var paragraphEndIfIndex = elementList.FindIndex(a => a == endIfP); for (int i = paragraphIfIndex + 1; i <= paragraphEndIfIndex - 1; i++) { - descendants[i].Remove(); + if(rootXmlElement.ChildElements.Any(c=>c == elementList[i])) rootXmlElement.RemoveChild(elementList[i]); } - } + if(rootXmlElement.ChildElements.Any(c => c == ifP)) + rootXmlElement.RemoveChild(ifP); + if (rootXmlElement.ChildElements.Any(c => c == endIfP)) + rootXmlElement.RemoveChild(endIfP); + paragraphs.Remove(ifP); + paragraphs.Remove(endIfP); + } + } - paragraphs[ifIndex].Remove(); - paragraphs[endIfFinalIndex].Remove(); + /// + /// @if处理逻辑 + /// + /// @if-endif的父元素 + /// + private static void ReplaceIfStatements(OpenXmlElement xmlElement, Dictionary tags) + { + var descendants = xmlElement.Descendants().ToList(); - paragraphs = xmlElement.Descendants().ToList(); - } + ReplaceIfStatements(xmlElement,descendants, tags); } private static string EvaluateIfStatement(string text)