diff --git a/DocxTemplater.Test/DocxTemplateTest.cs b/DocxTemplater.Test/DocxTemplateTest.cs index 6ccc27a..a0b9c14 100644 --- a/DocxTemplater.Test/DocxTemplateTest.cs +++ b/DocxTemplater.Test/DocxTemplateTest.cs @@ -1,9 +1,10 @@ -using System.Collections; -using System.Globalization; -using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; using DocxTemplater.Images; +using System.Collections; +using System.Dynamic; +using System.Globalization; using Bold = DocumentFormat.OpenXml.Wordprocessing.Bold; using Paragraph = DocumentFormat.OpenXml.Wordprocessing.Paragraph; using Run = DocumentFormat.OpenXml.Wordprocessing.Run; @@ -14,6 +15,231 @@ namespace DocxTemplater.Test { internal class DocxTemplateTest { + + [Test] + public void DynamicTable() + { + using var fileStream = File.OpenRead("Resources/DynamicTable.docx"); + var docTemplate = new DocxTemplate(fileStream); + var tableModel = new DynamicTable(); + tableModel.AddRow(new Dictionary() { { "Header1", "Value1" }, { "Header2", "Value2" }, { "Header3", "Value3" } }); + tableModel.AddRow(new Dictionary() { { "Header1", "Value4" }, { "Header2", "Value5" }, { "Header3", "Value6" } }); + tableModel.AddRow(new Dictionary() { { "Header1", "Value7" }, { "Header2", "Value8" }, { "Header3", "Value9" } }); + + docTemplate.BindModel("ds", tableModel); + + var result = docTemplate.Process(); + docTemplate.Validate(); + result.Position = 0; + result.SaveAsFileAndOpenInWord(); + result.Position = 0; + var document = WordprocessingDocument.Open(result, false); + var body = document.MainDocumentPart.Document.Body; + var table = body.Descendants().First(); + var rows = table.Descendants().ToList(); + Assert.That(rows.Count, Is.EqualTo(5)); + Assert.That(rows[0].InnerText, Is.EqualTo("Header1Header2Header3")); + Assert.That(rows[2].InnerText, Is.EqualTo("Value1Value2Value3")); + Assert.That(rows[3].InnerText, Is.EqualTo("Value4Value5Value6")); + Assert.That(rows[4].InnerText, Is.EqualTo("Value7Value8Value9")); + } + + /// + /// Dynamic tables are only required if the number of columns is not known at design time. + /// otherwise a simple table bound to a collection of objects is sufficient. + /// + [Test] + public void DynamicTableWithComplexObjectsAsHeaderAndValues() + { + using var fileStream = File.OpenRead("Resources/DynamicTableWithComplexObjectsAsHeaderAndValues.docx"); + var docTemplate = new DocxTemplate(fileStream); + docTemplate.Settings.Culture = new CultureInfo("en-US"); + var tableModel = new DynamicTable(); + tableModel.AddRow(new Dictionary() + { + { + new {HeaderTitle = "Header1"}, new { TheDouble = 20.0, TheDate = new DateTime(2007, 11, 12) } + }, + { + new {HeaderTitle = "Header2"}, new { TheDouble = 30.0, TheDate = new DateTime(2007, 9, 12) } + }, + { + new {HeaderTitle = "Header3"}, new { TheDouble = 40.0, TheDate = new DateTime(2001, 11, 14) } + } + }); + tableModel.AddRow(new Dictionary() + { + { + new {HeaderTitle = "Header1"}, new { TheDouble = 50.0, TheDate = new DateTime(2007, 11, 12) } + }, + { + new {HeaderTitle = "Header2"}, new { TheDouble = 60.0, TheDate = new DateTime(2007, 9, 12) } + }, + { + new {HeaderTitle = "Header3"}, new { TheDouble = 70.0, TheDate = new DateTime(2002, 11, 9) } + } + }); + tableModel.AddRow(new Dictionary() + { + { + new {HeaderTitle = "Header1"}, new { TheDouble = 80.0, TheDate = new DateTime(2007, 11, 12) } + }, + { + new {HeaderTitle = "Header2"}, new { TheDouble = 90.0, TheDate = new DateTime(2007, 9, 12) } + }, + { + new {HeaderTitle = "Header3"}, new { TheDouble = 100.0, TheDate = new DateTime(2003, 11, 12) } + } + }); + + docTemplate.BindModel("ds", tableModel); + var result = docTemplate.Process(); + docTemplate.Validate(); + result.Position = 0; + result.SaveAsFileAndOpenInWord(); + result.Position = 0; + var document = WordprocessingDocument.Open(result, false); + var body = document.MainDocumentPart.Document.Body; + var table = body.Descendants
().First(); + var rows = table.Descendants().ToList(); + Assert.That(rows.Count, Is.EqualTo(5)); + Assert.That(rows[0].InnerText, Is.EqualTo("HEADER1HEADER2HEADER3")); + Assert.That(rows[2].InnerText, Is.EqualTo("20.00 11/12/200730.00 9/12/200740.00 11/14/2001")); + Assert.That(rows[3].InnerText, Is.EqualTo("50.00 11/12/200760.00 9/12/200770.00 11/9/2002")); + Assert.That(rows[4].InnerText, Is.EqualTo("80.00 11/12/200790.00 9/12/2007100.00 11/12/2003")); + } + + [Test] + public void MissingVariableThrows() + { + using var memStream = new MemoryStream(); + using var wpDocument = WordprocessingDocument.Create(memStream, WordprocessingDocumentType.Document); + MainDocumentPart mainPart = wpDocument.AddMainDocumentPart(); + mainPart.Document = new Document(new Body(new Paragraph(new Run(new Text("{{missing}}"))))); + wpDocument.Save(); + memStream.Position = 0; + var docTemplate = new DocxTemplate(memStream); + Assert.Throws(() => docTemplate.Process()); + } + + [Test] + public void MissingVariableWithSkipErrorHandling() + { + using var memStream = new MemoryStream(); + using var wpDocument = WordprocessingDocument.Create(memStream, WordprocessingDocumentType.Document); + MainDocumentPart mainPart = wpDocument.AddMainDocumentPart(); + mainPart.Document = new Document(new Body(new Paragraph(new Run(new Text("Text1{{missing}}Text2{{missing2}:toupper}{{missingImg}:img()}"))))); + wpDocument.Save(); + memStream.Position = 0; + var docTemplate = new DocxTemplate(memStream); + docTemplate.Settings.BindingErrorHandling = BindingErrorHandling.SkipBindingAndRemoveContent; + var result = docTemplate.Process(); + + var document = WordprocessingDocument.Open(result, false); + var body = document.MainDocumentPart.Document.Body; + //check values have been replaced + Assert.That(body.InnerText, Is.EqualTo("Text1Text2")); + } + + [Test] + public void LoopStartAndEndTagsAreRemoved() + { + using var memStream = new MemoryStream(); + using var wpDocument = WordprocessingDocument.Create(memStream, WordprocessingDocumentType.Document); + MainDocumentPart mainPart = wpDocument.AddMainDocumentPart(); + mainPart.Document = new Document(new Body( + new Paragraph(new Run(new Text("Text123"))), + new Paragraph(new Run(new Text("{{#ds.Items}}"))), + new Paragraph(new Run(new Text("{{Items.Name}} {{Items.Price < 6}} less than 6 {{else}} more than 6{{/}}"))), + new Paragraph(new Run(new Text("{{/ds.Items}}"))), + new Paragraph(new Run(new Text("Text456"))) + )); + wpDocument.Save(); + memStream.Position = 0; + var docTemplate = new DocxTemplate(memStream); + docTemplate.BindModel("ds", new { Items = new[] { new { Name = "Item1", Price = 5 }, new { Name = "Item2", Price = 7 } } }); + var result = docTemplate.Process(); + docTemplate.Validate(); + Assert.IsNotNull(result); + result.Position = 0; + result.SaveAsFileAndOpenInWord(); + result.Position = 0; + // there should only be 4 paragraphs after processing + var document = WordprocessingDocument.Open(result, false); + var body = document.MainDocumentPart.Document.Body; + Assert.That(body.Descendants().Count(), Is.EqualTo(4)); + } + + [Test] + public void ConditionsWithAndWithoutPrefix() + { + using var memStream = new MemoryStream(); + using var wpDocument = WordprocessingDocument.Create(memStream, WordprocessingDocumentType.Document); + MainDocumentPart mainPart = wpDocument.AddMainDocumentPart(); + mainPart.Document = new Document(new Body( + new Paragraph(new Run(new Text("{{ Test > 5 }}Test1{{ else }}else1{{ / }}"))), + new Paragraph(new Run(new Text("{{ds.Test > 5}}Test2{{else}}else2{{/}}"))), + new Paragraph(new Run(new Text("{{ds2.Test > 5}}Test3{{else}}else3{{/}}"))) + + )); + wpDocument.Save(); + memStream.Position = 0; + var docTemplate = new DocxTemplate(memStream); + docTemplate.BindModel("ds", new { Test = 6 }); + docTemplate.BindModel("ds2", new { Test = 6 }); + var result = docTemplate.Process(); + docTemplate.Validate(); + Assert.IsNotNull(result); + result.Position = 0; + result.SaveAsFileAndOpenInWord(); + result.Position = 0; + // check result text + var document = WordprocessingDocument.Open(result, false); + var body = document.MainDocumentPart.Document.Body; + Assert.That(body.InnerText, Is.EqualTo("Test1Test2Test3")); + } + + [Test] + public void BindToMultipleModels() + { + using var memStream = new MemoryStream(); + using var wpDocument = WordprocessingDocument.Create(memStream, WordprocessingDocumentType.Document); + + MainDocumentPart mainPart = wpDocument.AddMainDocumentPart(); + mainPart.Document = new Document(new Body(new Paragraph( + new Run(new Text("{{obj.var1}}")), + new Run(new Text("{{obj.var2}}")), + new Run(new Text("{{dynObj.var3}}")), + new Run(new Text("{{dict.var4}}")), + new Run(new Text("{{interface.var5}}")) + ))); + wpDocument.Save(); + memStream.Position = 0; + var docTemplate = new DocxTemplate(memStream); + + docTemplate.BindModel("obj", new { var1 = "var1", var2 = "var2" }); + dynamic dynObj = new ExpandoObject(); + dynObj.var3 = "var3"; + docTemplate.BindModel("dynObj", dynObj); + + var dict = new Dictionary(); + dict.Add("var4", "var4"); + docTemplate.BindModel("dict", dict); + + var dummyModel = new DummyModel(); + dummyModel.Add("var5", "var5"); + docTemplate.BindModel("interface", dummyModel); + + var result = docTemplate.Process(); + docTemplate.Validate(); + Assert.IsNotNull(result); + result.Position = 0; + + var document = WordprocessingDocument.Open(result, false); + var body = document.MainDocumentPart.Document.Body; + Assert.That(body.InnerText, Is.EqualTo("var1var2var3var4var5")); + } + [Test] public void ReplaceTextBoldIsPreserved() { @@ -416,7 +642,25 @@ public decimal TotalPrice set; } } - } + private class DummyModel : ITemplateModel + { + private readonly Dictionary m_dict; + public DummyModel() + { + m_dict = new Dictionary(); + } + + public void Add(string key, object value) + { + m_dict.Add(key, value); + } + + public bool TryGetPropertyValue(string propertyName, out object value) + { + return m_dict.TryGetValue(propertyName, out value); + } + } + } } diff --git a/DocxTemplater.Test/DocxTemplater.Test.csproj b/DocxTemplater.Test/DocxTemplater.Test.csproj index a713716..82ec6e2 100644 --- a/DocxTemplater.Test/DocxTemplater.Test.csproj +++ b/DocxTemplater.Test/DocxTemplater.Test.csproj @@ -27,6 +27,12 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + PreserveNewest diff --git a/DocxTemplater.Test/PatternMatcherTest.cs b/DocxTemplater.Test/PatternMatcherTest.cs index 5b76601..ab998a4 100644 --- a/DocxTemplater.Test/PatternMatcherTest.cs +++ b/DocxTemplater.Test/PatternMatcherTest.cs @@ -25,30 +25,35 @@ static IEnumerable TestPatternMatch_Cases() yield return new TestCaseData("{{Foo}:blupp()}").Returns(new[] { PatternType.Variable }); - yield return new TestCaseData("{{/Items}}").Returns(new[] { PatternType.LoopEnd }); - yield return new TestCaseData("{{#items}}").Returns(new[] { PatternType.LoopStart }); - yield return new TestCaseData("{{#ds.items_foo}}").Returns(new[] { PatternType.LoopStart }).SetName("LoopStart Underscore dots"); - yield return new TestCaseData("{{/ds.items_foo}}").Returns(new[] { PatternType.LoopEnd }).SetName("LoopEnd Underscore dots"); - yield return new TestCaseData("{{/Items.InnerCollection}}").Returns(new[] { PatternType.LoopEnd }); - yield return new TestCaseData("{{#items.InnerCollection}}").Returns(new[] { PatternType.LoopStart }); + yield return new TestCaseData("{{/Items}}").Returns(new[] { PatternType.CollectionEnd }); + yield return new TestCaseData("{{ /Items }}").Returns(new[] { PatternType.CollectionEnd }); + yield return new TestCaseData("{{#items}}").Returns(new[] { PatternType.CollectionStart }); + yield return new TestCaseData("{{ #items }}").Returns(new[] { PatternType.CollectionStart }); + yield return new TestCaseData("{{#ds.items_foo}}").Returns(new[] { PatternType.CollectionStart }).SetName("LoopStart Underscore dots"); + yield return new TestCaseData("{{/ds.items_foo}}").Returns(new[] { PatternType.CollectionEnd }).SetName("LoopEnd Underscore dots"); + yield return new TestCaseData("{{/Items.InnerCollection}}").Returns(new[] { PatternType.CollectionEnd }); + yield return new TestCaseData("{{#items.InnerCollection}}").Returns(new[] { PatternType.CollectionStart }); yield return new TestCaseData("{{a.foo > 5}}").Returns(new[] { PatternType.Condition }); yield return new TestCaseData("{{ a > 5 }}").Returns(new[] { PatternType.Condition }); + yield return new TestCaseData("{ { a > 5 } }").Returns(new[] { PatternType.Condition }); yield return new TestCaseData("{{ a / 20 >= 12 }}").Returns(new[] { PatternType.Condition }); yield return new TestCaseData("{{var}:F(d)}").Returns(new[] { PatternType.Variable }); yield return new TestCaseData("{{ds.foo.var}:F(d)}").Returns(new[] { PatternType.Variable }).SetName("Variable with dot"); yield return new TestCaseData("{{ds.foo_blubb.var}:F(d)}").Returns(new[] { PatternType.Variable }).SetName("Variable with underscore"); yield return new TestCaseData("{{var}:toupper}").Returns(new[] { PatternType.Variable }); yield return new TestCaseData("{{else}}").Returns(new[] { PatternType.ConditionElse }); + yield return new TestCaseData("{{ else }}").Returns(new[] { PatternType.ConditionElse }); yield return new TestCaseData("{{var}:format(a,b)}").Returns(new[] { PatternType.Variable }) .SetName("Multiple Arguments"); yield return new TestCaseData("{{/}}").Returns(new[] { PatternType.ConditionEnd }); + yield return new TestCaseData("{ { / } }").Returns(new[] { PatternType.ConditionEnd }); yield return new TestCaseData( "NumericValue is greater than 0 - {{ds.Items.InnerCollection.InnerValue}:toupper()}{{else}}" + "I'm here if if this is not the case{{/}}{{/ds.Items.InnerCollection}}{{/Items}}") .Returns(new[] { - PatternType.Variable, PatternType.ConditionElse, PatternType.ConditionEnd, PatternType.LoopEnd, - PatternType.LoopEnd + PatternType.Variable, PatternType.ConditionElse, PatternType.ConditionEnd, PatternType.CollectionEnd, + PatternType.CollectionEnd }) .SetName("Complex Match 1"); } @@ -75,6 +80,15 @@ static IEnumerable PatternMatcherArgumentParsingTest_Cases() yield return new TestCaseData("{{Foo}:format(a,b,'YYYY_MMM/DD FF',d)}").Returns(new[] { "a", "b", "YYYY_MMM/DD FF", "d" }); yield return new TestCaseData("{{Foo}:format(a,'John Doe','YYYY_MMM/DD FF',d)}").Returns(new[] { "a", "John Doe", "YYYY_MMM/DD FF", "d" }); } + + [TestCase("Some TExt {{ Variable }} Some other text", ExpectedResult = "Variable")] + [TestCase("Some TExt {{Variable }} Some other text", ExpectedResult = "Variable")] + [TestCase("Some TExt {{ Variable }} Some other text", ExpectedResult = "Variable")] + public string AllowWhiteSpaceForVariables(string syntax) + { + var match = PatternMatcher.FindSyntaxPatterns(syntax).First(); + return match.Variable; + } } } diff --git a/DocxTemplater.Test/Resources/DynamicTable.docx b/DocxTemplater.Test/Resources/DynamicTable.docx new file mode 100644 index 0000000..7a12eb9 Binary files /dev/null and b/DocxTemplater.Test/Resources/DynamicTable.docx differ diff --git a/DocxTemplater.Test/Resources/DynamicTableWithComplexObjectsAsHeaderAndValues.docx b/DocxTemplater.Test/Resources/DynamicTableWithComplexObjectsAsHeaderAndValues.docx new file mode 100644 index 0000000..c76758a Binary files /dev/null and b/DocxTemplater.Test/Resources/DynamicTableWithComplexObjectsAsHeaderAndValues.docx differ diff --git a/DocxTemplater.Test/TestHelper.cs b/DocxTemplater.Test/TestHelper.cs index c37e5f3..623f8bf 100644 --- a/DocxTemplater.Test/TestHelper.cs +++ b/DocxTemplater.Test/TestHelper.cs @@ -1,6 +1,5 @@ -#if DEBUG -using System.Diagnostics; -#endif +using System.Diagnostics; + namespace DocxTemplater.Test { @@ -8,7 +7,14 @@ internal static class TestHelper { public static void SaveAsFileAndOpenInWord(this Stream stream, string extension = "docx") { -#if DEBUG +#if RELEASE + return; +#pragma warning disable CS0162 +#endif + if (Environment.GetEnvironmentVariable("DOCX_TEMPLATER_VISUAL_TESTING") == null) + { + return; + } stream.Position = 0; var fileName = Path.ChangeExtension(Path.GetTempFileName(), extension); using (var fileStream = File.OpenWrite(fileName)) @@ -22,7 +28,7 @@ public static void SaveAsFileAndOpenInWord(this Stream stream, string extension UseShellExecute = true }; using var proc = Process.Start(psi); -#endif +#pragma warning restore CS0162 } } } diff --git a/DocxTemplater/BindingErrorHandling.cs b/DocxTemplater/BindingErrorHandling.cs new file mode 100644 index 0000000..e71fbb8 --- /dev/null +++ b/DocxTemplater/BindingErrorHandling.cs @@ -0,0 +1,8 @@ +namespace DocxTemplater +{ + public enum BindingErrorHandling + { + ThrowException, + SkipBindingAndRemoveContent + } +} diff --git a/DocxTemplater/Blocks/ConditionalBlock.cs b/DocxTemplater/Blocks/ConditionalBlock.cs index bfbda82..4a58647 100644 --- a/DocxTemplater/Blocks/ConditionalBlock.cs +++ b/DocxTemplater/Blocks/ConditionalBlock.cs @@ -31,15 +31,15 @@ public override void Expand(ModelDictionary models, OpenXmlElement parentNode) element.Remove(); } - public override void SetContent(OpenXmlElement leadingPart, IReadOnlyCollection loopContent) + public override void SetContent(OpenXmlElement leadingPart, IReadOnlyCollection blockContent) { if (m_leadingPart == null) { - base.SetContent(leadingPart, loopContent); + base.SetContent(leadingPart, blockContent); } else { - m_elseContent = loopContent; + m_elseContent = blockContent; } } diff --git a/DocxTemplater/Blocks/ContentBlock.cs b/DocxTemplater/Blocks/ContentBlock.cs index a8b1529..8528a42 100644 --- a/DocxTemplater/Blocks/ContentBlock.cs +++ b/DocxTemplater/Blocks/ContentBlock.cs @@ -65,10 +65,10 @@ public override string ToString() return m_leadingPart?.Id ?? "RootBlock"; } - public virtual void SetContent(OpenXmlElement leadingPart, IReadOnlyCollection loopContent) + public virtual void SetContent(OpenXmlElement leadingPart, IReadOnlyCollection blockContent) { m_leadingPart = InsertionPoint.CreateForElement(leadingPart); - m_content = loopContent; + m_content = blockContent; } public void AddInnerBlock(ContentBlock block) diff --git a/DocxTemplater/Blocks/DynamicTableBlock.cs b/DocxTemplater/Blocks/DynamicTableBlock.cs new file mode 100644 index 0000000..ff5aa30 --- /dev/null +++ b/DocxTemplater/Blocks/DynamicTableBlock.cs @@ -0,0 +1,112 @@ +using System.Collections.Generic; +using System.Linq; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Wordprocessing; +using DocxTemplater.Formatter; + +namespace DocxTemplater.Blocks +{ + internal class DynamicTableBlock : ContentBlock + { + private readonly string m_tablenName; + + public DynamicTableBlock(string tablenName, VariableReplacer variableReplacer) + : base(variableReplacer) + { + m_tablenName = tablenName; + } + + public override void Expand(ModelDictionary models, OpenXmlElement parentNode) + { + var model = models.GetValue(m_tablenName); + if (model is IDynamicTable dynamicTable) + { + + var headersName = $"{m_tablenName}.{nameof(IDynamicTable.Headers)}"; + var columnsName = $"{m_tablenName}.Columns"; + + var table = m_content.OfType
().FirstOrDefault(); + var headerRow = table?.Elements().FirstOrDefault(row => row.Descendants().Any(d => d.HasMarker(PatternType.Variable) && d.Text.Contains($"{{{{{headersName}"))); + var headerCell = headerRow?.Elements().FirstOrDefault(); + + var dataRow = table?.Elements().FirstOrDefault(row => row.Descendants().Any(d => d.HasMarker(PatternType.Variable) && d.Text.Contains($"{{{{{columnsName}"))); + var dataCell = dataRow?.Elements().FirstOrDefault(row => row.Descendants().Any(d => d.HasMarker(PatternType.Variable) && d.Text.Contains($"{{{{{columnsName}"))); + if (headerCell == null || dataCell == null) + { + throw new OpenXmlTemplateException($"Dynamic table block must contain exactly one table with at least two rows and one column, but found"); + } + + // write headers + foreach (var header in dynamicTable.Headers.Reverse()) + { + models.RemoveLoopVariable(headersName); + models.AddLoopVariable(headersName, header); + var clonedCell = CreateBlockContentForCurrentVariableStack(new List { headerCell }); + headerCell.InsertAfterSelf(clonedCell); + ExpandChildBlocks(models, parentNode); + } + models.RemoveLoopVariable(headersName); + // remove header cell + headerCell.Remove(); + + // write data + var lastRow = dataRow; + var cellInsertionPoint = InsertionPoint.CreateForElement(dataCell); + foreach (var row in dynamicTable.Rows) + { + TableRow clonedRow = (TableRow)dataRow.CloneNode(true); + lastRow.InsertAfterSelf(clonedRow); + lastRow = clonedRow; + + var insertion = cellInsertionPoint.GetElement(clonedRow); + foreach (var column in row.Reverse()) + { + models.RemoveLoopVariable(columnsName); + models.AddLoopVariable(columnsName, column); + var clonedCell = CreateBlockContentForCurrentVariableStack(new List { dataCell }).Single(); + insertion.InsertAfterSelf(clonedCell); + ExpandChildBlocks(models, parentNode); + } + insertion.Remove(); + models.RemoveLoopVariable(columnsName); + } + dataRow.Remove(); + dataCell.Remove(); + + // ensure all rows have the same number of cells + var maxCells = dynamicTable.Rows.Max(r => r.Count()); + foreach (var row in table.Elements()) + { + var cells = row.Elements().ToList(); + while (cells.Count < maxCells) + { + var cell = (TableCell)cells.Last().CloneNode(true); + cells.Last().InsertAfterSelf(cell); + cells.Add(cell); + } + } + + InsertContent(parentNode, new List { table }); + } + else + { + throw new OpenXmlTemplateException($"Value of {m_tablenName} is not of type {typeof(IDynamicTable)}"); + } + } + + public override void SetContent(OpenXmlElement leadingPart, IReadOnlyCollection blockContent) + { + var tables = blockContent.OfType
().ToList(); + if (tables.Count != 1) + { + throw new OpenXmlTemplateException($"Dynamic table block must contain exactly one table, but found {tables.Count}"); + } + base.SetContent(leadingPart, tables); + } + + public override string ToString() + { + return $"Dynamic Table: {m_tablenName}"; + } + } +} diff --git a/DocxTemplater/DocxTemplate.cs b/DocxTemplater/DocxTemplate.cs index 8e0ac5d..32ad6f0 100644 --- a/DocxTemplater/DocxTemplate.cs +++ b/DocxTemplater/DocxTemplate.cs @@ -181,7 +181,7 @@ private static void Cleanup(OpenXmlCompositeElement element) foreach (var markedText in element.Descendants().Where(x => x.IsMarked()).ToList()) { var value = markedText.GetMarker(); - if (value is PatternType.LoopStart or PatternType.LoopEnd or PatternType.ConditionEnd or PatternType.ConditionElse) + if (value is PatternType.CollectionStart or PatternType.CollectionEnd or PatternType.ConditionEnd or PatternType.ConditionElse) { var parent = markedText.Parent; markedText.RemoveWithEmptyParent(); @@ -215,10 +215,17 @@ private IReadOnlyCollection ExpandLoops(OpenXmlCompositeElement el foreach (var text in element.Descendants().Where(x => x.IsMarked())) { var value = text.GetMarker(); - if (value is PatternType.LoopStart) + if (value is PatternType.CollectionStart) { var match = PatternMatcher.FindSyntaxPatterns(text.Text).Single(); - blockStack.Push((new LoopBlock(match.Variable, m_variableReplacer), match, text)); + if (match.Formatter.Equals("dyntable", StringComparison.InvariantCultureIgnoreCase)) + { + blockStack.Push((new DynamicTableBlock(match.Variable, m_variableReplacer), match, text)); + } + else + { + blockStack.Push((new LoopBlock(match.Variable, m_variableReplacer), match, text)); + } } else if (value == PatternType.Condition) { @@ -232,7 +239,7 @@ private IReadOnlyCollection ExpandLoops(OpenXmlCompositeElement el { throw new OpenXmlTemplateException($"'{block}' is not closed"); } - var loopContent = ExtractLoopContent(matchedTextNode, text, out var leadingPart); + var loopContent = ExtractBlockContent(matchedTextNode, text, out var leadingPart); block.SetContent(leadingPart, loopContent); blockStack.Push((block, patternMatch, text)); // push same block again on Stack but with other text element } @@ -243,18 +250,18 @@ private IReadOnlyCollection ExpandLoops(OpenXmlCompositeElement el { throw new OpenXmlTemplateException($"'{block}' is not closed"); } - var loopContent = ExtractLoopContent(matchedTextNode, text, out var leadingPart); + var loopContent = ExtractBlockContent(matchedTextNode, text, out var leadingPart); block.SetContent(leadingPart, loopContent); blockStack.Peek().Block.AddInnerBlock(block); } - else if (value == PatternType.LoopEnd) + else if (value == PatternType.CollectionEnd) { var (block, patternMatch, matchedTextNode) = blockStack.Pop(); - if (patternMatch.Type != PatternType.LoopStart) + if (patternMatch.Type != PatternType.CollectionStart) { throw new OpenXmlTemplateException($"'{block}' is not closed"); } - var loopContent = ExtractLoopContent(matchedTextNode, text, out var leadingPart); + var loopContent = ExtractBlockContent(matchedTextNode, text, out var leadingPart); block.SetContent(leadingPart, loopContent); blockStack.Peek().Block.AddInnerBlock(block); } @@ -265,7 +272,7 @@ private IReadOnlyCollection ExpandLoops(OpenXmlCompositeElement el } - internal static IReadOnlyCollection ExtractLoopContent(OpenXmlElement startText, OpenXmlElement endText, out OpenXmlElement leadingPart) + internal static IReadOnlyCollection ExtractBlockContent(OpenXmlElement startText, OpenXmlElement endText, out OpenXmlElement leadingPart) { var commonParent = startText.FindCommonParent(endText) ?? throw new OpenXmlTemplateException("Start and end text are not in the same tree"); var result = new List(); @@ -282,7 +289,7 @@ internal static IReadOnlyCollection ExtractLoopContent(OpenXmlEl } else { - // find childs of commmon parent that contains start and end text + // find childs of common parent that contains start and end text var startChildOfCommonParent = commonParent.ChildElements.Single(c => c == startText || c.Descendants().Any(d => d == startText)); var endChildOfCommonParent = diff --git a/DocxTemplater/DynamicTable.cs b/DocxTemplater/DynamicTable.cs new file mode 100644 index 0000000..02d92d0 --- /dev/null +++ b/DocxTemplater/DynamicTable.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Linq; + +namespace DocxTemplater +{ + public class DynamicTable : IDynamicTable + { + private readonly List> m_rows; + public IEnumerable Headers => m_rows.SelectMany(x => x.Keys).Distinct().ToList(); + + public IEnumerable> Rows => m_rows.Select(x => x.Values.ToList()).ToList(); + + public DynamicTable() + { + m_rows = new List>(); + } + + public void AddRow(Dictionary row) + { + m_rows.Add(row); + } + } +} diff --git a/DocxTemplater/Formatter/VariableReplacer.cs b/DocxTemplater/Formatter/VariableReplacer.cs index 70c1e60..901417e 100644 --- a/DocxTemplater/Formatter/VariableReplacer.cs +++ b/DocxTemplater/Formatter/VariableReplacer.cs @@ -65,8 +65,18 @@ public void ReplaceVariables(OpenXmlElement cloned) foreach (var text in variables) { var variableMatch = PatternMatcher.FindSyntaxPatterns(text.Text).FirstOrDefault() ?? throw new OpenXmlTemplateException($"Invalid variable syntax '{text.Text}'"); - var value = m_models.GetValue(variableMatch.Variable); - ApplyFormatter(variableMatch, value, text); + try + { + var value = m_models.GetValue(variableMatch.Variable); + ApplyFormatter(variableMatch, value, text); + } + catch (OpenXmlTemplateException) when (m_processSettings.BindingErrorHandling != BindingErrorHandling.ThrowException) + { + if (m_processSettings.BindingErrorHandling == BindingErrorHandling.SkipBindingAndRemoveContent) + { + text.RemoveWithEmptyParent(); + } + } } } } diff --git a/DocxTemplater/IDynamicTable.cs b/DocxTemplater/IDynamicTable.cs new file mode 100644 index 0000000..fab4ab5 --- /dev/null +++ b/DocxTemplater/IDynamicTable.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace DocxTemplater +{ + public interface IDynamicTable + { + public IEnumerable Headers { get; } + + + public IEnumerable> Rows { get; } + + } +} diff --git a/DocxTemplater/ITemplateModel.cs b/DocxTemplater/ITemplateModel.cs new file mode 100644 index 0000000..ac21f21 --- /dev/null +++ b/DocxTemplater/ITemplateModel.cs @@ -0,0 +1,10 @@ +namespace DocxTemplater +{ + /// + /// Interface for template model if a normal object or a dictionary is not suitable. + /// + public interface ITemplateModel + { + bool TryGetPropertyValue(string propertyName, out object value); + } +} diff --git a/DocxTemplater/ModelDictionary.cs b/DocxTemplater/ModelDictionary.cs index 120f493..5f236e8 100644 --- a/DocxTemplater/ModelDictionary.cs +++ b/DocxTemplater/ModelDictionary.cs @@ -68,7 +68,7 @@ public object GetValue(string variableName) var path = parts[0]; int startIndex = 0; - if (!m_models.ContainsKey(path)) + if (!m_models.ContainsKey(path) && m_models.Count > 0) { startIndex = -1; path = m_defaultModelPrefix.Value; @@ -82,23 +82,52 @@ public object GetValue(string variableName) { throw new OpenXmlTemplateException($"Model {path} not found"); } - var property = model.GetType().GetProperty(parts[i], BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.GetProperty | BindingFlags.Instance); - if (property != null) + if (model is ITemplateModel templateModel) { - model = property.GetValue(model); + if (templateModel.TryGetPropertyValue(parts[i], out var value)) + { + model = value; + } + else + { + throw new OpenXmlTemplateException($"Property {parts[i]} not found in {path}"); + } } - else if (model is ICollection) + else if (model is IDictionary dict) { - throw new OpenXmlTemplateException($"Property {parts[i]} on collection {path} not found - is collection start missing? '#{variableName}'"); + if (dict.TryGetValue(parts[i], out var value)) + { + model = value; + } + else + { + throw new OpenXmlTemplateException($"Property {parts[i]} not found in {path}"); + } } else { - throw new OpenXmlTemplateException($"Property {parts[i]} not found in {path}"); + var property = model.GetType().GetProperty(parts[i], BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.GetProperty | BindingFlags.Instance); + if (property != null) + { + model = property.GetValue(model); + } + else if (model is ICollection) + { + throw new OpenXmlTemplateException($"Property {parts[i]} on collection {path} not found - is collection start missing? '#{variableName}'"); + } + else + { + throw new OpenXmlTemplateException($"Property {parts[i]} not found in {parts[Math.Max(i - 1, 0)]}"); + } } } else { model = nextModel; + if (path == variableName) + { + break; + } } if (i + 1 < parts.Length) { diff --git a/DocxTemplater/PatterMatcher.cs b/DocxTemplater/PatterMatcher.cs index 7b2f3fb..02d7003 100644 --- a/DocxTemplater/PatterMatcher.cs +++ b/DocxTemplater/PatterMatcher.cs @@ -20,7 +20,7 @@ internal static class PatternMatcher {{images}:foo(arg1,arg2)} -- variable with formatter and arguments */ - private static readonly Regex PatternRegex = new(@"\{\{ + private static readonly Regex PatternRegex = new(@"\{\s*\{\s* (?: (?else) | (?: @@ -28,11 +28,11 @@ internal static class PatternMatcher (?: (?[a-zA-Z0-9\._]+) #variable name | #or - (?[a-zA-Z0-9+\-*\/><=\s\.]+) #condition + (?[a-zA-Z0-9+\-*\/><=\s\.]{2,}) #condition )? ) ) - \} + \s*\} (?:: (?[a-zA-z0-9]+) #formatter (?:\( #arguments with brackets @@ -43,7 +43,7 @@ internal static class PatternMatcher ) \))? )? - \} + \s*\} ", RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace); public static IEnumerable FindSyntaxPatterns(string text) { @@ -65,7 +65,7 @@ public static IEnumerable FindSyntaxPatterns(string text) { if (match.Groups["prefix"].Value == "#") { - result.Add(new PatternMatch(match, PatternType.LoopStart, null, match.Groups["prefix"].Value, match.Groups["varname"].Value, match.Groups["formatter"].Value, match.Groups["arg"].Value.Split(','), match.Index, match.Length)); + result.Add(new PatternMatch(match, PatternType.CollectionStart, null, match.Groups["prefix"].Value, match.Groups["varname"].Value, match.Groups["formatter"].Value, match.Groups["arg"].Value.Split(','), match.Index, match.Length)); } else if (!match.Groups["varname"].Success) { @@ -73,7 +73,7 @@ public static IEnumerable FindSyntaxPatterns(string text) } else { - result.Add(new PatternMatch(match, PatternType.LoopEnd, null, match.Groups["prefix"].Value, match.Groups["varname"].Value, match.Groups["formatter"].Value, match.Groups["arg"].Value.Split(','), match.Index, match.Length)); + result.Add(new PatternMatch(match, PatternType.CollectionEnd, null, match.Groups["prefix"].Value, match.Groups["varname"].Value, match.Groups["formatter"].Value, match.Groups["arg"].Value.Split(','), match.Index, match.Length)); } } else diff --git a/DocxTemplater/PatternType.cs b/DocxTemplater/PatternType.cs index 091bb44..86e1d09 100644 --- a/DocxTemplater/PatternType.cs +++ b/DocxTemplater/PatternType.cs @@ -5,8 +5,8 @@ internal enum PatternType None, Condition, ConditionEnd, - LoopStart, - LoopEnd, + CollectionStart, + CollectionEnd, Variable, ConditionElse } diff --git a/DocxTemplater/ProcessSettings.cs b/DocxTemplater/ProcessSettings.cs index 197a560..86be2dd 100644 --- a/DocxTemplater/ProcessSettings.cs +++ b/DocxTemplater/ProcessSettings.cs @@ -4,13 +4,18 @@ namespace DocxTemplater { public class ProcessSettings { - private CultureInfo m_culture; public CultureInfo Culture { - get => m_culture ?? CultureInfo.CurrentUICulture; - set => m_culture = value; - } + get; + set; + } = CultureInfo.CurrentUICulture; + + public BindingErrorHandling BindingErrorHandling + { + get; + set; + } = BindingErrorHandling.ThrowException; public static ProcessSettings Default { get; } = new ProcessSettings() { Culture = null }; // will use current ui culture } diff --git a/DocxTemplater/ScriptCompiler.cs b/DocxTemplater/ScriptCompiler.cs index cc3701e..8a0a5b1 100644 --- a/DocxTemplater/ScriptCompiler.cs +++ b/DocxTemplater/ScriptCompiler.cs @@ -19,7 +19,15 @@ public Func CompileScript(string scriptAsString) var identifiers = interpreter.DetectIdentifiers(scriptAsString); foreach (var identifier in identifiers.UnknownIdentifiers) { - interpreter.SetVariable(identifier, new ModelVariable(m_modelDictionary, identifier)); + var val = m_modelDictionary.GetValue(identifier); + if (val == null || val.GetType().IsPrimitive) + { + interpreter.SetVariable(identifier, val); + } + else + { + interpreter.SetVariable(identifier, new ModelVariable(m_modelDictionary, identifier)); + } } return interpreter.ParseAsDelegate>(scriptAsString); } diff --git a/README.md b/README.md index a95f1e9..72c3202 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,9 @@ It supports placeholder **replacement** and **loops** and **images**_ **Features:** * Variable Replacement -* Loops - Bind to collections +* Collections - Bind to collections * Conditional Blocks +* Dynamic Tables - Columns are defined by the datasource * HTML Snippets - Replace placeholder with HTML Content * Images - Replace placeholder with Image data @@ -23,9 +24,9 @@ This Text: {{ds.Title}} - will be replaced ``` Open the template, add a model and store the result to a file ```c# -ver template = DocxTemplate.Open("template.docx") -template.AddModel("ds", new {Title = "Some Text"}) -template.ProcessToFile("generated.docx") + var template = DocxTemplate.Open("template.docx"); + template.BindModel("ds", new { Title = "Some Text"}); + template.Save("generated.docx"); ``` The generated word document then contains @@ -48,7 +49,7 @@ PM> Install-Package DocxTemplater.Images ## Placeholder Syntax -A placholder can consist of three parts: {{**property**}:**formatter**(**arguments**)} +A placeholder can consist of three parts: {{**property**}:**formatter**(**arguments**)} - **property**: the path to the property in the datasource objects. - **formatter**: formatter applied to convert the model value to openxml _(ae. toupper, tolower img format etc)_ @@ -68,7 +69,7 @@ The syntax is case insensitive | {{SomeDate:F("MM/dd/yyyy")}} | Date variable with formatting - short syntax | {{SomeBytes:img()}} | Image Formatter for image data | {{SomeHtmlString:html()}} | Inserts html string into word document -### Loops +### Collections To repeat document content for each item in a collection the loop syntax can be used: **{{#_\_}}** .. content .. **{{__}}** @@ -167,3 +168,26 @@ The stretching behavior can be configured | KEEPRATIO| {{imgData}:img(keepratio)} | Scales the image to fit the container - keeps aspect ratio | STRETCHW | {imgData}:img(STRETCHW)}| Scales the image to fit the width of the container | STRETCHH | {imgData}:img(STRETCHH)}| Scales the image to fit the height of the container + +### Error Handling + +If a placeholder is not found in the model an exception is thrown. +This can be configured with the ```ProcessSettings``` + +```c# +var docTemplate = new DocxTemplate(memStream); +docTemplate.Settings.BindingErrorHandling = BindingErrorHandling.SkipBindingAndRemoveContent; +var result = docTemplate.Process(); +``` + +### Culture + +The culture used to format the model values can be configured with the ```ProcessSettings``` + +```c# +var docTemplate = new DocxTemplate(memStream, new ProcessSettings() +{ + Culture = new CultureInfo("en-us") +}); +var result = docTemplate.Process(); +```