diff --git a/DocxTemplater.Test/DocxTemplateTest.cs b/DocxTemplater.Test/DocxTemplateTest.cs index a0b9c14..6923e99 100644 --- a/DocxTemplater.Test/DocxTemplateTest.cs +++ b/DocxTemplater.Test/DocxTemplateTest.cs @@ -15,7 +15,6 @@ namespace DocxTemplater.Test { internal class DocxTemplateTest { - [Test] public void DynamicTable() { @@ -122,6 +121,90 @@ public void MissingVariableThrows() Assert.Throws(() => docTemplate.Process()); } + [Test] + public void ImplicitIterator() + { + 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("{{#ds}} {{.OuterVal}} {{#.Inner}} {{.InnerVal}} {{..OuterVal}} {{/.Inner}} {{/ds}}"))))); + wpDocument.Save(); + memStream.Position = 0; + + var docTemplate = new DocxTemplate(memStream); + var model = new[] + { + new { OuterVal = "OuterValue0", Inner = new[] { new { InnerVal = "InnerValue00" } } }, + new { OuterVal = "OuterValue1", Inner = new[] { new { InnerVal = "InnerValue10" } , new { InnerVal = "InnerValue11" } } }, + new { OuterVal = "OuterValue2", Inner = new[] { new { InnerVal = "InnerValue20" } , new { InnerVal = "InnerValue21" } } } + }; + docTemplate.BindModel("ds", model); + var result = docTemplate.Process(); + docTemplate.Validate(); + Assert.IsNotNull(result); + // check result text + var document = WordprocessingDocument.Open(result, false); + var body = document.MainDocumentPart.Document.Body; + Assert.That(body.InnerText, Is.EqualTo(" OuterValue0 InnerValue00 OuterValue0 OuterValue1 InnerValue10 OuterValue1 InnerValue11 OuterValue1 OuterValue2 InnerValue20 OuterValue2 InnerValue21 OuterValue2 ")); + } + + [TestCase("

Test

", "

Test

")] + [TestCase("

Test

", "

Test

")] + [TestCase("

Test

", "

Test

")] + [TestCase("Test", "Test")] + [TestCase("foo
Test", "foo
Test")] + public void HtmlIsAlwaysEnclosedWithHtmlTags(string html, string expexted) + { + 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("Here comes HTML {{ds}:html}"))))); + wpDocument.Save(); + memStream.Position = 0; + var docTemplate = new DocxTemplate(memStream); + docTemplate.BindModel("ds", html); + + var result = docTemplate.Process(); + docTemplate.Validate(); + Assert.IsNotNull(result); + result.SaveAsFileAndOpenInWord(); + result.Position = 0; + var document = WordprocessingDocument.Open(result, false); + // check word contains altChunk + var body = document.MainDocumentPart.Document.Body; + var altChunk = body.Descendants().FirstOrDefault(); + Assert.IsNotNull(altChunk); + // extract html part + var htmlPart = document.MainDocumentPart.GetPartById(altChunk.Id); + var stream = htmlPart.GetStream(); + var content = new StreamReader(stream).ReadToEnd(); + Assert.That(content, Is.EqualTo(expexted)); + // check html part contains html; + } + + [Test] + public void InsertHtmlInLoop() + { + 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("{{#Items}}{{Items}:html}{{/Items}}"))))); + wpDocument.Save(); + memStream.Position = 0; + + var docTemplate = new DocxTemplate(memStream); + docTemplate.BindModel("Items", new[] { "

Test1

", "

Test2

" }); + var result = docTemplate.Process(); + docTemplate.Validate(); + Assert.IsNotNull(result); + result.SaveAsFileAndOpenInWord(); + // check document contains 2 altChunks + var document = WordprocessingDocument.Open(result, false); + var body = document.MainDocumentPart.Document.Body; + var altChunks = body.Descendants().ToList(); + Assert.That(altChunks.Count, Is.EqualTo(2)); + } + [Test] public void MissingVariableWithSkipErrorHandling() { @@ -150,7 +233,7 @@ public void LoopStartAndEndTagsAreRemoved() 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("{{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"))) )); @@ -170,6 +253,27 @@ public void LoopStartAndEndTagsAreRemoved() Assert.That(body.Descendants().Count(), Is.EqualTo(4)); } + + [Test] + public void CollectionSeparatorTest() + { + 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("{{#ds}}{{.}}{{:s:}},{{/ds}}"))))); + wpDocument.Save(); + memStream.Position = 0; + var docTemplate = new DocxTemplate(memStream); + docTemplate.BindModel("ds", new[] { "Item1", "Item2", "Item3" }); + var result = docTemplate.Process(); + docTemplate.Validate(); + Assert.IsNotNull(result); + // check result text + var document = WordprocessingDocument.Open(result, false); + var body = document.MainDocumentPart.Document.Body; + Assert.That(body.InnerText, Is.EqualTo("Item1,Item2,Item3")); + } + [Test] public void ConditionsWithAndWithoutPrefix() { @@ -177,16 +281,19 @@ public void ConditionsWithAndWithoutPrefix() 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{{/}}"))) - + 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{{/}}"))), + new Paragraph(new Run(new Text("{?{ds3.MyBool}}Test4{{:}}else4{{/}}"))), + new Paragraph(new Run(new Text("{?{!ds4.MyBool}}Test5{{:}}else4{{/}}"))) )); wpDocument.Save(); memStream.Position = 0; var docTemplate = new DocxTemplate(memStream); docTemplate.BindModel("ds", new { Test = 6 }); docTemplate.BindModel("ds2", new { Test = 6 }); + docTemplate.BindModel("ds3", new { MyBool = true }); + docTemplate.BindModel("ds4", new { MyBool = false }); var result = docTemplate.Process(); docTemplate.Validate(); Assert.IsNotNull(result); @@ -196,7 +303,7 @@ public void ConditionsWithAndWithoutPrefix() // check result text var document = WordprocessingDocument.Open(result, false); var body = document.MainDocumentPart.Document.Body; - Assert.That(body.InnerText, Is.EqualTo("Test1Test2Test3")); + Assert.That(body.InnerText, Is.EqualTo("Test1Test2Test3Test4Test5")); } [Test] @@ -331,7 +438,7 @@ public void BindCollection() new Run(new Text("{{Items.Value}}")), // --> same as ds.Items.Value new Run(new Text("{{ds.Items.InnerCollection.Name}}")), new Run(new Text("{{Items.InnerCollection.InnerValue}}")), // --> same as ds.Items.InnerCollection.InnerValue - new Run(new Text("{{ds.Items.InnerCollection.NumericValue > 0 }}I'm only here if NumericValue is greater than 0 - {{ds.Items.InnerCollection.InnerValue}:toupper()}{{else}}I'm here if if this is not the case{{/}}")), + new Run(new Text("{?{.NumericValue > 0 }}I'm only here if NumericValue is greater than 0 - {{ds.Items.InnerCollection.InnerValue}:toupper()}{{:}}I'm here if if this is not the case{{/}}")), new Run(new Text("{{/ds.Items.InnerCollection}}")), new Run(new Text("{{/Items}}")), // --> same as ds.Items.InnerCollection new Run(new Text("will be replaced {{company.Name}}")) diff --git a/DocxTemplater.Test/ModelLookupTest.cs b/DocxTemplater.Test/ModelLookupTest.cs new file mode 100644 index 0000000..3939eba --- /dev/null +++ b/DocxTemplater.Test/ModelLookupTest.cs @@ -0,0 +1,69 @@ +namespace DocxTemplater.Test +{ + internal class ModelLookupTest + { + [Test] + public void LookupFirstModelDoesNotNeedAPrefix() + { + var modelLookup = new ModelLookup(); + modelLookup.Add("y", new { a = 6 }); + modelLookup.Add("x", new { a = 55 }); + modelLookup.Add("x.b", 42); + Assert.That(modelLookup.GetValue("x.a"), Is.EqualTo(55)); + Assert.That(modelLookup.GetValue("x.b"), Is.EqualTo(42)); + Assert.That(modelLookup.GetValue("y.a"), Is.EqualTo(6)); + Assert.That(modelLookup.GetValue("a"), Is.EqualTo(6)); // y is added first.. y does not need a prefix + } + + [Test] + public void LookupFirstModelWithNestedPath() + { + var modelLookup = new ModelLookup(); + modelLookup.Add("y.a.b", new { c = 6 }); + Assert.That(modelLookup.GetValue("y.a.b.c"), Is.EqualTo(6)); + Assert.That(modelLookup.GetValue("a.b.c"), Is.EqualTo(6)); + + modelLookup.Add("x.aa.bb", new { c = 55 }); + Assert.That(modelLookup.GetValue("x.aa.bb.c"), Is.EqualTo(55)); + Assert.Throws(() => modelLookup.GetValue("aa.bb.c")); + } + + [Test] + public void ScopeVariablesAndImplicitAccessWithDot() + { + var modelLookup = new ModelLookup(); + modelLookup.Add("y", new { a = 6 }); + Assert.That(modelLookup.GetValue("y.a"), Is.EqualTo(6)); + + using (var scope = modelLookup.OpenScope()) + { + scope.AddVariable("y", new { a = 55 }); + Assert.That(modelLookup.GetValue("y.a"), Is.EqualTo(55)); + } + Assert.That(modelLookup.GetValue("y.a"), Is.EqualTo(6)); + + using (var outher = modelLookup.OpenScope()) + { + outher.AddVariable("y", new { a = 66 }); + Assert.That(modelLookup.GetValue("y.a"), Is.EqualTo(66)); +#pragma warning disable IDE0063 // Use simple 'using' statement + using (var inner = modelLookup.OpenScope()) + { + inner.AddVariable("y", new { a = 77 }); + Assert.That(modelLookup.GetValue("y.a"), Is.EqualTo(77)); + Assert.That(modelLookup.GetValue(".a"), Is.EqualTo(77)); + Assert.That(modelLookup.GetValue("..a"), Is.EqualTo(66)); + Assert.That(modelLookup.GetValue("...a"), Is.EqualTo(6)); + } +#pragma warning restore IDE0063 // Use simple 'using' statement + } + + // Add variable with leading dots to scope --> dots are removed + using (var scope = modelLookup.OpenScope()) + { + scope.AddVariable("...y", new { a = 55 }); + Assert.That(modelLookup.GetValue("y.a"), Is.EqualTo(55)); + } + } + } +} diff --git a/DocxTemplater.Test/PatternMatcherTest.cs b/DocxTemplater.Test/PatternMatcherTest.cs index ab998a4..978e9ba 100644 --- a/DocxTemplater.Test/PatternMatcherTest.cs +++ b/DocxTemplater.Test/PatternMatcherTest.cs @@ -33,16 +33,23 @@ static IEnumerable TestPatternMatch_Cases() 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("{?{ 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("{?{MyBool}}").Returns(new[] { PatternType.Condition }); + yield return new TestCaseData("{?{!MyBool}}").Returns(new[] { PatternType.Condition }); + yield return new TestCaseData("{ ? { MyBool}}").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("{{ : }}").Returns(new[] { PatternType.ConditionElse }); + yield return new TestCaseData("{{:}}").Returns(new[] { PatternType.ConditionElse }); + yield return new TestCaseData("{{:s:}}").Returns(new[] { PatternType.CollectionSeparator }); + yield return new TestCaseData("{{: s :}}").Returns(new[] { PatternType.CollectionSeparator }); yield return new TestCaseData("{{var}:format(a,b)}").Returns(new[] { PatternType.Variable }) .SetName("Multiple Arguments"); yield return new TestCaseData("{{/}}").Returns(new[] { PatternType.ConditionEnd }); diff --git a/DocxTemplater.Test/Resources/DynamicTable.docx b/DocxTemplater.Test/Resources/DynamicTable.docx index 7a12eb9..b1cc832 100644 Binary files a/DocxTemplater.Test/Resources/DynamicTable.docx and b/DocxTemplater.Test/Resources/DynamicTable.docx differ diff --git a/DocxTemplater.Test/Resources/DynamicTableWithComplexObjectsAsHeaderAndValues.docx b/DocxTemplater.Test/Resources/DynamicTableWithComplexObjectsAsHeaderAndValues.docx index c76758a..0b26221 100644 Binary files a/DocxTemplater.Test/Resources/DynamicTableWithComplexObjectsAsHeaderAndValues.docx and b/DocxTemplater.Test/Resources/DynamicTableWithComplexObjectsAsHeaderAndValues.docx differ diff --git a/DocxTemplater.Test/ScriptCompilerTest.cs b/DocxTemplater.Test/ScriptCompilerTest.cs index bc1c7c8..fc039a5 100644 --- a/DocxTemplater.Test/ScriptCompilerTest.cs +++ b/DocxTemplater.Test/ScriptCompilerTest.cs @@ -3,12 +3,12 @@ internal class ScriptCompilerTest { private ScriptCompiler m_scriptCompiler; - private ModelDictionary m_modelDictionary; + private ModelLookup m_modelDictionary; [SetUp] public void Setup() { - m_modelDictionary = new ModelDictionary(); + m_modelDictionary = new ModelLookup(); m_scriptCompiler = new ScriptCompiler(m_modelDictionary); } @@ -31,7 +31,8 @@ public void WithMemberAccess() new { b = 6 } } }); - m_modelDictionary.AddLoopVariable("y.items", new + var blockScope = m_modelDictionary.OpenScope(); + blockScope.AddVariable("y.items", new { b = 5 }); diff --git a/DocxTemplater/Blocks/ConditionalBlock.cs b/DocxTemplater/Blocks/ConditionalBlock.cs index 4a58647..a96c1e2 100644 --- a/DocxTemplater/Blocks/ConditionalBlock.cs +++ b/DocxTemplater/Blocks/ConditionalBlock.cs @@ -1,6 +1,7 @@ using DocumentFormat.OpenXml; using DocxTemplater.Formatter; using System.Collections.Generic; +using System.Linq; namespace DocxTemplater.Blocks { @@ -17,14 +18,15 @@ public ConditionalBlock(string condition, VariableReplacer variableReplacer, Scr m_scriptCompiler = scriptCompiler; } - public override void Expand(ModelDictionary models, OpenXmlElement parentNode) + public override void Expand(ModelLookup models, OpenXmlElement parentNode) { var conditionResult = m_scriptCompiler.CompileScript(m_condition)(); var content = conditionResult ? m_content : m_elseContent; if (content != null) { - var paragraphs = CreateBlockContentForCurrentVariableStack(content); - InsertContent(parentNode, paragraphs); + var cloned = content.Select(x => x.CloneNode(true)).ToList(); + InsertContent(parentNode, cloned); + m_variableReplacer.ReplaceVariables(cloned); ExpandChildBlocks(models, parentNode); } var element = m_leadingPart.GetElement(parentNode); diff --git a/DocxTemplater/Blocks/ContentBlock.cs b/DocxTemplater/Blocks/ContentBlock.cs index 8528a42..b984634 100644 --- a/DocxTemplater/Blocks/ContentBlock.cs +++ b/DocxTemplater/Blocks/ContentBlock.cs @@ -23,14 +23,15 @@ public ContentBlock(VariableReplacer variableReplacer) public IReadOnlyCollection ChildBlocks => m_childBlocks; - public virtual void Expand(ModelDictionary models, OpenXmlElement parentNode) + public virtual void Expand(ModelLookup models, OpenXmlElement parentNode) { - var paragraphs = CreateBlockContentForCurrentVariableStack(m_content); - InsertContent(parentNode, paragraphs); + var cloned = m_content.Select(x => x.CloneNode(true)).ToList(); + InsertContent(parentNode, cloned); + m_variableReplacer.ReplaceVariables(cloned); ExpandChildBlocks(models, parentNode); } - protected void ExpandChildBlocks(ModelDictionary models, OpenXmlElement parentNode) + protected void ExpandChildBlocks(ModelLookup models, OpenXmlElement parentNode) { foreach (var child in m_childBlocks) { @@ -38,17 +39,6 @@ protected void ExpandChildBlocks(ModelDictionary models, OpenXmlElement parentNo } } - protected IEnumerable CreateBlockContentForCurrentVariableStack(IReadOnlyCollection content) - { - var paragraphs = content.Select(x => - { - var cloned = x.CloneNode(true); - m_variableReplacer.ReplaceVariables(cloned); - return cloned; - }); - return paragraphs; - } - protected void InsertContent(OpenXmlElement parentNode, IEnumerable paragraphs) { var element = m_leadingPart.GetElement(parentNode); diff --git a/DocxTemplater/Blocks/DynamicTableBlock.cs b/DocxTemplater/Blocks/DynamicTableBlock.cs index ff5aa30..bf7bcc9 100644 --- a/DocxTemplater/Blocks/DynamicTableBlock.cs +++ b/DocxTemplater/Blocks/DynamicTableBlock.cs @@ -16,14 +16,14 @@ public DynamicTableBlock(string tablenName, VariableReplacer variableReplacer) m_tablenName = tablenName; } - public override void Expand(ModelDictionary models, OpenXmlElement parentNode) + public override void Expand(ModelLookup 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 columnsName = $"{m_tablenName}.{nameof(IDynamicTable.Rows)}"; var table = m_content.OfType().FirstOrDefault(); var headerRow = table?.Elements().FirstOrDefault(row => row.Descendants().Any(d => d.HasMarker(PatternType.Variable) && d.Text.Contains($"{{{{{headersName}"))); @@ -33,19 +33,19 @@ public override void Expand(ModelDictionary models, OpenXmlElement parentNode) 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"); + throw new OpenXmlTemplateException($"Dynamic table block must contain exactly one table with at least a header and a data row"); } // write headers foreach (var header in dynamicTable.Headers.Reverse()) { - models.RemoveLoopVariable(headersName); - models.AddLoopVariable(headersName, header); - var clonedCell = CreateBlockContentForCurrentVariableStack(new List { headerCell }); + using var headerScope = models.OpenScope(); + headerScope.AddVariable(headersName, header); + var clonedCell = headerCell.CloneNode(true); headerCell.InsertAfterSelf(clonedCell); + m_variableReplacer.ReplaceVariables(clonedCell); ExpandChildBlocks(models, parentNode); } - models.RemoveLoopVariable(headersName); // remove header cell headerCell.Remove(); @@ -61,14 +61,14 @@ public override void Expand(ModelDictionary models, OpenXmlElement parentNode) 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(); + using var columnScope = models.OpenScope(); + columnScope.AddVariable(columnsName, column); + var clonedCell = dataCell.CloneNode(true); insertion.InsertAfterSelf(clonedCell); + m_variableReplacer.ReplaceVariables(clonedCell); ExpandChildBlocks(models, parentNode); } insertion.Remove(); - models.RemoveLoopVariable(columnsName); } dataRow.Remove(); dataCell.Remove(); diff --git a/DocxTemplater/Blocks/LoopBlock.cs b/DocxTemplater/Blocks/LoopBlock.cs index be950fd..e7a19a9 100644 --- a/DocxTemplater/Blocks/LoopBlock.cs +++ b/DocxTemplater/Blocks/LoopBlock.cs @@ -9,6 +9,7 @@ namespace DocxTemplater.Blocks internal class LoopBlock : ContentBlock { private readonly string m_collectionName; + private IReadOnlyCollection m_separatorBlock; public LoopBlock(string collectionName, VariableReplacer variableReplacer) : base(variableReplacer) @@ -16,23 +17,30 @@ public LoopBlock(string collectionName, VariableReplacer variableReplacer) m_collectionName = collectionName; } - public override void Expand(ModelDictionary models, OpenXmlElement parentNode) + public override void Expand(ModelLookup models, OpenXmlElement parentNode) { var model = models.GetValue(m_collectionName); if (model is IEnumerable enumerable) { - int count = 0; - foreach (var item in enumerable.Reverse()) + var items = enumerable.Reverse().ToList(); + int counter = 0; + foreach (var item in items) { - count++; - models.RemoveLoopVariable(m_collectionName); - models.AddLoopVariable(m_collectionName, item); - - var paragraphs = CreateBlockContentForCurrentVariableStack(m_content); - InsertContent(parentNode, paragraphs); + counter++; + using var loopScope = models.OpenScope(); + loopScope.AddVariable(m_collectionName, item); + var cloned = m_content.Select(x => x.CloneNode(true)).ToList(); + InsertContent(parentNode, cloned); + m_variableReplacer.ReplaceVariables(cloned); ExpandChildBlocks(models, parentNode); + if (counter < items.Count && m_separatorBlock != null) + { + var clonedSeparator = m_separatorBlock.Select(x => x.CloneNode(true)).ToList(); + InsertContent(parentNode, clonedSeparator); + m_variableReplacer.ReplaceVariables(clonedSeparator); + ExpandChildBlocks(models, parentNode); + } } - models.RemoveLoopVariable(m_collectionName); } else { @@ -40,6 +48,19 @@ public override void Expand(ModelDictionary models, OpenXmlElement parentNode) } } + public override void SetContent(OpenXmlElement leadingPart, IReadOnlyCollection blockContent) + { + if (m_leadingPart == null) + { + base.SetContent(leadingPart, blockContent); + } + else + { + m_separatorBlock = blockContent; + leadingPart.RemoveWithEmptyParent(); + } + } + public override string ToString() { return $"LoopBlock: {m_collectionName}"; diff --git a/DocxTemplater/DocxTemplate.cs b/DocxTemplater/DocxTemplate.cs index 32ad6f0..7ab3147 100644 --- a/DocxTemplater/DocxTemplate.cs +++ b/DocxTemplater/DocxTemplate.cs @@ -16,7 +16,7 @@ public sealed class DocxTemplate : IDisposable { private readonly Stream m_stream; private readonly WordprocessingDocument m_wpDocument; - private readonly ModelDictionary m_models; + private readonly ModelLookup m_models; private static readonly FileFormatVersions TargetMinimumVersion = FileFormatVersions.Office2010; @@ -26,6 +26,7 @@ public sealed class DocxTemplate : IDisposable public DocxTemplate(Stream docXStream, ProcessSettings settings = null) { + ArgumentNullException.ThrowIfNull(docXStream); Settings = settings ?? ProcessSettings.Default; m_stream = new MemoryStream(); docXStream.CopyTo(m_stream); @@ -41,7 +42,7 @@ public DocxTemplate(Stream docXStream, ProcessSettings settings = null) }; m_wpDocument = WordprocessingDocument.Open(m_stream, true, openSettings); - m_models = new ModelDictionary(); + m_models = new ModelLookup(); m_scriptCompiler = new ScriptCompiler(m_models); m_variableReplacer = new VariableReplacer(m_models, Settings); Processed = false; @@ -112,7 +113,7 @@ public Stream Process() { ProcessNode(header.Header); } - ProcessNode(m_wpDocument.MainDocumentPart.Document.Body); + ProcessNode(m_wpDocument.MainDocumentPart.RootElement); foreach (var footer in m_wpDocument.MainDocumentPart.FooterParts) { ProcessNode(footer.Footer); @@ -122,35 +123,35 @@ public Stream Process() return m_stream; } - private void ProcessNode(OpenXmlCompositeElement content) + private void ProcessNode(OpenXmlPartRootElement rootElement) { #if DEBUG Console.WriteLine("----------- Original --------"); - Console.WriteLine(content.ToPrettyPrintXml()); + Console.WriteLine(rootElement.ToPrettyPrintXml()); #endif - PreProcess(content); + PreProcess(rootElement); - DocxTemplate.IsolateAndMergeTextTemplateMarkers(content); + DocxTemplate.IsolateAndMergeTextTemplateMarkers(rootElement); #if DEBUG Console.WriteLine("----------- Isolate Texts --------"); - Console.WriteLine(content.ToPrettyPrintXml()); + Console.WriteLine(rootElement.ToPrettyPrintXml()); #endif - var loops = ExpandLoops(content); + var loops = ExpandLoops(rootElement); #if DEBUG Console.WriteLine("----------- After Loops --------"); - Console.WriteLine(content.ToPrettyPrintXml()); + Console.WriteLine(rootElement.ToPrettyPrintXml()); #endif - m_variableReplacer.ReplaceVariables(content); + m_variableReplacer.ReplaceVariables(rootElement); foreach (var loop in loops) { - loop.Expand(m_models, content); + loop.Expand(m_models, rootElement); } - Cleanup(content); + Cleanup(rootElement); #if DEBUG Console.WriteLine("----------- Completed --------"); - Console.WriteLine(content.ToPrettyPrintXml()); + Console.WriteLine(rootElement.ToPrettyPrintXml()); #endif } @@ -205,7 +206,7 @@ private static void Cleanup(OpenXmlCompositeElement element) } } - private IReadOnlyCollection ExpandLoops(OpenXmlCompositeElement element) + private IReadOnlyCollection ExpandLoops(OpenXmlPartRootElement element) { // TODO: store metadata for tag in cache @@ -227,6 +228,28 @@ private IReadOnlyCollection ExpandLoops(OpenXmlCompositeElement el blockStack.Push((new LoopBlock(match.Variable, m_variableReplacer), match, text)); } } + else if (value == PatternType.CollectionSeparator) + { + var (block, patternMatch, matchedTextNode) = blockStack.Pop(); + if (block is not LoopBlock) + { + throw new OpenXmlTemplateException($"Separator in '{block}' is invalid"); + } + 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 + } + else if (value == PatternType.CollectionEnd) + { + var (block, patternMatch, matchedTextNode) = blockStack.Pop(); + if (patternMatch.Type != PatternType.CollectionStart) + { + throw new OpenXmlTemplateException($"'{block}' is not closed"); + } + var loopContent = ExtractBlockContent(matchedTextNode, text, out var leadingPart); + block.SetContent(leadingPart, loopContent); + blockStack.Peek().Block.AddInnerBlock(block); + } else if (value == PatternType.Condition) { var match = PatternMatcher.FindSyntaxPatterns(text.Text).Single(); @@ -237,7 +260,7 @@ private IReadOnlyCollection ExpandLoops(OpenXmlCompositeElement el var (block, patternMatch, matchedTextNode) = blockStack.Pop(); if (block is not ConditionalBlock) { - throw new OpenXmlTemplateException($"'{block}' is not closed"); + throw new OpenXmlTemplateException($"else block in '{block}' is invalid"); } var loopContent = ExtractBlockContent(matchedTextNode, text, out var leadingPart); block.SetContent(leadingPart, loopContent); @@ -254,18 +277,6 @@ private IReadOnlyCollection ExpandLoops(OpenXmlCompositeElement el block.SetContent(leadingPart, loopContent); blockStack.Peek().Block.AddInnerBlock(block); } - else if (value == PatternType.CollectionEnd) - { - var (block, patternMatch, matchedTextNode) = blockStack.Pop(); - if (patternMatch.Type != PatternType.CollectionStart) - { - throw new OpenXmlTemplateException($"'{block}' is not closed"); - } - var loopContent = ExtractBlockContent(matchedTextNode, text, out var leadingPart); - block.SetContent(leadingPart, loopContent); - blockStack.Peek().Block.AddInnerBlock(block); - } - } var (contentBlock, _, _) = blockStack.Pop(); return contentBlock.ChildBlocks; diff --git a/DocxTemplater/Formatter/HtmlFormatter.cs b/DocxTemplater/Formatter/HtmlFormatter.cs index 4171d9b..8817fd1 100644 --- a/DocxTemplater/Formatter/HtmlFormatter.cs +++ b/DocxTemplater/Formatter/HtmlFormatter.cs @@ -26,6 +26,15 @@ public void ApplyFormat(FormatterContext context, Text target) return; } + // fix html - ensure starts and ends with and + if (!html.StartsWith("", StringComparison.CurrentCultureIgnoreCase)) + { + html = "" + html; + } + if (!html.EndsWith("", StringComparison.CurrentCultureIgnoreCase)) + { + html += ""; + } var root = target.GetRoot(); string alternativeFormatImportPartId = null; if (root is OpenXmlPartRootElement openXmlPartRootElement && openXmlPartRootElement.OpenXmlPart != null) @@ -45,7 +54,7 @@ public void ApplyFormat(FormatterContext context, Text target) } if (alternativeFormatImportPartId == null) { - throw new OpenXmlTemplateException("Could not find a valid image part"); + throw new OpenXmlTemplateException("Could not find root to insert HTML"); } AltChunk altChunk = new() { diff --git a/DocxTemplater/Formatter/VariableReplacer.cs b/DocxTemplater/Formatter/VariableReplacer.cs index 901417e..18edf00 100644 --- a/DocxTemplater/Formatter/VariableReplacer.cs +++ b/DocxTemplater/Formatter/VariableReplacer.cs @@ -8,11 +8,11 @@ namespace DocxTemplater.Formatter { internal class VariableReplacer { - private readonly ModelDictionary m_models; + private readonly ModelLookup m_models; private readonly ProcessSettings m_processSettings; private readonly List m_formatters; - public VariableReplacer(ModelDictionary models, ProcessSettings processSettings) + public VariableReplacer(ModelLookup models, ProcessSettings processSettings) { m_models = models; m_processSettings = processSettings; @@ -59,6 +59,14 @@ public void ApplyFormatter(PatternMatch patternMatch, object value, Text target) } + public void ReplaceVariables(IReadOnlyCollection content) + { + foreach (var element in content) + { + ReplaceVariables(element); + } + } + public void ReplaceVariables(OpenXmlElement cloned) { var variables = cloned.GetElementsWithMarker(PatternType.Variable).OfType().ToList(); diff --git a/DocxTemplater/ModelDictionary.cs b/DocxTemplater/ModelDictionary.cs deleted file mode 100644 index 5f236e8..0000000 --- a/DocxTemplater/ModelDictionary.cs +++ /dev/null @@ -1,140 +0,0 @@ -using System; -using System.Collections; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; - -namespace DocxTemplater -{ - internal class ModelDictionary - { - private readonly Dictionary m_models; - private readonly Dictionary m_loopVariables; - private readonly Lazy m_defaultModelPrefix; - - private string m_rootModelPrefix; - - public ModelDictionary() - { - m_models = new Dictionary(); - m_loopVariables = new Dictionary(); - m_defaultModelPrefix = new Lazy(() => m_rootModelPrefix = m_models.Keys.FirstOrDefault()); - } - - public IReadOnlyDictionary Models => m_models; - - public void Add(string prefix, object model) - { - m_models.Add(prefix, model); - } - - public void AddLoopVariable(string name, object value) - { - name = AddPathPrefixInSingleModelMode(name); - m_loopVariables.Add(name, value); - } - - public bool IsLoopVariable(string name) - { - name = AddPathPrefixInSingleModelMode(name); - return m_loopVariables.ContainsKey(name); - } - - public void RemoveLoopVariable(string name) - { - name = AddPathPrefixInSingleModelMode(name); - m_loopVariables.Remove(name); - } - - private string AddPathPrefixInSingleModelMode(string name) - { - var dotIndex = name.IndexOf('.'); - if (dotIndex == -1 || !m_models.ContainsKey(name[..dotIndex])) - { - if (m_defaultModelPrefix.Value != null && - !name.Equals(m_rootModelPrefix, StringComparison.CurrentCultureIgnoreCase) && - !name.StartsWith(m_defaultModelPrefix.Value + ".", StringComparison.CurrentCultureIgnoreCase)) - { - name = $"{m_rootModelPrefix}.{name}"; - } - } - - return name; - } - - public object GetValue(string variableName) - { - var parts = variableName.Split('.'); - var path = parts[0]; - - int startIndex = 0; - if (!m_models.ContainsKey(path) && m_models.Count > 0) - { - startIndex = -1; - path = m_defaultModelPrefix.Value; - } - object model = null; - for (int i = startIndex; i < parts.Length; i++) - { - if (!m_loopVariables.TryGetValue(path, out var nextModel) && !m_models.TryGetValue(path, out nextModel)) - { - if (model == null) - { - throw new OpenXmlTemplateException($"Model {path} not found"); - } - if (model is ITemplateModel templateModel) - { - 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 IDictionary dict) - { - if (dict.TryGetValue(parts[i], out var value)) - { - model = value; - } - else - { - throw new OpenXmlTemplateException($"Property {parts[i]} not found in {path}"); - } - } - else - { - 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) - { - path = $"{path}.{parts[i + 1]}"; - } - } - return model; - } - } -} diff --git a/DocxTemplater/ModelLookup.cs b/DocxTemplater/ModelLookup.cs new file mode 100644 index 0000000..caf8eff --- /dev/null +++ b/DocxTemplater/ModelLookup.cs @@ -0,0 +1,160 @@ +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; + +namespace DocxTemplater +{ + internal class ModelLookup + { + private readonly Dictionary m_rootScope; + private readonly Stack> m_blockScopes; + + public ModelLookup() + { + m_rootScope = new Dictionary(); + m_blockScopes = new Stack>(); + m_blockScopes.Push(m_rootScope); + } + + public IReadOnlyDictionary Models => m_rootScope; + + public void Add(string prefix, object model) + { + m_rootScope.Add(prefix, model); + } + + public IVariableScope OpenScope() + { + return new VariableScope(m_blockScopes); + } + + public object GetValue(string variableName) + { + var leadingDotsCount = variableName.TakeWhile(x => x == '.').Count(); + variableName = variableName[leadingDotsCount..]; + int partIndex = 0; + var parts = variableName.Split('.'); + object model = null; + string modelRootPath = variableName; + if (leadingDotsCount == 0) + { + model = SearchLongestPathInLookup(parts, out modelRootPath, out partIndex, 0); + if (model == null && m_rootScope.Count > 0) + { + var firstModelEntry = m_rootScope.First(); + // a.b.c.d and b.c.d.e ==> a.b.c.d.e + parts = firstModelEntry.Key.Split('.').Concat(variableName.Split('.')).Distinct().ToArray(); + model = SearchLongestPathInLookup(parts, out modelRootPath, out partIndex, 0); + } + + if (model == null) + { + throw new OpenXmlTemplateException($"Model {variableName} not found"); + } + } + else + { + modelRootPath = "parent scope"; + model = m_blockScopes.ElementAt(leadingDotsCount - 1).Values.FirstOrDefault(); + if (parts.Length == 1 && string.IsNullOrWhiteSpace(parts[0])) + { + return model; + } + } + if (model == null) + { + throw new OpenXmlTemplateException($"Model {variableName} not found"); + } + + for (int i = partIndex; i < parts.Length; i++) + { + var propertyName = parts[i]; + if (model is ITemplateModel templateModel) + { + if (!templateModel.TryGetPropertyValue(propertyName, out model)) + { + throw new OpenXmlTemplateException($"Property {propertyName} not found in {modelRootPath}"); + } + } + else if (model is IDictionary dict) + { + if (!dict.TryGetValue(parts[i], out model)) + { + throw new OpenXmlTemplateException($"Property {propertyName} not found in {modelRootPath}"); + } + } + else + { + var property = model.GetType().GetProperty(propertyName, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.GetProperty | BindingFlags.Instance); + if (property != null) + { + model = property.GetValue(model); + } + else if (model is ICollection) + { + throw new OpenXmlTemplateException($"Property {propertyName} on collection {modelRootPath} not found - is collection start missing? '#{variableName}'"); + } + else + { + throw new OpenXmlTemplateException($"Property {propertyName} not found in {modelRootPath}"); + } + } + } + return model; + } + + private object SearchLongestPathInLookup(string[] parts, out string modelRootPath, out int partIndex, int startScopeIndex) + { + modelRootPath = null; + partIndex = parts.Length; + foreach (Dictionary scope in m_blockScopes.Skip(startScopeIndex)) + { + partIndex = parts.Length; + // search the longest path in the lookup + for (; partIndex > 0; partIndex--) + { + modelRootPath = string.Join('.', parts[..partIndex]); + if (scope.TryGetValue(modelRootPath, out var model)) + { + return model; + } + } + } + return null; + } + + + internal class VariableScope : IVariableScope + { + private readonly Dictionary m_scope; + private readonly Stack> m_scopeStack; + + public VariableScope(Stack> scopeStack) + { + m_scopeStack = scopeStack; + m_scope = new Dictionary(); + scopeStack.Push(m_scope); + } + + public void AddVariable(string name, object value) + { + // remove leading dots + name = name.TrimStart('.'); + Debug.Assert(m_scopeStack.Count > 1, "Added Block variable in root scope"); + m_scope.Add(name, value); + } + + public void Dispose() + { + m_scopeStack.Pop(); + } + } + + public object GetScopeParentLevel(int parentLevel) + { + return m_blockScopes.ElementAt(parentLevel).Values.FirstOrDefault(); + } + } +} diff --git a/DocxTemplater/PatterMatcher.cs b/DocxTemplater/PatterMatcher.cs index 02d7003..13f523c 100644 --- a/DocxTemplater/PatterMatcher.cs +++ b/DocxTemplater/PatterMatcher.cs @@ -20,16 +20,16 @@ internal static class PatternMatcher {{images}:foo(arg1,arg2)} -- variable with formatter and arguments */ - private static readonly Regex PatternRegex = new(@"\{\s*\{\s* + private static readonly Regex PatternRegex = new(@"\{\s*(?\?\s*)?\{\s* # a leading ? indicates a condition (?: - (?else) | - (?: - (?[\/\#])? #prefix + (?:\s*s\s*:) | + (?(?:else)|:) | + (?(condMarker) # if condition marker is set, we expect a condition + (?[a-zA-Z0-9+\-*\/><=\s\.\!]+)? #condition name (without brackets) + | (?: - (?[a-zA-Z0-9\._]+) #variable name - | #or - (?[a-zA-Z0-9+\-*\/><=\s\.]{2,}) #condition - )? + (?[\/\#])?(?[a-zA-Z0-9\._]+)? #variable name + ) ) ) \s*\} @@ -51,13 +51,15 @@ public static IEnumerable FindSyntaxPatterns(string text) var result = new List(matches.Count); foreach (Match match in matches) { - if (match.Groups["else"].Success) + if (match.Groups["separator"].Success) + { + result.Add(new PatternMatch(match, PatternType.CollectionSeparator, null, null, null, null, null, match.Index, match.Length)); + } + else if (match.Groups["else"].Success) { result.Add(new PatternMatch(match, PatternType.ConditionElse, null, null, null, null, null, match.Index, match.Length)); } - else - - if (match.Groups["condition"].Success) + else if (match.Groups["condition"].Success) { result.Add(new PatternMatch(match, PatternType.Condition, match.Groups["condition"].Value, null, null, null, null, match.Index, match.Length)); } @@ -76,12 +78,16 @@ public static IEnumerable FindSyntaxPatterns(string text) 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 + else if (match.Groups["varname"].Success) { var argGroup = match.Groups["arg"]; var arguments = argGroup.Success ? argGroup.Captures.Select(x => x.Value).ToArray() : Array.Empty(); result.Add(new PatternMatch(match, PatternType.Variable, null, null, match.Groups["varname"].Value, match.Groups["formatter"].Value, arguments, match.Index, match.Length)); } + else + { + throw new OpenXmlTemplateException($"Invalid syntax '{match.Value}'"); + } } return result; } diff --git a/DocxTemplater/PatternType.cs b/DocxTemplater/PatternType.cs index 86e1d09..afbf10a 100644 --- a/DocxTemplater/PatternType.cs +++ b/DocxTemplater/PatternType.cs @@ -6,6 +6,7 @@ internal enum PatternType Condition, ConditionEnd, CollectionStart, + CollectionSeparator, CollectionEnd, Variable, ConditionElse diff --git a/DocxTemplater/ScriptCompiler.cs b/DocxTemplater/ScriptCompiler.cs index 8a0a5b1..9701d19 100644 --- a/DocxTemplater/ScriptCompiler.cs +++ b/DocxTemplater/ScriptCompiler.cs @@ -1,21 +1,25 @@ using DynamicExpresso; using System; using System.Dynamic; +using System.Text.RegularExpressions; namespace DocxTemplater { internal class ScriptCompiler { - private readonly ModelDictionary m_modelDictionary; + private readonly ModelLookup m_modelDictionary; + private static readonly Regex RegexWordStartingWithDot = new(@"^(\.+)([a-zA-z0-9_]+)", RegexOptions.Compiled); - public ScriptCompiler(ModelDictionary modelDictionary) + public ScriptCompiler(ModelLookup modelDictionary) { this.m_modelDictionary = modelDictionary; } public Func CompileScript(string scriptAsString) { + // replace replace leading dots (implicit scope) with variables var interpreter = new Interpreter(); + scriptAsString = RegexWordStartingWithDot.Replace(scriptAsString, (m) => OnVariableReplace(m, interpreter)); var identifiers = interpreter.DetectIdentifiers(scriptAsString); foreach (var identifier in identifiers.UnknownIdentifiers) { @@ -32,12 +36,21 @@ public Func CompileScript(string scriptAsString) return interpreter.ParseAsDelegate>(scriptAsString); } + private string OnVariableReplace(Match match, Interpreter interpreter) + { + var dotCount = match.Groups[1].Length; + var scope = m_modelDictionary.GetScopeParentLevel(dotCount - 1); + var varName = $"__s{dotCount}_"; // choose a variable name that is unlikely to be used by the user + interpreter.SetVariable(varName, scope); + return $"{varName}.{match.Groups[2].Value}"; + } + private class ModelVariable : DynamicObject { - private readonly ModelDictionary m_modelDictionary; + private readonly ModelLookup m_modelDictionary; private readonly string m_rootName; - public ModelVariable(ModelDictionary modelDictionary, string rootName) + public ModelVariable(ModelLookup modelDictionary, string rootName) { m_modelDictionary = modelDictionary; m_rootName = rootName; @@ -49,7 +62,7 @@ public override bool TryGetMember(GetMemberBinder binder, out object result) { var name = m_rootName + "." + binder.Name; result = m_modelDictionary.GetValue(name); - if (m_modelDictionary.IsLoopVariable(name)) + if (result != null && !result.GetType().IsPrimitive) { result = new ModelVariable(m_modelDictionary, name); } diff --git a/DocxTemplater/VariableScope.cs b/DocxTemplater/VariableScope.cs new file mode 100644 index 0000000..f7cbe70 --- /dev/null +++ b/DocxTemplater/VariableScope.cs @@ -0,0 +1,9 @@ +using System; + +namespace DocxTemplater +{ + internal interface IVariableScope : IDisposable + { + void AddVariable(string name, object value); + } +} diff --git a/README.md b/README.md index 72c3202..d4b7b47 100644 --- a/README.md +++ b/README.md @@ -62,8 +62,11 @@ The syntax is case insensitive | Syntax | Desciption | |----------------|--------------------------| | {{SomeVar}} | Simple Variable replacement -| {{someVar > 5}}...{{else}}...{{/}} | Conditional blocks -| {{/Items}}...{\{Items.Name}} ... {{/Items}} | Text block bound to collection items +| {?{someVar > 5}}...{{:}}...{{/}} | Conditional blocks +| {{/Items}}...{{Items.Name}} ... {{/Items}} | Text block bound to collection of complex items +| {{/Items}}...{{.Name}} ... {{/Items}} | Dot notation - implicit iterator +| {{/Items}}...{{.}:toUpper} ... {{/Items}} | A list of string all upper case +| {{/Items}}{{.}}{{:s:}},{{/Items}} | A list of string comma separated | {{SomeString:ToUpper()}} | Variable with formatter to upper | {{SomeDate:Format("MM/dd/yyyy")}} | Date variable with formatting | {{SomeDate:F("MM/dd/yyyy")}} | Date variable with formatting - short syntax @@ -107,15 +110,24 @@ will render a table row for each item in the collection | John | Developer| | Alice | CEO| +#### Separator + +If you want to render a separator between the items in the collection, you can use the separator syntax: +``` +{{#Items}} This text {{.Name}} is rendered for each element in the items collection {{:s:}} This is rendered between each elment {{/items}} +``` + + + ### Conditional Blocks Show or hide a given section depending on a condition: -**{{\}}** .. content .. **{{/}}** +**{?{\}}** .. content .. **{{/}}** All document content between the start and end tag is rendered only if the condition is met ``` -{{Item.Value >= 0}}Only visible if value is >= 0 {{/}} -{{else}}Otherwise this text is shown{{/}} +{?{Item.Value >= 0}}Only visible if value is >= 0 +{{:}}Otherwise this text is shown{{/}} ``` ## Formatters